feat(feishu): enhance interactive card parsing to extract markdown content#42218
feat(feishu): enhance interactive card parsing to extract markdown content#42218just2gooo wants to merge 1 commit intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR adds a comprehensive Feishu interactive card parser ( Key changes:
Minor issue found:
Confidence Score: 4/5
Last reviewed commit: 8c028f0 |
| } | ||
|
|
||
| case "heading": { | ||
| const level = (obj.level as number) || 1; |
There was a problem hiding this comment.
Heading level always defaults to 1
The code reads level from obj.level, but the Feishu card JSON stores it in obj.property.level (as shown in the fixture). Since obj.level is always undefined, every heading falls back to level 1 and renders as #, regardless of the actual heading depth in the input.
The test fixture encodes this buggy behavior — all expected headings are # even when the source JSON has "level": 2 or "level": 3.
Fix by checking the property object as well, consistent with how getContent already handles property fallbacks:
| const level = (obj.level as number) || 1; | |
| const level = (obj.level as number) || ((obj.property as Record<string, unknown>)?.level as number) || 1; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/card-parser.ts
Line: 91
Comment:
**Heading level always defaults to 1**
The code reads `level` from `obj.level`, but the Feishu card JSON stores it in `obj.property.level` (as shown in the fixture). Since `obj.level` is always undefined, every heading falls back to level `1` and renders as `#`, regardless of the actual heading depth in the input.
The test fixture encodes this buggy behavior — all expected headings are `#` even when the source JSON has `"level": 2` or `"level": 3`.
Fix by checking the `property` object as well, consistent with how `getContent` already handles property fallbacks:
```suggestion
const level = (obj.level as number) || ((obj.property as Record<string, unknown>)?.level as number) || 1;
```
How can I resolve this? If you propose a fix, please make it concise.| case "button": { | ||
| const property = obj.property as Record<string, unknown> | undefined; | ||
| const text = property?.text; | ||
| const actions = property?.actions as CardElement[] | undefined; | ||
| if (actions && Array.isArray(actions)) { | ||
| return actions | ||
| .map((ia) => { | ||
| const iaObj = ia as Record<string, unknown>; | ||
| const url = (iaObj.action as Record<string, unknown>)?.url || iaObj.url; | ||
| const textObj = text as Record<string, unknown> | undefined; | ||
| const textProp = textObj?.property as CardElement | undefined; | ||
| const btnText = | ||
| typeof text === "string" | ||
| ? text | ||
| : (textProp?.content as string) || (textObj?.content as string) || "button"; | ||
| return url ? `[${btnText}](${url})` : btnText; | ||
| }) | ||
| .join(" "); | ||
| } | ||
| return ""; | ||
| } |
There was a problem hiding this comment.
Button text silently dropped when no actions array
The button handler only produces output when an actions array exists. If a button has a text property but no actions, the label is completely lost and an empty string is returned. The btnText variable is computed inside the actions loop, so it's only used when actions are present.
Consider falling back to render button text alone when no actions are available:
| case "button": { | |
| const property = obj.property as Record<string, unknown> | undefined; | |
| const text = property?.text; | |
| const actions = property?.actions as CardElement[] | undefined; | |
| if (actions && Array.isArray(actions)) { | |
| return actions | |
| .map((ia) => { | |
| const iaObj = ia as Record<string, unknown>; | |
| const url = (iaObj.action as Record<string, unknown>)?.url || iaObj.url; | |
| const textObj = text as Record<string, unknown> | undefined; | |
| const textProp = textObj?.property as CardElement | undefined; | |
| const btnText = | |
| typeof text === "string" | |
| ? text | |
| : (textProp?.content as string) || (textObj?.content as string) || "button"; | |
| return url ? `[${btnText}](${url})` : btnText; | |
| }) | |
| .join(" "); | |
| } | |
| return ""; | |
| } | |
| } | |
| // Fallback: render button text without a URL | |
| const textObj = text as Record<string, unknown> | undefined; | |
| const textProp = textObj?.property as CardElement | undefined; | |
| const btnText = | |
| typeof text === "string" | |
| ? text | |
| : (textProp?.content as string) || (textObj?.content as string) || ""; | |
| return btnText; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/card-parser.ts
Line: 142-162
Comment:
**Button text silently dropped when no `actions` array**
The `button` handler only produces output when an `actions` array exists. If a button has a `text` property but no `actions`, the label is completely lost and an empty string is returned. The `btnText` variable is computed inside the actions loop, so it's only used when actions are present.
Consider falling back to render button text alone when no actions are available:
```suggestion
}
// Fallback: render button text without a URL
const textObj = text as Record<string, unknown> | undefined;
const textProp = textObj?.property as CardElement | undefined;
const btnText =
typeof text === "string"
? text
: (textProp?.content as string) || (textObj?.content as string) || "";
return btnText;
```
How can I resolve this? If you propose a fix, please make it concise.| case "code_block": { | ||
| const property = obj.property as Record<string, unknown> | undefined; | ||
| const contents = property?.contents as Array<Record<string, unknown>> | undefined; | ||
| if (contents && Array.isArray(contents)) { | ||
| const text = contents | ||
| .map((c) => { | ||
| const inner = (c.contents as unknown) || c; | ||
| if (Array.isArray(inner)) { | ||
| return inner.map((x) => (x as Record<string, unknown>).content || "").join(""); | ||
| } | ||
| return ((inner as Record<string, unknown>).content as string) || ""; | ||
| }) | ||
| .join(""); | ||
| return `\n\`\`\`\n${text}\n\`\`\`\n`; | ||
| } | ||
| return ""; | ||
| } |
There was a problem hiding this comment.
Code block language identifier not included
The code_block handler ignores the language field from the card JSON (e.g., "language": "javascript"). The test fixture contains code blocks with language: "bash" and language: "javascript", but the output always renders as bare ``` without any language hint, losing syntax highlighting in markdown renderers.
Include the language identifier in the fenced code block:
| case "code_block": { | |
| const property = obj.property as Record<string, unknown> | undefined; | |
| const contents = property?.contents as Array<Record<string, unknown>> | undefined; | |
| if (contents && Array.isArray(contents)) { | |
| const text = contents | |
| .map((c) => { | |
| const inner = (c.contents as unknown) || c; | |
| if (Array.isArray(inner)) { | |
| return inner.map((x) => (x as Record<string, unknown>).content || "").join(""); | |
| } | |
| return ((inner as Record<string, unknown>).content as string) || ""; | |
| }) | |
| .join(""); | |
| return `\n\`\`\`\n${text}\n\`\`\`\n`; | |
| } | |
| return ""; | |
| } | |
| return `\n\`\`\`${(property?.language as string) || ""}\n${text}\n\`\`\`\n`; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/card-parser.ts
Line: 219-235
Comment:
**Code block language identifier not included**
The `code_block` handler ignores the `language` field from the card JSON (e.g., `"language": "javascript"`). The test fixture contains code blocks with `language: "bash"` and `language: "javascript"`, but the output always renders as bare ` ``` ` without any language hint, losing syntax highlighting in markdown renderers.
Include the language identifier in the fenced code block:
```suggestion
return `\n\`\`\`${(property?.language as string) || ""}\n${text}\n\`\`\`\n`;
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 95ffdce76e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const level = (obj.level as number) || 1; | ||
| const content = getContent(obj); | ||
| if (content) return `${"#".repeat(level)} ${content}\n`; |
There was a problem hiding this comment.
Read heading level from the element property
Feishu heading nodes in raw card payloads carry their level under property.level (the new fixture uses that shape), but this parser reads obj.level, so every heading without a top-level level is rendered as H1. That flattens section hierarchy in quoted card content and degrades the structure the agent receives from multi-level docs/cards.
Useful? React with 👍 / 👎.
| try { | ||
| if (!obj.tag && (obj.body || obj.header)) { | ||
| const parts: string[] = []; | ||
| if (obj.header) parts.push(parseFeishuCardToMarkdown(obj.header as CardElement, depth + 1)); |
There was a problem hiding this comment.
Parse top-level card headers before generic fallback
Top-level Feishu card headers are commonly objects like { header: { title: { tag: "plain_text", content: "..." } } }, but this path sends obj.header back into the generic parser, which has no title handling for untagged header objects and emits an [unknown: ...] block instead of the title text. As a result, quoted interactive cards with headers lose their primary heading content.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0f4378d120
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const title = | ||
| (obj.title as string) || ((obj.property as Record<string, unknown>)?.title as string); | ||
| return title ? `# ${parseFeishuCardToMarkdown({ content: title }, depth + 1)}\n` : ""; |
There was a problem hiding this comment.
Parse object titles in card_header nodes
card_header title fields are often rich-text objects (for example plain_text nodes), but this code coerces obj.title / obj.property.title to a string and then re-parses that value. When the title is an object, the output becomes # [object Object] instead of the real header text, so quoted interactive cards lose their primary heading content.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8a6a3892f8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!obj.tag && (obj.body || obj.header)) { | ||
| const parts: string[] = []; | ||
| if (obj.header) parts.push(parseFeishuCardToMarkdown(obj.header as CardElement, depth + 1)); |
There was a problem hiding this comment.
Parse top-level header title instead of recursing generically
When a raw card includes a top-level header object like { "header": { "title": { "tag": "plain_text", "content": "..." } } }, this branch recurses into obj.header as an untagged object, which falls through to the default formatter and emits [unknown: ...] instead of the actual title text. In quoted interactive cards that use this common header shape, the primary heading is lost, so the parsed markdown omits key context.
Useful? React with 👍 / 👎.
3d4a143 to
d0aec1c
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d0aec1c735
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| try { | ||
| if (!obj.tag && (obj.body || obj.header)) { | ||
| const parts: string[] = []; | ||
| if (obj.header) parts.push(parseFeishuCardToMarkdown(obj.header as CardElement, depth + 1)); |
There was a problem hiding this comment.
Handle top-level header.title objects explicitly
When the card root has a header object (for example the shape produced by extensions/feishu/src/streaming-card.ts with header.title.tag = "plain_text"), this line recurses into the untagged header object and falls through to the unknown formatter, yielding [unknown: ...] instead of the actual title text. As a result, quoted interactive cards lose their primary heading context even though the title is present in payload.
Useful? React with 👍 / 👎.
| if (content) return content; | ||
| const elems = getElements(obj); | ||
| if (elems) { | ||
| return elems.map((e) => parseFeishuCardToMarkdown(e, depth + 1)).join(""); |
There was a problem hiding this comment.
Avoid concatenating block children without separators
For markdown nodes that carry rich children in property.elements, this joins every child with "", so block-level nodes (for example blockquote, which exists in test/fixtures/feishu-card-parser/card-input.json) get glued to adjacent paragraphs (API.Note in the fixture output) instead of being separated as distinct blocks. This degrades readability and loses card structure in quoted content.
Useful? React with 👍 / 👎.
80b8030 to
212348f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 212348fbff
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| "| " + | ||
| columns | ||
| .map((_, idx) => { | ||
| const cell = row[idx.toString()] as Record<string, unknown> | undefined; |
There was a problem hiding this comment.
Read table cells using declared column names
This parser indexes each row with idx.toString() instead of the column’s declared key, so any table whose columns[].name values are not "0", "1", etc. will render blank cells even though row data exists. In cards that use semantic column keys, quoted table content loses most of its data.
Useful? React with 👍 / 👎.
| ((obj.property as Record<string, unknown>)?.url as string); | ||
| const text = | ||
| (obj.text as string) || | ||
| ((obj.property as Record<string, unknown>)?.text as string) || | ||
| "link"; |
There was a problem hiding this comment.
Normalize object fields in action_link nodes
The action_link branch assumes url and text are strings, so payloads that provide structured values (for example property.url as { url: "..." } or rich-text text) are interpolated as [object Object] and produce broken markdown links. This causes quoted cards with structured action links to lose readable/clickable link output.
Useful? React with 👍 / 👎.
212348f to
0beb8ec
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0beb8ec6ac
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return actions | ||
| .map((ia) => { | ||
| const iaObj = ia as Record<string, unknown>; | ||
| const url = (iaObj.action as Record<string, unknown>)?.url || iaObj.url; |
There was a problem hiding this comment.
Normalize object URLs before rendering button links
When a button action carries a structured URL (for example { "url": "https://..." }, which is a shape already handled in the link branch), this code stores the whole object in url and interpolates it directly, producing markdown like [Go]([object Object]) instead of a clickable link. This breaks quoted card output for cards whose button action URL is object-typed rather than a plain string.
Useful? React with 👍 / 👎.
…ntent - Add card-parser.ts for parsing Feishu card JSON to markdown - Support 15+ element types: heading, list, table, code_block, link, button, etc. - Add card_msg_content_type=raw_card_content param when fetching quoted messages - Add blockquote support with > prefix - Add unit tests with test fixtures - Exclude test/fixtures/ from oxfmt formatting
0beb8ec to
8c028f0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c028f0119
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| function cleanMarkdown(text: string): string { | ||
| if (typeof text !== "string") return ""; | ||
| return text.replace(/\n{3,}/g, "\n\n").trim(); |
There was a problem hiding this comment.
Preserve blank lines when normalizing parsed markdown
The global text.replace(/\n{3,}/g, "\n\n") normalization also runs inside fenced code blocks, so cards containing code with intentional multiple blank lines are silently rewritten (e.g. line1\n\n\nline2 becomes line1\n\nline2). This loses fidelity of quoted interactive card content and can change the meaning/readability of code snippets users are replying to.
Useful? React with 👍 / 👎.
Addressing Bot Review FeedbackAll review suggestions from @greptile-apps and @chatgpt-codex-connector have been reviewed. Fixed in latest commit
Acknowledged (acceptable for current scope)
|
|
To use Codex here, create a Codex account and connect to github. |
Addressing Bot Review FeedbackAll review suggestions from @greptile-apps and @chatgpt-codex-connector have been reviewed. Fixed in latest commit
Acknowledged (acceptable for current scope)
|
|
To use Codex here, create a Codex account and connect to github. |
| const elems = getElements(obj); | ||
| if (elems) { | ||
| const inner = elems.map((e) => parseFeishuCardToMarkdown(e, depth + 1)).join(""); | ||
| return ( | ||
| "\n" + | ||
| inner | ||
| .split("\n") | ||
| .map((line) => `> ${line}`) | ||
| .join("\n") + | ||
| "\n" | ||
| ); | ||
| } | ||
| return ""; | ||
| } |
There was a problem hiding this comment.
Blockquote prefixes blank lines with > (trailing space)
When an inner element produces content that ends in a newline (e.g., headings return "# text\n\n", code blocks, or hr elements), the .split("\n") produces trailing empty strings that each get > prepended. This results in lines like > (blockquote marker with a trailing space) in the rendered output, which is technically valid CommonMark but clutters the output and may be visually odd in raw markdown views.
Consider filtering empty lines before prefixing, or trimming trailing newlines from inner before splitting:
| const elems = getElements(obj); | |
| if (elems) { | |
| const inner = elems.map((e) => parseFeishuCardToMarkdown(e, depth + 1)).join(""); | |
| return ( | |
| "\n" + | |
| inner | |
| .split("\n") | |
| .map((line) => `> ${line}`) | |
| .join("\n") + | |
| "\n" | |
| ); | |
| } | |
| return ""; | |
| } | |
| case "blockquote": { | |
| const elems = getElements(obj); | |
| if (elems) { | |
| const inner = elems.map((e) => parseFeishuCardToMarkdown(e, depth + 1)).join(""); | |
| return ( | |
| "\n" + | |
| inner | |
| .trimEnd() | |
| .split("\n") | |
| .map((line) => `> ${line}`) | |
| .join("\n") + | |
| "\n" | |
| ); | |
| } | |
| return ""; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/card-parser.ts
Line: 143-156
Comment:
**Blockquote prefixes blank lines with `> ` (trailing space)**
When an inner element produces content that ends in a newline (e.g., headings return `"# text\n\n"`, code blocks, or `hr` elements), the `.split("\n")` produces trailing empty strings that each get `> ` prepended. This results in lines like `> ` (blockquote marker with a trailing space) in the rendered output, which is technically valid CommonMark but clutters the output and may be visually odd in raw markdown views.
Consider filtering empty lines before prefixing, or trimming trailing newlines from `inner` before splitting:
```suggestion
case "blockquote": {
const elems = getElements(obj);
if (elems) {
const inner = elems.map((e) => parseFeishuCardToMarkdown(e, depth + 1)).join("");
return (
"\n" +
inner
.trimEnd()
.split("\n")
.map((line) => `> ${line}`)
.join("\n") +
"\n"
);
}
return "";
}
```
How can I resolve this? If you propose a fix, please make it concise.|
This pull request has been automatically marked as stale due to inactivity. |
|
Closing this as duplicate or superseded after Codex automated review. Close this PR as superseded. The focused quoted/replied Feishu interactive-card extraction was merged in #72397, while the broader raw-card/full inbound parser scope remains tracked by #41609 and #56795. Best possible solution: Close this PR as superseded. Keep the merged #72397 implementation for the focused quoted/replied-card extraction, continue broader inbound/raw-card parsing review in #41609 and #56795, and reintroduce richer markdown element support only as a narrower Feishu parser patch with focused fixtures and RawBody compatibility review. What I checked:
So I’m closing this here and keeping the remaining discussion on the canonical linked item. Codex Review notes: model gpt-5.5, reasoning high; reviewed against 91e835ebe0ab; fix evidence: commit c6cf37068cae. |
Summary
divandmarkdowntags, returning[unknown: ...]for complex cards.card-parser.tssupporting 15+ element types (heading, list, table, code_block, code_span, link, button, action, action_link, blockquote, card_header, div, hr, br, etc.)card_msg_content_type=raw_card_contentAPI parameter to fetch raw card JSONproperty.levelactionsarray>prefixcard_headerwith both object and string title formatsThe example intercative card:

Markdown content parsed please see in test/fixtures/feishu-card-parser/card-expected.md:
Change Type
Scope
Linked Issue/PR
User-visible / Behavior Changes
[Interactive Card]or[unknown: ...]Security Impact
Test Coverage
Expanded from 1 fixture test to 20 unit tests covering: invalid input, empty objects, null, JSON strings, heading levels, ordered/unordered lists, code blocks with language, code spans, blockquotes, tables, card headers (object + string title), links, buttons (with/without actions), hr/br, deep nesting truncation, and top-level header+body structure.
Repro + Verification
Environment
Steps
Expected
Card content displayed in conversation
Actual
Works correctly in testing
Evidence
Human Verification
Compatibility / Migration
[Interactive Card]for unknown tags)Risks and Mitigations
[unknown: ...]with serialized content