-
Notifications
You must be signed in to change notification settings - Fork 52.4k
fix(feishu): render markdown tables via cards #28837
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Once table messages become |
||
| if _MARKDOWN_HINT_RE.search(content): | ||
| return "post", _build_markdown_post_payload(content) | ||
| text_payload = {"text": content} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test locks in a card markdown element, but the PR claims CardKit table elements; if native table rendering is the intended fix, assert a |
||
| "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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sends the raw GFM table through a single card markdown element rather than converting rows into native CardKit table elements, so it does not implement the PR summary’s stated table-element path.