diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 7f307fdbdf..f0b03efa8a 100644 --- a/gateway/platforms/weixin.py +++ b/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((":", ":")) diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index 95df61910f..8fc2920011 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -24,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 = ( @@ -44,19 +41,14 @@ class TestWeixinFormatting: "| 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() @@ -102,7 +94,7 @@ class TestWeixinChunking: 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() @@ -378,16 +370,13 @@ class TestWeixinRemoteMediaSafety: 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 --git a/website/docs/user-guide/messaging/weixin.md b/website/docs/user-guide/messaging/weixin.md index f658e0e233..7254cf6d85 100644 --- a/website/docs/user-guide/messaging/weixin.md +++ b/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