Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
125 changes: 125 additions & 0 deletions tests/gateway/test_telegram_polling_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions website/docs/user-guide/messaging/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading