fix(gateway): Weixin media sends honour iLink ret and re-send without a stale token
`_send_file` (send_document / send_image / send_video / send_voice, i.e. the cron `media_files` leg of `_deliver_direct`) awaited the caption and media `sendmessage` calls without reading `ret`/`errcode`. `_api_request` only raises on non-2xx HTTP, so a stale-token `ret=-2 errmsg=prepare failed` (or `errcode=-14`) came back as `success=True` with the token still attached and the attachment silently never arrived — the previous commit's claim that the media path shared `_send_text_chunk`'s `_is_session_expired` handling was wrong; only the text path had it. Apply the same mechanism inline in `_send_file`: check `ret`/`errcode` on every item-list send, re-send once without `context_token` (and drop the cached token) when `_is_session_expired` says so, and raise on any other non-zero response so `_send_file_result` reports the failure. Clearing the token covers both the caption and the media item, and bounds the loop. Also mirrors the `prepare failed` troubleshooting row into the zh-Hans docs. Part of #112709
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
Reference in New Issue
Block a user