M1: bot webhook delivery - secret-token aiohttp server, /healthz, mode switch, TLS-edge routing (#001, #002)
This commit is contained in:
parent
e73430a078
commit
a1e119ce6e
15 changed files with 698 additions and 31 deletions
|
|
@ -26,3 +26,27 @@ API_URL=
|
|||
# If you point this at an external Redis, it must be reachable over a private
|
||||
# network/VPN; never expose Redis to the public internet.
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ── Update delivery mode ─────────────────────────────────────────────────────
|
||||
# polling (default) or webhook. In webhook mode the bot receives Telegram
|
||||
# updates at a secret-derived path on the webhook port and serves GET /healthz.
|
||||
# See docs/DEPLOY.md §14.4 for the full setup (edge routing, path derivation).
|
||||
BOT_UPDATE_MODE=polling
|
||||
|
||||
# Required when BOT_UPDATE_MODE=webhook:
|
||||
# BOT_WEBHOOK_PUBLIC_BASE_URL — public https base URL of the TLS edge that
|
||||
# routes /tg-webhook/<secret-derived-path> to this server, no trailing
|
||||
# slash (e.g. https://contract-check.example.com).
|
||||
# BOT_WEBHOOK_SECRET_TOKEN — random string Telegram echoes back in the
|
||||
# X-Telegram-Bot-Api-Secret-Token header (chars: A-Z a-z 0-9 _ -, max
|
||||
# 256). Generate with: openssl rand -hex 32
|
||||
# It also keys the secret-derived webhook path (`make bot-webhook-path`).
|
||||
BOT_WEBHOOK_PUBLIC_BASE_URL=
|
||||
BOT_WEBHOOK_SECRET_TOKEN=
|
||||
|
||||
# Host port publishing for the webhook server (webhook mode only).
|
||||
# The compose file binds it to 127.0.0.1 by default — route it through your
|
||||
# TLS edge on the same host, or set BOT_WEBHOOK_BIND_HOST to a private/VPN
|
||||
# interface. Never expose the webhook port to the public internet directly.
|
||||
BOT_WEBHOOK_BIND_HOST=127.0.0.1
|
||||
BOT_WEBHOOK_PORT=8080
|
||||
|
|
|
|||
|
|
@ -194,3 +194,11 @@ BOT_SERVICE_TOKEN= # bearer looked up against service_tokens.name="bo
|
|||
API_URL=http://api:8000 # base URL of the api service. In compose this is the api container.
|
||||
# For a bot running on a separate server point this at the public
|
||||
# API endpoint, e.g. https://contract-check.example.com (no trailing slash).
|
||||
|
||||
# Update delivery mode: polling (default) or webhook. Webhook mode serves
|
||||
# updates at a secret-derived path + GET /healthz on the bot container; the
|
||||
# edge profile proxies BOT_WEBHOOK_PATH to it. Full guide: docs/DEPLOY.md §14.4.
|
||||
BOT_UPDATE_MODE=polling
|
||||
BOT_WEBHOOK_PUBLIC_BASE_URL= # required when BOT_UPDATE_MODE=webhook, e.g. https://contract-check.example.com
|
||||
BOT_WEBHOOK_SECRET_TOKEN= # required when BOT_UPDATE_MODE=webhook; openssl rand -hex 32 (chars: A-Za-z0-9_-)
|
||||
BOT_WEBHOOK_PATH=/tg-webhook/change-me # derived path for the nginx edge; print the real one with `make bot-webhook-path`
|
||||
|
|
|
|||
7
Makefile
7
Makefile
|
|
@ -9,7 +9,7 @@
|
|||
observer-up observer-down observer-logs \
|
||||
nginx-up nginx-down nginx-logs nginx-ps \
|
||||
api api-logs bot bot-up bot-down bot-logs bot-ps worker-extract worker-analyze worker-notify \
|
||||
bot-remote-up bot-remote-down bot-remote-logs bot-remote-ps \
|
||||
bot-remote-up bot-remote-down bot-remote-logs bot-remote-ps bot-webhook-path \
|
||||
seed-token jwt-secret jwt-token jwt-verify health shell-api shell-bot \
|
||||
shell-db admin-promote admin-list clean dev dev-obs stop stop-obs
|
||||
|
||||
|
|
@ -198,6 +198,11 @@ bot-remote-logs: ## Tail remote bot logs
|
|||
bot-remote-ps: ## Show remote bot container status
|
||||
docker compose -f docker-compose.bot.yml ps
|
||||
|
||||
bot-webhook-path: ## Print the secret-derived Telegram webhook path (reads BOT_TOKEN + BOT_WEBHOOK_SECRET_TOKEN from .env)
|
||||
@uv run python -c "import os; from dotenv import load_dotenv; load_dotenv(); \
|
||||
from src.contract_check.bot.webhook import derive_webhook_path; \
|
||||
print(derive_webhook_path(os.environ['BOT_TOKEN'], os.environ['BOT_WEBHOOK_SECRET_TOKEN']))"
|
||||
|
||||
worker-extract: ## Start/restart extract worker
|
||||
docker compose --profile services up -d --build --remove-orphans worker-extract
|
||||
|
||||
|
|
|
|||
|
|
@ -108,6 +108,29 @@ server {
|
|||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Telegram bot webhook (bot service, profile `bot`, webhook update mode).
|
||||
# The path is secret-derived (HMAC of BOT_WEBHOOK_SECRET_TOKEN keyed by
|
||||
# BOT_TOKEN — print it with `make bot-webhook-path`) and served ONLY
|
||||
# through this TLS edge. The bot itself additionally validates the
|
||||
# X-Telegram-Bot-Api-Secret-Token header and answers 403 on mismatch, so
|
||||
# the path alone never grants access. Default keeps the location valid
|
||||
# while the bot runs in polling mode.
|
||||
location ${BOT_WEBHOOK_PATH} {
|
||||
# Variable forces dynamic DNS resolution for `bot`.
|
||||
set $bot http://bot:8080;
|
||||
proxy_pass $bot;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
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_connect_timeout 10s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Grafana / Loki: exposed under /grafana so everything lives on one domain.
|
||||
# In production restrict access here or via firewall (basic auth / VPN / SSO).
|
||||
location /grafana/ {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
# Telegram bot adapter — run on a separate server.
|
||||
#
|
||||
# The bot is deliberately stateless: it polls Telegram and talks to the central
|
||||
# The bot is deliberately stateless: it talks to Telegram and the central
|
||||
# API over HTTP(S). It needs NO DB/MQ/S3 credentials, only BOT_TOKEN,
|
||||
# BOT_SERVICE_TOKEN and API_URL. Keep this server lean and firewall-hardened.
|
||||
#
|
||||
# Update mode: polling by default; set BOT_UPDATE_MODE=webhook to serve
|
||||
# updates at a secret-derived path (+ GET /healthz) on the webhook port.
|
||||
# The port is published on loopback only — route it through your TLS edge
|
||||
# (or a VPN interface via BOT_WEBHOOK_BIND_HOST), never the public internet.
|
||||
# See docs/DEPLOY.md §14.4.
|
||||
#
|
||||
# Usage on the bot server:
|
||||
# cp .env.bot.example .env # minimal bot-only environment
|
||||
# docker compose -f docker-compose.bot.yml up -d --build
|
||||
|
|
@ -27,17 +33,33 @@ services:
|
|||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
API_URL: ${API_URL}
|
||||
BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN}
|
||||
# Update delivery mode + webhook settings (BOT_UPDATE_MODE=polling|webhook).
|
||||
BOT_UPDATE_MODE: ${BOT_UPDATE_MODE:-polling}
|
||||
BOT_WEBHOOK_PUBLIC_BASE_URL: ${BOT_WEBHOOK_PUBLIC_BASE_URL:-}
|
||||
BOT_WEBHOOK_SECRET_TOKEN: ${BOT_WEBHOOK_SECRET_TOKEN:-}
|
||||
# Local Redis for rate-limit state. Set to empty to use the in-memory
|
||||
# backend (fine for a single bot instance). The Redis container below is
|
||||
# not exposed outside the host; only the bot container can reach it.
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
ports:
|
||||
# Webhook/healthz server, only meaningful in webhook mode. Published on
|
||||
# loopback by default so only a local TLS edge / tunnel can reach it;
|
||||
# use BOT_WEBHOOK_BIND_HOST to point it at a private interface instead.
|
||||
- "${BOT_WEBHOOK_BIND_HOST:-127.0.0.1}:${BOT_WEBHOOK_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
# Webhook mode: probe the bot's own /healthz (the process under
|
||||
# supervision). Polling mode: keep probing the central API as before.
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "python -c \"import urllib.request, os; urllib.request.urlopen(os.environ['API_URL'] + '/healthz', timeout=5)\""
|
||||
- >-
|
||||
if [ "$$BOT_UPDATE_MODE" = "webhook" ]; then
|
||||
python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=5)";
|
||||
else
|
||||
python -c "import urllib.request, os; urllib.request.urlopen(os.environ['API_URL'] + '/healthz', timeout=5)";
|
||||
fi
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -316,6 +316,13 @@ services:
|
|||
BOT_TOKEN: ${BOT_TOKEN:-}
|
||||
API_URL: ${API_URL:-http://api:8000}
|
||||
BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN:-}
|
||||
# Update delivery: polling (default) or webhook. In webhook mode the bot
|
||||
# serves updates + GET /healthz on port 8080 inside the container; the
|
||||
# edge profile proxies the secret path (BOT_WEBHOOK_PATH) here.
|
||||
# See docs/DEPLOY.md §14.4.
|
||||
BOT_UPDATE_MODE: ${BOT_UPDATE_MODE:-polling}
|
||||
BOT_WEBHOOK_PUBLIC_BASE_URL: ${BOT_WEBHOOK_PUBLIC_BASE_URL:-}
|
||||
BOT_WEBHOOK_SECRET_TOKEN: ${BOT_WEBHOOK_SECRET_TOKEN:-}
|
||||
|
||||
# ── EDGE (profile: edge) ──────────────────────────────────────────────────
|
||||
# Reverse proxy + TLS terminator. Listens on 80/443 and forwards
|
||||
|
|
@ -347,6 +354,10 @@ services:
|
|||
NGINX_ENVSUBST_TEMPLATE_SUFFIX: .template
|
||||
# Passed to the template so the OpenObserve subpath can be changed in one place.
|
||||
OPENOBSERVE_BASE_URI: ${OPENOBSERVE_BASE_URI:-/openobserve}
|
||||
# Secret-derived Telegram bot webhook path (print with `make
|
||||
# bot-webhook-path`). Proxied to the bot service inside the TLS edge;
|
||||
# the placeholder default keeps the config valid in polling mode.
|
||||
BOT_WEBHOOK_PATH: ${BOT_WEBHOOK_PATH:-/tg-webhook/change-me}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "--no-check-certificate", "http://localhost/healthz"]
|
||||
interval: 10s
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ DealDocumentScreening/
|
|||
│ └── bot/ (new — aiogram adapter, HTTP-only)
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py
|
||||
│ ├── config.py (BotSettings: BOT_TOKEN, API_URL, BOT_SERVICE_TOKEN, poll tuning)
|
||||
│ ├── config.py (BotSettings: BOT_TOKEN, API_URL, BOT_SERVICE_TOKEN, poll tuning, update-mode/webhook settings)
|
||||
│ ├── client.py (ApiClient: typed httpx wrapper; ApiError/NoCreditsError/...)
|
||||
│ └── handlers.py (/start, doc upload→POST api, poll→send report)
|
||||
└── tests/
|
||||
|
|
@ -294,10 +294,11 @@ DealDocumentScreening/
|
|||
|
||||
- `core/*` may import anything.
|
||||
- `api/*`, `worker_extract/*`, `worker_prescreen/*`, `worker_analyze/*` import only `core/*`.
|
||||
- `bot/*` imports only `httpx` + its own modules. **It must NOT import
|
||||
`core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`.** It speaks to
|
||||
the api over HTTP only. (Add a ruff/flake8 import-forbidden rule or a unit
|
||||
test that asserts this.)
|
||||
- `bot/*` imports only `httpx` (outbound to the api), `aiohttp` (inbound
|
||||
webhook-mode transport, aiogram's HTTP engine) + its own modules. **It must
|
||||
NOT import `core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`.** It
|
||||
speaks to the api over HTTP only. (Add a ruff/flake8 import-forbidden rule
|
||||
or a unit test that asserts this.)
|
||||
|
||||
|
||||
---
|
||||
|
|
@ -1170,6 +1171,10 @@ None of 1–5 requires touching `core/` application code — only compose/infra.
|
|||
| `BOT_TOKEN` | (required) |
|
||||
| `API_URL` | `http://api:8000` |
|
||||
| `BOT_SERVICE_TOKEN` | (required — bearer for adapter auth to `/api/v1/auth/telegram/bot`, looked up against `service_tokens`) |
|
||||
| `BOT_UPDATE_MODE` | `polling` — `webhook` runs the aiohttp server from `bot/webhook.py`: secret-path updates (403 on `X-Telegram-Bot-Api-Secret-Token` mismatch) + `GET /healthz`; registers/deregisters the Telegram webhook on start/stop |
|
||||
| `BOT_WEBHOOK_PUBLIC_BASE_URL` | (empty; required in webhook mode) public https base URL of the TLS edge, no trailing slash |
|
||||
| `BOT_WEBHOOK_SECRET_TOKEN` | (empty; required in webhook mode) `A-Za-z0-9_-`, max 256; keys both the header check and the HMAC-derived path |
|
||||
| `BOT_WEBHOOK_LISTEN_HOST` / `BOT_WEBHOOK_LISTEN_PORT` | `0.0.0.0` / `8080` — webhook server bind (inside the container) |
|
||||
|
||||
### Prototype (standalone benchmark image)
|
||||
|
||||
|
|
@ -1619,6 +1624,14 @@ aiogram 3. Pure HTTP client to the api. **Forbidden imports**: `core.db`,
|
|||
`core.s3`, `core.llm`, `core.mq`, `core.credits`, `sqlalchemy`, `minio`,
|
||||
`aio_pika`, `pymupdf`. Enforced by a unit test (§14).
|
||||
|
||||
Update delivery is config-driven (`BOT_UPDATE_MODE`, §11): long-polling by
|
||||
default; in webhook mode `bot/webhook.py` runs a small aiohttp server —
|
||||
secret-derived path (`/tg-webhook/<HMAC-SHA256(secret, key=token)[:32]>`),
|
||||
403 on `X-Telegram-Bot-Api-Secret-Token` mismatch before the dispatcher,
|
||||
`GET /healthz`, `set_webhook(drop_pending_updates)` on startup and
|
||||
`delete_webhook` on graceful shutdown. Transport only; the api boundary is
|
||||
unchanged (docs/DEPLOY.md §14.4).
|
||||
|
||||
Flow:
|
||||
|
||||
- `/start` → `GET /api/v1/me` → greeting + credit balance.
|
||||
|
|
|
|||
124
docs/DEPLOY.md
124
docs/DEPLOY.md
|
|
@ -356,11 +356,10 @@ docker compose exec postgres psql -U contract_check -d contract_check \
|
|||
|
||||
1. Напишите `@BotFather` → `/newbot`
|
||||
2. Скопируйте токен (`BOT_TOKEN`) в `.env`
|
||||
3. Установите webhook (опционально, polling работает по умолчанию):
|
||||
```bash
|
||||
curl -F "url=https://your-domain.com/webhook" \
|
||||
https://api.telegram.org/bot$BOT_TOKEN/setWebhook
|
||||
```
|
||||
3. Режим доставки updates выбирается переменной `BOT_UPDATE_MODE`:
|
||||
по умолчанию `polling` (ничего настраивать не нужно). Для webhook-режима
|
||||
смотрите §14.4 — вручную вызывать `setWebhook` через curl не нужно, бот
|
||||
сам регистрирует webhook на старте и снимает его при остановке.
|
||||
|
||||
### 7.2 Запуск
|
||||
|
||||
|
|
@ -569,9 +568,11 @@ docker compose --profile bot logs -f bot
|
|||
# Если бот на отдельном сервере — смотрите логи там:
|
||||
# docker compose -f docker-compose.bot.yml logs -f
|
||||
|
||||
# Проверка polling:
|
||||
# Бот использует polling по умолчанию (aiogram). Если webhook установлен —
|
||||
# убедитесь, что Nginx проксирует /webhook к api:8000.
|
||||
# Проверка режима доставки:
|
||||
# Бот использует polling по умолчанию (BOT_UPDATE_MODE не задан или polling).
|
||||
# В webhook-режиме проверьте: /healthz на ботовом сервере отвечает 200,
|
||||
# edge маршрутизирует BOT_WEBHOOK_PATH на бот, секретный заголовок совпадает
|
||||
# (см. §14.4.3).
|
||||
```
|
||||
|
||||
## 13. Edge proxy (Nginx + certbot)
|
||||
|
|
@ -631,6 +632,7 @@ curl https://contract-check.example.com/healthz
|
|||
- `/healthz`, `/readyz`
|
||||
- `/metrics` (открыт наружу; закройте файрволом или уберите приватный скрейпинг)
|
||||
- `/webhook/*` → `/api/v1/webhooks/`
|
||||
- `${BOT_WEBHOOK_PATH}` → `bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4)
|
||||
- `/grafana/*` → `grafana:3000` (когда поднят профиль `obs`)
|
||||
|
||||
Grafana под путём `/grafana`:
|
||||
|
|
@ -699,11 +701,15 @@ docker compose down -v
|
|||
|
||||
| Направление | Протокол | Порт | Комментарий |
|
||||
|---|---|---|---|
|
||||
| bot → Telegram | outbound HTTPS | 443 | Polling updates от `api.telegram.org` |
|
||||
| bot → Telegram | outbound HTTPS | 443 | Polling updates / API-вызовы `api.telegram.org`; в webhook-режиме — только API-вызовы |
|
||||
| bot → API | HTTPS (рекомендуется) | 443 / 8000 | `API_URL` без trailing slash, например `https://contract-check.example.com` |
|
||||
| bot → Redis (опц.) | Redis | 6379 | Только внутри приватной сети/VPN; иначе оставьте `REDIS_URL` пустым |
|
||||
| edge → bot (webhook-режим) | HTTP | 8080 | Только с TLS-edge (nginx) на приватном интерфейсе/loopback; наружу порт не публикуется |
|
||||
|
||||
На firewall ботового сервера открывайте только **outbound** 443 (и 22 для SSH). Входящих портов бот не требует.
|
||||
На firewall ботового сервера открывайте только **outbound** 443 (и 22 для SSH).
|
||||
В polling-режиме входящих портов бот не требует. В webhook-режиме порт 8080
|
||||
публикуется compose-файлом только на `127.0.0.1` (см. `BOT_WEBHOOK_BIND_HOST`)
|
||||
— наружу бот доступен исключительно через TLS-edge.
|
||||
|
||||
### 14.2 Подготовка центрального API
|
||||
|
||||
|
|
@ -734,6 +740,7 @@ docker compose --profile services exec api python -m src.contract_check.api seed
|
|||
BOT_SERVICE_TOKEN=bot-prod-secret-xxx # должен совпадать с service_tokens.name='bot-prod'
|
||||
API_URL=https://contract-check.example.com # публичный адрес центрального API, без trailing slash
|
||||
REDIS_URL=redis://redis:6379/0 # локальный Redis из docker-compose.bot.yml
|
||||
BOT_UPDATE_MODE=polling # webhook-режим: см. §14.4
|
||||
```
|
||||
|
||||
`docker-compose.bot.yml` поднимает собственный Redis-контейнер (только для
|
||||
|
|
@ -767,15 +774,99 @@ docker compose --profile services exec api python -m src.contract_check.api seed
|
|||
"import urllib.request, os; print(urllib.request.urlopen(os.environ['API_URL'] + '/healthz').read())"
|
||||
```
|
||||
|
||||
### 14.4 Webhook вместо polling (опционально, для HA)
|
||||
### 14.4 Webhook вместо polling (встроенный режим)
|
||||
|
||||
По умолчанию бот использует **polling**. Два и более инстанса с одним токеном в polling-режиме будут конфликтовать (Telegram отдаёт updates только одному соединению). Для высокой доступности:
|
||||
Бот поддерживает два режима доставки updates — переключение одной переменной:
|
||||
|
||||
1. Переведите бота на webhook: укажите публичный URL, за который отвечает Nginx, и проксируйте запросы на ботовый сервер.
|
||||
2. В боте реализуйте webhook-эндпоинт (не входит в текущую версию; требуется небольшая доработка `bot/__main__.py` и добавление HTTP-сервера, например aiohttp/uvicorn).
|
||||
3. Используйте `docker-compose.bot.yml` с healthcheck и `deploy.replicas`, но за балансировщиком, который направляет Telegram-запросы на одну активную реплику (либо на все, если webhook-эндпоинт идемпотентен и дедуплицирует updates по `update_id`).
|
||||
```bash
|
||||
BOT_UPDATE_MODE=polling # по умолчанию: long-polling (локальная разработка)
|
||||
BOT_UPDATE_MODE=webhook # продакшн: HTTP-сервер + регистрация webhook в Telegram
|
||||
```
|
||||
|
||||
> Для большинства production-развёртываний polling на выделенном ботовом сервере достаточен.
|
||||
В webhook-режиме бот поднимает маленький aiohttp-сервер, который:
|
||||
|
||||
- принимает Telegram updates по секретному пути `/tg-webhook/<digest>` и
|
||||
проверяет заголовок `X-Telegram-Bot-Api-Secret-Token` (сравнение в
|
||||
constant-time); неправильный или отсутствующий токен → **403** до
|
||||
диспетчеризации, поэтому подделанные updates не могут тратить кредиты
|
||||
пользователей;
|
||||
- отвечает `GET /healthz` (200) — compose healthcheck и оркестратор
|
||||
перезапускают умерший процесс;
|
||||
- на старте регистрирует webhook (`set_webhook` с `drop_pending_updates`),
|
||||
при graceful shutdown снимает его (`delete_webhook`) — стейджинг и прод
|
||||
никогда не дерутся за один токен, и на ботовом сервере не остаётся
|
||||
устаревший webhook.
|
||||
|
||||
#### 14.4.1 Переменные окружения webhook-режима
|
||||
|
||||
| Переменная | Обязательна | Описание |
|
||||
|---|---|---|
|
||||
| `BOT_UPDATE_MODE` | — | `polling` (default) / `webhook` |
|
||||
| `BOT_WEBHOOK_PUBLIC_BASE_URL` | в webhook-режиме | Публичный https-базовый URL TLS-edge без trailing slash, напр. `https://contract-check.example.com` |
|
||||
| `BOT_WEBHOOK_SECRET_TOKEN` | в webhook-режиме | Секрет для `X-Telegram-Bot-Api-Secret-Token` и деривации пути. Только `A-Z a-z 0-9 _ -`, до 256 символов; генерируйте `openssl rand -hex 32` |
|
||||
| `BOT_WEBHOOK_BIND_HOST` | — | Куда публиковать порт в `docker-compose.bot.yml` (default `127.0.0.1`); используйте адрес приватного/VPN-интерфейса, не публичный |
|
||||
| `BOT_WEBHOOK_PORT` | — | Хостовый порт webhook-сервера в `docker-compose.bot.yml` (default `8080`; внутри контейнера всегда 8080) |
|
||||
|
||||
Polling-режим не требует ни одной из них и остаётся ровно тем же кодом, что и
|
||||
до появления webhook-режима.
|
||||
|
||||
#### 14.4.2 Секретный путь и маршрутизация через edge
|
||||
|
||||
Путь вебхука вычисляется детерминированно из секретов —
|
||||
HMAC-SHA256(секрет, ключ = токен бота), первые 32 hex-символа:
|
||||
|
||||
```bash
|
||||
make bot-webhook-path
|
||||
# → /tg-webhook/81440d4113c56ead9fa711fce29bc371
|
||||
```
|
||||
|
||||
Знание домена (или чтение шаблона nginx в репозитории) не раскрывает путь.
|
||||
Telegram шлёт updates на `BOT_WEBHOOK_PUBLIC_BASE_URL` + этот путь, и даже
|
||||
угадавший путь получает 403 без правильного секретного заголовка.
|
||||
|
||||
Настройка edge:
|
||||
|
||||
- **Бот на том же хосте, что и стек** (профили `bot` + `edge`): задайте в
|
||||
`.env` `BOT_WEBHOOK_PATH` (значение из `make bot-webhook-path`) — nginx уже
|
||||
умеет проксировать этот путь на `bot:8080` внутри Docker-сети. Порт наружу
|
||||
не публикуется: только 443 у edge.
|
||||
- **Бот на отдельном сервере**: `docker-compose.bot.yml` публикует webhook-порт
|
||||
на `127.0.0.1:${BOT_WEBHOOK_PORT:-8080}`. Поставьте TLS-edge (nginx/caddy)
|
||||
на ботовом серверере, проксируйте секретный путь на `127.0.0.1:8080` и
|
||||
укажите этот домен в `BOT_WEBHOOK_PUBLIC_BASE_URL`. Альтернатива —
|
||||
`BOT_WEBHOOK_BIND_HOST` с адресом интерфейса VPN.
|
||||
|
||||
Минимальный `.env` webhook-режима на ботовом сервере:
|
||||
|
||||
```bash
|
||||
BOT_TOKEN=123456789:ABCDEF...
|
||||
BOT_SERVICE_TOKEN=bot-prod-secret-xxx
|
||||
API_URL=https://contract-check.example.com
|
||||
BOT_UPDATE_MODE=webhook
|
||||
BOT_WEBHOOK_PUBLIC_BASE_URL=https://bot.example.com
|
||||
BOT_WEBHOOK_SECRET_TOKEN=<openssl rand -hex 32>
|
||||
```
|
||||
|
||||
#### 14.4.3 Проверка
|
||||
|
||||
```bash
|
||||
# Healthcheck бота (webhook-режим): compose проверяет сам процесс бота
|
||||
docker compose -f docker-compose.bot.yml ps # STATUS: Up (healthy)
|
||||
|
||||
# Ручная проверка /healthz
|
||||
curl http://127.0.0.1:8080/healthz # → {"status": "ok"}
|
||||
|
||||
# Секретный путь без токена → 403
|
||||
curl -i -X POST https://<edge>/tg-webhook/<digest> # → HTTP/1.1 403 Forbidden
|
||||
```
|
||||
|
||||
Для HA за балансировщиком помните: webhook-эндпоинт обрабатывает update до
|
||||
ответа; при нескольких репликах балансировщик должен направлять запросы
|
||||
Telegram на активную реплику (Telegram сам ретраит неотвеченные updates).
|
||||
|
||||
> Для большинства production-развёртываний polling на выделенном ботовом
|
||||
> сервере достаточен; webhook-режим нужен при ограничительных сетях (где
|
||||
> outbound long-polling нестабилен) и для healthcheck-надзора за процессом.
|
||||
|
||||
### 14.5 Управление и обновление
|
||||
|
||||
|
|
@ -807,6 +898,7 @@ make bot-remote-ps
|
|||
- [ ] Backup cron настроен
|
||||
- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP)
|
||||
- [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS
|
||||
- [ ] Если бот в webhook-режиме: `BOT_UPDATE_MODE=webhook`, `BOT_WEBHOOK_PUBLIC_BASE_URL`/`BOT_WEBHOOK_SECRET_TOKEN` заданы, edge маршрутизирует `BOT_WEBHOOK_PATH` на бота, webhook-порт не опубликован в интернет
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ prescreen = [
|
|||
]
|
||||
bot = [
|
||||
"aiogram>=3.4",
|
||||
# The webhook server imports aiohttp directly (aiogram's HTTP engine);
|
||||
# declared here so the lean bot image stays correct if aiogram ever swaps.
|
||||
"aiohttp>=3.9",
|
||||
"redis>=5.0",
|
||||
]
|
||||
notify = [
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
"""Bot entrypoint: configure logging, wire the api client, start aiogram polling."""
|
||||
"""Bot entrypoint: configure logging, wire the api client, run the update loop.
|
||||
|
||||
Update mode is configuration-driven: `polling` (default, local development)
|
||||
long-polls Telegram; `webhook` (production) runs the aiohttp server from
|
||||
`webhook.py` — secret-token-authenticated updates plus `GET /healthz` — and
|
||||
registers/deregisters the Telegram webhook around its lifetime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -6,11 +12,13 @@ import asyncio
|
|||
import signal
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiohttp import web
|
||||
|
||||
from src.contract_check.bot.client import ApiClient
|
||||
from src.contract_check.bot.config import get_bot_settings
|
||||
from src.contract_check.bot.handlers import router as bot_router
|
||||
from src.contract_check.bot.rate_limit import get_rate_limiter
|
||||
from src.contract_check.bot.webhook import build_webhook_app
|
||||
from src.contract_check.core.logging import bind_context, configure_logging, get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
|
@ -38,7 +46,12 @@ async def main() -> None:
|
|||
dp.include_router(bot_router)
|
||||
|
||||
me = await bot.get_me()
|
||||
log.info("bot_started", username=me.username, api_url=settings.api_url)
|
||||
log.info(
|
||||
"bot_started",
|
||||
username=me.username,
|
||||
api_url=settings.api_url,
|
||||
update_mode=settings.update_mode,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
stop_event = asyncio.Event()
|
||||
|
|
@ -48,17 +61,45 @@ async def main() -> None:
|
|||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
polling = asyncio.create_task(dp.start_polling(bot, handle_signals=False, polling_timeout=30))
|
||||
# Webhook mode: the runner owns registration on startup and deregistration
|
||||
# on cleanup (delete_webhook); polling mode stays exactly as before.
|
||||
runner: web.AppRunner | None = None
|
||||
polling: asyncio.Task | None = None
|
||||
if settings.update_mode == "webhook":
|
||||
runner = web.AppRunner(build_webhook_app(dp, bot, settings))
|
||||
await runner.setup()
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.webhook_listen_host,
|
||||
port=settings.webhook_listen_port,
|
||||
)
|
||||
await site.start()
|
||||
log.info(
|
||||
"webhook_listening",
|
||||
host=settings.webhook_listen_host,
|
||||
port=settings.webhook_listen_port,
|
||||
)
|
||||
else:
|
||||
polling = asyncio.create_task(
|
||||
dp.start_polling(bot, handle_signals=False, polling_timeout=30)
|
||||
)
|
||||
|
||||
stopper = asyncio.create_task(stop_event.wait())
|
||||
|
||||
try:
|
||||
await asyncio.wait({polling, stopper}, return_when=asyncio.FIRST_COMPLETED)
|
||||
if polling is not None:
|
||||
await asyncio.wait({polling, stopper}, return_when=asyncio.FIRST_COMPLETED)
|
||||
else:
|
||||
await stopper
|
||||
finally:
|
||||
if not polling.done():
|
||||
if polling is not None and not polling.done():
|
||||
polling.cancel()
|
||||
if not stopper.done():
|
||||
stopper.cancel()
|
||||
await dp.stop_polling()
|
||||
if runner is not None:
|
||||
await runner.cleanup()
|
||||
else:
|
||||
await dp.stop_polling()
|
||||
await bot.session.close()
|
||||
await api.aclose()
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@ the api.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Telegram restricts webhook secret tokens to 1-256 chars of [A-Za-z0-9_-].
|
||||
_WEBHOOK_SECRET_RE = re.compile(r"^[A-Za-z0-9_-]{1,256}$")
|
||||
|
||||
|
||||
class BotSettings(BaseSettings):
|
||||
"""Telegram-bot adapter settings. See docs/ARCHITECTURE.md §11 (Bot)."""
|
||||
|
|
@ -61,6 +66,52 @@ class BotSettings(BaseSettings):
|
|||
description="Suggest /plans command on 402 if billing is enabled in the api.",
|
||||
)
|
||||
|
||||
# ── Update delivery mode (webhook-report-payments spec, milestone 1) ──────
|
||||
update_mode: Literal["polling", "webhook"] = Field(
|
||||
default="polling",
|
||||
description="How the bot receives Telegram updates: 'polling' (default, "
|
||||
"local development) or 'webhook' (production behind the TLS edge).",
|
||||
)
|
||||
webhook_public_base_url: str = Field(
|
||||
default="",
|
||||
description="Public https base URL the webhook is served under, e.g. "
|
||||
"https://contract-check.example.com (no trailing slash).",
|
||||
)
|
||||
webhook_secret_token: str = Field(
|
||||
default="",
|
||||
description="Value Telegram must echo in X-Telegram-Bot-Api-Secret-Token; "
|
||||
"also keys the secret-derived webhook path. Chars: A-Za-z0-9_- (max 256).",
|
||||
)
|
||||
webhook_listen_host: str = Field(
|
||||
default="0.0.0.0",
|
||||
description="Listen host for the webhook/healthz HTTP server (webhook mode).",
|
||||
)
|
||||
webhook_listen_port: int = Field(
|
||||
default=8080,
|
||||
description="Listen port for the webhook/healthz HTTP server (webhook mode).",
|
||||
)
|
||||
|
||||
@field_validator("webhook_secret_token")
|
||||
@classmethod
|
||||
def _validate_secret_token_charset(cls, value: str) -> str:
|
||||
if value and not _WEBHOOK_SECRET_RE.match(value):
|
||||
raise ValueError(
|
||||
"webhook_secret_token may only contain A-Z, a-z, 0-9, '_' and '-' "
|
||||
f"(1-256 chars), got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _require_webhook_settings(self) -> BotSettings:
|
||||
if self.update_mode == "webhook":
|
||||
if not self.webhook_public_base_url:
|
||||
raise ValueError("webhook_public_base_url is required when update_mode=webhook")
|
||||
if not self.webhook_public_base_url.startswith(("http://", "https://")):
|
||||
raise ValueError("webhook_public_base_url must start with http:// or https://")
|
||||
if not self.webhook_secret_token:
|
||||
raise ValueError("webhook_secret_token is required when update_mode=webhook")
|
||||
return self
|
||||
|
||||
@property
|
||||
def json_logs(self) -> bool:
|
||||
return self.env != "dev"
|
||||
|
|
|
|||
113
src/contract_check/bot/webhook.py
Normal file
113
src/contract_check/bot/webhook.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Webhook-mode transport for the bot (webhook-report-payments spec, ticket 01+02).
|
||||
|
||||
The bot's update mode is selected by configuration (`polling` default for
|
||||
local development, `webhook` for production behind the TLS edge). In webhook
|
||||
mode this module provides a small aiohttp application that:
|
||||
|
||||
- receives Telegram updates at a secret-derived path (HMAC-SHA256 of the
|
||||
secret token keyed by the bot token — not guessable from the public domain
|
||||
or the committed nginx template);
|
||||
- authenticates every request by the `X-Telegram-Bot-Api-Secret-Token` header
|
||||
with a constant-time compare; a wrong or missing token is rejected with 403
|
||||
*before* the update reaches the dispatcher, so forged updates cannot trigger
|
||||
bot actions or spend users' Credits;
|
||||
- answers `GET /healthz` so orchestrators and compose healthchecks can probe
|
||||
the bot process and restart it when dead;
|
||||
- registers the webhook on startup (`set_webhook` with
|
||||
`drop_pending_updates`) and deregisters it on graceful shutdown
|
||||
(`delete_webhook`), so staging and production never fight over one bot
|
||||
token and separate-host deployments never leave a stale webhook behind.
|
||||
|
||||
The hexagonal boundary is preserved: this is transport only — no core
|
||||
persistence/storage/MQ/LLM imports (enforced by tests/unit/test_bot_boundary.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiohttp import web
|
||||
|
||||
from src.contract_check.bot.config import BotSettings
|
||||
from src.contract_check.core.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
SECRET_HEADER = "X-Telegram-Bot-Api-Secret-Token"
|
||||
PATH_PREFIX = "/tg-webhook"
|
||||
|
||||
|
||||
def derive_webhook_path(bot_token: str, webhook_secret_token: str) -> str:
|
||||
"""Deterministic secret-derived URL path for the Telegram webhook.
|
||||
|
||||
HMAC-SHA256 of the webhook secret token keyed by the bot token; the first
|
||||
32 hex chars (128 bits) are used. Neither input is public, so knowing the
|
||||
domain or reading the nginx template does not reveal the path. The same
|
||||
derivation runs on the bot and in `make bot-webhook-path` for the edge
|
||||
config.
|
||||
"""
|
||||
digest = hmac.new(
|
||||
bot_token.encode("utf-8"),
|
||||
webhook_secret_token.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"{PATH_PREFIX}/{digest[:32]}"
|
||||
|
||||
|
||||
def webhook_public_url(settings: BotSettings) -> str:
|
||||
"""Full URL registered with Telegram: public base URL + secret-derived path."""
|
||||
base = settings.webhook_public_base_url.rstrip("/")
|
||||
return base + derive_webhook_path(settings.bot_token, settings.webhook_secret_token)
|
||||
|
||||
|
||||
async def handle_healthz(request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
def build_webhook_app(dp: Dispatcher, bot: Bot, settings: BotSettings) -> web.Application:
|
||||
"""Build the webhook-mode aiohttp application.
|
||||
|
||||
Wires three routes/callbacks: the secret-path update handler (403 on
|
||||
secret-token mismatch), `GET /healthz`, and the registration lifecycle
|
||||
(`set_webhook` with `drop_pending_updates` on startup, `delete_webhook`
|
||||
on graceful shutdown — i.e. when the AppRunner is cleaned up).
|
||||
"""
|
||||
path = derive_webhook_path(settings.bot_token, settings.webhook_secret_token)
|
||||
expected_secret = settings.webhook_secret_token
|
||||
|
||||
async def handle_update(request: web.Request) -> web.Response:
|
||||
presented = request.headers.get(SECRET_HEADER, "")
|
||||
# Bytes compare: compare_digest raises on non-ASCII str, and a forged
|
||||
# header must get 403, never a 500.
|
||||
if not secrets.compare_digest(presented.encode(), expected_secret.encode()):
|
||||
log.warning("webhook_forbidden", path=path)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
try:
|
||||
update = await request.json()
|
||||
except ValueError:
|
||||
# Not JSON (or not a valid update shape): Telegram always sends
|
||||
# well-formed updates, so this is a probe — reject, don't 500.
|
||||
return web.Response(status=400, text="Bad Request")
|
||||
# Respond only after the update is processed (handle_in_background=False
|
||||
# semantics): Telegram gets the ack exactly when the bot is done.
|
||||
await dp.feed_webhook_update(bot, update)
|
||||
return web.json_response({})
|
||||
|
||||
async def register_webhook(app: web.Application) -> None:
|
||||
url = webhook_public_url(settings)
|
||||
await bot.set_webhook(url=url, secret_token=expected_secret, drop_pending_updates=True)
|
||||
log.info("webhook_registered", url=url)
|
||||
|
||||
async def deregister_webhook(app: web.Application) -> None:
|
||||
await bot.delete_webhook()
|
||||
log.info("webhook_deleted")
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/healthz", handle_healthz)
|
||||
app.router.add_post(path, handle_update)
|
||||
app.on_startup.append(register_webhook)
|
||||
app.on_shutdown.append(deregister_webhook)
|
||||
return app
|
||||
|
|
@ -155,5 +155,6 @@ def test_bot_imports_resolve() -> None:
|
|||
"contract_check.bot.config",
|
||||
"contract_check.bot.client",
|
||||
"contract_check.bot.handlers",
|
||||
"contract_check.bot.webhook",
|
||||
):
|
||||
importlib.import_module(mod)
|
||||
|
|
|
|||
256
tests/unit/test_bot_webhook.py
Normal file
256
tests/unit/test_bot_webhook.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""Webhook-mode transport tests: the bot's HTTP boundary (ticket 01) and the
|
||||
webhook registration lifecycle (ticket 02).
|
||||
|
||||
The webhook server is the single new seam introduced by the
|
||||
webhook-report-payments spec, so it is exercised in-process over real HTTP
|
||||
against the aiohttp application (TestServer/TestClient). The dispatcher is
|
||||
observed through an outer update middleware — forged requests must never
|
||||
reach it. The Telegram Bot API side of the lifecycle (set/delete webhook) is
|
||||
observed through a recording stand-in bot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from aiogram import Dispatcher
|
||||
from aiogram.types import Update
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.contract_check.bot.config import BotSettings
|
||||
from src.contract_check.bot.webhook import (
|
||||
build_webhook_app,
|
||||
derive_webhook_path,
|
||||
webhook_public_url,
|
||||
)
|
||||
|
||||
BOT_TOKEN = "123456:webhook-unit-test-token"
|
||||
SECRET = "unit-test-secret_token-01"
|
||||
PATH = derive_webhook_path(BOT_TOKEN, SECRET)
|
||||
|
||||
UPDATE_PAYLOAD: dict[str, Any] = {
|
||||
"update_id": 42,
|
||||
"message": {
|
||||
"message_id": 1,
|
||||
"date": 1735689600,
|
||||
"text": "привет",
|
||||
"chat": {"id": 4242, "type": "private"},
|
||||
"from": {"id": 4242, "is_bot": False, "first_name": "Tester"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RecordingBot:
|
||||
"""Duck-typed Bot: records lifecycle calls, never touches the network."""
|
||||
|
||||
def __init__(self, token: str = BOT_TOKEN) -> None:
|
||||
self.id = int(token.split(":")[0])
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
async def set_webhook(self, **kwargs: Any) -> None:
|
||||
self.calls.append(("set_webhook", kwargs))
|
||||
|
||||
async def delete_webhook(self, **kwargs: Any) -> None:
|
||||
self.calls.append(("delete_webhook", kwargs))
|
||||
|
||||
|
||||
def make_settings(**overrides: Any) -> BotSettings:
|
||||
base: dict[str, Any] = {
|
||||
"env": "test",
|
||||
"bot_token": BOT_TOKEN,
|
||||
"bot_service_token": "svc-token",
|
||||
"update_mode": "webhook",
|
||||
"webhook_public_base_url": "https://edge.example.com",
|
||||
"webhook_secret_token": SECRET,
|
||||
}
|
||||
base.update(overrides)
|
||||
return BotSettings(**base) # type: ignore[call-arg]
|
||||
|
||||
|
||||
def make_dispatcher(seen: list[Update]) -> Dispatcher:
|
||||
"""Dispatcher that records every fed update and runs no handlers."""
|
||||
|
||||
async def capture(handler: Any, event: Update, data: dict[str, Any]) -> None:
|
||||
seen.append(event)
|
||||
return None
|
||||
|
||||
dp = Dispatcher()
|
||||
dp.update.outer_middleware(capture)
|
||||
return dp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def webhook_client() -> Any:
|
||||
"""Started webhook app behind a real in-process HTTP server."""
|
||||
|
||||
seen: list[Update] = []
|
||||
app = build_webhook_app(make_dispatcher(seen), RecordingBot(), make_settings())
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
yield client, seen
|
||||
|
||||
|
||||
# ── Ticket 01: HTTP boundary ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebhookHttpBoundary:
|
||||
async def test_valid_update_with_correct_secret_is_accepted(self, webhook_client: Any) -> None:
|
||||
client, seen = webhook_client
|
||||
resp = await client.post(
|
||||
PATH,
|
||||
json=UPDATE_PAYLOAD,
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": SECRET},
|
||||
)
|
||||
assert resp.status == 200
|
||||
assert len(seen) == 1
|
||||
assert seen[0].update_id == 42
|
||||
assert seen[0].message is not None
|
||||
assert seen[0].message.text == "привет"
|
||||
|
||||
async def test_wrong_secret_token_rejected_403(self, webhook_client: Any) -> None:
|
||||
client, seen = webhook_client
|
||||
resp = await client.post(
|
||||
PATH,
|
||||
json=UPDATE_PAYLOAD,
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": "wrong-secret"},
|
||||
)
|
||||
assert resp.status == 403
|
||||
assert seen == []
|
||||
|
||||
async def test_missing_secret_token_rejected_403(self, webhook_client: Any) -> None:
|
||||
client, seen = webhook_client
|
||||
resp = await client.post(PATH, json=UPDATE_PAYLOAD)
|
||||
assert resp.status == 403
|
||||
assert seen == []
|
||||
|
||||
async def test_empty_secret_token_rejected_403(self, webhook_client: Any) -> None:
|
||||
client, seen = webhook_client
|
||||
resp = await client.post(
|
||||
PATH,
|
||||
json=UPDATE_PAYLOAD,
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": ""},
|
||||
)
|
||||
assert resp.status == 403
|
||||
assert seen == []
|
||||
|
||||
async def test_non_ascii_secret_token_rejected_403(self, webhook_client: Any) -> None:
|
||||
"""A forged header must get 403, never a 500 from compare_digest."""
|
||||
client, seen = webhook_client
|
||||
resp = await client.post(
|
||||
PATH,
|
||||
json=UPDATE_PAYLOAD,
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": "не-секрет"},
|
||||
)
|
||||
assert resp.status == 403
|
||||
assert seen == []
|
||||
|
||||
async def test_malformed_json_with_valid_secret_rejected_400(self, webhook_client: Any) -> None:
|
||||
client, seen = webhook_client
|
||||
resp = await client.post(
|
||||
PATH,
|
||||
data=b"not-json",
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": SECRET},
|
||||
)
|
||||
assert resp.status == 400
|
||||
assert seen == []
|
||||
|
||||
async def test_healthz_returns_200(self, webhook_client: Any) -> None:
|
||||
client, _ = webhook_client
|
||||
resp = await client.get("/healthz")
|
||||
assert resp.status == 200
|
||||
|
||||
async def test_unknown_path_returns_404(self, webhook_client: Any) -> None:
|
||||
client, seen = webhook_client
|
||||
resp = await client.post(
|
||||
"/tg-webhook/not-the-derived-path",
|
||||
json=UPDATE_PAYLOAD,
|
||||
headers={"X-Telegram-Bot-Api-Secret-Token": SECRET},
|
||||
)
|
||||
assert resp.status == 404
|
||||
assert seen == []
|
||||
|
||||
|
||||
# ── Ticket 01: mode-switch configuration ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdateModeConfig:
|
||||
def test_default_mode_is_polling(self) -> None:
|
||||
settings = BotSettings(env="test", bot_token=BOT_TOKEN, bot_service_token="svc") # type: ignore[call-arg]
|
||||
assert settings.update_mode == "polling"
|
||||
|
||||
def test_invalid_mode_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
make_settings(update_mode="banana")
|
||||
|
||||
def test_webhook_mode_requires_public_base_url(self) -> None:
|
||||
with pytest.raises(ValidationError, match="webhook_public_base_url"):
|
||||
make_settings(webhook_public_base_url="")
|
||||
|
||||
def test_webhook_base_url_scheme_validated(self) -> None:
|
||||
with pytest.raises(ValidationError, match="http"):
|
||||
make_settings(webhook_public_base_url="ftp://edge.example.com")
|
||||
|
||||
def test_webhook_mode_requires_secret_token(self) -> None:
|
||||
with pytest.raises(ValidationError, match="webhook_secret_token"):
|
||||
make_settings(webhook_secret_token="")
|
||||
|
||||
def test_webhook_secret_token_charset_validated(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
make_settings(webhook_secret_token="bad secret!")
|
||||
|
||||
def test_polling_mode_tolerates_empty_webhook_settings(self) -> None:
|
||||
settings = make_settings(
|
||||
update_mode="polling",
|
||||
webhook_public_base_url="",
|
||||
webhook_secret_token="",
|
||||
)
|
||||
assert settings.update_mode == "polling"
|
||||
|
||||
|
||||
# ── Secret-derived path ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebhookPathDerivation:
|
||||
def test_path_is_deterministic_and_prefixed(self) -> None:
|
||||
assert PATH == derive_webhook_path(BOT_TOKEN, SECRET)
|
||||
assert PATH.startswith("/tg-webhook/")
|
||||
assert len(PATH) > len("/tg-webhook/")
|
||||
|
||||
def test_path_depends_on_secret_and_bot_token(self) -> None:
|
||||
assert PATH != derive_webhook_path(BOT_TOKEN, "another-secret-token")
|
||||
assert PATH != derive_webhook_path("999999:other-bot", SECRET)
|
||||
|
||||
def test_public_url_joins_base_and_path(self) -> None:
|
||||
assert webhook_public_url(make_settings()) == f"https://edge.example.com{PATH}"
|
||||
|
||||
def test_public_url_tolerates_trailing_slash(self) -> None:
|
||||
settings = make_settings(webhook_public_base_url="https://edge.example.com/")
|
||||
assert webhook_public_url(settings) == f"https://edge.example.com{PATH}"
|
||||
|
||||
|
||||
# ── Ticket 02: registration lifecycle ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebhookLifecycle:
|
||||
async def test_startup_registers_webhook_with_drop_pending_updates(self) -> None:
|
||||
|
||||
bot = RecordingBot()
|
||||
app = build_webhook_app(make_dispatcher([]), bot, make_settings())
|
||||
async with TestServer(app):
|
||||
pass
|
||||
|
||||
assert bot.calls[0][0] == "set_webhook"
|
||||
kwargs = bot.calls[0][1]
|
||||
assert kwargs["url"] == f"https://edge.example.com{PATH}"
|
||||
assert kwargs["secret_token"] == SECRET
|
||||
assert kwargs["drop_pending_updates"] is True
|
||||
|
||||
async def test_shutdown_deregisters_webhook(self) -> None:
|
||||
|
||||
bot = RecordingBot()
|
||||
app = build_webhook_app(make_dispatcher([]), bot, make_settings())
|
||||
async with TestServer(app):
|
||||
pass
|
||||
|
||||
assert [name for name, _ in bot.calls][-1] == "delete_webhook"
|
||||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -692,6 +692,7 @@ billing = [
|
|||
]
|
||||
bot = [
|
||||
{ name = "aiogram" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "redis" },
|
||||
]
|
||||
db = [
|
||||
|
|
@ -702,6 +703,7 @@ db = [
|
|||
dev = [
|
||||
{ name = "aio-pika" },
|
||||
{ name = "aiogram" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiosmtplib" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "alembic" },
|
||||
|
|
@ -857,6 +859,7 @@ billing = [
|
|||
]
|
||||
bot = [
|
||||
{ name = "aiogram", specifier = ">=3.4" },
|
||||
{ name = "aiohttp", specifier = ">=3.9" },
|
||||
{ name = "redis", specifier = ">=5.0" },
|
||||
]
|
||||
db = [
|
||||
|
|
@ -867,6 +870,7 @@ db = [
|
|||
dev = [
|
||||
{ name = "aio-pika", specifier = ">=9.4" },
|
||||
{ name = "aiogram", specifier = ">=3.4" },
|
||||
{ name = "aiohttp", specifier = ">=3.9" },
|
||||
{ name = "aiosmtplib", specifier = ">=3.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
||||
{ name = "alembic", specifier = ">=1.13" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue