diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index bf18ff61d39a..6a6fb549c3ac 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -4336,16 +4336,45 @@ def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict: kwargs["limits"] = _pool_limits return kwargs - disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in {"1", "true", "yes", "on"}) + disable_fallback = ( + os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "") + .strip() + .lower() + in {"1", "true", "yes", "on"} + ) fallback_ips = self._fallback_ips() - if not fallback_ips: - logger.warning("[%s] Discovering Telegram API fallback IPs via DNS-over-HTTPS…", self.name) - fallback_ips = await discover_fallback_ips() - logger.info( - "[%s] Auto-discovered Telegram fallback IPs: %s", + if disable_fallback: + fallback_ips = [] + if not fallback_ips and not disable_fallback: + discovery_timeout = self._env_float_clamped( + "HERMES_TELEGRAM_FALLBACK_DISCOVERY_TIMEOUT", + 5.0, + min_value=0.0, + ) + logger.warning( + "[%s] Discovering Telegram API fallback IPs via DNS-over-HTTPS…", self.name, - ", ".join(fallback_ips), ) + try: + fallback_ips = await _await_with_thread_deadline( + discover_fallback_ips(), + timeout=discovery_timeout, + ) + except Exception as exc: + logger.warning( + "[%s] Telegram fallback-IP discovery failed after %.0fs; " + "continuing with the plain api.telegram.org path: %s", + self.name, + discovery_timeout, + _redact_telegram_error_text(exc), + ) + fallback_ips = [] + else: + logger.info( + "[%s] Auto-discovered Telegram fallback IPs: %s", + self.name, + ", ".join(fallback_ips), + ) proxy_targets = ["api.telegram.org", *fallback_ips] proxy_url = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=proxy_targets) diff --git a/tests/gateway/test_telegram_polling_progress.py b/tests/gateway/test_telegram_polling_progress.py index 318d13acbe1e..1cc85155675e 100644 --- a/tests/gateway/test_telegram_polling_progress.py +++ b/tests/gateway/test_telegram_polling_progress.py @@ -214,6 +214,131 @@ async def heartbeat(): await adapter.disconnect() +@pytest.mark.asyncio +async def test_fallback_disabled_skips_doh_discovery_on_connect(monkeypatch): + """The fallback kill switch must bypass DoH discovery, not just transport use.""" + adapter = _make_adapter() + polling_app = _lifecycle_app() + + async def start_polling_with_progress(**_kwargs): + adapter._record_polling_progress(adapter._polling_generation) + + polling_app.updater.start_polling = AsyncMock( + side_effect=start_polling_with_progress + ) + builders = _configure_lifecycle_connect(monkeypatch, adapter, [polling_app]) + monkeypatch.setenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "true") + + async def fail_if_discovered(): + raise AssertionError("fallback discovery should be skipped when disabled") + + monkeypatch.setattr(tg_adapter, "discover_fallback_ips", fail_if_discovered) + + assert await adapter.connect() is True + assert builders[0].polling_request is _ControlledRequest.instances[-1] + assert "transport" not in ( + builders[0].polling_request.kwargs.get("httpx_kwargs") or {} + ) + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_fallback_discovery_timeout_falls_back_to_plain_connect(monkeypatch): + """A stuck DoH fallback lookup must not block Telegram cold connect.""" + adapter = _make_adapter() + polling_app = _lifecycle_app() + + async def start_polling_with_progress(**_kwargs): + adapter._record_polling_progress(adapter._polling_generation) + + polling_app.updater.start_polling = AsyncMock( + side_effect=start_polling_with_progress + ) + builders = _configure_lifecycle_connect(monkeypatch, adapter, [polling_app]) + monkeypatch.setenv("HERMES_TELEGRAM_FALLBACK_DISCOVERY_TIMEOUT", "0.05") + + async def stuck_discovery(): + await asyncio.Event().wait() + + monkeypatch.setattr(tg_adapter, "discover_fallback_ips", stuck_discovery) + + assert await adapter.connect() is True + assert "transport" not in ( + builders[0].polling_request.kwargs.get("httpx_kwargs") or {} + ) + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_non_finite_fallback_discovery_timeout_uses_finite_default(monkeypatch): + """NaN/Inf timeout values must not defeat the cold-connect deadline.""" + adapter = _make_adapter() + polling_app = _lifecycle_app() + + async def start_polling_with_progress(**_kwargs): + adapter._record_polling_progress(adapter._polling_generation) + + polling_app.updater.start_polling = AsyncMock( + side_effect=start_polling_with_progress + ) + builders = _configure_lifecycle_connect(monkeypatch, adapter, [polling_app]) + monkeypatch.setenv("HERMES_TELEGRAM_FALLBACK_DISCOVERY_TIMEOUT", "nan") + + async def stuck_discovery(): + await asyncio.Event().wait() + + original_deadline = tg_adapter._await_with_thread_deadline + + async def deadline(awaitable, timeout, **_kwargs): + if getattr(getattr(awaitable, "cr_code", None), "co_name", "") == "stuck_discovery": + assert timeout == 5.0 + awaitable.close() + raise asyncio.TimeoutError() + return await original_deadline(awaitable, timeout, **_kwargs) + + monkeypatch.setattr(tg_adapter, "discover_fallback_ips", stuck_discovery) + monkeypatch.setattr(tg_adapter, "_await_with_thread_deadline", deadline) + + assert await adapter.connect() is True + assert "transport" not in ( + builders[0].polling_request.kwargs.get("httpx_kwargs") or {} + ) + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_fallback_disabled_excludes_configured_ips_from_proxy_targets(monkeypatch): + """Disabled fallback IPs must not affect proxy bypass decisions.""" + adapter = _make_adapter() + polling_app = _lifecycle_app() + + async def start_polling_with_progress(**_kwargs): + adapter._record_polling_progress(adapter._polling_generation) + + polling_app.updater.start_polling = AsyncMock( + side_effect=start_polling_with_progress + ) + builders = _configure_lifecycle_connect(monkeypatch, adapter, [polling_app]) + monkeypatch.setenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "true") + monkeypatch.setattr(adapter, "_fallback_ips", lambda: ["149.154.167.220"]) + + proxy_targets = [] + + def resolve_proxy(_env_name, *, target_hosts): + proxy_targets.append(list(target_hosts)) + return "http://127.0.0.1:8080" + + monkeypatch.setattr(tg_adapter, "resolve_proxy_url", resolve_proxy) + + assert await adapter.connect() is True + assert proxy_targets == [["api.telegram.org"]] + assert builders[0].polling_request.kwargs.get("proxy") == "http://127.0.0.1:8080" + assert "transport" not in ( + builders[0].polling_request.kwargs.get("httpx_kwargs") or {} + ) + await adapter.disconnect() + + @pytest.mark.asyncio async def test_current_polling_generation_success_records_progress(): adapter = _make_adapter() diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 35f38e30e8a0..993423874b70 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -323,6 +323,8 @@ Supported schemes: `http://`, `https://`, `socks5://`. The proxy applies to both the main Telegram connection and the fallback IP transport. If no Telegram-specific proxy is set, the gateway falls back to `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (or macOS system proxy auto-detection). +If the fallback IP discovery path is unhealthy on your host, set `HERMES_TELEGRAM_DISABLE_FALLBACK_IPS=true` to keep cold connect on the plain `api.telegram.org` path. You can also bound DNS-over-HTTPS fallback discovery with `HERMES_TELEGRAM_FALLBACK_DISCOVERY_TIMEOUT` in seconds; the default is `5`. + ## Home Channel Use the `/sethome` command in any Telegram chat (DM or group) to designate it as the **home channel**. Scheduled tasks (cron jobs) deliver their results to this channel.