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 random
import re import re
import time import time
import uuid
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@ -1109,6 +1108,14 @@ class WeixinChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.debug("typing clear failed for {}: {}", chat_id, 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( async def _send_text(
self, self,
to_user_id: str, to_user_id: str,
@ -1116,7 +1123,7 @@ class WeixinChannel(BaseChannel):
context_token: str, context_token: str,
) -> None: ) -> None:
"""Send a text message matching the exact protocol from send.ts.""" """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] = [] item_list: list[dict] = []
if text: if text:
@ -1143,32 +1150,48 @@ class WeixinChannel(BaseChannel):
ret = data.get("ret", 0) ret = data.get("ret", 0)
errcode = data.get("errcode", 0) errcode = data.get("errcode", 0)
# If ret=-2 (parameter error / rate limit / expired token) and we sent # The iLink sendmessage API frequently returns ret=-2 (parameter
# with a context_token, retry once without it. The openclaw reference # error / rate limit / expired token) even though HTTP status is 200.
# plugin can send without a token (it just warns), and issue #61174 # The openclaw reference plugin ignores the JSON body for sendmessage
# shows that expired context_tokens are a common cause of ret=-2. # and only checks HTTP status. We retry once without context_token
if ret == -2 and 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( self.logger.warning(
"WeChat send text returned ret=-2 for {} (client_id={}); " "WeChat send text returned ret=-2 for {} (client_id={}); "
"retrying without context_token", "treating as non-fatal. Request body: {}. Response: {}",
to_user_id, to_user_id,
client_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) self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id)
body_no_ctx["msg"].pop("context_token", None) return
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._check_response_error(data, "send text", body=body) self._check_response_error(data, "send text", body=body)
self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id) 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) media_item["len"] = str(raw_size)
# Send each media item as its own message (matching reference plugin) # 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}] item_list: list[dict] = [{"type": item_type, item_key: media_item}]
weixin_msg: dict[str, Any] = { weixin_msg: dict[str, Any] = {
@ -1317,6 +1340,15 @@ class WeixinChannel(BaseChannel):
} }
data = await self._api_post("ilink/bot/sendmessage", body) 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) 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 @pytest.mark.asyncio
async def test_send_text_raises_when_ret_minus_two_retry_also_fails() -> None: async def test_send_text_swallows_ret_minus_two_when_retry_also_fails() -> None:
"""If both attempts (with and without token) return ret=-2, raise.""" """If both attempts return ret=-2, swallow the error (matching openclaw)."""
channel, _bus = _make_channel() channel, _bus = _make_channel()
channel._client = object() channel._client = object()
channel._token = "token" 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"): # Should NOT raise
await channel._send_text("wx-user", "hello", "bad-token") await channel._send_text("wx-user", "hello", "bad-token")
assert channel._api_post.await_count == 2 assert channel._api_post.await_count == 2
# Token is NOT cleared because retry also failed # 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 @pytest.mark.asyncio
async def test_send_text_does_not_retry_without_token_when_no_context_token() -> None: async def test_send_text_swallows_ret_minus_two_when_no_context_token() -> None:
"""If no context_token was provided, ret=-2 should raise immediately.""" """If no context_token was provided, ret=-2 is swallowed without retry."""
channel, _bus = _make_channel() channel, _bus = _make_channel()
channel._client = object() channel._client = object()
channel._token = "token" channel._token = "token"
channel._api_post = AsyncMock(return_value={"ret": -2}) channel._api_post = AsyncMock(return_value={"ret": -2})
with pytest.raises(RuntimeError, match="ret=-2"): # Should NOT raise
await channel._send_text("wx-user", "hello", "") await channel._send_text("wx-user", "hello", "")
# Only one API call (no retry) # Only one API call (no retry)
channel._api_post.assert_awaited_once() channel._api_post.assert_awaited_once()