diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin.py index fc08ca900..9c8144a9c 100644 --- a/nanobot/channels/weixin.py +++ b/nanobot/channels/weixin.py @@ -526,6 +526,22 @@ class WeixinChannel(BaseChannel): f"WeChat session paused, {remaining_min} min remaining (errcode {ERRCODE_SESSION_EXPIRED})" ) + @staticmethod + def _check_response_error(data: dict, operation: str) -> None: + """Check both ``ret`` and ``errcode`` like the reference TS code. + + The iLink API may signal failure through either field (or both). + ``_poll_once`` already checks both; outbound send helpers must do + the same to avoid silent drops. + """ + ret = data.get("ret", 0) + errcode = data.get("errcode", 0) + is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0) + if is_error: + raise RuntimeError( + f"WeChat {operation} error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}" + ) + async def _poll_once(self) -> None: remaining = self._session_pause_remaining_s() if remaining > 0: @@ -1123,11 +1139,8 @@ class WeixinChannel(BaseChannel): } data = await self._api_post("ilink/bot/sendmessage", body) - errcode = data.get("errcode", 0) - if errcode and errcode != 0: - raise RuntimeError( - f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}" - ) + self._check_response_error(data, "send text") + self.logger.debug("WeChat text sent to {} (client_id={})", to_user_id, client_id) async def _send_media_file( self, @@ -1273,11 +1286,7 @@ class WeixinChannel(BaseChannel): } data = await self._api_post("ilink/bot/sendmessage", body) - errcode = data.get("errcode", 0) - if errcode and errcode != 0: - raise RuntimeError( - f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}" - ) + self._check_response_error(data, "send media") # --------------------------------------------------------------------------- diff --git a/tests/channels/test_weixin_channel.py b/tests/channels/test_weixin_channel.py index 6edcc1123..0722cfc7b 100644 --- a/tests/channels/test_weixin_channel.py +++ b/tests/channels/test_weixin_channel.py @@ -1252,6 +1252,26 @@ async def test_send_text_succeeds_on_zero_errcode() -> None: channel._api_post.assert_awaited_once() +@pytest.mark.asyncio +async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None: + """_send_text must raise when the API returns ret != 0, even if errcode is 0. + + The iLink API signals failure through either field. Checking only errcode + caused silent message drops (responses generated but never delivered). + """ + channel, _bus = _make_channel() + channel._client = object() + channel._token = "token" + channel._api_post = AsyncMock( + return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"} + ) + + with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"): + await channel._send_text("wx-user", "hello", "ctx-ok") + + channel._api_post.assert_awaited_once() + + # --------------------------------------------------------------------------- # Tests for _poll_once not silently dropping messages on processing errors # ---------------------------------------------------------------------------