Compare commits

...

2 Commits

Author SHA1 Message Date
Teknium
34c52f2460 fix: add SOCKS proxy support, DISCORD_PROXY env var, and send_message proxy coverage
Follow-up improvements on top of the shared resolver from PR #6562:

- Add platform_env_var parameter to resolve_proxy_url() so DISCORD_PROXY
  takes priority over generic HTTPS_PROXY/ALL_PROXY env vars
- Add SOCKS proxy support via aiohttp_socks.ProxyConnector with rdns=True
  (critical for GFW/Shadowrocket/Clash users — issue #6649)
- proxy_kwargs_for_bot() returns connector= for SOCKS, proxy= for HTTP
- proxy_kwargs_for_aiohttp() returns split (session_kw, request_kw) for
  standalone aiohttp sessions
- Add proxy support to send_message_tool.py (Discord REST, Slack, SMS)
  for cron job delivery behind proxies (from PR #2208)
- Add proxy support to Discord image/document downloads
- Fix duplicate import sys in base.py
2026-04-09 14:16:39 -07:00
Zheng Li
76b683af89 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
2026-04-09 14:08:59 -07:00
5 changed files with 164 additions and 19 deletions

View File

@@ -10,18 +10,142 @@ 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(platform_env_var: str | None = None) -> str | None:
"""Return a proxy URL from env vars, or macOS system proxy.
Check order:
0. *platform_env_var* (e.g. ``DISCORD_PROXY``) — highest priority
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.
"""
if platform_env_var:
value = (os.environ.get(platform_env_var) or "").strip()
if value:
return value
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()
def proxy_kwargs_for_bot(proxy_url: str | None) -> dict:
"""Build kwargs for ``commands.Bot()`` / ``discord.Client()`` with proxy.
Returns:
- SOCKS URL → ``{"connector": ProxyConnector(..., rdns=True)}``
- HTTP URL → ``{"proxy": url}``
- *None* → ``{}``
``rdns=True`` forces remote DNS resolution through the proxy — required
by many SOCKS implementations (Shadowrocket, Clash) and essential for
bypassing DNS pollution behind the GFW.
"""
if not proxy_url:
return {}
if proxy_url.lower().startswith("socks"):
try:
from aiohttp_socks import ProxyConnector
connector = ProxyConnector.from_url(proxy_url, rdns=True)
return {"connector": connector}
except ImportError:
logger.warning(
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
"Run: pip install aiohttp-socks",
proxy_url,
)
return {}
return {"proxy": proxy_url}
def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
Returns ``(session_kwargs, request_kwargs)`` where:
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
- HTTP → ``({}, {"proxy": url})``
- None → ``({}, {})``
Usage::
sess_kw, req_kw = proxy_kwargs_for_aiohttp(proxy_url)
async with aiohttp.ClientSession(**sess_kw) as session:
async with session.get(url, **req_kw) as resp:
...
"""
if not proxy_url:
return {}, {}
if proxy_url.lower().startswith("socks"):
try:
from aiohttp_socks import ProxyConnector
connector = ProxyConnector.from_url(proxy_url, rdns=True)
return {"connector": connector}, {}
except ImportError:
logger.warning(
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
"Run: pip install aiohttp-socks",
proxy_url,
)
return {}, {}
return {}, {"proxy": proxy_url}
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
from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple
from enum import Enum from enum import Enum
import sys
from pathlib import Path as _Path from pathlib import Path as _Path
sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))

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
# Create bot # Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy)
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_bot
proxy_url = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
if proxy_url:
logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url)
# Create bot — proxy= for HTTP, connector= for SOCKS
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_kwargs_for_bot(proxy_url),
) )
adapter_self = self # capture for closure adapter_self = self # capture for closure
@@ -1307,8 +1314,11 @@ class DiscordAdapter(BasePlatformAdapter):
# Download the image and send as a Discord file attachment # Download the image and send as a Discord file attachment
# (Discord renders attachments inline, unlike plain URLs) # (Discord renders attachments inline, unlike plain URLs)
async with aiohttp.ClientSession() as session: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
async with aiohttp.ClientSession(**_sess_kw) as session:
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp:
if resp.status != 200: if resp.status != 200:
raise Exception(f"Failed to download image: HTTP {resp.status}") raise Exception(f"Failed to download image: HTTP {resp.status}")
@@ -2391,10 +2401,14 @@ class DiscordAdapter(BasePlatformAdapter):
else: else:
try: try:
import aiohttp import aiohttp
async with aiohttp.ClientSession() as session: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
async with aiohttp.ClientSession(**_sess_kw) as session:
async with session.get( async with session.get(
att.url, att.url,
timeout=aiohttp.ClientTimeout(total=30), timeout=aiohttp.ClientTimeout(total=30),
**_req_kw,
) as resp: ) as resp:
if resp.status != 200: if resp.status != 200:
raise Exception(f"HTTP {resp.status}") raise Exception(f"HTTP {resp.status}")

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):

View File

@@ -555,10 +555,13 @@ async def _send_discord(token, chat_id, message):
except ImportError: except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"} return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try: try:
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
url = f"https://discord.com/api/v10/channels/{chat_id}/messages" url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
async with session.post(url, headers=headers, json={"content": message}) as resp: async with session.post(url, headers=headers, json={"content": message}, **_req_kw) as resp:
if resp.status not in (200, 201): if resp.status not in (200, 201):
body = await resp.text() body = await resp.text()
return _error(f"Discord API error ({resp.status}): {body}") return _error(f"Discord API error ({resp.status}): {body}")
@@ -575,11 +578,14 @@ async def _send_slack(token, chat_id, message):
except ImportError: except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"} return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try: try:
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url()
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
url = "https://slack.com/api/chat.postMessage" url = "https://slack.com/api/chat.postMessage"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
payload = {"channel": chat_id, "text": message, "mrkdwn": True} payload = {"channel": chat_id, "text": message, "mrkdwn": True}
async with session.post(url, headers=headers, json=payload) as resp: async with session.post(url, headers=headers, json=payload, **_req_kw) as resp:
data = await resp.json() data = await resp.json()
if data.get("ok"): if data.get("ok"):
return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")} return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}
@@ -712,18 +718,21 @@ async def _send_sms(auth_token, chat_id, message):
message = message.strip() message = message.strip()
try: try:
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url()
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
creds = f"{account_sid}:{auth_token}" creds = f"{account_sid}:{auth_token}"
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii") encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
headers = {"Authorization": f"Basic {encoded}"} headers = {"Authorization": f"Basic {encoded}"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
form_data = aiohttp.FormData() form_data = aiohttp.FormData()
form_data.add_field("From", from_number) form_data.add_field("From", from_number)
form_data.add_field("To", chat_id) form_data.add_field("To", chat_id)
form_data.add_field("Body", message) form_data.add_field("Body", message)
async with session.post(url, data=form_data, headers=headers) as resp: async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp:
body = await resp.json() body = await resp.json()
if resp.status >= 400: if resp.status >= 400:
error_msg = body.get("message", str(body)) error_msg = body.get("message", str(body))