feat(gateway): unified proxy support for Discord and Telegram with macOS auto-detection

- Add resolve_proxy_url() to base.py — shared by all platform adapters
- Check HTTPS_PROXY / HTTP_PROXY / ALL_PROXY env vars first
- Fall back to macOS system proxy via scutil --proxy (zero-config)
- Pass proxy= to discord.py commands.Bot() for gateway connectivity
- Refactor telegram_network.py to use shared resolver
- Update test fixtures to accept proxy kwarg
This commit is contained in:
Zheng Li 2026-04-09 19:37:58 +08:00 committed by Teknium
parent 88845b99d2
commit 88dbbfe982
4 changed files with 68 additions and 8 deletions

View File

@ -10,11 +10,66 @@ import logging
import os import os
import random import random
import re import re
import subprocess
import sys
import uuid import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from urllib.parse import urlsplit from urllib.parse import urlsplit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _detect_macos_system_proxy() -> str | None:
"""Read the macOS system HTTP(S) proxy via ``scutil --proxy``.
Returns an ``http://host:port`` URL string if an HTTP or HTTPS proxy is
enabled, otherwise *None*. Falls back silently on non-macOS or on any
subprocess error.
"""
if sys.platform != "darwin":
return None
try:
out = subprocess.check_output(
["scutil", "--proxy"], timeout=3, text=True, stderr=subprocess.DEVNULL,
)
except Exception:
return None
props: dict[str, str] = {}
for line in out.splitlines():
line = line.strip()
if " : " in line:
key, _, val = line.partition(" : ")
props[key.strip()] = val.strip()
# Prefer HTTPS, fall back to HTTP
for enable_key, host_key, port_key in (
("HTTPSEnable", "HTTPSProxy", "HTTPSPort"),
("HTTPEnable", "HTTPProxy", "HTTPPort"),
):
if props.get(enable_key) == "1":
host = props.get(host_key)
port = props.get(port_key)
if host and port:
return f"http://{host}:{port}"
return None
def resolve_proxy_url() -> str | None:
"""Return an HTTP(S) proxy URL from env vars, or macOS system proxy.
Check order:
1. HTTPS_PROXY / HTTP_PROXY / ALL_PROXY (and lowercase variants)
2. macOS system proxy via ``scutil --proxy`` (auto-detect)
Returns *None* if no proxy is found.
"""
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
"https_proxy", "http_proxy", "all_proxy"):
value = (os.environ.get(key) or "").strip()
if value:
return value
return _detect_macos_system_proxy()
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path

View File

@ -529,10 +529,17 @@ class DiscordAdapter(BasePlatformAdapter):
intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids) intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids)
intents.voice_states = True intents.voice_states = True
# Resolve HTTP proxy (env vars first, then macOS system proxy)
from gateway.platforms.base import resolve_proxy_url
proxy_url = resolve_proxy_url()
if proxy_url:
logger.info("[%s] Using HTTP proxy: %s", self.name, proxy_url)
# Create bot # Create bot
self._client = commands.Bot( self._client = commands.Bot(
command_prefix="!", # Not really used, we handle raw messages command_prefix="!", # Not really used, we handle raw messages
intents=intents, intents=intents,
proxy=proxy_url,
) )
adapter_self = self # capture for closure adapter_self = self # capture for closure

View File

@ -45,11 +45,9 @@ _SEED_FALLBACK_IPS: list[str] = ["149.154.167.220"]
def _resolve_proxy_url() -> str | None: def _resolve_proxy_url() -> str | None:
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"): # Delegate to shared implementation (env vars + macOS system proxy detection)
value = (os.environ.get(key) or "").strip() from gateway.platforms.base import resolve_proxy_url
if value: return resolve_proxy_url()
return value
return None
class TelegramFallbackTransport(httpx.AsyncBaseTransport): class TelegramFallbackTransport(httpx.AsyncBaseTransport):

View File

@ -56,7 +56,7 @@ class FakeTree:
class FakeBot: class FakeBot:
def __init__(self, *, intents): def __init__(self, *, intents, proxy=None):
self.intents = intents self.intents = intents
self.user = SimpleNamespace(id=999, name="Hermes") self.user = SimpleNamespace(id=999, name="Hermes")
self._events = {} self._events = {}
@ -95,7 +95,7 @@ async def test_connect_only_requests_members_intent_when_needed(monkeypatch, all
created = {} created = {}
def fake_bot_factory(*, command_prefix, intents): def fake_bot_factory(*, command_prefix, intents, proxy=None):
created["bot"] = FakeBot(intents=intents) created["bot"] = FakeBot(intents=intents)
return created["bot"] return created["bot"]
@ -124,7 +124,7 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
discord_platform.commands, discord_platform.commands,
"Bot", "Bot",
lambda **kwargs: FakeBot(intents=kwargs["intents"]), lambda **kwargs: FakeBot(intents=kwargs["intents"], proxy=kwargs.get("proxy")),
) )
async def fake_wait_for(awaitable, timeout): async def fake_wait_for(awaitable, timeout):