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
|
# If you point this at an external Redis, it must be reachable over a private
|
||||||
# network/VPN; never expose Redis to the public internet.
|
# network/VPN; never expose Redis to the public internet.
|
||||||
REDIS_URL=redis://redis:6379/0
|
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.
|
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
|
# 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).
|
# 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 \
|
observer-up observer-down observer-logs \
|
||||||
nginx-up nginx-down nginx-logs nginx-ps \
|
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 \
|
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 \
|
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
|
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
|
bot-remote-ps: ## Show remote bot container status
|
||||||
docker compose -f docker-compose.bot.yml ps
|
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
|
worker-extract: ## Start/restart extract worker
|
||||||
docker compose --profile services up -d --build --remove-orphans worker-extract
|
docker compose --profile services up -d --build --remove-orphans worker-extract
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,29 @@ server {
|
||||||
proxy_read_timeout 60s;
|
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.
|
# Grafana / Loki: exposed under /grafana so everything lives on one domain.
|
||||||
# In production restrict access here or via firewall (basic auth / VPN / SSO).
|
# In production restrict access here or via firewall (basic auth / VPN / SSO).
|
||||||
location /grafana/ {
|
location /grafana/ {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
# Telegram bot adapter — run on a separate server.
|
# 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,
|
# 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.
|
# 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:
|
# Usage on the bot server:
|
||||||
# cp .env.bot.example .env # minimal bot-only environment
|
# cp .env.bot.example .env # minimal bot-only environment
|
||||||
# docker compose -f docker-compose.bot.yml up -d --build
|
# docker compose -f docker-compose.bot.yml up -d --build
|
||||||
|
|
@ -27,17 +33,33 @@ services:
|
||||||
BOT_TOKEN: ${BOT_TOKEN}
|
BOT_TOKEN: ${BOT_TOKEN}
|
||||||
API_URL: ${API_URL}
|
API_URL: ${API_URL}
|
||||||
BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN}
|
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
|
# 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
|
# backend (fine for a single bot instance). The Redis container below is
|
||||||
# not exposed outside the host; only the bot container can reach it.
|
# not exposed outside the host; only the bot container can reach it.
|
||||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
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:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
# Webhook mode: probe the bot's own /healthz (the process under
|
||||||
|
# supervision). Polling mode: keep probing the central API as before.
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- 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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,13 @@ services:
|
||||||
BOT_TOKEN: ${BOT_TOKEN:-}
|
BOT_TOKEN: ${BOT_TOKEN:-}
|
||||||
API_URL: ${API_URL:-http://api:8000}
|
API_URL: ${API_URL:-http://api:8000}
|
||||||
BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN:-}
|
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) ──────────────────────────────────────────────────
|
# ── EDGE (profile: edge) ──────────────────────────────────────────────────
|
||||||
# Reverse proxy + TLS terminator. Listens on 80/443 and forwards
|
# Reverse proxy + TLS terminator. Listens on 80/443 and forwards
|
||||||
|
|
@ -347,6 +354,10 @@ services:
|
||||||
NGINX_ENVSUBST_TEMPLATE_SUFFIX: .template
|
NGINX_ENVSUBST_TEMPLATE_SUFFIX: .template
|
||||||
# Passed to the template so the OpenObserve subpath can be changed in one place.
|
# Passed to the template so the OpenObserve subpath can be changed in one place.
|
||||||
OPENOBSERVE_BASE_URI: ${OPENOBSERVE_BASE_URI:-/openobserve}
|
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:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "--no-check-certificate", "http://localhost/healthz"]
|
test: ["CMD", "wget", "-qO-", "--no-check-certificate", "http://localhost/healthz"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@ DealDocumentScreening/
|
||||||
│ └── bot/ (new — aiogram adapter, HTTP-only)
|
│ └── bot/ (new — aiogram adapter, HTTP-only)
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── __main__.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/...)
|
│ ├── client.py (ApiClient: typed httpx wrapper; ApiError/NoCreditsError/...)
|
||||||
│ └── handlers.py (/start, doc upload→POST api, poll→send report)
|
│ └── handlers.py (/start, doc upload→POST api, poll→send report)
|
||||||
└── tests/
|
└── tests/
|
||||||
|
|
@ -294,10 +294,11 @@ DealDocumentScreening/
|
||||||
|
|
||||||
- `core/*` may import anything.
|
- `core/*` may import anything.
|
||||||
- `api/*`, `worker_extract/*`, `worker_prescreen/*`, `worker_analyze/*` import only `core/*`.
|
- `api/*`, `worker_extract/*`, `worker_prescreen/*`, `worker_analyze/*` import only `core/*`.
|
||||||
- `bot/*` imports only `httpx` + its own modules. **It must NOT import
|
- `bot/*` imports only `httpx` (outbound to the api), `aiohttp` (inbound
|
||||||
`core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`.** It speaks to
|
webhook-mode transport, aiogram's HTTP engine) + its own modules. **It must
|
||||||
the api over HTTP only. (Add a ruff/flake8 import-forbidden rule or a unit
|
NOT import `core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`.** It
|
||||||
test that asserts this.)
|
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) |
|
| `BOT_TOKEN` | (required) |
|
||||||
| `API_URL` | `http://api:8000` |
|
| `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_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)
|
### 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`,
|
`core.s3`, `core.llm`, `core.mq`, `core.credits`, `sqlalchemy`, `minio`,
|
||||||
`aio_pika`, `pymupdf`. Enforced by a unit test (§14).
|
`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:
|
Flow:
|
||||||
|
|
||||||
- `/start` → `GET /api/v1/me` → greeting + credit balance.
|
- `/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`
|
1. Напишите `@BotFather` → `/newbot`
|
||||||
2. Скопируйте токен (`BOT_TOKEN`) в `.env`
|
2. Скопируйте токен (`BOT_TOKEN`) в `.env`
|
||||||
3. Установите webhook (опционально, polling работает по умолчанию):
|
3. Режим доставки updates выбирается переменной `BOT_UPDATE_MODE`:
|
||||||
```bash
|
по умолчанию `polling` (ничего настраивать не нужно). Для webhook-режима
|
||||||
curl -F "url=https://your-domain.com/webhook" \
|
смотрите §14.4 — вручную вызывать `setWebhook` через curl не нужно, бот
|
||||||
https://api.telegram.org/bot$BOT_TOKEN/setWebhook
|
сам регистрирует webhook на старте и снимает его при остановке.
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Запуск
|
### 7.2 Запуск
|
||||||
|
|
||||||
|
|
@ -569,9 +568,11 @@ docker compose --profile bot logs -f bot
|
||||||
# Если бот на отдельном сервере — смотрите логи там:
|
# Если бот на отдельном сервере — смотрите логи там:
|
||||||
# docker compose -f docker-compose.bot.yml logs -f
|
# docker compose -f docker-compose.bot.yml logs -f
|
||||||
|
|
||||||
# Проверка polling:
|
# Проверка режима доставки:
|
||||||
# Бот использует polling по умолчанию (aiogram). Если webhook установлен —
|
# Бот использует polling по умолчанию (BOT_UPDATE_MODE не задан или polling).
|
||||||
# убедитесь, что Nginx проксирует /webhook к api:8000.
|
# В webhook-режиме проверьте: /healthz на ботовом сервере отвечает 200,
|
||||||
|
# edge маршрутизирует BOT_WEBHOOK_PATH на бот, секретный заголовок совпадает
|
||||||
|
# (см. §14.4.3).
|
||||||
```
|
```
|
||||||
|
|
||||||
## 13. Edge proxy (Nginx + certbot)
|
## 13. Edge proxy (Nginx + certbot)
|
||||||
|
|
@ -631,6 +632,7 @@ curl https://contract-check.example.com/healthz
|
||||||
- `/healthz`, `/readyz`
|
- `/healthz`, `/readyz`
|
||||||
- `/metrics` (открыт наружу; закройте файрволом или уберите приватный скрейпинг)
|
- `/metrics` (открыт наружу; закройте файрволом или уберите приватный скрейпинг)
|
||||||
- `/webhook/*` → `/api/v1/webhooks/`
|
- `/webhook/*` → `/api/v1/webhooks/`
|
||||||
|
- `${BOT_WEBHOOK_PATH}` → `bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4)
|
||||||
- `/grafana/*` → `grafana:3000` (когда поднят профиль `obs`)
|
- `/grafana/*` → `grafana:3000` (когда поднят профиль `obs`)
|
||||||
|
|
||||||
Grafana под путём `/grafana`:
|
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 → API | HTTPS (рекомендуется) | 443 / 8000 | `API_URL` без trailing slash, например `https://contract-check.example.com` |
|
||||||
| bot → Redis (опц.) | Redis | 6379 | Только внутри приватной сети/VPN; иначе оставьте `REDIS_URL` пустым |
|
| 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
|
### 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'
|
BOT_SERVICE_TOKEN=bot-prod-secret-xxx # должен совпадать с service_tokens.name='bot-prod'
|
||||||
API_URL=https://contract-check.example.com # публичный адрес центрального API, без trailing slash
|
API_URL=https://contract-check.example.com # публичный адрес центрального API, без trailing slash
|
||||||
REDIS_URL=redis://redis:6379/0 # локальный Redis из docker-compose.bot.yml
|
REDIS_URL=redis://redis:6379/0 # локальный Redis из docker-compose.bot.yml
|
||||||
|
BOT_UPDATE_MODE=polling # webhook-режим: см. §14.4
|
||||||
```
|
```
|
||||||
|
|
||||||
`docker-compose.bot.yml` поднимает собственный Redis-контейнер (только для
|
`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())"
|
"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, и проксируйте запросы на ботовый сервер.
|
```bash
|
||||||
2. В боте реализуйте webhook-эндпоинт (не входит в текущую версию; требуется небольшая доработка `bot/__main__.py` и добавление HTTP-сервера, например aiohttp/uvicorn).
|
BOT_UPDATE_MODE=polling # по умолчанию: long-polling (локальная разработка)
|
||||||
3. Используйте `docker-compose.bot.yml` с healthcheck и `deploy.replicas`, но за балансировщиком, который направляет Telegram-запросы на одну активную реплику (либо на все, если webhook-эндпоинт идемпотентен и дедуплицирует updates по `update_id`).
|
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 Управление и обновление
|
### 14.5 Управление и обновление
|
||||||
|
|
||||||
|
|
@ -807,6 +898,7 @@ make bot-remote-ps
|
||||||
- [ ] Backup cron настроен
|
- [ ] Backup cron настроен
|
||||||
- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP)
|
- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP)
|
||||||
- [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS
|
- [ ] Если бот на отдельном сервере: `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 = [
|
bot = [
|
||||||
"aiogram>=3.4",
|
"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",
|
"redis>=5.0",
|
||||||
]
|
]
|
||||||
notify = [
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -6,11 +12,13 @@ import asyncio
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from src.contract_check.bot.client import ApiClient
|
from src.contract_check.bot.client import ApiClient
|
||||||
from src.contract_check.bot.config import get_bot_settings
|
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.handlers import router as bot_router
|
||||||
from src.contract_check.bot.rate_limit import get_rate_limiter
|
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
|
from src.contract_check.core.logging import bind_context, configure_logging, get_logger
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
@ -38,7 +46,12 @@ async def main() -> None:
|
||||||
dp.include_router(bot_router)
|
dp.include_router(bot_router)
|
||||||
|
|
||||||
me = await bot.get_me()
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
|
|
@ -48,17 +61,45 @@ async def main() -> None:
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
pass
|
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())
|
stopper = asyncio.create_task(stop_event.wait())
|
||||||
|
|
||||||
try:
|
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:
|
finally:
|
||||||
if not polling.done():
|
if polling is not None and not polling.done():
|
||||||
polling.cancel()
|
polling.cancel()
|
||||||
if not stopper.done():
|
if not stopper.done():
|
||||||
stopper.cancel()
|
stopper.cancel()
|
||||||
await dp.stop_polling()
|
if runner is not None:
|
||||||
|
await runner.cleanup()
|
||||||
|
else:
|
||||||
|
await dp.stop_polling()
|
||||||
await bot.session.close()
|
await bot.session.close()
|
||||||
await api.aclose()
|
await api.aclose()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,16 @@ the api.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from functools import lru_cache
|
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
|
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):
|
class BotSettings(BaseSettings):
|
||||||
"""Telegram-bot adapter settings. See docs/ARCHITECTURE.md §11 (Bot)."""
|
"""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.",
|
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
|
@property
|
||||||
def json_logs(self) -> bool:
|
def json_logs(self) -> bool:
|
||||||
return self.env != "dev"
|
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.config",
|
||||||
"contract_check.bot.client",
|
"contract_check.bot.client",
|
||||||
"contract_check.bot.handlers",
|
"contract_check.bot.handlers",
|
||||||
|
"contract_check.bot.webhook",
|
||||||
):
|
):
|
||||||
importlib.import_module(mod)
|
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 = [
|
bot = [
|
||||||
{ name = "aiogram" },
|
{ name = "aiogram" },
|
||||||
|
{ name = "aiohttp" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
]
|
]
|
||||||
db = [
|
db = [
|
||||||
|
|
@ -702,6 +703,7 @@ db = [
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "aio-pika" },
|
{ name = "aio-pika" },
|
||||||
{ name = "aiogram" },
|
{ name = "aiogram" },
|
||||||
|
{ name = "aiohttp" },
|
||||||
{ name = "aiosmtplib" },
|
{ name = "aiosmtplib" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
|
@ -857,6 +859,7 @@ billing = [
|
||||||
]
|
]
|
||||||
bot = [
|
bot = [
|
||||||
{ name = "aiogram", specifier = ">=3.4" },
|
{ name = "aiogram", specifier = ">=3.4" },
|
||||||
|
{ name = "aiohttp", specifier = ">=3.9" },
|
||||||
{ name = "redis", specifier = ">=5.0" },
|
{ name = "redis", specifier = ">=5.0" },
|
||||||
]
|
]
|
||||||
db = [
|
db = [
|
||||||
|
|
@ -867,6 +870,7 @@ db = [
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "aio-pika", specifier = ">=9.4" },
|
{ name = "aio-pika", specifier = ">=9.4" },
|
||||||
{ name = "aiogram", specifier = ">=3.4" },
|
{ name = "aiogram", specifier = ">=3.4" },
|
||||||
|
{ name = "aiohttp", specifier = ">=3.9" },
|
||||||
{ name = "aiosmtplib", specifier = ">=3.0" },
|
{ name = "aiosmtplib", specifier = ">=3.0" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
{ name = "aiosqlite", specifier = ">=0.20" },
|
||||||
{ name = "alembic", specifier = ">=1.13" },
|
{ name = "alembic", specifier = ">=1.13" },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue