Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions gateway/platforms/weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,33 @@
MESSAGE_DEDUP_TTL_SECONDS = 300


STALE_SESSION_RET_CODES = {-2, -3, -14}


def _is_stale_session_ret(
ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]",
) -> bool:
"""True when iLink returns ret=-2 / errcode=-2 with 'unknown error',
which is a stale-session signal (same as errcode=-14) rather than
a genuine rate limit."""
if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
return False
return (errmsg or "").lower() == "unknown error"
"""True when iLink returns a stale-session signal rather than a genuine error.

iLink returns several different codes for the same underlying condition
(stale context_token):
- errcode=-14 (SESSION_EXPIRED_ERRCODE)
- ret=-2 with errmsg="unknown error"
- ret=-3 with errmsg="unknown error"
- ret=-3 with errmsg=null / absent
All indicate the session needs to be refreshed by retrying *without* a
context_token.

Note: ret=-3 is exclusive to stale-session — it is never used for rate
limiting — so we don't gate on errmsg for it. ret=-2 is shared with
rate limiting (RATE_LIMIT_ERRCODE), so errmsg remains the distinguisher."""
if ret in STALE_SESSION_RET_CODES or errcode in STALE_SESSION_RET_CODES:
# -3 / -14 alone always signal stale session regardless of errmsg.
if ret == -3 or errcode == -14:
return True
# -2 could be either stale session or rate limiting; errmsg disambiguates.
return (errmsg or "").lower() == "unknown error"
return False


MEDIA_IMAGE = 1
Expand Down Expand Up @@ -386,7 +404,14 @@ async def _do() -> Dict[str, Any]:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
result = json.loads(raw)
if endpoint == EP_SEND_MESSAGE:
logger.debug(
"[_api_post] %s resp ret=%s errcode=%s errmsg=%s body_len=%d",
endpoint, result.get("ret"), result.get("errcode"),
result.get("errmsg", ""), len(raw),
)
return result
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)


Expand Down Expand Up @@ -444,6 +469,7 @@ async def _send_message(
text: str,
context_token: Optional[str],
client_id: str,
from_user_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Send a text message via iLink sendmessage API.

Expand All @@ -453,7 +479,7 @@ async def _send_message(
if not text or not text.strip():
raise ValueError("_send_message: text must not be empty")
message: Dict[str, Any] = {
"from_user_id": "",
"from_user_id": from_user_id or "",
"to_user_id": to,
"client_id": client_id,
"message_type": MSG_TYPE_BOT,
Expand All @@ -462,6 +488,12 @@ async def _send_message(
}
if context_token:
message["context_token"] = context_token
logger.debug(
"[_send_message] to=%s from=%s ctx=%s client=%s msg_type=%s msg_state=%s text_len=%d",
_safe_id(to), _safe_id(from_user_id or ""),
"yes" if context_token else "no",
client_id[:16], MSG_TYPE_BOT, MSG_STATE_FINISH, len(text),
)
return await _api_post(
session,
base_url=base_url,
Expand Down Expand Up @@ -1672,6 +1704,7 @@ async def _send_text_chunk(
text=chunk,
context_token=context_token,
client_id=client_id,
from_user_id=self._account_id,
)
# Check iLink response for session-expired error
if resp and isinstance(resp, dict):
Expand All @@ -1684,7 +1717,7 @@ async def _send_text_chunk(
or _is_stale_session_ret(ret, errcode, resp.get("errmsg"))
)
# Session expired — strip token and retry once
if is_session_expired and not retried_without_token and context_token:
if is_session_expired and not retried_without_token:
retried_without_token = True
context_token = None
self._token_store._cache.pop(
Expand Down Expand Up @@ -2044,6 +2077,7 @@ async def _send_file(
text=self.format_message(caption),
context_token=context_token,
client_id=last_message_id,
from_user_id=self._account_id,
)

last_message_id = f"hermes-weixin-{uuid.uuid4().hex}"
Expand Down
6 changes: 5 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10831,7 +10831,11 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem
# exits when the gateway dies, taking the detached helper with it).
_under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this
_in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
if _under_service or _in_container:
# Launchd on macOS has KeepAlive=true which restarts the gateway on any
# exit — same semantics as systemd. Use via_service=True so the gateway
# exits cleanly and launchd restarts it natively, rather than spawning a
# detached watcher shell that races with KeepAlive (SIGTERM ping-pong).
if _under_service or _in_container or sys.platform == "darwin":
self.request_restart(detached=False, via_service=True)
else:
self.request_restart(detached=True, via_service=False)
Expand Down
20 changes: 0 additions & 20 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -6147,16 +6147,6 @@ def _gateway_command_inner(args):
sys.exit(1)

elif subcmd == "stop":
# Defense: refuse self-targeting gateway stop from inside the gateway.
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
if os.getenv("_HERMES_GATEWAY") == "1":
print_error(
"Refusing to stop the gateway from inside the gateway process.\n"
"This command was blocked to prevent restart loops.\n"
"Use `hermes gateway stop` from a shell outside the running gateway."
)
sys.exit(1)

stop_all = getattr(args, "all", False)
system = getattr(args, "system", False)

Expand Down Expand Up @@ -6240,16 +6230,6 @@ def _gateway_command_inner(args):
print(f"✓ Stopped {get_service_name()} service")

elif subcmd == "restart":
# Defense: refuse self-targeting gateway restart from inside the gateway.
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
if os.getenv("_HERMES_GATEWAY") == "1":
print_error(
"Refusing to restart the gateway from inside the gateway process.\n"
"This command was blocked to prevent restart loops.\n"
"Use `hermes gateway restart` from a shell outside the running gateway."
)
sys.exit(1)

# Try service first, fall back to killing and restarting
service_available = False
system = getattr(args, "system", False)
Expand Down