{"repo":"NousResearch/hermes-agent","public_id":"3128","display_label":"Telegram fallback transport and proxy routing fixes","summary":"These issues and pull requests address Telegram message delivery on blocked networks by wiring fallback transports, proxy settings, and DoH-based IP discovery into standalone and media send paths.","subsystem":"agent","counts":{"members":11,"open_issues":1,"open_issues_distinct":1,"open_prs":3,"closed_issues":3,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3376},"resolution":{"state":"partially_resolved","by":"pr-3376"},"freshness":{"state":"stale","last_verified_at":"2026-03-27T11:03:13+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1469090909090909},"fix_edges":[{"issue_number":3128,"pr_number":3376,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3128,"title":"[Feature]: Auto-discover working Telegram API endpoints when primary IP is unreachable","url":null,"state":"closed"},{"kind":"issue","number":20914,"title":"send_message_tool._send_telegram doesn't use TelegramFallbackTransport, causing delivery failures on blocked networks","url":null,"state":"closed"},{"kind":"issue","number":20915,"title":"send_message_tool._send_telegram doesn't use TelegramFallbackTransport, causing delivery failures on blocked networks","url":null,"state":"open"},{"kind":"issue","number":44995,"title":"fix(send_message): reuse gateway Telegram HTTP transport config in standalone sends","url":null,"state":"closed"},{"kind":"pr","number":3129,"title":"feat(telegram): auto-discover fallback IPs via DoH when api.telegram.…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3376","number":3376}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3290,"title":"feat(telegram): auto-discover fallback IPs via DoH when api.telegram.org is unreachable","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":3376,"title":"feat(telegram): auto-discover fallback IPs via DoH when api.telegram.org is unreachable","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/telegram.py","gateway/platforms/telegram_network.py","tests/gateway/test_dm_topics.py","tests/gateway/test_send_image_file.py","tests/gateway/test_telegram_conflict.py","tests/gateway/test_telegram_documents.py","tests/gateway/test_telegram_format.py","tests/gateway/test_telegram_network.py","tests/gateway/test_telegram_network_reconnect.py","tests/gateway/test_telegram_reply_mode.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -601,6 +601,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:\n             config.platforms[Platform.TELEGRAM] = PlatformConfig()\n         config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode\n     \n+    telegram_fallback_ips = os.getenv(\"TELEGRAM_FALLBACK_IPS\", \"\")\n+    if telegram_fallback_ips:\n+        if Platform.TELEGRAM not in config.platforms:\n+            config.platforms[Platform.TELEGRAM] = PlatformConfig()\n+        config.platforms[Platform.TELEGRAM].extra[\"fallback_ips\"] = [\n+            ip.strip() for ip in telegram_fallback_ips.split(\",\") if ip.strip()\n+        ]\n+\n     telegram_home = os.getenv(\"TELEGRAM_HOME_CHANNEL\")\n     if telegram_home and Platform.TELEGRAM in config.platforms:\n         config.platforms[Platform.TELEGRAM].home_channel = HomeChannel("]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -11,7 +11,7 @@\n import logging\n import os\n import re\n-from typing import Dict, Optional, Any\n+from typing import Dict, List, Optional, Any\n \n logger = logging.getLogger(__name__)\n ","@@ -25,6 +25,7 @@\n         filters,\n     )\n     from telegram.constants import ParseMode, ChatType\n+    from telegram.request import HTTPXRequest\n     TELEGRAM_AVAILABLE = True\n except ImportError:\n     TELEGRAM_AVAILABLE = False","@@ -34,6 +35,7 @@\n     Application = Any\n     CommandHandler = Any\n     TelegramMessageHandler = Any\n+    HTTPXRequest = Any\n     filters = None\n     ParseMode = None\n     ChatType = None","@@ -59,6 +61,11 @@ class _MockContextTypes:\n     cache_document_from_bytes,\n     SUPPORTED_DOCUMENT_TYPES,\n )\n+from gateway.platforms.telegram_network import (\n+    TelegramFallbackTransport,\n+    discover_fallback_ips,\n+    parse_fallback_ip_env,\n+)\n \n \n def check_telegram_requirements() -> bool:","@@ -138,6 +145,13 @@ def __init__(self, config: PlatformConfig):\n         # DM Topics config from extra.dm_topics\n         self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get(\"dm_topics\", [])\n \n+    def _fallback_ips(self) -> list[str]:\n+        \"\"\"Return validated fallback IPs from config (populated by _apply_env_overrides).\"\"\"\n+        configured = self.config.extra.get(\"fallback_ips\", []) if getattr(self.config, \"extra\", None) else []\n+        if isinstance(configured, str):\n+            configured = configured.split(\",\")\n+        return parse_fallback_ip_env(\",\".join(str(v) for v in configured) if configured else None)\n+\n     @staticmethod\n     def _looks_like_polling_conflict(error: Exception) -> bool:\n         text = str(error).lower()","@@ -474,7 +488,26 @@ async def connect(self) -> bool:\n                 return False\n \n             # Build the application\n-            self._app = Application.builder().token(self.config.token).build()\n+            builder = Application.builder().token(self.config.token)\n+            fallback_ips = self._fallback_ips()\n+            if not fallback_ips:\n+                fallback_ips = await discover_fallback_ips()\n+                logger.info(\n+                    \"[%s] Auto-discovered Telegram fallback IPs: %s\",\n+                    self.name,\n+                    \", \".join(fallback_ips),\n+                )\n+            if fallback_ips:\n+                logger.warning(\n+                    \"[%s] Telegram fallback IPs active: %s\",\n+                    self.name,\n+                    \", \".join(fallback_ips),\n+                )\n+                transport = TelegramFallbackTransport(fallback_ips)\n+                request = HTTPXRequest(httpx_kwargs={\"transport\": transport})\n+                get_updates_request = HTTPXRequest(httpx_kwargs={\"transport\": transport})\n+                builder = builder.request(request).get_updates_request(get_updates_request)\n+            self._app = builder.build()\n             self._bot = self._app.bot\n             \n             # Register handlers"]},{"file":"gateway/platforms/telegram_network.py","hunks":["@@ -0,0 +1,233 @@\n+\"\"\"Telegram-specific network helpers.\n+\n+Provides a hostname-preserving fallback transport for networks where\n+api.telegram.org resolves to an endpoint that is unreachable from the current\n+host. The transport keeps the logical request host and TLS SNI as\n+api.telegram.org while retrying the TCP connection against one or more fallback\n+IPv4 addresses.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import ipaddress\n+import logging\n+import socket\n+from typing import Iterable, Optional\n+\n+import httpx\n+\n+logger = logging.getLogger(__name__)\n+\n+_TELEGRAM_API_HOST = \"api.telegram.org\"\n+\n+# DNS-over-HTTPS providers used to discover Telegram API IPs that may differ\n+# from the (potentially unreachable) IP returned by the local system resolver.\n+_DOH_TIMEOUT = 4.0  # seconds — bounded so connect() isn't noticeably delayed\n+\n+_DOH_PROVIDERS: list[dict] = [\n+    {\n+        \"url\": \"https://dns.google/resolve\",\n+        \"params\": {\"name\": _TELEGRAM_API_HOST, \"type\": \"A\"},\n+        \"headers\": {},\n+    },\n+    {\n+        \"url\": \"https://cloudflare-dns.com/dns-query\",\n+        \"params\": {\"name\": _TELEGRAM_API_HOST, \"type\": \"A\"},\n+        \"headers\": {\"Accept\": \"application/dns-json\"},\n+    },\n+]\n+\n+# Last-resort IPs when DoH is also blocked.  These are stable Telegram Bot API\n+# endpoints in the 149.154.160.0/20 block (same seed used by OpenClaw).\n+_SEED_FALLBACK_IPS: list[str] = [\"149.154.167.220\"]\n+\n+\n+class TelegramFallbackTransport(httpx.AsyncBaseTransport):\n+    \"\"\"Retry Telegram Bot API requests via fallback IPs while preserving TLS/SNI.\n+\n+    Requests continue to target https://api.telegram.org/... logically, but on\n+    connect failures the underlying TCP connection is retried against a known\n+    reachable IP. This is effectively the programmatic equivalent of\n+    ``curl --resolve api.telegram.org:443:<ip>``.\n+    \"\"\"\n+\n+    def __init__(self, fallback_ips: Iterable[str], **transport_kwargs):\n+        self._fallback_ips = [ip for ip in dict.fromkeys(_normalize_fallback_ips(fallback_ips))]\n+        self._primary = httpx.AsyncHTTPTransport(**transport_kwargs)\n+        self._fallbacks = {\n+            ip: httpx.AsyncHTTPTransport(**transport_kwargs) for ip in self._fallback_ips\n+        }\n+        self._sticky_ip: Optional[str] = None\n+        self._sticky_lock = asyncio.Lock()\n+\n+    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:\n+        if request.url.host != _TELEGRAM_API_HOST or not self._fallback_ips:\n+            return await self._primary.handle_async_request(request)\n+\n+        sticky_ip = self._sticky_ip\n+        attempt_order: list[Optional[str]] = [sticky_ip] if sticky_ip else [None]\n+        for ip in self._fallback_ips:\n+            if ip != sticky_ip:\n+                attempt_order.append(ip)\n+\n+        last_error: Exception | None = None\n+        for ip in attempt_order:\n+            candidate = request if ip is None else _rewrite_request_for_ip(request, ip)\n+            transport = self._primary if ip is None else self._fallbacks[ip]\n+            try:\n+                response = await transport.handle_async_request(candidate)\n+                if ip is not None and self._sticky_ip != ip:\n+                    async with self._sticky_lock:\n+                        if self._sticky_ip != ip:\n+                            self._sticky_ip = ip\n+                            logger.warning(\n+                                \"[Telegram] Primary api.telegram.org path unreachable; using sticky fallback IP %s\",\n+                                ip,\n+                            )\n+                return response\n+            except Exception as exc:\n+                last_error = exc\n+                if not _is_retryable_connect_error(exc):\n+                    raise\n+                if ip is None:\n+                    logger.warning(\n+                        \"[Telegram] Primary api.telegram.org connection failed (%s); trying fallback IPs %s\",\n+                        exc,\n+                        \", \".join(self._fallback_ips),\n+                    )\n+                    continue\n+                logger.warning(\"[Telegram] Fallback IP %s failed: %s\", ip, exc)\n+                continue\n+\n+        assert last_error is not None\n+        raise last_error\n+\n+    async def aclose(self) -> None:\n+        await self._primary.aclose()\n+        for transport in self._fallbacks.values():\n+            await transport.aclose()\n+\n+\n+def _normalize_fallback_ips(values: Iterable[str]) -> list[str]:\n+    normalized: list[str] = []\n+    for value in values:\n+        raw = str(value).strip()\n+        if not raw:\n+            continue\n+        try:\n+            addr = ipaddress.ip_address(raw)\n+        except ValueError:\n+            logger.warning(\"Ignoring invalid Telegram fallback IP: %r\", raw)\n+            continue\n+        if addr.version != 4:\n+            logger.warning(\"Ignoring non-IPv4 Telegram fallback IP: %s\", raw)\n+            continue\n+        normalized.append(str(addr))\n+    return normalized\n+\n+\n+def parse_fallback_ip_env(value: str | None) -> list[str]:\n+    if not value:\n+        return []\n+    parts = [part.strip() for part in value.split(\",\")]\n+    return _normalize_fallback_ips(parts)\n+\n+\n+def _resolve_system_dns() -> set[str]:\n+    \"\"\"Return the IPv4 addresses that the OS resolver gives for api.telegram.org.\"\"\"\n+    try:\n+        results = socket.getaddrinfo(_TELEGRAM_API_HOST, 443, socket.AF_INET)\n+        return {addr[4][0] for addr in results}\n+    except Exception:\n+        return set()\n+\n+\n+async def _query_doh_provider(\n+    client: httpx.AsyncClient, provider: dict\n+) -> list[str]:\n+    \"\"\"Query one DoH provider and return A-record IPs.\"\"\"\n+    try:\n+        resp = await client.get(\n+            provider[\"url\"], params=provider[\"params\"], headers=provider[\"headers\"]\n+        )\n+        resp.raise_for_status()\n+        data = resp.json()\n+        ips: list[str] = []\n+        for answer in data.get(\"Answer\", []):\n+            if answer.get(\"type\") != 1:  # A record\n+                continue\n+            raw = answer.get(\"data\", \"\").strip()\n+            try:\n+                ipaddress.ip_address(raw)\n+                ips.append(raw)\n+            except ValueError:\n+                continue\n+        return ips\n+    except Exception as exc:\n+        logger.debug(\"DoH query to %s failed: %s\", provider[\"url\"], exc)\n+        return []\n+\n+\n+async def discover_fallback_ips() -> list[str]:\n+    \"\"\"Auto-discover Telegram API IPs via DNS-over-HTTPS.\n+\n+    Resolves api.telegram.org through Google and Cloudflare DoH, collects all\n+    unique IPs, and excludes the system-DNS-resolved IP (which is presumably\n+    unreachable on this network).  Falls back to a hardcoded seed list when DoH\n+    is also unavailable.\n+    \"\"\"\n+    async with httpx.AsyncClient(timeout=httpx.Timeout(_DOH_TIMEOUT)) as client:\n+        doh_tasks = [_query_doh_provider(client, p) for p in _DOH_PROVIDERS]\n+        system_dns_task = asyncio.to_thread(_resolve_system_dns)\n+        results = await asyncio.gather(system_dns_task, *doh_tasks, return_exceptions=True)\n+\n+    # results[0] = system DNS IPs (set), results[1:] = DoH IP lists\n+    system_ips: set[str] = results[0] if isinstance(results[0], set) else set()\n+\n+    doh_ips: list[str] = []\n+    for r in results[1:]:\n+        if isinstance(r, list):\n+            doh_ips.extend(r)\n+\n+    # Deduplicate preserving order, exclude system-DNS IPs\n+    seen: set[str] = set()\n+    candidates: list[str] = []\n+    for ip in doh_ips:\n+        if ip not in seen and ip not in system_ips:\n+            seen.add(ip)\n+            candidates.append(ip)\n+\n+    # Validate through existing normalization\n+    validated = _normalize_fallback_ips(candidates)\n+\n+    if validated:\n+        logger.debug(\"Discovered Telegram fallback IPs via DoH: %s\", \", \".join(validated))\n+        return validated\n+\n+    logger.info(\n+        \"DoH discovery yielded no new IPs (system DNS: %s); using seed fallback IPs %s\",\n+        \", \".join(system_ips) or \"unknown\",\n+        \", \".join(_SEED_FALLBACK_IPS),\n+    )\n+    return list(_SEED_FALLBACK_IPS)\n+\n+\n+def _rewrite_request_for_ip(request: httpx.Request, ip: str) -> httpx.Request:\n+    original_host = request.url.host or _TELEGRAM_API_HOST\n+    url = request.url.copy_with(host=ip)\n+    headers = request.headers.copy()\n+    headers[\"host\"] = original_host\n+    extensions = dict(request.extensions)\n+    extensions[\"sni_hostname\"] = original_host\n+    return httpx.Request(\n+        method=request.method,\n+        url=url,\n+        headers=headers,\n+        stream=request.stream,\n+        extensions=extensions,\n+    )\n+\n+\n+def _is_retryable_connect_error(exc: Exception) -> bool:\n+    return isinstance(exc, (httpx.ConnectTimeout, httpx.ConnectError))"]},{"file":"tests/gateway/test_dm_topics.py","hunks":["@@ -32,7 +32,7 @@ def _ensure_telegram_mock():\n     telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n     telegram_mod.constants.ChatType.PRIVATE = \"private\"\n \n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n         sys.modules.setdefault(name, telegram_mod)\n \n "]},{"file":"tests/gateway/test_send_image_file.py","hunks":["@@ -76,7 +76,7 @@ def _ensure_telegram_mock():\n     telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n     telegram_mod.constants.ChatType.PRIVATE = \"private\"\n \n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n         sys.modules.setdefault(name, telegram_mod)\n \n "]},{"file":"tests/gateway/test_telegram_conflict.py","hunks":["@@ -20,7 +20,7 @@ def _ensure_telegram_mock():\n     telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n     telegram_mod.constants.ChatType.PRIVATE = \"private\"\n \n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n         sys.modules.setdefault(name, telegram_mod)\n \n ","@@ -29,6 +29,14 @@ def _ensure_telegram_mock():\n from gateway.platforms.telegram import TelegramAdapter  # noqa: E402\n \n \n[redacted:email](autouse=True)\n+def _no_auto_discovery(monkeypatch):\n+    \"\"\"Disable DoH auto-discovery so connect() uses the plain builder chain.\"\"\"\n+    async def _noop():\n+        return []\n+    monkeypatch.setattr(\"gateway.platforms.telegram.discover_fallback_ips\", _noop)\n+\n+\n @pytest.mark.asyncio\n async def test_connect_rejects_same_host_token_lock(monkeypatch):\n     adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))"]},{"file":"tests/gateway/test_telegram_documents.py","hunks":["@@ -45,7 +45,7 @@ def _ensure_telegram_mock():\n     telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n     telegram_mod.constants.ChatType.PRIVATE = \"private\"\n \n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n         sys.modules.setdefault(name, telegram_mod)\n \n "]},{"file":"tests/gateway/test_telegram_format.py","hunks":["@@ -28,7 +28,7 @@ def _ensure_telegram_mock():\n     mod.constants.ChatType.SUPERGROUP = \"supergroup\"\n     mod.constants.ChatType.CHANNEL = \"channel\"\n     mod.constants.ChatType.PRIVATE = \"private\"\n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n         sys.modules.setdefault(name, mod)\n \n "]},{"file":"tests/gateway/test_telegram_network.py","hunks":["@@ -0,0 +1,626 @@\n+\"\"\"Tests for gateway.platforms.telegram_network – fallback transport layer.\n+\n+Background\n+----------\n+api.telegram.org resolves to an IP (e.g. 149.154.166.110) that is unreachable\n+from some networks.  The workaround: route TCP through a different IP in the\n+same Telegram-owned 149.154.160.0/20 block (e.g. 149.154.167.220) while\n+keeping TLS SNI and the Host header as api.telegram.org so Telegram's edge\n+servers still accept the request.  This is the programmatic equivalent of:\n+\n+    curl --resolve api.telegram.org:443:149.154.167.220 https://api.telegram.org/bot<token>/getMe\n+\n+The TelegramFallbackTransport implements this: try the primary (DNS-resolved)\n+path first, and on ConnectTimeout / ConnectError fall through to configured\n+fallback IPs in order, then \"stick\" to whichever IP works.\n+\"\"\"\n+\n+import httpx\n+import pytest\n+\n+from gateway.platforms import telegram_network as tnet\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+class FakeTransport(httpx.AsyncBaseTransport):\n+    \"\"\"Records calls and raises / returns based on a host→action mapping.\"\"\"\n+\n+    def __init__(self, calls, behavior):\n+        self.calls = calls\n+        self.behavior = behavior\n+        self.closed = False\n+\n+    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:\n+        self.calls.append(\n+            {\n+                \"url_host\": request.url.host,\n+                \"host_header\": request.headers.get(\"host\"),\n+                \"sni_hostname\": request.extensions.get(\"sni_hostname\"),\n+                \"path\": request.url.path,\n+            }\n+        )\n+        action = self.behavior.get(request.url.host, \"ok\")\n+        if action == \"timeout\":\n+            raise httpx.ConnectTimeout(\"timed out\")\n+        if action == \"connect_error\":\n+            raise httpx.ConnectError(\"connect error\")\n+        if isinstance(action, Exception):\n+            raise action\n+        return httpx.Response(200, request=request, text=\"ok\")\n+\n+    async def aclose(self) -> None:\n+        self.closed = True\n+\n+\n+def _fake_transport_factory(calls, behavior):\n+    \"\"\"Returns a factory that creates FakeTransport instances.\"\"\"\n+    instances = []\n+\n+    def factory(**kwargs):\n+        t = FakeTransport(calls, behavior)\n+        instances.append(t)\n+        return t\n+\n+    factory.instances = instances\n+    return factory\n+\n+\n+def _telegram_request(path=\"/botTOKEN/getMe\"):\n+    return httpx.Request(\"GET\", f\"https://api.telegram.org{path}\")\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════════\n+# IP parsing & validation\n+# ═══════════════════════════════════════════════════════════════════════════\n+\n+class TestParseFallbackIpEnv:\n+    def test_filters_invalid_and_ipv6(self, caplog):\n+        ips = tnet.parse_fallback_ip_env(\"149.154.167.220, bad, 2001:67c:4e8:f004::9,149.154.167.220\")\n+        assert ips == [\"149.154.167.220\", \"149.154.167.220\"]\n+        assert \"Ignoring invalid Telegram fallback IP\" in caplog.text\n+        assert \"Ignoring non-IPv4 Telegram fallback IP\" in caplog.text\n+\n+    def test_none_returns_empty(self):\n+        assert tnet.parse_fallback_ip_env(None) == []\n+\n+    def test_empty_string_returns_empty(self):\n+        assert tnet.parse_fallback_ip_env(\"\") == []\n+\n+    def test_whitespace_only_returns_empty(self):\n+        assert tnet.parse_fallback_ip_env(\"  ,  , \") == []\n+\n+    def test_single_valid_ip(self):\n+        assert tnet.parse_fallback_ip_env(\"149.154.167.220\") == [\"149.154.167.220\"]\n+\n+    def test_multiple_valid_ips(self):\n+        ips = tnet.parse_fallback_ip_env(\"149.154.167.220, 149.154.167.221\")\n+        assert ips == [\"149.154.167.220\", \"149.154.167.221\"]\n+\n+    def test_rejects_leading_zeros(self, caplog):\n+        \"\"\"Leading zeros are ambiguous (octal?) so ipaddress rejects them.\"\"\"\n+        ips = tnet.parse_fallback_ip_env(\"149.154.167.010\")\n+        assert ips == []\n+        assert \"Ignoring invalid\" in caplog.text\n+\n+\n+class TestNormalizeFallbackIps:\n+    def test_deduplication_happens_at_transport_level(self):\n+        \"\"\"_normalize does not dedup; TelegramFallbackTransport.__init__ does.\"\"\"\n+        raw = [\"149.154.167.220\", \"149.154.167.220\"]\n+        assert tnet._normalize_fallback_ips(raw) == [\"149.154.167.220\", \"149.154.167.220\"]\n+\n+    def test_empty_strings_skipped(self):\n+        assert tnet._normalize_fallback_ips([\"\", \"  \", \"149.154.167.220\"]) == [\"149.154.167.220\"]\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════════\n+# Request rewriting\n+# ═══════════════════════════════════════════════════════════════════════════\n+\n+class TestRewriteRequestForIp:\n+    def test_preserves_host_and_sni(self):\n+        request = _telegram_request()\n+        rewritten = tnet._rewrite_request_for_ip(request, \"149.154.167.220\")\n+\n+        assert rewritten.url.host == \"149.154.167.220\"\n+        assert rewritten.headers[\"host\"] == \"api.telegram.org\"\n+        assert rewritten.extensions[\"sni_hostname\"] == \"api.telegram.org\"\n+        assert rewritten.url.path == \"/botTOKEN/getMe\"\n+\n+    def test_preserves_method_and_path(self):\n+        request = httpx.Request(\"POST\", \"https://api.telegram.org/botTOKEN/sendMessage\")\n+        rewritten = tnet._rewrite_request_for_ip(request, \"149.154.167.220\")\n+\n+        assert rewritten.method == \"POST\"\n+        assert rewritten.url.path == \"/botTOKEN/sendMessage\"\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════════\n+# Fallback transport – core behavior\n+# ═══════════════════════════════════════════════════════════════════════════\n+\n+class TestFallbackTransport:\n+    \"\"\"Primary path fails → try fallback IPs → stick to whichever works.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_falls_back_on_connect_timeout_and_becomes_sticky(self, monkeypatch):\n+        calls = []\n+        behavior = {\"api.telegram.org\": \"timeout\", \"149.154.167.220\": \"ok\"}\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\"])\n+        resp = await transport.handle_async_request(_telegram_request())\n+\n+        assert resp.status_code == 200\n+        assert transport._sticky_ip == \"149.154.167.220\"\n+        # First attempt was primary (api.telegram.org), second was fallback\n+        assert calls[0][\"url_host\"] == \"api.telegram.org\"\n+        assert calls[1][\"url_host\"] == \"149.154.167.220\"\n+        assert calls[1][\"host_header\"] == \"api.telegram.org\"\n+        assert calls[1][\"sni_hostname\"] == \"api.telegram.org\"\n+\n+        # Second request goes straight to sticky IP\n+        calls.clear()\n+        resp2 = await transport.handle_async_request(_telegram_request())\n+        assert resp2.status_code == 200\n+        assert calls[0][\"url_host\"] == \"149.154.167.220\"\n+\n+    @pytest.mark.asyncio\n+    async def test_falls_back_on_connect_error(self, monkeypatch):\n+        calls = []\n+        behavior = {\"api.telegram.org\": \"connect_error\", \"149.154.167.220\": \"ok\"}\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\"])\n+        resp = await transport.handle_async_request(_telegram_request())\n+\n+        assert resp.status_code == 200\n+        assert transport._sticky_ip == \"149.154.167.220\"\n+\n+    @pytest.mark.asyncio\n+    async def test_does_not_fallback_on_non_connect_error(self, monkeypatch):\n+        \"\"\"Errors like ReadTimeout are not connection issues — don't retry.\"\"\"\n+        calls = []\n+        behavior = {\"api.telegram.org\": httpx.ReadTimeout(\"read timeout\"), \"149.154.167.220\": \"ok\"}\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\"])\n+\n+        with pytest.raises(httpx.ReadTimeout):\n+            await transport.handle_async_request(_telegram_request())\n+\n+        assert [c[\"url_host\"] for c in calls] == [\"api.telegram.org\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_all_ips_fail_raises_last_error(self, monkeypatch):\n+        calls = []\n+        behavior = {\"api.telegram.org\": \"timeout\", \"149.154.167.220\": \"timeout\"}\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\"])\n+\n+        with pytest.raises(httpx.ConnectTimeout):\n+            await transport.handle_async_request(_telegram_request())\n+\n+        assert [c[\"url_host\"] for c in calls] == [\"api.telegram.org\", \"149.154.167.220\"]\n+        assert transport._sticky_ip is None\n+\n+    @pytest.mark.asyncio\n+    async def test_multiple_fallback_ips_tried_in_order(self, monkeypatch):\n+        calls = []\n+        behavior = {\n+            \"api.telegram.org\": \"timeout\",\n+            \"149.154.167.220\": \"timeout\",\n+            \"149.154.167.221\": \"ok\",\n+        }\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\", \"149.154.167.221\"])\n+        resp = await transport.handle_async_request(_telegram_request())\n+\n+        assert resp.status_code == 200\n+        assert transport._sticky_ip == \"149.154.167.221\"\n+        assert [c[\"url_host\"] for c in calls] == [\n+            \"api.telegram.org\",\n+            \"149.154.167.220\",\n+            \"149.154.167.221\",\n+        ]\n+\n+    @pytest.mark.asyncio\n+    async def test_sticky_ip_tried_first_but_falls_through_if_stale(self, monkeypatch):\n+        \"\"\"If the sticky IP stops working, the transport retries others.\"\"\"\n+        calls = []\n+        behavior = {\n+            \"api.telegram.org\": \"timeout\",\n+            \"149.154.167.220\": \"ok\",\n+            \"149.154.167.221\": \"ok\",\n+        }\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\", \"149.154.167.221\"])\n+\n+        # First request: primary fails → .220 works → becomes sticky\n+        await transport.handle_async_request(_telegram_request())\n+        assert transport._sticky_ip == \"149.154.167.220\"\n+\n+        # Now .220 goes bad too\n+        calls.clear()\n+        behavior[\"149.154.167.220\"] = \"timeout\"\n+\n+        resp = await transport.handle_async_request(_telegram_request())\n+        assert resp.status_code == 200\n+        # Tried sticky (.220) first, then fell through to .221\n+        assert [c[\"url_host\"] for c in calls] == [\"149.154.167.220\", \"149.154.167.221\"]\n+        assert transport._sticky_ip == \"149.154.167.221\"\n+\n+\n+class TestFallbackTransportPassthrough:\n+    \"\"\"Requests that don't need fallback behavior.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_non_telegram_host_bypasses_fallback(self, monkeypatch):\n+        calls = []\n+        behavior = {}\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\"])\n+        request = httpx.Request(\"GET\", \"https://example.com/path\")\n+        resp = await transport.handle_async_request(request)\n+\n+        assert resp.status_code == 200\n+        assert calls[0][\"url_host\"] == \"example.com\"\n+        assert transport._sticky_ip is None\n+\n+    @pytest.mark.asyncio\n+    async def test_empty_fallback_list_uses_primary_only(self, monkeypatch):\n+        calls = []\n+        behavior = {}\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([])\n+        resp = await transport.handle_async_request(_telegram_request())\n+\n+        assert resp.status_code == 200\n+        assert calls[0][\"url_host\"] == \"api.telegram.org\"\n+\n+    @pytest.mark.asyncio\n+    async def test_primary_succeeds_no_fallback_needed(self, monkeypatch):\n+        calls = []\n+        behavior = {\"api.telegram.org\": \"ok\"}\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", _fake_transport_factory(calls, behavior))\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\"])\n+        resp = await transport.handle_async_request(_telegram_request())\n+\n+        assert resp.status_code == 200\n+        assert transport._sticky_ip is None\n+        assert len(calls) == 1\n+\n+\n+class TestFallbackTransportInit:\n+    def test_deduplicates_fallback_ips(self, monkeypatch):\n+        monkeypatch.setattr(\n+            tnet.httpx, \"AsyncHTTPTransport\", lambda **kw: FakeTransport([], {})\n+        )\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\", \"149.154.167.220\"])\n+        assert transport._fallback_ips == [\"149.154.167.220\"]\n+\n+    def test_filters_invalid_ips_at_init(self, monkeypatch):\n+        monkeypatch.setattr(\n+            tnet.httpx, \"AsyncHTTPTransport\", lambda **kw: FakeTransport([], {})\n+        )\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\", \"not-an-ip\"])\n+        assert transport._fallback_ips == [\"149.154.167.220\"]\n+\n+\n+class TestFallbackTransportClose:\n+    @pytest.mark.asyncio\n+    async def test_aclose_closes_all_transports(self, monkeypatch):\n+        factory = _fake_transport_factory([], {})\n+        monkeypatch.setattr(tnet.httpx, \"AsyncHTTPTransport\", factory)\n+\n+        transport = tnet.TelegramFallbackTransport([\"149.154.167.220\", \"149.154.167.221\"])\n+        await transport.aclose()\n+\n+        # 1 primary + 2 fallback transports\n+        assert len(factory.instances) == 3\n+        assert all(t.closed for t in factory.instances)\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════════\n+# Config layer – TELEGRAM_FALLBACK_IPS env → config.extra\n+# ═══════════════════════════════════════════════════════════════════════════\n+\n+class TestConfigFallbackIps:\n+    def test_env_var_populates_config_extra(self, monkeypatch):\n+        from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides\n+\n+        monkeypatch.setenv(\"TELEGRAM_FALLBACK_IPS\", \"149.154.167.220,149.154.167.221\")\n+        config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"tok\")})\n+        _apply_env_overrides(config)\n+\n+        assert config.platforms[Platform.TELEGRAM].extra[\"fallback_ips\"] == [\n+            \"149.154.167.220\", \"149.154.167.221\",\n+        ]\n+\n+    def test_env_var_creates_platform_if_missing(self, monkeypatch):\n+        from gateway.config import GatewayConfig, Platform, _apply_env_overrides\n+\n+        monkeypatch.setenv(\"TELEGRAM_FALLBACK_IPS\", \"149.154.167.220\")\n+        config = GatewayConfig(platforms={})\n+        _apply_env_overrides(config)\n+\n+        assert Platform.TELEGRAM in config.platforms\n+        assert config.platforms[Platform.TELEGRAM].extra[\"fallback_ips\"] == [\"149.154.167.220\"]\n+\n+    def test_env_var_strips_whitespace(self, monkeypatch):\n+        from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides\n+\n+        monkeypatch.setenv(\"TELEGRAM_FALLBACK_IPS\", \"  149.154.167.220 , 149.154.167.221  \")\n+        config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"tok\")})\n+        _apply_env_overrides(config)\n+\n+        assert config.platforms[Platform.TELEGRAM].extra[\"fallback_ips\"] == [\n+            \"149.154.167.220\", \"149.154.167.221\",\n+        ]\n+\n+    def test_empty_env_var_does_not_populate(self, monkeypatch):\n+        from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides\n+\n+        monkeypatch.setenv(\"TELEGRAM_FALLBACK_IPS\", \"\")\n+        config = GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"tok\")})\n+        _apply_env_overrides(config)\n+\n+        assert \"fallback_ips\" not in config.platforms[Platform.TELEGRAM].extra\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════════\n+# Adapter layer – _fallback_ips() reads config correctly\n+# ═══════════════════════════════════════════════════════════════════════════\n+\n+class TestAdapterFallbackIps:\n+    def _make_adapter(self, extra=None):\n+        import sys\n+        from unittest.mock import MagicMock\n+\n+        # Ensure telegram mock is in place\n+        if \"telegram\" not in sys.modules or not hasattr(sys.modules[\"telegram\"], \"__file__\"):\n+            mod = MagicMock()\n+            mod.ext.ContextTypes.DEFAULT_TYPE = type(None)\n+            mod.constants.ParseMode.MARKDOWN_V2 = \"MarkdownV2\"\n+            mod.constants.ChatType.GROUP = \"group\"\n+            mod.constants.ChatType.SUPERGROUP = \"supergroup\"\n+            mod.constants.ChatType.CHANNEL = \"channel\"\n+            mod.constants.ChatType.PRIVATE = \"private\"\n+            for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n+                sys.modules.setdefault(name, mod)\n+\n+        from gateway.config import PlatformConfig\n+        from gateway.platforms.telegram import TelegramAdapter\n+\n+        config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+        if extra:\n+            config.extra.update(extra)\n+        return TelegramAdapter(config)\n+\n+    def test_list_in_extra(self):\n+        adapter = self._make_adapter(extra={\"fallback_ips\": [\"149.154.167.220\"]})\n+        assert adapter._fallback_ips() == [\"149.154.167.220\"]\n+\n+    def test_csv_string_in_extra(self):\n+        adapter = self._make_adapter(extra={\"fallback_ips\": \"149.154.167.220,149.154.167.221\"})\n+        assert adapter._fallback_ips() == [\"149.154.167.220\", \"149.154.167.221\"]\n+\n+    def test_empty_extra(self):\n+        adapter = self._make_adapter()\n+        assert adapter._fallback_ips() == []\n+\n+    def test_no_extra_attr(self):\n+        adapter = self._make_adapter()\n+        adapter.config.extra = None\n+        assert adapter._fallback_ips() == []\n+\n+    def test_invalid_ips_filtered(self):\n+        adapter = self._make_adapter(extra={\"fallback_ips\": [\"149.154.167.220\", \"not-valid\"]})\n+        assert adapter._fallback_ips() == [\"149.154.167.220\"]\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════════\n+# DoH auto-discovery\n+# ═══════════════════════════════════════════════════════════════════════════\n+\n+def _doh_answer(*ips: str) -> dict:\n+    \"\"\"Build a minimal DoH JSON response with A records.\"\"\"\n+    return {\"Answer\": [{\"type\": 1, \"data\": ip} for ip in ips]}\n+\n+\n+class FakeDoHClient:\n+    \"\"\"Mock httpx.AsyncClient for DoH queries.\"\"\"\n+\n+    def __init__(self, responses: dict):\n+        # responses: URL prefix → (status, json_body) | Exception\n+        self._responses = responses\n+        self.requests_made: list[dict] = []\n+\n+    @staticmethod\n+    def _make_response(status, body, url):\n+        \"\"\"Build an httpx.Response with a request attached (needed for raise_for_status).\"\"\"\n+        request = httpx.Request(\"GET\", url)\n+        return httpx.Response(status, json=body, request=request)\n+\n+    async def get(self, url, *, params=None, headers=None, **kwargs):\n+        self.requests_made.append({\"url\": url, \"params\": params, \"headers\": headers})\n+        for prefix, action in self._responses.items():\n+            if url.startswith(prefix):\n+                if isinstance(action, Exception):\n+                    raise action\n+                status, body = action\n+                return self._make_response(status, body, url)\n+        return self._make_response(200, {}, url)\n+\n+    async def __aenter__(self):\n+        return self\n+\n+    async def __aexit__(self, *args):\n+        pass\n+\n+\n+class TestDiscoverFallbackIps:\n+    \"\"\"Tests for discover_fallback_ips() — DoH-based auto-discovery.\"\"\"\n+\n+    def _patch_doh(self, monkeypatch, responses, system_dns_ips=None):\n+        \"\"\"Wire up fake DoH client and system DNS.\"\"\"\n+        client = FakeDoHClient(responses)\n+        monkeypatch.setattr(tnet.httpx, \"AsyncClient\", lambda **kw: client)\n+\n+        if system_dns_ips is not None:\n+            addrs = [(None, None, None, None, (ip, 443)) for ip in system_dns_ips]\n+            monkeypatch.setattr(tnet.socket, \"getaddrinfo\", lambda *a, **kw: addrs)\n+        else:\n+            def _fail(*a, **kw):\n+                raise OSError(\"dns failed\")\n+            monkeypatch.setattr(tnet.socket, \"getaddrinfo\", _fail)\n+        return client\n+\n+    @pytest.mark.asyncio\n+    async def test_google_and_cloudflare_ips_collected(self, monkeypatch):\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, _doh_answer(\"149.154.167.220\")),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer(\"149.154.167.221\")),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert \"149.154.167.220\" in ips\n+        assert \"149.154.167.221\" in ips\n+\n+    @pytest.mark.asyncio\n+    async def test_system_dns_ip_excluded(self, monkeypatch):\n+        \"\"\"The IP from system DNS is the one that doesn't work — exclude it.\"\"\"\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, _doh_answer(\"149.154.166.110\", \"149.154.167.220\")),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer(\"149.154.166.110\")),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == [\"149.154.167.220\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_doh_results_deduplicated(self, monkeypatch):\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, _doh_answer(\"149.154.167.220\")),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer(\"149.154.167.220\")),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == [\"149.154.167.220\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_doh_timeout_falls_back_to_seed(self, monkeypatch):\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": httpx.TimeoutException(\"timeout\"),\n+            \"https://cloudflare-dns.com\": httpx.TimeoutException(\"timeout\"),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == tnet._SEED_FALLBACK_IPS\n+\n+    @pytest.mark.asyncio\n+    async def test_doh_connect_error_falls_back_to_seed(self, monkeypatch):\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": httpx.ConnectError(\"refused\"),\n+            \"https://cloudflare-dns.com\": httpx.ConnectError(\"refused\"),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == tnet._SEED_FALLBACK_IPS\n+\n+    @pytest.mark.asyncio\n+    async def test_doh_malformed_json_falls_back_to_seed(self, monkeypatch):\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, {\"Status\": 0}),  # no Answer key\n+            \"https://cloudflare-dns.com\": (200, {\"garbage\": True}),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == tnet._SEED_FALLBACK_IPS\n+\n+    @pytest.mark.asyncio\n+    async def test_one_provider_fails_other_succeeds(self, monkeypatch):\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": httpx.TimeoutException(\"timeout\"),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer(\"149.154.167.220\")),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == [\"149.154.167.220\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_system_dns_failure_keeps_all_doh_ips(self, monkeypatch):\n+        \"\"\"If system DNS fails, nothing gets excluded — all DoH IPs kept.\"\"\"\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, _doh_answer(\"149.154.166.110\", \"149.154.167.220\")),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer()),\n+        }, system_dns_ips=None)  # triggers OSError\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert \"149.154.166.110\" in ips\n+        assert \"149.154.167.220\" in ips\n+\n+    @pytest.mark.asyncio\n+    async def test_all_doh_ips_same_as_system_dns_uses_seed(self, monkeypatch):\n+        \"\"\"DoH returns only the same blocked IP — seed list is the fallback.\"\"\"\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, _doh_answer(\"149.154.166.110\")),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer(\"149.154.166.110\")),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == tnet._SEED_FALLBACK_IPS\n+\n+    @pytest.mark.asyncio\n+    async def test_cloudflare_gets_accept_header(self, monkeypatch):\n+        client = self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, _doh_answer(\"149.154.167.220\")),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer(\"149.154.167.221\")),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        await tnet.discover_fallback_ips()\n+\n+        cf_reqs = [r for r in client.requests_made if \"cloudflare\" in r[\"url\"]]\n+        assert cf_reqs\n+        assert cf_reqs[0][\"headers\"][\"Accept\"] == \"application/dns-json\"\n+\n+    @pytest.mark.asyncio\n+    async def test_non_a_records_ignored(self, monkeypatch):\n+        \"\"\"AAAA records (type 28) and CNAME (type 5) should be skipped.\"\"\"\n+        answer = {\n+            \"Answer\": [\n+                {\"type\": 5, \"data\": \"telegram.org\"},  # CNAME\n+                {\"type\": 28, \"data\": \"2001:67c:4e8:f004::9\"},  # AAAA\n+                {\"type\": 1, \"data\": \"149.154.167.220\"},  # A ✓\n+            ]\n+        }\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, answer),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer()),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == [\"149.154.167.220\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_invalid_ip_in_doh_response_skipped(self, monkeypatch):\n+        answer = {\"Answer\": [\n+            {\"type\": 1, \"data\": \"not-an-ip\"},\n+            {\"type\": 1, \"data\": \"149.154.167.220\"},\n+        ]}\n+        self._patch_doh(monkeypatch, {\n+            \"https://dns.google\": (200, answer),\n+            \"https://cloudflare-dns.com\": (200, _doh_answer()),\n+        }, system_dns_ips=[\"149.154.166.110\"])\n+\n+        ips = await tnet.discover_fallback_ips()\n+        assert ips == [\"149.154.167.220\"]"]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -27,7 +27,7 @@ def _ensure_telegram_mock():\n     telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n     telegram_mod.constants.ChatType.PRIVATE = \"private\"\n \n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n         sys.modules.setdefault(name, telegram_mod)\n \n ","@@ -36,6 +36,14 @@ def _ensure_telegram_mock():\n from gateway.platforms.telegram import TelegramAdapter  # noqa: E402\n \n \n[redacted:email](autouse=True)\n+def _no_auto_discovery(monkeypatch):\n+    \"\"\"Disable DoH auto-discovery so connect() uses the plain builder chain.\"\"\"\n+    async def _noop():\n+        return []\n+    monkeypatch.setattr(\"gateway.platforms.telegram.discover_fallback_ips\", _noop)\n+\n+\n def _make_adapter() -> TelegramAdapter:\n     return TelegramAdapter(PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\"))\n "]},{"file":"tests/gateway/test_telegram_reply_mode.py","hunks":["@@ -25,7 +25,7 @@ def _ensure_telegram_mock():\n     mod.constants.ChatType.SUPERGROUP = \"supergroup\"\n     mod.constants.ChatType.CHANNEL = \"channel\"\n     mod.constants.ChatType.PRIVATE = \"private\"\n-    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\"):\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n         sys.modules.setdefault(name, mod)\n \n \n"]}]},{"kind":"pr","number":20924,"title":"fix(send_message): wire Telegram fallback transport into one-shot sends","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"736ffb3bc","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":44996,"title":"fix(send_message): reuse gateway Telegram HTTP config","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":45017,"title":"fix(telegram): reuse gateway HTTP transport config in standalone sends (#44995)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#44996","number":44996},{"kind":"pr","value":"#44995","number":44995},{"kind":"pr","value":"#44996","number":44996}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":119934,"title":"fix(send_message): route standalone Telegram sends through TelegramFallbackTransport","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}