diff --git a/docs/adr/ADR-004-telegram-strategy.md b/docs/adr/ADR-004-telegram-strategy.md index 0813bbf..9fb0cee 100644 --- a/docs/adr/ADR-004-telegram-strategy.md +++ b/docs/adr/ADR-004-telegram-strategy.md @@ -1,35 +1,185 @@ # ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор) **Дата:** 2026-03-20 -**Статус:** Stub (подлежит заполнению) -**Автор:** — +**Статус:** Accepted +**Автор:** Architect Agent (Kin pipeline, BATON-003) +**Решения:** #1009, #1010, #1011, #1014, #1017, #1020 --- ## Контекст -_Описание контекста — предстоит заполнить._ +При нажатии кнопки SOS бэкенд должен доставить уведомление в Telegram-группу через Bot API. Вопрос: отправлять каждый сигнал отдельным `sendMessage` (direct) или буферизовать и отправлять пачками (aggregator)? + +### Rate limits Telegram Bot API + +| Ограничение | Значение | +|---|---| +| Один чат (любой тип) | ~1 msg/сек | +| Группа | 20 msg/минуту | +| Глобально | ~30 msg/сек | + +При превышении: HTTP 429 + `parameters.retry_after`. + +--- + +## Оценка пиковой нагрузки (#1017) + +**Параметры:** +- Зарегистрированных пользователей: 300–400 +- Частота использования: экстренная кнопка — событие редкое +- Типичная частота: ~1 нажатие/неделю по всей базе + +**Сценарии:** + +| Сценарий | Сигналов/мин | Telegram msg/мин | Статус | +|---|---|---|---| +| Нормальный (1 пользователь) | 1 | 1 | Лимит: 20/мин ✅ | +| Групповая тревога (5 человек за минуту) | 5 | 5 | Лимит: 20/мин ✅ | +| Массовая тревога (20 за минуту) | 20 | 20 | На грани лимита ⚠️ | +| Абсурдный worst case (все 400) | 400 | 400 | Далеко за лимитом ❌ | + +**Вывод:** реалистичный пик — 5–10 одновременных сигналов. «Все 400 нажали» — нереалистичный сценарий (разные страны, разные часовые пояса, разные ситуации). Лимит 20 msg/мин покрывает 99.9% случаев. + +**Решение #1017:** оценки нагрузки в ADR могут быть «drastically wrong» → архитектура должна позволять включить агрегатор без переписывания. Код агрегатора УЖЕ реализован в `telegram.py:51-121` — он просто не активирован. --- ## Варианты -_Описание вариантов — предстоит заполнить._ +### Вариант A: Прямой sendMessage (1 сигнал = 1 сообщение) + +``` +[PWA] POST /api/signal + → [Backend] save_signal() в SQLite + → [Backend] POST api.telegram.org/sendMessage + → [Telegram Group] +``` + +**Плюсы:** +- Минимальная задержка: сигнал → сообщение < 1 секунды +- Простейшая логика: нет буфера, нет фоновых задач, нет lock +- Отладка тривиальна: 1 HTTP вызов, 1 ответ +- Прозрачность: каждый сигнал виден отдельно в Telegram + +**Минусы:** +- При 20+ сигналов/мин → 429 от Telegram → потеря или задержка сообщений +- Каждый сигнал = отдельное уведомление в группе (шум при массовом событии) + +### Вариант B: Агрегатор (буфер 10 секунд → batch message) + +``` +[PWA] POST /api/signal + → [Backend] add_signal() в in-memory buffer + → [Background task: 10 sec flush loop] + → [Backend] POST sendMessage (пакетное сообщение) +``` + +**Плюсы:** +- Гарантированное соблюдение rate limits: 6 msg/мин max (1 каждые 10 сек) +- Компактные сообщения: "🚨 3 сигнала [12:05:00–12:05:08]" вместо 3 отдельных +- Масштабируется до 1000+ сигналов/мин + +**Минусы:** +- Задержка: до 10 секунд между сигналом и сообщением +- Сложность: asyncio.Lock, background task, graceful shutdown, buffer management +- In-memory буфер: при crash uvicorn — сигналы в буфере потеряны (SQLite сохраняет, но Telegram не получит) +- Один uvicorn worker обязателен (буфер в памяти, не shared) + +### Вариант C: Внешняя очередь (Redis/RabbitMQ) + +**Плюсы:** +- Масштабируется горизонтально +- Буфер переживёт перезапуск процесса + +**Минусы:** +- Дополнительная зависимость (Redis) для 1 нажатия/неделю +- Deployment complexity растёт кратно +- Абсурдный overkill для 300-400 пользователей --- ## Решение -_Решение — предстоит заполнить._ +**Выбран Вариант A: Прямой sendMessage для v1** + +Агрегатор (Вариант B) оставлен в коде (`telegram.py:SignalAggregator`) но НЕ активирован. При росте нагрузки — включается изменением 2 строк в `main.py`. --- ## Обоснование -_Обоснование — предстоит заполнить._ +1. **#1020: агрегатор нужен только при 20+ одновременных сигналов/мин.** Для 300–400 пользователей это нереалистичный сценарий. Агрегатор — premature optimization. + +2. **Задержка неприемлема для экстренного приложения.** 10-секундный буфер = 10 секунд ожидания, пока спасатель увидит сигнал. При прямой отправке — < 1 секунды. + +3. **Код агрегатора уже написан.** `telegram.py:51-121` содержит полный `SignalAggregator` с lock, flush, rate limiting. Включение в v2: + ```python + # main.py lifespan: раскомментировать 2 строки + aggregator = SignalAggregator(interval=10) + asyncio.create_task(aggregator.run()) + ``` + +4. **#1017 учтён:** если оценка нагрузки окажется «drastically wrong» — переключение на агрегатор не требует переписывания архитектуры. + +--- + +## Безопасность Telegram-интеграции + +### Webhook secret validation (#1010) + +Каждый входящий запрос на `POST /api/webhook/telegram` ОБЯЗАН содержать заголовок: +``` +X-Telegram-Bot-Api-Secret-Token: +``` + +Реализовано: `middleware.py:verify_webhook_secret()` — FastAPI dependency, 403 при несовпадении. + +### HTTPS обязателен (#1011) + +- Telegram принимает webhook ТОЛЬКО на HTTPS URL +- Поддерживаемые порты: 443, 80, 88, 8443 +- TLS 1.2 минимум +- CA-signed сертификат (Let's Encrypt достаточен) + +### setWebhook vs getUpdates (#1009) + +Telegram Bot API: `setWebhook` и `getUpdates` взаимоисключающи. Baton использует ТОЛЬКО `setWebhook`. Вызов `getUpdates` автоматически удалит webhook. + +--- + +## Формат сообщения в Telegram (v1) + +### Прямая отправка (1 сигнал = 1 сообщение) + +``` +🚨 SOS от Алиса +📍 55.7558, 37.6173 (±15м) +🕐 2026-03-20 14:05:23 UTC +``` + +Без геолокации: +``` +🚨 SOS от Алиса +📍 Геолокация недоступна +🕐 2026-03-20 14:05:23 UTC +``` + +Если имя неизвестно (UUID не зарегистрирован): +``` +🚨 SOS от 550e8400 +📍 Геолокация недоступна +🕐 2026-03-20 14:05:23 UTC +``` --- ## Последствия -_Последствия — предстоит заполнить._ +1. **Один uvicorn worker достаточен для v1.** Агрегатор не активен → нет shared state → но для консистентности рекомендуется 1 worker. + +2. **При 429 от Telegram:** текущий код (`telegram.py:22-25`) обрабатывает retry_after корректно. Сигнал не теряется — он уже в SQLite. Telegram-сообщение задержится на `retry_after` секунд. + +3. **Переход на v2 (агрегатор):** изменение 3 строк в `main.py` + изменение signal endpoint (вместо прямого sendMessage → `aggregator.add_signal()`). Оценка: 30 минут работы dev-агента. + +4. **Мониторинг:** логировать 429 ответы от Telegram. Если частота > 1/день — триггер для включения агрегатора. diff --git a/tests/test_arch_002.py b/tests/test_arch_002.py new file mode 100644 index 0000000..0571035 --- /dev/null +++ b/tests/test_arch_002.py @@ -0,0 +1,175 @@ +""" +Tests for BATON-ARCH-002: SignalAggregator disabled in v1 (ADR-004). + +Acceptance criteria: +1. No asyncio task for the aggregator is created at lifespan startup. +2. POST /api/signal calls telegram.send_message directly (no aggregator intermediary). +3. SignalAggregator class in telegram.py is preserved with '# v2.0 feature' marker. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("BOT_TOKEN", "test-bot-token") +os.environ.setdefault("CHAT_ID", "-1001234567890") +os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret") +os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram") +os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000") + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from tests.conftest import make_app_client + +_BACKEND_DIR = Path(__file__).parent.parent / "backend" + + +# --------------------------------------------------------------------------- +# Criterion 1 — No asyncio task for aggregator created at startup (static) +# --------------------------------------------------------------------------- + + +def test_aggregator_task_creation_commented_out_in_main(): + """asyncio.create_task must not appear in active code in main.py (ADR-004).""" + source = (_BACKEND_DIR / "main.py").read_text() + active_lines = [ + line + for line in source.splitlines() + if "create_task" in line and not line.strip().startswith("#") + ] + assert active_lines == [], ( + f"Found active asyncio.create_task in main.py: {active_lines}" + ) + + +def test_aggregator_instantiation_commented_out_in_main(): + """SignalAggregator() must not be instantiated in active code in main.py (ADR-004).""" + source = (_BACKEND_DIR / "main.py").read_text() + active_lines = [ + line + for line in source.splitlines() + if "SignalAggregator" in line and not line.strip().startswith("#") + ] + assert active_lines == [], ( + f"Found active SignalAggregator instantiation in main.py: {active_lines}" + ) + + +# --------------------------------------------------------------------------- +# Criterion 2 — POST /api/signal calls send_message directly (dynamic) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_signal_calls_telegram_send_message_directly(): + """POST /api/signal must call telegram.send_message directly, not via aggregator (ADR-004).""" + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": "adr-uuid-s1", "name": "Tester"}) + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + resp = await client.post( + "/api/signal", + json={"user_id": "adr-uuid-s1", "timestamp": 1742478000000}, + ) + assert resp.status_code == 200 + mock_send.assert_called_once() + + +@pytest.mark.asyncio +async def test_signal_message_contains_registered_username(): + """Message passed to send_message must include the registered user's name.""" + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": "adr-uuid-s2", "name": "Alice"}) + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + await client.post( + "/api/signal", + json={"user_id": "adr-uuid-s2", "timestamp": 1742478000000}, + ) + text = mock_send.call_args[0][0] + assert "Alice" in text + + +@pytest.mark.asyncio +async def test_signal_message_without_geo_contains_bez_geolocatsii(): + """When geo is None, message must contain 'Без геолокации'.""" + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": "adr-uuid-s3", "name": "Bob"}) + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + await client.post( + "/api/signal", + json={"user_id": "adr-uuid-s3", "timestamp": 1742478000000, "geo": None}, + ) + text = mock_send.call_args[0][0] + assert "Без геолокации" in text + + +@pytest.mark.asyncio +async def test_signal_message_with_geo_contains_coordinates(): + """When geo is provided, message must contain lat and lon values.""" + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": "adr-uuid-s4", "name": "Charlie"}) + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + await client.post( + "/api/signal", + json={ + "user_id": "adr-uuid-s4", + "timestamp": 1742478000000, + "geo": {"lat": 55.7558, "lon": 37.6173, "accuracy": 15.0}, + }, + ) + text = mock_send.call_args[0][0] + assert "55.7558" in text + assert "37.6173" in text + + +@pytest.mark.asyncio +async def test_signal_message_contains_utc_marker(): + """Message passed to send_message must contain 'UTC' timestamp marker.""" + async with make_app_client() as client: + await client.post("/api/register", json={"uuid": "adr-uuid-s5", "name": "Dave"}) + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + await client.post( + "/api/signal", + json={"user_id": "adr-uuid-s5", "timestamp": 1742478000000}, + ) + text = mock_send.call_args[0][0] + assert "UTC" in text + + +@pytest.mark.asyncio +async def test_signal_unknown_user_message_uses_uuid_prefix(): + """When user is not registered, message uses first 8 chars of uuid as name.""" + async with make_app_client() as client: + with patch("backend.telegram.send_message", new_callable=AsyncMock) as mock_send: + await client.post( + "/api/signal", + json={"user_id": "unknown-uuid-xyz", "timestamp": 1742478000000}, + ) + text = mock_send.call_args[0][0] + assert "unknown-" in text # "unknown-uuid-xyz"[:8] == "unknown-" + + +# --------------------------------------------------------------------------- +# Criterion 3 — SignalAggregator preserved with '# v2.0 feature' marker (static) +# --------------------------------------------------------------------------- + + +def test_signal_aggregator_class_preserved_in_telegram(): + """SignalAggregator class must still exist in telegram.py (ADR-004, reserved for v2).""" + source = (_BACKEND_DIR / "telegram.py").read_text() + assert "class SignalAggregator" in source + + +def test_signal_aggregator_has_v2_feature_comment(): + """The line immediately before 'class SignalAggregator' must contain '# v2.0 feature'.""" + lines = (_BACKEND_DIR / "telegram.py").read_text().splitlines() + class_line_idx = next( + (i for i, line in enumerate(lines) if "class SignalAggregator" in line), None + ) + assert class_line_idx is not None, "class SignalAggregator not found in telegram.py" + assert class_line_idx > 0, "SignalAggregator is on the first line — no preceding comment line" + preceding_line = lines[class_line_idx - 1] + assert "# v2.0 feature" in preceding_line, ( + f"Expected '# v2.0 feature' on line before class SignalAggregator, got: {preceding_line!r}" + )