baton/docs/adr/ADR-004-telegram-strategy.md
Gros Frumos 2ee953866b kin: BATON-ARCH-014 Доработать ADR-002 и ADR-004 по замечаниям ревью
- Создан docs/adr/ADR-002-offline-pattern.md (Accepted, дата 2026-03-20)
  с секцией Open Questions: #1001, охват 78.75%, ACTION:/конвенция #1049
- ADR-004: добавлен "exponential backoff согласно решению #1046" к строке 429/retry_after
- ARCHITECTURE.md: добавлена вводная фраза "ADR-файлы хранятся в docs/adr/"
  и строка таблицы для ADR-002 (Accepted)
- tests/test_arch_004.py: удалены 4 теста на отсутствие ADR-002,
  устаревшие после создания нового ADR-002 (BATON-ARCH-014 supersedes)
- tests/test_arch_014.py: 14 новых тестов для критериев приёмки
- Все 216 тестов: passed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:05:04 +02:00

8.9 KiB
Raw Permalink Blame History

ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор)

Дата: 2026-03-20 Статус: 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. При последовательных 429 рекомендуется exponential backoff согласно решению #1046.


Оценка пиковой нагрузки (#1017)

Параметры:

  • Зарегистрированных пользователей: 300400
  • Частота использования: экстренная кнопка — событие редкое
  • Типичная частота: ~1 нажатие/неделю по всей базе

Сценарии:

Сценарий Сигналов/мин Telegram msg/мин Статус
Нормальный (1 пользователь) 1 1 Лимит: 20/мин
Групповая тревога (5 человек за минуту) 5 5 Лимит: 20/мин
Массовая тревога (20 за минуту) 20 20 На грани лимита ⚠️
Абсурдный worst case (все 400) 400 400 Далеко за лимитом

Вывод: реалистичный пик — 510 одновременных сигналов. «Все 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:0012: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+ одновременных сигналов/мин. Для 300400 пользователей это нереалистичный сценарий. Агрегатор — premature optimization.

  2. Задержка неприемлема для экстренного приложения. 10-секундный буфер = 10 секунд ожидания, пока спасатель увидит сигнал. При прямой отправке — < 1 секунды.

  3. Код агрегатора уже написан. telegram.py:51-121 содержит полный SignalAggregator с lock, flush, rate limiting. Включение в v2:

    # 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: <WEBHOOK_SECRET>

Реализовано: 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/день — триггер для включения агрегатора.