Skip to content

feat(feishu): use interactive card messages for markdown rendering - #21000

Open
vince-core wants to merge 1 commit into
NousResearch:mainfrom
vince-core:feat/feishu-interactive-card-markdown
Open

vince-core wants to merge 1 commit into
NousResearch:mainfrom
vince-core:feat/feishu-interactive-card-markdown

Conversation

@vince-core

@vince-core vince-core commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Switch Feishu message rendering from Post messages (msg_type: "post" with {tag: "md"} elements) to Interactive Card messages (msg_type: "interactive" with Card JSON 2.0 {tag: "markdown"} elements), enabling full markdown rendering including headings, tables, and blockquotes. Additionally, implement automatic card payload splitting when content exceeds the API size or table count limits.

Problem

  1. Markdown rendering limitations in Post messages: The Feishu Post message {tag: "md"} element has severely limited rendering:

    • ## Heading renders as raw text
    • | Table | not rendered at all (the old code had to force plain text fallback for any content with tables)
    • > Blockquote not rendered
    • Tables in responses caused blank messages, requiring a workaround that dropped all formatting
  2. Payload size limit: Feishu Card JSON 2.0 has an undocumented total payload size limit (~30KB). Messages with many tables or dense content silently fail when the assembled card payload exceeds this limit.

  3. Table count limit: Feishu Card JSON 2.0 has an undocumented per-card table count limit (≤5 tables). Cards with more tables are rejected with card table number over limit, and the original error regex did not match this message — causing unhandled exceptions that silently dropped messages.

Solution

Post → Interactive Card Migration

Replace the Post message approach (msg_type: "post", {tag: "md"} elements) with Interactive Card messages (msg_type: "interactive", Card JSON 2.0 schema, {tag: "markdown"} elements). The Card markdown renderer supports the full markdown spec:

