From 620111ee47a66a43846787c495ad5bc672343a25 Mon Sep 17 00:00:00 2001 From: Rocky-Y008 <286073397+Rocky-Y008@users.noreply.github.com> Date: Wed, 20 May 2026 00:48:15 +0800 Subject: [PATCH] fix(feishu): render markdown tables via cards Route Feishu markdown replies containing tables to Card JSON 2.0 interactive messages so tables render natively instead of forcing the whole reply to plain text. Keep regular markdown replies on post messages and add tests for table routing. --- gateway/platforms/feishu.py | 94 +++++++++++++++++++++++++++++++++--- tests/gateway/test_feishu.py | 75 ++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index a9b0447080de1..43aa4eebe97b3 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -547,7 +547,7 @@ def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int: def _build_markdown_post_payload(content: str) -> str: - rows = _build_markdown_post_rows(content) + rows = _build_markdown_post_rows(_protect_markdown_tables_for_feishu_md(content)) return json.dumps( { "zh_cn": { @@ -558,6 +558,92 @@ def _build_markdown_post_payload(content: str) -> str: ) +def _build_markdown_card_payload(content: str) -> str: + """Build a Feishu Card JSON 2.0 payload for Markdown needing tables. + + Feishu IM `post` messages use legacy rich-text `md` nodes, which do not + reliably render markdown tables. Card JSON 2.0's markdown component does + support tables, so table-bearing assistant replies are sent as interactive + cards while ordinary markdown continues to use post messages. + """ + card = { + "schema": "2.0", + "config": { + "update_multi": True, + "width_mode": "fill", + "enable_forward": True, + "summary": {"content": _strip_markdown_to_plain_text(content)[:120] or "Hermes"}, + }, + "body": { + "elements": [ + { + "tag": "markdown", + "content": content, + "text_align": "left", + "text_size": "normal", + } + ] + }, + } + return json.dumps(card, ensure_ascii=False) + + +def _protect_markdown_tables_for_feishu_md(content: str) -> str: + """Wrap markdown tables in code fences before sending as Feishu post md. + + Feishu post `md` nodes support most Markdown we emit (bold, headings, + lists, links, fences), but table blocks are a known sharp edge: clients can + render the whole post as blank or force callers to downgrade the entire + message to raw text. Preserve the table text as a fenced `text` block while + keeping the rest of the reply in renderable markdown. + """ + if not content or "|" not in content: + return content + + lines = content.replace("\r\n", "\n").split("\n") + output: List[str] = [] + i = 0 + in_code_block = False + + def _is_table_separator(line: str) -> bool: + stripped = line.strip() + if not (stripped.startswith("|") and stripped.endswith("|")): + return False + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells) + + def _is_table_row(line: str) -> bool: + stripped = line.strip() + return stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2 + + while i < len(lines): + line = lines[i] + if _MARKDOWN_FENCE_OPEN_RE.match(line.strip()) or _MARKDOWN_FENCE_CLOSE_RE.match(line.strip()): + in_code_block = not in_code_block + output.append(line) + i += 1 + continue + + if ( + not in_code_block + and i + 1 < len(lines) + and _is_table_row(line) + and _is_table_separator(lines[i + 1]) + ): + table_lines = [line, lines[i + 1]] + i += 2 + while i < len(lines) and _is_table_row(lines[i]): + table_lines.append(lines[i]) + i += 1 + output.extend(["```text", *table_lines, "```"]) + continue + + output.append(line) + i += 1 + + return "\n".join(output) + + def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]: """Build Feishu post rows while isolating fenced code blocks. @@ -4222,12 +4308,8 @@ def _is_duplicate(self, message_id: str) -> bool: # ========================================================================= def _build_outbound_payload(self, content: str) -> tuple[str, str]: - # Feishu post-type 'md' elements do not render markdown tables; sending - # table content as post causes the message to appear blank on the client. - # Force plain text for anything that looks like a markdown table. if _MARKDOWN_TABLE_RE.search(content): - text_payload = {"text": content} - return "text", json.dumps(text_payload, ensure_ascii=False) + return "interactive", _build_markdown_card_payload(content) if _MARKDOWN_HINT_RE.search(content): return "post", _build_markdown_post_payload(content) text_payload = {"text": content} diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 63287d88cb4bd..ab9b109422044 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2766,6 +2766,81 @@ async def _direct(func, *args, **kwargs): json.dumps({"text": "可以用 粗体 和 斜体。"}, ensure_ascii=False), ) + @patch.dict(os.environ, {}, clear=True) + def test_send_uses_card_json_2_for_markdown_tables(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + captured = {} + + class _MessageAPI: + def create(self, request): + captured["request"] = request + return SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="om_markdown_table"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + content = ( + "**说明**\n\n" + "| 类型 | 作用 |\n" + "|---|---|\n" + "| session | 聊天档案 |\n" + "\n## 下一节" + ) + with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + result = asyncio.run(adapter.send(chat_id="oc_chat", content=content)) + + self.assertTrue(result.success) + self.assertEqual(captured["request"].request_body.msg_type, "interactive") + payload = json.loads(captured["request"].request_body.content) + self.assertEqual(payload["schema"], "2.0") + self.assertEqual(payload["config"]["width_mode"], "fill") + self.assertEqual( + payload["body"]["elements"], + [ + { + "tag": "markdown", + "content": content, + "text_align": "left", + "text_size": "normal", + } + ], + ) + + @patch.dict(os.environ, {}, clear=True) + def test_markdown_table_inside_existing_code_fence_is_not_rewrapped(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + payload = json.loads( + adapter._build_post_payload( + "before\n```text\n| a | b |\n|---|---|\n| 1 | 2 |\n```\nafter" + ) + ) + + self.assertEqual( + payload["zh_cn"]["content"], + [ + [{"tag": "md", "text": "before"}], + [{"tag": "md", "text": "```text\n| a | b |\n|---|---|\n| 1 | 2 |\n```"}], + [{"tag": "md", "text": "after"}], + ], + ) + @patch.dict(os.environ, {}, clear=True) def test_send_uses_post_for_advanced_markdown_lines(self): from gateway.config import PlatformConfig