Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 82 additions & 4 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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}]]

Expand Down
168 changes: 168 additions & 0 deletions tests/gateway/test_feishu_table_conversion.py
Original file line number Diff line number Diff line change
@@ -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()