From e09d693ba6e44aff8cdff4623d1334222ab3cfd8 Mon Sep 17 00:00:00 2001 From: thunderredondo Date: Wed, 6 May 2026 17:03:59 +0800 Subject: [PATCH] fix(feishu): convert markdown tables to lists before rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu's md renderer does not support markdown table syntax (| col | col |). Tables are silently rendered as blank cells, sometimes swallowing trailing content. This is a known limitation of the Feishu post message format, not a rendering bug — the md tag spec simply omits table support. Add _convert_tables_to_text() which detects markdown table blocks (header + separator + data rows) and converts them to readable bullet lists using header values as labels. Pipes inside fenced code blocks are left untouched. The conversion runs at the top of _build_markdown_post_rows(), so all outbound Feishu markdown messages benefit automatically. Includes 9 unit tests covering: simple/3-column tables, surrounding prose, no-table passthrough, code block isolation, empty cells, and integration with the post row builder. --- gateway/platforms/feishu.py | 86 ++++++++- tests/gateway/test_feishu_table_conversion.py | 168 ++++++++++++++++++ 2 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/test_feishu_table_conversion.py diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 8bc2ae816ed8..8f5915c22f3b 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -538,6 +538,79 @@ def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int: return default if parsed is None else parsed +# --------------------------------------------------------------------------- +# Table → text conversion +# --------------------------------------------------------------------------- +# Feishu's ``md`` renderer has a known bug: markdown tables render as blank +# cells. We detect table blocks and convert them to readable plain-text +# lists before handing content to the renderer. + +_TABLE_ROW_RE = re.compile(r"^\s*\|.+\|\s*$") +_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|[\s:|-]+\|\s*$") + + +def _convert_tables_to_text(content: str) -> str: + """Convert markdown table blocks to plain-text lists. + + Feishu's ``md`` renderer renders markdown tables as blank. This helper + detects consecutive table rows (lines starting and ending with ``|``), + strips the separator row, and converts each data row to a bullet list + item using the header values as labels. + + Pipes inside fenced code blocks are left untouched. + """ + if "|" not in content: + return content + + lines = content.split("\n") + result: list[str] = [] + i = 0 + in_code_block = False + + while i < len(lines): + line = lines[i] + + # Track code block state + if line.strip().startswith("```"): + in_code_block = not in_code_block + result.append(line) + i += 1 + continue + + if in_code_block: + result.append(line) + i += 1 + continue + + # Detect table block: header row + separator row + data rows + if _TABLE_ROW_RE.match(line) and i + 1 < len(lines) and _TABLE_SEPARATOR_RE.match(lines[i + 1]): + # Parse header cells + header_cells = [c.strip() for c in line.strip().strip("|").split("|")] + # Skip separator + i += 2 + # Parse data rows + table_lines: list[str] = [] + while i < len(lines) and _TABLE_ROW_RE.match(lines[i]) and not _TABLE_SEPARATOR_RE.match(lines[i]): + cells = [c.strip() for c in lines[i].strip().strip("|").split("|")] + # Build label:value pairs using header names + parts: list[str] = [] + for idx, cell in enumerate(cells): + label = header_cells[idx] if idx < len(header_cells) else "" + if label: + parts.append(f"{label}:{cell}") + else: + parts.append(cell) + table_lines.append("- " + " | ".join(parts)) + i += 1 + result.extend(table_lines) + continue + + result.append(line) + i += 1 + + return "\n".join(result) + + # --------------------------------------------------------------------------- # Post payload builders and parsers # --------------------------------------------------------------------------- @@ -558,13 +631,18 @@ def _build_markdown_post_payload(content: str) -> str: def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]: """Build Feishu post rows while isolating fenced code blocks. - Feishu's `md` renderer can swallow trailing content when a fenced code block - appears inside one large markdown element. Split the reply at real fence - lines so prose before/after the code block remains visible while code stays - in a dedicated row. + Feishu's ``md`` renderer can swallow trailing content when a fenced code + block appears inside one large markdown element. Split the reply at real + fence lines so prose before/after the code block remains visible while + code stays in a dedicated row. + + Markdown tables are also converted to plain-text lists because Feishu's + ``md`` renderer renders them as blank cells. """ if not content: return [[{"tag": "md", "text": ""}]] + # Convert tables to plain text before any further processing. + content = _convert_tables_to_text(content) if "```" not in content: return [[{"tag": "md", "text": content}]] diff --git a/tests/gateway/test_feishu_table_conversion.py b/tests/gateway/test_feishu_table_conversion.py new file mode 100644 index 000000000000..af324c57adb2 --- /dev/null +++ b/tests/gateway/test_feishu_table_conversion.py @@ -0,0 +1,168 @@ +"""Tests for Feishu markdown table auto-conversion. + +Feishu's ``md`` renderer has a known bug: markdown tables render as blank, +sometimes swallowing trailing content. The ``_convert_tables_to_text`` +function detects markdown table blocks and converts them to plain-text +lists before they reach the renderer. +""" + +import json +import unittest + + +class TestConvertTablesToText(unittest.TestCase): + """Unit tests for the ``_convert_tables_to_text`` helper.""" + + def _call(self, text: str) -> str: + from gateway.platforms.feishu import _convert_tables_to_text + return _convert_tables_to_text(text) + + # -- simple table -------------------------------------------------------- + + def test_simple_two_column_table(self): + table = ( + "| 项目 | 值 |\n" + "|------|-----|\n" + "| 模型 | mimo |\n" + "| 状态 | 正常 |" + ) + result = self._call(table) + self.assertIn("项目:模型", result) + self.assertIn("值:mimo", result) + self.assertIn("项目:状态", result) + self.assertIn("值:正常", result) + # Original pipe-delimited form must be gone + self.assertNotIn("| 模型 |", result) + self.assertNotIn("|------|", result) + + # -- three column table -------------------------------------------------- + + def test_three_column_table(self): + table = ( + "| 名称 | 值 | 说明 |\n" + "|------|-----|------|\n" + "| Alpha | 1 | first |\n" + "| Beta | 2 | second |" + ) + result = self._call(table) + self.assertIn("Alpha", result) + self.assertIn("1", result) + self.assertIn("first", result) + self.assertIn("Beta", result) + self.assertNotIn("| Alpha |", result) + + # -- table surrounded by prose ------------------------------------------- + + def test_table_with_surrounding_text(self): + content = ( + "前面的文字。\n" + "\n" + "| A | B |\n" + "|---|---|\n" + "| x | y |\n" + "\n" + "后面的文字。" + ) + result = self._call(content) + self.assertIn("前面的文字。", result) + self.assertIn("后面的文字。", result) + self.assertIn("x", result) + self.assertIn("y", result) + self.assertNotIn("| A |", result) + + # -- no table → unchanged ------------------------------------------------ + + def test_no_table_unchanged(self): + content = "这是一段普通文字,**没有表格**。" + self.assertEqual(self._call(content), content) + + # -- pipe inside code block must NOT be treated as table ------------------ + + def test_pipe_in_code_block_ignored(self): + content = ( + "示例:\n" + "```\n" + "| not | a | table |\n" + "|-----|---|-------|\n" + "| foo | bar | baz |\n" + "```\n" + "结束。" + ) + result = self._call(content) + # Code block content must be preserved verbatim + self.assertIn("| not | a | table |", result) + self.assertIn("| foo | bar | baz |", result) + + # -- empty cells --------------------------------------------------------- + + def test_empty_cells(self): + table = ( + "| 名 | 值 |\n" + "|---|----|\n" + "| A | |\n" + "| | B |" + ) + result = self._call(table) + self.assertIn("A", result) + self.assertIn("B", result) + + # -- single row table ---------------------------------------------------- + + def test_single_row_table(self): + table = ( + "| Key | Value |\n" + "|-----|-------|\n" + "| X | 42 |" + ) + result = self._call(table) + self.assertIn("Key:X", result) + self.assertIn("Value:42", result) + + +class TestBuildMarkdownPostRowsWithTables(unittest.TestCase): + """Integration test: tables go through _build_markdown_post_rows and + arrive as list-formatted md rows, not raw table syntax.""" + + def _call(self, content: str): + from gateway.platforms.feishu import _build_markdown_post_rows + return _build_markdown_post_rows(content) + + def test_table_converted_in_post_rows(self): + content = ( + "标题\n" + "\n" + "| 项目 | 值 |\n" + "|------|-----|\n" + "| 模型 | mimo-v2.5-pro |\n" + "| 状态 | 正常 |\n" + "\n" + "后续文字。" + ) + rows = self._call(content) + # Flatten all text from all rows + all_text = "\n".join( + element["text"] + for row in rows + for element in row + ) + # Table content should be present as list items + self.assertIn("mimo-v2.5-pro", all_text) + self.assertIn("正常", all_text) + self.assertIn("后续文字。", all_text) + # Raw table syntax should be gone + self.assertNotIn("| 项目 |", all_text) + self.assertNotIn("|------|", all_text) + + def test_no_table_content_passes_through(self): + content = "没有表格的内容,**粗体** 和 `代码`。" + rows = self._call(content) + all_text = "\n".join( + element["text"] + for row in rows + for element in row + ) + self.assertEqual(all_text, content) + + +if __name__ == "__main__": + unittest.main()