fix(weixin): preserve native markdown rendering

- stop rewriting markdown tables, headings, and links before delivery
- keep markdown table blocks and headings together during chunking
- update Weixin tests and docs for native markdown rendering

Closes #10308
This commit is contained in:
Berny Linville 2026-04-15 21:40:32 +08:00 committed by Teknium
parent 498fc6780e
commit 6ee65b4d61
3 changed files with 35 additions and 53 deletions

View file

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

View file

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

View file

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