Skip to content

[feishu] Unified fix for markdown rendering: inbound escaping + GFM table → Card 2.0 #27469

@TshyGO

Description

@TshyGO

Summary

The Feishu adapter has three interrelated rendering problems that degrade message quality for all Feishu users. This issue consolidates the root cause analysis and proposes a unified fix.

Current Problems

1. Inbound markdown escaping corrupts agent context

_render_text_element calls _escape_markdown_text on inbound post messages, which escapes every markdown special character (\, `, *, _, {, }, [, ], (, ), #, +, -, !, |, >, ~). The escaped text enters the agent's context window, and when the agent echoes or paraphrases user content, the backslash-escapes survive into the outbound reply. Feishu's md renderer then displays \\*\\*bold\\*\\* literally instead of as bold text.

Key insight: The outbound path itself does not escape — _build_markdown_post_rows passes content through untouched. The corruption happens entirely on the inbound side.

2. GFM tables silently dropped

Feishu's post-type md tag does not support GFM pipe table syntax. The current workaround in _build_outbound_payload detects tables via _MARKDOWN_TABLE_RE and forces them to msg_type=text, which renders as raw | col | col | garbage. This was a reasonable workaround for older SDK versions but is now strictly worse than the original problem.

3. No native interactive card support

Feishu supports rich interactive cards (Card 2.0) with native table elements that render beautifully with column alignment, pagination, and structured layouts. The Hermes adapter already uses interactive cards for approval workflows (send_exec_approval), but the general message path never uses them.

Related Issues

Related PRs

Multiple PRs have attempted to fix the table rendering independently: #12114, #20028, #20152, #22272, #22316, #24249, #25453, #26429, #27046, #27328. The proliferation suggests strong community demand but also creates review burden.

Proposed Solution

Phase 1 — Fix inbound escaping (low risk, high impact)

In _render_text_element: stop applying _escape_markdown_text to text from inbound post messages. Feishu's structured post format already separates styled elements (bold, code, etc.) from plain text. The existing style-wrapping logic re-applies correct markdown syntax, so blanket escaping is redundant and harmful.

Phase 2 — GFM table → Interactive Card (medium effort, high impact)

Approach: When outbound content contains a GFM table, parse it into a Feishu Card 2.0 payload with native table elements.

Implementation sketch:

def _build_outbound_payload(self, content: str) -> tuple[str, str]:
    # Phase 2: table → interactive card
    if _MARKDOWN_TABLE_RE.search(content):
        try:
            card_json = _build_table_card_payload(content)
            return "interactive", card_json
        except Exception:
            logger.debug("[Feishu] Card table build failed, falling back to post")
    
    # Existing logic (unchanged)
    if _MARKDOWN_HINT_RE.search(content):
        return "post", _build_markdown_post_payload(content)
    return "text", json.dumps({"text": content}, ensure_ascii=False)

Card structure:

{
  "config": {"wide_screen_mode": true},
  "elements": [
    {"tag": "markdown", "text": "Prose before table..."},
    {
      "tag": "table",
      "page_size": 20,
      "columns": [
        {"name": "col_1", "display_name": "Platform", "data_type": "text", "horizontal_align": "Left"},
        {"name": "col_2", "display_name": "Price", "data_type": "text", "horizontal_align": "Right"}
      ],
      "rows": [
        {"col_1": "京东", "col_2": "¥3,040"},
        {"col_1": "拼多多", "col_2": "¥2,999"}
      ]
    },
    {"tag": "markdown", "text": "Prose after table..."}
  ]
}

Key design decisions:

  1. Mixed content handling — Split content at table boundaries, interleave markdown + table elements inside one card
  2. Multi-table pagination — When >5 tables, split into multiple cards
  3. Heading normalization# Title**Title** (Feishu md tag has inconsistent heading support)
  4. Fallback chain preserved — Card → post → text (existing degradation behavior)
  5. No new dependencies — Pure JSON construction, no external packages

Reference implementation: QwenPaw (agentscope-ai/QwenPaw) already implements this successfully via _parse_md_table / _build_elements / _split_elements. The approach can be adapted rather than invented from scratch.

Phase 3 — Streaming card updates (future, optional)

Feishu's CardKit supports streaming updates via patch API. This would enable progressive rendering during long agent responses, similar to what @Cheerwhy/hermes-lark-streaming achieves as an external plugin.

Testing

  • Messages with single table → should render as interactive card with formatted table
  • Messages with mixed prose + table → card with interleaved markdown and table elements
  • Messages with >5 tables → split into multiple cards
  • Messages with only inline markdown (bold, code) → existing post/text behavior unchanged
  • Messages with headings → headings converted to bold before sending
  • All fallback paths → card failure degrades to post, post failure degrades to text

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/gatewayGateway runner, session dispatch, deliveryplatform/feishuFeishu / Lark adaptertype/featureNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions