Skip to content

fix(feishu): render markdown tables via card v2 table component#12114

Open
lintingbin wants to merge 2 commits into
NousResearch:mainfrom
lintingbin:fix/feishu-render-tables-via-card-v2
Open

fix(feishu): render markdown tables via card v2 table component#12114
lintingbin wants to merge 2 commits into
NousResearch:mainfrom
lintingbin:fix/feishu-render-tables-via-card-v2

Conversation

@lintingbin

@lintingbin lintingbin commented Apr 18, 2026

Copy link
Copy Markdown

Summary

When Hermes sends a response containing a markdown table to Feishu/Lark, the table rows arrive missing — Feishu's post md and card markdown grammars both silently drop GFM pipe-table syntax, and the same grammars also render <br> tags and ATX headings as literal text. The result is a message where the data the user asked for simply isn't there.

This PR routes any content that contains a GFM table to a card JSON v2 payload whose native table component renders tabular data properly. Each GFM table is parsed and converted to a typed table element (text columns + per-row cells); the surrounding markdown is emitted as markdown elements in the same card.

For non-table markdown, the existing post md path is kept, but with two small fix-ups so the content no longer shows literal junk:

  • ATX headings (# foo, ## foo, …) are rewritten as **foo** bold lines (Feishu md does not render any heading levels).
  • <br> / <br/> HTML tags are replaced with real newlines (Feishu md does not render HTML line-break tags).

See create_json for the supported post-md grammar.

Before

### Income in the last 7 days
| Date       | Week | Income   |
| ---------- | ---- | ----------: |
| 2026-04-11 | Saturday | $10  |
| 2026-04-12 | Sunday | $11  |

Arrives in Feishu as ### Income in the last 7 days (literal ###, no heading), with the table rows stripped entirely.

After

The same input arrives as a card with:

  • a markdown element containing **Income in the last 7 days**
  • a native Feishu table (sortable, paginated) with the 3 columns and 2 rows

What's covered

  • _split_markdown_for_feishu(content) – parses content into ordered text / table segments.
  • _build_feishu_table_element(headers, rows) – emits a card v2 table component (data_type: "text" cells; inline **bold** / `code` / links survive verbatim).
  • _build_markdown_card_payload(content) – builds the full card (schema: "2.0", body.elements).
  • _transform_text_for_feishu(text) – rewrites headings → bold, <br>\n.
  • _build_outbound_payload – routes to interactive when tables are present, otherwise the existing post / text paths.

Test plan

  • pytest tests/gateway/test_feishu.py — 109 passed (no regressions). New cases:
    • test_transform_text_for_feishu_rewrites_headings_and_br
    • test_build_outbound_payload_routes_tables_to_interactive_card
    • test_build_outbound_payload_keeps_non_table_markdown_on_post
    • test_card_splits_text_and_tables_in_order
    • test_card_table_preserves_inline_markdown_in_cells
    • test_send_uses_interactive_card_for_table_markdown
  • test_build_post_payload_extracts_title_and_links updated to expect **Title** instead of # Title (the new heading transform).
  • Manual verification in a Feishu chat: heading + table + bullets renders as a card with a real table and bold heading (no literal #, no literal <br>, no rows dropped).

Feishu's post `md` and card `markdown` grammars silently drop GFM pipe
tables, so a response containing a markdown table previously arrived as
a message with the table rows missing. The same grammars do not render
ATX headings or `<br>` tags either — those show as literal text.

Route content that contains a GFM table to an interactive card JSON v2
payload. Each table is parsed and emitted as a native `table` component
(with typed text columns and per-row cells); surrounding markdown rides
along as `markdown` elements. For non-table markdown, the existing post
`md` path is kept but headings are rewritten as `**bold**` lines and
stray `<br>` tags are replaced with real newlines so emphasis and line
breaks survive rendering.

Refs Feishu docs:
  - https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/card-json-v2-components/content-components/table
  - https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json
Card v2 table cells default to data_type="text" which renders the cell
verbatim — so `**13,316**` arrived with literal asterisks instead of
bold. Detect inline markdown (bold/italic/code/link) per column and
promote affected columns to data_type="lark_md" so Feishu renders the
formatting. Plain-text columns keep the safer "text" type.
@hellojackyleon

Copy link
Copy Markdown

+1 for this approach. We've been running a local workaround (disabling the forced text fallback) but native Card v2 table rendering is clearly the better long-term fix. The unified handling of text segments + table segments, plus the heading/br fix-ups, makes this the most complete solution among the competing PRs. Happy to help test if needed.
— hellojackyleon

@Crazy-FuQing

Copy link
Copy Markdown

We took a complementary approach based on OpenClaw's @larksuite/openclaw-lark (PR #27990):

  • Instead of parsing tables into native table components, we put markdown inside a card markdown element
  • This handles both code blocks AND tables in one mechanism
  • Added _optimize_feishu_markdown() for heading downgrade and spacing
  • Triple fallback: interactive → post → text
  • 27 new tests, all 198 existing pass

Your native table approach is more powerful (sortable, paginated). Ideally the two could be combined: card markdown for simple cases, native table when sorting/pagination is needed.

PR #27990: #27990

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/feishu Feishu / Lark adapter type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants