Merge pull request #1747 from Fr1ngg/dev4

Бот на Вебхуке Без смс и регистрации
This commit is contained in:
Egor
2025-11-07 01:47:53 +03:00
committed by GitHub
20 changed files with 2139 additions and 848 deletions

View File

@@ -188,6 +188,8 @@ TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -273,6 +275,8 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Эти порты используются только при ручном запуске старого aiohttp сервера.
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
@@ -349,6 +353,7 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -449,3 +454,20 @@ LOG_FILE=logs/bot.log
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
MINIAPP_STATIC_PATH=miniapp

View File

@@ -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
View File

@@ -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">
![GitHub stars](https://img.shields.io/github/stars/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)
![GitHub forks](https://img.shields.io/github/forks/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)
![GitHub watchers](https://img.shields.io/github/watchers/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)
@@ -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?

View File

@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
```
### 📊 Мониторинг безопасности
@@ -283,7 +282,7 @@ services:
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8081:8081" # Только localhost
- "127.0.0.1:8080:8080" # Только localhost
# Read-only root filesystem
read_only: true

View File

@@ -291,6 +291,7 @@ class Settings(BaseSettings):
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_STATIC_PATH: str = "miniapp"
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
@@ -319,6 +320,13 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEBHOOK_SECRET_TOKEN: Optional[str] = None
WEBHOOK_DROP_PENDING_UPDATES: bool = True
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
WEBHOOK_WORKERS: int = 4
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
BOT_RUN_MODE: str = "polling"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
@@ -1423,7 +1431,62 @@ class Settings(BaseSettings):
def is_support_contact_enabled(self) -> bool:
return self.get_support_system_mode() in {"contact", "both"}
def get_bot_run_mode(self) -> str:
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
if mode not in {"polling", "webhook", "both"}:
return "polling"
return mode
def get_telegram_webhook_path(self) -> str:
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
if not raw_path:
raw_path = "/webhook"
if not raw_path.startswith("/"):
raw_path = "/" + raw_path
return raw_path
def get_webhook_queue_maxsize(self) -> int:
try:
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
except (TypeError, ValueError):
size = 1024
return max(1, size)
def get_webhook_worker_count(self) -> int:
try:
workers = int(self.WEBHOOK_WORKERS)
except (TypeError, ValueError):
workers = 1
return max(1, workers)
def get_webhook_enqueue_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
except (TypeError, ValueError):
timeout = 0.0
return max(0.0, timeout)
def get_webhook_shutdown_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
except (TypeError, ValueError):
timeout = 30.0
return max(1.0, timeout)
def get_telegram_webhook_url(self) -> Optional[str]:
base_url = (self.WEBHOOK_URL or "").strip()
if not base_url:
return None
path = self.get_telegram_webhook_path()
return f"{base_url.rstrip('/')}{path}"
def get_miniapp_static_path(self) -> Path:
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
if not raw_path:
raw_path = "miniapp"
return Path(raw_path)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",

View File

@@ -42,7 +42,15 @@ from app.utils.timezone import format_local_datetime
from app.utils.subscription_utils import (
resolve_hwid_device_limit_for_payload,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.database.models import (
MonitoringLog,
SubscriptionStatus,
Subscription,
User,
Ticket,
TicketStatus,
UserPromoGroup,
)
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.services.payment_service import PaymentService
@@ -386,7 +394,12 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user)
.selectinload(User.user_promo_groups)
.selectinload(UserPromoGroup.promo_group),
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,

View File

@@ -17,8 +17,8 @@ logger = logging.getLogger(__name__)
class WebAPIServer:
"""Асинхронный uvicorn-сервер для административного API."""
def __init__(self) -> None:
self._app = create_web_api_app()
def __init__(self, app: Optional[object] = None) -> None:
self._app = app or create_web_api_app()
workers = max(1, int(settings.WEB_API_WORKERS or 1))
if workers > 1:
@@ -46,6 +46,7 @@ class WebAPIServer:
await self._server.serve()
except Exception as error: # pragma: no cover - логируем ошибки сервера
logger.exception("❌ Ошибка работы веб-API: %s", error)
raise
logger.info(
"🌐 Запуск административного API на %s:%s",

11
app/webserver/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
from typing import Any
__all__ = ["create_unified_app"]
def __getattr__(name: str) -> Any:
if name == "create_unified_app":
from .unified_app import create_unified_app as _create_unified_app
return _create_unified_app
raise AttributeError(name)

596
app/webserver/payments.py Normal file
View File

@@ -0,0 +1,596 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
from typing import Iterable
from fastapi import APIRouter, Request, Response, status
from fastapi.responses import JSONResponse
from aiogram import Bot
from app.config import settings
from app.database.database import get_db
from app.external.tribute import TributeService as TributeAPI
from app.external.yookassa_webhook import YooKassaWebhookHandler
from app.external.wata_webhook import WataWebhookHandler
from app.external.heleket_webhook import HeleketWebhookHandler
from app.external.pal24_client import Pal24APIError
from app.services.pal24_service import Pal24Service
from app.services.payment_service import PaymentService
from app.services.tribute_service import TributeService
logger = logging.getLogger(__name__)
def _create_cors_response() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization",
},
)
def _extract_header(request: Request, header_names: Iterable[str]) -> str | None:
for header_name in header_names:
value = request.headers.get(header_name)
if value:
return value.strip()
return None
def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
display_name = settings.get_mulenpay_display_name()
if not secret_key:
logger.error("%s secret key is not configured", display_name)
return False
signature = _extract_header(
request,
(
"X-MulenPay-Signature",
"X-Mulenpay-Signature",
"X-MULENPAY-SIGNATURE",
"X-MulenPay-Webhook-Signature",
"X-Mulenpay-Webhook-Signature",
"X-MULENPAY-WEBHOOK-SIGNATURE",
"X-Signature",
"Signature",
"X-MulenPay-Sign",
"X-Mulenpay-Sign",
"X-MULENPAY-SIGN",
"MulenPay-Signature",
"Mulenpay-Signature",
"MULENPAY-SIGNATURE",
"signature",
"sign",
),
)
if signature:
normalized_signature = signature
if normalized_signature.lower().startswith("sha256="):
normalized_signature = normalized_signature.split("=", 1)[1].strip()
hmac_digest = hmac.new(secret_key.encode("utf-8"), raw_body, hashlib.sha256).digest()
expected_hex = hmac_digest.hex()
expected_base64 = base64.b64encode(hmac_digest).decode("utf-8").strip()
expected_urlsafe = base64.urlsafe_b64encode(hmac_digest).decode("utf-8").strip()
normalized_lower = normalized_signature.lower()
if hmac.compare_digest(normalized_lower, expected_hex.lower()):
return True
normalized_no_padding = normalized_signature.rstrip("=")
if hmac.compare_digest(normalized_no_padding, expected_base64.rstrip("=")):
return True
if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip("=")):
return True
logger.error("Неверная подпись %s webhook", display_name)
return False
authorization_header = request.headers.get("Authorization")
if authorization_header:
scheme, _, value = authorization_header.partition(" ")
scheme_lower = scheme.lower()
token = value.strip() if value else scheme.strip()
if scheme_lower in {"bearer", "token"}:
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен %s webhook", scheme, display_name)
return False
if not value and hmac.compare_digest(token, secret_key):
return True
fallback_token = _extract_header(
request,
(
"X-MulenPay-Token",
"X-Mulenpay-Token",
"X-Webhook-Token",
),
)
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.error("Отсутствует подпись %s webhook", display_name)
return False
async def _process_payment_service_callback(
payment_service: PaymentService,
payload: dict,
method_name: str,
) -> bool:
db_generator = get_db()
try:
db = await db_generator.__anext__()
except StopAsyncIteration: # pragma: no cover - defensive guard
return False
try:
process_callback = getattr(payment_service, method_name)
return await process_callback(db, payload)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
async def _parse_pal24_payload(request: Request) -> dict[str, str]:
try:
if request.headers.get("content-type", "").startswith("application/json"):
data = await request.json()
if isinstance(data, dict):
return {str(k): str(v) for k, v in data.items()}
except json.JSONDecodeError:
logger.debug("Pal24 webhook JSON payload не удалось распарсить")
form = await request.form()
if form:
return {str(k): str(v) for k, v in form.multi_items()}
raw_body = (await request.body()).decode("utf-8")
if raw_body:
try:
data = json.loads(raw_body)
if isinstance(data, dict):
return {str(k): str(v) for k, v in data.items()}
except json.JSONDecodeError:
logger.debug("Pal24 webhook body не удалось распарсить как JSON: %s", raw_body)
return {}
def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRouter | None:
router = APIRouter()
routes_registered = False
if settings.TRIBUTE_ENABLED:
tribute_service = TributeService(bot)
tribute_api = TributeAPI()
@router.options(settings.TRIBUTE_WEBHOOK_PATH)
async def tribute_options() -> Response:
return _create_cors_response()
@router.post(settings.TRIBUTE_WEBHOOK_PATH)
async def tribute_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
payload = raw_body.decode("utf-8")
signature = request.headers.get("trbt-signature")
if not signature:
return JSONResponse(
{"status": "error", "reason": "missing_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if settings.TRIBUTE_API_KEY and not tribute_api.verify_webhook_signature(payload, signature):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
json.loads(payload)
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
result = await tribute_service.process_webhook(payload)
if result:
return JSONResponse({"status": "ok", "result": result})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_mulenpay_enabled():
@router.options(settings.MULENPAY_WEBHOOK_PATH)
async def mulenpay_options() -> Response:
return _create_cors_response()
@router.post(settings.MULENPAY_WEBHOOK_PATH)
async def mulenpay_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
if not _verify_mulenpay_signature(request, raw_body):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_mulenpay_callback",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_cryptobot_enabled():
@router.options(settings.CRYPTOBOT_WEBHOOK_PATH)
async def cryptobot_options() -> Response:
return _create_cors_response()
@router.post(settings.CRYPTOBOT_WEBHOOK_PATH)
async def cryptobot_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
payload_text = raw_body.decode("utf-8")
try:
payload = json.loads(payload_text)
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
signature = request.headers.get("Crypto-Pay-API-Signature")
secret = settings.CRYPTOBOT_WEBHOOK_SECRET
if secret:
if not signature:
return JSONResponse(
{"status": "error", "reason": "missing_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
from app.external.cryptobot import CryptoBotService
if not CryptoBotService().verify_webhook_signature(payload_text, signature):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_cryptobot_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_yookassa_enabled():
yookassa_secret = settings.YOOKASSA_WEBHOOK_SECRET or ""
@router.options(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-YooKassa-Signature, Signature",
},
)
@router.get(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "yookassa_webhook",
"enabled": settings.is_yookassa_enabled(),
}
)
@router.post(settings.YOOKASSA_WEBHOOK_PATH)
async def yookassa_webhook(request: Request) -> JSONResponse:
body_bytes = await request.body()
if not body_bytes:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
body = body_bytes.decode("utf-8")
signature = request.headers.get("Signature") or request.headers.get("X-YooKassa-Signature")
if yookassa_secret:
if not signature:
return JSONResponse(
{"status": "error", "reason": "missing_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, yookassa_secret):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
webhook_data = json.loads(body)
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
event_type = webhook_data.get("event")
if not event_type:
return JSONResponse(
{"status": "error", "reason": "missing_event"},
status_code=status.HTTP_400_BAD_REQUEST,
)
if event_type not in {"payment.succeeded", "payment.waiting_for_capture"}:
return JSONResponse({"status": "ok", "ignored": event_type})
success = await _process_payment_service_callback(
payment_service,
webhook_data,
"process_yookassa_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "processing_failed"},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
routes_registered = True
if settings.is_wata_enabled():
wata_handler = WataWebhookHandler(payment_service)
@router.options(settings.WATA_WEBHOOK_PATH)
async def wata_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
},
)
@router.get(settings.WATA_WEBHOOK_PATH)
async def wata_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "wata_webhook",
"enabled": settings.is_wata_enabled(),
}
)
@router.post(settings.WATA_WEBHOOK_PATH)
async def wata_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse({"status": "error", "reason": "empty_body"}, status_code=status.HTTP_400_BAD_REQUEST)
signature = request.headers.get("X-Signature") or ""
if not await wata_handler._verify_signature(raw_body.decode("utf-8"), signature): # type: ignore[attr-defined]
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
payload = json.loads(raw_body.decode("utf-8"))
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_wata_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_heleket_enabled():
heleket_handler = HeleketWebhookHandler(payment_service)
@router.options(settings.HELEKET_WEBHOOK_PATH)
async def heleket_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
)
@router.get(settings.HELEKET_WEBHOOK_PATH)
async def heleket_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "heleket_webhook",
"enabled": settings.is_heleket_enabled(),
}
)
@router.post(settings.HELEKET_WEBHOOK_PATH)
async def heleket_webhook(request: Request) -> JSONResponse:
try:
payload = await request.json()
except json.JSONDecodeError:
return JSONResponse(
{"status": "error", "reason": "invalid_json"},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not heleket_handler.service.verify_webhook_signature(payload):
return JSONResponse(
{"status": "error", "reason": "invalid_signature"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
"process_heleket_webhook",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if settings.is_pal24_enabled():
pal24_service = Pal24Service()
@router.options(settings.PAL24_WEBHOOK_PATH)
async def pal24_options() -> Response:
return Response(
status_code=status.HTTP_200_OK,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
)
@router.get(settings.PAL24_WEBHOOK_PATH)
async def pal24_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "pal24_webhook",
"enabled": settings.is_pal24_enabled(),
}
)
@router.post(settings.PAL24_WEBHOOK_PATH)
async def pal24_webhook(request: Request) -> JSONResponse:
if not pal24_service.is_configured:
return JSONResponse(
{"status": "error", "reason": "service_not_configured"},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
payload = await _parse_pal24_payload(request)
if not payload:
return JSONResponse(
{"status": "error", "reason": "empty_payload"},
status_code=status.HTTP_400_BAD_REQUEST,
)
try:
parsed_payload = pal24_service.parse_postback(payload)
except Pal24APIError as error:
return JSONResponse(
{"status": "error", "reason": str(error)},
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
"process_pal24_postback",
)
if success:
return JSONResponse({"status": "ok"})
return JSONResponse(
{"status": "error", "reason": "not_processed"},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
if routes_registered:
@router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"mulenpay_enabled": settings.is_mulenpay_enabled(),
"cryptobot_enabled": settings.is_cryptobot_enabled(),
"yookassa_enabled": settings.is_yookassa_enabled(),
"wata_enabled": settings.is_wata_enabled(),
"heleket_enabled": settings.is_heleket_enabled(),
"pal24_enabled": settings.is_pal24_enabled(),
}
)
return router if routes_registered else None

250
app/webserver/telegram.py Normal file
View File

@@ -0,0 +1,250 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from aiogram import Bot, Dispatcher
from aiogram.types import Update
from app.config import settings
logger = logging.getLogger(__name__)
class TelegramWebhookProcessorError(RuntimeError):
"""Базовое исключение очереди Telegram webhook."""
class TelegramWebhookProcessorNotRunningError(TelegramWebhookProcessorError):
"""Очередь ещё не запущена или уже остановлена."""
class TelegramWebhookOverloadedError(TelegramWebhookProcessorError):
"""Очередь переполнена и не успевает обрабатывать новые обновления."""
class TelegramWebhookProcessor:
"""Асинхронная очередь обработки Telegram webhook-ов."""
def __init__(
self,
*,
bot: Bot,
dispatcher: Dispatcher,
queue_maxsize: int,
worker_count: int,
enqueue_timeout: float,
shutdown_timeout: float,
) -> None:
self._bot = bot
self._dispatcher = dispatcher
self._queue_maxsize = max(1, queue_maxsize)
self._worker_count = max(0, worker_count)
self._enqueue_timeout = max(0.0, enqueue_timeout)
self._shutdown_timeout = max(1.0, shutdown_timeout)
self._queue: asyncio.Queue[Update | object] = asyncio.Queue(maxsize=self._queue_maxsize)
self._workers: list[asyncio.Task[None]] = []
self._running = False
self._stop_sentinel: object = object()
self._lifecycle_lock = asyncio.Lock()
@property
def is_running(self) -> bool:
return self._running
async def start(self) -> None:
async with self._lifecycle_lock:
if self._running:
return
self._running = True
self._queue = asyncio.Queue(maxsize=self._queue_maxsize)
self._workers.clear()
for index in range(self._worker_count):
task = asyncio.create_task(
self._worker_loop(index),
name=f"telegram-webhook-worker-{index}",
)
self._workers.append(task)
if self._worker_count:
logger.info(
"🚀 Telegram webhook processor запущен: %s воркеров, очередь %s",
self._worker_count,
self._queue_maxsize,
)
else:
logger.warning(
"Telegram webhook processor запущен без воркеров — обновления не будут обрабатываться"
)
async def stop(self) -> None:
async with self._lifecycle_lock:
if not self._running:
return
self._running = False
if self._worker_count > 0:
try:
await asyncio.wait_for(self._queue.join(), timeout=self._shutdown_timeout)
except asyncio.TimeoutError:
logger.warning(
"⏱️ Не удалось дождаться завершения очереди Telegram webhook за %s секунд",
self._shutdown_timeout,
)
else:
drained = 0
while not self._queue.empty():
try:
self._queue.get_nowait()
except asyncio.QueueEmpty: # pragma: no cover - гонка состояния
break
else:
drained += 1
self._queue.task_done()
if drained:
logger.warning(
"Очередь Telegram webhook остановлена без воркеров, потеряно %s обновлений",
drained,
)
for _ in range(len(self._workers)):
try:
self._queue.put_nowait(self._stop_sentinel)
except asyncio.QueueFull:
# Очередь переполнена, подождём пока освободится место
await self._queue.put(self._stop_sentinel)
if self._workers:
await asyncio.gather(*self._workers, return_exceptions=True)
self._workers.clear()
logger.info("🛑 Telegram webhook processor остановлен")
async def enqueue(self, update: Update) -> None:
if not self._running:
raise TelegramWebhookProcessorNotRunningError
try:
if self._enqueue_timeout <= 0:
self._queue.put_nowait(update)
else:
await asyncio.wait_for(self._queue.put(update), timeout=self._enqueue_timeout)
except asyncio.QueueFull as error: # pragma: no cover - защитный сценарий
raise TelegramWebhookOverloadedError from error
except asyncio.TimeoutError as error:
raise TelegramWebhookOverloadedError from error
async def wait_until_drained(self, timeout: float | None = None) -> None:
if not self._running or self._worker_count == 0:
return
if timeout is None:
await self._queue.join()
return
await asyncio.wait_for(self._queue.join(), timeout=timeout)
async def _worker_loop(self, worker_id: int) -> None:
try:
while True:
try:
item = await self._queue.get()
except asyncio.CancelledError: # pragma: no cover - остановка приложения
logger.debug("Worker %s cancelled", worker_id)
raise
if item is self._stop_sentinel:
self._queue.task_done()
break
update = item
try:
await self._dispatcher.feed_update(self._bot, update) # type: ignore[arg-type]
except asyncio.CancelledError: # pragma: no cover - остановка приложения
logger.debug("Worker %s cancelled during processing", worker_id)
raise
except Exception as error: # pragma: no cover - логируем сбой обработчика
logger.exception("Ошибка обработки Telegram update в worker %s: %s", worker_id, error)
finally:
self._queue.task_done()
finally:
logger.debug("Worker %s завершён", worker_id)
async def _dispatch_update(
update: Update,
*,
dispatcher: Dispatcher,
bot: Bot,
processor: TelegramWebhookProcessor | None,
) -> None:
if processor is not None:
try:
await processor.enqueue(update)
except TelegramWebhookOverloadedError as error:
logger.warning("Очередь Telegram webhook переполнена: %s", error)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="webhook_queue_full") from error
except TelegramWebhookProcessorNotRunningError as error:
logger.error("Telegram webhook processor неактивен: %s", error)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="webhook_processor_unavailable") from error
return
await dispatcher.feed_update(bot, update)
def create_telegram_router(
bot: Bot,
dispatcher: Dispatcher,
*,
processor: TelegramWebhookProcessor | None = None,
) -> APIRouter:
router = APIRouter()
webhook_path = settings.get_telegram_webhook_path()
secret_token = settings.WEBHOOK_SECRET_TOKEN
@router.post(webhook_path)
async def telegram_webhook(request: Request) -> JSONResponse:
if secret_token:
header_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if header_token != secret_token:
logger.warning("Получен Telegram webhook с неверным секретом")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_secret_token")
content_type = request.headers.get("content-type", "")
if content_type and "application/json" not in content_type.lower():
raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="invalid_content_type")
try:
payload: Any = await request.json()
except Exception as error: # pragma: no cover - defensive logging
logger.error("Ошибка чтения Telegram webhook: %s", error)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_payload") from error
try:
update = Update.model_validate(payload)
except Exception as error: # pragma: no cover - defensive logging
logger.error("Ошибка валидации Telegram update: %s", error)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_update") from error
await _dispatch_update(update, dispatcher=dispatcher, bot=bot, processor=processor)
return JSONResponse({"status": "ok"})
@router.get("/health/telegram-webhook")
async def telegram_webhook_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"mode": settings.get_bot_run_mode(),
"path": webhook_path,
"webhook_configured": bool(settings.get_telegram_webhook_url()),
"queue_maxsize": settings.get_webhook_queue_maxsize(),
"workers": settings.get_webhook_worker_count(),
}
)
return router

View File

@@ -0,0 +1,165 @@
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import FastAPI, status
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from aiogram import Bot
from aiogram import Dispatcher
from app.config import settings
from app.services.payment_service import PaymentService
from app.webapi.app import create_web_api_app
from . import payments
from . import telegram
logger = logging.getLogger(__name__)
def _attach_docs_alias(app: FastAPI, docs_url: str | None) -> None:
if not docs_url:
return
alias_path = "/doc"
if alias_path == docs_url:
return
for route in app.router.routes:
if getattr(route, "path", None) == alias_path:
return
target_url = docs_url
@app.get(alias_path, include_in_schema=False)
async def redirect_doc() -> RedirectResponse: # pragma: no cover - simple redirect
return RedirectResponse(url=target_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT)
def _create_base_app() -> FastAPI:
docs_config = settings.get_web_api_docs_config()
if settings.is_web_api_enabled():
app = create_web_api_app()
else:
app = FastAPI(
title="Bedolaga Unified Server",
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
)
_attach_docs_alias(app, app.docs_url)
return app
def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]:
static_path: Path = settings.get_miniapp_static_path()
if not static_path.exists():
logger.debug("Miniapp static path %s does not exist, skipping mount", static_path)
return False, static_path
try:
app.mount("/miniapp/static", StaticFiles(directory=static_path), name="miniapp-static")
logger.info("📦 Miniapp static files mounted at /miniapp/static from %s", static_path)
except RuntimeError as error: # pragma: no cover - defensive guard
logger.warning("Не удалось смонтировать статические файлы миниаппа: %s", error)
return False, static_path
return True, static_path
def create_unified_app(
bot: Bot,
dispatcher: Dispatcher,
payment_service: PaymentService,
*,
enable_telegram_webhook: bool,
) -> FastAPI:
app = _create_base_app()
app.state.bot = bot
app.state.dispatcher = dispatcher
app.state.payment_service = payment_service
payments_router = payments.create_payment_router(bot, payment_service)
if payments_router:
app.include_router(payments_router)
payment_providers_state = {
"tribute": settings.TRIBUTE_ENABLED,
"mulenpay": settings.is_mulenpay_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"yookassa": settings.is_yookassa_enabled(),
"pal24": settings.is_pal24_enabled(),
"wata": settings.is_wata_enabled(),
"heleket": settings.is_heleket_enabled(),
}
if enable_telegram_webhook:
telegram_processor = telegram.TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=settings.get_webhook_queue_maxsize(),
worker_count=settings.get_webhook_worker_count(),
enqueue_timeout=settings.get_webhook_enqueue_timeout(),
shutdown_timeout=settings.get_webhook_shutdown_timeout(),
)
app.state.telegram_webhook_processor = telegram_processor
@app.on_event("startup")
async def start_telegram_webhook_processor() -> None: # pragma: no cover - event hook
await telegram_processor.start()
@app.on_event("shutdown")
async def stop_telegram_webhook_processor() -> None: # pragma: no cover - event hook
await telegram_processor.stop()
app.include_router(telegram.create_telegram_router(bot, dispatcher, processor=telegram_processor))
else:
telegram_processor = None
miniapp_mounted, miniapp_path = _mount_miniapp_static(app)
unified_health_path = "/health/unified" if settings.is_web_api_enabled() else "/health"
@app.get(unified_health_path)
async def unified_health() -> JSONResponse:
webhook_path = settings.get_telegram_webhook_path() if enable_telegram_webhook else None
telegram_state = {
"enabled": enable_telegram_webhook,
"running": bool(telegram_processor and telegram_processor.is_running),
"url": settings.get_telegram_webhook_url(),
"path": webhook_path,
"secret_configured": bool(settings.WEBHOOK_SECRET_TOKEN),
"queue_maxsize": settings.get_webhook_queue_maxsize(),
"workers": settings.get_webhook_worker_count(),
}
payment_state = {
"enabled": bool(payments_router),
"providers": payment_providers_state,
}
miniapp_state = {
"mounted": miniapp_mounted,
"path": str(miniapp_path),
}
return JSONResponse(
{
"status": "ok",
"bot_run_mode": settings.get_bot_run_mode(),
"web_api_enabled": settings.is_web_api_enabled(),
"payment_webhooks": payment_state,
"telegram_webhook": telegram_state,
"miniapp_static": miniapp_state,
}
)
return app

View File

@@ -73,16 +73,10 @@ services:
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3

View File

@@ -73,16 +73,10 @@ services:
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
- "${CRYPTOBOT_WEBHOOK_PORT:-8083}:8083"
- "${PAL24_WEBHOOK_PORT:-8084}:8084"
- "${WATA_WEBHOOK_PORT:-8085}:8085"
- "${HELEKET_WEBHOOK_PORT:-8086}:8086"
networks:
- bot_network
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8080/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3

View File

@@ -20,7 +20,7 @@ API разворачивается вместе с ботом, использу
| `WEB_API_HOST` | IP/hostname, на котором слушает API. | `0.0.0.0`
| `WEB_API_PORT` | Порт веб-API. | `8080`
| `WEB_API_ALLOWED_ORIGINS` | Список доменов для CORS, через запятую. `*` разрешит всё. | `https://admin.example.com`
| `WEB_API_DOCS_ENABLED` | Включить `/docs` и `/openapi.json`. В проде лучше `false`. | `false`
| `WEB_API_DOCS_ENABLED` | Включить `/docs`, `/doc` (редирект), `/redoc` и `/openapi.json`. В проде лучше `false`. | `false`
| `WEB_API_WORKERS` | Количество воркеров uvicorn. В embed-режиме всегда приводится к `1`. | `1`
| `WEB_API_REQUEST_LOGGING` | Логировать каждый запрос API. | `true`
| `WEB_API_DEFAULT_TOKEN` | Бутстрап-токен, который будет создан при миграции. | `super-secret-token`
@@ -34,7 +34,7 @@ API разворачивается вместе с ботом, использу
Чтобы открыть интерфейс Swagger UI на `/docs`, убедитесь, что одновременно заданы две переменные окружения:
1. `WEB_API_ENABLED=true` — включает само веб-API.
2. `WEB_API_DOCS_ENABLED=true` — публикует `/docs`, `/redoc` и `/openapi.json`.
2. `WEB_API_DOCS_ENABLED=true` — публикует `/docs`, `/doc` (редирект для старых ссылок), `/redoc` и `/openapi.json`.
После изменения значений перезапустите бота. Интерфейс будет доступен по адресу `http://<WEB_API_HOST>:<WEB_API_PORT>/docs`.

View File

@@ -625,23 +625,18 @@ configure_webhook_proxy() {
echo -e "${CYAN} Используем домен: ${YELLOW}$webhook_domain${NC}" >&2
cat <<EOF
$webhook_domain {
handle /tribute-webhook* {
reverse_proxy localhost:8081
}
handle /cryptobot-webhook* {
reverse_proxy localhost:8081
}
handle /mulenpay-webhook* {
reverse_proxy localhost:8081
}
handle /pal24-webhook* {
reverse_proxy localhost:8084
}
handle /yookassa-webhook* {
reverse_proxy localhost:8082
}
handle /health {
reverse_proxy localhost:8081/health
encode gzip zstd
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
transport http {
read_buffer 0
}
}
}
EOF
@@ -665,15 +660,17 @@ configure_miniapp_proxy() {
cat <<EOF
$miniapp_domain {
encode gzip zstd
root * /var/www/remnawave-miniapp
file_server
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
@redirect path /miniapp/redirect/index.html
redir @redirect /miniapp/redirect/index.html permanent
reverse_proxy /miniapp/* 127.0.0.1:8080 {
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
transport http {
read_buffer 0
}
}
}
EOF

309
main.py
View File

@@ -22,11 +22,8 @@ from app.services.payment_verification_service import (
)
from app.database.models import PaymentMethod
from app.services.version_service import version_service
from app.external.webhook_server import WebhookServer
from app.external.heleket_webhook import start_heleket_webhook_server
from app.external.yookassa_webhook import start_yookassa_webhook_server
from app.external.pal24_webhook import start_pal24_webhook_server, Pal24WebhookServer
from app.external.wata_webhook import start_wata_webhook_server
from app.webapi.server import WebAPIServer
from app.webserver.unified_app import create_unified_app
from app.database.universal_migration import run_universal_migration
from app.services.backup_service import backup_service
from app.services.reporting_service import reporting_service
@@ -97,17 +94,16 @@ async def main():
signal.signal(signal.SIGINT, killer.exit_gracefully)
signal.signal(signal.SIGTERM, killer.exit_gracefully)
webhook_server = None
yookassa_server_task = None
wata_server_task = None
heleket_server_task = None
pal24_server: Pal24WebhookServer | None = None
web_app = None
monitoring_task = None
maintenance_task = None
version_check_task = None
polling_task = None
web_api_server = None
telegram_webhook_enabled = False
polling_enabled = True
payment_webhooks_enabled = False
summary_logged = False
try:
@@ -320,92 +316,86 @@ async def main():
stage.warning(f"Ошибка подготовки внешней админки: {error}")
logger.error("❌ Ошибка подготовки внешней админки: %s", error)
webhook_needed = (
settings.TRIBUTE_ENABLED
or settings.is_cryptobot_enabled()
or settings.is_mulenpay_enabled()
bot_run_mode = settings.get_bot_run_mode()
polling_enabled = bot_run_mode in {"polling", "both"}
telegram_webhook_enabled = bot_run_mode in {"webhook", "both"}
payment_webhooks_enabled = any(
[
settings.TRIBUTE_ENABLED,
settings.is_cryptobot_enabled(),
settings.is_mulenpay_enabled(),
settings.is_yookassa_enabled(),
settings.is_pal24_enabled(),
settings.is_wata_enabled(),
settings.is_heleket_enabled(),
]
)
async with timeline.stage(
"Webhook сервисы",
"Единый веб-сервер",
"🌐",
success_message="Webhook сервера настроены",
success_message="Веб-сервер запущен",
) as stage:
if webhook_needed:
enabled_services = []
if settings.TRIBUTE_ENABLED:
enabled_services.append("Tribute")
if settings.is_mulenpay_enabled():
enabled_services.append(settings.get_mulenpay_display_name())
if settings.is_cryptobot_enabled():
enabled_services.append("CryptoBot")
should_start_web_app = (
settings.is_web_api_enabled()
or telegram_webhook_enabled
or payment_webhooks_enabled
or settings.get_miniapp_static_path().exists()
)
webhook_server = WebhookServer(bot)
await webhook_server.start()
stage.log(f"Активированы: {', '.join(enabled_services)}")
stage.success("Webhook сервера запущены")
else:
stage.skip(
f"Tribute, {settings.get_mulenpay_display_name()} и CryptoBot отключены"
if should_start_web_app:
web_app = create_unified_app(
bot,
dp,
payment_service,
enable_telegram_webhook=telegram_webhook_enabled,
)
web_api_server = WebAPIServer(app=web_app)
await web_api_server.start()
base_url = settings.WEBHOOK_URL or f"http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
stage.log(f"Базовый URL: {base_url}")
features: list[str] = []
if settings.is_web_api_enabled():
features.append("админка")
if payment_webhooks_enabled:
features.append("платежные webhook-и")
if telegram_webhook_enabled:
features.append("Telegram webhook")
if settings.get_miniapp_static_path().exists():
features.append("статические файлы миниаппа")
if features:
stage.log("Активные сервисы: " + ", ".join(features))
stage.success("HTTP-сервисы активны")
else:
stage.skip("HTTP-сервисы отключены настройками")
async with timeline.stage(
"YooKassa webhook",
"💳",
success_message="YooKassa webhook запущен",
"Telegram webhook",
"🤖",
success_message="Telegram webhook настроен",
) as stage:
if settings.is_yookassa_enabled():
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
)
if telegram_webhook_enabled:
webhook_url = settings.get_telegram_webhook_url()
if not webhook_url:
stage.warning("WEBHOOK_URL не задан, пропускаем настройку webhook")
else:
allowed_updates = dp.resolve_used_update_types()
await bot.set_webhook(
url=webhook_url,
secret_token=settings.WEBHOOK_SECRET_TOKEN,
drop_pending_updates=settings.WEBHOOK_DROP_PENDING_UPDATES,
allowed_updates=allowed_updates,
)
stage.log(f"Webhook установлен: {webhook_url}")
stage.log(f"Allowed updates: {', '.join(sorted(allowed_updates)) if allowed_updates else 'all'}")
stage.success("Telegram webhook активен")
else:
stage.skip("YooKassa отключена настройками")
async with timeline.stage(
"PayPalych webhook",
"💳",
success_message="PayPalych webhook запущен",
) as stage:
if settings.is_pal24_enabled():
pal24_server = await start_pal24_webhook_server(payment_service)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
)
else:
stage.skip("PayPalych отключен настройками")
async with timeline.stage(
"WATA webhook",
"💳",
success_message="WATA webhook запущен",
) as stage:
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
else:
stage.skip("WATA отключен настройками")
async with timeline.stage(
"Heleket webhook",
"🪙",
success_message="Heleket webhook запущен",
) as stage:
if settings.is_heleket_enabled():
heleket_server_task = asyncio.create_task(
start_heleket_webhook_server(payment_service)
)
stage.log(
f"Endpoint: {settings.WEBHOOK_URL}:{settings.HELEKET_WEBHOOK_PORT}{settings.HELEKET_WEBHOOK_PATH}"
)
else:
stage.skip("Heleket отключен настройками")
stage.skip("Режим webhook отключен")
async with timeline.stage(
"Служба мониторинга",
@@ -447,61 +437,43 @@ async def main():
version_check_task = None
stage.skip("Проверка версий отключена настройками")
async with timeline.stage(
"Административное веб-API",
"🌐",
success_message="Веб-API запущено",
) as stage:
if settings.is_web_api_enabled():
try:
from app.webapi import WebAPIServer
web_api_server = WebAPIServer()
await web_api_server.start()
stage.success(
f"Доступно на http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
)
except Exception as error:
stage.warning(f"Не удалось запустить веб-API: {error}")
logger.error(f"Не удалось запустить веб-API: {error}")
else:
stage.skip("Веб-API отключено")
async with timeline.stage(
"Запуск polling",
"🤖",
success_message="Aiogram polling запущен",
) as stage:
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
stage.log("skip_updates=True")
if polling_enabled:
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
stage.log("skip_updates=True")
else:
polling_task = None
stage.skip("Polling отключен режимом работы")
webhook_lines = []
if webhook_needed:
if settings.TRIBUTE_ENABLED:
webhook_lines.append(
f"Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}"
)
if settings.is_mulenpay_enabled():
webhook_lines.append(
f"{settings.get_mulenpay_display_name()}: "
f"{settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
)
if settings.is_cryptobot_enabled():
webhook_lines.append(
f"CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}"
)
webhook_lines: list[str] = []
base_url = settings.WEBHOOK_URL or f"http://{settings.WEB_API_HOST}:{settings.WEB_API_PORT}"
def _fmt(path: str) -> str:
return f"{base_url}{path if path.startswith('/') else '/' + path}"
telegram_webhook_url = settings.get_telegram_webhook_url()
if telegram_webhook_enabled and telegram_webhook_url:
webhook_lines.append(f"Telegram: {telegram_webhook_url}")
if settings.TRIBUTE_ENABLED:
webhook_lines.append(f"Tribute: {_fmt(settings.TRIBUTE_WEBHOOK_PATH)}")
if settings.is_mulenpay_enabled():
webhook_lines.append(
f"{settings.get_mulenpay_display_name()}: {_fmt(settings.MULENPAY_WEBHOOK_PATH)}"
)
if settings.is_cryptobot_enabled():
webhook_lines.append(f"CryptoBot: {_fmt(settings.CRYPTOBOT_WEBHOOK_PATH)}")
if settings.is_yookassa_enabled():
webhook_lines.append(
f"YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}"
)
webhook_lines.append(f"YooKassa: {_fmt(settings.YOOKASSA_WEBHOOK_PATH)}")
if settings.is_pal24_enabled():
webhook_lines.append(
f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}"
)
webhook_lines.append(f"PayPalych: {_fmt(settings.PAL24_WEBHOOK_PATH)}")
if settings.is_wata_enabled():
webhook_lines.append(
f"WATA: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}"
)
webhook_lines.append(f"WATA: {_fmt(settings.WATA_WEBHOOK_PATH)}")
if settings.is_heleket_enabled():
webhook_lines.append(f"Heleket: {_fmt(settings.HELEKET_WEBHOOK_PATH)}")
timeline.log_section(
"Активные webhook endpoints",
@@ -536,39 +508,6 @@ async def main():
while not killer.exit:
await asyncio.sleep(1)
if yookassa_server_task and yookassa_server_task.done():
exception = yookassa_server_task.exception()
if exception:
logger.error(f"YooKassa webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск YooKassa webhook сервера...")
yookassa_server_task = asyncio.create_task(
start_yookassa_webhook_server(payment_service)
)
if wata_server_task and wata_server_task.done():
exception = wata_server_task.exception()
if exception:
logger.error(f"WATA webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск WATA webhook сервера...")
if settings.is_wata_enabled():
wata_server_task = asyncio.create_task(
start_wata_webhook_server(payment_service)
)
else:
wata_server_task = None
if heleket_server_task and heleket_server_task.done():
exception = heleket_server_task.exception()
if exception:
logger.error(f"Heleket webhook сервер завершился с ошибкой: {exception}")
logger.info("🔄 Перезапуск Heleket webhook сервера...")
if settings.is_heleket_enabled():
heleket_server_task = asyncio.create_task(
start_heleket_webhook_server(payment_service)
)
else:
heleket_server_task = None
if monitoring_task.done():
exception = monitoring_task.exception()
if exception:
@@ -596,7 +535,7 @@ async def main():
await auto_payment_verification_service.start()
auto_verification_active = auto_payment_verification_service.is_running()
if polling_task.done():
if polling_task and polling_task.done():
exception = polling_task.exception()
if exception:
logger.error(f"Polling завершился с ошибкой: {exception}")
@@ -623,30 +562,6 @@ async def main():
f"Ошибка остановки сервиса автопроверки пополнений: {error}"
)
if yookassa_server_task and not yookassa_server_task.done():
logger.info(" Остановка YooKassa webhook сервера...")
yookassa_server_task.cancel()
try:
await yookassa_server_task
except asyncio.CancelledError:
pass
if wata_server_task and not wata_server_task.done():
logger.info(" Остановка WATA webhook сервера...")
wata_server_task.cancel()
try:
await wata_server_task
except asyncio.CancelledError:
pass
if heleket_server_task and not heleket_server_task.done():
logger.info(" Остановка Heleket webhook сервера...")
heleket_server_task.cancel()
try:
await heleket_server_task
except asyncio.CancelledError:
pass
if monitoring_task and not monitoring_task.done():
logger.info(" Остановка службы мониторинга...")
monitoring_service.stop_monitoring()
@@ -656,10 +571,6 @@ async def main():
except asyncio.CancelledError:
pass
if pal24_server:
logger.info(" Остановка PayPalych webhook сервера...")
await asyncio.get_running_loop().run_in_executor(None, pal24_server.stop)
if maintenance_task and not maintenance_task.done():
logger.info(" Остановка службы техработ...")
await maintenance_service.stop_monitoring()
@@ -703,9 +614,13 @@ async def main():
except asyncio.CancelledError:
pass
if webhook_server:
logger.info(" Остановка webhook сервера...")
await webhook_server.stop()
if telegram_webhook_enabled and 'bot' in locals():
logger.info(" Снятие Telegram webhook...")
try:
await bot.delete_webhook(drop_pending_updates=False)
logger.info("✅ Telegram webhook удалён")
except Exception as error:
logger.error(f"Ошибка удаления Telegram webhook: {error}")
if web_api_server:
try:

View File

@@ -1,137 +0,0 @@
"""Тестирование хендлеров WebhookServer без запуска реального сервера."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Tuple
import sys
from unittest.mock import AsyncMock
import pytest
from aiohttp.test_utils import make_mocked_request
from aiohttp import web
ROOT_DIR = Path(__file__).resolve().parents[2]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from app.config import settings # noqa: E402
from app.external.webhook_server import WebhookServer # noqa: E402
class DummyBot:
async def send_message(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - уведомления не проверяем
return None
@pytest.fixture
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture
def webhook_server(monkeypatch: pytest.MonkeyPatch) -> Tuple[WebhookServer, AsyncMock, AsyncMock]:
monkeypatch.setattr(settings, "TRIBUTE_WEBHOOK_PATH", "/tribute", raising=False)
monkeypatch.setattr(settings, "MULENPAY_WEBHOOK_PATH", "/mulen", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_PATH", "/cryptobot", raising=False)
monkeypatch.setattr(settings, "MULENPAY_SECRET_KEY", "mulen-secret", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "", raising=False)
monkeypatch.setattr(type(settings), "is_mulenpay_enabled", lambda self: True, raising=False)
monkeypatch.setattr(type(settings), "is_cryptobot_enabled", lambda self: True, raising=False)
server = WebhookServer(DummyBot())
tribute_mock = AsyncMock()
tribute_mock.process_webhook = AsyncMock(return_value={"status": "ok"})
server.tribute_service = tribute_mock
payment_mock = AsyncMock()
payment_mock.process_mulenpay_callback = AsyncMock(return_value=True)
payment_mock.process_cryptobot_webhook = AsyncMock(return_value=True)
monkeypatch.setattr("app.external.webhook_server.PaymentService", lambda *args, **kwargs: payment_mock)
monkeypatch.setattr("app.services.payment_service.PaymentService", lambda *args, **kwargs: payment_mock)
server._verify_mulenpay_signature = lambda request, raw: True # type: ignore[attr-defined]
class DummyDB:
async def commit(self) -> None: # pragma: no cover - не проверяем транзакции
return None
async def fake_get_db():
yield DummyDB()
monkeypatch.setattr("app.external.webhook_server.get_db", fake_get_db)
class DummySessionManager:
def __init__(self) -> None:
self.session = DummyDB()
async def __aenter__(self) -> DummyDB:
return self.session
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
monkeypatch.setattr("app.database.database.AsyncSessionLocal", lambda: DummySessionManager())
return server, tribute_mock, payment_mock
def _mock_request(method: str, path: str, body: dict[str, Any], headers: dict[str, str] | None = None) -> AsyncMock:
request = AsyncMock(spec=web.Request)
request.method = method
request.path = path
request.headers = headers or {}
request.read.return_value = json.dumps(body).encode("utf-8")
return request
@pytest.mark.anyio("asyncio")
async def test_health_endpoint(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, _ = webhook_server
request = make_mocked_request("GET", "/health")
response = await server._health_check(request)
assert response.status == 200
data = json.loads(response.text)
assert data["status"] == "ok"
assert data["service"] == "payment-webhooks"
@pytest.mark.anyio("asyncio")
async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch, webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, tribute_mock, _ = webhook_server
monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "key", raising=False)
class FakeTributeAPI:
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
return True
monkeypatch.setattr("app.external.tribute.TributeService", FakeTributeAPI)
request = _mock_request("POST", "/tribute", {"event_type": "payment", "status": "paid"}, headers={"trbt-signature": "sig"})
response = await server._tribute_webhook_handler(request)
assert response.status == 200
assert tribute_mock.process_webhook.await_count == 1
@pytest.mark.anyio("asyncio")
async def test_mulenpay_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, payment_mock = webhook_server
request = _mock_request("POST", "/mulen", {"uuid": "uuid", "payment_status": "success"})
response = await server._mulenpay_webhook_handler(request)
assert response.status == 200
payment_mock.process_mulenpay_callback.assert_awaited_once()
@pytest.mark.anyio("asyncio")
async def test_cryptobot_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None:
server, _, payment_mock = webhook_server
request = _mock_request(
"POST",
"/cryptobot",
{"update_type": "invoice_paid", "payload": {"invoice_id": 1}},
)
response = await server._cryptobot_webhook_handler(request)
assert response.status == 200
payment_mock.process_cryptobot_webhook.assert_awaited_once()

View File

@@ -0,0 +1,191 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from starlette.requests import Request
from app.config import settings
from app.webserver.payments import create_payment_router
class DummyBot:
pass
@pytest.fixture(autouse=True)
def reset_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "TRIBUTE_API_KEY", None, raising=False)
monkeypatch.setattr(settings, "TRIBUTE_WEBHOOK_PATH", "/tribute", raising=False)
monkeypatch.setattr(settings, "MULENPAY_WEBHOOK_PATH", "/mulen", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", None, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_PATH", "/cryptobot", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", None, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_PATH", "/yookassa", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", None, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop", raising=False)
monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "key", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", "http://test", raising=False)
def _get_route(router, path: str, method: str = "POST"):
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return route
raise AssertionError(f"Route {path} with method {method} not found")
def _build_request(path: str, body: bytes, headers: dict[str, str]) -> Request:
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"method": "POST",
"path": path,
"headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()],
}
async def receive() -> dict:
return {"type": "http.request", "body": body, "more_body": False}
return Request(scope, receive)
@pytest.mark.anyio
async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False)
process_mock = AsyncMock(return_value={"status": "ok"})
class StubTributeService:
def __init__(self, *_args, **_kwargs):
pass
async def process_webhook(self, payload: str): # type: ignore[override]
return await process_mock(payload)
class StubTributeAPI:
@staticmethod
def verify_webhook_signature(payload: str, signature: str) -> bool: # noqa: D401 - test stub
return True
monkeypatch.setattr("app.webserver.payments.TributeService", StubTributeService)
monkeypatch.setattr("app.webserver.payments.TributeAPI", StubTributeAPI)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.TRIBUTE_WEBHOOK_PATH)
request = _build_request(
settings.TRIBUTE_WEBHOOK_PATH,
body=json.dumps({"event": "payment"}).encode("utf-8"),
headers={"trbt-signature": "sig"},
)
response = await route.endpoint(request)
assert response.status_code == 200
assert json.loads(response.body.decode("utf-8"))["status"] == "ok"
process_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_yookassa_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
class StubHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool: # noqa: D401
return False
monkeypatch.setattr("app.webserver.payments.YooKassaWebhookHandler", StubHandler)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={"Signature": "bad"},
)
response = await route.endpoint(request)
assert response.status_code == 401
@pytest.mark.anyio
async def test_yookassa_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "YOOKASSA_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "YOOKASSA_WEBHOOK_SECRET", "secret", raising=False)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.YOOKASSA_WEBHOOK_PATH)
request = _build_request(
settings.YOOKASSA_WEBHOOK_PATH,
body=json.dumps({"event": "payment.succeeded"}).encode("utf-8"),
headers={},
)
response = await route.endpoint(request)
assert response.status_code == 401
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "missing_signature"
@pytest.mark.anyio
async def test_cryptobot_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH)
request = _build_request(
settings.CRYPTOBOT_WEBHOOK_PATH,
body=json.dumps({"test": "value"}).encode("utf-8"),
headers={},
)
response = await route.endpoint(request)
assert response.status_code == 401
payload = json.loads(response.body.decode("utf-8"))
assert payload["reason"] == "missing_signature"
@pytest.mark.anyio
async def test_cryptobot_invalid_signature(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CRYPTOBOT_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False)
monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False)
class StubCryptoBotService:
@staticmethod
def verify_webhook_signature(body: str, signature: str) -> bool: # noqa: D401 - test stub
return False
monkeypatch.setattr("app.external.cryptobot.CryptoBotService", StubCryptoBotService)
router = create_payment_router(DummyBot(), SimpleNamespace())
assert router is not None
route = _get_route(router, settings.CRYPTOBOT_WEBHOOK_PATH)
request = _build_request(
settings.CRYPTOBOT_WEBHOOK_PATH,
body=json.dumps({"test": "value"}).encode("utf-8"),
headers={"Crypto-Pay-API-Signature": "sig"},
)
response = await route.endpoint(request)
assert response.status_code == 401

View File

@@ -0,0 +1,338 @@
import json
from typing import Any
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from app.config import settings
from app.webserver.telegram import (
TelegramWebhookProcessor,
create_telegram_router,
)
@pytest.fixture(autouse=True)
def reset_webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "WEBHOOK_PATH", "/telegram-webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", None, raising=False)
monkeypatch.setattr(settings, "BOT_RUN_MODE", "webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 8, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 1, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_ENQUEUE_TIMEOUT", 0.0, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKER_SHUTDOWN_TIMEOUT", 1.0, raising=False)
def _get_route(router, path: str, method: str = "POST"):
for route in router.routes:
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
return route
raise AssertionError(f"Route {path} with method {method} not found")
def _build_request(path: str, body: bytes, headers: dict[str, str] | None = None) -> Request:
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"method": "POST",
"path": path,
"headers": [
(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in (headers or {}).items()
],
}
async def receive() -> dict[str, Any]:
return {"type": "http.request", "body": body, "more_body": False}
return Request(scope, receive)
def _webhook_path() -> str:
return settings.get_telegram_webhook_path()
@pytest.mark.anyio
async def test_webhook_without_secret() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
sample_update = {
"update_id": 123,
"message": {
"message_id": 10,
"date": 1715700000,
"chat": {"id": 456, "type": "private"},
"text": "ping",
},
}
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, json.dumps(sample_update).encode("utf-8"))
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_awaited_once()
args, _kwargs = dispatcher.feed_update.await_args
assert args[0] is bot
@pytest.mark.anyio
async def test_webhook_with_secret(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "super-secret", raising=False)
sample_update = {
"update_id": 321,
"message": {
"message_id": 20,
"date": 1715700000,
"chat": {"id": 789, "type": "private"},
"text": "pong",
},
}
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(
path,
json.dumps(sample_update).encode("utf-8"),
headers={"X-Telegram-Bot-Api-Secret-Token": "super-secret"},
)
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_awaited_once()
@pytest.mark.anyio
async def test_webhook_secret_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "expected", raising=False)
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(
path,
json.dumps({"update_id": 1}).encode("utf-8"),
headers={"X-Telegram-Bot-Api-Secret-Token": "wrong"},
)
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 401
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_invalid_payload() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, b"not-json")
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 400
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_invalid_content_type() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
sample_update = {
"update_id": 123,
"message": {
"message_id": 10,
"date": 1715700000,
"chat": {"id": 456, "type": "private"},
"text": "ping",
},
}
router = create_telegram_router(bot, dispatcher)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(
path,
json.dumps(sample_update).encode("utf-8"),
headers={"Content-Type": "text/plain"},
)
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 415
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_uses_processor() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
processor = TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=1,
worker_count=0,
enqueue_timeout=0.0,
shutdown_timeout=1.0,
)
await processor.start()
sample_update = {
"update_id": 999,
"message": {
"message_id": 77,
"date": 1715700000,
"chat": {"id": 111, "type": "private"},
"text": "processor",
},
}
router = create_telegram_router(bot, dispatcher, processor=processor)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, json.dumps(sample_update).encode("utf-8"))
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_not_awaited()
assert processor.is_running
await processor.stop()
@pytest.mark.anyio
async def test_webhook_processor_overloaded() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
processor = TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=1,
worker_count=0,
enqueue_timeout=0.0,
shutdown_timeout=1.0,
)
await processor.start()
router = create_telegram_router(bot, dispatcher, processor=processor)
path = _webhook_path()
route = _get_route(router, path)
request_payload = json.dumps({"update_id": 1}).encode("utf-8")
request = _build_request(path, request_payload)
await route.endpoint(request)
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 503
assert exc.value.detail == "webhook_queue_full"
dispatcher.feed_update.assert_not_called()
await processor.stop()
@pytest.mark.anyio
async def test_webhook_processor_not_running() -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
processor = TelegramWebhookProcessor(
bot=bot,
dispatcher=dispatcher,
queue_maxsize=1,
worker_count=1,
enqueue_timeout=0.0,
shutdown_timeout=1.0,
)
router = create_telegram_router(bot, dispatcher, processor=processor)
path = _webhook_path()
route = _get_route(router, path)
request = _build_request(path, json.dumps({"update_id": 5}).encode("utf-8"))
with pytest.raises(HTTPException) as exc:
await route.endpoint(request)
assert exc.value.status_code == 503
assert exc.value.detail == "webhook_processor_unavailable"
dispatcher.feed_update.assert_not_called()
@pytest.mark.anyio
async def test_webhook_path_normalization(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_PATH", " telegram/webhook ", raising=False)
router = create_telegram_router(bot, dispatcher)
normalized_path = settings.get_telegram_webhook_path()
assert normalized_path == "/telegram/webhook"
route = _get_route(router, normalized_path)
request = _build_request(normalized_path, json.dumps({"update_id": 7}).encode("utf-8"))
response = await route.endpoint(request)
assert response.status_code == 200
dispatcher.feed_update.assert_awaited_once()
@pytest.mark.anyio
async def test_health_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = AsyncMock()
dispatcher.feed_update = AsyncMock()
monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_PATH", "/custom", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 42, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 2, raising=False)
router = create_telegram_router(bot, dispatcher)
route = _get_route(router, "/health/telegram-webhook", method="GET")
response = await route.endpoint()
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload["status"] == "ok"
assert payload["mode"] == settings.get_bot_run_mode()
assert payload["path"] == "/custom"
assert payload["webhook_configured"] is True
assert payload["queue_maxsize"] == 42
assert payload["workers"] == 2

View File

@@ -0,0 +1,148 @@
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, status
from app.config import settings
from app.services.payment_service import PaymentService
# ensure backup directory exists before importing the unified app to avoid side effects during module import
_backup_dir = Path("data/backups")
_backup_dir.mkdir(parents=True, exist_ok=True)
from app.webserver.unified_app import create_unified_app
@pytest.mark.anyio
async def test_unified_app_health_reports_features(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
bot = AsyncMock()
dispatcher = SimpleNamespace(feed_update=AsyncMock())
payment_service = AsyncMock(spec=PaymentService)
miniapp_static_dir = tmp_path / "miniapp"
miniapp_static_dir.mkdir()
monkeypatch.setattr(settings, "WEB_API_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_URL", "https://hooks.example.com", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_PATH", "/telegram-webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_SECRET_TOKEN", "super-secret", raising=False)
monkeypatch.setattr(settings, "BOT_RUN_MODE", "webhook", raising=False)
monkeypatch.setattr(settings, "WEBHOOK_MAX_QUEUE_SIZE", 8, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKERS", 1, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_ENQUEUE_TIMEOUT", 0.0, raising=False)
monkeypatch.setattr(settings, "WEBHOOK_WORKER_SHUTDOWN_TIMEOUT", 1.0, raising=False)
monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", str(miniapp_static_dir), raising=False)
app = create_unified_app(
bot,
dispatcher, # type: ignore[arg-type]
payment_service,
enable_telegram_webhook=True,
)
health_route = next(
route
for route in app.routes
if getattr(route, "endpoint", None) and getattr(route.endpoint, "__name__", "") == "unified_health"
)
assert getattr(health_route, "path", None) == "/health/unified"
await app.router.startup()
try:
response = await health_route.endpoint() # type: ignore[func-returns-value]
finally:
await app.router.shutdown()
payload = json.loads(response.body.decode("utf-8")) # type: ignore[attr-defined]
assert payload["status"] == "ok"
assert payload["web_api_enabled"] is True
assert payload["bot_run_mode"] == "webhook"
assert payload["telegram_webhook"]["enabled"] is True
assert payload["telegram_webhook"]["running"] is True
assert payload["telegram_webhook"]["path"] == "/telegram-webhook"
assert payload["telegram_webhook"]["secret_configured"] is True
assert payload["payment_webhooks"]["enabled"] is True
assert payload["payment_webhooks"]["providers"]["tribute"] is True
assert payload["miniapp_static"]["mounted"] is True
assert payload["miniapp_static"]["path"].endswith("miniapp")
def _build_unified_app(monkeypatch: pytest.MonkeyPatch, docs_enabled: bool) -> FastAPI:
bot = AsyncMock()
dispatcher = SimpleNamespace(feed_update=AsyncMock())
payment_service = AsyncMock(spec=PaymentService)
monkeypatch.setattr(settings, "WEB_API_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "WEB_API_DOCS_ENABLED", docs_enabled, raising=False)
monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", "miniapp", raising=False)
return create_unified_app(
bot,
dispatcher, # type: ignore[arg-type]
payment_service,
enable_telegram_webhook=False,
)
@pytest.mark.anyio
async def test_unified_app_health_path_without_admin(monkeypatch: pytest.MonkeyPatch) -> None:
bot = AsyncMock()
dispatcher = SimpleNamespace(feed_update=AsyncMock())
payment_service = AsyncMock(spec=PaymentService)
monkeypatch.setattr(settings, "WEB_API_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "MINIAPP_STATIC_PATH", "miniapp", raising=False)
app = create_unified_app(
bot,
dispatcher, # type: ignore[arg-type]
payment_service,
enable_telegram_webhook=False,
)
health_route = next(
route
for route in app.routes
if getattr(route, "endpoint", None) and getattr(route.endpoint, "__name__", "") == "unified_health"
)
assert getattr(health_route, "path", None) == "/health"
def test_unified_app_docs_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
app = _build_unified_app(monkeypatch, docs_enabled=False)
assert app.docs_url is None
assert app.redoc_url is None
assert app.openapi_url is None
registered_paths = {getattr(route, "path", None) for route in app.routes}
assert "/doc" not in registered_paths
@pytest.mark.anyio
async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPatch) -> None:
app = _build_unified_app(monkeypatch, docs_enabled=True)
assert app.docs_url == "/docs"
assert app.redoc_url == "/redoc"
assert app.openapi_url == "/openapi.json"
alias_route = next(
(route for route in app.routes if getattr(route, "path", None) == "/doc"),
None,
)
assert alias_route is not None
assert getattr(alias_route, "include_in_schema", True) is False
response = await alias_route.endpoint() # type: ignore[func-returns-value]
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
assert response.headers["location"] == "/docs"