All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m4s
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот доставляет ответ клиенту. Полный лог переписки в Postgres. Отдельный контейнер на long-polling, а не webhook в tradein-backend: не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API. Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage. Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате, поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор. Безопасность (найдено ревью, воспроизведено эмпирически): - токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет; утекал в GlitchTip через locals стек-фреймов (include_local_variables по умолчанию True) и через span data HttpxIntegration. Закрыто include_local_variables=False + regex-редактор в before_send (обе формы: /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub. - httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в docker logs каждые 30с. Приглушён до WARNING. Надёжность: - kill-switch при пустом токене — idle-блокировка, не exit(0): при restart: unless-stopped выход с любым кодом даёт рестарт-луп. unless-stopped выбран сознательно — только он гарантирует автозапуск после ребута VPS. - stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше, чем докрутится long-poll (30с) и отработает drain (100с). - сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset: иначе сессия в failed-transaction не давала сохранить offset, апдейт переигрывался и зеркалился в топик по кругу. 152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента одним DELETE. Ретенция — follow-up. Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS, без них воркер штатно висит в idle. Порядок — в DEPLOY.md. Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked, throttle-окно шапки, redaction токена во всех формах event).
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""Тонкая httpx-обёртка над Telegram Bot API (#tgsupport).
|
||
|
||
Зачем свой клиент, а не aiogram: единственные нужные методы — `getUpdates`
|
||
(long-polling), `copyMessage` (зеркалирование ЛЮБОГО типа контента без ре-аплоада)
|
||
и `sendMessage` (заголовки/приветствия/уведомления). aiogram — избыточная
|
||
зависимость (webhook-framework, dispatcher, FSM) ради трёх HTTP-вызовов; в стеке
|
||
уже есть httpx (см. `app.services.dadata`, `app.services.geocoder` — тот же паттерн
|
||
retry/timeout).
|
||
|
||
Docs: https://core.telegram.org/bots/api
|
||
|
||
Ретраи:
|
||
- HTTP 429 (Too Many Requests) — уважаем `parameters.retry_after` из тела ответа
|
||
(Telegram сам говорит сколько ждать), fallback на `_DEFAULT_RETRY_AFTER_S`.
|
||
- HTTP 5xx / сетевые ошибки (timeout/connect) — экспоненциальный backoff,
|
||
`capped` на `_MAX_BACKOFF_S`.
|
||
- Любая другая 4xx (400/401/403/404) — НЕ ретраится, сразу `TelegramApiError`
|
||
(запрос некорректен или прав нет — повтор не поможет).
|
||
|
||
БЕЗОПАСНОСТЬ: наши `logger.*`-вызовы здесь содержат только имя метода API,
|
||
HTTP-статус и `description` из ответа Telegram — токен туда не пишем.
|
||
Это НЕ гарантирует, что токен не утечёт по другим стокам: он живёт в
|
||
`self._base`/`url` (локальные переменные stack-фрейма `_request`), а GlitchTip
|
||
(sentry_sdk) по умолчанию прикладывает locals к traceback и Httpx-интеграция
|
||
кладёт полный URL в span data. Эти стоки закрываются НЕ здесь, а в
|
||
`app.tgbot_main` (`include_local_variables=False`, `before_send`-редактор,
|
||
`traces_sample_rate=0.0`) и подавлением INFO-логов самого `httpx`-логгера
|
||
(который печатает полный request URL, включая токен, на уровне INFO).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_DEFAULT_TIMEOUT_S = 15.0
|
||
_DEFAULT_RETRY_AFTER_S = 5.0
|
||
_MAX_BACKOFF_S = 30.0
|
||
_DEFAULT_MAX_RETRIES = 5
|
||
|
||
|
||
class TelegramApiError(Exception):
|
||
"""Telegram Bot API ответил `ok: false` (после исчерпания ретраев, если применимо)."""
|
||
|
||
def __init__(self, method: str, error_code: int, description: str) -> None:
|
||
self.method = method
|
||
self.error_code = error_code
|
||
self.description = description
|
||
super().__init__(f"Telegram API {method} failed: {error_code} {description}")
|
||
|
||
|
||
def _extract_retry_after(
|
||
response: httpx.Response, default: float = _DEFAULT_RETRY_AFTER_S
|
||
) -> float:
|
||
"""Достаёт `parameters.retry_after` из тела 429-ответа. Fallback — `default`."""
|
||
try:
|
||
data = response.json()
|
||
except ValueError:
|
||
return default
|
||
if not isinstance(data, dict):
|
||
return default
|
||
params = data.get("parameters")
|
||
if isinstance(params, dict):
|
||
retry_after = params.get("retry_after")
|
||
if isinstance(retry_after, int | float):
|
||
return float(retry_after)
|
||
return default
|
||
|
||
|
||
def _error_from_body(response: httpx.Response) -> tuple[int, str]:
|
||
"""Парсит (error_code, description) из тела ответа Telegram; fallback на HTTP-статус."""
|
||
try:
|
||
data = response.json()
|
||
except ValueError:
|
||
return response.status_code, (response.text or "")[:200]
|
||
if not isinstance(data, dict):
|
||
return response.status_code, str(data)[:200]
|
||
error_code = data.get("error_code", response.status_code)
|
||
description = data.get("description", "")
|
||
code = int(error_code) if isinstance(error_code, int | float) else response.status_code
|
||
return code, str(description)
|
||
|
||
|
||
class TelegramClient:
|
||
"""Bot API клиент на httpx.AsyncClient. Каждый вызов — отдельное короткоживущее соединение."""
|
||
|
||
def __init__(
|
||
self,
|
||
token: str,
|
||
base_url: str = "https://api.telegram.org",
|
||
timeout: float = _DEFAULT_TIMEOUT_S,
|
||
) -> None:
|
||
self._base = f"{base_url}/bot{token}"
|
||
self._timeout = timeout
|
||
|
||
async def _request(
|
||
self,
|
||
method: str,
|
||
payload: dict[str, Any],
|
||
*,
|
||
timeout: float | None = None,
|
||
max_retries: int = _DEFAULT_MAX_RETRIES,
|
||
) -> Any:
|
||
"""POST `method` с JSON-телом `payload`. Ретраит 429/5xx/network, иначе raise сразу."""
|
||
url = f"{self._base}/{method}"
|
||
effective_timeout = timeout if timeout is not None else self._timeout
|
||
attempt = 0
|
||
|
||
while True:
|
||
attempt += 1
|
||
try:
|
||
async with httpx.AsyncClient(timeout=effective_timeout) as client:
|
||
response = await client.post(url, json=payload)
|
||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||
if attempt > max_retries:
|
||
logger.error(
|
||
"tg client: %s — network error после %d попыток: %s", method, attempt, exc
|
||
)
|
||
raise
|
||
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||
logger.warning(
|
||
"tg client: %s — network error (попытка %d/%d): %s — retry через %.0fs",
|
||
method,
|
||
attempt,
|
||
max_retries,
|
||
exc,
|
||
backoff,
|
||
)
|
||
await asyncio.sleep(backoff)
|
||
continue
|
||
|
||
if response.status_code == 429:
|
||
retry_after = _extract_retry_after(response)
|
||
if attempt > max_retries:
|
||
error_code, description = _error_from_body(response)
|
||
logger.error("tg client: %s — 429 после %d попыток, сдаёмся", method, attempt)
|
||
raise TelegramApiError(method, error_code, description)
|
||
logger.warning(
|
||
"tg client: %s — HTTP 429 (попытка %d/%d), retry_after=%.0fs",
|
||
method,
|
||
attempt,
|
||
max_retries,
|
||
retry_after,
|
||
)
|
||
await asyncio.sleep(retry_after)
|
||
continue
|
||
|
||
if response.status_code >= 500:
|
||
if attempt > max_retries:
|
||
error_code, description = _error_from_body(response)
|
||
logger.error(
|
||
"tg client: %s — HTTP %d после %d попыток, сдаёмся",
|
||
method,
|
||
response.status_code,
|
||
attempt,
|
||
)
|
||
raise TelegramApiError(method, error_code, description)
|
||
backoff = min(2.0**attempt, _MAX_BACKOFF_S)
|
||
logger.warning(
|
||
"tg client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs",
|
||
method,
|
||
response.status_code,
|
||
attempt,
|
||
max_retries,
|
||
backoff,
|
||
)
|
||
await asyncio.sleep(backoff)
|
||
continue
|
||
|
||
if response.status_code >= 400:
|
||
# 4xx кроме 429 — запрос некорректен/прав нет, повтор не поможет.
|
||
error_code, description = _error_from_body(response)
|
||
raise TelegramApiError(method, error_code, description)
|
||
|
||
try:
|
||
data = response.json()
|
||
except ValueError as exc:
|
||
raise TelegramApiError(
|
||
method, response.status_code, f"invalid json: {exc}"
|
||
) from exc
|
||
|
||
if not isinstance(data, dict) or not data.get("ok"):
|
||
error_code, description = _error_from_body(response)
|
||
raise TelegramApiError(method, error_code, description)
|
||
|
||
return data.get("result")
|
||
|
||
async def get_updates(
|
||
self,
|
||
offset: int,
|
||
timeout: int = 30,
|
||
allowed_updates: list[str] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Long-polling getUpdates. `timeout` — сколько Telegram держит запрос открытым (сек).
|
||
|
||
HTTP-таймаут запроса берётся с запасом (`timeout + 10s`), чтобы не обрывать
|
||
соединение раньше, чем ответит сам Telegram long-poll.
|
||
"""
|
||
payload: dict[str, Any] = {"offset": offset, "timeout": timeout}
|
||
if allowed_updates is not None:
|
||
payload["allowed_updates"] = allowed_updates
|
||
result = await self._request(
|
||
"getUpdates", payload, timeout=float(timeout) + 10.0, max_retries=3
|
||
)
|
||
return result if isinstance(result, list) else []
|
||
|
||
async def copy_message(
|
||
self,
|
||
*,
|
||
chat_id: int,
|
||
from_chat_id: int,
|
||
message_id: int,
|
||
message_thread_id: int | None = None,
|
||
reply_to_message_id: int | None = None,
|
||
) -> dict[str, Any]:
|
||
"""copyMessage — зеркалит ЛЮБОЙ тип контента без ре-аплоада файла."""
|
||
payload: dict[str, Any] = {
|
||
"chat_id": chat_id,
|
||
"from_chat_id": from_chat_id,
|
||
"message_id": message_id,
|
||
}
|
||
if message_thread_id:
|
||
payload["message_thread_id"] = message_thread_id
|
||
if reply_to_message_id:
|
||
payload["reply_to_message_id"] = reply_to_message_id
|
||
result = await self._request("copyMessage", payload)
|
||
return result if isinstance(result, dict) else {}
|
||
|
||
async def send_message(
|
||
self,
|
||
*,
|
||
chat_id: int,
|
||
text: str,
|
||
message_thread_id: int | None = None,
|
||
reply_to_message_id: int | None = None,
|
||
) -> dict[str, Any]:
|
||
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке)."""
|
||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||
if message_thread_id:
|
||
payload["message_thread_id"] = message_thread_id
|
||
if reply_to_message_id:
|
||
payload["reply_to_message_id"] = reply_to_message_id
|
||
result = await self._request("sendMessage", payload)
|
||
return result if isinstance(result, dict) else {}
|