-
Notifications
You must be signed in to change notification settings - Fork 52.2k
fix(plugins/feishu): render markdown tables as CardKit v2 interactive cards #51739
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
base: main
Are you sure you want to change the base?
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 |
|---|---|---|
|
|
@@ -154,15 +154,183 @@ | |
| r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\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): | ||
|
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. Table detection runs inside fenced code because this parser tracks no fence state. Preserve pipe-shaped examples inside ``` fences as text/code rather than turning them into CardKit tables. |
||
| 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) | ||
|
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 helper is also used by |
||
| 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} | ||
|
|
||
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 emits a table even when
table_linescontains only| literal |or otherwise lacks a GFM divider. Require a validated header-plus-divider sequence before appending atablesegment; otherwise return these lines to the text segment.