Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -665,6 +665,17 @@ def load_gateway_config() -> GatewayConfig:
|
||||
_apply_env_overrides(config)
|
||||
|
||||
# --- Validate loaded values ---
|
||||
_validate_gateway_config(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _validate_gateway_config(config: "GatewayConfig") -> None:
|
||||
"""Validate and sanitize a loaded GatewayConfig in place.
|
||||
|
||||
Called by ``load_gateway_config()`` after all config sources are merged.
|
||||
Extracted as a separate function for testability.
|
||||
"""
|
||||
policy = config.default_reset_policy
|
||||
|
||||
if not (0 <= policy.at_hour <= 23):
|
||||
@@ -701,7 +712,31 @@ def load_gateway_config() -> GatewayConfig:
|
||||
platform.value, env_name,
|
||||
)
|
||||
|
||||
return config
|
||||
# Reject known-weak placeholder tokens.
|
||||
# Ported from openclaw/openclaw#64586: users who copy .env.example
|
||||
# without changing placeholder values get a clear startup error instead
|
||||
# of a confusing "auth failed" from the platform API.
|
||||
try:
|
||||
from hermes_cli.auth import has_usable_secret
|
||||
except ImportError:
|
||||
has_usable_secret = None # type: ignore[assignment]
|
||||
|
||||
if has_usable_secret is not None:
|
||||
for platform, pconfig in config.platforms.items():
|
||||
if not pconfig.enabled:
|
||||
continue
|
||||
env_name = _token_env_names.get(platform)
|
||||
if not env_name:
|
||||
continue
|
||||
token = pconfig.token
|
||||
if token and token.strip() and not has_usable_secret(token, min_length=4):
|
||||
logger.error(
|
||||
"%s is enabled but %s is set to a placeholder value ('%s'). "
|
||||
"Set a real bot token before starting the gateway. "
|
||||
"The adapter will NOT be started.",
|
||||
platform.value, env_name, token.strip()[:6] + "...",
|
||||
)
|
||||
pconfig.enabled = False
|
||||
|
||||
|
||||
def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
|
||||
@@ -82,7 +82,7 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
|
||||
# Tier 3 — no edit support, progress messages are permanent
|
||||
"signal": _TIER_LOW,
|
||||
"whatsapp": _TIER_LOW,
|
||||
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
|
||||
"bluebubbles": _TIER_LOW,
|
||||
"weixin": _TIER_LOW,
|
||||
"wecom": _TIER_LOW,
|
||||
|
||||
@@ -54,6 +54,66 @@ DEFAULT_PORT = 8642
|
||||
MAX_STORED_RESPONSES = 100
|
||||
MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
|
||||
CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
|
||||
MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
|
||||
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
|
||||
|
||||
|
||||
def _normalize_chat_content(
|
||||
content: Any, *, _max_depth: int = 10, _depth: int = 0,
|
||||
) -> str:
|
||||
"""Normalize OpenAI chat message content into a plain text string.
|
||||
|
||||
Some clients (Open WebUI, LobeChat, etc.) send content as an array of
|
||||
typed parts instead of a plain string::
|
||||
|
||||
[{"type": "text", "text": "hello"}, {"type": "input_text", "text": "..."}]
|
||||
|
||||
This function flattens those into a single string so the agent pipeline
|
||||
(which expects strings) doesn't choke.
|
||||
|
||||
Defensive limits prevent abuse: recursion depth, list size, and output
|
||||
length are all bounded.
|
||||
"""
|
||||
if _depth > _max_depth:
|
||||
return ""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content
|
||||
|
||||
if isinstance(content, list):
|
||||
parts: List[str] = []
|
||||
items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
if item:
|
||||
parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH])
|
||||
elif isinstance(item, dict):
|
||||
item_type = str(item.get("type") or "").strip().lower()
|
||||
if item_type in {"text", "input_text", "output_text"}:
|
||||
text = item.get("text", "")
|
||||
if text:
|
||||
try:
|
||||
parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH])
|
||||
except Exception:
|
||||
pass
|
||||
# Silently skip image_url / other non-text parts
|
||||
elif isinstance(item, list):
|
||||
nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1)
|
||||
if nested:
|
||||
parts.append(nested)
|
||||
# Check accumulated size
|
||||
if sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH:
|
||||
break
|
||||
result = "\n".join(parts)
|
||||
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
|
||||
|
||||
# Fallback for unexpected types (int, float, bool, etc.)
|
||||
try:
|
||||
result = str(content)
|
||||
return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def check_api_server_requirements() -> bool:
|
||||
@@ -553,7 +613,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
content = _normalize_chat_content(msg.get("content", ""))
|
||||
if role == "system":
|
||||
# Accumulate system messages
|
||||
if system_prompt is None:
|
||||
@@ -926,18 +986,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
input_messages.append({"role": "user", "content": item})
|
||||
elif isinstance(item, dict):
|
||||
role = item.get("role", "user")
|
||||
content = item.get("content", "")
|
||||
# Handle content that may be a list of content parts
|
||||
if isinstance(content, list):
|
||||
text_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "input_text":
|
||||
text_parts.append(part.get("text", ""))
|
||||
elif isinstance(part, dict) and part.get("type") == "output_text":
|
||||
text_parts.append(part.get("text", ""))
|
||||
elif isinstance(part, str):
|
||||
text_parts.append(part)
|
||||
content = "\n".join(text_parts)
|
||||
content = _normalize_chat_content(item.get("content", ""))
|
||||
input_messages.append({"role": role, "content": content})
|
||||
else:
|
||||
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
|
||||
@@ -1770,6 +1819,23 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
|
||||
# Refuse to start network-accessible with a placeholder key.
|
||||
# Ported from openclaw/openclaw#64586.
|
||||
if is_network_accessible(self._host) and self._api_key:
|
||||
try:
|
||||
from hermes_cli.auth import has_usable_secret
|
||||
if not has_usable_secret(self._api_key, min_length=8):
|
||||
logger.error(
|
||||
"[%s] Refusing to start: API_SERVER_KEY is set to a "
|
||||
"placeholder value. Generate a real secret "
|
||||
"(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY "
|
||||
"before exposing the API server on %s.",
|
||||
self.name, self._host,
|
||||
)
|
||||
return False
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Port conflict detection — fail fast if port is already in use
|
||||
try:
|
||||
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
|
||||
|
||||
@@ -21,6 +21,59 @@ from urllib.parse import urlsplit
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def utf16_len(s: str) -> int:
|
||||
"""Count UTF-16 code units in *s*.
|
||||
|
||||
Telegram's message-length limit (4 096) is measured in UTF-16 code units,
|
||||
**not** Unicode code-points. Characters outside the Basic Multilingual
|
||||
Plane (emoji like 😀, CJK Extension B, musical symbols, …) are encoded as
|
||||
surrogate pairs and therefore consume **two** UTF-16 code units each, even
|
||||
though Python's ``len()`` counts them as one.
|
||||
|
||||
Ported from nearai/ironclaw#2304 which discovered the same discrepancy in
|
||||
Rust's ``chars().count()``.
|
||||
"""
|
||||
return len(s.encode("utf-16-le")) // 2
|
||||
|
||||
|
||||
def _prefix_within_utf16_limit(s: str, limit: int) -> str:
|
||||
"""Return the longest prefix of *s* whose UTF-16 length ≤ *limit*.
|
||||
|
||||
Unlike a plain ``s[:limit]``, this respects surrogate-pair boundaries so
|
||||
we never slice a multi-code-unit character in half.
|
||||
"""
|
||||
if utf16_len(s) <= limit:
|
||||
return s
|
||||
# Binary search for the longest safe prefix
|
||||
lo, hi = 0, len(s)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if utf16_len(s[:mid]) <= limit:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return s[:lo]
|
||||
|
||||
|
||||
def _custom_unit_to_cp(s: str, budget: int, len_fn) -> int:
|
||||
"""Return the largest codepoint offset *n* such that ``len_fn(s[:n]) <= budget``.
|
||||
|
||||
Used by :meth:`BasePlatformAdapter.truncate_message` when *len_fn* measures
|
||||
length in units different from Python codepoints (e.g. UTF-16 code units).
|
||||
Falls back to binary search which is O(log n) calls to *len_fn*.
|
||||
"""
|
||||
if len_fn(s) <= budget:
|
||||
return len(s)
|
||||
lo, hi = 0, len(s)
|
||||
while lo < hi:
|
||||
mid = (lo + hi + 1) // 2
|
||||
if len_fn(s[:mid]) <= budget:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid - 1
|
||||
return lo
|
||||
|
||||
|
||||
def is_network_accessible(host: str) -> bool:
|
||||
"""Return True if *host* would expose the server beyond loopback.
|
||||
|
||||
@@ -1897,7 +1950,11 @@ class BasePlatformAdapter(ABC):
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def truncate_message(content: str, max_length: int = 4096) -> List[str]:
|
||||
def truncate_message(
|
||||
content: str,
|
||||
max_length: int = 4096,
|
||||
len_fn: Optional["Callable[[str], int]"] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Split a long message into chunks, preserving code block boundaries.
|
||||
|
||||
@@ -1909,11 +1966,16 @@ class BasePlatformAdapter(ABC):
|
||||
Args:
|
||||
content: The full message content
|
||||
max_length: Maximum length per chunk (platform-specific)
|
||||
len_fn: Optional length function for measuring string length.
|
||||
Defaults to ``len`` (Unicode code-points). Pass
|
||||
``utf16_len`` for platforms that measure message
|
||||
length in UTF-16 code units (e.g. Telegram).
|
||||
|
||||
Returns:
|
||||
List of message chunks
|
||||
"""
|
||||
if len(content) <= max_length:
|
||||
_len = len_fn or len
|
||||
if _len(content) <= max_length:
|
||||
return [content]
|
||||
|
||||
INDICATOR_RESERVE = 10 # room for " (XX/XX)"
|
||||
@@ -1932,22 +1994,33 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# How much body text we can fit after accounting for the prefix,
|
||||
# a potential closing fence, and the chunk indicator.
|
||||
headroom = max_length - INDICATOR_RESERVE - len(prefix) - len(FENCE_CLOSE)
|
||||
headroom = max_length - INDICATOR_RESERVE - _len(prefix) - _len(FENCE_CLOSE)
|
||||
if headroom < 1:
|
||||
headroom = max_length // 2
|
||||
|
||||
# Everything remaining fits in one final chunk
|
||||
if len(prefix) + len(remaining) <= max_length - INDICATOR_RESERVE:
|
||||
if _len(prefix) + _len(remaining) <= max_length - INDICATOR_RESERVE:
|
||||
chunks.append(prefix + remaining)
|
||||
break
|
||||
|
||||
# Find a natural split point (prefer newlines, then spaces)
|
||||
region = remaining[:headroom]
|
||||
# Find a natural split point (prefer newlines, then spaces).
|
||||
# When _len != len (e.g. utf16_len for Telegram), headroom is
|
||||
# measured in the custom unit. We need codepoint-based slice
|
||||
# positions that stay within the custom-unit budget.
|
||||
#
|
||||
# _safe_slice_pos() maps a custom-unit budget to the largest
|
||||
# codepoint offset whose custom length ≤ budget.
|
||||
if _len is not len:
|
||||
# Map headroom (custom units) → codepoint slice length
|
||||
_cp_limit = _custom_unit_to_cp(remaining, headroom, _len)
|
||||
else:
|
||||
_cp_limit = headroom
|
||||
region = remaining[:_cp_limit]
|
||||
split_at = region.rfind("\n")
|
||||
if split_at < headroom // 2:
|
||||
if split_at < _cp_limit // 2:
|
||||
split_at = region.rfind(" ")
|
||||
if split_at < 1:
|
||||
split_at = headroom
|
||||
split_at = _cp_limit
|
||||
|
||||
# Avoid splitting inside an inline code span (`...`).
|
||||
# If the text before split_at has an odd number of unescaped
|
||||
@@ -1967,7 +2040,7 @@ class BasePlatformAdapter(ABC):
|
||||
safe_split = candidate.rfind(" ", 0, last_bt)
|
||||
nl_split = candidate.rfind("\n", 0, last_bt)
|
||||
safe_split = max(safe_split, nl_split)
|
||||
if safe_split > headroom // 4:
|
||||
if safe_split > _cp_limit // 4:
|
||||
split_at = safe_split
|
||||
|
||||
chunk_body = remaining[:split_at]
|
||||
|
||||
@@ -442,6 +442,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id
|
||||
self._voice_sources: Dict[int, Dict[str, Any]] = {} # guild_id -> linked text channel source metadata
|
||||
self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task
|
||||
# Phase 2: voice listening
|
||||
self._voice_receivers: Dict[int, VoiceReceiver] = {} # guild_id -> VoiceReceiver
|
||||
@@ -1045,6 +1046,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if task:
|
||||
task.cancel()
|
||||
self._voice_text_channels.pop(guild_id, None)
|
||||
self._voice_sources.pop(guild_id, None)
|
||||
|
||||
# Maximum seconds to wait for voice playback before giving up
|
||||
PLAYBACK_TIMEOUT = 120
|
||||
@@ -2244,6 +2246,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
thread_id = str(message.channel.id)
|
||||
parent_channel_id = self._get_parent_channel_id(message.channel)
|
||||
|
||||
is_voice_linked_channel = False
|
||||
if not isinstance(message.channel, discord.DMChannel):
|
||||
channel_ids = {str(message.channel.id)}
|
||||
if parent_channel_id:
|
||||
@@ -2270,7 +2273,12 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
channel_ids.add(parent_channel_id)
|
||||
|
||||
require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no")
|
||||
is_free_channel = bool(channel_ids & free_channels)
|
||||
# Voice-linked text channels act as free-response while voice is active.
|
||||
# Only the exact bound channel gets the exemption, not sibling threads.
|
||||
voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()}
|
||||
current_channel_id = str(message.channel.id)
|
||||
is_voice_linked_channel = current_channel_id in voice_linked_ids
|
||||
is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel
|
||||
|
||||
# Skip the mention check if the message is in a thread where
|
||||
# the bot has previously participated (auto-created or replied in).
|
||||
@@ -2294,7 +2302,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
|
||||
skip_thread = bool(channel_ids & no_thread_channels)
|
||||
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
|
||||
if auto_thread and not skip_thread:
|
||||
if auto_thread and not skip_thread and not is_voice_linked_channel:
|
||||
thread = await self._auto_create_thread(message)
|
||||
if thread:
|
||||
is_thread = True
|
||||
|
||||
@@ -34,6 +34,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# aiohttp/websockets are independent optional deps — import outside lark_oapi
|
||||
# so they remain available for tests and webhook mode even if lark_oapi is missing.
|
||||
@@ -169,6 +172,19 @@ _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60 # card action token dedup win
|
||||
_FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs
|
||||
_FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback
|
||||
_FEISHU_ACK_EMOJI = "OK"
|
||||
|
||||
# QR onboarding constants
|
||||
_ONBOARD_ACCOUNTS_URLS = {
|
||||
"feishu": "https://accounts.feishu.cn",
|
||||
"lark": "https://accounts.larksuite.com",
|
||||
}
|
||||
_ONBOARD_OPEN_URLS = {
|
||||
"feishu": "https://open.feishu.cn",
|
||||
"lark": "https://open.larksuite.com",
|
||||
}
|
||||
_REGISTRATION_PATH = "/oauth/v1/app/registration"
|
||||
_ONBOARD_REQUEST_TIMEOUT_S = 10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback display strings
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3621,3 +3637,328 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
return _FEISHU_FILE_UPLOAD_TYPE, "file"
|
||||
|
||||
return _FEISHU_FILE_UPLOAD_TYPE, "file"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# QR scan-to-create onboarding
|
||||
#
|
||||
# Device-code flow: user scans a QR code with Feishu/Lark mobile app and the
|
||||
# platform creates a fully configured bot application automatically.
|
||||
# Called by `hermes gateway setup` via _setup_feishu() in hermes_cli/gateway.py.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _accounts_base_url(domain: str) -> str:
|
||||
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
|
||||
|
||||
|
||||
def _onboard_open_base_url(domain: str) -> str:
|
||||
return _ONBOARD_OPEN_URLS.get(domain, _ONBOARD_OPEN_URLS["feishu"])
|
||||
|
||||
|
||||
def _post_registration(base_url: str, body: Dict[str, str]) -> dict:
|
||||
"""POST form-encoded data to the registration endpoint, return parsed JSON.
|
||||
|
||||
The registration endpoint returns JSON even on 4xx (e.g. poll returns
|
||||
authorization_pending as a 400). We always parse the body regardless of
|
||||
HTTP status.
|
||||
"""
|
||||
url = f"{base_url}{_REGISTRATION_PATH}"
|
||||
data = urlencode(body).encode("utf-8")
|
||||
req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||
try:
|
||||
with urlopen(req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
body_bytes = exc.read()
|
||||
if body_bytes:
|
||||
try:
|
||||
return json.loads(body_bytes.decode("utf-8"))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
raise exc from None
|
||||
raise
|
||||
|
||||
|
||||
def _init_registration(domain: str = "feishu") -> None:
|
||||
"""Verify the environment supports client_secret auth.
|
||||
|
||||
Raises RuntimeError if not supported.
|
||||
"""
|
||||
base_url = _accounts_base_url(domain)
|
||||
res = _post_registration(base_url, {"action": "init"})
|
||||
methods = res.get("supported_auth_methods") or []
|
||||
if "client_secret" not in methods:
|
||||
raise RuntimeError(
|
||||
f"Feishu / Lark registration environment does not support client_secret auth. "
|
||||
f"Supported: {methods}"
|
||||
)
|
||||
|
||||
|
||||
def _begin_registration(domain: str = "feishu") -> dict:
|
||||
"""Start the device-code flow. Returns device_code, qr_url, user_code, interval, expire_in."""
|
||||
base_url = _accounts_base_url(domain)
|
||||
res = _post_registration(base_url, {
|
||||
"action": "begin",
|
||||
"archetype": "PersonalAgent",
|
||||
"auth_method": "client_secret",
|
||||
"request_user_info": "open_id",
|
||||
})
|
||||
device_code = res.get("device_code")
|
||||
if not device_code:
|
||||
raise RuntimeError("Feishu / Lark registration did not return a device_code")
|
||||
qr_url = res.get("verification_uri_complete", "")
|
||||
if "?" in qr_url:
|
||||
qr_url += "&from=hermes&tp=hermes"
|
||||
else:
|
||||
qr_url += "?from=hermes&tp=hermes"
|
||||
return {
|
||||
"device_code": device_code,
|
||||
"qr_url": qr_url,
|
||||
"user_code": res.get("user_code", ""),
|
||||
"interval": res.get("interval") or 5,
|
||||
"expire_in": res.get("expire_in") or 600,
|
||||
}
|
||||
|
||||
|
||||
def _poll_registration(
|
||||
*,
|
||||
device_code: str,
|
||||
interval: int,
|
||||
expire_in: int,
|
||||
domain: str = "feishu",
|
||||
) -> Optional[dict]:
|
||||
"""Poll until the user scans the QR code, or timeout/denial.
|
||||
|
||||
Returns dict with app_id, app_secret, domain, open_id on success.
|
||||
Returns None on failure.
|
||||
"""
|
||||
deadline = time.time() + expire_in
|
||||
current_domain = domain
|
||||
domain_switched = False
|
||||
poll_count = 0
|
||||
|
||||
while time.time() < deadline:
|
||||
base_url = _accounts_base_url(current_domain)
|
||||
try:
|
||||
res = _post_registration(base_url, {
|
||||
"action": "poll",
|
||||
"device_code": device_code,
|
||||
"tp": "ob_app",
|
||||
})
|
||||
except (URLError, OSError, json.JSONDecodeError):
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
poll_count += 1
|
||||
if poll_count == 1:
|
||||
print(" Fetching configuration results...", end="", flush=True)
|
||||
elif poll_count % 6 == 0:
|
||||
print(".", end="", flush=True)
|
||||
|
||||
# Domain auto-detection
|
||||
user_info = res.get("user_info") or {}
|
||||
tenant_brand = user_info.get("tenant_brand")
|
||||
if tenant_brand == "lark" and not domain_switched:
|
||||
current_domain = "lark"
|
||||
domain_switched = True
|
||||
# Fall through — server may return credentials in this same response.
|
||||
|
||||
# Success
|
||||
if res.get("client_id") and res.get("client_secret"):
|
||||
if poll_count > 0:
|
||||
print() # newline after "Fetching configuration results..." dots
|
||||
return {
|
||||
"app_id": res["client_id"],
|
||||
"app_secret": res["client_secret"],
|
||||
"domain": current_domain,
|
||||
"open_id": user_info.get("open_id"),
|
||||
}
|
||||
|
||||
# Terminal errors
|
||||
error = res.get("error", "")
|
||||
if error in ("access_denied", "expired_token"):
|
||||
if poll_count > 0:
|
||||
print()
|
||||
logger.warning("[Feishu onboard] Registration %s", error)
|
||||
return None
|
||||
|
||||
# authorization_pending or unknown — keep polling
|
||||
time.sleep(interval)
|
||||
|
||||
if poll_count > 0:
|
||||
print()
|
||||
logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in)
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
import qrcode as _qrcode_mod
|
||||
except (ImportError, TypeError):
|
||||
_qrcode_mod = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _render_qr(url: str) -> bool:
|
||||
"""Try to render a QR code in the terminal. Returns True if successful."""
|
||||
if _qrcode_mod is None:
|
||||
return False
|
||||
try:
|
||||
qr = _qrcode_mod.QRCode()
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr.print_ascii(invert=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
||||
"""Verify bot connectivity via /open-apis/bot/v3/info.
|
||||
|
||||
Uses lark_oapi SDK when available, falls back to raw HTTP otherwise.
|
||||
Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure.
|
||||
"""
|
||||
if FEISHU_AVAILABLE:
|
||||
return _probe_bot_sdk(app_id, app_secret, domain)
|
||||
return _probe_bot_http(app_id, app_secret, domain)
|
||||
|
||||
|
||||
def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any:
|
||||
"""Build a lark Client for the given credentials and domain."""
|
||||
sdk_domain = LARK_DOMAIN if domain == "lark" else FEISHU_DOMAIN
|
||||
return (
|
||||
lark.Client.builder()
|
||||
.app_id(app_id)
|
||||
.app_secret(app_secret)
|
||||
.domain(sdk_domain)
|
||||
.log_level(lark.LogLevel.WARNING)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def _parse_bot_response(data: dict) -> Optional[dict]:
|
||||
"""Extract bot_name and bot_open_id from a /bot/v3/info response."""
|
||||
if data.get("code") != 0:
|
||||
return None
|
||||
bot = data.get("bot") or data.get("data", {}).get("bot") or {}
|
||||
return {
|
||||
"bot_name": bot.get("bot_name"),
|
||||
"bot_open_id": bot.get("open_id"),
|
||||
}
|
||||
|
||||
|
||||
def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
||||
"""Probe bot info using lark_oapi SDK."""
|
||||
try:
|
||||
client = _build_onboard_client(app_id, app_secret, domain)
|
||||
resp = client.request(
|
||||
method="GET",
|
||||
url="/open-apis/bot/v3/info",
|
||||
body=None,
|
||||
raw_response=True,
|
||||
)
|
||||
return _parse_bot_response(json.loads(resp.content))
|
||||
except Exception as exc:
|
||||
logger.debug("[Feishu onboard] SDK probe failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _probe_bot_http(app_id: str, app_secret: str, domain: str) -> Optional[dict]:
|
||||
"""Fallback probe using raw HTTP (when lark_oapi is not installed)."""
|
||||
base_url = _onboard_open_base_url(domain)
|
||||
try:
|
||||
token_data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8")
|
||||
token_req = Request(
|
||||
f"{base_url}/open-apis/auth/v3/tenant_access_token/internal",
|
||||
data=token_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urlopen(token_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
|
||||
token_res = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
access_token = token_res.get("tenant_access_token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
bot_req = Request(
|
||||
f"{base_url}/open-apis/bot/v3/info",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urlopen(bot_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp:
|
||||
bot_res = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
return _parse_bot_response(bot_res)
|
||||
except (URLError, OSError, KeyError, json.JSONDecodeError) as exc:
|
||||
logger.debug("[Feishu onboard] HTTP probe failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def qr_register(
|
||||
*,
|
||||
initial_domain: str = "feishu",
|
||||
timeout_seconds: int = 600,
|
||||
) -> Optional[dict]:
|
||||
"""Run the Feishu / Lark scan-to-create QR registration flow.
|
||||
|
||||
Returns on success::
|
||||
|
||||
{
|
||||
"app_id": str,
|
||||
"app_secret": str,
|
||||
"domain": "feishu" | "lark",
|
||||
"open_id": str | None,
|
||||
"bot_name": str | None,
|
||||
"bot_open_id": str | None,
|
||||
}
|
||||
|
||||
Returns None on expected failures (network, auth denied, timeout).
|
||||
Unexpected errors (bugs, protocol regressions) propagate to the caller.
|
||||
"""
|
||||
try:
|
||||
return _qr_register_inner(initial_domain=initial_domain, timeout_seconds=timeout_seconds)
|
||||
except (RuntimeError, URLError, OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("[Feishu onboard] Registration failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _qr_register_inner(
|
||||
*,
|
||||
initial_domain: str,
|
||||
timeout_seconds: int,
|
||||
) -> Optional[dict]:
|
||||
"""Run init → begin → poll → probe. Raises on network/protocol errors."""
|
||||
print(" Connecting to Feishu / Lark...", end="", flush=True)
|
||||
_init_registration(initial_domain)
|
||||
begin = _begin_registration(initial_domain)
|
||||
print(" done.")
|
||||
|
||||
print()
|
||||
qr_url = begin["qr_url"]
|
||||
if _render_qr(qr_url):
|
||||
print(f"\n Scan the QR code above, or open this URL directly:\n {qr_url}")
|
||||
else:
|
||||
print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n")
|
||||
print(" Tip: pip install qrcode to display a scannable QR code here next time")
|
||||
print()
|
||||
|
||||
result = _poll_registration(
|
||||
device_code=begin["device_code"],
|
||||
interval=begin["interval"],
|
||||
expire_in=min(begin["expire_in"], timeout_seconds),
|
||||
domain=initial_domain,
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
# Probe bot — best-effort, don't fail the registration
|
||||
bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"])
|
||||
if bot_info:
|
||||
result["bot_name"] = bot_info.get("bot_name")
|
||||
result["bot_open_id"] = bot_info.get("bot_open_id")
|
||||
else:
|
||||
result["bot_name"] = None
|
||||
result["bot_open_id"] = None
|
||||
|
||||
return result
|
||||
|
||||
@@ -782,7 +782,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Try aiohttp first (always available), fall back to httpx
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
async with _aiohttp.ClientSession() as http:
|
||||
async with _aiohttp.ClientSession(trust_env=True) as http:
|
||||
async with http.get(image_url, timeout=_aiohttp.ClientTimeout(total=30)) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.read()
|
||||
@@ -1135,7 +1135,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
thread_id = relates_to.get("event_id")
|
||||
|
||||
formatted_body = source_content.get("formatted_body")
|
||||
is_mentioned = self._is_bot_mentioned(body, formatted_body)
|
||||
# m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal.
|
||||
mentions_block = source_content.get("m.mentions") or {}
|
||||
mention_user_ids = mentions_block.get("user_ids") if isinstance(mentions_block, dict) else None
|
||||
is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids)
|
||||
|
||||
# Require-mention gating.
|
||||
if not is_dm:
|
||||
@@ -1822,8 +1825,24 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Mention detection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool:
|
||||
"""Return True if the bot is mentioned in the message."""
|
||||
def _is_bot_mentioned(
|
||||
self,
|
||||
body: str,
|
||||
formatted_body: Optional[str] = None,
|
||||
mention_user_ids: Optional[list] = None,
|
||||
) -> bool:
|
||||
"""Return True if the bot is mentioned in the message.
|
||||
|
||||
Per MSC3952, ``m.mentions.user_ids`` is the authoritative mention
|
||||
signal in the Matrix spec. When the sender's client populates that
|
||||
field with the bot's user-id, we trust it — even when the visible
|
||||
body text does not contain an explicit ``@bot`` string (some clients
|
||||
only render mention "pills" in ``formatted_body`` or use display
|
||||
names).
|
||||
"""
|
||||
# m.mentions.user_ids — authoritative per MSC3952 / Matrix v1.7.
|
||||
if mention_user_ids and self._user_id and self._user_id in mention_user_ids:
|
||||
return True
|
||||
if not body and not formatted_body:
|
||||
return False
|
||||
if self._user_id and self._user_id in body:
|
||||
|
||||
@@ -65,7 +65,10 @@ from gateway.platforms.base import (
|
||||
cache_image_from_bytes,
|
||||
cache_audio_from_bytes,
|
||||
cache_document_from_bytes,
|
||||
resolve_proxy_url,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
utf16_len,
|
||||
_prefix_within_utf16_limit,
|
||||
)
|
||||
from gateway.platforms.telegram_network import (
|
||||
TelegramFallbackTransport,
|
||||
@@ -537,10 +540,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
|
||||
}
|
||||
|
||||
proxy_configured = any(
|
||||
(os.getenv(k) or "").strip()
|
||||
for k in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy")
|
||||
)
|
||||
proxy_url = resolve_proxy_url()
|
||||
disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on"))
|
||||
fallback_ips = self._fallback_ips()
|
||||
if not fallback_ips:
|
||||
@@ -551,7 +551,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
", ".join(fallback_ips),
|
||||
)
|
||||
|
||||
if fallback_ips and not proxy_configured and not disable_fallback:
|
||||
if fallback_ips and not proxy_url and not disable_fallback:
|
||||
logger.info(
|
||||
"[%s] Telegram fallback IPs active: %s",
|
||||
self.name,
|
||||
@@ -567,10 +567,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
**request_kwargs,
|
||||
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
|
||||
)
|
||||
elif proxy_url:
|
||||
logger.info("[%s] Proxy detected; passing explicitly to HTTPXRequest: %s", self.name, proxy_url)
|
||||
request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
|
||||
get_updates_request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
|
||||
else:
|
||||
if proxy_configured:
|
||||
logger.info("[%s] Proxy configured; skipping Telegram fallback-IP transport", self.name)
|
||||
elif disable_fallback:
|
||||
if disable_fallback:
|
||||
logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
|
||||
request = HTTPXRequest(**request_kwargs)
|
||||
get_updates_request = HTTPXRequest(**request_kwargs)
|
||||
@@ -799,7 +801,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
# Format and split message if needed
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||
chunks = self.truncate_message(
|
||||
formatted, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len,
|
||||
)
|
||||
if len(chunks) > 1:
|
||||
# truncate_message appends a raw " (1/2)" suffix. Escape the
|
||||
# MarkdownV2-special parentheses so Telegram doesn't reject the
|
||||
@@ -970,7 +974,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# streaming). Truncate and succeed so the stream consumer can
|
||||
# split the overflow into a new message instead of dying.
|
||||
if "message_too_long" in err_str or "too long" in err_str:
|
||||
truncated = content[: self.MAX_MESSAGE_LENGTH - 20] + "…"
|
||||
truncated = _prefix_within_utf16_limit(
|
||||
content, self.MAX_MESSAGE_LENGTH - 20
|
||||
) + "…"
|
||||
try:
|
||||
await self._bot.edit_message_text(
|
||||
chat_id=int(chat_id),
|
||||
|
||||
@@ -266,7 +266,7 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
async def _open_connection(self) -> None:
|
||||
"""Open and authenticate a websocket connection."""
|
||||
await self._cleanup_ws()
|
||||
self._session = aiohttp.ClientSession()
|
||||
self._session = aiohttp.ClientSession(trust_env=True)
|
||||
self._ws = await self._session.ws_connect(
|
||||
self._ws_url,
|
||||
heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2,
|
||||
|
||||
@@ -112,6 +112,7 @@ TYPING_STOP = 2
|
||||
_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||||
_TABLE_RULE_RE = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$")
|
||||
_FENCE_RE = re.compile(r"^```([^\n`]*)\s*$")
|
||||
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
|
||||
|
||||
def check_weixin_requirements() -> bool:
|
||||
@@ -398,15 +399,16 @@ async def _send_message(
|
||||
context_token: Optional[str],
|
||||
client_id: str,
|
||||
) -> None:
|
||||
if not text or not text.strip():
|
||||
raise ValueError("_send_message: text must not be empty")
|
||||
message: Dict[str, Any] = {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to,
|
||||
"client_id": client_id,
|
||||
"message_type": MSG_TYPE_BOT,
|
||||
"message_state": MSG_STATE_FINISH,
|
||||
"item_list": [{"type": ITEM_TEXT, "text_item": {"text": text}}],
|
||||
}
|
||||
if text:
|
||||
message["item_list"] = [{"type": ITEM_TEXT, "text_item": {"text": text}}]
|
||||
if context_token:
|
||||
message["context_token"] = context_token
|
||||
await _api_post(
|
||||
@@ -499,13 +501,15 @@ async def _upload_ciphertext(
|
||||
session: "aiohttp.ClientSession",
|
||||
*,
|
||||
ciphertext: bytes,
|
||||
cdn_base_url: str,
|
||||
upload_param: str,
|
||||
filekey: str,
|
||||
upload_url: str,
|
||||
) -> str:
|
||||
url = _cdn_upload_url(cdn_base_url, upload_param, filekey)
|
||||
"""Upload encrypted media to the CDN.
|
||||
|
||||
Accepts either a constructed CDN URL (from upload_param) or a direct
|
||||
upload_full_url — both use POST with the raw ciphertext as the body.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
async with session.post(url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response:
|
||||
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response:
|
||||
if response.status == 200:
|
||||
encrypted_param = response.headers.get("x-encrypted-param")
|
||||
if encrypted_param:
|
||||
@@ -649,7 +653,7 @@ def _normalize_markdown_blocks(content: str) -> str:
|
||||
result.append(_rewrite_table_block_for_weixin(table_lines))
|
||||
continue
|
||||
|
||||
result.append(_rewrite_headers_for_weixin(line))
|
||||
result.append(_MARKDOWN_LINK_RE.sub(r"\1 (\2)", _rewrite_headers_for_weixin(line)))
|
||||
i += 1
|
||||
|
||||
normalized = "\n".join(item.rstrip() for item in result)
|
||||
@@ -811,6 +815,8 @@ def _split_text_for_weixin_delivery(
|
||||
``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``)
|
||||
or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``.
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
if split_per_line:
|
||||
# Legacy: one message per top-level delivery unit.
|
||||
if len(content) <= max_length and "\n" not in content:
|
||||
@@ -821,14 +827,14 @@ def _split_text_for_weixin_delivery(
|
||||
chunks.append(unit)
|
||||
continue
|
||||
chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length))
|
||||
return chunks or [content]
|
||||
return [c for c in chunks if c] or [content]
|
||||
|
||||
# Compact (default): single message when under the limit — unless the
|
||||
# content looks like a short chatty exchange, in which case split into
|
||||
# separate bubbles for a more natural chat feel.
|
||||
if len(content) <= max_length:
|
||||
return (
|
||||
_split_delivery_units_for_weixin(content)
|
||||
[u for u in _split_delivery_units_for_weixin(content) if u]
|
||||
if _should_split_short_chat_block_for_weixin(content)
|
||||
else [content]
|
||||
)
|
||||
@@ -929,7 +935,7 @@ async def qr_login(
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
raise RuntimeError("aiohttp is required for Weixin QR login")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
try:
|
||||
qr_resp = await _api_get(
|
||||
session,
|
||||
@@ -1042,6 +1048,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
|
||||
# WeChat does not support editing sent messages — streaming must use the
|
||||
# fallback "send-final-only" path so the cursor (▉) is never left visible.
|
||||
SUPPORTS_MESSAGE_EDITING = False
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.WEIXIN)
|
||||
extra = config.extra or {}
|
||||
@@ -1124,7 +1134,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc)
|
||||
|
||||
self._session = aiohttp.ClientSession()
|
||||
self._session = aiohttp.ClientSession(trust_env=True)
|
||||
self._token_store.restore(self._account_id)
|
||||
self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll")
|
||||
self._mark_connected()
|
||||
@@ -1451,7 +1461,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
context_token = self._token_store.get(self._account_id, chat_id)
|
||||
last_message_id: Optional[str] = None
|
||||
try:
|
||||
chunks = self._split_text(self.format_message(content))
|
||||
chunks = [c for c in self._split_text(self.format_message(content)) if c and c.strip()]
|
||||
for idx, chunk in enumerate(chunks):
|
||||
client_id = f"hermes-weixin-{uuid.uuid4().hex}"
|
||||
await self._send_text_chunk(
|
||||
@@ -1537,24 +1547,51 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
return await self.send_document(chat_id, path, caption=caption, metadata=metadata)
|
||||
return await self.send_document(chat_id, file_path=path, caption=caption, metadata=metadata)
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: str,
|
||||
path: str,
|
||||
file_path: str,
|
||||
caption: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if not self._session or not self._token:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
message_id = await self._send_file(chat_id, path, caption)
|
||||
message_id = await self._send_file(chat_id, file_path, caption)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: str,
|
||||
video_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
if not self._session or not self._token:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
message_id = await self._send_file(chat_id, video_path, caption or "")
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as exc:
|
||||
logger.error("[%s] send_video failed to=%s: %s", self.name, _safe_id(chat_id), exc)
|
||||
return SendResult(success=False, error=str(exc))
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
audio_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
return await self.send_document(chat_id, audio_path, caption=caption or "", metadata=metadata)
|
||||
|
||||
async def _download_remote_media(self, url: str) -> str:
|
||||
from tools.url_safety import is_safe_url
|
||||
|
||||
@@ -1577,6 +1614,7 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
filekey = secrets.token_hex(16)
|
||||
aes_key = secrets.token_bytes(16)
|
||||
rawsize = len(plaintext)
|
||||
rawfilemd5 = hashlib.md5(plaintext).hexdigest()
|
||||
upload_response = await _get_upload_url(
|
||||
self._session,
|
||||
base_url=self._base_url,
|
||||
@@ -1585,41 +1623,42 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
media_type=media_type,
|
||||
filekey=filekey,
|
||||
rawsize=rawsize,
|
||||
rawfilemd5=hashlib.md5(plaintext).hexdigest(),
|
||||
rawfilemd5=rawfilemd5,
|
||||
filesize=_aes_padded_size(rawsize),
|
||||
aeskey_hex=aes_key.hex(),
|
||||
)
|
||||
upload_param = str(upload_response.get("upload_param") or "")
|
||||
upload_full_url = str(upload_response.get("upload_full_url") or "")
|
||||
ciphertext = _aes128_ecb_encrypt(plaintext, aes_key)
|
||||
if upload_param:
|
||||
encrypted_query_param = await _upload_ciphertext(
|
||||
self._session,
|
||||
ciphertext=ciphertext,
|
||||
cdn_base_url=self._cdn_base_url,
|
||||
upload_param=upload_param,
|
||||
filekey=filekey,
|
||||
)
|
||||
elif upload_full_url:
|
||||
timeout = aiohttp.ClientTimeout(total=120)
|
||||
async with self._session.put(
|
||||
upload_full_url,
|
||||
data=ciphertext,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
encrypted_query_param = response.headers.get("x-encrypted-param") or filekey
|
||||
|
||||
# Prefer upload_full_url (direct CDN), fall back to constructed CDN URL
|
||||
# from upload_param. Both paths use POST — the old PUT for
|
||||
# upload_full_url caused 404s on the WeChat CDN.
|
||||
if upload_full_url:
|
||||
upload_url = upload_full_url
|
||||
elif upload_param:
|
||||
upload_url = _cdn_upload_url(self._cdn_base_url, upload_param, filekey)
|
||||
else:
|
||||
raise RuntimeError(f"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}")
|
||||
|
||||
encrypted_query_param = await _upload_ciphertext(
|
||||
self._session,
|
||||
ciphertext=ciphertext,
|
||||
upload_url=upload_url,
|
||||
)
|
||||
|
||||
context_token = self._token_store.get(self._account_id, chat_id)
|
||||
# The iLink API expects aes_key as base64(hex_string), not base64(raw_bytes).
|
||||
# Sending base64(raw_bytes) causes images to show as grey boxes on the
|
||||
# receiver side because the decryption key doesn't match.
|
||||
aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii")
|
||||
media_item = item_builder(
|
||||
encrypt_query_param=encrypted_query_param,
|
||||
aes_key_b64=base64.b64encode(aes_key).decode("ascii"),
|
||||
aes_key_for_api=aes_key_for_api,
|
||||
ciphertext_size=len(ciphertext),
|
||||
plaintext_size=rawsize,
|
||||
filename=Path(path).name,
|
||||
rawfilemd5=rawfilemd5,
|
||||
)
|
||||
|
||||
last_message_id = None
|
||||
@@ -1659,39 +1698,53 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
def _outbound_media_builder(self, path: str):
|
||||
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
if mime.startswith("image/"):
|
||||
return MEDIA_IMAGE, lambda **kwargs: {
|
||||
return MEDIA_IMAGE, lambda **kw: {
|
||||
"type": ITEM_IMAGE,
|
||||
"image_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": kwargs["encrypt_query_param"],
|
||||
"aes_key": kwargs["aes_key_b64"],
|
||||
"encrypt_query_param": kw["encrypt_query_param"],
|
||||
"aes_key": kw["aes_key_for_api"],
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"mid_size": kwargs["ciphertext_size"],
|
||||
"mid_size": kw["ciphertext_size"],
|
||||
},
|
||||
}
|
||||
if mime.startswith("video/"):
|
||||
return MEDIA_VIDEO, lambda **kwargs: {
|
||||
return MEDIA_VIDEO, lambda **kw: {
|
||||
"type": ITEM_VIDEO,
|
||||
"video_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": kwargs["encrypt_query_param"],
|
||||
"aes_key": kwargs["aes_key_b64"],
|
||||
"encrypt_query_param": kw["encrypt_query_param"],
|
||||
"aes_key": kw["aes_key_for_api"],
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"video_size": kwargs["ciphertext_size"],
|
||||
"video_size": kw["ciphertext_size"],
|
||||
"play_length": kw.get("play_length", 0),
|
||||
"video_md5": kw.get("rawfilemd5", ""),
|
||||
},
|
||||
}
|
||||
return MEDIA_FILE, lambda **kwargs: {
|
||||
if mime.startswith("audio/") or path.endswith(".silk"):
|
||||
return MEDIA_VOICE, lambda **kw: {
|
||||
"type": ITEM_VOICE,
|
||||
"voice_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": kw["encrypt_query_param"],
|
||||
"aes_key": kw["aes_key_for_api"],
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"playtime": kw.get("playtime", 0),
|
||||
},
|
||||
}
|
||||
return MEDIA_FILE, lambda **kw: {
|
||||
"type": ITEM_FILE,
|
||||
"file_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": kwargs["encrypt_query_param"],
|
||||
"aes_key": kwargs["aes_key_b64"],
|
||||
"encrypt_query_param": kw["encrypt_query_param"],
|
||||
"aes_key": kw["aes_key_for_api"],
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"file_name": kwargs["filename"],
|
||||
"len": str(kwargs["plaintext_size"]),
|
||||
"file_name": kw["filename"],
|
||||
"len": str(kw["plaintext_size"]),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1731,7 +1784,7 @@ async def send_weixin_direct(
|
||||
token_store.restore(account_id)
|
||||
context_token = token_store.get(account_id, chat_id)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
adapter = WeixinAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
|
||||
@@ -120,8 +120,9 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
- session_path: Path to store WhatsApp session data
|
||||
"""
|
||||
|
||||
# WhatsApp message limits
|
||||
MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages
|
||||
# WhatsApp message limits — practical UX limit, not protocol max.
|
||||
# WhatsApp allows ~65K but long messages are unreadable on mobile.
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
# Default bridge location relative to the hermes-agent install
|
||||
_DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
|
||||
@@ -531,6 +532,63 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
self._close_bridge_log()
|
||||
print(f"[{self.name}] Disconnected")
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Convert standard markdown to WhatsApp-compatible formatting.
|
||||
|
||||
WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
|
||||
and monospaced `inline`. Standard markdown uses different syntax
|
||||
for bold/italic/strikethrough, so we convert here.
|
||||
|
||||
Code blocks (``` fenced) and inline code (`) are protected from
|
||||
conversion via placeholder substitution.
|
||||
"""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
# --- 1. Protect fenced code blocks from formatting changes ---
|
||||
_FENCE_PH = "\x00FENCE"
|
||||
fences: list[str] = []
|
||||
|
||||
def _save_fence(m: re.Match) -> str:
|
||||
fences.append(m.group(0))
|
||||
return f"{_FENCE_PH}{len(fences) - 1}\x00"
|
||||
|
||||
result = re.sub(r"```[\s\S]*?```", _save_fence, content)
|
||||
|
||||
# --- 2. Protect inline code ---
|
||||
_CODE_PH = "\x00CODE"
|
||||
codes: list[str] = []
|
||||
|
||||
def _save_code(m: re.Match) -> str:
|
||||
codes.append(m.group(0))
|
||||
return f"{_CODE_PH}{len(codes) - 1}\x00"
|
||||
|
||||
result = re.sub(r"`[^`\n]+`", _save_code, result)
|
||||
|
||||
# --- 3. Convert markdown formatting to WhatsApp syntax ---
|
||||
# Bold: **text** or __text__ → *text*
|
||||
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
|
||||
result = re.sub(r"__(.+?)__", r"*\1*", result)
|
||||
# Strikethrough: ~~text~~ → ~text~
|
||||
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
|
||||
# Italic: *text* is already WhatsApp italic — leave as-is
|
||||
# _text_ is already WhatsApp italic — leave as-is
|
||||
|
||||
# --- 4. Convert markdown headers to bold text ---
|
||||
# # Header → *Header*
|
||||
result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE)
|
||||
|
||||
# --- 5. Convert markdown links: [text](url) → text (url) ---
|
||||
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
|
||||
|
||||
# --- 6. Restore protected sections ---
|
||||
for i, fence in enumerate(fences):
|
||||
result = result.replace(f"{_FENCE_PH}{i}\x00", fence)
|
||||
for i, code in enumerate(codes):
|
||||
result = result.replace(f"{_CODE_PH}{i}\x00", code)
|
||||
|
||||
return result
|
||||
|
||||
async def send(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -538,38 +596,57 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> SendResult:
|
||||
"""Send a message via the WhatsApp bridge."""
|
||||
"""Send a message via the WhatsApp bridge.
|
||||
|
||||
Formats markdown for WhatsApp, splits long messages into chunks
|
||||
that preserve code block boundaries, and sends each chunk sequentially.
|
||||
"""
|
||||
if not self._running or not self._http_session:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
bridge_exit = await self._check_managed_bridge_exit()
|
||||
if bridge_exit:
|
||||
return SendResult(success=False, error=bridge_exit)
|
||||
|
||||
|
||||
if not content or not content.strip():
|
||||
return SendResult(success=True, message_id=None)
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
payload = {
|
||||
"chatId": chat_id,
|
||||
"message": content,
|
||||
}
|
||||
if reply_to:
|
||||
payload["replyTo"] = reply_to
|
||||
|
||||
async with self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/send",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=data.get("messageId"),
|
||||
raw_response=data
|
||||
)
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
# Format and chunk the message
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||
|
||||
last_message_id = None
|
||||
for chunk in chunks:
|
||||
payload: Dict[str, Any] = {
|
||||
"chatId": chat_id,
|
||||
"message": chunk,
|
||||
}
|
||||
if reply_to and last_message_id is None:
|
||||
# Only reply-to on the first chunk
|
||||
payload["replyTo"] = reply_to
|
||||
|
||||
async with self._http_session.post(
|
||||
f"http://127.0.0.1:{self._bridge_port}/send",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
last_message_id = data.get("messageId")
|
||||
else:
|
||||
error = await resp.text()
|
||||
return SendResult(success=False, error=error)
|
||||
|
||||
# Small delay between chunks to avoid rate limiting
|
||||
if len(chunks) > 1:
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=last_message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
|
||||
270
gateway/run.py
270
gateway/run.py
@@ -186,6 +186,8 @@ if _config_path.exists():
|
||||
os.environ["HERMES_AGENT_TIMEOUT"] = str(_agent_cfg["gateway_timeout"])
|
||||
if "gateway_timeout_warning" in _agent_cfg and "HERMES_AGENT_TIMEOUT_WARNING" not in os.environ:
|
||||
os.environ["HERMES_AGENT_TIMEOUT_WARNING"] = str(_agent_cfg["gateway_timeout_warning"])
|
||||
if "gateway_notify_interval" in _agent_cfg and "HERMES_AGENT_NOTIFY_INTERVAL" not in os.environ:
|
||||
os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"])
|
||||
if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ:
|
||||
os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"])
|
||||
_display_cfg = _cfg.get("display", {})
|
||||
@@ -1715,6 +1717,9 @@ class GatewayRunner:
|
||||
):
|
||||
self._schedule_update_notification_watch()
|
||||
|
||||
# Notify the chat that initiated /restart that the gateway is back.
|
||||
await self._send_restart_notification()
|
||||
|
||||
# Drain any recovered process watchers (from crash recovery checkpoint)
|
||||
try:
|
||||
from tools.process_registry import process_registry
|
||||
@@ -2541,11 +2546,8 @@ class GatewayRunner:
|
||||
self._pending_messages.pop(_quick_key, None)
|
||||
if _quick_key in self._running_agents:
|
||||
del self._running_agents[_quick_key]
|
||||
# Mark session suspended so the next message starts fresh
|
||||
# instead of resuming the stuck context (#7536).
|
||||
self.session_store.suspend_session(_quick_key)
|
||||
logger.info("HARD STOP for session %s — suspended, session lock released", _quick_key[:20])
|
||||
return "⚡ Force-stopped. The session is suspended — your next message will start fresh."
|
||||
logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key[:20])
|
||||
return "⚡ Stopped. You can continue this session."
|
||||
|
||||
# /reset and /new must bypass the running-agent guard so they
|
||||
# actually dispatch as commands instead of being queued as user
|
||||
@@ -2762,6 +2764,9 @@ class GatewayRunner:
|
||||
if canonical == "update":
|
||||
return await self._handle_update_command(event)
|
||||
|
||||
if canonical == "debug":
|
||||
return await self._handle_debug_command(event)
|
||||
|
||||
if canonical == "title":
|
||||
return await self._handle_title_command(event)
|
||||
|
||||
@@ -3329,21 +3334,26 @@ class GatewayRunner:
|
||||
# Must run after runtime resolution so _hyg_base_url is set.
|
||||
if _hyg_config_context_length is None and _hyg_base_url:
|
||||
try:
|
||||
_hyg_custom_providers = _hyg_data.get("custom_providers")
|
||||
if isinstance(_hyg_custom_providers, list):
|
||||
for _cp in _hyg_custom_providers:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_cp_url = (_cp.get("base_url") or "").rstrip("/")
|
||||
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
|
||||
_cp_models = _cp.get("models", {})
|
||||
if isinstance(_cp_models, dict):
|
||||
_cp_model_cfg = _cp_models.get(_hyg_model, {})
|
||||
if isinstance(_cp_model_cfg, dict):
|
||||
_cp_ctx = _cp_model_cfg.get("context_length")
|
||||
if _cp_ctx is not None:
|
||||
_hyg_config_context_length = int(_cp_ctx)
|
||||
break
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers as _gw_gcp
|
||||
_hyg_custom_providers = _gw_gcp(_hyg_data)
|
||||
except Exception:
|
||||
_hyg_custom_providers = _hyg_data.get("custom_providers")
|
||||
if not isinstance(_hyg_custom_providers, list):
|
||||
_hyg_custom_providers = []
|
||||
for _cp in _hyg_custom_providers:
|
||||
if not isinstance(_cp, dict):
|
||||
continue
|
||||
_cp_url = (_cp.get("base_url") or "").rstrip("/")
|
||||
if _cp_url and _cp_url == _hyg_base_url.rstrip("/"):
|
||||
_cp_models = _cp.get("models", {})
|
||||
if isinstance(_cp_models, dict):
|
||||
_cp_model_cfg = _cp_models.get(_hyg_model, {})
|
||||
if isinstance(_cp_model_cfg, dict):
|
||||
_cp_ctx = _cp_model_cfg.get("context_length")
|
||||
if _cp_ctx is not None:
|
||||
_hyg_config_context_length = int(_cp_ctx)
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
@@ -4204,9 +4214,7 @@ class GatewayRunner:
|
||||
only through normal command dispatch (no running agent) or as a
|
||||
fallback. Force-clean the session lock in all cases for safety.
|
||||
|
||||
When there IS a running/pending agent, the session is also marked
|
||||
as *suspended* so the next message starts a fresh session instead
|
||||
of resuming the stuck context (#7536).
|
||||
The session is preserved so the user can continue the conversation.
|
||||
"""
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
@@ -4217,17 +4225,15 @@ class GatewayRunner:
|
||||
# Force-clean the sentinel so the session is unlocked.
|
||||
if session_key in self._running_agents:
|
||||
del self._running_agents[session_key]
|
||||
self.session_store.suspend_session(session_key)
|
||||
logger.info("HARD STOP (pending) for session %s — suspended, sentinel cleared", session_key[:20])
|
||||
return "⚡ Force-stopped. The agent was still starting — your next message will start fresh."
|
||||
logger.info("STOP (pending) for session %s — sentinel cleared", session_key[:20])
|
||||
return "⚡ Stopped. The agent hadn't started yet — you can continue this session."
|
||||
if agent:
|
||||
agent.interrupt("Stop requested")
|
||||
# Force-clean the session lock so a truly hung agent doesn't
|
||||
# keep it locked forever.
|
||||
if session_key in self._running_agents:
|
||||
del self._running_agents[session_key]
|
||||
self.session_store.suspend_session(session_key)
|
||||
return "⚡ Force-stopped. Your next message will start a fresh session."
|
||||
return "⚡ Stopped. You can continue this session."
|
||||
else:
|
||||
return "No active task to stop."
|
||||
|
||||
@@ -4239,11 +4245,36 @@ class GatewayRunner:
|
||||
return f"⏳ Draining {count} active agent(s) before restart..."
|
||||
return "⏳ Gateway restart already in progress..."
|
||||
|
||||
# Save the requester's routing info so the new gateway process can
|
||||
# notify them once it comes back online.
|
||||
try:
|
||||
import json as _json
|
||||
notify_data = {
|
||||
"platform": event.source.platform.value if event.source.platform else None,
|
||||
"chat_id": event.source.chat_id,
|
||||
}
|
||||
if event.source.thread_id:
|
||||
notify_data["thread_id"] = event.source.thread_id
|
||||
(_hermes_home / ".restart_notify.json").write_text(
|
||||
_json.dumps(notify_data)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to write restart notify file: %s", e)
|
||||
|
||||
active_agents = self._running_agent_count()
|
||||
self.request_restart(detached=True, via_service=False)
|
||||
# When running under a service manager (systemd/launchd), use the
|
||||
# service restart path: exit with code 75 so the service manager
|
||||
# restarts us. The detached subprocess approach (setsid + bash)
|
||||
# doesn't work under systemd because KillMode=mixed kills all
|
||||
# processes in the cgroup, including the detached helper.
|
||||
_under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this
|
||||
if _under_service:
|
||||
self.request_restart(detached=False, via_service=True)
|
||||
else:
|
||||
self.request_restart(detached=True, via_service=False)
|
||||
if active_agents:
|
||||
return f"⏳ Draining {active_agents} active agent(s) before restart..."
|
||||
return "♻ Restarting gateway..."
|
||||
return "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`."
|
||||
|
||||
async def _handle_help_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /help command - list available commands."""
|
||||
@@ -4360,7 +4391,11 @@ class GatewayRunner:
|
||||
current_provider = model_cfg.get("provider", current_provider)
|
||||
current_base_url = model_cfg.get("base_url", "")
|
||||
user_provs = cfg.get("providers")
|
||||
custom_provs = cfg.get("custom_providers")
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers
|
||||
custom_provs = get_compatible_custom_providers(cfg)
|
||||
except Exception:
|
||||
custom_provs = cfg.get("custom_providers")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -4991,6 +5026,8 @@ class GatewayRunner:
|
||||
|
||||
if success:
|
||||
adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
|
||||
if hasattr(adapter, "_voice_sources"):
|
||||
adapter._voice_sources[guild_id] = event.source.to_dict()
|
||||
self._voice_mode[event.source.chat_id] = "all"
|
||||
self._save_voice_modes()
|
||||
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
|
||||
@@ -5051,14 +5088,23 @@ class GatewayRunner:
|
||||
if not text_ch_id:
|
||||
return
|
||||
|
||||
# Build source — reuse the linked text channel's metadata when available
|
||||
# so voice input shares the same session as the bound text conversation.
|
||||
source_data = getattr(adapter, "_voice_sources", {}).get(guild_id)
|
||||
if source_data:
|
||||
source = SessionSource.from_dict(source_data)
|
||||
source.user_id = str(user_id)
|
||||
source.user_name = str(user_id)
|
||||
else:
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id=str(text_ch_id),
|
||||
user_id=str(user_id),
|
||||
user_name=str(user_id),
|
||||
chat_type="channel",
|
||||
)
|
||||
|
||||
# Check authorization before processing voice input
|
||||
source = SessionSource(
|
||||
platform=Platform.DISCORD,
|
||||
chat_id=str(text_ch_id),
|
||||
user_id=str(user_id),
|
||||
user_name=str(user_id),
|
||||
chat_type="channel",
|
||||
)
|
||||
if not self._is_user_authorized(source):
|
||||
logger.debug("Unauthorized voice input from user %d, ignoring", user_id)
|
||||
return
|
||||
@@ -6523,6 +6569,61 @@ class GatewayRunner:
|
||||
Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL,
|
||||
})
|
||||
|
||||
async def _handle_debug_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /debug — upload debug report + logs and return paste URLs."""
|
||||
import asyncio
|
||||
from hermes_cli.debug import (
|
||||
_capture_dump, collect_debug_report, _read_full_log,
|
||||
upload_to_pastebin,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Run blocking I/O (dump capture, log reads, uploads) in a thread.
|
||||
def _collect_and_upload():
|
||||
dump_text = _capture_dump()
|
||||
report = collect_debug_report(log_lines=200, dump_text=dump_text)
|
||||
agent_log = _read_full_log("agent")
|
||||
gateway_log = _read_full_log("gateway")
|
||||
|
||||
if agent_log:
|
||||
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
|
||||
if gateway_log:
|
||||
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
|
||||
|
||||
urls = {}
|
||||
failures = []
|
||||
|
||||
try:
|
||||
urls["Report"] = upload_to_pastebin(report)
|
||||
except Exception as exc:
|
||||
return f"✗ Failed to upload debug report: {exc}"
|
||||
|
||||
if agent_log:
|
||||
try:
|
||||
urls["agent.log"] = upload_to_pastebin(agent_log)
|
||||
except Exception:
|
||||
failures.append("agent.log")
|
||||
|
||||
if gateway_log:
|
||||
try:
|
||||
urls["gateway.log"] = upload_to_pastebin(gateway_log)
|
||||
except Exception:
|
||||
failures.append("gateway.log")
|
||||
|
||||
lines = ["**Debug report uploaded:**", ""]
|
||||
label_width = max(len(k) for k in urls)
|
||||
for label, url in urls.items():
|
||||
lines.append(f"`{label:<{label_width}}` {url}")
|
||||
|
||||
if failures:
|
||||
lines.append(f"\n_(failed to upload: {', '.join(failures)})_")
|
||||
|
||||
lines.append("\nShare these links with the Hermes team for support.")
|
||||
return "\n".join(lines)
|
||||
|
||||
return await loop.run_in_executor(None, _collect_and_upload)
|
||||
|
||||
async def _handle_update_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /update command — update Hermes Agent to the latest version.
|
||||
|
||||
@@ -6917,6 +7018,48 @@ class GatewayRunner:
|
||||
|
||||
return True
|
||||
|
||||
async def _send_restart_notification(self) -> None:
|
||||
"""Notify the chat that initiated /restart that the gateway is back."""
|
||||
import json as _json
|
||||
|
||||
notify_path = _hermes_home / ".restart_notify.json"
|
||||
if not notify_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
data = _json.loads(notify_path.read_text())
|
||||
platform_str = data.get("platform")
|
||||
chat_id = data.get("chat_id")
|
||||
thread_id = data.get("thread_id")
|
||||
|
||||
if not platform_str or not chat_id:
|
||||
return
|
||||
|
||||
platform = Platform(platform_str)
|
||||
adapter = self.adapters.get(platform)
|
||||
if not adapter:
|
||||
logger.debug(
|
||||
"Restart notification skipped: %s adapter not connected",
|
||||
platform_str,
|
||||
)
|
||||
return
|
||||
|
||||
metadata = {"thread_id": thread_id} if thread_id else None
|
||||
await adapter.send(
|
||||
chat_id,
|
||||
"♻ Gateway restarted successfully. Your session continues.",
|
||||
metadata=metadata,
|
||||
)
|
||||
logger.info(
|
||||
"Sent restart notification to %s:%s",
|
||||
platform_str,
|
||||
chat_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Restart notification failed: %s", e)
|
||||
finally:
|
||||
notify_path.unlink(missing_ok=True)
|
||||
|
||||
def _set_session_env(self, context: SessionContext) -> list:
|
||||
"""Set session context variables for the current async task.
|
||||
|
||||
@@ -7448,9 +7591,11 @@ class GatewayRunner:
|
||||
_pl = get_tool_preview_max_len()
|
||||
import json as _json
|
||||
args_str = _json.dumps(args, ensure_ascii=False, default=str)
|
||||
_cap = _pl if _pl > 0 else 200
|
||||
if len(args_str) > _cap:
|
||||
args_str = args_str[:_cap - 3] + "..."
|
||||
# When tool_preview_length is 0 (default), don't truncate
|
||||
# in verbose mode — the user explicitly asked for full
|
||||
# detail. Platform message-length limits handle the rest.
|
||||
if _pl > 0 and len(args_str) > _pl:
|
||||
args_str = args_str[:_pl - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}"
|
||||
elif preview:
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
@@ -7760,10 +7905,18 @@ class GatewayRunner:
|
||||
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
|
||||
_adapter = self.adapters.get(source.platform)
|
||||
if _adapter:
|
||||
# Platforms that don't support editing sent messages
|
||||
# (e.g. WeChat) must not show a cursor in intermediate
|
||||
# sends — the cursor would be permanently visible because
|
||||
# it can never be edited away. Use an empty cursor for
|
||||
# such platforms so streaming still delivers the final
|
||||
# response, just without the typing indicator.
|
||||
_adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True)
|
||||
_effective_cursor = _scfg.cursor if _adapter_supports_edit else ""
|
||||
_consumer_cfg = StreamConsumerConfig(
|
||||
edit_interval=_scfg.edit_interval,
|
||||
buffer_threshold=_scfg.buffer_threshold,
|
||||
cursor=_scfg.cursor,
|
||||
cursor=_effective_cursor,
|
||||
)
|
||||
_stream_consumer = GatewayStreamConsumer(
|
||||
adapter=_adapter,
|
||||
@@ -8243,11 +8396,17 @@ class GatewayRunner:
|
||||
interrupt_monitor = asyncio.create_task(monitor_for_interrupt())
|
||||
|
||||
# Periodic "still working" notifications for long-running tasks.
|
||||
# Fires every 10 minutes so the user knows the agent hasn't died.
|
||||
_NOTIFY_INTERVAL = 600 # 10 minutes
|
||||
# Fires every N seconds so the user knows the agent hasn't died.
|
||||
# Config: agent.gateway_notify_interval in config.yaml, or
|
||||
# HERMES_AGENT_NOTIFY_INTERVAL env var. Default 600s (10 min).
|
||||
# 0 = disable notifications.
|
||||
_NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 600))
|
||||
_NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None
|
||||
_notify_start = time.time()
|
||||
|
||||
async def _notify_long_running():
|
||||
if _NOTIFY_INTERVAL is None:
|
||||
return # Notifications disabled (gateway_notify_interval: 0)
|
||||
_notify_adapter = self.adapters.get(source.platform)
|
||||
if not _notify_adapter:
|
||||
return
|
||||
@@ -8842,16 +9001,19 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
runner.request_restart(detached=False, via_service=True)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, shutdown_signal_handler)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
if hasattr(signal, "SIGUSR1"):
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, shutdown_signal_handler)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
if hasattr(signal, "SIGUSR1"):
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGUSR1, restart_signal_handler)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
else:
|
||||
logger.info("Skipping signal handlers (not running in main thread).")
|
||||
|
||||
# Start the gateway
|
||||
success = await runner.start()
|
||||
|
||||
@@ -878,7 +878,8 @@ class SessionStore:
|
||||
Used by ``/resume`` to restore a previously-named session.
|
||||
Ends the current session in SQLite (like reset), but instead of
|
||||
generating a fresh session ID, re-uses ``target_session_id`` so the
|
||||
old transcript is loaded on the next message.
|
||||
old transcript is loaded on the next message. If the target session was
|
||||
previously ended, re-open it so gateway resume semantics match the CLI.
|
||||
"""
|
||||
db_end_session_id = None
|
||||
new_entry = None
|
||||
@@ -918,6 +919,12 @@ class SessionStore:
|
||||
except Exception as e:
|
||||
logger.debug("Session DB end_session failed: %s", e)
|
||||
|
||||
if self._db:
|
||||
try:
|
||||
self._db.reopen_session(target_session_id)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB reopen_session failed: %s", e)
|
||||
|
||||
return new_entry
|
||||
|
||||
def list_sessions(self, active_minutes: Optional[int] = None) -> List[SessionEntry]:
|
||||
|
||||
@@ -290,6 +290,15 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str,
|
||||
}
|
||||
|
||||
existing = _read_json_file(lock_path)
|
||||
if existing is None and lock_path.exists():
|
||||
# Lock file exists but is empty or contains invalid JSON — treat as
|
||||
# stale. This happens when a previous process was killed between
|
||||
# O_CREAT|O_EXCL and the subsequent json.dump() (e.g. DNS failure
|
||||
# during rapid Slack reconnect retries).
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
if existing:
|
||||
try:
|
||||
existing_pid = int(existing["pid"])
|
||||
|
||||
Reference in New Issue
Block a user