Weixin adapter media send signature and delivery fixes
NousResearch/hermes-agent · cluster 8783 · gateway
Data as of
Nothing open on GitHub. Fix merged in #8990. Check it covers your case.
The short answer
- Known?
- Yes. 12 related items grouped here, 5 issues already closed.
- Fix event
- A fix was merged to main in PR #8990. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #8990, #11478. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs address parameter name mismatches in WeixinAdapter's send_image_file and send_document methods relative to the base class contract, and fix MEDIA: attachment delivery being dropped in normal send and gateway streaming paths. Tests for these behaviors are also included.
Open issues0
Open PRs0
Fixes on record
-
#8990 fix(weixin): rename send_document parameter to match base class
files:
gateway/platforms/weixin.pyShow the diff
-
gateway/platforms/weixin.py@@ -1547,19 +1547,19 @@ async def send_image_file( reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - return await self.send_document(chat_id, path, caption=caption, metadata=metadata) + return await self.send_document(chat_id, file_path=path, caption=caption, metadata=metadata) async def send_document( self, chat_id: str, - path: str, + file_path: str, caption: str = "", metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: if not self._session or not self._token: return SendResult(success=False, error="Not connected") try: - message_id = await self._send_file(chat_id, path, caption) + message_id = await self._send_file(chat_id, file_path, caption) return SendResult(success=True, message_id=message_id) except Exception as exc: logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc)
-
-
#11478 fix(weixin): correct send_image_file parameter name to match base class
files:
gateway/platforms/weixin.py,scripts/release.py,tests/gateway/test_weixin.pyShow the diff
-
gateway/platforms/weixin.py@@ -1542,12 +1542,12 @@ async def send_image( async def send_image_file( self, chat_id: str, - path: str, + image_path: str, caption: str = "", reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - return await self.send_document(chat_id, file_path=path, caption=caption, metadata=metadata) + return await self.send_document(chat_id, file_path=image_path, caption=caption, metadata=metadata) async def send_document( self, -
scripts/release.py@@ -232,6 +232,7 @@ "[redacted:email]": "ZaynJarvis", "[redacted:email]": "malaiwah", "[redacted:email]": "v1k22", + "[redacted:email]": "RucchiZ", } -
tests/gateway/test_weixin.py@@ -520,3 +520,63 @@ def test_voice_builder_for_silk_files(self): adapter = _make_adapter() media_type, builder = adapter._outbound_media_builder("recording.silk") assert media_type == weixin.MEDIA_VOICE + + +class TestWeixinSendImageFileParameterName: + """Regression test for send_image_file parameter name mismatch. + + The gateway calls send_image_file(chat_id=..., image_path=...) but the + WeixinAdapter previously used 'path' as the parameter name, causing + image sending to fail. This test ensures the interface stays correct. + """ + + @patch.object(WeixinAdapter, "send_document", new_callable=AsyncMock) + def test_send_image_file_uses_image_path_parameter(self, send_document_mock): + """Verify send_image_file accepts image_path and forwards to send_document.""" + adapter = _make_adapter() + adapter._session = object() + adapter._token = "[redacted:secret-assignment]" + + send_document_mock.return_value = weixin.SendResult(success=True, message_id="test-id") + + # This is the call pattern used by gateway/run.py extract_media + result = asyncio.run( + adapter.send_image_file( + chat_id="wxid_test123", + image_path="/tmp/test_image.png", + caption="Test caption", + metadata={"thread_id": "thread-123"}, + ) + ) + + assert result.success is True + send_document_mock.assert_awaited_once_with( + "wxid_test123", + file_path="/tmp/test_image.png", + caption="Test caption", + metadata={"thread_id": "thread-123"}, + ) + + @patch.object(WeixinAdapter, "send_document", new_callable=AsyncMock) + def test_send_image_file_works_without_optional_params(self, send_document_mock): + """Verify send_image_file works with minimal required params.""" + adapter = _make_adapter() + adapter._session = object() + adapter._token = "[redacted:secret-assignment]" + + send_document_mock.return_value = weixin.SendResult(success=True, message_id="test-id") + + result = asyncio.run( + adapter.send_image_file( + chat_id="wxid_test123", + image_path="/tmp/test_image.jpg", + ) + ) + + assert result.success is True + send_document_mock.assert_awaited_once_with( + "wxid_test123", + file_path="/tmp/test_image.jpg", + caption="", + metadata=None, + )
-
-
#11571 fix(weixin): Batch-3 salvage — voice fallback, MEDIA extraction, native markdown rendering
files:
gateway/platforms/weixin.py,gateway/run.py,scripts/release.py,tests/gateway/test_weixin.py,website/docs/user-guide/messaging/weixin.mdShow the diff
-
gateway/platforms/weixin.py@@ -623,42 +623,31 @@ def _rewrite_table_block_for_weixin(lines: List[str]) -> str: def _normalize_markdown_blocks(content: str) -> str: lines = content.splitlines() result: List[str] = [] - i = 0 in_code_block = False + blank_run = 0 - while i < len(lines): - line = lines[i].rstrip() - fence_match = _FENCE_RE.match(line.strip()) - if fence_match: + for raw_line in lines: + line = raw_line.rstrip() + if _FENCE_RE.match(line.strip()): in_code_block = not in_code_block result.append(line) - i += 1 + blank_run = 0 continue if in_code_block: result.append(line) - i += 1 continue - if ( - i + 1 < len(lines) - and "|" in lines[i] - and _TABLE_RULE_RE.match(lines[i + 1].rstrip()) - ): - table_lines = [lines[i].rstrip(), lines[i + 1].rstrip()] - i += 2 - while i < len(lines) and "|" in lines[i]: - table_lines.append(lines[i].rstrip()) - i += 1 - result.append(_rewrite_table_block_for_weixin(table_lines)) + if not line.strip(): + blank_run += 1 + if blank_run <= 1: + result.append("") continue - result.append(_MARKDOWN_LINK_RE.sub(r"\1 (\2)", _rewrite_headers_for_weixin(line))) - i += 1 + blank_run = 0 + result.append(line) - normalized = "\n".join(item.rstrip() for item in result) - normalized = re.sub(r"\n{3,}", "\n\n", normalized) - return normalized.strip() + return "\n".join(result).strip() def _split_markdown_blocks(content: str) -> List[str]: @@ -704,8 +693,8 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]: Weixin can render Markdown, but chat readability is better when top-level line breaks become separate messages. Keep fenced code blocks intact and - attach indented continuation lines to the previous top-level line so - transformed tables/lists do not get torn apart. + attach indented continuation lines to the previous top-level line so nested + list items do not get torn apart. """ units: List[str] = [] @@ -747,7 +736,9 @@ def _looks_like_chatty_line_for_weixin(line: str) -> bool: return False if line.startswith((" ", "\t")): return False - if stripped.startswith((">", "-", "*", "【")): + if stripped.startswith((">", "-", "*", "【", "#", "|")): + return False + if _TABLE_RULE_RE.match(stripped): return False if re.match(r"^\*\*[^*]+\*\*$", stripped): return False @@ -757,10 +748,12 @@ def _looks_like_chatty_line_for_weixin(line: str) -> bool: def _looks_like_heading_line_for_weixin(line: str) -> bool: - """Return True when a short line behaves like a plain-text heading.""" + """Return True when a short line behaves like a heading.""" stripped = line.strip() if not stripped: return False + if _HEADER_RE.match(stripped): + return True return len(stripped) <= 24 and stripped.endswith((":", ":")) @@ -1460,8 +1453,44 @@ async def send( return SendResult(success=False, error="Not connected") context_token = [redacted:secret-assignment] chat_id) last_message_id: Optional[str] = None + + # Extract MEDIA: tags and bare local file paths before text delivery. + media_files, cleaned_content = self.extract_media(content) + _, image_cleaned = self.extract_images(cleaned_content) + local_files, final_content = self.extract_local_files(image_cleaned) + + _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"} + _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} + + async def _deliver_media(path: str, is_voice: bool = False) -> None: + ext = Path(path).suffix.lower() + if is_voice or ext in _AUDIO_EXTS: + await self.send_voice(chat_id=chat_id, audio_path=path, metadata=metadata) + elif ext in _VIDEO_EXTS: + await self.send_video(chat_id=chat_id, video_path=path, metadata=metadata) + elif ext in _IMAGE_EXTS: + await self.send_image_file(chat_id=chat_id, image_path=path, metadata=metadata) + else: + await self.send_document(chat_id=chat_id, file_path=path, metadata=metadata) + try: - chunks = [c for c in self._split_text(self.format_message(content)) if c and c.strip()] + # Deliver extracted MEDIA: attachments first. + for media_path, is_voice in media_files: + try: + await _deliver_media(media_path, is_voice) + except Exception as exc: + logger.warning("[%s] media delivery failed for %s: %s", self.name, media_path, exc) + + # Deliver bare local file paths. + for file_path in local_files: + try: + await _deliver_media(file_path, is_voice=False) + except Exception as exc: + logger.warning("[%s] local file delivery failed for %s: %s", self.name, file_path, exc) + + # Deliver text content. + chunks = [c for c in self._split_text(self.format_message(final_content)) if c and c.strip()] for idx, chunk in enumerate(chunks): client_id = f"hermes-weixin-{uuid.uuid4().hex}" await self._send_text_chunk( @@ -1590,7 +1619,24 @@ async def send_voice( reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - return await self.send_document(chat_id, audio_path, caption=caption or "", metadata=metadata) + if not self._session or not self._token: + return SendResult(success=False, error="Not connected") + + # Native outbound Weixin voice bubbles are not proven-working in the + # upstream reference implementation. Prefer a reliable file attachment + # fallback so users at least receive playable audio, even for .silk. + fallback_caption = caption or "[voice message as attachment]" + try: + message_id = await self._send_file( + chat_id, + audio_path, + fallback_caption, + force_file_attachment=True, + ) + return SendResult(success=True, message_id=message_id) + except Exception as exc: + logger.error("[%s] send_voice failed to=%s: %s", self.name, _safe_id(chat_id), exc) + return SendResult(success=False, error=str(exc)) async def _download_remote_media(self, url: str) -> str: from tools.url_safety import is_safe_url @@ -1607,10 +1653,16 @@ async def _download_remote_media(self, url: str) -> str: handle.write(data) return handle.name - async def _send_file(self, chat_id: str, path: str, caption: str) -> str: + async def _send_file( + self, + chat_id: str, + path: str, + caption: str, + force_file_attachment: bool = False, + ) -> str: assert self._session is not None and self._token is not None plaintext = Path(path).read_bytes() - media_type, item_builder = self._outbound_media_builder(path) + media_type, item_builder = self._outbound_media_builder(path, force_file_attachment=force_file_attachment) filekey = secrets.token_hex(16) aes_key = secrets.token_bytes(16) rawsize = len(plaintext) @@ -1652,14 +1704,19 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str: # Sending base64(raw_bytes) causes images to show as grey boxes on the # receiver side because the decryption key doesn't match. aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii") - media_item = item_builder( - encrypt_query_param=encrypted_query_param, - aes_key_for_api=aes_key_for_api, - ciphertext_size=len(ciphertext), - plaintext_size=rawsize, - filename=Path(path).name, - rawfilemd5=rawfilemd5, - ) + item_kwargs = { + "encrypt_query_param": encrypted_query_param, + "aes_key_for_api": aes_key_for_api, + "ciphertext_size": len(ciphertext), + "plaintext_size": rawsize, + "filename": Path(path).name, + "rawfilemd5": rawfilemd5, + } + if media_type == MEDIA_VOICE and path.endswith(".silk"): + item_kwargs["encode_type"] = 6 + item_kwargs["sample_rate"] = 24000 + item_kwargs["bits_per_sample"] = 16 + media_item = item_builder(**item_kwargs) last_message_id = None if caption: @@ -1695,7 +1752,7 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str: ) return last_message_id - def _outbound_media_builder(self, path: str): + def _outbound_media_builder(self, path: str, force_file_attachment: bool = False): mime = mimetypes.guess_type(path)[0] or "application/octet-stream" if mime.startswith("image/"): return MEDIA_IMAGE, lambda **kw: { @@ -1723,7 +1780,7 @@ def _outbound_media_builder(self, path: str): "video_md5": kw.get("rawfilemd5", ""), }, } - if mime.startswith("audio/") or path.endswith(".silk"): + if path.endswith(".silk") and not force_file_attachment: return MEDIA_VOICE, lambda **kw: { "type": ITEM_VOICE, "voice_item": { @@ -1732,9 +1789,25 @@ def _outbound_media_builder(self, path: str): "aes_key": kw["aes_key_for_api"], "encrypt_type": 1, }, + "encode_type": kw.get("encode_type"), + "bits_per_sample": kw.get("bits_per_sample"), + "sample_rate": kw.get("sample_rate"), "playtime": kw.get("playtime", 0), }, } + if mime.startswith("audio/"): + return MEDIA_FILE, lambda **kw: { + "type": ITEM_FILE, + "file_item": { + "media": { + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], + "encrypt_type": 1, + }, + "file_name": kw["filename"], + "len": str(kw["plaintext_size"]), + }, + } return MEDIA_FILE, lambda **kw: { "type": ITEM_FILE, "file_item": { -
gateway/run.py@@ -5811,7 +5811,7 @@ def run_sync(): pass # Send media files - for media_path in (media_files or []): + for media_path, _is_voice in (media_files or []): try: await adapter.send_document( chat_id=source.chat_id, @@ -5989,7 +5989,7 @@ def run_sync(): except Exception: pass - for media_path in (media_files or []): + for media_path, _is_voice in (media_files or []): try: await adapter.send_file(chat_id=source.chat_id, file_path=media_path) except Exception: -
scripts/release.py@@ -238,6 +238,9 @@ "[redacted:email]": "v1k22", "[redacted:email]": "RucchiZ", "[redacted:email]": "LehaoLin", + "[redacted:email]": "imink", + "[redacted:email]": "hedgeho9X", + "[redacted:email]": "bernylinville", } -
tests/gateway/test_weixin.py@@ -3,6 +3,7 @@ import asyncio import json import os +from pathlib import Path from unittest.mock import AsyncMock, patch from gateway.config import PlatformConfig @@ -23,17 +24,14 @@ def _make_adapter() -> WeixinAdapter: class TestWeixinFormatting: - def test_format_message_preserves_markdown_and_rewrites_headers(self): + def test_format_message_preserves_markdown(self): adapter = _make_adapter() content = "# Title\n\n## Plan\n\nUse **bold** and [docs](https://example.com)." - assert ( - adapter.format_message(content) - == "【Title】\n\n**Plan**\n\nUse **bold** and docs (https://example.com)." - ) + assert adapter.format_message(content) == content - def test_format_message_rewrites_markdown_tables(self): + def test_format_message_preserves_markdown_tables(self): adapter = _make_adapter() content = ( @@ -43,19 +41,14 @@ def test_format_message_rewrites_markdown_tables(self): "| Retries | 3 |\n" ) - assert adapter.format_message(content) == ( - "- Setting: Timeout\n" - " Value: 30s\n" - "- Setting: Retries\n" - " Value: 3" - ) + assert adapter.format_message(content) == content.strip() def test_format_message_preserves_fenced_code_blocks(self): adapter = _make_adapter() content = "## Snippet\n\n```python\nprint('hi')\n```" - assert adapter.format_message(content) == "**Snippet**\n\n```python\nprint('hi')\n```" + assert adapter.format_message(content) == content def test_format_message_returns_empty_string_for_none(self): adapter = _make_adapter() @@ -101,7 +94,7 @@ def test_split_text_keeps_heading_with_body_together(self): content = adapter.format_message("## 结论\n这是正文") chunks = adapter._split_text(content) - assert chunks == ["**结论**\n这是正文"] + assert chunks == ["## 结论\n这是正文"] def test_split_text_keeps_short_reformatted_table_in_single_chunk(self): adapter = _make_adapter() @@ -377,16 +370,13 @@ def test_download_remote_media_blocks_unsafe_urls(self): class TestWeixinMarkdownLinks: - """Markdown links should be converted to plaintext since WeChat can't render them.""" + """Markdown links should be preserved so WeChat can render them natively.""" - def test_format_message_converts_markdown_links_to_plain_text(self): + def test_format_message_preserves_markdown_links(self): adapter = _make_adapter() content = "Check [the docs](https://example.com) and [GitHub](https://github.com) for details" - assert ( - adapter.format_message(content) - == "Check the docs (https://example.com) and GitHub (https://github.com) for details" - ) + assert adapter.format_message(content) == content def test_format_message_preserves_links_inside_code_blocks(self): adapter = _make_adapter() @@ -500,10 +490,10 @@ def test_video_builder_includes_md5(self): ) assert item["video_item"]["video_md5"] == "deadbeef" - def test_voice_builder_for_audio_files(self): + def test_voice_builder_for_audio_files_uses_file_attachment_type(self): adapter = _make_adapter() media_type, builder = adapter._outbound_media_builder("note.mp3") - assert media_type == weixin.MEDIA_VOICE + assert media_type == weixin.MEDIA_FILE item = builder( encrypt_query_param="eq", @@ -513,8 +503,8 @@ def test_voice_builder_for_audio_files(self): filename="note.mp3", rawfilemd5="abc", ) - assert item["type"] == weixin.ITEM_VOICE - assert "voice_item" in item + assert item["type"] == weixin.ITEM_FILE + assert item["file_item"]["file_name"] == "note.mp3" def test_voice_builder_for_silk_files(self): adapter = _make_adapter() @@ -580,3 +570,75 @@ def test_send_image_file_works_without_optional_params(self, send_document_mock) caption="", metadata=None, ) + + +class TestWeixinVoiceSending: + def _connected_adapter(self) -> WeixinAdapter: + adapter = _make_adapter() + adapter._session = object() + adapter._token = "[redacted:secret-assignment]" + adapter._base_url = "https://weixin.example.com" + adapter._token_store.get = lambda account_id, chat_id: "ctx-token" + return adapter + + @patch.object(WeixinAdapter, "_send_file", new_callable=AsyncMock) + def test_send_voice_downgrades_to_document_attachment(self, send_file_mock, tmp_path): + adapter = self._connected_adapter() + source = tmp_path / "voice.ogg" + source.write_bytes(b"ogg") + send_file_mock.return_value = "msg-1" + + result = asyncio.run(adapter.send_voice("wxid_test123", str(source))) + + assert result.success is True + send_file_mock.assert_awaited_once_with( + "wxid_test123", + str(source), + "[voice message as attachment]", + force_file_attachment=True, + ) + + def test_voice_builder_for_silk_files_can_be_forced_to_file_attachment(self): + adapter = _make_adapter() + media_type, builder = adapter._outbound_media_builder( + "recording.silk", + force_file_attachment=True, + ) + assert media_type == weixin.MEDIA_FILE + + item = builder( + encrypt_query_param="eq", + aes_key_for_api="fakekey", + ciphertext_size=512, + plaintext_size=500, + filename="recording.silk", + rawfilemd5="abc", + ) + assert item["type"] == weixin.ITEM_FILE + assert item["file_item"]["file_name"] == "recording.silk" + + @patch.object(weixin, "_api_post", new_callable=AsyncMock) + @patch.object(weixin, "_upload_ciphertext", new_callable=AsyncMock) + @patch.object(weixin, "_get_upload_url", new_callable=AsyncMock) + def test_send_file_sets_voice_metadata_for_silk_payload( + self, + get_upload_url_mock, + upload_ciphertext_mock, + api_post_mock, + tmp_path, + ): + adapter = self._connected_adapter() + silk = tmp_path / "voice.silk" + silk.write_bytes(b"\x02#!SILK_V3\x01\x00") + get_upload_url_mock.return_value = {"upload_full_url": "https://cdn.example.com/upload"} + upload_ciphertext_mock.return_value = "enc-q" + api_post_mock.return_value = {"success": True} + + asyncio.run(adapter._send_file("wxid_test123", str(silk), "")) + + payload = api_post_mock.await_args.kwargs["payload"] + voice_item = payload["msg"]["item_list"][0]["voice_item"] + assert voice_item.get("playtime", 0) == 0 + assert voice_item["encode_type"] == 6 + assert voice_item["sample_rate"] == 24000 + assert voice_item["bits_per_sample"] == 16 -
website/docs/user-guide/messaging/weixin.md@@ -90,7 +90,7 @@ The adapter will restore saved credentials, connect to the iLink API, and begin - **Media support** — images, video, files, and voice messages - **AES-128-ECB encrypted CDN** — automatic encryption/decryption for all media transfers - **Context token persistence** — disk-backed reply continuity across restarts -- **Markdown formatting** — headers, tables, and code blocks are reformatted for WeChat readability +- **Markdown formatting** — preserves Markdown, including headers, tables, and code blocks, so WeChat clients that support Markdown can render it natively - **Smart message chunking** — messages stay as a single bubble when under the limit; only oversized payloads split at logical boundaries - **Typing indicators** — shows "typing…" status in the WeChat client while the agent processes - **SSRF protection** — outbound media URLs are validated before download @@ -206,12 +206,12 @@ This ensures reply continuity even after gateway restarts. ## Markdown Formatting -WeChat's personal chat does not natively render full Markdown. The adapter reformats content for better readability: +WeChat clients connected through the iLink Bot API can render Markdown directly, so the adapter preserves Markdown instead of rewriting it: -- **Headers** (`# Title`) → converted to `【Title】` (level 1) or `**Title**` (level 2+) -- **Tables** → reformatted as labeled key-value lists (e.g., `- Column: Value`) -- **Code fences** → preserved as-is (WeChat renders these adequately) -- **Excessive blank lines** → collapsed to double newlines +- **Headers** stay as Markdown headings (`#`, `##`, ...) +- **Tables** stay as Markdown tables +- **Code fences** stay as fenced code blocks +- **Excessive blank lines** are collapsed to double newlines outside fenced code blocks ## Message Chunking
-
4 PRs closed without merging
From the close comments: merged via another change 3 · done elsewhere 1
-
#8824 fix: WeixinAdapter.send_document() parameter name mismatch · merged via another change →
#8990,#8895 -
#8895 fix(weixin): rename send_document parameter to match base class · merged via another change →
#8990 - #9304 fix(weixin): align send_image_file with image_path keyword · merged via another change
-
#52337 test(weixin): add MEDIA: tag delivery tests for adapter.send() · done elsewhere →
d682f32,#48345
Closed issues · 5
- Closed#8783bug(gateway): WeixinAdapter media send methods drop files due to signature mismatch
- Closed#8819WeixinAdapter.send_document() parameter name mismatch causes file sending to fail
- Closed#9738Weixin: MEDIA image attachments don't seem to arrive in chat
- Closed#13769[Weixin] send_image_file() fails due to parameter name mismatch
- Closed#48345Weixin: MEDIA: tag does not trigger send_image_file — images never delivered
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.13: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab