diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 48fb148593..0a8390a7a5 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -55,28 +55,97 @@ def _detect_macos_system_proxy() -> str | None: return None -def resolve_proxy_url() -> str | None: - """Return an HTTP(S) proxy URL from env vars, or macOS system proxy. +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 datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any, Callable, Awaitable, Tuple from enum import Enum -import sys from pathlib import Path as _Path sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2715400a4a..686d60618f 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -529,17 +529,17 @@ class DiscordAdapter(BasePlatformAdapter): intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids) 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() + # 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 HTTP proxy: %s", self.name, proxy_url) + logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url) - # Create bot + # Create bot — proxy= for HTTP, connector= for SOCKS self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, - proxy=proxy_url, + **proxy_kwargs_for_bot(proxy_url), ) adapter_self = self # capture for closure @@ -1314,8 +1314,11 @@ class DiscordAdapter(BasePlatformAdapter): # Download the image and send as a Discord file attachment # (Discord renders attachments inline, unlike plain URLs) - async with aiohttp.ClientSession() as session: - async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + 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(image_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp: if resp.status != 200: raise Exception(f"Failed to download image: HTTP {resp.status}") @@ -2398,10 +2401,14 @@ class DiscordAdapter(BasePlatformAdapter): else: try: 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( att.url, timeout=aiohttp.ClientTimeout(total=30), + **_req_kw, ) as resp: if resp.status != 200: raise Exception(f"HTTP {resp.status}") diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 4957609ef5..2700231e95 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -555,10 +555,13 @@ async def _send_discord(token, chat_id, message): except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} 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" headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: - async with session.post(url, headers=headers, json={"content": message}) as resp: + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: + async with session.post(url, headers=headers, json={"content": message}, **_req_kw) as resp: if resp.status not in (200, 201): body = await resp.text() return _error(f"Discord API error ({resp.status}): {body}") @@ -575,11 +578,14 @@ async def _send_slack(token, chat_id, message): except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} 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" 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} - 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() if data.get("ok"): 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() 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}" encoded = base64.b64encode(creds.encode("ascii")).decode("ascii") url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" 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.add_field("From", from_number) form_data.add_field("To", chat_id) 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() if resp.status >= 400: error_msg = body.get("message", str(body))