Skip to content

fix(feishu): route tables and code blocks to CardKit 2.0 with post/text fallback#23861

Open
lqhl wants to merge 2 commits into
NousResearch:mainfrom
lqhl:fix/feishu-card-tables-codeblocks-v2
Open

fix(feishu): route tables and code blocks to CardKit 2.0 with post/text fallback#23861
lqhl wants to merge 2 commits into
NousResearch:mainfrom
lqhl:fix/feishu-card-tables-codeblocks-v2

Conversation

@lqhl

@lqhl lqhl commented May 11, 2026

Copy link
Copy Markdown

Summary

Routes GFM tables and multi-line fenced code blocks to CardKit 2.0 interactive messages in Feishu, with automatic fallback to plain text when card delivery fails.

Problem

Feishu's post/md tag has two rendering limitations:

  1. Tables — GFM table syntax (| col1 | col2 |) is silently dropped
  2. Code blocks — Fenced code blocks longer than ~6 lines are truncated

Solution

Three new regex detectors + CardKit 2.0 builder + failure fallback:

  • _TABLE_MARKDOWN_RE — detects GFM tables (pipe-separated rows + alignment separator)
  • _CODE_BLOCK_RE — detects fenced code blocks with 2+ content lines (short blocks stay in post/md)
  • _build_card_payload() — wraps content in a CardKit 2.0 card with tag: markdown element
  • _build_outbound_payload() — routes detected content to interactive msg_type

Fallback mechanism (not present in original PR #19038)

If the interactive card send fails (bot lacks card permission, API rejection, etc.), the message automatically falls back to plain text. Three layers of protection:

  1. Exception handler in send() — catches send failures, retries as plain text
  2. Response check in send() and edit_message() — catches API rejection without exception
  3. _feishu_send_with_retry() — interactive failures raise immediately (no retry loop)

Testing

  • 11 new unit tests covering table detection, code block detection, CardKit JSON format, regex boundary cases, and mixed content
  • 209/209 existing Feishu tests pass (no regressions)
  • Live-tested on a real Feishu bot (both tables and code blocks render correctly as CardKit 2.0 cards)

Changes

  • gateway/platforms/feishu.py: +62/-3 lines
  • tests/gateway/test_feishu.py: +114/-0 lines

Related

Cherry-pick of PR 19038 plus fallback mechanism:

- _TABLE_MARKDOWN_RE: detect GFM tables
- _CODE_BLOCK_RE: detect fenced code blocks with 2plus content lines
- _build_card_payload: CardKit 2.0 interactive card with tag:markdown
- _build_outbound_payload: route tables and long code blocks to interactive
- send() and edit_message(): fallback to plain text on interactive failure
- _feishu_send_with_retry(): fail fast on interactive errors

Fixes 19035 and 9549 (table and code block rendering in Feishu)
@lqhl

lqhl commented May 11, 2026

Copy link
Copy Markdown
Author

I've tested this PR with my feishu bot:
image

@liuhao1024

Copy link
Copy Markdown
Contributor

Minor: Comment/regex mismatch in _CODE_BLOCK_RE

The inline comment says "route 3+ content lines to CardKit 2.0" but the regex uses {2,} which matches 2+ content lines:

# Multi-line fenced code block (opening fence + 2+ content lines + closing fence)
# Empty or 1-line blocks render fine in post/md; route 3+ content lines to CardKit 2.0.
_CODE_BLOCK_RE = re.compile(r"^```[^\n]*\n(.*\n){2,}```", re.MULTILINE | re.DOTALL)

The test at line 217 confirms a 2-line block ("```\na\nb\n```") does match, so the regex behavior is {2,}. Either:

  • Change {2,}{3,} if the intent is truly 3+ content lines, or
  • Update the comment to say "2+ content lines"

Additionally, re.DOTALL is unnecessary here since \n is already explicit in the pattern. It makes .* match across newlines which adds backtracking complexity without changing the outcome on well-formed input. Consider dropping it.

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery platform/feishu Feishu / Lark adapter P2 Medium — degraded but workaround exists labels May 11, 2026
…drop redundant DOTALL

_CODE_BLOCK_RE uses {2,} to match 2+ content lines, but the comment claimed '3+ content lines'. Fix the comment to match the actual regex. Also remove re.DOTALL — the pattern already uses explicit \n, so DOTALL only adds unnecessary backtracking complexity.
@lqhl

lqhl commented May 12, 2026

Copy link
Copy Markdown
Author

@liuhao1024 Thanks for catching this! Fixed in 8a60abe:

  1. Comment updated: "3+ content lines" → "2+ content lines" to match the {2,} regex
  2. Removed re.DOTALL — as you noted, \n is already explicit so DOTALL only adds backtracking overhead

Both changes are comment/cosmetic — the regex behavior is unchanged, 209/209 feishu tests still pass.

@lqhl

lqhl commented May 18, 2026

Copy link
Copy Markdown
Author

@teknium1 hi, can you take a look?

huangyoje pushed a commit to huangyoje/hermes-agent that referenced this pull request Jun 6, 2026
…ards

Closes NousResearch#9549, NousResearch#19035. Supersedes NousResearch#23861.

## Problem

Feishu's post-type 'md' element does not render GFM tables and truncates
multi-line fenced code blocks. The current behaviour (force-text fallback,
PR NousResearch#20275) avoids the blank-message symptom but leaks raw markdown source
to the user — pipes, separators and code fences are visible as plain text.

## Approach

Route content containing GFM tables or multi-line fenced code blocks to
CardKit 2.0 interactive messages (schema: 2.0, tag: markdown), which
render both natively. Plain markdown without tables/code stays in post/md
as before.

Falls back to plain text when the interactive card is rejected (bot lacks
card permission, malformed payload, etc.) so failures degrade gracefully.

## ErrCode 11310: per-card table cap

CardKit 2.0 caps the total number of GFM tables across an entire card at
5. Exceeding this triggers ErrCode 11310 ('card table number over limit')
and the API rejects the whole card.

The cap is per-CARD, not per-element — verified empirically against the
live Feishu API on 2026-05-27. A single element with 6 tables fails. So
do two elements with 4+2 tables, three elements with 2+2+2, and a layout
with one table per element. Five tables in any layout pass.

When content has more than 5 tables, _build_outbound_messages() splits
it into multiple cards and send() iterates the resulting (msg_type,
payload) list. Splits cut at section boundaries — the first paragraph
break after the previous table block ends — so each table's heading and
lead-in prose travel into the same card as the table itself, rather than
being orphaned in the previous chunk.

## Tests

tests/gateway/test_feishu.py::TestCardTableLimitSplitting

- single card for ≤5 tables
- two cards for 6 tables (5 + 1)
- three cards for 12 tables (5 + 5 + 2)
- non-table content keeps post/text routing untouched
- prose between tables stays with the following table at split points

## Migration notes

- Bots without card-send permission will silently fall back to plain
  text (existing fallback path, extended for the interactive case).
- Edit_message remains on the single-payload path; streaming edits do
  not currently produce > 5 tables in a single chunk in practice.
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/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feishu] Code blocks cannot be expanded — only first ~2 lines visible [Feishu] Markdown tables not rendering in Feishu messages

3 participants