From 340041ddaef4704893968fe34847324fd79f8323 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 6 Nov 2025 17:27:39 +0300 Subject: [PATCH 1/8] Restore FastAPI docs endpoints for webhook mode --- .env.example | 22 + Dockerfile | 4 +- README.md | 351 +++++++-------- SECURITY.md | 5 +- app/config.py | 65 ++- app/webapi/server.py | 4 +- app/webserver/__init__.py | 11 + app/webserver/payments.py | 596 ++++++++++++++++++++++++++ app/webserver/telegram.py | 250 +++++++++++ app/webserver/unified_app.py | 163 +++++++ docker-compose.local.yml | 8 +- docker-compose.yml | 8 +- docs/web-admin-integration.md | 4 +- install_bot.sh | 41 +- main.py | 307 +++++-------- tests/external/test_webhook_server.py | 137 ------ tests/webserver/test_payments.py | 191 +++++++++ tests/webserver/test_telegram.py | 338 +++++++++++++++ tests/webserver/test_unified_app.py | 121 ++++++ 19 files changed, 2074 insertions(+), 552 deletions(-) create mode 100644 app/webserver/__init__.py create mode 100644 app/webserver/payments.py create mode 100644 app/webserver/telegram.py create mode 100644 app/webserver/unified_app.py delete mode 100644 tests/external/test_webhook_server.py create mode 100644 tests/webserver/test_payments.py create mode 100644 tests/webserver/test_telegram.py create mode 100644 tests/webserver/test_unified_app.py diff --git a/.env.example b/.env.example index 3ebadd30..c1d4f610 100644 --- a/.env.example +++ b/.env.example @@ -188,6 +188,8 @@ TRIBUTE_API_KEY= TRIBUTE_DONATE_LINK= TRIBUTE_WEBHOOK_PATH=/tribute-webhook TRIBUTE_WEBHOOK_HOST=0.0.0.0 +# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска. +# В режиме единого FastAPI сервера оставьте эти значения по умолчанию. TRIBUTE_WEBHOOK_PORT=8081 # YooKassa (https://yookassa.ru) @@ -273,6 +275,8 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here CRYPTOBOT_BASE_URL=https://pay.crypt.bot CRYPTOBOT_TESTNET=false CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook +# Эти порты используются только при ручном запуске старого aiohttp сервера. +# В новой конфигурации все вебхуки обрабатываются через порт 8080. CRYPTOBOT_WEBHOOK_PORT=8081 CRYPTOBOT_DEFAULT_ASSET=USDT CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC @@ -349,6 +353,7 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_STATIC_PATH=miniapp MINIAPP_SERVICE_NAME_EN=Bedolaga VPN MINIAPP_SERVICE_NAME_RU=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection @@ -449,3 +454,20 @@ LOG_FILE=logs/bot.log DEBUG=false WEBHOOK_URL= WEBHOOK_PATH=/webhook +WEBHOOK_SECRET_TOKEN= +WEBHOOK_DROP_PENDING_UPDATES=true +WEBHOOK_MAX_QUEUE_SIZE=1024 +WEBHOOK_WORKERS=4 +WEBHOOK_ENQUEUE_TIMEOUT=0.1 +WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0 +BOT_RUN_MODE=polling # polling, webhook или both + +# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР ===== +WEB_API_ENABLED=false +WEB_API_HOST=0.0.0.0 +WEB_API_PORT=8080 +WEB_API_ALLOWED_ORIGINS=* +WEB_API_DOCS_ENABLED=false +WEB_API_DEFAULT_TOKEN= +WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token +MINIAPP_STATIC_PATH=miniapp diff --git a/Dockerfile b/Dockerfile index 7dc16c78..477c1ba0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 87bb8331..cd849b2e 100644 --- a/README.md +++ b/README.md @@ -122,35 +122,67 @@ docker compose logs --- -## 🌐 Настройка обратного прокси и доменов +## 🌐 Настройка webhook-режима и обратного прокси -> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) должны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса портов наружу. +> Встроенный FastAPI-сервер теперь обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт. -### 1. Планирование доменов и переменных окружения +### ♻️ Миграция со старой схемы (несколько портов) -1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот. -2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол). -3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами: - ```env - WEBHOOK_URL=https://hooks.example.com - WEB_API_ENABLED=true - WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com - MINIAPP_CUSTOM_URL=https://miniapp.example.com - ``` +Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему: -### 2. Общая Docker-сеть для бота и прокси +1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN`. +2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `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` и `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 ``` -Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю: +Если прокси стартует отдельным compose-файлом, объявите сеть внешней: ```yaml networks: @@ -158,178 +190,139 @@ 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 | 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` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). +- `/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; +### 6. Пример nginx-конфига - 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; } +```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 - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } +networks: + bot_network: + external: true +``` - server { - listen 80; - listen 443 ssl http2; - server_name miniapp.example.com; +`nginx.conf`: - ssl_certificate /etc/ssl/private/miniapp.fullchain.pem; - ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem; +```nginx +events {} - root /var/www/remnawave-miniapp; - index index.html; +http { + include /etc/nginx/mime.types; + sendfile on; - location /miniapp/ { - proxy_pass http://remnawave_bot_api/miniapp/; - } - } - } - ``` + 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; + } + } +} +``` + +Рекомендации: + +- Откройте входящие 80/443 в фаерволе. +- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`. +- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook. --- @@ -349,10 +342,22 @@ networks: Подробное пошаговое руководство по запуску административного веб-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`. + ### 📊 Статус серверов в главном меню | Переменная | Описание | Пример | @@ -947,9 +952,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` +- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook` +- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks` ### 🔧 Полезные команды ```bash @@ -984,7 +989,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 в платежных системах | @@ -1272,7 +1277,7 @@ docker compose ps docker compose logs -f bot # Проверка здоровья -curl http://localhost:8081/health +curl http://localhost:8080/health # Использование ресурсов docker stats diff --git a/SECURITY.md b/SECURITY.md index 63b4197b..4a59898a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/app/config.py b/app/config.py index 025e4719..8202b90a 100644 --- a/app/config.py +++ b/app/config.py @@ -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", diff --git a/app/webapi/server.py b/app/webapi/server.py index 6472e2ec..5218a04b 100644 --- a/app/webapi/server.py +++ b/app/webapi/server.py @@ -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: diff --git a/app/webserver/__init__.py b/app/webserver/__init__.py new file mode 100644 index 00000000..3aa403f3 --- /dev/null +++ b/app/webserver/__init__.py @@ -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) diff --git a/app/webserver/payments.py b/app/webserver/payments.py new file mode 100644 index 00000000..61a19dcc --- /dev/null +++ b/app/webserver/payments.py @@ -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 diff --git a/app/webserver/telegram.py b/app/webserver/telegram.py new file mode 100644 index 00000000..d31d20ac --- /dev/null +++ b/app/webserver/telegram.py @@ -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 diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py new file mode 100644 index 00000000..31b79351 --- /dev/null +++ b/app/webserver/unified_app.py @@ -0,0 +1,163 @@ +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) + + @app.get("/health") + 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 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 5a4444c4..0ad610be 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5a4444c4..0ad610be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 79e2d298..01e2b84d 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -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://:/docs`. diff --git a/install_bot.sh b/install_bot.sh index 9fd486d7..8f3614fd 100755 --- a/install_bot.sh +++ b/install_bot.sh @@ -625,23 +625,18 @@ configure_webhook_proxy() { echo -e "${CYAN}ℹ Используем домен: ${YELLOW}$webhook_domain${NC}" >&2 cat < 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: @@ -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: diff --git a/tests/external/test_webhook_server.py b/tests/external/test_webhook_server.py deleted file mode 100644 index aaae1e2f..00000000 --- a/tests/external/test_webhook_server.py +++ /dev/null @@ -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() diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py new file mode 100644 index 00000000..70543e8c --- /dev/null +++ b/tests/webserver/test_payments.py @@ -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 diff --git a/tests/webserver/test_telegram.py b/tests/webserver/test_telegram.py new file mode 100644 index 00000000..958eb401 --- /dev/null +++ b/tests/webserver/test_telegram.py @@ -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 diff --git a/tests/webserver/test_unified_app.py b/tests/webserver/test_unified_app.py new file mode 100644 index 00000000..d135eee6 --- /dev/null +++ b/tests/webserver/test_unified_app.py @@ -0,0 +1,121 @@ +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" + ) + + 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, + ) + + +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" From 54bfff0ed6d8ad7c6e487f5bb8631e52851d0a34 Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 6 Nov 2025 17:28:06 +0300 Subject: [PATCH 2/8] Revert "Restore FastAPI docs endpoints for webhook mode" --- .env.example | 22 - Dockerfile | 4 +- README.md | 351 ++++++++------- SECURITY.md | 5 +- app/config.py | 65 +-- app/webapi/server.py | 4 +- app/webserver/__init__.py | 11 - app/webserver/payments.py | 596 -------------------------- app/webserver/telegram.py | 250 ----------- app/webserver/unified_app.py | 163 ------- docker-compose.local.yml | 8 +- docker-compose.yml | 8 +- docs/web-admin-integration.md | 4 +- install_bot.sh | 41 +- main.py | 303 ++++++++----- tests/external/test_webhook_server.py | 137 ++++++ tests/webserver/test_payments.py | 191 --------- tests/webserver/test_telegram.py | 338 --------------- tests/webserver/test_unified_app.py | 121 ------ 19 files changed, 550 insertions(+), 2072 deletions(-) delete mode 100644 app/webserver/__init__.py delete mode 100644 app/webserver/payments.py delete mode 100644 app/webserver/telegram.py delete mode 100644 app/webserver/unified_app.py create mode 100644 tests/external/test_webhook_server.py delete mode 100644 tests/webserver/test_payments.py delete mode 100644 tests/webserver/test_telegram.py delete mode 100644 tests/webserver/test_unified_app.py diff --git a/.env.example b/.env.example index c1d4f610..3ebadd30 100644 --- a/.env.example +++ b/.env.example @@ -188,8 +188,6 @@ 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) @@ -275,8 +273,6 @@ 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 @@ -353,7 +349,6 @@ 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 @@ -454,20 +449,3 @@ 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 diff --git a/Dockerfile b/Dockerfile index 477c1ba0..7dc16c78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \ BUILD_DATE=${BUILD_DATE} \ VCS_REF=${VCS_REF} -EXPOSE 8080 +EXPOSE 8080 8081 8082 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:8080/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1 CMD ["python", "main.py"] diff --git a/README.md b/README.md index cd849b2e..87bb8331 100644 --- a/README.md +++ b/README.md @@ -122,67 +122,35 @@ docker compose logs --- -## 🌐 Настройка webhook-режима и обратного прокси +## 🌐 Настройка обратного прокси и доменов -> Встроенный FastAPI-сервер теперь обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт. +> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) должны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса портов наружу. -### ♻️ Миграция со старой схемы (несколько портов) +### 1. Планирование доменов и переменных окружения -Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему: +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 + ``` -1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN`. -2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `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` и `curl http://localhost:8080/health/telegram-webhook`. Убедитесь, что в логах нет ошибок регистрации webhook. +### 2. Общая Docker-сеть для бота и прокси -После миграции старые контейнеры/сервисы для отдельных вебхуков можно удалить. - -### 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`. +`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно: ```bash +# Убедиться, что сеть существует docker network ls | grep bot_network || docker network create bot_network + +# Подключить прокси (если контейнер уже запущен отдельно) docker network connect bot_network ``` -Если прокси стартует отдельным compose-файлом, объявите сеть внешней: +Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю: ```yaml networks: @@ -190,139 +158,178 @@ networks: external: true ``` -### 4. Проверка здоровья +### 3. Ручная установка Caddy в Docker -Статические файлы миниапки автоматически монтируются из каталога `MINIAPP_STATIC_PATH` (по умолчанию `miniapp/`) и доступны по пути `/miniapp/static`. +1. Создайте каталог для конфигурации: + ```bash + mkdir -p ~/caddy + cd ~/caddy + ``` -Проверьте, что единый сервер отвечает: +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 -```bash -curl -s https://bot.example.com/health | jq -``` + volumes: + caddy_data: + caddy_config: -Полезные диагностические endpoints: + networks: + bot_network: + external: true + ``` -- `/health` — агрегированный статус (режим бота, очередь Telegram, наличие миниапки и платежей). -- `/health/telegram-webhook` — состояние очереди Telegram webhook. -- `/health/payment-webhooks` — какие платёжные интеграции активированы. +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} + } + } + ``` -### 5. Swagger и документация +4. Запустите прокси: + ```bash + docker compose -f docker-compose.caddy.yml up -d + ``` -- Включите `WEB_API_DOCS_ENABLED=true`, если нужно открыть Swagger UI и OpenAPI. После перезапуска сервиса станут доступны эндпоинты `/docs`, `/doc` (редирект для обратной совместимости), `/redoc` и `/openapi.json`. -- Не забудьте проксировать эти пути через внешний HTTPS-прокси вместе с остальными эндпоинтами бота. -- В продакшене держите `WEB_API_DOCS_ENABLED=false`, чтобы документация не была публичной. При необходимости включайте временно или защищайте прокси базовой авторизацией/IP-фильтрацией. +### 4. Ручная настройка nginx в Docker -### 6. Пример Caddy-конфига +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 -```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 + networks: + bot_network: + external: true + ``` -volumes: - caddy_data: - caddy_config: +2. Пример `nginx.conf`: + ```nginx + events {} -networks: - bot_network: - external: true -``` + http { + include /etc/nginx/mime.types; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; -`Caddyfile`: + upstream remnawave_bot_hooks { + server remnawave_bot:8081; + } -```caddy -bot.example.com { - encode gzip zstd + upstream remnawave_bot_yookassa { + server remnawave_bot:8082; + } - @config path /app-config.json - header @config Access-Control-Allow-Origin "*" + upstream remnawave_bot_api { + server remnawave_bot:8080; + } - 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 - } - } -} -``` + server { + listen 80; + listen 443 ssl http2; + server_name hooks.example.com; -### 6. Пример nginx-конфига + ssl_certificate /etc/ssl/private/hooks.fullchain.pem; + ssl_certificate_key /etc/ssl/private/hooks.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 + 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; } -networks: - bot_network: - external: true -``` + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } -`nginx.conf`: + server { + listen 80; + listen 443 ssl http2; + server_name miniapp.example.com; -```nginx -events {} + ssl_certificate /etc/ssl/private/miniapp.fullchain.pem; + ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem; -http { - include /etc/nginx/mime.types; - sendfile on; + root /var/www/remnawave-miniapp; + index index.html; - 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; - } - } -} -``` - -Рекомендации: - -- Откройте входящие 80/443 в фаерволе. -- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`. -- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook. + location /miniapp/ { + proxy_pass http://remnawave_bot_api/miniapp/; + } + } + } + ``` --- @@ -342,22 +349,10 @@ http { Подробное пошаговое руководство по запуску административного веб-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`. - ### 📊 Статус серверов в главном меню | Переменная | Описание | Пример | @@ -952,9 +947,9 @@ ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # ID топика для тикет ## 🐛 Устранение неполадок ### 🏥 Health Checks -- **Unified сервер**: `http://localhost:8080/health` -- **Telegram webhook**: `http://localhost:8080/health/telegram-webhook` -- **Платёжные webhooks**: `http://localhost:8080/health/payment-webhooks` +- **Основной**: `http://localhost:8081/health` +- **YooKassa**: `http://localhost:8082/health` +- **Pal24**: `http://localhost:8084/health` ### 🔧 Полезные команды ```bash @@ -989,7 +984,7 @@ docker system prune |----------|-------------|---------| | **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет | | **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL | -| **Webhook не работает** | `curl http://localhost:8080/health/telegram-webhook` | Проверь `WEBHOOK_URL`, прокси и секрет | +| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер | | **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` | | **Корзина не сохраняется** | `docker compose ps redis` | Проверь статус Redis | | **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах | @@ -1277,7 +1272,7 @@ docker compose ps docker compose logs -f bot # Проверка здоровья -curl http://localhost:8080/health +curl http://localhost:8081/health # Использование ресурсов docker stats diff --git a/SECURITY.md b/SECURITY.md index 4a59898a..63b4197b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -136,7 +136,8 @@ logger.debug(f"Webhook payload: {webhook_data}") # Открыть только необходимые порты ufw allow 80/tcp # HTTP (redirect to HTTPS) ufw allow 443/tcp # HTTPS -ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети +ufw deny 8081/tcp # Webhook порты (только через reverse proxy) +ufw deny 8082/tcp ``` ### 📊 Мониторинг безопасности @@ -282,7 +283,7 @@ services: # Не пробрасывайте порты наружу без необходимости ports: - - "127.0.0.1:8080:8080" # Только localhost + - "127.0.0.1:8081:8081" # Только localhost # Read-only root filesystem read_only: true diff --git a/app/config.py b/app/config.py index 8202b90a..025e4719 100644 --- a/app/config.py +++ b/app/config.py @@ -291,7 +291,6 @@ 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" @@ -320,13 +319,6 @@ 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" @@ -1431,62 +1423,7 @@ 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", diff --git a/app/webapi/server.py b/app/webapi/server.py index 5218a04b..6472e2ec 100644 --- a/app/webapi/server.py +++ b/app/webapi/server.py @@ -17,8 +17,8 @@ logger = logging.getLogger(__name__) class WebAPIServer: """Асинхронный uvicorn-сервер для административного API.""" - def __init__(self, app: Optional[object] = None) -> None: - self._app = app or create_web_api_app() + def __init__(self) -> None: + self._app = create_web_api_app() workers = max(1, int(settings.WEB_API_WORKERS or 1)) if workers > 1: diff --git a/app/webserver/__init__.py b/app/webserver/__init__.py deleted file mode 100644 index 3aa403f3..00000000 --- a/app/webserver/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -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) diff --git a/app/webserver/payments.py b/app/webserver/payments.py deleted file mode 100644 index 61a19dcc..00000000 --- a/app/webserver/payments.py +++ /dev/null @@ -1,596 +0,0 @@ -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 diff --git a/app/webserver/telegram.py b/app/webserver/telegram.py deleted file mode 100644 index d31d20ac..00000000 --- a/app/webserver/telegram.py +++ /dev/null @@ -1,250 +0,0 @@ -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 diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py deleted file mode 100644 index 31b79351..00000000 --- a/app/webserver/unified_app.py +++ /dev/null @@ -1,163 +0,0 @@ -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) - - @app.get("/health") - 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 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0ad610be..5a4444c4 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -73,10 +73,16 @@ 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:8080/health\", timeout=5)' || exit 1"] + test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"] interval: 60s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index 0ad610be..5a4444c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,10 +73,16 @@ 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:8080/health\", timeout=5)' || exit 1"] + test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"] interval: 60s timeout: 10s retries: 3 diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 01e2b84d..79e2d298 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -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`, `/doc` (редирект), `/redoc` и `/openapi.json`. В проде лучше `false`. | `false` +| `WEB_API_DOCS_ENABLED` | Включить `/docs` и `/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`, `/doc` (редирект для старых ссылок), `/redoc` и `/openapi.json`. +2. `WEB_API_DOCS_ENABLED=true` — публикует `/docs`, `/redoc` и `/openapi.json`. После изменения значений перезапустите бота. Интерфейс будет доступен по адресу `http://:/docs`. diff --git a/install_bot.sh b/install_bot.sh index 8f3614fd..9fd486d7 100755 --- a/install_bot.sh +++ b/install_bot.sh @@ -625,18 +625,23 @@ configure_webhook_proxy() { echo -e "${CYAN}ℹ Используем домен: ${YELLOW}$webhook_domain${NC}" >&2 cat < 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)}") + 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}" + ) if settings.is_yookassa_enabled(): - webhook_lines.append(f"YooKassa: {_fmt(settings.YOOKASSA_WEBHOOK_PATH)}") + webhook_lines.append( + f"YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}" + ) if settings.is_pal24_enabled(): - webhook_lines.append(f"PayPalych: {_fmt(settings.PAL24_WEBHOOK_PATH)}") + webhook_lines.append( + f"PayPalych: {settings.WEBHOOK_URL}:{settings.PAL24_WEBHOOK_PORT}{settings.PAL24_WEBHOOK_PATH}" + ) if settings.is_wata_enabled(): - 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)}") + webhook_lines.append( + f"WATA: {settings.WEBHOOK_URL}:{settings.WATA_WEBHOOK_PORT}{settings.WATA_WEBHOOK_PATH}" + ) timeline.log_section( "Активные webhook endpoints", @@ -508,6 +536,39 @@ 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: @@ -562,6 +623,30 @@ 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() @@ -571,6 +656,10 @@ 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() @@ -614,13 +703,9 @@ async def main(): except asyncio.CancelledError: pass - 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 webhook_server: + logger.info("ℹ️ Остановка webhook сервера...") + await webhook_server.stop() if web_api_server: try: diff --git a/tests/external/test_webhook_server.py b/tests/external/test_webhook_server.py new file mode 100644 index 00000000..aaae1e2f --- /dev/null +++ b/tests/external/test_webhook_server.py @@ -0,0 +1,137 @@ +"""Тестирование хендлеров 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() diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py deleted file mode 100644 index 70543e8c..00000000 --- a/tests/webserver/test_payments.py +++ /dev/null @@ -1,191 +0,0 @@ -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 diff --git a/tests/webserver/test_telegram.py b/tests/webserver/test_telegram.py deleted file mode 100644 index 958eb401..00000000 --- a/tests/webserver/test_telegram.py +++ /dev/null @@ -1,338 +0,0 @@ -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 diff --git a/tests/webserver/test_unified_app.py b/tests/webserver/test_unified_app.py deleted file mode 100644 index d135eee6..00000000 --- a/tests/webserver/test_unified_app.py +++ /dev/null @@ -1,121 +0,0 @@ -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" - ) - - 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, - ) - - -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" From 9fe7d0592f7d0121b5bfdf773463969d780576fe Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 6 Nov 2025 18:02:08 +0300 Subject: [PATCH 3/8] Expose unified health status alongside admin API --- .env.example | 22 + Dockerfile | 4 +- README.md | 351 +++++++-------- SECURITY.md | 5 +- app/config.py | 65 ++- app/webapi/server.py | 5 +- app/webserver/__init__.py | 11 + app/webserver/payments.py | 596 ++++++++++++++++++++++++++ app/webserver/telegram.py | 250 +++++++++++ app/webserver/unified_app.py | 165 +++++++ docker-compose.local.yml | 8 +- docker-compose.yml | 8 +- docs/web-admin-integration.md | 4 +- install_bot.sh | 41 +- main.py | 309 +++++-------- tests/external/test_webhook_server.py | 137 ------ tests/webserver/test_payments.py | 191 +++++++++ tests/webserver/test_telegram.py | 338 +++++++++++++++ tests/webserver/test_unified_app.py | 148 +++++++ 19 files changed, 2105 insertions(+), 553 deletions(-) create mode 100644 app/webserver/__init__.py create mode 100644 app/webserver/payments.py create mode 100644 app/webserver/telegram.py create mode 100644 app/webserver/unified_app.py delete mode 100644 tests/external/test_webhook_server.py create mode 100644 tests/webserver/test_payments.py create mode 100644 tests/webserver/test_telegram.py create mode 100644 tests/webserver/test_unified_app.py diff --git a/.env.example b/.env.example index 3ebadd30..c1d4f610 100644 --- a/.env.example +++ b/.env.example @@ -188,6 +188,8 @@ TRIBUTE_API_KEY= TRIBUTE_DONATE_LINK= TRIBUTE_WEBHOOK_PATH=/tribute-webhook TRIBUTE_WEBHOOK_HOST=0.0.0.0 +# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска. +# В режиме единого FastAPI сервера оставьте эти значения по умолчанию. TRIBUTE_WEBHOOK_PORT=8081 # YooKassa (https://yookassa.ru) @@ -273,6 +275,8 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here CRYPTOBOT_BASE_URL=https://pay.crypt.bot CRYPTOBOT_TESTNET=false CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook +# Эти порты используются только при ручном запуске старого aiohttp сервера. +# В новой конфигурации все вебхуки обрабатываются через порт 8080. CRYPTOBOT_WEBHOOK_PORT=8081 CRYPTOBOT_DEFAULT_ASSET=USDT CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC @@ -349,6 +353,7 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_STATIC_PATH=miniapp MINIAPP_SERVICE_NAME_EN=Bedolaga VPN MINIAPP_SERVICE_NAME_RU=Bedolaga VPN MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection @@ -449,3 +454,20 @@ LOG_FILE=logs/bot.log DEBUG=false WEBHOOK_URL= WEBHOOK_PATH=/webhook +WEBHOOK_SECRET_TOKEN= +WEBHOOK_DROP_PENDING_UPDATES=true +WEBHOOK_MAX_QUEUE_SIZE=1024 +WEBHOOK_WORKERS=4 +WEBHOOK_ENQUEUE_TIMEOUT=0.1 +WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0 +BOT_RUN_MODE=polling # polling, webhook или both + +# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР ===== +WEB_API_ENABLED=false +WEB_API_HOST=0.0.0.0 +WEB_API_PORT=8080 +WEB_API_ALLOWED_ORIGINS=* +WEB_API_DOCS_ENABLED=false +WEB_API_DEFAULT_TOKEN= +WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token +MINIAPP_STATIC_PATH=miniapp diff --git a/Dockerfile b/Dockerfile index 7dc16c78..477c1ba0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 87bb8331..ef212d6d 100644 --- a/README.md +++ b/README.md @@ -122,35 +122,67 @@ docker compose logs --- -## 🌐 Настройка обратного прокси и доменов +## 🌐 Настройка webhook-режима и обратного прокси -> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) должны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса портов наружу. +> Встроенный FastAPI-сервер теперь обслуживает Telegram webhook, платежные webhooks, административное API и статические файлы миниапки на **одном порту 8080**. Снаружи вы публикуете только HTTPS-прокси, которое проксирует весь трафик на этот порт. -### 1. Планирование доменов и переменных окружения +### ♻️ Миграция со старой схемы (несколько портов) -1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот. -2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол). -3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами: - ```env - WEBHOOK_URL=https://hooks.example.com - WEB_API_ENABLED=true - WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com - MINIAPP_CUSTOM_URL=https://miniapp.example.com - ``` +Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему: -### 2. Общая Docker-сеть для бота и прокси +1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN`. +2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `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 ``` -Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю: +Если прокси стартует отдельным compose-файлом, объявите сеть внешней: ```yaml networks: @@ -158,178 +190,139 @@ 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; +### 6. Пример nginx-конфига - 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; } +```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 - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } +networks: + bot_network: + external: true +``` - server { - listen 80; - listen 443 ssl http2; - server_name miniapp.example.com; +`nginx.conf`: - ssl_certificate /etc/ssl/private/miniapp.fullchain.pem; - ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem; +```nginx +events {} - root /var/www/remnawave-miniapp; - index index.html; +http { + include /etc/nginx/mime.types; + sendfile on; - location /miniapp/ { - proxy_pass http://remnawave_bot_api/miniapp/; - } - } - } - ``` + 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; + } + } +} +``` + +Рекомендации: + +- Откройте входящие 80/443 в фаерволе. +- Если используете Cloudflare/анти-DDoS, разрешите методы `POST` и заголовок `X-Telegram-Bot-Api-Secret-Token`. +- После развёртывания перезапустите бот (`make reload`), чтобы он заново зарегистрировал webhook. --- @@ -349,10 +342,22 @@ networks: Подробное пошаговое руководство по запуску административного веб-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`. + ### 📊 Статус серверов в главном меню | Переменная | Описание | Пример | @@ -947,9 +952,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 +989,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 в платежных системах | @@ -1272,7 +1277,7 @@ docker compose ps docker compose logs -f bot # Проверка здоровья -curl http://localhost:8081/health +curl http://localhost:8080/health/unified # или /health, если административное API отключено # Использование ресурсов docker stats diff --git a/SECURITY.md b/SECURITY.md index 63b4197b..4a59898a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/app/config.py b/app/config.py index 025e4719..8202b90a 100644 --- a/app/config.py +++ b/app/config.py @@ -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", diff --git a/app/webapi/server.py b/app/webapi/server.py index 6472e2ec..b38b7e00 100644 --- a/app/webapi/server.py +++ b/app/webapi/server.py @@ -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", diff --git a/app/webserver/__init__.py b/app/webserver/__init__.py new file mode 100644 index 00000000..3aa403f3 --- /dev/null +++ b/app/webserver/__init__.py @@ -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) diff --git a/app/webserver/payments.py b/app/webserver/payments.py new file mode 100644 index 00000000..61a19dcc --- /dev/null +++ b/app/webserver/payments.py @@ -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 diff --git a/app/webserver/telegram.py b/app/webserver/telegram.py new file mode 100644 index 00000000..d31d20ac --- /dev/null +++ b/app/webserver/telegram.py @@ -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 diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py new file mode 100644 index 00000000..d58b454f --- /dev/null +++ b/app/webserver/unified_app.py @@ -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 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 5a4444c4..0ad610be 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 5a4444c4..0ad610be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 79e2d298..01e2b84d 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -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://:/docs`. diff --git a/install_bot.sh b/install_bot.sh index 9fd486d7..8f3614fd 100755 --- a/install_bot.sh +++ b/install_bot.sh @@ -625,23 +625,18 @@ configure_webhook_proxy() { echo -e "${CYAN}ℹ Используем домен: ${YELLOW}$webhook_domain${NC}" >&2 cat < 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: diff --git a/tests/external/test_webhook_server.py b/tests/external/test_webhook_server.py deleted file mode 100644 index aaae1e2f..00000000 --- a/tests/external/test_webhook_server.py +++ /dev/null @@ -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() diff --git a/tests/webserver/test_payments.py b/tests/webserver/test_payments.py new file mode 100644 index 00000000..70543e8c --- /dev/null +++ b/tests/webserver/test_payments.py @@ -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 diff --git a/tests/webserver/test_telegram.py b/tests/webserver/test_telegram.py new file mode 100644 index 00000000..958eb401 --- /dev/null +++ b/tests/webserver/test_telegram.py @@ -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 diff --git a/tests/webserver/test_unified_app.py b/tests/webserver/test_unified_app.py new file mode 100644 index 00000000..7404a507 --- /dev/null +++ b/tests/webserver/test_unified_app.py @@ -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" From 8b9c1d1cc632f3b776f498dcdb51aeb79b18eff1 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 7 Nov 2025 01:01:33 +0300 Subject: [PATCH 4/8] Fix trial notification promo group loading --- app/services/monitoring_service.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index c4d248d1..1cad6f98 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -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, From 3a0e558b6f9e3325cbf415a764db157bd795ec3b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 7 Nov 2025 01:34:05 +0300 Subject: [PATCH 5/8] Update README.md --- README.md | 273 +++++------------------------------------------------- 1 file changed, 23 insertions(+), 250 deletions(-) diff --git a/README.md b/README.md index ef212d6d..4949d491 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,8 @@ docker compose logs Если бот уже развернут в режиме polling или с отдельными контейнерами для `payments-webhook`, выполните переход на единую схему: -1. **Обновите `.env`:** установите `BOT_RUN_MODE=webhook` (или `both` для гибридного режима), задайте `WEBHOOK_URL`, `WEBHOOK_PATH` и обязательно сгенерируйте собственный `WEBHOOK_SECRET_TOKEN`. -2. **Проверьте `docker-compose.*`:** оставьте публикацию только порта `8080` у сервиса `bot`. Все значения `*_WEBHOOK_PORT` теперь используются лишь для обратной совместимости и могут быть удалены. +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. @@ -259,6 +259,11 @@ bot.example.com { } ``` +#### + +Либо вместо `remnawave_bot:8080` используйте `localhost:8080` + + ### 6. Пример nginx-конфига ```yaml @@ -318,6 +323,10 @@ http { } ``` +#### + +Либо вместо `remnawave_bot:8080` используйте `localhost:8080` + Рекомендации: - Откройте входящие 80/443 в фаерволе. @@ -333,10 +342,10 @@ http { | Настройка | Где взять | Пример | |-----------|-----------|---------| | 🤖 **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) ### 🌐 Интеграция веб-админки @@ -468,211 +477,6 @@ MONITORING_INTERVAL=60 REDIS_URL=redis://redis:6379/0 ``` -
-🔧 Полная конфигурация .env - -```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 -``` - -
- --- #### ⭐ Функционал @@ -703,6 +507,7 @@ LOG_FILE=logs/bot.log - 🔔 Уведомления об истечении и автоконверсия - 💎 Автовыдача бонусов за кампании и инвайты - 🛡️ Контроль обязательной подписки на канал (отключает подписку при отписке) +- 💎 Автовыдача сквада из пула выбранных 💰 **Платежи и баланс** - ⭐ Telegram Stars @@ -733,7 +538,7 @@ LOG_FILE=logs/bot.log - 💬 Быстрые ссылки на поддержку 🧩 **Бонусы и промо** -- 🎫 Промокоды на деньги, дни, триал подписку +- 🎫 Промокоды на деньги, дни, триал подписку, промогруппу - 🎁 **Персональные промо-предложения** от админов - 💰 **Тестовый доступ к серверам** через промо-акции - 💸 **Автоматические скидки** при оплате и автопродлении @@ -742,7 +547,7 @@ LOG_FILE=logs/bot.log - 🔗 Генерация реферальных ссылок и QR кодов 💎 **Промо-группы и скидки** -- 🏷️ **Система промогрупп** с индивидуальными скидками +- 🏷️ **Система промогрупп** с индивидуальными скидками с приоритетами - 💰 Скидки на серверы, трафик и устройства - 📊 **Скидочные уровни за траты** - прозрачная система лояльности - 📈 Автоматическое повышение уровня при достижении порога @@ -809,7 +614,6 @@ LOG_FILE=logs/bot.log - 💬 Автоматические сообщения о задолженностях 🧰 **Обслуживание и DevOps** -- 🛠️ `install_bot.sh` - **интерактивное меню управления** - 🚧 Ручной и авто-режим техработ - 🗒️ Просмотр системных логов и health-check - 🔄 **Автосинхронизация Remnawave** по расписанию и при старте бота @@ -850,8 +654,9 @@ LOG_FILE=logs/bot.log - 🧠 **Асинхронная архитектура** - aiogram 3, PostgreSQL/SQLite, Redis и очереди задач - 🌐 **Мультиязычность** - локализации RU/EN, быстрый выбор языка - 📦 **Интеграция с Remnawave API** - автоматическое создание пользователей и синхронизация -- 🔄 **Миграция сквадов** - массовый перенос пользователей между серверами +- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами - 🧾 **История операций** - хранение всех транзакций и действий для аудита +- 💸 **Сервис автопрвоерки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч ### 🌐 Веб-API и мини-приложение @@ -859,7 +664,7 @@ LOG_FILE=logs/bot.log - 🔑 **Управление API-ключами** - выпуск, отзыв, реактивация токенов - 🛰️ **Mini App** - полноценный личный кабинет внутри Telegram - 💳 **Интегрированные платежи** в Mini App (Stars, Pal24, YooKassa, WATA) -- 🧭 **App Config** - централизованная раздача ссылок на клиенты +- 🧭 **Единый стандартный app-config.json** - централизованная раздача ссылок на клиенты - 🪝 **Платёжные вебхуки** - встроенные серверы для всех платёжных систем - 📡 **Мониторинг серверов** - REST-эндпоинты для просмотра нод и статистики @@ -1073,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)** - защита панели @@ -1164,23 +969,8 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ### 🚧 **В разработке** - 🌎 **Веб-панель** - полноценная административная панель -- 📊 **Расширенная аналитика** - больше метрик и графиков - 🔄 **API для интеграций** - подключение внешних сервисов -- 🎨 **Темы оформления** - кастомизация интерфейса Mini App - -### ✅ **Недавно добавлено** - -- 💳 **WATA** - оплата банковскими картами -- 🔄 **Автосинхронизация Remnawave** - фоновая синхронизация серверов -- 🛒 **Умная корзина** - сохранение параметров подписки -- 🏗️ **Модульная архитектура** - подписок и платежей -- 🖥️ **Полноценный личный кабинет** в Mini App -- 💎 **Промо-группы и скидочные уровни** - система лояльности -- 🎁 **Персональные промо-предложения** - таргетированные акции -- 📄 **Система управления контентом** - политика, оферта, FAQ -- 🎫 **Система тикетов** - поддержка пользователей -- 📊 **Мониторинг серверов** - интеграция с XrayChecker -- 🛡️ **Защита от блокировок** - антифрод система +- 🎨 **Темы оформления** - Новая тема интерфейса Mini App by https://t.me/arpicme --- @@ -1236,8 +1026,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD # Автоустановка (рекомендуется) 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 @@ -1246,26 +1034,11 @@ 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. 📦 Восстановить из бэкапа +docker compose up -d --build ``` ### 📊 Мониторинг From 9ee39d5f71f77064cc80f15fbee0a83b21e876bb Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 7 Nov 2025 01:36:17 +0300 Subject: [PATCH 6/8] Update README.md --- README.md | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/README.md b/README.md index 4949d491..9056e984 100644 --- a/README.md +++ b/README.md @@ -1019,45 +1019,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD --- -## 🔄 Быстрые команды - -### 📦 Установка и запуск -```bash -# Автоустановка (рекомендуется) -git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git -cd remnawave-bedolaga-telegram-bot - -# Ручной запуск -docker compose up -d -docker compose logs -f -``` - -### 🔄 Обновление -```bash -# Ручное обновление -git pull -docker compose down -docker compose pull -docker compose up -d --build -``` - -### 📊 Мониторинг -```bash -# Статус сервисов -docker compose ps - -# Логи бота -docker compose logs -f bot - -# Проверка здоровья -curl http://localhost:8080/health/unified # или /health, если административное API отключено - -# Использование ресурсов -docker stats -``` - ---- - ## 📈 Статистика проекта
From 3624832ed8358275e1b753a25ebb6dc34623c3a0 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 7 Nov 2025 01:37:06 +0300 Subject: [PATCH 7/8] Update README.md --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 9056e984..b62adbd7 100644 --- a/README.md +++ b/README.md @@ -1041,8 +1041,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ## 🎯 Ключевые особенности в цифрах -
- | Метрика | Значение | |---------|----------| | 💳 **Платёжных систем** | 8 (Stars, YooKassa, Tribute, CryptoBot, Heleket, MulenPay, Pal24, WATA) | @@ -1054,8 +1052,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD | 🛡️ **Методов авторизации** | 4 (API Key, Bearer, Basic Auth, eGames) | | 🗄️ **Способов хранения** | 2 (PostgreSQL, SQLite) с автовыбором | -
- --- ## 🔥 Почему выбирают Bedolaga? From 37c97de5d6eb268c3ae388185580a950c08f228f Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 7 Nov 2025 01:37:42 +0300 Subject: [PATCH 8/8] Update README.md --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index b62adbd7..c7ed08b8 100644 --- a/README.md +++ b/README.md @@ -974,8 +974,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD --- -
- ## 📄 Лицензия Проект распространяется под лицензией **MIT** @@ -1021,8 +1019,6 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD ## 📈 Статистика проекта -
- ![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)