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

0open issues
0open PRs
12items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #8990 fix(weixin): rename send_document parameter to match base class

    merged to main

    files: gateway/platforms/weixin.py

    Show 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

    merged to main

    files: gateway/platforms/weixin.py, scripts/release.py, tests/gateway/test_weixin.py

    Show 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

    merged to main

    files: gateway/platforms/weixin.py, gateway/run.py, scripts/release.py, tests/gateway/test_weixin.py, website/docs/user-guide/messaging/weixin.md

    Show 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.00 means 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

At a glance

Subsystem
gateway
Merged PRs
#8990, #11478, #11571
Closed issues
5
Attempts
4 PRs closed without merging
Fix link checked
17 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.