From edce8a5fd42936114c6d1b6f90b34d8f8faed9e8 Mon Sep 17 00:00:00 2001 From: pepelax Date: Thu, 14 May 2026 04:34:44 +0000 Subject: [PATCH] fix(send_message): route standalone Telegram sends through TELEGRAM_PROXY When the send_message tool runs outside the gateway process (agent loop, TUI, cron, etc.), _gateway_runner_ref() returns None and the standalone path in _send_telegram constructs Bot(token=token) directly, bypassing any configured proxy. In regions where api.telegram.org is blocked, the send times out after ~5s with 'Telegram send failed: Timed out' and nothing ever shows up in gateway.log because the request never reaches the gateway. Resolve TELEGRAM_PROXY (via gateway.platforms.base.resolve_proxy_url, which also honours HTTPS_PROXY/HTTP_PROXY/ALL_PROXY and NO_PROXY) just before constructing the Bot. When a proxy is found, attach an HTTPXRequest(proxy=...) for both 'request' and 'get_updates_request', matching what gateway/platforms/telegram.py already does for in-gateway sends and what the Discord standalone sender already does. Any exception attaching the proxy falls back cleanly to a direct connection, preserving prior behaviour for users without a proxy configured. Adds tests/tools/test_send_message_telegram_proxy.py covering both the proxy-configured and no-proxy cases. --- .../tools/test_send_message_telegram_proxy.py | 154 ++++++++++++++++++ tools/send_message_tool.py | 25 ++- 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_send_message_telegram_proxy.py diff --git a/tests/tools/test_send_message_telegram_proxy.py b/tests/tools/test_send_message_telegram_proxy.py new file mode 100644 index 00000000000..130965a7e5c --- /dev/null +++ b/tests/tools/test_send_message_telegram_proxy.py @@ -0,0 +1,154 @@ +"""Regression tests for the standalone Telegram send path's proxy support. + +The ``send_message`` tool, when invoked from a process *other than* the +gateway (agent / TUI / cron), runs ``_send_telegram`` directly instead of +delegating to the in-process gateway adapter. Before the fix that +accompanies these tests, that standalone path constructed +``telegram.Bot(token=...)`` with no proxy, so in regions where +api.telegram.org is blocked (e.g. RU) the send would just time out with +``Telegram send failed: Timed out`` and never show up in ``gateway.log``. + +These tests verify that the standalone path now honours ``TELEGRAM_PROXY`` +the same way the gateway adapter (and the Discord standalone path) do. +""" + +from __future__ import annotations + +import asyncio +import sys +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _install_telegram_mock_with_request( + monkeypatch: pytest.MonkeyPatch, + bot_factory: MagicMock, + httpx_request_factory: MagicMock, +) -> None: + """Install a stub ``telegram`` package whose ``Bot`` and + ``telegram.request.HTTPXRequest`` are the supplied mocks. + + Mirrors ``_install_telegram_mock`` in test_send_message_tool.py but also + provides the ``telegram.request`` submodule that the proxy branch needs. + """ + parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML") + constants_mod = SimpleNamespace(ParseMode=parse_mode) + request_mod = SimpleNamespace(HTTPXRequest=httpx_request_factory) + telegram_mod = SimpleNamespace( + Bot=bot_factory, + constants=constants_mod, + request=request_mod, + ) + monkeypatch.setitem(sys.modules, "telegram", telegram_mod) + monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod) + monkeypatch.setitem(sys.modules, "telegram.request", request_mod) + + +def _make_bot() -> MagicMock: + bot = MagicMock() + bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=42)) + return bot + + +class TestSendTelegramStandaloneProxy: + """The standalone ``_send_telegram`` path must route through + ``TELEGRAM_PROXY`` when one is configured, even when no in-process + gateway runner is available. + """ + + def test_proxy_env_passed_to_httpx_request( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With TELEGRAM_PROXY set, Bot() is constructed with HTTPXRequest + instances whose ``proxy=`` kwarg is the configured URL — applied to + both ``request`` and ``get_updates_request``. + """ + from tools.send_message_tool import _send_telegram + + proxy_url = "socks5://127.0.0.1:1080" + monkeypatch.setenv("TELEGRAM_PROXY", proxy_url) + # Clear NO_PROXY so resolve_proxy_url() doesn't short-circuit on + # leftover env from the host running the tests. + monkeypatch.delenv("NO_PROXY", raising=False) + monkeypatch.delenv("no_proxy", raising=False) + # Ensure the test does not depend on the in-process gateway runner. + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: None) + + bot = _make_bot() + bot_factory = MagicMock(return_value=bot) + httpx_request_factory = MagicMock(side_effect=lambda **kw: MagicMock(_kw=kw)) + _install_telegram_mock_with_request(monkeypatch, bot_factory, httpx_request_factory) + + result: dict[str, Any] = asyncio.run( + _send_telegram("tok", "123", "hello world") + ) + + assert result["success"] is True + bot_factory.assert_called_once() + call_kwargs = bot_factory.call_args.kwargs + assert call_kwargs.get("token") == "tok" + assert "request" in call_kwargs, "request= kwarg missing — proxy not wired" + assert "get_updates_request" in call_kwargs, ( + "get_updates_request= kwarg missing — proxy not wired" + ) + + # HTTPXRequest must have been invoked twice, both times with the + # resolved proxy URL. + assert httpx_request_factory.call_count == 2 + for call in httpx_request_factory.call_args_list: + assert call.kwargs.get("proxy") == proxy_url, ( + f"HTTPXRequest called without proxy={proxy_url!r}: {call.kwargs!r}" + ) + + # And the bot was actually used to send. + bot.send_message.assert_awaited_once() + + def test_no_proxy_env_uses_plain_bot( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Without TELEGRAM_PROXY (and no inherited HTTPS_PROXY/etc), Bot() + is constructed plainly — no ``request``/``get_updates_request`` + kwargs, and HTTPXRequest is not invoked at all. + """ + from tools.send_message_tool import _send_telegram + + # Wipe every env var resolve_proxy_url() inspects so the host's + # ambient proxy settings can't flip this test green-or-red. + for var in ( + "TELEGRAM_PROXY", + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: None) + # Make sure macOS system-proxy auto-detection (scutil) can't kick in. + monkeypatch.setattr(sys, "platform", "linux") + + bot = _make_bot() + bot_factory = MagicMock(return_value=bot) + httpx_request_factory = MagicMock(side_effect=lambda **kw: MagicMock(_kw=kw)) + _install_telegram_mock_with_request(monkeypatch, bot_factory, httpx_request_factory) + + result: dict[str, Any] = asyncio.run( + _send_telegram("tok", "123", "hello world") + ) + + assert result["success"] is True + bot_factory.assert_called_once() + call_kwargs = bot_factory.call_args.kwargs + call_args = bot_factory.call_args.args + # token may be passed positionally or as a kwarg; either is fine. + assert call_kwargs.get("token", call_args[0] if call_args else None) == "tok" + assert "request" not in call_kwargs + assert "get_updates_request" not in call_kwargs + httpx_request_factory.assert_not_called() + bot.send_message.assert_awaited_once() diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 5c31160afbe..b767cf66bb6 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -816,7 +816,30 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No formatted = message send_parse_mode = ParseMode.MARKDOWN_V2 - bot = Bot(token=token) + # Honour a configured proxy (telegram.proxy_url in config.yaml, exported + # as TELEGRAM_PROXY env var by load_gateway_config). Without this, the + # standalone send path bypasses the proxy and times out in regions + # where api.telegram.org is blocked. The in-gateway adapter does the + # same thing in gateway/platforms/telegram.py. + try: + from gateway.platforms.base import resolve_proxy_url + _tg_proxy = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=["api.telegram.org"]) + except Exception: + _tg_proxy = None + if _tg_proxy: + try: + from telegram.request import HTTPXRequest + logger.info("send_message: standalone Telegram send routed through proxy %s", _tg_proxy) + bot = Bot( + token=token, + request=HTTPXRequest(proxy=_tg_proxy), + get_updates_request=HTTPXRequest(proxy=_tg_proxy), + ) + except Exception as _proxy_err: + logger.warning("send_message: failed to attach Telegram proxy (%s), falling back to direct connection", _proxy_err) + bot = Bot(token=token) + else: + bot = Bot(token=token) int_chat_id = int(chat_id) media_files = media_files or [] thread_kwargs = {}