fix(weixin): treat ret=-2 as non-fatal on sendmessage and align client_id format

The iLink sendmessage API frequently returns ret=-2 (parameter error / rate
limit / expired token) even when HTTP status is 200.  The openclaw reference
plugin ignores the JSON body for sendmessage entirely and only checks HTTP
status.  Our previous strict ret checking turned ret=-2 into RuntimeError,
causing ChannelManager retries which only made things worse.

Changes:
- _send_text: swallow ret=-2 after one retry without context_token.
  Log request body + response at warning level for diagnostics.
- _send_media_file: same ret=-2 swallowing.
- _generate_client_id: change format to ``nanobot:{timestamp}-{hex}`` to
  match openclaw-weixin ``{prefix}:{Date.now()}-{hex}``.
- Update tests to expect swallowing instead of raising for ret=-2.
This commit is contained in:
chengyongru 2026-05-07 18:01:58 +08:00
parent 28358980ed
commit 9fefb31344
2 changed files with 64 additions and 32 deletions

View File

@ -18,7 +18,6 @@ import os
import random
import re
import time
import uuid
from collections import OrderedDict
from contextlib import suppress
from pathlib import Path
@ -1109,6 +1108,14 @@ class WeixinChannel(BaseChannel):
except Exception as e:
self.logger.debug("typing clear failed for {}: {}", chat_id, e)
@staticmethod
def _generate_client_id() -> str:
"""Generate a client_id matching the reference plugin format.
openclaw-weixin uses ``{prefix}:{timestamp}-{8-char hex}``.
"""
return f"nanobot:{int(time.time() * 1000)}-{os.urandom(4).hex()}"
async def _send_text(
self,
to_user_id: str,
@ -1116,7 +1123,7 @@ class WeixinChannel(BaseChannel):
context_token: str,
) -> None:
"""Send a text message matching the exact protocol from send.ts."""
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
client_id = self._generate_client_id()
item_list: list[dict] = []
if text:
@ -1143,32 +1150,48 @@ class WeixinChannel(BaseChannel):
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
# If ret=-2 (parameter error / rate limit / expired token) and we sent
# with a context_token, retry once without it. The openclaw reference
# plugin can send without a token (it just warns), and issue #61174
# shows that expired context_tokens are a common cause of ret=-2.
if ret == -2 and context_token:
# The iLink sendmessage API frequently returns ret=-2 (parameter
# error / rate limit / expired token) even though HTTP status is 200.
# The openclaw reference plugin ignores the JSON body for sendmessage
# and only checks HTTP status. We retry once without context_token
# (a common fix for token expiry per openclaw#61174), but if that
# also fails we swallow ret=-2 to avoid silent message drops.
if ret == -2:
if context_token:
self.logger.warning(
"WeChat send text returned ret=-2 for {} (client_id={}); "
"retrying without context_token",
to_user_id,
client_id,
)
body_no_ctx = copy.deepcopy(body)
body_no_ctx["msg"].pop("context_token", None)
data = await self._api_post("ilink/bot/sendmessage", body_no_ctx)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if ret == 0 and (errcode == 0 or errcode is None):
self.logger.warning(
"WeChat send text succeeded WITHOUT context_token for {}; "
"clearing expired token from cache",
to_user_id,
)
self._context_tokens.pop(to_user_id, None)
self._save_state()
self.logger.debug(
"WeChat text sent to {} (client_id={})", to_user_id, client_id
)
return
# Treat persistent ret=-2 as non-fatal (matching reference plugin).
self.logger.warning(
"WeChat send text returned ret=-2 for {} (client_id={}); "
"retrying without context_token",
"treating as non-fatal. Request body: {}. Response: {}",
to_user_id,
client_id,
json.dumps(body, ensure_ascii=False, default=str),
json.dumps(data, ensure_ascii=False, default=str),
)
body_no_ctx = copy.deepcopy(body)
body_no_ctx["msg"].pop("context_token", None)
data = await self._api_post("ilink/bot/sendmessage", body_no_ctx)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if ret == 0 and (errcode == 0 or errcode is None):
self.logger.warning(
"WeChat send text succeeded WITHOUT context_token for {}; "
"clearing expired token from cache",
to_user_id,
)
self._context_tokens.pop(to_user_id, None)
self._save_state()
self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id)
return
self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id)
return
self._check_response_error(data, "send text", body=body)
self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id)
@ -1297,7 +1320,7 @@ class WeixinChannel(BaseChannel):
media_item["len"] = str(raw_size)
# Send each media item as its own message (matching reference plugin)
client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
client_id = self._generate_client_id()
item_list: list[dict] = [{"type": item_type, item_key: media_item}]
weixin_msg: dict[str, Any] = {
@ -1317,6 +1340,15 @@ class WeixinChannel(BaseChannel):
}
data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
if ret == -2:
# See _send_text for rationale: openclaw ignores ret on sendmessage.
self.logger.warning(
"WeChat send media returned ret=-2 for {} (client_id={}); treating as non-fatal",
to_user_id,
client_id,
)
return
self._check_response_error(data, "send media", body=body)

View File

@ -1394,8 +1394,8 @@ async def test_send_text_retries_without_context_token_on_ret_minus_two() -> Non
@pytest.mark.asyncio
async def test_send_text_raises_when_ret_minus_two_retry_also_fails() -> None:
"""If both attempts (with and without token) return ret=-2, raise."""
async def test_send_text_swallows_ret_minus_two_when_retry_also_fails() -> None:
"""If both attempts return ret=-2, swallow the error (matching openclaw)."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
@ -1408,8 +1408,8 @@ async def test_send_text_raises_when_ret_minus_two_retry_also_fails() -> None:
]
)
with pytest.raises(RuntimeError, match="ret=-2"):
await channel._send_text("wx-user", "hello", "bad-token")
# Should NOT raise
await channel._send_text("wx-user", "hello", "bad-token")
assert channel._api_post.await_count == 2
# Token is NOT cleared because retry also failed
@ -1417,16 +1417,16 @@ async def test_send_text_raises_when_ret_minus_two_retry_also_fails() -> None:
@pytest.mark.asyncio
async def test_send_text_does_not_retry_without_token_when_no_context_token() -> None:
"""If no context_token was provided, ret=-2 should raise immediately."""
async def test_send_text_swallows_ret_minus_two_when_no_context_token() -> None:
"""If no context_token was provided, ret=-2 is swallowed without retry."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._api_post = AsyncMock(return_value={"ret": -2})
with pytest.raises(RuntimeError, match="ret=-2"):
await channel._send_text("wx-user", "hello", "")
# Should NOT raise
await channel._send_text("wx-user", "hello", "")
# Only one API call (no retry)
channel._api_post.assert_awaited_once()