Feature Post {tag: "md"} Card {tag: "markdown"}
Bold/Italic/Strikethrough
Inline code
Code blocks
Links
Ordered/unordered lists
Headings (# ## ###)
Tables
Blockquotes (>)

Auto-split Oversized Card Payloads

When the assembled card payload exceeds 28KB (safe threshold below the ~30KB API limit), elements are automatically partitioned into multiple independent cards and sent as separate messages. This is transparent to callers — cron jobs and agent responses no longer need to artificially limit table count or content length.

Table Count Aware Splitting & Smart Retry

  • Pre-check: Count tables in card elements before sending. If total exceeds _CARD_MAX_TABLES (5), split elements into multiple cards at table boundaries.
  • _explode_multi_table_elements: Break single elements containing multiple tables into one-table-per-element, enabling fine-grained distribution across cards.
  • _split_elements_into_cards: Respects both byte AND table count limits.
  • Smart retry: When API still rejects with card table number over limit (e.g. if Feishu lowers the limit), automatically halve max_tables and re-split + resend rather than immediately degrading to plain text.
  • Expanded error regex: _POST_CONTENT_INVALID_RE now matches card table number over limit, card .* over limit, and Failed to create card content — all triggering graceful fallback.
  • Only fall back to plain text as a last resort after split retry fails.

Changes

  • Switch _build_outbound_payload() from returning ("post", post_payload) to ("interactive", card_payload) for markdown content
  • Add Card JSON 2.0 structure: "schema": "2.0", elements under body.elements, {tag: "markdown"} element type
  • Add _CARD_PAYLOAD_MAX_BYTES = 28000 constant for payload byte limit
  • Add _CARD_MAX_TABLES = 5 constant for per-card table count limit
  • Add _CARD_TABLE_LIMIT_RE regex for table-specific error detection
  • Expand _POST_CONTENT_INVALID_RE to match additional card limit error messages
  • Add _assemble_card() helper for Card 2.0 structure assembly
  • Add _count_tables_in_element() to count markdown tables in an element
  • Add _explode_multi_table_elements() to split multi-table elements at table boundaries
  • Add _split_elements_into_cards() to greedily partition elements respecting both byte and table limits
  • Add _resplit_and_send_card() for smart retry with halved table limit on rejection
  • _build_markdown_card_payload() returns str | List[str] — single payload or multiple when content exceeds limits
  • _build_outbound_payload() returns tuple | List[tuple] for multi-card scenarios
  • send() iterates over multiple payloads, sending each as an independent message
  • edit_message() gracefully handles multi-card by using first card only (edit API limitation)
  • Update fallback logic in send/edit/retry to handle interactive type alongside post
  • Remove legacy table-to-plain-text workaround (tables now render natively in cards)

Testing

Manually verified on Feishu client (desktop + mobile):

  • ✅ Headings (#/##/###) render correctly with proper sizing
  • ✅ Tables render with proper alignment and borders
  • ✅ Blockquotes render with quote styling
  • ✅ Code blocks with syntax highlighting
  • ✅ Bold, italic, lists, links all work
  • ✅ Long messages split correctly across multiple elements
  • ✅ Oversized payloads (>28KB) auto-split into multiple card messages
  • ✅ Messages with >5 tables auto-split at table boundaries
  • ✅ Smart retry on table-limit rejection (halves limit and resends)
  • ✅ Each split card independently within size and table limits
  • ✅ Fallback to plain text still works when card fails
  • ✅ All 407 existing feishu tests pass

@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/feishu Feishu / Lark adapter labels May 7, 2026
@vince-core
vince-core force-pushed the feat/feishu-interactive-card-markdown branch 3 times, most recently from 7c263e6 to 6e75618 Compare May 7, 2026 04:41
@vince-core
vince-core force-pushed the feat/feishu-interactive-card-markdown branch 2 times, most recently from 3075bf3 to 5f8daf7 Compare May 23, 2026 07:41

@teknium1 teknium1 left a comment

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.

Thanks for tackling a real Feishu rendering gap: current main still routes table-bearing content to plain text in plugins/platforms/feishu/adapter.py:4524-4534.

Problems

  • The PR targets the pre-plugin path gateway/platforms/feishu.py; current main uses plugins/platforms/feishu/adapter.py, and GitHub marks the branch conflicting. This needs a deliberate port rather than a clean cherry-pick.
  • gateway/platforms/feishu.py:791-800 does not split a single over-limit paragraph, so the stated 3,800-character element cap is not guaranteed.
  • gateway/platforms/feishu.py:2142-2150 returns the final resplit response without checking success. A rejected sub-card is treated as a non-None retry result, contrary to the claimed fallback behavior.
  • gateway/platforms/feishu.py:2173-2176 updates only the first split payload, silently dropping the remaining content.
  • website/docs/user-guide/messaging/feishu.md:415-421 and the zh-Hans translation still document post/md routing and require an update.

Suggested changes

  • Port onto the current plugin adapter, enforce per-element splitting, validate every resplit response, and add coverage for size/table limits, failed resplits, and split edits.

Automated hermes-sweeper review.

Comment thread plugins/platforms/feishu/adapter.py Outdated

for para in paragraphs:
para_len = len(para) + 2 # +2 for \n\n separator
if current and current_len + para_len > max_chars:

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 only splits when current already has a paragraph. A single paragraph longer than _CARD_MD_ELEMENT_MAX_CHARS remains unsplit, so the advertised per-element limit is not enforced. Please split oversized individual paragraphs as well and add a boundary test.

Comment thread plugins/platforms/feishu/adapter.py Outdated
reply_to=reply_to,
metadata=metadata,
)
return last_response

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.

last_response can be a non-successful Feishu response. Returning it as a non-None resplit result makes the caller skip its fallback path; validate every sub-card response and surface failure explicitly.

Comment thread plugins/platforms/feishu/adapter.py Outdated
# split into multiple payloads, use only the first one (best effort;
# edit cannot create additional messages).
if isinstance(outbound, list):
msg_type, payload = outbound[0]

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.

Selecting only the first split payload silently truncates an edit whenever byte-based card splitting occurs. Please preserve the full content through a supported fallback or return an explicit unsupported-result failure rather than updating a partial message.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
@vince-core
vince-core force-pushed the feat/feishu-interactive-card-markdown branch from 5f8daf7 to c4fd95c Compare July 13, 2026 02:22
@vince-core

Copy link
Copy Markdown
Contributor Author

Update: Complete rewrite addressing all review feedback

Force-pushed with the following changes:

Core: Switched from legacy post-type md to Card JSON 2.0 (interactive msg_type). Now targets gateway/platforms/feishu.py (the correct upstream path).

Bug fixes from review:

  1. _split_by_paragraphs — hard-splits oversized single paragraphs at line/char boundaries
  2. _resplit_and_send_card — validates each sub-card response, returns None on failure for graceful fallback
  3. edit_message — sends remaining split payloads as follow-up messages instead of silently dropping
  4. _feishu_send_with_retry — skips retries for interactive content-invalid errors (fail fast)

Tests: 106/106 pass. 4 existing tests updated to expect "interactive" instead of "post".

@LuGIT2025

Copy link
Copy Markdown

First off — amazing work on this! 🎉 The Card JSON 2.0 native markdown approach for rendering tables and headings is exactly the right strategy. It's elegant, targeted, and avoids the overhead of converting all messages into interactive cards.

We had a similar attempt in #36925 but after studying your approach and the maintainer feedback, we've decided to close ours in favor of this PR. Your solution is clearly more mature and better aligned.

A couple of thoughts/suggestions:

  1. Auto-split behavior: When messages are very long and contain multiple tables (e.g., a detailed comparison response with 3-4 markdown tables), does the card rendering handle auto-splitting gracefully? Curious if there's a character/content limit per card message and how that's managed.

  2. Config option for all-card mode: Would it make sense to add an optional config toggle (e.g., feishu_card_format: "always" | "auto" | "never") so users who want all messages rendered as cards (not just table/heading messages) can opt in? This could be useful for teams that prefer the polished card look consistently.

  3. Fallback behavior: What happens if the Card JSON 2.0 API call fails? Does it gracefully fall back to the current plain-text markdown rendering?

Again, great work — looking forward to seeing this merged! 🚀

@vince-core

Copy link
Copy Markdown
Contributor Author

Thanks for the kind words @LuGIT2025, and glad the approach resonates! Let me address your questions:

1. Auto-split behavior for long multi-table messages:

Yes — this is fully handled. The implementation has a multi-layer split strategy:

  • Content exceeding 28KB payload size or 5 tables per card triggers _split_elements_into_cards, which distributes elements across multiple card messages.
  • _explode_multi_table_elements further breaks any single element containing multiple tables into one-table-per-element before splitting.
  • The send loop iterates over all resulting card payloads and sends them sequentially.

So a response with 3-4 markdown tables will render correctly — either in one card (if under limits) or split across cards automatically.

2. Config option for all-card mode:

Currently the behavior is effectively auto — any content with markdown features (bold, tables, code fences, headings, etc.) routes through Card JSON 2.0, while pure plain text stays as text type. This covers real-world usage well since plain text gains nothing from card rendering (and would add unnecessary card chrome).

That said, an explicit config toggle is a reasonable future enhancement if users request it. For now the auto-detection is comprehensive enough that I have not seen a case where it mis-routes.

3. Fallback behavior on Card API failure:

Three layers of fallback are implemented:

  1. If the API rejects for table-count-over-limit → _resplit_and_send_card re-splits with a halved table limit and retries.
  2. If resplit also fails → falls back to plain text type (full content preserved, just no formatting).
  3. If the API rejects for content-invalid → immediate fallback to plain text (no retry loop on a fundamentally bad payload).

Each sub-card in a resplit is individually validated — a rejected sub-card triggers the plain-text fallback rather than silently dropping content.

@vince-core
vince-core force-pushed the feat/feishu-interactive-card-markdown branch from bbea038 to f8ceae2 Compare July 21, 2026 02:16
@vince-core
vince-core force-pushed the feat/feishu-interactive-card-markdown branch from f8ceae2 to 5664fcb Compare July 31, 2026 07:53
…(Card JSON 2.0)

Replace Post msg_type with Interactive Card messages for outbound markdown
content. Card JSON 2.0 renders the full markdown spec — headings, tables,
blockquotes, code blocks — while Post {tag:'md'} elements only support a
subset.

Changes:
- Add Card JSON 2.0 helper functions: _assemble_card, _build_markdown_card_elements,
  _build_markdown_card_payload, _split_elements_into_cards, _explode_multi_table_elements
- Add constants: _CARD_MD_ELEMENT_MAX_CHARS, _CARD_PAYLOAD_MAX_BYTES, _CARD_MAX_TABLES,
  _CARD_TABLE_LIMIT_RE, _MARKDOWN_TABLE_RE
- Modify _build_outbound_payload to return interactive card instead of post
- Update send() with multi-card splitting and 3-tier fallback chain:
  interactive → post → plain text
- Update edit_message() with interactive → post → text fallback
- Extend _POST_CONTENT_INVALID_RE to match card-specific error patterns
- Add _resplit_and_send_card() for table-limit-aware card resplitting
- Update all related tests to assert interactive msg_type
@vince-core
vince-core force-pushed the feat/feishu-interactive-card-markdown branch from 5664fcb to 7c89e8c Compare September 9, 2026 08:43
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 sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants