diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index f673b01015..d65c100583 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1150,14 +1150,28 @@ class WeixinAdapter(OwnAccessPolicyMixin, BasePlatformAdapter): "ciphertext_size": len(ciphertext), "plaintext_size": rawsize, "filename": Path(path).name, "rawfilemd5": rawfilemd5} if media_type == MEDIA_VOICE and path.endswith(".silk"): item_kwargs.update(encode_type=6, sample_rate=24000, bits_per_sample=16) + item_lists: List[List[Dict[str, Any]]] = [[item_builder(**item_kwargs)]] if caption: - await _send_message( - self._send_session, base_url=self._base_url, token=self._token, to=chat_id, text=self.format_message(caption), - context_token=context_token, client_id=f"hermes-weixin-{uuid.uuid4().hex}") - last_message_id = f"hermes-weixin-{uuid.uuid4().hex}" - await _send_items( - self._send_session, base_url=self._base_url, token=self._token, to=chat_id, item_list=[item_builder(**item_kwargs)], - context_token=context_token, client_id=last_message_id) + item_lists.insert(0, [{"type": ITEM_TEXT, "text_item": {"text": self.format_message(caption)}}]) + last_message_id = "" + for item_list in item_lists: + last_message_id = f"hermes-weixin-{uuid.uuid4().hex}" + while True: + resp = await _send_items( + self._send_session, base_url=self._base_url, token=self._token, to=chat_id, item_list=item_list, + context_token=context_token, client_id=last_message_id) + ret, errcode = (resp.get("ret"), resp.get("errcode")) if resp and isinstance(resp, dict) else (None, None) + if (ret is None or ret == 0) and (errcode is None or errcode == 0): + break + # Same stale-session fallback as _send_text_chunk: re-send once without context_token. Clearing the + # token also covers the remaining item lists (caption, then media) and bounds this loop. + if _is_session_expired(resp, ret, errcode) and context_token: + context_token = None + self._token_store._cache.pop(self._token_store._key(self._account_id, chat_id), None) + logger.warning("[%s] session expired for %s; re-sending media without context_token", self.name, _safe_id(chat_id)) + continue + errmsg = resp.get("errmsg") or resp.get("msg") + raise RuntimeError(f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg or 'unknown error'}") return last_message_id def _outbound_media_builder(self, path: str, force_file_attachment: bool = False): diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index 1489d8f630..9e539decd2 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -350,6 +350,23 @@ class TestWeixinChunkDelivery: assert result.success is True assert [call.kwargs["context_token"] for call in send_message_mock.await_args_list] == ["ctx-token", None] + @patch.object(weixin, "_send_items", new_callable=AsyncMock) + @patch.object(weixin, "_upload_ciphertext", new=AsyncMock(return_value="enc-q")) + @patch.object(weixin, "_get_upload_url", new=AsyncMock(return_value={"upload_full_url": "https://cdn.example.com/upload"})) + def test_media_send_reads_ret_and_resends_without_token_on_stale_session(self, send_items_mock, tmp_path): + """The media leg (cron media_files / send_document) must honour iLink ret like _send_text_chunk: a stale-token + ``ret=-2 prepare failed`` gets one tokenless re-send, and a persistent error is a failure, not success (#112709).""" + adapter = self._connected_adapter() + doc = tmp_path / "report.pdf" + doc.write_bytes(b"%PDF-1.4") + send_items_mock.return_value = {"ret": -2, "errmsg": "prepare failed"} + + result = asyncio.run(adapter.send_document("wxid_test123", str(doc))) + + assert result.success is False + assert "prepare failed" in (result.error or "") + assert [call.kwargs["context_token"] for call in send_items_mock.await_args_list] == ["ctx-token", None] + class TestWeixinOutboundMedia: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/weixin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/weixin.md index b8bf74dd69..127b4632eb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/weixin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/weixin.md @@ -301,6 +301,7 @@ iLink Bot API 要求在每条出站消息中回传 `context_token`(针对特 | `Weixin startup failed: WEIXIN_ACCOUNT_ID is required` | 在 `.env` 中设置 `WEIXIN_ACCOUNT_ID`,或运行 `hermes gateway setup` | | `Another local Hermes gateway is already using this Weixin token` | 先停止另一个网关实例——每个 token 只允许一个轮询器 | | 会话过期(`errcode=-14`) | 登录会话已过期。重新运行 `hermes gateway setup` 扫描新二维码 | +| 主动发送(cron / 通知)失败并报 `ret=-2 errmsg=prepare failed` 或 `unknown error` | 对方的 `context_token` 已失效(近期没有收到其入站消息)。适配器将其视为会话失效而非限流,并且会不带 token 重发一次,因此消息仍能送达。只有其他 `-2` 响应才会触发限流退避/冷却 | | 配置过程中二维码过期 | 二维码最多自动刷新 3 次。若持续过期,请检查网络连接 | | Bot 不响应私信 | 检查 `WEIXIN_DM_POLICY`——若设置为 `allowlist`,发送方必须在 `WEIXIN_ALLOWED_USERS` 中 | | Bot 忽略群消息 | 群组策略默认为 `disabled`。设置 `WEIXIN_GROUP_POLICY=open` 或 `allowlist`——但请注意,扫码登录的 iLink bot 身份(`...@im.bot`)通常根本无法接收普通微信群消息。若网关日志中没有群消息的原始入站事件,限制来自 iLink 侧,而非 Hermes。 |