H1 (critical): reorder send_support_message — get_or_create_thread now runs AFTER a successful Telegram send, not before. Prod runs a single uvicorn process with no --workers on a sync SQLAlchemy engine sharing one event loop; holding a row-lock across the Telegram call (which can legally take minutes on 429/5xx worker-grade retries) risked freezing the entire API on a second concurrent request from the same user. send_message now also accepts explicit timeout/max_retries so the interactive endpoint uses a bounded budget instead of inheriting the long-polling worker's retry policy. M1: web_support_messages and tg_support_messages both gain a support_chat_id column (188 migration for the already-applied tg_support_messages table; 187 edited in place since it hasn't shipped yet). bridge._handle_group_reply now resolves BOTH the Telegram and web candidate under the CURRENT support_chat_id and refuses delivery loudly (logger.error) if both match, instead of silently preferring the Telegram path — closing a cross-table misroute risk that would surface if the support group is ever recreated. M2: a media reply to a web-mirrored message (including photos with a caption) is now refused in full with an explicit notice back in the topic, instead of silently delivering just the caption text or logging a WARNING nobody sees. M3: list_support_messages/get_support_unread/mark_support_read switched from async def to def — their bodies are pure sync psycopg calls; running them as async def executed blocking DB work directly on the event loop. M5: list_messages now takes a bounded LIMIT (last N, chronological) so a long-lived thread doesn't return its entire history on every poll. L1-L4: warn when Telegram doesn't return a message_id (routing dead-end), rate limit is peeked before send and only recorded on success (failed attempts no longer burn the budget), SlidingWindowLimiter gained the same empty-bucket cleanup as RateLimitMiddleware, and _require_username now strips whitespace so a proxy-injected space can't fork a second thread. L5: removed 187 from _manifest_applied.txt — the deploy pipeline tracks applied migrations via _schema_migrations, not this file, and keeping an unmerged migration name out of it preserves the option to rename before merge without tripping the "can't rename applied migrations" test.
265 lines
12 KiB
Python
265 lines
12 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,
|
||
timeout: float | None = None,
|
||
max_retries: int | None = None,
|
||
) -> dict[str, Any]:
|
||
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
|
||
|
||
`timeout`/`max_retries` — по умолчанию наследуют воркерную политику
|
||
(`_DEFAULT_TIMEOUT_S`/`_DEFAULT_MAX_RETRIES`: на 429 спим Telegram-овский
|
||
`retry_after` — для группы это штатные 30-60с, на 5xx backoff до 30с).
|
||
Это ПРИЕМЛЕМО для `tgbot_main.py` (изолированный long-polling воркер), но
|
||
ФАТАЛЬНО для интерактивного HTTP-запроса (#tgsupport-web review H1) —
|
||
синхронный request/response путь не может легально висеть минуты. Вызывающая
|
||
сторона на interactive-пути ОБЯЗАНА передать узкий бюджет явно (см.
|
||
`app.api.v1.support.send_support_message`)."""
|
||
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
|
||
kwargs: dict[str, Any] = {}
|
||
if timeout is not None:
|
||
kwargs["timeout"] = timeout
|
||
if max_retries is not None:
|
||
kwargs["max_retries"] = max_retries
|
||
result = await self._request("sendMessage", payload, **kwargs)
|
||
return result if isinstance(result, dict) else {}
|