TG bot was separated as service with envs.
This commit is contained in:
parent
4b71fe4291
commit
b45274e01b
9 changed files with 231 additions and 12 deletions
27
.env.bot.example
Normal file
27
.env.bot.example
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Minimal environment for running the Telegram bot on a separate server.
|
||||
# Copy to `.env` on the bot host and fill in the secrets.
|
||||
#
|
||||
# The bot is stateless: it polls Telegram and calls the central API over HTTP(S).
|
||||
# It does NOT need DB/RabbitMQ/MinIO/LLM credentials. Keep this file minimal.
|
||||
|
||||
ENV=prod
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
|
||||
# Telegram bot token from @BotFather.
|
||||
BOT_TOKEN=
|
||||
|
||||
# Bearer token the bot uses to authenticate against the API on
|
||||
# POST /api/v1/auth/telegram/bot. Must match the hash stored in
|
||||
# service_tokens.name='bot-prod' on the central API server.
|
||||
BOT_SERVICE_TOKEN=
|
||||
|
||||
# Public URL of the central API, without trailing slash.
|
||||
# Example: https://contract-check.example.com
|
||||
API_URL=
|
||||
|
||||
# Optional: Redis URL for shared rate-limit state.
|
||||
# Leave empty for the in-memory backend (recommended for a single bot instance).
|
||||
# If set, Redis must be reachable from this host over a private network/VPN;
|
||||
# never expose Redis to the public internet.
|
||||
REDIS_URL=
|
||||
|
|
@ -185,4 +185,6 @@ NGINX_SERVER_NAME=contract-check.example.com
|
|||
# --- Telegram bot (adapter, HTTP-only to api) ---
|
||||
BOT_TOKEN= # same value as TELEGRAM_BOT_TOKEN (kept for the bot image)
|
||||
BOT_SERVICE_TOKEN= # bearer looked up against service_tokens.name="bot-prod"
|
||||
API_URL=http://api:8000 # base URL of the api service (container DNS in compose)
|
||||
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).
|
||||
|
|
|
|||
19
Makefile
19
Makefile
|
|
@ -146,12 +146,27 @@ api: ## Start/restart API service
|
|||
api-logs: ## Tail API logs
|
||||
docker compose logs -f api
|
||||
|
||||
bot: ## Start/restart Telegram bot
|
||||
bot: ## Start/restart Telegram bot (same host as the api stack)
|
||||
docker compose --profile services up -d --build --remove-orphans bot
|
||||
|
||||
bot-logs: ## Tail bot logs
|
||||
bot-logs: ## Tail bot logs (same host as the api stack)
|
||||
docker compose logs -f bot
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Docker: bot on a separate server (HTTP-only adapter to central API)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
bot-remote-up: ## Start bot on a separate server (set API_URL to central API)
|
||||
docker compose -f docker-compose.bot.yml up -d --build --remove-orphans
|
||||
|
||||
bot-remote-down: ## Stop remote bot
|
||||
docker compose -f docker-compose.bot.yml down
|
||||
|
||||
bot-remote-logs: ## Tail remote bot logs
|
||||
docker compose -f docker-compose.bot.yml logs -f
|
||||
|
||||
bot-remote-ps: ## Show remote bot container status
|
||||
docker compose -f docker-compose.bot.yml ps
|
||||
|
||||
worker-extract: ## Start/restart extract worker
|
||||
docker compose --profile services up -d --build --remove-orphans worker-extract
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI)
|
|||
- **worker-analyze** — I/O: LLM-анализ по чек-листу, валидация/repair, сохраняет `Report`, `status=done`.
|
||||
- **worker-notify** — доставка email-уведомлений (восстановление пароля и др.) через SMTP; без `SMTP_HOST` — dev-логгер.
|
||||
- **worker-billing** — таймерный планировщик биллинга (без MQ): продления подписок (счета за 3 дня до конца периода), expiry/past_due-переходы, сверка pending-счетов старше 15 минут через ЮKassa API.
|
||||
- **bot** — адаптер: HTTP-клиент к api, **не импортирует** core.db/s3/llm/mq (граница проверяется тестом `tests/unit/test_bot_boundary.py`).
|
||||
- **bot** — адаптер: HTTP-клиент к api, **не импортирует** core.db/s3/llm/mq (граница проверяется тестом `tests/unit/test_bot_boundary.py`). Может работать на отдельном сервере — см. `docs/DEPLOY.md` §14.
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
|
|
|
|||
42
docker-compose.bot.yml
Normal file
42
docker-compose.bot.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Telegram bot adapter — run on a separate server.
|
||||
#
|
||||
# The bot is deliberately stateless: it polls Telegram and talks to 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.
|
||||
#
|
||||
# Usage on the bot server:
|
||||
# cp .env.bot.example .env # minimal bot-only environment
|
||||
# docker compose -f docker-compose.bot.yml up -d --build
|
||||
#
|
||||
# See docs/DEPLOY.md §14 for the full separate-server guide.
|
||||
|
||||
services:
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: srv/bot/Dockerfile
|
||||
container_name: contract_check-bot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
ENV: ${ENV:-prod}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
LOG_FORMAT: ${LOG_FORMAT:-json}
|
||||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
API_URL: ${API_URL}
|
||||
BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN}
|
||||
# Optional Redis for shared rate-limit state across bot replicas.
|
||||
# Leave empty (or omit) to use the in-memory backend; safe for a single
|
||||
# bot instance. If set, Redis must be reachable from this host (private
|
||||
# network / VPN — do NOT expose Redis to the public internet).
|
||||
REDIS_URL: ${REDIS_URL:-}
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "python -c \"import urllib.request, os; urllib.request.urlopen(os.environ['API_URL'] + '/healthz', timeout=5)\""
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
106
docs/DEPLOY.md
106
docs/DEPLOY.md
|
|
@ -20,6 +20,8 @@
|
|||
10. [Обновление (zero-downtime-ish)](#10-обновление)
|
||||
11. [Масштабирование](#11-масштабирование)
|
||||
12. [Troubleshooting](#12-troubleshooting)
|
||||
13. [Edge proxy (Nginx + certbot)](#13-edge-proxy-nginx--certbot)
|
||||
14. [Telegram-бот на отдельном сервере](#14-telegram-бот-на-отдельном-сервере)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -655,6 +657,109 @@ docker compose down -v
|
|||
|
||||
---
|
||||
|
||||
## 14. Telegram-бот на отдельном сервере
|
||||
|
||||
Бот изначально спроектирован как тонкий HTTP-адаптер: он не импортирует `core.db`/`core.s3`/`core.mq`/`core.llm` и не хранит состояние. Поэтому его можно вынести на отдельный хост без изменения кода — только настроив `API_URL` на центральный API.
|
||||
|
||||
Типичные причины вынести бота:
|
||||
|
||||
- **Безопасность**: на ботовом сервере нет секретов БД/MinIO/RabbitMQ/LLM.
|
||||
- **Сетевая изоляция**: боту нужен только outbound HTTPS к Telegram и к API.
|
||||
- **Масштабирование**: можно запустить несколько ботов за одним токеном (aiogram polling не допускает несколько активных polling-инстансов с одним токеном; для HA используйте webhook + балансировку, см. §14.4).
|
||||
|
||||
### 14.1 Сетевая связность
|
||||
|
||||
| Направление | Протокол | Порт | Комментарий |
|
||||
|---|---|---|---|
|
||||
| bot → Telegram | outbound HTTPS | 443 | Polling updates от `api.telegram.org` |
|
||||
| bot → API | HTTPS (рекомендуется) | 443 / 8000 | `API_URL` без trailing slash, например `https://contract-check.example.com` |
|
||||
| bot → Redis (опц.) | Redis | 6379 | Только внутри приватной сети/VPN; иначе оставьте `REDIS_URL` пустым |
|
||||
|
||||
На firewall ботового сервера открывайте только **outbound** 443 (и 22 для SSH). Входящих портов бот не требует.
|
||||
|
||||
### 14.2 Подготовка центрального API
|
||||
|
||||
Убедитесь, что API доступен ботовому серверу по домену/IP и что `service_tokens` содержит запись для бота (§5.2):
|
||||
|
||||
```bash
|
||||
# На сервере с API — убедитесь, что токен засеян:
|
||||
docker compose --profile services exec api python -m src.contract_check.api seed-token bot-prod bot
|
||||
```
|
||||
|
||||
Если API закрыт файрволом по IP, разрешите входящие от IP ботового сервера на порт API/Nginx.
|
||||
|
||||
### 14.3 Развёртывание бота на отдельном сервере
|
||||
|
||||
1. Склонируйте репозиторий на ботовый сервер (нужны только `srv/bot/Dockerfile`, `docker-compose.bot.yml`, `pyproject.toml`, `uv.lock`, `src/`, `.env.bot.example`).
|
||||
2. Создайте `.env` из `.env.bot.example` с минимальным набором переменных:
|
||||
|
||||
```bash
|
||||
cp .env.bot.example .env
|
||||
# отредактируйте .env
|
||||
```
|
||||
|
||||
```bash
|
||||
ENV=prod
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
BOT_TOKEN=123456789:ABCDEF... # тот же токен из @BotFather
|
||||
BOT_SERVICE_TOKEN=bot-prod-secret-xxx # должен совпадать с service_tokens.name='bot-prod'
|
||||
API_URL=https://contract-check.example.com # публичный адрес центрального API, без trailing slash
|
||||
REDIS_URL= # пусто → in-memory rate limiter; достаточно для одного инстанса
|
||||
```
|
||||
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
make bot-remote-up
|
||||
# или напрямую:
|
||||
docker compose -f docker-compose.bot.yml up -d --build
|
||||
```
|
||||
|
||||
4. Проверьте логи:
|
||||
|
||||
```bash
|
||||
make bot-remote-logs
|
||||
```
|
||||
|
||||
Ожидаемое сообщение:
|
||||
```
|
||||
bot_started username=<...> api_url=https://contract-check.example.com
|
||||
```
|
||||
|
||||
5. Проверьте healthcheck контейнера:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.bot.yml ps
|
||||
docker compose -f docker-compose.bot.yml exec bot python -c \
|
||||
"import urllib.request, os; print(urllib.request.urlopen(os.environ['API_URL'] + '/healthz').read())"
|
||||
```
|
||||
|
||||
### 14.4 Webhook вместо polling (опционально, для HA)
|
||||
|
||||
По умолчанию бот использует **polling**. Два и более инстанса с одним токеном в polling-режиме будут конфликтовать (Telegram отдаёт 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`).
|
||||
|
||||
> Для большинства production-развёртываний polling на выделенном ботовом сервере достаточен.
|
||||
|
||||
### 14.5 Управление и обновление
|
||||
|
||||
```bash
|
||||
# Пересобрать и перезапустить бота после git pull:
|
||||
make bot-remote-up
|
||||
|
||||
# Остановить:
|
||||
make bot-remote-down
|
||||
|
||||
# Посмотреть статус:
|
||||
make bot-remote-ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Чек-лист перед production
|
||||
|
||||
- [ ] `.env` заполнен, `.env.example` не содержит реальных секретов
|
||||
|
|
@ -669,6 +774,7 @@ docker compose down -v
|
|||
- [ ] B2B-ключ создаётся через `/api/v1/b2b/keys` с user JWT
|
||||
- [ ] Backup cron настроен
|
||||
- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP)
|
||||
- [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ async def main() -> None:
|
|||
await api.start()
|
||||
|
||||
# Rate limiter for /start and other bot-level guards. Redis in prod,
|
||||
# in-memory fallback in dev/tests.
|
||||
rate_limiter = await get_rate_limiter(settings.redis_url or "redis://redis:6379/0")
|
||||
# in-memory fallback in dev/tests or when REDIS_URL is empty.
|
||||
rate_limiter = await get_rate_limiter(settings.redis_url)
|
||||
|
||||
bot = Bot(token=settings.bot_token)
|
||||
dp = Dispatcher(api=api, settings=settings, rate_limiter=rate_limiter)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,17 @@ def _redis_client(url: str) -> Any:
|
|||
|
||||
|
||||
async def get_rate_limiter(redis_url: str) -> RateLimiter:
|
||||
"""Build a Redis rate limiter or fall back to memory on failure."""
|
||||
redis_client = _redis_client(redis_url)
|
||||
"""Build a Redis rate limiter or fall back to memory on failure.
|
||||
|
||||
An empty/None URL selects the in-memory backend immediately. This lets a
|
||||
bot running on a separate server disable Redis by setting ``REDIS_URL=``.
|
||||
"""
|
||||
if not redis_url:
|
||||
log.info("rate_limiter_memory_selected")
|
||||
return MemoryRateLimiter()
|
||||
redis_client = None
|
||||
try:
|
||||
redis_client = _redis_client(redis_url)
|
||||
await redis_client.ping()
|
||||
from src.contract_check.core.rate_limit import RedisRateLimiter
|
||||
|
||||
|
|
@ -31,6 +39,7 @@ async def get_rate_limiter(redis_url: str) -> RateLimiter:
|
|||
return RedisRateLimiter(redis_client)
|
||||
except Exception as exc:
|
||||
log.warning("rate_limiter_redis_unavailable", error=str(exc))
|
||||
if redis_client is not None:
|
||||
try:
|
||||
await redis_client.aclose()
|
||||
except Exception:
|
||||
|
|
|
|||
18
tests/unit/test_bot_rate_limiter.py
Normal file
18
tests/unit/test_bot_rate_limiter.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Tests for the bot-specific rate limiter factory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contract_check.bot.rate_limit import get_rate_limiter
|
||||
from contract_check.core.rate_limit import MemoryRateLimiter
|
||||
|
||||
|
||||
async def test_get_rate_limiter_empty_url_uses_memory() -> None:
|
||||
limiter = await get_rate_limiter("")
|
||||
assert isinstance(limiter, MemoryRateLimiter)
|
||||
|
||||
|
||||
async def test_get_rate_limiter_invalid_url_falls_back_to_memory() -> None:
|
||||
# An unparseable/malformed URL should not crash the bot; it should fall
|
||||
# back to the in-memory backend so the adapter stays up.
|
||||
limiter = await get_rate_limiter("not-a-valid-redis-url")
|
||||
assert isinstance(limiter, MemoryRateLimiter)
|
||||
Loading…
Add table
Reference in a new issue