From 05dae97248de23329fa7355d921862d67ca17287 Mon Sep 17 00:00:00 2001 From: debil746429 Date: Mon, 6 Jul 2026 06:59:23 +0300 Subject: [PATCH] add telegram proxy support (#988) --- .env.example | 4 +++ api/admin_config/manifest.py | 10 ++++++ api/runtime.py | 1 + config/settings.py | 1 + messaging/platforms/factory.py | 2 ++ messaging/platforms/telegram.py | 30 +++++++++++++--- pyproject.toml | 2 +- tests/api/test_admin.py | 27 +++++++++++++++ tests/api/test_app_lifespan_and_errors.py | 1 + tests/messaging/test_messaging_factory.py | 2 ++ tests/messaging/test_telegram.py | 42 +++++++++++++++++++++++ uv.lock | 2 +- 12 files changed, 118 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 2ab2f50c..c9119e8a 100644 --- a/.env.example +++ b/.env.example @@ -208,6 +208,10 @@ WHISPER_MODEL="openai/whisper-large-v3" # Telegram Config TELEGRAM_BOT_TOKEN="" ALLOWED_TELEGRAM_USER_ID="" +# Optional Telegram-only proxy. +# Supported schemes: http, https, socks4, socks5, socks5h. +# Example: "socks5://127.0.0.1:1080" or "https://user:password@host:port" +TELEGRAM_PROXY_URL="" # Discord Config diff --git a/api/admin_config/manifest.py b/api/admin_config/manifest.py index 4ba0a753..65d64000 100644 --- a/api/admin_config/manifest.py +++ b/api/admin_config/manifest.py @@ -279,6 +279,16 @@ _NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = ( settings_attr="allowed_telegram_user_id", session_sensitive=True, ), + ConfigFieldSpec( + "TELEGRAM_PROXY_URL", + "Telegram Proxy URL", + "messaging", + "secret", + settings_attr="telegram_proxy_url", + secret=True, + session_sensitive=True, + description="Optional Telegram-only proxy, e.g. socks5://127.0.0.1:1080.", + ), ConfigFieldSpec( "DISCORD_BOT_TOKEN", "Discord Bot Token", diff --git a/api/runtime.py b/api/runtime.py index 2c0b27fc..d2c9089f 100644 --- a/api/runtime.py +++ b/api/runtime.py @@ -185,6 +185,7 @@ class AppRuntime: MessagingPlatformOptions( telegram_bot_token=self.settings.telegram_bot_token, allowed_telegram_user_id=self.settings.allowed_telegram_user_id, + telegram_proxy_url=self.settings.telegram_proxy_url, discord_bot_token=self.settings.discord_bot_token, allowed_discord_channels=self.settings.allowed_discord_channels, voice_note_enabled=self.settings.voice_note_enabled, diff --git a/config/settings.py b/config/settings.py index a47e6b8a..33b84725 100644 --- a/config/settings.py +++ b/config/settings.py @@ -259,6 +259,7 @@ class Settings(BaseSettings): # ==================== Bot Wrapper Config ==================== telegram_bot_token: str | None = None allowed_telegram_user_id: str | None = None + telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL") discord_bot_token: str | None = Field( default=None, validation_alias="DISCORD_BOT_TOKEN" ) diff --git a/messaging/platforms/factory.py b/messaging/platforms/factory.py index adf9dfc1..5d917914 100644 --- a/messaging/platforms/factory.py +++ b/messaging/platforms/factory.py @@ -13,6 +13,7 @@ class MessagingPlatformOptions: telegram_bot_token: str | None = None allowed_telegram_user_id: str | None = None + telegram_proxy_url: str = "" discord_bot_token: str | None = None allowed_discord_channels: str | None = None voice_note_enabled: bool = True @@ -47,6 +48,7 @@ def create_messaging_components( runtime = TelegramRuntime( bot_token=bot_token, allowed_user_id=opts.allowed_telegram_user_id, + telegram_proxy_url=opts.telegram_proxy_url, voice_note_enabled=opts.voice_note_enabled, whisper_model=opts.whisper_model, whisper_device=opts.whisper_device, diff --git a/messaging/platforms/telegram.py b/messaging/platforms/telegram.py index 9f4da856..b12201b2 100644 --- a/messaging/platforms/telegram.py +++ b/messaging/platforms/telegram.py @@ -49,6 +49,7 @@ class TelegramRuntime: bot_token: str | None = None, allowed_user_id: str | None = None, *, + telegram_proxy_url: str = "", voice_note_enabled: bool = True, whisper_model: str = "base", whisper_device: str = "cpu", @@ -66,6 +67,7 @@ class TelegramRuntime: self.bot_token = bot_token self.allowed_user_id = allowed_user_id + self.telegram_proxy_url = telegram_proxy_url.strip() if not self.bot_token: logger.warning("TELEGRAM_BOT_TOKEN not set") @@ -102,10 +104,30 @@ class TelegramRuntime: if not self.bot_token: raise ValueError("TELEGRAM_BOT_TOKEN is required") - request = HTTPXRequest( - connection_pool_size=8, connect_timeout=30.0, read_timeout=30.0 - ) - builder = Application.builder().token(self.bot_token).request(request) + if self.telegram_proxy_url: + request = HTTPXRequest( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.telegram_proxy_url, + ) + update_request = HTTPXRequest( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.telegram_proxy_url, + ) + builder = ( + Application.builder() + .token(self.bot_token) + .request(request) + .get_updates_request(update_request) + ) + else: + request = HTTPXRequest( + connection_pool_size=8, connect_timeout=30.0, read_timeout=30.0 + ) + builder = Application.builder().token(self.bot_token).request(request) self._application = builder.build() self._application.add_handler( diff --git a/pyproject.toml b/pyproject.toml index 3f78835a..1bdc760d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "3.3.1" +version = "3.4.0" description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM" readme = "README.md" requires-python = ">=3.14.0" diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index 1c064c25..eb0a5638 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -26,6 +26,7 @@ def _clear_process_config(monkeypatch) -> None: "NVIDIA_NIM_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_AUTH_TOKEN", + "TELEGRAM_PROXY_URL", "FCC_ENV_FILE", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", @@ -111,6 +112,7 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path): assert "GEMINI_API_KEY" in keys assert "GROQ_API_KEY" in keys assert "SAMBANOVA_API_KEY" in keys + assert "TELEGRAM_PROXY_URL" in keys assert "CEREBRAS_API_KEY" in keys assert "ZAI_BASE_URL" not in keys assert "CLAUDE_WORKSPACE" not in keys @@ -122,6 +124,10 @@ def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path): assert auth_field["secret"] is True assert auth_field["value"] == MASKED_SECRET assert auth_field["source"] == "template" + telegram_proxy_field = next( + field for field in body["fields"] if field["key"] == "TELEGRAM_PROXY_URL" + ) + assert telegram_proxy_field["secret"] is True def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_path): @@ -141,6 +147,27 @@ def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_pat assert model_field["locked"] is False +def test_admin_apply_masks_telegram_proxy_credentials(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_app(lifespan_enabled=False) + proxy_url = "https://user:password@proxy.example:8443" + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"TELEGRAM_PROXY_URL": proxy_url}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "TELEGRAM_PROXY_URL=********" in body["env_preview"] + assert proxy_url not in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert f"TELEGRAM_PROXY_URL={proxy_url}" in text + + def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path): _set_home(monkeypatch, tmp_path) _clear_process_config(monkeypatch) diff --git a/tests/api/test_app_lifespan_and_errors.py b/tests/api/test_app_lifespan_and_errors.py index 81ab028e..e4c5ab59 100644 --- a/tests/api/test_app_lifespan_and_errors.py +++ b/tests/api/test_app_lifespan_and_errors.py @@ -18,6 +18,7 @@ _RUNTIME_EXTRAS = { "model_opus": None, "model_sonnet": None, "model_haiku": None, + "telegram_proxy_url": "", "voice_note_enabled": True, "whisper_model": "base", "whisper_device": "cpu", diff --git a/tests/messaging/test_messaging_factory.py b/tests/messaging/test_messaging_factory.py index 71f0f74d..feecd3b8 100644 --- a/tests/messaging/test_messaging_factory.py +++ b/tests/messaging/test_messaging_factory.py @@ -28,6 +28,7 @@ class TestCreateMessagingComponents: MessagingPlatformOptions( telegram_bot_token="test_token", allowed_telegram_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", voice_note_enabled=False, whisper_model="large-v3", whisper_device="cuda", @@ -41,6 +42,7 @@ class TestCreateMessagingComponents: runtime_cls.assert_called_once_with( bot_token="test_token", allowed_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", voice_note_enabled=False, whisper_model="large-v3", whisper_device="cuda", diff --git a/tests/messaging/test_telegram.py b/tests/messaging/test_telegram.py index f43dbd9e..8db2eb91 100644 --- a/tests/messaging/test_telegram.py +++ b/tests/messaging/test_telegram.py @@ -37,6 +37,48 @@ async def test_telegram_platform_start_success(telegram_platform): mock_app.start.assert_called_once() +@pytest.mark.asyncio +async def test_telegram_platform_start_with_proxy(): + with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True): + platform = TelegramRuntime( + bot_token="test_token", + allowed_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", + ) + + with ( + patch("telegram.ext.Application.builder") as mock_builder, + patch("messaging.platforms.telegram.HTTPXRequest") as request_cls, + ): + mock_app = MagicMock() + mock_app.initialize = AsyncMock() + mock_app.start = AsyncMock() + mock_app.updater.start_polling = AsyncMock() + + builder = mock_builder.return_value + builder.token.return_value = builder + builder.request.return_value = builder + builder.get_updates_request.return_value = builder + builder.build.return_value = mock_app + request = MagicMock() + update_request = MagicMock() + request_cls.side_effect = [request, update_request] + + with patch("messaging.limiter.MessagingRateLimiter.get_instance", AsyncMock()): + await platform.start() + + assert request_cls.call_count == 2 + request_cls.assert_any_call( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy="socks5://127.0.0.1:1080", + ) + builder.request.assert_called_once_with(request) + builder.get_updates_request.assert_called_once_with(update_request) + assert platform._connected is True + + @pytest.mark.asyncio async def test_telegram_platform_send_message_success(telegram_platform): mock_bot = AsyncMock() diff --git a/uv.lock b/uv.lock index d9c43b78..0e68181c 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "3.3.1" +version = "3.4.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },