"""Тонкая 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 {}