kin: BATON-UI-002 Исправить устаревший статус iOS 17.4 EU PWA и добавить UI-текст в ADR-002
This commit is contained in:
parent
4fd8d860a7
commit
bb1a3b643a
2 changed files with 332 additions and 7 deletions
|
|
@ -1,35 +1,185 @@
|
||||||
# ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор)
|
# ADR-004: Стратегия отправки в Telegram (прямой vs агрегатор)
|
||||||
|
|
||||||
**Дата:** 2026-03-20
|
**Дата:** 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: <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/день — триггер для включения агрегатора.
|
||||||
|
|
|
||||||
175
tests/test_arch_002.py
Normal file
175
tests/test_arch_002.py
Normal file
|
|
@ -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}"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue