# NousResearch/hermes-agent · Weixin adapter media send signature and delivery fixes

## Open now

- 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.

## TL;DR

- 12 member(s): 0 open issue(s), 5 closed issue(s), 0 open PR(s), 3 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #8990. Verify it addresses your case.
- **Recommended:** read PR #8990 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-8990,pr-11478`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #8783: bug(gateway): WeixinAdapter media send methods drop files due to signature mismatch
- #8819: WeixinAdapter.send_document() parameter name mismatch causes file sending to fail
- #9738: Weixin: MEDIA image attachments don't seem to arrive in chat
- #13769: \[Weixin\] send_image_file() fails due to parameter name mismatch
- #48345: Weixin: MEDIA: tag does not trigger send_image_file — images never delivered

## Prior & competing PRs

- **#8990: fix(weixin): rename send_document parameter to match base class**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/weixin.py`
  - fix hunks:
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -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)
    ```
- **#9304: fix(weixin): align send_image_file with image_path keyword**. closed without merge · fix tier `suggested`
  - maintainer direction: category `merged_via`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#11478: fix(weixin): correct send_image_file parameter name to match base class**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/weixin.py`, `scripts/release.py`, `tests/gateway/test_weixin.py`
  - fix hunks:
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -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`

    ```diff
    @@ -232,6 +232,7 @@
         "[redacted:email]": "ZaynJarvis",
         "[redacted:email]": "malaiwah",
         "[redacted:email]": "v1k22",
    +    "[redacted:email]": "RucchiZ",
     }
     
     
    ```
  - `tests/gateway/test_weixin.py`

    ```diff
    @@ -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**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/weixin.py`, `gateway/run.py`, `scripts/release.py`, `tests/gateway/test_weixin.py`, `website/docs/user-guide/messaging/weixin.md`
  - fix hunks:
  - `gateway/platforms/weixin.py`

    ```diff
    @@ -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]:
    ```

    ```diff
    @@ -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] = []
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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((":", "："))
     
     
    ```

    ```diff
    @@ -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(
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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: {
    ```

    ```diff
    @@ -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": {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 = (
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
     
    ```
- **#8824: fix: WeixinAdapter.send_document() parameter name mismatch**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#8990`, `#8895`
- **#8895: fix(weixin): rename send_document parameter to match base class**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#8990`
- **#52337: test(weixin): add MEDIA: tag delivery tests for adapter.send()**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `d682f320b35a13084371a541a835e1d988c982b8`, `#48345`

## Fix status

- Merged to main in PR #8990 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-17`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
