From 81ee9575f94c31eff97c1c202063095e811bb8a9 Mon Sep 17 00:00:00 2001 From: UltraMan Date: Thu, 21 May 2026 16:46:33 +0800 Subject: [PATCH] fix(feishu): render markdown tables with card json 2.0 Convert Feishu Markdown table replies to Card JSON 2.0 native table components instead of relying on legacy post/card structures. - add outbound format selection for Feishu replies - build interactive Card JSON 2.0 payloads under body.elements - render pipe tables with native table components - fall back to plain text when interactive/post payloads are rejected - add regression coverage for Feishu table rendering --- gateway/config.py | 4 + gateway/platforms/feishu.py | 197 ++++++++++++++++++++++++++++++++--- tests/gateway/test_feishu.py | 44 ++++++++ 3 files changed, 233 insertions(+), 12 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 7180f1ddb84ac..92bec6407cf4c 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1147,6 +1147,10 @@ def load_gateway_config() -> GatewayConfig: if isinstance(feishu_cfg, dict): if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"): os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower() + if "outbound_format" in feishu_cfg and not os.getenv("HERMES_FEISHU_OUTBOUND_FORMAT"): + os.environ["HERMES_FEISHU_OUTBOUND_FORMAT"] = str(feishu_cfg["outbound_format"]).lower() + if "message_format" in feishu_cfg and not os.getenv("HERMES_FEISHU_OUTBOUND_FORMAT"): + os.environ["HERMES_FEISHU_OUTBOUND_FORMAT"] = str(feishu_cfg["message_format"]).lower() except Exception as e: logger.warning( diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index a9b0447080de1..879decfac159b 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -393,6 +393,7 @@ class FeishuAdapterSettings: group_rules: Dict[str, FeishuGroupRule] = field(default_factory=dict) allow_bots: str = "none" # "none" | "mentions" | "all" require_mention: bool = True + outbound_format: str = "auto" # "auto" | "card" | "post" | "text" @dataclass @@ -558,6 +559,128 @@ def _build_markdown_post_payload(content: str) -> str: ) +def _is_markdown_table_separator(line: str) -> bool: + return bool(re.match(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$", line)) + + +def _split_markdown_table_row(line: str) -> List[str]: + stripped = line.strip() + if stripped.startswith("|"): + stripped = stripped[1:] + if stripped.endswith("|"): + stripped = stripped[:-1] + return [cell.strip() or " " for cell in stripped.split("|")] + + +def _build_card_table_element(table_lines: List[str]) -> Dict[str, Any]: + """Render a GitHub-style markdown table as a Feishu Card JSON v2 table. + + Card JSON v2 rejects unknown v1 properties and requires card content under + ``body.elements``. It also has a native table component; use it instead of + synthetic ``column_set`` rows so pipe tables render reliably in Feishu/Lark. + """ + if len(table_lines) < 2: + return {"tag": "markdown", "content": "\n".join(table_lines)} + + header = _split_markdown_table_row(table_lines[0]) + rows = [_split_markdown_table_row(line) for line in table_lines[2:]] + column_count = max([len(header), *(len(row) for row in rows)] or [len(header)]) + column_count = min(max(column_count, 1), 50) + + def _normalize_cells(cells: List[str]) -> List[str]: + padded = cells[:column_count] + [" "] * max(0, column_count - len(cells)) + return padded[:column_count] + + columns = [] + for index, cell in enumerate(_normalize_cells(header)): + columns.append( + { + "name": f"c{index + 1}", + "display_name": cell.strip() or " ", + "data_type": "markdown", + "width": "auto", + "horizontal_align": "left", + "vertical_align": "top", + } + ) + + table_rows = [] + for row in rows: + cells = _normalize_cells(row) + table_rows.append({f"c{index + 1}": cell or " " for index, cell in enumerate(cells)}) + + return { + "tag": "table", + "page_size": min(max(len(table_rows), 1), 10), + "row_height": "auto", + "header_style": { + "text_align": "left", + "text_size": "normal", + "background_style": "grey", + "text_color": "default", + "bold": True, + "lines": 1, + }, + "columns": columns, + "rows": table_rows, + } + + +def _build_card_elements_from_markdown(content: str) -> List[Dict[str, Any]]: + """Split markdown content into Feishu card elements, converting pipe tables.""" + if not content: + return [{"tag": "markdown", "content": " "}] + if "|" not in content: + return [{"tag": "markdown", "content": content}] + + lines = content.splitlines() + elements: List[Dict[str, Any]] = [] + markdown_buffer: List[str] = [] + index = 0 + + def _flush_markdown() -> None: + nonlocal markdown_buffer + text = "\n".join(markdown_buffer).strip("\n") + if text.strip(): + elements.append({"tag": "markdown", "content": text}) + markdown_buffer = [] + + while index < len(lines): + line = lines[index] + next_line = lines[index + 1] if index + 1 < len(lines) else "" + if "|" in line and _is_markdown_table_separator(next_line): + _flush_markdown() + table_lines = [line, next_line] + index += 2 + while index < len(lines) and "|" in lines[index] and lines[index].strip(): + table_lines.append(lines[index]) + index += 1 + elements.append(_build_card_table_element(table_lines)) + continue + markdown_buffer.append(line) + index += 1 + + _flush_markdown() + return elements or [{"tag": "markdown", "content": content or " "}] + + +def _build_markdown_card_payload(content: str) -> str: + """Build a Feishu/Lark Card JSON v2 interactive payload for markdown replies.""" + return json.dumps( + { + "schema": "2.0", + "config": { + "update_multi": True, + "width_mode": "fill", + }, + "body": { + "elements": _build_card_elements_from_markdown(content or " "), + }, + }, + ensure_ascii=False, + ) + + def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]: """Build Feishu post rows while isolating fenced code blocks. @@ -1507,6 +1630,18 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: ) allow_bots = "none" + outbound_format = str( + extra.get("outbound_format") + or extra.get("message_format") + or os.getenv("HERMES_FEISHU_OUTBOUND_FORMAT", "auto") + ).strip().lower() + if outbound_format not in {"auto", "card", "post", "text"}: + logger.warning( + "[Feishu] Unknown outbound_format=%r, falling back to 'auto'. Valid: auto, card, post, text.", + outbound_format, + ) + outbound_format = "auto" + return FeishuAdapterSettings( app_id=str(extra.get("app_id") or os.getenv("FEISHU_APP_ID", "")).strip(), app_secret=str(extra.get("app_secret") or os.getenv("FEISHU_APP_SECRET", "")).strip(), @@ -1567,6 +1702,7 @@ def _load_settings(extra: Dict[str, Any]) -> FeishuAdapterSettings: require_mention=_to_boolean( extra.get("require_mention", os.getenv("FEISHU_REQUIRE_MENTION", "true")) ), + outbound_format=outbound_format, ) def _apply_settings(self, settings: FeishuAdapterSettings) -> None: @@ -1599,6 +1735,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._outbound_format = settings.outbound_format def _build_event_handler(self) -> Any: if EventDispatcherHandler is None: @@ -1781,9 +1918,32 @@ async def send( metadata=metadata, ) except Exception as exc: - if msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)): + if msg_type == "interactive": + logger.warning("[Feishu] Interactive card payload rejected by API; falling back to plain text") + 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), + reply_to=reply_to, + metadata=metadata, + ) + elif msg_type != "post" or not _POST_CONTENT_INVALID_RE.search(str(exc)): raise - logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text") + else: + logger.warning("[Feishu] Invalid post payload rejected by API; falling back to plain text") + 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), + 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 "")) + ): + logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text") response = await self._feishu_send_with_retry( chat_id=chat_id, msg_type="text", @@ -1791,12 +1951,8 @@ async def send( 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 "")) - ): - logger.warning("[Feishu] Post payload rejected by API response; falling back to plain text") + if msg_type == "interactive" and not self._response_succeeded(response): + logger.warning("[Feishu] Interactive card payload rejected by API response; falling back to plain text") response = await self._feishu_send_with_retry( chat_id=chat_id, msg_type="text", @@ -1839,6 +1995,15 @@ async def edit_message( fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body) fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request) result = self._finalize_send_result(fallback_response, "update failed") + if not result.success and msg_type == "interactive": + logger.warning("[Feishu] Interactive card update rejected by API; falling back to plain text") + fallback_body = self._build_update_message_body( + msg_type="text", + content=json.dumps({"text": _strip_markdown_to_plain_text(content)}, ensure_ascii=False), + ) + fallback_request = self._build_update_message_request(message_id=message_id, request_body=fallback_body) + fallback_response = await asyncio.to_thread(self._client.im.v1.message.update, fallback_request) + result = self._finalize_send_result(fallback_response, "update failed") if result.success: result.message_id = message_id return result @@ -4222,12 +4387,20 @@ 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): + outbound_format = getattr(self, "_outbound_format", "auto") + if outbound_format == "card": + return "interactive", _build_markdown_card_payload(content) + if outbound_format == "text": text_payload = {"text": content} return "text", json.dumps(text_payload, ensure_ascii=False) + if outbound_format == "post": + return "post", _build_markdown_post_payload(content) + + # Feishu post-type 'md' elements do not render markdown tables. Card JSON + # v2 has a native table component, so route pipe tables to interactive + # cards instead of sending a blank/unstyled post. + if _MARKDOWN_TABLE_RE.search(content): + 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..834470c5939ee 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -414,6 +414,50 @@ async def _direct(func, *args, **kwargs): json.dumps({"text": "可以用 粗体 和 斜体。"}, ensure_ascii=False), ) + @patch.dict(os.environ, {}, clear=True) + def test_outbound_format_card_wraps_markdown_in_interactive_card(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig(extra={"outbound_format": "card"})) + msg_type, payload = adapter._build_outbound_payload("**Bold**\n\n| A | B |\n|---|---|") + + self.assertEqual(msg_type, "interactive") + card = json.loads(payload) + self.assertEqual(card["schema"], "2.0") + self.assertEqual(card["config"]["width_mode"], "fill") + elements = card["body"]["elements"] + self.assertEqual(elements[0]["tag"], "markdown") + self.assertIn("**Bold**", elements[0]["content"]) + self.assertEqual(elements[1]["tag"], "table") + self.assertEqual(elements[1]["header_style"]["background_style"], "grey") + self.assertEqual(elements[1]["columns"][0]["display_name"], "A") + self.assertEqual(elements[1]["columns"][0]["data_type"], "markdown") + self.assertEqual(elements[1]["columns"][1]["display_name"], "B") + self.assertEqual(elements[1]["rows"], []) + self.assertNotIn("wide_screen_mode", json.dumps(card, ensure_ascii=False)) + self.assertNotIn("column_set", json.dumps(card, ensure_ascii=False)) + + @patch.dict(os.environ, {}, clear=True) + def test_auto_routes_markdown_tables_to_card_json_v2_table(self): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + + adapter = FeishuAdapter(PlatformConfig()) + msg_type, payload = adapter._build_outbound_payload( + "| Name | Value |\n|---|---|\n| Alpha | **1** |\n| Beta | [two](https://example.com) |" + ) + + self.assertEqual(msg_type, "interactive") + card = json.loads(payload) + table = card["body"]["elements"][0] + self.assertEqual(card["schema"], "2.0") + self.assertEqual(table["tag"], "table") + self.assertEqual(table["columns"][0]["display_name"], "Name") + self.assertEqual(table["columns"][1]["display_name"], "Value") + self.assertEqual(table["rows"][0], {"c1": "Alpha", "c2": "**1**"}) + self.assertEqual(table["rows"][1], {"c1": "Beta", "c2": "[two](https://example.com)"}) + @patch.dict(os.environ, {}, clear=True) def test_get_chat_info_uses_real_feishu_chat_api(self): from gateway.config import PlatformConfig