{"repo":"NousResearch/hermes-agent","public_id":"8783","display_label":"Weixin adapter media send signature and delivery fixes","summary":"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.","subsystem":"gateway","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":3,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":8990},"resolution":{"state":"partially_resolved","by":"pr-8990,pr-11478"},"freshness":{"state":"stale","last_verified_at":"2026-04-17T12:01:30+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1278787878787879},"fix_edges":[{"issue_number":8819,"pr_number":8990,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":9738,"pr_number":11478,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":9738,"pr_number":11571,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":10308,"pr_number":11571,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":8783,"title":"bug(gateway): WeixinAdapter media send methods drop files due to signature mismatch","url":null,"state":"closed"},{"kind":"issue","number":8819,"title":"WeixinAdapter.send_document() parameter name mismatch causes file sending to fail","url":null,"state":"closed"},{"kind":"issue","number":9738,"title":"Weixin: MEDIA image attachments don't seem to arrive in chat","url":null,"state":"closed"},{"kind":"issue","number":13769,"title":"[Weixin] send_image_file() fails due to parameter name mismatch","url":null,"state":"closed"},{"kind":"issue","number":48345,"title":"Weixin: MEDIA: tag does not trigger send_image_file — images never delivered","url":null,"state":"closed"},{"kind":"pr","number":8824,"title":"fix: WeixinAdapter.send_document() parameter name mismatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8990","number":8990},{"kind":"pr","value":"#8895","number":8895}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":8895,"title":"fix(weixin): rename send_document parameter to match base class","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8990","number":8990}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":8990,"title":"fix(weixin): rename send_document parameter to match base class","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -1547,19 +1547,19 @@ async def send_image_file(\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n-        return await self.send_document(chat_id, path, caption=caption, metadata=metadata)\n+        return await self.send_document(chat_id, file_path=path, caption=caption, metadata=metadata)\n \n     async def send_document(\n         self,\n         chat_id: str,\n-        path: str,\n+        file_path: str,\n         caption: str = \"\",\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n         if not self._session or not self._token:\n             return SendResult(success=False, error=\"Not connected\")\n         try:\n-            message_id = await self._send_file(chat_id, path, caption)\n+            message_id = await self._send_file(chat_id, file_path, caption)\n             return SendResult(success=True, message_id=message_id)\n         except Exception as exc:\n             logger.error(\"[%s] send_document failed to=%s: %s\", self.name, _safe_id(chat_id), exc)\n"]}]},{"kind":"pr","number":9304,"title":"fix(weixin): align send_image_file with image_path keyword","url":null,"state":"closed","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11478,"title":"fix(weixin): correct send_image_file parameter name to match base class","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","scripts/release.py","tests/gateway/test_weixin.py"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -1542,12 +1542,12 @@ async def send_image(\n     async def send_image_file(\n         self,\n         chat_id: str,\n-        path: str,\n+        image_path: str,\n         caption: str = \"\",\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n-        return await self.send_document(chat_id, file_path=path, caption=caption, metadata=metadata)\n+        return await self.send_document(chat_id, file_path=image_path, caption=caption, metadata=metadata)\n \n     async def send_document(\n         self,"]},{"file":"scripts/release.py","hunks":["@@ -232,6 +232,7 @@\n     \"[redacted:email]\": \"ZaynJarvis\",\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"v1k22\",\n+    \"[redacted:email]\": \"RucchiZ\",\n }\n \n "]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -520,3 +520,63 @@ def test_voice_builder_for_silk_files(self):\n         adapter = _make_adapter()\n         media_type, builder = adapter._outbound_media_builder(\"recording.silk\")\n         assert media_type == weixin.MEDIA_VOICE\n+\n+\n+class TestWeixinSendImageFileParameterName:\n+    \"\"\"Regression test for send_image_file parameter name mismatch.\n+\n+    The gateway calls send_image_file(chat_id=..., image_path=...) but the\n+    WeixinAdapter previously used 'path' as the parameter name, causing\n+    image sending to fail. This test ensures the interface stays correct.\n+    \"\"\"\n+\n+    @patch.object(WeixinAdapter, \"send_document\", new_callable=AsyncMock)\n+    def test_send_image_file_uses_image_path_parameter(self, send_document_mock):\n+        \"\"\"Verify send_image_file accepts image_path and forwards to send_document.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._session = object()\n+        adapter._token = \"[redacted:secret-assignment]\"\n+\n+        send_document_mock.return_value = weixin.SendResult(success=True, message_id=\"test-id\")\n+\n+        # This is the call pattern used by gateway/run.py extract_media\n+        result = asyncio.run(\n+            adapter.send_image_file(\n+                chat_id=\"wxid_test123\",\n+                image_path=\"/tmp/test_image.png\",\n+                caption=\"Test caption\",\n+                metadata={\"thread_id\": \"thread-123\"},\n+            )\n+        )\n+\n+        assert result.success is True\n+        send_document_mock.assert_awaited_once_with(\n+            \"wxid_test123\",\n+            file_path=\"/tmp/test_image.png\",\n+            caption=\"Test caption\",\n+            metadata={\"thread_id\": \"thread-123\"},\n+        )\n+\n+    @patch.object(WeixinAdapter, \"send_document\", new_callable=AsyncMock)\n+    def test_send_image_file_works_without_optional_params(self, send_document_mock):\n+        \"\"\"Verify send_image_file works with minimal required params.\"\"\"\n+        adapter = _make_adapter()\n+        adapter._session = object()\n+        adapter._token = \"[redacted:secret-assignment]\"\n+\n+        send_document_mock.return_value = weixin.SendResult(success=True, message_id=\"test-id\")\n+\n+        result = asyncio.run(\n+            adapter.send_image_file(\n+                chat_id=\"wxid_test123\",\n+                image_path=\"/tmp/test_image.jpg\",\n+            )\n+        )\n+\n+        assert result.success is True\n+        send_document_mock.assert_awaited_once_with(\n+            \"wxid_test123\",\n+            file_path=\"/tmp/test_image.jpg\",\n+            caption=\"\",\n+            metadata=None,\n+        )\n"]}]},{"kind":"pr","number":11571,"title":"fix(weixin): Batch-3 salvage — voice fallback, MEDIA extraction, native markdown rendering","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/weixin.py","gateway/run.py","scripts/release.py","tests/gateway/test_weixin.py","website/docs/user-guide/messaging/weixin.md"],"fix_hunks":[{"file":"gateway/platforms/weixin.py","hunks":["@@ -623,42 +623,31 @@ def _rewrite_table_block_for_weixin(lines: List[str]) -> str:\n def _normalize_markdown_blocks(content: str) -> str:\n     lines = content.splitlines()\n     result: List[str] = []\n-    i = 0\n     in_code_block = False\n+    blank_run = 0\n \n-    while i < len(lines):\n-        line = lines[i].rstrip()\n-        fence_match = _FENCE_RE.match(line.strip())\n-        if fence_match:\n+    for raw_line in lines:\n+        line = raw_line.rstrip()\n+        if _FENCE_RE.match(line.strip()):\n             in_code_block = not in_code_block\n             result.append(line)\n-            i += 1\n+            blank_run = 0\n             continue\n \n         if in_code_block:\n             result.append(line)\n-            i += 1\n             continue\n \n-        if (\n-            i + 1 < len(lines)\n-            and \"|\" in lines[i]\n-            and _TABLE_RULE_RE.match(lines[i + 1].rstrip())\n-        ):\n-            table_lines = [lines[i].rstrip(), lines[i + 1].rstrip()]\n-            i += 2\n-            while i < len(lines) and \"|\" in lines[i]:\n-                table_lines.append(lines[i].rstrip())\n-                i += 1\n-            result.append(_rewrite_table_block_for_weixin(table_lines))\n+        if not line.strip():\n+            blank_run += 1\n+            if blank_run <= 1:\n+                result.append(\"\")\n             continue\n \n-        result.append(_MARKDOWN_LINK_RE.sub(r\"\\1 (\\2)\", _rewrite_headers_for_weixin(line)))\n-        i += 1\n+        blank_run = 0\n+        result.append(line)\n \n-    normalized = \"\\n\".join(item.rstrip() for item in result)\n-    normalized = re.sub(r\"\\n{3,}\", \"\\n\\n\", normalized)\n-    return normalized.strip()\n+    return \"\\n\".join(result).strip()\n \n \n def _split_markdown_blocks(content: str) -> List[str]:","@@ -704,8 +693,8 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]:\n \n     Weixin can render Markdown, but chat readability is better when top-level\n     line breaks become separate messages. Keep fenced code blocks intact and\n-    attach indented continuation lines to the previous top-level line so\n-    transformed tables/lists do not get torn apart.\n+    attach indented continuation lines to the previous top-level line so nested\n+    list items do not get torn apart.\n     \"\"\"\n     units: List[str] = []\n ","@@ -747,7 +736,9 @@ def _looks_like_chatty_line_for_weixin(line: str) -> bool:\n         return False\n     if line.startswith((\" \", \"\\t\")):\n         return False\n-    if stripped.startswith((\">\", \"-\", \"*\", \"【\")):\n+    if stripped.startswith((\">\", \"-\", \"*\", \"【\", \"#\", \"|\")):\n+        return False\n+    if _TABLE_RULE_RE.match(stripped):\n         return False\n     if re.match(r\"^\\*\\*[^*]+\\*\\*$\", stripped):\n         return False","@@ -757,10 +748,12 @@ def _looks_like_chatty_line_for_weixin(line: str) -> bool:\n \n \n def _looks_like_heading_line_for_weixin(line: str) -> bool:\n-    \"\"\"Return True when a short line behaves like a plain-text heading.\"\"\"\n+    \"\"\"Return True when a short line behaves like a heading.\"\"\"\n     stripped = line.strip()\n     if not stripped:\n         return False\n+    if _HEADER_RE.match(stripped):\n+        return True\n     return len(stripped) <= 24 and stripped.endswith((\":\", \"：\"))\n \n ","@@ -1460,8 +1453,44 @@ async def send(\n             return SendResult(success=False, error=\"Not connected\")\n         context_token = [redacted:secret-assignment] chat_id)\n         last_message_id: Optional[str] = None\n+\n+        # Extract MEDIA: tags and bare local file paths before text delivery.\n+        media_files, cleaned_content = self.extract_media(content)\n+        _, image_cleaned = self.extract_images(cleaned_content)\n+        local_files, final_content = self.extract_local_files(image_cleaned)\n+\n+        _AUDIO_EXTS = {\".ogg\", \".opus\", \".mp3\", \".wav\", \".m4a\"}\n+        _VIDEO_EXTS = {\".mp4\", \".mov\", \".avi\", \".mkv\", \".webm\", \".3gp\"}\n+        _IMAGE_EXTS = {\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\"}\n+\n+        async def _deliver_media(path: str, is_voice: bool = False) -> None:\n+            ext = Path(path).suffix.lower()\n+            if is_voice or ext in _AUDIO_EXTS:\n+                await self.send_voice(chat_id=chat_id, audio_path=path, metadata=metadata)\n+            elif ext in _VIDEO_EXTS:\n+                await self.send_video(chat_id=chat_id, video_path=path, metadata=metadata)\n+            elif ext in _IMAGE_EXTS:\n+                await self.send_image_file(chat_id=chat_id, image_path=path, metadata=metadata)\n+            else:\n+                await self.send_document(chat_id=chat_id, file_path=path, metadata=metadata)\n+\n         try:\n-            chunks = [c for c in self._split_text(self.format_message(content)) if c and c.strip()]\n+            # Deliver extracted MEDIA: attachments first.\n+            for media_path, is_voice in media_files:\n+                try:\n+                    await _deliver_media(media_path, is_voice)\n+                except Exception as exc:\n+                    logger.warning(\"[%s] media delivery failed for %s: %s\", self.name, media_path, exc)\n+\n+            # Deliver bare local file paths.\n+            for file_path in local_files:\n+                try:\n+                    await _deliver_media(file_path, is_voice=False)\n+                except Exception as exc:\n+                    logger.warning(\"[%s] local file delivery failed for %s: %s\", self.name, file_path, exc)\n+\n+            # Deliver text content.\n+            chunks = [c for c in self._split_text(self.format_message(final_content)) if c and c.strip()]\n             for idx, chunk in enumerate(chunks):\n                 client_id = f\"hermes-weixin-{uuid.uuid4().hex}\"\n                 await self._send_text_chunk(","@@ -1590,7 +1619,24 @@ async def send_voice(\n         reply_to: Optional[str] = None,\n         metadata: Optional[Dict[str, Any]] = None,\n     ) -> SendResult:\n-        return await self.send_document(chat_id, audio_path, caption=caption or \"\", metadata=metadata)\n+        if not self._session or not self._token:\n+            return SendResult(success=False, error=\"Not connected\")\n+\n+        # Native outbound Weixin voice bubbles are not proven-working in the\n+        # upstream reference implementation. Prefer a reliable file attachment\n+        # fallback so users at least receive playable audio, even for .silk.\n+        fallback_caption = caption or \"[voice message as attachment]\"\n+        try:\n+            message_id = await self._send_file(\n+                chat_id,\n+                audio_path,\n+                fallback_caption,\n+                force_file_attachment=True,\n+            )\n+            return SendResult(success=True, message_id=message_id)\n+        except Exception as exc:\n+            logger.error(\"[%s] send_voice failed to=%s: %s\", self.name, _safe_id(chat_id), exc)\n+            return SendResult(success=False, error=str(exc))\n \n     async def _download_remote_media(self, url: str) -> str:\n         from tools.url_safety import is_safe_url","@@ -1607,10 +1653,16 @@ async def _download_remote_media(self, url: str) -> str:\n             handle.write(data)\n             return handle.name\n \n-    async def _send_file(self, chat_id: str, path: str, caption: str) -> str:\n+    async def _send_file(\n+        self,\n+        chat_id: str,\n+        path: str,\n+        caption: str,\n+        force_file_attachment: bool = False,\n+    ) -> str:\n         assert self._session is not None and self._token is not None\n         plaintext = Path(path).read_bytes()\n-        media_type, item_builder = self._outbound_media_builder(path)\n+        media_type, item_builder = self._outbound_media_builder(path, force_file_attachment=force_file_attachment)\n         filekey = secrets.token_hex(16)\n         aes_key = secrets.token_bytes(16)\n         rawsize = len(plaintext)","@@ -1652,14 +1704,19 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str:\n         # Sending base64(raw_bytes) causes images to show as grey boxes on the\n         # receiver side because the decryption key doesn't match.\n         aes_key_for_api = base64.b64encode(aes_key.hex().encode(\"ascii\")).decode(\"ascii\")\n-        media_item = item_builder(\n-            encrypt_query_param=encrypted_query_param,\n-            aes_key_for_api=aes_key_for_api,\n-            ciphertext_size=len(ciphertext),\n-            plaintext_size=rawsize,\n-            filename=Path(path).name,\n-            rawfilemd5=rawfilemd5,\n-        )\n+        item_kwargs = {\n+            \"encrypt_query_param\": encrypted_query_param,\n+            \"aes_key_for_api\": aes_key_for_api,\n+            \"ciphertext_size\": len(ciphertext),\n+            \"plaintext_size\": rawsize,\n+            \"filename\": Path(path).name,\n+            \"rawfilemd5\": rawfilemd5,\n+        }\n+        if media_type == MEDIA_VOICE and path.endswith(\".silk\"):\n+            item_kwargs[\"encode_type\"] = 6\n+            item_kwargs[\"sample_rate\"] = 24000\n+            item_kwargs[\"bits_per_sample\"] = 16\n+        media_item = item_builder(**item_kwargs)\n \n         last_message_id = None\n         if caption:","@@ -1695,7 +1752,7 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str:\n         )\n         return last_message_id\n \n-    def _outbound_media_builder(self, path: str):\n+    def _outbound_media_builder(self, path: str, force_file_attachment: bool = False):\n         mime = mimetypes.guess_type(path)[0] or \"application/octet-stream\"\n         if mime.startswith(\"image/\"):\n             return MEDIA_IMAGE, lambda **kw: {","@@ -1723,7 +1780,7 @@ def _outbound_media_builder(self, path: str):\n                     \"video_md5\": kw.get(\"rawfilemd5\", \"\"),\n                 },\n             }\n-        if mime.startswith(\"audio/\") or path.endswith(\".silk\"):\n+        if path.endswith(\".silk\") and not force_file_attachment:\n             return MEDIA_VOICE, lambda **kw: {\n                 \"type\": ITEM_VOICE,\n                 \"voice_item\": {","@@ -1732,9 +1789,25 @@ def _outbound_media_builder(self, path: str):\n                         \"aes_key\": kw[\"aes_key_for_api\"],\n                         \"encrypt_type\": 1,\n                     },\n+                    \"encode_type\": kw.get(\"encode_type\"),\n+                    \"bits_per_sample\": kw.get(\"bits_per_sample\"),\n+                    \"sample_rate\": kw.get(\"sample_rate\"),\n                     \"playtime\": kw.get(\"playtime\", 0),\n                 },\n             }\n+        if mime.startswith(\"audio/\"):\n+            return MEDIA_FILE, lambda **kw: {\n+                \"type\": ITEM_FILE,\n+                \"file_item\": {\n+                    \"media\": {\n+                        \"encrypt_query_param\": kw[\"encrypt_query_param\"],\n+                        \"aes_key\": kw[\"aes_key_for_api\"],\n+                        \"encrypt_type\": 1,\n+                    },\n+                    \"file_name\": kw[\"filename\"],\n+                    \"len\": str(kw[\"plaintext_size\"]),\n+                },\n+            }\n         return MEDIA_FILE, lambda **kw: {\n             \"type\": ITEM_FILE,\n             \"file_item\": {"]},{"file":"gateway/run.py","hunks":["@@ -5811,7 +5811,7 @@ def run_sync():\n                         pass\n \n                 # Send media files\n-                for media_path in (media_files or []):\n+                for media_path, _is_voice in (media_files or []):\n                     try:\n                         await adapter.send_document(\n                             chat_id=source.chat_id,","@@ -5989,7 +5989,7 @@ def run_sync():\n                 except Exception:\n                     pass\n \n-            for media_path in (media_files or []):\n+            for media_path, _is_voice in (media_files or []):\n                 try:\n                     await adapter.send_file(chat_id=source.chat_id, file_path=media_path)\n                 except Exception:"]},{"file":"scripts/release.py","hunks":["@@ -238,6 +238,9 @@\n     \"[redacted:email]\": \"v1k22\",\n     \"[redacted:email]\": \"RucchiZ\",\n     \"[redacted:email]\": \"LehaoLin\",\n+    \"[redacted:email]\": \"imink\",\n+    \"[redacted:email]\": \"hedgeho9X\",\n+    \"[redacted:email]\": \"bernylinville\",\n }\n \n "]},{"file":"tests/gateway/test_weixin.py","hunks":["@@ -3,6 +3,7 @@\n import asyncio\n import json\n import os\n+from pathlib import Path\n from unittest.mock import AsyncMock, patch\n \n from gateway.config import PlatformConfig","@@ -23,17 +24,14 @@ def _make_adapter() -> WeixinAdapter:\n \n \n class TestWeixinFormatting:\n-    def test_format_message_preserves_markdown_and_rewrites_headers(self):\n+    def test_format_message_preserves_markdown(self):\n         adapter = _make_adapter()\n \n         content = \"# Title\\n\\n## Plan\\n\\nUse **bold** and [docs](https://example.com).\"\n \n-        assert (\n-            adapter.format_message(content)\n-            == \"【Title】\\n\\n**Plan**\\n\\nUse **bold** and docs (https://example.com).\"\n-        )\n+        assert adapter.format_message(content) == content\n \n-    def test_format_message_rewrites_markdown_tables(self):\n+    def test_format_message_preserves_markdown_tables(self):\n         adapter = _make_adapter()\n \n         content = (","@@ -43,19 +41,14 @@ def test_format_message_rewrites_markdown_tables(self):\n             \"| Retries | 3 |\\n\"\n         )\n \n-        assert adapter.format_message(content) == (\n-            \"- Setting: Timeout\\n\"\n-            \"  Value: 30s\\n\"\n-            \"- Setting: Retries\\n\"\n-            \"  Value: 3\"\n-        )\n+        assert adapter.format_message(content) == content.strip()\n \n     def test_format_message_preserves_fenced_code_blocks(self):\n         adapter = _make_adapter()\n \n         content = \"## Snippet\\n\\n```python\\nprint('hi')\\n```\"\n \n-        assert adapter.format_message(content) == \"**Snippet**\\n\\n```python\\nprint('hi')\\n```\"\n+        assert adapter.format_message(content) == content\n \n     def test_format_message_returns_empty_string_for_none(self):\n         adapter = _make_adapter()","@@ -101,7 +94,7 @@ def test_split_text_keeps_heading_with_body_together(self):\n         content = adapter.format_message(\"## 结论\\n这是正文\")\n         chunks = adapter._split_text(content)\n \n-        assert chunks == [\"**结论**\\n这是正文\"]\n+        assert chunks == [\"## 结论\\n这是正文\"]\n \n     def test_split_text_keeps_short_reformatted_table_in_single_chunk(self):\n         adapter = _make_adapter()","@@ -377,16 +370,13 @@ def test_download_remote_media_blocks_unsafe_urls(self):\n \n \n class TestWeixinMarkdownLinks:\n-    \"\"\"Markdown links should be converted to plaintext since WeChat can't render them.\"\"\"\n+    \"\"\"Markdown links should be preserved so WeChat can render them natively.\"\"\"\n \n-    def test_format_message_converts_markdown_links_to_plain_text(self):\n+    def test_format_message_preserves_markdown_links(self):\n         adapter = _make_adapter()\n \n         content = \"Check [the docs](https://example.com) and [GitHub](https://github.com) for details\"\n-        assert (\n-            adapter.format_message(content)\n-            == \"Check the docs (https://example.com) and GitHub (https://github.com) for details\"\n-        )\n+        assert adapter.format_message(content) == content\n \n     def test_format_message_preserves_links_inside_code_blocks(self):\n         adapter = _make_adapter()","@@ -500,10 +490,10 @@ def test_video_builder_includes_md5(self):\n         )\n         assert item[\"video_item\"][\"video_md5\"] == \"deadbeef\"\n \n-    def test_voice_builder_for_audio_files(self):\n+    def test_voice_builder_for_audio_files_uses_file_attachment_type(self):\n         adapter = _make_adapter()\n         media_type, builder = adapter._outbound_media_builder(\"note.mp3\")\n-        assert media_type == weixin.MEDIA_VOICE\n+        assert media_type == weixin.MEDIA_FILE\n \n         item = builder(\n             encrypt_query_param=\"eq\",","@@ -513,8 +503,8 @@ def test_voice_builder_for_audio_files(self):\n             filename=\"note.mp3\",\n             rawfilemd5=\"abc\",\n         )\n-        assert item[\"type\"] == weixin.ITEM_VOICE\n-        assert \"voice_item\" in item\n+        assert item[\"type\"] == weixin.ITEM_FILE\n+        assert item[\"file_item\"][\"file_name\"] == \"note.mp3\"\n \n     def test_voice_builder_for_silk_files(self):\n         adapter = _make_adapter()","@@ -580,3 +570,75 @@ def test_send_image_file_works_without_optional_params(self, send_document_mock)\n             caption=\"\",\n             metadata=None,\n         )\n+\n+\n+class TestWeixinVoiceSending:\n+    def _connected_adapter(self) -> WeixinAdapter:\n+        adapter = _make_adapter()\n+        adapter._session = object()\n+        adapter._token = \"[redacted:secret-assignment]\"\n+        adapter._base_url = \"https://weixin.example.com\"\n+        adapter._token_store.get = lambda account_id, chat_id: \"ctx-token\"\n+        return adapter\n+\n+    @patch.object(WeixinAdapter, \"_send_file\", new_callable=AsyncMock)\n+    def test_send_voice_downgrades_to_document_attachment(self, send_file_mock, tmp_path):\n+        adapter = self._connected_adapter()\n+        source = tmp_path / \"voice.ogg\"\n+        source.write_bytes(b\"ogg\")\n+        send_file_mock.return_value = \"msg-1\"\n+\n+        result = asyncio.run(adapter.send_voice(\"wxid_test123\", str(source)))\n+\n+        assert result.success is True\n+        send_file_mock.assert_awaited_once_with(\n+            \"wxid_test123\",\n+            str(source),\n+            \"[voice message as attachment]\",\n+            force_file_attachment=True,\n+        )\n+\n+    def test_voice_builder_for_silk_files_can_be_forced_to_file_attachment(self):\n+        adapter = _make_adapter()\n+        media_type, builder = adapter._outbound_media_builder(\n+            \"recording.silk\",\n+            force_file_attachment=True,\n+        )\n+        assert media_type == weixin.MEDIA_FILE\n+\n+        item = builder(\n+            encrypt_query_param=\"eq\",\n+            aes_key_for_api=\"fakekey\",\n+            ciphertext_size=512,\n+            plaintext_size=500,\n+            filename=\"recording.silk\",\n+            rawfilemd5=\"abc\",\n+        )\n+        assert item[\"type\"] == weixin.ITEM_FILE\n+        assert item[\"file_item\"][\"file_name\"] == \"recording.silk\"\n+\n+    @patch.object(weixin, \"_api_post\", new_callable=AsyncMock)\n+    @patch.object(weixin, \"_upload_ciphertext\", new_callable=AsyncMock)\n+    @patch.object(weixin, \"_get_upload_url\", new_callable=AsyncMock)\n+    def test_send_file_sets_voice_metadata_for_silk_payload(\n+        self,\n+        get_upload_url_mock,\n+        upload_ciphertext_mock,\n+        api_post_mock,\n+        tmp_path,\n+    ):\n+        adapter = self._connected_adapter()\n+        silk = tmp_path / \"voice.silk\"\n+        silk.write_bytes(b\"\\x02#!SILK_V3\\x01\\x00\")\n+        get_upload_url_mock.return_value = {\"upload_full_url\": \"https://cdn.example.com/upload\"}\n+        upload_ciphertext_mock.return_value = \"enc-q\"\n+        api_post_mock.return_value = {\"success\": True}\n+\n+        asyncio.run(adapter._send_file(\"wxid_test123\", str(silk), \"\"))\n+\n+        payload = api_post_mock.await_args.kwargs[\"payload\"]\n+        voice_item = payload[\"msg\"][\"item_list\"][0][\"voice_item\"]\n+        assert voice_item.get(\"playtime\", 0) == 0\n+        assert voice_item[\"encode_type\"] == 6\n+        assert voice_item[\"sample_rate\"] == 24000\n+        assert voice_item[\"bits_per_sample\"] == 16"]},{"file":"website/docs/user-guide/messaging/weixin.md","hunks":["@@ -90,7 +90,7 @@ The adapter will restore saved credentials, connect to the iLink API, and begin\n - **Media support** — images, video, files, and voice messages\n - **AES-128-ECB encrypted CDN** — automatic encryption/decryption for all media transfers\n - **Context token persistence** — disk-backed reply continuity across restarts\n-- **Markdown formatting** — headers, tables, and code blocks are reformatted for WeChat readability\n+- **Markdown formatting** — preserves Markdown, including headers, tables, and code blocks, so WeChat clients that support Markdown can render it natively\n - **Smart message chunking** — messages stay as a single bubble when under the limit; only oversized payloads split at logical boundaries\n - **Typing indicators** — shows \"typing…\" status in the WeChat client while the agent processes\n - **SSRF protection** — outbound media URLs are validated before download","@@ -206,12 +206,12 @@ This ensures reply continuity even after gateway restarts.\n \n ## Markdown Formatting\n \n-WeChat's personal chat does not natively render full Markdown. The adapter reformats content for better readability:\n+WeChat clients connected through the iLink Bot API can render Markdown directly, so the adapter preserves Markdown instead of rewriting it:\n \n-- **Headers** (`# Title`) → converted to `【Title】` (level 1) or `**Title**` (level 2+)\n-- **Tables** → reformatted as labeled key-value lists (e.g., `- Column: Value`)\n-- **Code fences** → preserved as-is (WeChat renders these adequately)\n-- **Excessive blank lines** → collapsed to double newlines\n+- **Headers** stay as Markdown headings (`#`, `##`, ...)\n+- **Tables** stay as Markdown tables\n+- **Code fences** stay as fenced code blocks\n+- **Excessive blank lines** are collapsed to double newlines outside fenced code blocks\n \n ## Message Chunking\n \n"]}]},{"kind":"pr","number":52337,"title":"test(weixin): add MEDIA: tag delivery tests for adapter.send()","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d682f320b35a13084371a541a835e1d988c982b8","number":0},{"kind":"pr","value":"#48345","number":48345}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}