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
94 changes: 88 additions & 6 deletions gateway/platforms/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ def _coerce_required_int(value: Any, default: int, min_value: int = 0) -> int:


def _build_markdown_post_payload(content: str) -> str:
rows = _build_markdown_post_rows(content)
rows = _build_markdown_post_rows(_protect_markdown_tables_for_feishu_md(content))
return json.dumps(
{
"zh_cn": {
Expand All @@ -558,6 +558,92 @@ def _build_markdown_post_payload(content: str) -> str:
)


def _build_markdown_card_payload(content: str) -> str:
"""Build a Feishu Card JSON 2.0 payload for Markdown needing tables.

Feishu IM `post` messages use legacy rich-text `md` nodes, which do not
reliably render markdown tables. Card JSON 2.0's markdown component does
support tables, so table-bearing assistant replies are sent as interactive
cards while ordinary markdown continues to use post messages.
"""
card = {
"schema": "2.0",
"config": {
"update_multi": True,
"width_mode": "fill",
"enable_forward": True,
"summary": {"content": _strip_markdown_to_plain_text(content)[:120] or "Hermes"},
},
"body": {
"elements": [
{
"tag": "markdown",
"content": content,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sends the raw GFM table through a single card markdown element rather than converting rows into native CardKit table elements, so it does not implement the PR summary’s stated table-element path.

"text_align": "left",
"text_size": "normal",
}
]
},
}
return json.dumps(card, ensure_ascii=False)


def _protect_markdown_tables_for_feishu_md(content: str) -> str:
"""Wrap markdown tables in code fences before sending as Feishu post md.

Feishu post `md` nodes support most Markdown we emit (bold, headings,
lists, links, fences), but table blocks are a known sharp edge: clients can
render the whole post as blank or force callers to downgrade the entire
message to raw text. Preserve the table text as a fenced `text` block while
keeping the rest of the reply in renderable markdown.
"""
if not content or "|" not in content:
return content

lines = content.replace("\r\n", "\n").split("\n")
output: List[str] = []
i = 0
in_code_block = False

def _is_table_separator(line: str) -> bool:
stripped = line.strip()
if not (stripped.startswith("|") and stripped.endswith("|")):
return False
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells)

def _is_table_row(line: str) -> bool:
stripped = line.strip()
return stripped.startswith("|") and stripped.endswith("|") and stripped.count("|") >= 2

while i < len(lines):
line = lines[i]
if _MARKDOWN_FENCE_OPEN_RE.match(line.strip()) or _MARKDOWN_FENCE_CLOSE_RE.match(line.strip()):
in_code_block = not in_code_block
output.append(line)
i += 1
continue

if (
not in_code_block
and i + 1 < len(lines)
and _is_table_row(line)
and _is_table_separator(lines[i + 1])
):
table_lines = [line, lines[i + 1]]
i += 2
while i < len(lines) and _is_table_row(lines[i]):
table_lines.append(lines[i])
i += 1
output.extend(["```text", *table_lines, "```"])
continue

output.append(line)
i += 1

return "\n".join(output)


def _build_markdown_post_rows(content: str) -> List[List[Dict[str, str]]]:
"""Build Feishu post rows while isolating fenced code blocks.

Expand Down Expand Up @@ -4222,12 +4308,8 @@ 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)
return "interactive", _build_markdown_card_payload(content)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once table messages become interactive, they no longer hit the existing post-only fallback paths; add interactive → text fallback so a rejected card does not become a failed/no-message send.

if _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
Expand Down
75 changes: 75 additions & 0 deletions tests/gateway/test_feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -2766,6 +2766,81 @@ async def _direct(func, *args, **kwargs):
json.dumps({"text": "可以用 粗体 和 斜体。"}, ensure_ascii=False),
)

@patch.dict(os.environ, {}, clear=True)
def test_send_uses_card_json_2_for_markdown_tables(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter

adapter = FeishuAdapter(PlatformConfig())
captured = {}

class _MessageAPI:
def create(self, request):
captured["request"] = request
return SimpleNamespace(
success=lambda: True,
data=SimpleNamespace(message_id="om_markdown_table"),
)

adapter._client = SimpleNamespace(
im=SimpleNamespace(
v1=SimpleNamespace(
message=_MessageAPI(),
)
)
)

async def _direct(func, *args, **kwargs):
return func(*args, **kwargs)

content = (
"**说明**\n\n"
"| 类型 | 作用 |\n"
"|---|---|\n"
"| session | 聊天档案 |\n"
"\n## 下一节"
)
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
result = asyncio.run(adapter.send(chat_id="oc_chat", content=content))

self.assertTrue(result.success)
self.assertEqual(captured["request"].request_body.msg_type, "interactive")
payload = json.loads(captured["request"].request_body.content)
self.assertEqual(payload["schema"], "2.0")
self.assertEqual(payload["config"]["width_mode"], "fill")
self.assertEqual(
payload["body"]["elements"],
[
{
"tag": "markdown",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test locks in a card markdown element, but the PR claims CardKit table elements; if native table rendering is the intended fix, assert a table component and parsed rows instead.

"content": content,
"text_align": "left",
"text_size": "normal",
}
],
)

@patch.dict(os.environ, {}, clear=True)
def test_markdown_table_inside_existing_code_fence_is_not_rewrapped(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter

adapter = FeishuAdapter(PlatformConfig())
payload = json.loads(
adapter._build_post_payload(
"before\n```text\n| a | b |\n|---|---|\n| 1 | 2 |\n```\nafter"
)
)

self.assertEqual(
payload["zh_cn"]["content"],
[
[{"tag": "md", "text": "before"}],
[{"tag": "md", "text": "```text\n| a | b |\n|---|---|\n| 1 | 2 |\n```"}],
[{"tag": "md", "text": "after"}],
],
)

@patch.dict(os.environ, {}, clear=True)
def test_send_uses_post_for_advanced_markdown_lines(self):
from gateway.config import PlatformConfig
Expand Down