mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 16:01:49 +08:00
Compare commits
2 Commits
skill/gith
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34c52f2460 | ||
|
|
76b683af89 |
@@ -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]))
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user