From 14f077b506fc68b6ced2b4e93e73292bbb70533d Mon Sep 17 00:00:00 2001 From: AceMagic2 Date: Wed, 5 Aug 2026 21:07:01 +0800 Subject: [PATCH 1/2] feat(feishu): render markdown tables via Card-Kit 2.0 interactive cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu post-type `md` elements do not render GitHub-style pipe tables, so agent responses containing tables arrive as raw or dropped markup. Detect markdown table blocks in outbound content and render them as interactive cards using the Card-Kit 2.0 native `table` component — the only stable table rendering path in Feishu. Surrounding prose is preserved as `markdown` card elements. - Table card uses schema 2.0 with column-keyed rows - Single-column or degenerate tables fall back to an ASCII code-fence table inside a post message - Card build failures fall back to the same ASCII path - Configurable via platforms.feishu.extra.table_mode: 'card' (default) or 'ascii' to skip Card-Kit entirely - `_build_outbound_payload` reads table mode defensively (getattr with a 'card' default) so it keeps working on bare instances per the test contract in tests/gateway/test_feishu_table_markdown.py Tests: - Add test_send_uses_interactive_card_for_markdown_table asserting msg_type=interactive, schema 2.0 and a native table element - Update tests/gateway/test_feishu_table_markdown.py: the #52786 intent (never downgrade a table to plain text) is preserved; the default table_mode now asserts the interactive card path, and ascii mode asserts the post + ASCII code-fence path --- plugins/platforms/feishu/adapter.py | 239 ++++++++++++++++++-- tests/gateway/test_feishu.py | 49 ++++ tests/gateway/test_feishu_table_markdown.py | 74 ++++-- 3 files changed, 320 insertions(+), 42 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 8c50942042c29..9be4703583528 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -191,6 +191,13 @@ def _get_scoped_secret(name, default=None): _MENTION_RE = re.compile(r"@_user_\d+") _MULTISPACE_RE = re.compile(r"[ \t]{2,}") _POST_CONTENT_INVALID_RE = re.compile(r"content format of the post type is incorrect", re.IGNORECASE) +# Match a full markdown table block: header row, separator, body rows. +# Used for substitution — converts matches to ASCII + code fence. +_MARKDOWN_TABLE_BLOCK_RE = re.compile( + r"(?:^|\n)(\|[^\n]+\|\n\|[-:| ]+\|\n(?:(?:\|[^\n]*\|\n?))*)", + re.MULTILINE, +) + # --------------------------------------------------------------------------- # Media type sets and upload constants # --------------------------------------------------------------------------- @@ -436,6 +443,7 @@ class FeishuAdapterSettings: group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict) allow_bots: str = "none" # "none" | "mentions" | "all" require_mention: bool = True + table_mode: str = "card" # "card" (Card-Kit native table) | "ascii" (ASCII code-fence table) @dataclass @@ -495,7 +503,157 @@ def _sender_identity(sender: Any) -> frozenset: def _escape_markdown_text(text: str) -> str: - return _MARKDOWN_SPECIAL_CHARS_RE.sub(r"\\\1", text) + return _MARKDOWN_SPECIAL_CHARS_RE.sub(r"\", text) + + +def _markdown_table_to_ascii(table_text: str) -> str: + """Convert a markdown table to an ASCII box-drawing table. + + Handles CJK characters (counted as width 2 for alignment). + Returns the table wrapped in a code fence for monospace rendering + inside Feishu post md elements. + """ + lines = table_text.strip().split("\n") + if len(lines) < 2: + return table_text + + rows: list[list[str]] = [] + for line in lines: + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if all(re.match(r"^[-: ]+$", c) for c in cells): + continue + rows.append(cells) + + if not rows: + return table_text + + num_cols = max(len(r) for r in rows) + col_widths = [0] * num_cols + for row in rows: + for j, cell in enumerate(row): + if j < num_cols: + w = sum(2 if ord(c) > 127 else 1 for c in cell) + col_widths[j] = max(col_widths[j], w) + col_widths = [max(w, 3) for w in col_widths] + + def _pad_cell(text: str, width: int) -> str: + text_width = sum(2 if ord(c) > 127 else 1 for c in text) + return text + " " * max(width - text_width, 0) + + def _make_sep(char: str) -> str: + return "+" + "+".join(char * (w + 2) for w in col_widths) + "+" + + result: list[str] = [] + result.append(_make_sep("-")) + result.append( + "| " + " | ".join(_pad_cell(rows[0][j], col_widths[j]) for j in range(num_cols)) + " |" + ) + result.append(_make_sep("=")) + for row in rows[1:]: + padded = [_pad_cell(row[j] if j < len(row) else "", col_widths[j]) for j in range(num_cols)] + result.append("| " + " | ".join(padded) + " |") + result.append(_make_sep("-")) + + return "\n".join(result) + + +def _parse_markdown_table_rows(table_text: str) -> tuple[list[str], list[list[str]]]: + """Parse a markdown table block into (headers, data_rows). + + Skips the separator row. Pads short rows to match the header column + count. Returns empty lists when the block is too short to be a valid + table. + """ + lines = table_text.strip().split("\n") + if len(lines) < 2: + return [], [] + + rows_data: list[list[str]] = [] + for line in lines: + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if all(re.match(r"^[-: ]+$", c) for c in cells): + continue + rows_data.append(cells) + + if len(rows_data) < 2: + return [], [] + + headers = rows_data[0] + num_cols = len(headers) + data_rows = rows_data[1:] + + # Pad short rows + for row in data_rows: + if len(row) < num_cols: + row.extend([""] * (num_cols - len(row))) + elif len(row) > num_cols: + del row[num_cols:] + + return headers, data_rows + + +def _markdown_table_to_table_element(table_text: str) -> dict: + """Convert a single markdown table to a Feishu Card-Kit 2.0 ``table`` element. + + Falls back to a ``markdown`` code-fenced ASCII table when parsing fails + (e.g. 1-column table or empty block). + """ + headers, data_rows = _parse_markdown_table_rows(table_text) + if len(headers) < 2 or not data_rows: + # Single-column or degenerate table — render as code-fenced ASCII + ascii_table = _markdown_table_to_ascii(table_text) + return {"tag": "markdown", "content": f"```\n{ascii_table}\n```"} + + return { + "tag": "table", + "columns": [{"name": h, "display_name": h, "data_type": "text", "width": "auto"} for h in headers], + "rows": [ + {headers[j]: str(cell) for j, cell in enumerate(row)} + for row in data_rows + ], + } + + +def _build_table_card_text( + content: str, + table_matches: list[re.Match], +) -> tuple[str, str]: + """Build an interactive card from content containing markdown tables. + + Prose between and around tables becomes ``markdown`` card elements; + each table becomes a native ``table`` element. When no tables are + found or the result is empty, falls back to the ASCII+post path so + the caller can try the existing code path. + + Returns ``(msg_type, payload_json)``. + """ + card_elements: list[dict] = [] + last_end = 0 + + for match in table_matches: + # Prose before this table + before = content[last_end : match.start()].strip() + if before: + card_elements.append({"tag": "markdown", "content": before}) + + table_element = _markdown_table_to_table_element(match.group(1)) + card_elements.append(table_element) + last_end = match.end() + + # Trailing prose + after = content[last_end:].strip() + if after: + card_elements.append({"tag": "markdown", "content": after}) + + if not card_elements: + return ("", "") + + card = { + "schema": "2.0", + "config": {"wide_screen_mode": True}, + "body": {"elements": card_elements}, + } + return "interactive", json.dumps(card, ensure_ascii=False) def _to_boolean(value: Any) -> bool: @@ -1641,6 +1799,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: require_mention=_to_boolean( extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true")) ), + table_mode=str(extra.get("table_mode", "card")).strip().lower(), ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1673,6 +1832,7 @@ def _apply_settings(self, settings: FeishuAdapterSettings) -> None: self._ws_ping_timeout = settings.ws_ping_timeout self._allow_bots = settings.allow_bots self._require_mention = settings.require_mention + self._table_mode = settings.table_mode def _build_event_handler(self) -> Any: if EventDispatcherHandler is None: @@ -1962,26 +2122,42 @@ async def send( metadata=metadata, ) except Exception as exc: - if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)): + # post failures are caught by _POST_CONTENT_INVALID_RE; interactive + # card failures can produce a variety of errors (card validation, + # table column names, etc.) — fall back to ASCII table for any of them. + is_post_fail = msg_type == "post" and _POST_CONTENT_INVALID_RE.search(str(exc)) + is_interactive = msg_type == "interactive" + if not (is_post_fail or is_interactive): raise - logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text") + logger.warning("[Feishu] %s payload rejected by API; falling back to ASCII", msg_type) + fallback_content = _MARKDOWN_TABLE_BLOCK_RE.sub( + lambda m: "\n```\n" + _markdown_table_to_ascii(m.group(1)) + "\n```\n", + chunk, + ) response = await self._feishu_send_with_retry( chat_id=chat_id, - msg_type="text", - payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False), + msg_type="post", + payload=_build_markdown_post_payload(fallback_content), reply_to=reply_to, metadata=metadata, ) if ( - msg_type == "post" - and not self._response_succeeded(response) - and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or "")) + not self._response_succeeded(response) + and ( + (msg_type == "post" and _POST_CONTENT_INVALID_RE.search(str(getattr(response, "msg", "") or ""))) + or msg_type == "interactive" + ) ): - logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text") + logger.warning("[Feishu] %s payload rejected by API response; falling back to ASCII. " + "Error: %s", msg_type, getattr(response, 'msg', str(response))) + fallback_content = _MARKDOWN_TABLE_BLOCK_RE.sub( + lambda m: "\n```\n" + _markdown_table_to_ascii(m.group(1)) + "\n```\n", + chunk, + ) response = await self._feishu_send_with_retry( chat_id=chat_id, - msg_type="text", - payload=json.dumps({"text": _strip_markdown_to_plain_text(chunk)}, ensure_ascii=False), + msg_type="post", + payload=_build_markdown_post_payload(fallback_content), reply_to=reply_to, metadata=metadata, ) @@ -2011,8 +2187,8 @@ async def edit_message( request = self._build_update_message_request(message_id=message_id, request_body=body) response = await self._run_blocking(self._client.im.v1.message.update, request) result = self._finalize_send_result(response, "update failed") - if not result.success and msg_type == "post" and _POST_CONTENT_INVALID_RE.search(result.error or ""): - logger.warning("[Feishu] Invalid post update payload rejected by API; falling back to plain text") + if not result.success and msg_type in ("post", "interactive") and _POST_CONTENT_INVALID_RE.search(result.error or ""): + logger.warning("[Feishu] Invalid %s update payload rejected by API; falling back to plain text", msg_type) fallback_body = self._build_update_message_body( msg_type="text", content=json.dumps({"text": _strip_markdown_to_plain_text(content)}, ensure_ascii=False), @@ -4620,13 +4796,37 @@ def _is_duplicate(self, message_id: str) -> bool: def _build_outbound_payload( self, content: str, *, prefer_post: bool = False, ) -> tuple[str, str]: - # Empirically (issue #52786), current Feishu clients render markdown - # tables inside ``post``-type ``md`` elements natively. The previous - # table-downgrade branch forced any table-containing message to - # ``text``, which left Feishu readers seeing the raw pipe-and-dash - # source instead of a rendered table. Trust the common markdown path - # for table content too. + # Feishu chat messages do not render raw markdown tables. When a + # table is detected, convert it to an interactive card with a native + # Card-Kit ``table`` component — the only stable table rendering path + # in Feishu (post-type ``md`` elements drop tables silently). + # Surrounding prose is preserved as ``markdown`` card elements. + # If the table is degenerate (1 column or empty), fall back to the + # ASCII+code-fence approach inside a post message. # + # table_mode config toggle: + # "card" (default) → Card-Kit native table, ASCII fallback on failure + # "ascii" → skip Card-Kit entirely, always ASCII code-fence + if _MARKDOWN_TABLE_RE.search(content): + table_mode = getattr(self, "_table_mode", "card") + if table_mode == "ascii": + # User explicitly prefers ASCII tables — skip Card-Kit + converted = _MARKDOWN_TABLE_BLOCK_RE.sub( + lambda m: "\n```\n" + _markdown_table_to_ascii(m.group(1)) + "\n```\n", + content, + ) + return "post", _build_markdown_post_payload(converted) + table_matches = list(_MARKDOWN_TABLE_BLOCK_RE.finditer(content)) + if table_matches: + msg_type, card_payload = _build_table_card_text(content, table_matches) + if msg_type: + return msg_type, card_payload + # Card build failed (e.g. empty elements) — fall back to ASCII + converted = _MARKDOWN_TABLE_BLOCK_RE.sub( + lambda m: "\n```\n" + _markdown_table_to_ascii(m.group(1)) + "\n```\n", + content, + ) + return "post", _build_markdown_post_payload(converted) # ``prefer_post`` lets ``send`` treat the chunk as part of a larger # markdown document: when a long markdown reply is split at # MAX_MESSAGE_LENGTH, the per-chunk regex would otherwise @@ -4929,6 +5129,7 @@ async def _connect_websocket(self) -> None: # transport. The tag tells the server to use the Channel protocol # which enables group-message routing in addition to P2P DM. # See https://github.com/NousResearch/hermes-agent/issues/50656 + # NOTE: extra_ua_tags requires lark-oapi >= 1.6.0 extra_ua_tags=["channel"], ) self._ws_future = loop.run_in_executor( diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index a923ea5f5d46e..299b55484b0bb 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -1370,6 +1370,55 @@ async def _direct(func, *args, **kwargs): ], ) + @patch.dict(os.environ, {}, clear=True) + def test_send_uses_interactive_card_for_markdown_table(self): + from gateway.config import PlatformConfig + from plugins.platforms.feishu.adapter 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_table_card"), + ) + + adapter._client = SimpleNamespace( + im=SimpleNamespace( + v1=SimpleNamespace( + message=_MessageAPI(), + ) + ) + ) + + async def _direct(func, *args, **kwargs): + return func(*args, **kwargs) + + content = "| 名称 | 数量 |\n| --- | --- |\n| 苹果 | 3 |\n| 香蕉 | 5 |" + + with patch("plugins.platforms.feishu.adapter.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") + elements = payload["body"]["elements"] + self.assertEqual(elements[0]["tag"], "table") + self.assertEqual( + [c["name"] for c in elements[0]["columns"]], + ["名称", "数量"], + ) + self.assertEqual(len(elements[0]["rows"]), 2) + @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed") class TestHydrateBotIdentity(unittest.TestCase): diff --git a/tests/gateway/test_feishu_table_markdown.py b/tests/gateway/test_feishu_table_markdown.py index 230a20768a016..74542cc9494e6 100644 --- a/tests/gateway/test_feishu_table_markdown.py +++ b/tests/gateway/test_feishu_table_markdown.py @@ -3,12 +3,18 @@ Reproduces the bug tracked in hermes-agent issue #52786: `_build_outbound_payload` was force-downgrading any message containing a markdown pipe table to ``msg_type=text``, so Feishu clients rendered the raw -pipe-and-dash source instead of a table. Empirically current Feishu clients -render ``post``+``md`` tables natively, so the downgrade branch must be removed. - -These tests guard the fix. They invoke the real adapter via the project's -plugin-loader helper so that no ``sys.path`` / ``sys.modules`` games are -needed. +pipe-and-dash source instead of a table. + +This file also guards the Card-Kit table rendering path (default +``table_mode="card"``): tables are sent as ``interactive`` cards with a +Card-Kit 2.0 native ``table`` element — the only stable table rendering +path in Feishu (post-type ``md`` elements drop tables silently). With +``table_mode="ascii"`` the same content goes out as a ``post`` with an +ASCII code-fence table. Either way, a table-shaped message must never be +downgraded to plain ``text``. + +These tests invoke the real adapter via the project's plugin-loader helper +so that no ``sys.path`` / ``sys.modules`` games are needed. """ from __future__ import annotations @@ -20,15 +26,19 @@ _adapter = load_plugin_adapter("feishu") -def _call_build_outbound_payload(content: str) -> tuple[str, str]: +def _call_build_outbound_payload( + content: str, table_mode: str = "card" +) -> tuple[str, str]: """Invoke ``_build_outbound_payload`` on a bare adapter instance. - ``_build_outbound_payload`` is a method that only uses module-level - helpers (``_MARKDOWN_TABLE_RE``, ``_MARKDOWN_HINT_RE``, - ``_build_markdown_post_payload``) and never touches ``self.*``, so a bare - object is sufficient. + ``_build_outbound_payload`` only uses module-level helpers + (``_MARKDOWN_TABLE_RE``, ``_MARKDOWN_HINT_RE``, + ``_build_markdown_post_payload``) plus the ``_table_mode`` attribute + (read defensively with a ``card`` default), so a bare object with the + attribute set is sufficient. """ inst = object.__new__(_adapter.FeishuAdapter) + inst._table_mode = table_mode return inst._build_outbound_payload(content) @@ -63,21 +73,41 @@ def _md_texts_from_post_payload(payload_str: str) -> list[str]: return texts -def test_markdown_table_uses_post_not_text(): +_TABLE_CONTENT = ( + "| col A | col B |\n" + "| ----- | ----- |\n" + "| 1 | 2 |" +) + + +def test_markdown_table_uses_interactive_card_not_text(): """Regression test for issue #52786 (and its older sibling #23938). - A message whose only markdown is a table must take the ``post`` path, - not be downgraded to plain text. + With the default ``table_mode="card"`` a table-shaped message must take + the Card-Kit ``interactive`` path — never be downgraded to plain text. """ - content = ( - "| col A | col B |\n" - "| ----- | ----- |\n" - "| 1 | 2 |" + msg_type, payload_str = _call_build_outbound_payload(_TABLE_CONTENT) + assert msg_type == "interactive", ( + f"expected 'interactive' card for a markdown table, got {msg_type!r}; " + "the table-downgrade branch in _build_outbound_payload has been re-introduced" + ) + payload = json.loads(payload_str) + assert payload["schema"] == "2.0" + elements = payload["body"]["elements"] + assert elements and elements[0]["tag"] == "table", ( + "card payload must include a native table element" + ) + assert [c["name"] for c in elements[0]["columns"]] == ["col A", "col B"] + + +def test_markdown_table_uses_post_not_text_in_ascii_mode(): + """With ``table_mode="ascii"`` a table must go out as ``post`` (ASCII + code-fence), not plain text.""" + msg_type, payload_str = _call_build_outbound_payload( + _TABLE_CONTENT, table_mode="ascii" ) - msg_type, payload_str = _call_build_outbound_payload(content) assert msg_type == "post", ( - f"expected 'post' for a markdown table (issue #52786), got {msg_type!r}; " - "the table-downgrade branch in _build_outbound_payload has been re-introduced" + f"expected 'post' for a markdown table in ascii mode, got {msg_type!r}" ) md_texts = _md_texts_from_post_payload(payload_str) assert md_texts, f"post payload must include at least one md element; got {payload_str!r}" @@ -85,5 +115,3 @@ def test_markdown_table_uses_post_not_text(): assert "col A" in joined and "|" in joined, ( "table text was lost or reformatted when switching from text to post" ) - - From a2910217f88090284afcd4b6dec146109bdff587 Mon Sep 17 00:00:00 2001 From: AceMagic2 Date: Wed, 5 Aug 2026 21:44:20 +0800 Subject: [PATCH 2/2] chore: map contributor email foottube@163.com --- contributors/emails/foottube@163.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/foottube@163.com diff --git a/contributors/emails/foottube@163.com b/contributors/emails/foottube@163.com new file mode 100644 index 0000000000000..2420b27f06e1b --- /dev/null +++ b/contributors/emails/foottube@163.com @@ -0,0 +1 @@ +foottube