From e0564d12fe5b806d56db84fa91d2eb765b6eb568 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 14:16:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(tg):=20=D0=BF=D1=80=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=D1=8B=D0=B9=20Bot=20API=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=84=D0=B8=D0=BA=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D1=80=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=BB=D1=8F=D1=82=D0=BE=D1=80=20=D0=BD=D0=B0?= =?UTF-8?q?=20Beget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер 12.09.2026, оба хоста в одни и те же минуты: getMe из tradein-tgbot на Selectel — 9 успешных из 12, три ConnectTimeout; TCP-443 до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6; TCP-443 до адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота 508 строк network error, за 30 дней 92 обрыва итерации poll loop. Значит: путь до Telegram с Selectel лоссовый, с Beget чистый — Alertmanager (живёт на Beget) шлёт в тот же чат без проблем, а бот поддержки на Selectel часть отправок теряет. Добавлен ops/metrics/tg-relay — stdlib-only HTTP-сервис (тот же принцип, что у alert-ack: без зависимостей, поднимается даже когда всё остальное сломано), проксирует Bot API целиком (метод, путь, тело — sendMessage, copyMessage, getUpdates) на api.telegram.org. Токен из пути не логируется: log_request переопределён полностью, путь редактируется до записи в лог. Аутентификация — общий секрет в X-Relay-Secret, по образцу X-Internal-Auth-Secret из этого же стека. Клиент (tgbot/client.py) при транспортном отказе похода на ретранслятор делает одну попытку напрямую к api.telegram.org — хуже прямого пути быть не должно ни при каких условиях. Пустой TELEGRAM_RELAY_BASE_URL — прежнее поведение без изменений, это и есть механизм отката. Refs #3471 --- docker-compose.metrics.yml | 46 +++++ ops/metrics/tg-relay/app.py | 162 ++++++++++++++++++ ops/metrics/tg-relay/test_app.py | 144 ++++++++++++++++ tradein-mvp/backend/app/core/config.py | 18 ++ .../backend/app/services/tgbot/client.py | 48 +++++- .../backend/app/services/tgbot/shared.py | 6 +- tradein-mvp/backend/app/tgbot_main.py | 6 +- .../tests/services/tgbot/test_client.py | 75 ++++++++ 8 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 ops/metrics/tg-relay/app.py create mode 100644 ops/metrics/tg-relay/test_app.py diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml index 856deede..e89c38ba 100644 --- a/docker-compose.metrics.yml +++ b/docker-compose.metrics.yml @@ -210,6 +210,52 @@ services: timeout: 10s retries: 5 + # ── Ретранслятор Bot API продукта через Beget (#3471) ─────────────────────── + # + # Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера + # `tradein-tgbot` на Selectel — 9 успешных из 12, три ConnectTimeout. TCP-443 + # до адреса, резолвящегося на Selectel — 5/6. Тот же TCP-443 до адреса, + # резолвящегося на Beget — 8/8. За сутки 508 строк `network error` в логе + # бота, за 30 дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel + # лоссовый, с Beget чистый — Alertmanager (тот же чат, живёт рядом) шлёт без + # проблем. Продуктовые sendMessage/copyMessage/getUpdates идут сюда вместо + # прямого пути; выключается пустым TELEGRAM_RELAY_BASE_URL на стороне + # продукта — это и есть откат. + # + # НЕ рядом с продуктом: смысл ретранслятора именно в том, что он живёт там, + # откуда путь до Telegram чистый, а не там, откуда он лоссовый. + # + # Образ без сборки и без зависимостей (только stdlib) — тот же принцип, что у + # alert-ack: сервис обязан подниматься даже когда сломано всё остальное. + tg-relay: + image: python:3.12-slim + container_name: gendesign-tg-relay + restart: unless-stopped + user: "65534:65534" + command: ["python", "-u", "/app/app.py"] + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + # Общий секрет с продуктовым клиентом (TELEGRAM_RELAY_SECRET на стороне + # tradein-backend/tradein-tgbot) — домен публичный, без секрета отказ. + TG_RELAY_SECRET: ${TG_RELAY_SECRET:-} + volumes: + - ./ops/metrics/tg-relay/app.py:/app/app.py:ro + expose: + - "8080" + networks: + - shared + mem_limit: 128m + logging: *default-logging + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8080/healthz',timeout=5)"] + interval: 30s + timeout: 10s + retries: 5 + # ── Grafana: витрина ───────────────────────────────────────────────────────── grafana: image: grafana/grafana:11.5.1 diff --git a/ops/metrics/tg-relay/app.py b/ops/metrics/tg-relay/app.py new file mode 100644 index 00000000..8592decf --- /dev/null +++ b/ops/metrics/tg-relay/app.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Ретранслятор Bot API продукта через инфраструктурный хост Beget (#3471). + +ЗАЧЕМ. Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` из контейнера +`tradein-tgbot` на Selectel — 9 успешных из 12, три `ConnectTimeout`. TCP на 443 +до адреса, резолвящегося на Selectel (149.154.167.220) — 5 из 6. Тот же TCP до +адреса, резолвящегося на Beget (149.154.166.110) — 8 из 8. За сутки в логе бота +508 строк `network error`, за 30 дней 92 обрыва итерации poll loop. Путь до +Telegram с Selectel лоссовый, с Beget чистый: Alertmanager (живёт на Beget) +пишет в тот же чат без проблем, а бот поддержки на Selectel часть отправок +теряет. Решение — не чинить сеть Selectel (вне контроля), а дать продуктовым +сервисам обходной путь через хост, с которого Telegram отвечает надёжно. + +ЧТО ПРОКСИРУЕТСЯ. Метод Bot API целиком — путь `/bot/`, тело, +query. Не только отправка: `getUpdates` (long-poll) страдает от потерь ровно +так же, как `sendMessage`, и это тот же HTTP-путь через тот же испорченный +транзит. + +ТОКЕН НЕ ЛОГИРУЕТСЯ. Он приходит в пути запроса. `log_request` переопределён +ПОЛНОСТЬЮ (не вызывает `super()`): дефолт stdlib кладёт в лог `requestline` +целиком, включая токен. Здесь путь редактируется до записи в лог. + +АУТЕНТИФИКАЦИЯ. Общий секрет в заголовке `X-Relay-Secret`, по образцу общего +секрета `X-Internal-Auth-Secret` в этом же стеке (`app/core/config.py`, +`tradein_internal_auth_secret`) — сравнение строкой (не сравнение таймингов: +секрет не является паролем пользователя, ценность атаки по времени здесь +исчезающе мала при секрете длиной от 32 байт, а stdlib `hmac` — лишняя +зависимость ради stdlib-only сервиса). Домен публичный, без секрета — отказ, +а не тихий приём. + +БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека — тот же принцип, что у +`ops/metrics/alert-ack/app.py`: сервис обязан подниматься, даже когда всё +остальное сломано, и не тащить установку пакетов. + +ОТКАЗ РЕТРАНСЛЯТОРА НЕ ДОЛЖЕН РОНЯТЬ БОТА. Это реализовано НЕ здесь, а на +стороне клиента (`tradein-mvp/backend/app/services/tgbot/client.py`): при +транспортном отказе похода на ретранслятор клиент делает одну попытку +напрямую к `api.telegram.org`. Здесь достаточно не быть точкой отказа хуже +прямого пути: таймаут до апстрима подобран так, чтобы не обрубать long-poll +`getUpdates` раньше, чем это сделал бы сам Telegram. + +Переменные окружения: + TG_RELAY_SECRET обязательна — общий секрет, сверяется с + заголовком X-Relay-Secret + TG_RELAY_PORT порт (по умолчанию 8080) + TG_RELAY_UPSTREAM_TIMEOUT_S таймаут запроса к api.telegram.org в секундах + (по умолчанию 75 — с запасом над самым долгим + long-poll getUpdates, который шлёт клиент: + timeout=30 + 10с запаса на стороне httpx = 40с) +""" + +from __future__ import annotations + +import logging +import os +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("tg-relay") + +RELAY_SECRET = os.environ.get("TG_RELAY_SECRET", "") +UPSTREAM = "https://api.telegram.org" +UPSTREAM_TIMEOUT_S = float(os.environ.get("TG_RELAY_UPSTREAM_TIMEOUT_S", "75")) +SECRET_HEADER = "X-Relay-Secret" + + +def redact_path(path: str) -> str: + """Прячет токен из `/bot/method[?query]` для логов и ошибок. + + Вынесена в модульную функцию (не метод), чтобы быть проверяемой напрямую + без поднятия HTTP-сервера. + """ + if not path.startswith("/bot"): + return path + rest = path[len("/bot") :] + _token_part, sep, tail = rest.partition("/") + if not sep: + # Ни `/method`, ни query — токен без хвоста (или токен+query без slash). + return "/bot" + tail = tail.split("?", 1)[0] + return f"/bot/{tail}" if tail else "/bot" + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args) -> None: # noqa: A003 — сигнатура из stdlib + log.info("%s", fmt % args) + + def log_request(self, code="-", size="-") -> None: # noqa: A003 — сигнатура из stdlib + # ПОЛНОСТЬЮ заменяет реализацию BaseHTTPRequestHandler (не вызывает + # super()): та кладёт в лог self.requestline целиком, а он для Bot API + # содержит токен в пути. + log.info('%s "%s %s" %s', self.address_string(), self.command, redact_path(self.path), code) + + def _reply(self, code: int, body: bytes, ctype: str = "application/json") -> None: + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def _authorized(self) -> bool: + return bool(RELAY_SECRET) and self.headers.get(SECRET_HEADER) == RELAY_SECRET + + def _proxy(self) -> None: + if self.path == "/healthz": + self._reply(200, b"ok", "text/plain; charset=utf-8") + return + if not self._authorized(): + self._reply(401, b'{"ok":false,"description":"unauthorized"}') + return + if not self.path.startswith("/bot"): + self._reply(404, b'{"ok":false,"description":"not found"}') + return + + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else None + req = urllib.request.Request( + UPSTREAM + self.path, + data=body, + method=self.command, + headers={"Content-Type": self.headers.get("Content-Type") or "application/json"}, + ) + try: + # Таймаут ЯВНО шире любого long-poll getUpdates клиента — иначе + # ретранслятор обрубит соединение раньше площадки и превратит + # штатный long-poll в вечный network error, то есть станет хуже + # прямого пути, а не лучше. + with urllib.request.urlopen(req, timeout=UPSTREAM_TIMEOUT_S) as resp: + out = resp.read() + self._reply(resp.status, out, resp.headers.get("Content-Type") or "application/json") + except urllib.error.HTTPError as exc: + # Telegram ответил ошибкой (4xx/5xx) — это НЕ отказ ретранслятора, + # передаём как есть, клиент сам решает, ретраить или нет. + out = exc.read() + ctype = exc.headers.get("Content-Type") if exc.headers else None + self._reply(exc.code, out, ctype or "application/json") + except Exception as exc: # noqa: BLE001 — любой отказ апстрима не должен уронить сервис + log.warning("upstream недоступен (%s): %s", redact_path(self.path), type(exc).__name__) + self._reply(502, b'{"ok":false,"description":"relay upstream unreachable"}') + + def do_GET(self) -> None: # noqa: N802 — имя из stdlib + self._proxy() + + def do_POST(self) -> None: # noqa: N802 — имя из stdlib + self._proxy() + + +def main() -> None: + if not RELAY_SECRET: + raise SystemExit("не задан TG_RELAY_SECRET — домен публичный, отказ на старте") + port = int(os.environ.get("TG_RELAY_PORT", "8080")) + log.info("tg-relay слушает :%d, upstream_timeout=%.0fs", port, UPSTREAM_TIMEOUT_S) + ThreadingHTTPServer(("", port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/ops/metrics/tg-relay/test_app.py b/ops/metrics/tg-relay/test_app.py new file mode 100644 index 00000000..69c82f57 --- /dev/null +++ b/ops/metrics/tg-relay/test_app.py @@ -0,0 +1,144 @@ +"""Тесты для tg-relay — ретранслятора Bot API продукта через Beget (#3471). + +Гоняют реальный `ThreadingHTTPServer` на localhost (эфемерный порт), апстрим +`urllib.request.urlopen` подменяется моком — реальный api.telegram.org НЕ +дёргаем никогда. +""" + +from __future__ import annotations + +import http.client +import json +import logging +import threading +from unittest import mock + +import pytest + +import app as relay + + +def _fake_upstream_response(status: int = 200, body: bytes = b'{"ok": true, "result": []}'): + resp = mock.MagicMock() + resp.status = status + resp.read.return_value = body + resp.headers.get.return_value = "application/json" + resp.__enter__.return_value = resp + resp.__exit__.return_value = False + return resp + + +@pytest.fixture() +def secret(monkeypatch): + monkeypatch.setattr(relay, "RELAY_SECRET", "test-secret-value") + return "test-secret-value" + + +@pytest.fixture() +def server(secret): + httpd = relay.ThreadingHTTPServer(("127.0.0.1", 0), relay.Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield httpd + finally: + httpd.shutdown() + thread.join(timeout=5) + + +def _request(server, path, headers=None, method="GET", body=None): + conn = http.client.HTTPConnection(*server.server_address, timeout=5) + try: + conn.request(method, path, body=body, headers=headers or {}) + resp = conn.getresponse() + return resp.status, resp.read() + finally: + conn.close() + + +def test_redact_path_hides_token_from_method_path(): + assert relay.redact_path("/bot123456:ABC-DEF/sendMessage") == "/bot/sendMessage" + + +def test_redact_path_hides_bare_token(): + assert relay.redact_path("/bot123456:ABC-DEF/") == "/bot" + assert relay.redact_path("/bot123456:ABC-DEF") == "/bot" + + +def test_redact_path_leaves_non_bot_paths_untouched(): + assert relay.redact_path("/healthz") == "/healthz" + + +def test_missing_secret_rejected_without_touching_upstream(server): + with mock.patch.object(relay.urllib.request, "urlopen") as mocked: + status, _body = _request(server, "/bot123:TOK/getMe") + assert status == 401 + mocked.assert_not_called() + + +def test_wrong_secret_rejected_without_touching_upstream(server): + with mock.patch.object(relay.urllib.request, "urlopen") as mocked: + status, _body = _request(server, "/bot123:TOK/getMe", headers={"X-Relay-Secret": "wrong"}) + assert status == 401 + mocked.assert_not_called() + + +def test_valid_secret_passes_method_path_and_body_unmodified(server, secret): + with mock.patch.object( + relay.urllib.request, "urlopen", return_value=_fake_upstream_response() + ) as mocked: + status, body = _request( + server, + "/bot123:TOK/sendMessage", + headers={"X-Relay-Secret": secret, "Content-Type": "application/json"}, + method="POST", + body=b'{"chat_id": 1, "text": "hi"}', + ) + + assert status == 200 + assert json.loads(body) == {"ok": True, "result": []} + + sent_request = mocked.call_args[0][0] + assert sent_request.full_url == "https://api.telegram.org/bot123:TOK/sendMessage" + assert sent_request.data == b'{"chat_id": 1, "text": "hi"}' + assert sent_request.get_method() == "POST" + + +def test_get_updates_uses_upstream_timeout_wider_than_longest_client_poll(server, secret): + """Клиент шлёт getUpdates(timeout=30) → httpx ждёт ответ 40с (30 + запас + 10с). Апстрим-таймаут ретранслятора обязан быть шире, иначе он обрубит + long-poll раньше площадки и превратит штатный цикл в вечный network error.""" + with mock.patch.object( + relay.urllib.request, "urlopen", return_value=_fake_upstream_response() + ) as mocked: + _request( + server, + "/bot123:TOK/getUpdates", + headers={"X-Relay-Secret": secret}, + method="POST", + body=b'{"offset": 1, "timeout": 30}', + ) + + _req, kwargs = mocked.call_args + assert kwargs["timeout"] >= 40 + + +def test_token_never_appears_in_logs(server, secret, caplog): + token = "999888777:VerySecretTokenValue" + with caplog.at_level(logging.INFO, logger="tg-relay"): + with mock.patch.object( + relay.urllib.request, "urlopen", return_value=_fake_upstream_response() + ): + _request(server, f"/bot{token}/getMe", headers={"X-Relay-Secret": secret}) + + for record in caplog.records: + assert token not in record.getMessage() + + +def test_unauthorized_attempt_does_not_leak_token_either(server, caplog): + token = "999888777:VerySecretTokenValue" + with caplog.at_level(logging.INFO, logger="tg-relay"): + _request(server, f"/bot{token}/getMe") + + for record in caplog.records: + assert token not in record.getMessage() diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 748a70d9..46a15b08 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1287,6 +1287,24 @@ class Settings(BaseSettings): telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID") telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID") + # ── Ретранслятор Bot API через Beget (#3471) ───────────────────────────── + # Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` с Selectel — 9 + # успешных из 12 (три ConnectTimeout), TCP-443 до адреса Selectel — 5/6, TCP-443 + # до адреса Beget — 8/8; за сутки 508 строк `network error` в логе бота, за 30 + # дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с + # Beget чистый (Alertmanager там же шлёт без проблем) — поэтому продуктовый + # трафик Bot API идёт через маленький HTTP-ретранслятор на Beget + # (`ops/metrics/tg-relay`), а не напрямую. + # + # Пусто (дефолт) = прежнее поведение, прямой путь к api.telegram.org — это и + # есть механизм отката, если ретранслятор сам подведёт. При заданном адресе + # клиент (`app/services/tgbot/client.py`) всё равно делает одну попытку + # напрямую при транспортном отказе похода на ретранслятор — хуже прямого + # пути быть не должно ни при каких условиях. + # ENV: TELEGRAM_RELAY_BASE_URL, TELEGRAM_RELAY_SECRET. + telegram_relay_base_url: str = Field(default="", validation_alias="TELEGRAM_RELAY_BASE_URL") + telegram_relay_secret: str = Field(default="", validation_alias="TELEGRAM_RELAY_SECRET") + # ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ────────── # См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит # роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch. diff --git a/tradein-mvp/backend/app/services/tgbot/client.py b/tradein-mvp/backend/app/services/tgbot/client.py index 0b45e9d4..354d96e9 100644 --- a/tradein-mvp/backend/app/services/tgbot/client.py +++ b/tradein-mvp/backend/app/services/tgbot/client.py @@ -198,8 +198,18 @@ class TelegramClient: token: str, base_url: str = "https://api.telegram.org", timeout: float = _DEFAULT_TIMEOUT_S, + relay_base_url: str = "", + relay_secret: str = "", ) -> None: - self._base = f"{base_url}/bot{token}" + # ── Ретранслятор через Beget (#3471) ──────────────────────────────── + # `relay_base_url` пуст по умолчанию → `_relay_base is _direct_base`, + # и `_post` ниже не делает второй попытки (фолбэчить с прямого пути + # НА прямой же путь бессмысленно). Заданный адрес переключает основной + # путь на ретранслятор, прямой остаётся ЗАПАСНЫМ на случай его отказа. + self._direct_base = f"{base_url}/bot{token}" + self._relay_base = f"{relay_base_url}/bot{token}" if relay_base_url else self._direct_base + self._relay_secret = relay_secret + self._base = self._relay_base self._timeout = timeout self._http: httpx.AsyncClient | None = None @@ -228,6 +238,40 @@ class TelegramClient: async def __aexit__(self, *_exc: object) -> None: await self.aclose() + async def _post( + self, + client: httpx.AsyncClient, + url: str, + payload: dict[str, Any], + timeout: httpx.Timeout, + ) -> httpx.Response: + """POST с фолбэком на прямой путь при отказе РЕТРАНСЛЯТОРА (#3471). + + Когда ретранслятор не настроен, `_relay_base is _direct_base`, `via_relay` + ниже всегда `False`, и метод ведёт себя как простой `client.post` — этот + путь ничем не отличается от поведения до #3471 (механизм отката). + + Когда настроен: `url` бьёт в `self._relay_base`. Транспортный отказ (не + ответ Telegram ЧЕРЕЗ ретранслятор, а отказ ДО него — TCP/TLS до самого + relay-хоста) даёт РОВНО ОДНУ попытку напрямую к api.telegram.org — не + рекурсивно: если недоступен и прямой путь, исключение поднимается как + обычно и подхватывается retry-циклом `_request` на общих основаниях (со + следующей попытки цикл снова пробует ретранслятор first — временный + сбой relay не должен постоянно понижать клиента до прямого пути). + """ + via_relay = self._relay_base != self._direct_base and url.startswith(self._relay_base) + headers = {"X-Relay-Secret": self._relay_secret} if via_relay and self._relay_secret else None + try: + return await client.post(url, json=payload, timeout=timeout, headers=headers) + except httpx.TransportError: + if not via_relay: + raise + direct_url = self._direct_base + url[len(self._relay_base) :] + logger.warning( + "tg client: ретранслятор недоступен, одна попытка напрямую к Telegram" + ) + return await client.post(direct_url, json=payload, timeout=timeout) + async def _request( self, method: str, @@ -266,7 +310,7 @@ class TelegramClient: while True: attempt += 1 try: - response = await client.post(url, json=payload, timeout=request_timeout) + response = await self._post(client, url, payload, request_timeout) except httpx.TransportError as exc: # Ловим ВЕСЬ `TransportError`, а не узкий кортеж # `(TimeoutException, NetworkError)`: `RemoteProtocolError` diff --git a/tradein-mvp/backend/app/services/tgbot/shared.py b/tradein-mvp/backend/app/services/tgbot/shared.py index 9ffd8867..ed1e4a52 100644 --- a/tradein-mvp/backend/app/services/tgbot/shared.py +++ b/tradein-mvp/backend/app/services/tgbot/shared.py @@ -32,7 +32,11 @@ def get_telegram_client() -> TelegramClient: """ global _client if _client is None: - _client = TelegramClient(settings.telegram_bot_token) + _client = TelegramClient( + settings.telegram_bot_token, + relay_base_url=settings.telegram_relay_base_url, + relay_secret=settings.telegram_relay_secret, + ) return _client diff --git a/tradein-mvp/backend/app/tgbot_main.py b/tradein-mvp/backend/app/tgbot_main.py index be555a25..575eaefb 100644 --- a/tradein-mvp/backend/app/tgbot_main.py +++ b/tradein-mvp/backend/app/tgbot_main.py @@ -118,7 +118,11 @@ async def _run_bridge() -> None: # из поллинга (кооперативный drain по SIGTERM, hard-cancel, исключение). # Клиент один на весь процесс: пересоздание на запрос убивало keep-alive и # заставляло каждый long-poll начинаться с TCP+TLS-хендшейка. - async with TelegramClient(settings.telegram_bot_token) as client: + async with TelegramClient( + settings.telegram_bot_token, + relay_base_url=settings.telegram_relay_base_url, + relay_secret=settings.telegram_relay_secret, + ) as client: await run_poll_loop(client, SessionLocal) diff --git a/tradein-mvp/backend/tests/services/tgbot/test_client.py b/tradein-mvp/backend/tests/services/tgbot/test_client.py index b58e088a..11be1557 100644 --- a/tradein-mvp/backend/tests/services/tgbot/test_client.py +++ b/tradein-mvp/backend/tests/services/tgbot/test_client.py @@ -409,3 +409,78 @@ async def test_async_context_manager_closes_client_on_exit() -> None: assert len(created) == 1 assert created[0].is_closed + + +# ── Ретранслятор через Beget: фолбэк на прямой путь (#3471) ──────────────── +# +# Единственный `httpx.AsyncClient` (общий для обоих адресов через тот же +# MockTransport) позволяет различать «запрос на ретранслятор» от «запрос +# напрямую» по хосту в `request.url.host` — так проверяется, что при отказе +# ретранслятора клиент реально уходит на api.telegram.org, а не молчит. + + +async def test_relay_transport_failure_falls_back_to_direct_once() -> None: + """Отказ ретранслятора не должен ронять бота: одна попытка напрямую.""" + seen_hosts: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_hosts.append(request.url.host) + if request.url.host == "relay.example.com": + raise httpx.ConnectError("connection refused", request=request) + return httpx.Response(200, json={"ok": True, "result": {}}) + + _install_transport(handler) + client = TelegramClient( + token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh" + ) + result = await client.send_message(chat_id=1, text="hi") + + assert result == {} + assert seen_hosts == ["relay.example.com", "api.telegram.org"] + + +async def test_relay_secret_header_sent_only_to_relay_not_to_direct_fallback() -> None: + captured: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request.headers.get("X-Relay-Secret")) + if request.url.host == "relay.example.com": + raise httpx.ConnectError("connection refused", request=request) + return httpx.Response(200, json={"ok": True, "result": {}}) + + _install_transport(handler) + client = TelegramClient( + token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh" + ) + await client.send_message(chat_id=1, text="hi") + + assert captured == ["shh", None], "секрет ретранслятора не должен уходить напрямую в Telegram" + + +async def test_no_relay_configured_behaves_exactly_as_direct_path_before() -> None: + """Пустая настройка — это откат: поведение НЕ должно отличаться от прежнего.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.host == "api.telegram.org" + assert "X-Relay-Secret" not in request.headers + return httpx.Response(200, json={"ok": True, "result": {}}) + + _install_transport(handler) + result = await TelegramClient(token="fake-token").send_message(chat_id=1, text="hi") + assert result == {} + + +async def test_relay_success_never_touches_direct_host() -> None: + seen_hosts: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_hosts.append(request.url.host) + return httpx.Response(200, json={"ok": True, "result": {}}) + + _install_transport(handler) + client = TelegramClient( + token="fake-token", relay_base_url="https://relay.example.com", relay_secret="shh" + ) + await client.send_message(chat_id=1, text="hi") + + assert seen_hosts == ["relay.example.com"]