From 6029fabc98d3c5c87ac56030f470b9cae894eb18 Mon Sep 17 00:00:00 2001 From: chapaofan Date: Wed, 24 Jun 2026 14:55:45 +0800 Subject: [PATCH] fix(plugins/feishu): render markdown tables as CardKit v2 interactive cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #50602 Feishu's post-type 'md' elements do not support table syntax, so table-containing messages were force-sent as plain text, losing all table structure. This commit converts markdown tables to Feishu CardKit v2 table components by: 1. _parse_markdown_table — Parse markdown text into segments of non-table text and structured table data (header + rows). 2. _build_table_card — Convert table data into a CardKit v2 table component with proper column definitions, row data, and header style (bold, left-aligned). 3. _build_interactive_card_with_tables — Assemble a full schema 2.0 interactive card containing markdown text and table elements. 4. _build_outbound_payload — Send as interactive card when tables are detected, falling back to post/text as before. Cell content is stripped of markdown bold markers (**, __) since Feishu table data_type 'text' does not support inline markdown. Table header uses header_style: {bold: true} for visual emphasis. --- plugins/platforms/feishu/adapter.py | 185 ++++++++++++++++++++++++++-- 1 file changed, 176 insertions(+), 9 deletions(-) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index bf3c49d3b8679..4701cf6153f0c 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -154,15 +154,183 @@ r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)", re.MULTILINE, ) -# Detect markdown tables: a line starting with | followed by a separator line. -# Feishu post-type 'md' elements do not render tables, so we force text mode. -_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE) +# Markdown table detection / conversion — Feishu `md` tag does not support table +# syntax, so we convert tables to CardKit v2 interactive card table components. +_MARKDOWN_TABLE_LINE_RE = re.compile(r"^\s*\|(.+)\|\s*$") +_MARKDOWN_TABLE_DIVIDER_RE = re.compile(r"^\s*\|(?:[-:]+\|)+\s*$") _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") _MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$") _MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$") _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) + + +def _parse_markdown_table(text: str) -> List[Dict[str, Any]]: + """Parse markdown text and extract tables as Feishu CardKit v2 table components. + + Returns a list of dicts: each dict is either {"type": "text", "content": str} + or {"type": "table", "headers": [...], "rows": [[...], ...]}. + """ + if not text or "|" not in text: + return [{"type": "text", "content": text}] + + lines = text.splitlines(keepends=True) + segments: List[Dict[str, Any]] = [] + non_table_parts: List[str] = [] + table_lines: List[str] = [] + in_table = False + + def _flush_non_table() -> None: + if non_table_parts: + joined = "".join(non_table_parts) + if joined: + segments.append({"type": "text", "content": joined}) + non_table_parts.clear() + + def _flush_table() -> None: + nonlocal table_lines + if not table_lines: + return + header_line = table_lines[0].rstrip("\n") + row_lines = table_lines[2:] if len(table_lines) > 1 and _MARKDOWN_TABLE_DIVIDER_RE.match(table_lines[1]) else table_lines[1:] + + def _parse_cells(line: str) -> List[str]: + stripped = line.strip() + if stripped.startswith("|"): + stripped = stripped[1:] + if stripped.endswith("|"): + stripped = stripped[:-1] + return [cell.strip() for cell in stripped.split("|")] + + headers = _parse_cells(header_line) + rows = [_parse_cells(r) for r in row_lines] + for row in rows: + while len(row) < len(headers): + row.append("") + if len(row) > len(headers): + row[:] = row[:len(headers)] + + segments.append({"type": "table", "headers": headers, "rows": rows}) + table_lines = [] + + for line in lines: + if _MARKDOWN_TABLE_LINE_RE.match(line): + if not in_table: + _flush_non_table() + in_table = True + table_lines.append(line) + continue + + if in_table: + if line.strip() == "": + _flush_table() + in_table = False + non_table_parts.append(line) + continue + if _MARKDOWN_TABLE_DIVIDER_RE.match(line): + table_lines.append(line) + continue + if _MARKDOWN_TABLE_LINE_RE.match(line): + table_lines.append(line) + continue + _flush_table() + in_table = False + non_table_parts.append(line) + continue + + non_table_parts.append(line) + + if in_table: + _flush_table() + _flush_non_table() + + return segments + + +def _build_table_card(headers: List[str], rows: List[List[str]]) -> Dict[str, Any]: + """Build a Feishu CardKit v2 table component from headers and rows. + + Ref: https://open.feishu.cn/document/feishu-cards/card-json-v2-components/content-components/table + """ + + def _strip_md_bold(text: str) -> str: + if not text: + return text + text = re.sub(r'\*\*', '', text) + text = re.sub(r'__', '', text) + return text.strip() + + columns = [] + for i, h in enumerate(headers): + col_name = f"col_{i}" + columns.append({ + "name": col_name, + "display_name": _strip_md_bold(h) or " ", + "data_type": "text", + "width": "auto", + }) + + data_rows = [] + for row in rows: + row_obj = {} + for i, cell in enumerate(row): + if i < len(headers): + col_name = f"col_{i}" + row_obj[col_name] = _strip_md_bold(cell) or " " + data_rows.append(row_obj) + + return { + "tag": "table", + "columns": columns, + "rows": data_rows, + "header_style": { + "bold": True, + "text_align": "left", + "text_size": "normal", + "background_style": "none", + "text_color": "default", + "lines": 1 + }, + } + + +def _build_interactive_card_with_tables(text: str) -> Optional[Dict[str, Any]]: + """Build a CardKit v2 interactive card if the text contains markdown tables. + + Returns None if no tables are found (caller should fall back to post message). + """ + segments = _parse_markdown_table(text) + has_table = any(s["type"] == "table" for s in segments) + if not has_table: + return None + + elements: List[Dict[str, Any]] = [] + for seg in segments: + if seg["type"] == "text": + content = seg["content"].strip() + if content: + elements.append({ + "tag": "markdown", + "content": content, + }) + elif seg["type"] == "table": + elements.append(_build_table_card(seg["headers"], seg["rows"])) + + if not elements: + return None + + return { + "schema": "2.0", + "config": { + "wide_screen_mode": True, + }, + "body": { + "elements": elements, + }, + } + + # --------------------------------------------------------------------------- # Media type sets and upload constants # --------------------------------------------------------------------------- @@ -4375,12 +4543,11 @@ 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) + # Check for markdown tables first — if found, send as CardKit v2 interactive card + # for real table rendering instead of code blocks. + card = _build_interactive_card_with_tables(content) + if card is not None: + return "interactive", json.dumps(card, ensure_ascii=False) if _MARKDOWN_HINT_RE.search(content): return "post", _build_markdown_post_payload(content) text_payload = {"text": content}