feat(feishu): use interactive card messages for markdown rendering - #21000
vince-core wants to merge 1 commit into
Conversation
7c263e6 to
6e75618
Compare
3075bf3 to
5f8daf7
Compare
teknium1
left a comment
There was a problem hiding this comment.
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 usesplugins/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-800does not split a single over-limit paragraph, so the stated 3,800-character element cap is not guaranteed.gateway/platforms/feishu.py:2142-2150returns the final resplit response without checking success. A rejected sub-card is treated as a non-Noneretry result, contrary to the claimed fallback behavior.gateway/platforms/feishu.py:2173-2176updates only the first split payload, silently dropping the remaining content.website/docs/user-guide/messaging/feishu.md:415-421and the zh-Hans translation still documentpost/mdrouting 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.
|
|
||
| for para in paragraphs: | ||
| para_len = len(para) + 2 # +2 for \n\n separator | ||
| if current and current_len + para_len > max_chars: |
There was a problem hiding this comment.
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.
| reply_to=reply_to, | ||
| metadata=metadata, | ||
| ) | ||
| return last_response |
There was a problem hiding this comment.
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.
| # 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] |
There was a problem hiding this comment.
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.
5f8daf7 to
c4fd95c
Compare
Update: Complete rewrite addressing all review feedbackForce-pushed with the following changes: Core: Switched from legacy Bug fixes from review:
Tests: 106/106 pass. 4 existing tests updated to expect "interactive" instead of "post". |
c4fd95c to
bbea038
Compare
|
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:
Again, great work — looking forward to seeing this merged! 🚀 |
|
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:
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 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:
Each sub-card in a resplit is individually validated — a rejected sub-card triggers the plain-text fallback rather than silently dropping content. |
bbea038 to
f8ceae2
Compare
f8ceae2 to
5664fcb
Compare
…(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
5664fcb to
7c89e8c
Compare
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
Markdown rendering limitations in Post messages: The Feishu Post message
{tag: "md"}element has severely limited rendering:## Headingrenders as raw text| Table |not rendered at all (the old code had to force plain text fallback for any content with tables)> Blockquotenot renderedPayload 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.
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:{tag: "md"}{tag: "markdown"}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
_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.card table number over limit(e.g. if Feishu lowers the limit), automatically halvemax_tablesand re-split + resend rather than immediately degrading to plain text._POST_CONTENT_INVALID_REnow matchescard table number over limit,card .* over limit, andFailed to create card content— all triggering graceful fallback.Changes
_build_outbound_payload()from returning("post", post_payload)to("interactive", card_payload)for markdown content"schema": "2.0", elements underbody.elements,{tag: "markdown"}element type_CARD_PAYLOAD_MAX_BYTES = 28000constant for payload byte limit_CARD_MAX_TABLES = 5constant for per-card table count limit_CARD_TABLE_LIMIT_REregex for table-specific error detection_POST_CONTENT_INVALID_REto match additional card limit error messages_assemble_card()helper for Card 2.0 structure assembly_count_tables_in_element()to count markdown tables in an element_explode_multi_table_elements()to split multi-table elements at table boundaries_split_elements_into_cards()to greedily partition elements respecting both byte and table limits_resplit_and_send_card()for smart retry with halved table limit on rejection_build_markdown_card_payload()returnsstr | List[str]— single payload or multiple when content exceeds limits_build_outbound_payload()returnstuple | List[tuple]for multi-card scenariossend()iterates over multiple payloads, sending each as an independent messageedit_message()gracefully handles multi-card by using first card only (edit API limitation)interactivetype alongsidepostTesting
Manually verified on Feishu client (desktop + mobile):