Feature Request: Add Feishu Interactive Card Parsing support #48281 #56795
Feature Request: Add Feishu Interactive Card Parsing support #48281 #56795zeel2104 wants to merge 3 commits intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR adds inbound Feishu interactive card parsing so the bot can extract readable text (header + elements) and forward the raw card JSON as context to the agent, closing a long-standing feature gap where cards were seen only as Key findings:
Confidence Score: 4/5Safe to merge after fixing the One real P1 defect: using extensions/feishu/src/interactive-card.ts — fix the
|
| Filename | Overview |
|---|---|
| extensions/feishu/src/interactive-card.ts | New module for parsing inbound Feishu interactive card payloads. Contains a P1 type-guard bug (isRecord instead of Array.isArray for i18n_elements.zh_cn) that can cause a TypeError at spread time, and a P2 unbounded recursion issue in collectElementText. |
| extensions/feishu/src/bot.ts | Adds interactive card fetch + context enrichment block and updates RawBody/rawContent propagation. Logic is sound; the content-selection heuristic and raw-card JSON forwarding look correct. |
| extensions/feishu/src/send.ts | Removes old parseInteractiveCardContent in favour of the shared parseInteractiveCardPayload, adds rawContent to parsed message items, and introduces an unsafe SDK cast to pass raw_card_content: true. |
| extensions/feishu/src/bot-content.ts | Minimal change — adds an interactive branch that delegates to the new shared parser, consistent with the post branch pattern. |
| extensions/feishu/src/types.ts | Adds optional rawContent field to FeishuMessageContext and FeishuMessageInfo. Backward-compatible change. |
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/feishu/src/interactive-card.ts
Line: 150-153
Comment:
**Wrong type guard for `zh_cn` array**
`isRecord` returns `true` for both plain objects and arrays (since `typeof [] === "object"` and `[] !== null`). When `zh_cn` is a plain object (a malformed but structurally valid JSON response), the cast to `unknown[]` succeeds at compile time, but spreading a non-iterable plain object at runtime (`[...i18nElements]` on line 155) throws `TypeError: i18nElements is not iterable`. The catch block in `handleFeishuMessage` would swallow the error and silently fall back to the placeholder, making the feature appear broken for any card that includes `i18n_elements.zh_cn` as a non-array value.
Use `Array.isArray` here instead:
```suggestion
const i18nElements =
isRecord(card.i18n_elements) && Array.isArray((card.i18n_elements as Record<string, unknown>).zh_cn)
? ((card.i18n_elements as Record<string, unknown>).zh_cn as unknown[])
: [];
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/feishu/src/interactive-card.ts
Line: 66-131
Comment:
**No recursion depth guard in `collectElementText`**
`collectElementText` calls itself recursively for `note`, `fields`, `options`, `actions`, and `elements` on every visited node, with no depth limit or cycle detection. A deeply nested or adversarially crafted card payload could trigger a stack overflow. In Node.js the call stack is typically a few thousand frames, and a legitimate card schema from an untrusted Feishu user could be structured to exceed this (e.g., elements nested inside elements nested inside actions, etc.).
Consider adding a simple depth parameter:
```typescript
function collectElementText(node: unknown, out: string[], depth = 0): void {
if (depth > 20 || !node) return;
// ... recursive calls pass depth + 1
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/feishu/src/send.ts
Line: 267-274
Comment:
**Type-unsafe SDK cast for `raw_card_content` parameter**
The double `as unknown as (...)` cast bypasses TypeScript's type checking entirely for the `raw_card_content: true` query parameter. If the underlying Feishu SDK version in use does not actually accept or forward this parameter, the call will silently succeed but return a response without the richer card payload — `parseInteractiveCardPayload` would then fall back to re-parsing whatever the SDK returns, potentially producing the placeholder text without any runtime error or log warning.
If `raw_card_content` is a documented Feishu API param but simply absent from the SDK's type definitions, consider adding a comment noting the SDK type gap and the Feishu API version that introduced the field:
```typescript
// raw_card_content is a Feishu IM API v2 param not yet typed in the SDK.
// See: https://open.feishu.cn/document/server-docs/im-v1/message/get
const getMessageWithRawCard = client.im.message.get as unknown as (
opts: FeishuMessageGetRequestWithRawCard,
) => Promise<FeishuGetMessageResponse>;
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "feishu: parse inbound interactive cards ..." | Re-trigger Greptile
| const i18nElements = | ||
| isRecord(card.i18n_elements) && isRecord((card.i18n_elements as Record<string, unknown>).zh_cn) | ||
| ? ((card.i18n_elements as Record<string, unknown>).zh_cn as unknown[]) | ||
| : []; |
There was a problem hiding this comment.
Wrong type guard for
zh_cn array
isRecord returns true for both plain objects and arrays (since typeof [] === "object" and [] !== null). When zh_cn is a plain object (a malformed but structurally valid JSON response), the cast to unknown[] succeeds at compile time, but spreading a non-iterable plain object at runtime ([...i18nElements] on line 155) throws TypeError: i18nElements is not iterable. The catch block in handleFeishuMessage would swallow the error and silently fall back to the placeholder, making the feature appear broken for any card that includes i18n_elements.zh_cn as a non-array value.
Use Array.isArray here instead:
| const i18nElements = | |
| isRecord(card.i18n_elements) && isRecord((card.i18n_elements as Record<string, unknown>).zh_cn) | |
| ? ((card.i18n_elements as Record<string, unknown>).zh_cn as unknown[]) | |
| : []; | |
| const i18nElements = | |
| isRecord(card.i18n_elements) && Array.isArray((card.i18n_elements as Record<string, unknown>).zh_cn) | |
| ? ((card.i18n_elements as Record<string, unknown>).zh_cn as unknown[]) | |
| : []; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/interactive-card.ts
Line: 150-153
Comment:
**Wrong type guard for `zh_cn` array**
`isRecord` returns `true` for both plain objects and arrays (since `typeof [] === "object"` and `[] !== null`). When `zh_cn` is a plain object (a malformed but structurally valid JSON response), the cast to `unknown[]` succeeds at compile time, but spreading a non-iterable plain object at runtime (`[...i18nElements]` on line 155) throws `TypeError: i18nElements is not iterable`. The catch block in `handleFeishuMessage` would swallow the error and silently fall back to the placeholder, making the feature appear broken for any card that includes `i18n_elements.zh_cn` as a non-array value.
Use `Array.isArray` here instead:
```suggestion
const i18nElements =
isRecord(card.i18n_elements) && Array.isArray((card.i18n_elements as Record<string, unknown>).zh_cn)
? ((card.i18n_elements as Record<string, unknown>).zh_cn as unknown[])
: [];
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in follow-up commit: switched zh_cn to Array.isArray
| function collectElementText(node: unknown, out: string[]): void { | ||
| if (!node) { | ||
| return; | ||
| } | ||
| if (typeof node === "string") { | ||
| pushText(out, node); | ||
| return; | ||
| } | ||
| if (!isRecord(node)) { | ||
| return; | ||
| } | ||
|
|
||
| const obj = node as Record<string, unknown>; | ||
| const textField = obj.text; | ||
| if (isRecord(textField)) { | ||
| pushText(out, textField.content); | ||
| } else { | ||
| pushText(out, textField); | ||
| } | ||
| pushText(out, obj.content); | ||
|
|
||
| const title = obj.title; | ||
| if (isRecord(title)) { | ||
| pushText(out, title.content); | ||
| pushText(out, title.text); | ||
| } else { | ||
| pushText(out, title); | ||
| } | ||
|
|
||
| const note = obj.note; | ||
| if (Array.isArray(note)) { | ||
| for (const item of note) { | ||
| collectElementText(item, out); | ||
| } | ||
| } else { | ||
| collectElementText(note, out); | ||
| } | ||
|
|
||
| const fields = obj.fields; | ||
| if (Array.isArray(fields)) { | ||
| for (const field of fields) { | ||
| collectElementText(field, out); | ||
| } | ||
| } | ||
|
|
||
| const options = obj.options; | ||
| if (Array.isArray(options)) { | ||
| for (const option of options) { | ||
| collectElementText(option, out); | ||
| } | ||
| } | ||
|
|
||
| const actions = obj.actions; | ||
| if (Array.isArray(actions)) { | ||
| for (const action of actions) { | ||
| collectElementText(action, out); | ||
| } | ||
| } | ||
|
|
||
| const children = obj.elements; | ||
| if (Array.isArray(children)) { | ||
| for (const child of children) { | ||
| collectElementText(child, out); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
No recursion depth guard in
collectElementText
collectElementText calls itself recursively for note, fields, options, actions, and elements on every visited node, with no depth limit or cycle detection. A deeply nested or adversarially crafted card payload could trigger a stack overflow. In Node.js the call stack is typically a few thousand frames, and a legitimate card schema from an untrusted Feishu user could be structured to exceed this (e.g., elements nested inside elements nested inside actions, etc.).
Consider adding a simple depth parameter:
function collectElementText(node: unknown, out: string[], depth = 0): void {
if (depth > 20 || !node) return;
// ... recursive calls pass depth + 1
}Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/interactive-card.ts
Line: 66-131
Comment:
**No recursion depth guard in `collectElementText`**
`collectElementText` calls itself recursively for `note`, `fields`, `options`, `actions`, and `elements` on every visited node, with no depth limit or cycle detection. A deeply nested or adversarially crafted card payload could trigger a stack overflow. In Node.js the call stack is typically a few thousand frames, and a legitimate card schema from an untrusted Feishu user could be structured to exceed this (e.g., elements nested inside elements nested inside actions, etc.).
Consider adding a simple depth parameter:
```typescript
function collectElementText(node: unknown, out: string[], depth = 0): void {
if (depth > 20 || !node) return;
// ... recursive calls pass depth + 1
}
```
How can I resolve this? If you propose a fix, please make it concise.| const getMessageWithRawCard = client.im.message.get as unknown as ( | ||
| opts: FeishuMessageGetRequestWithRawCard, | ||
| ) => Promise<FeishuGetMessageResponse>; | ||
|
|
||
| try { | ||
| const response = (await client.im.message.get({ | ||
| const response = await getMessageWithRawCard({ | ||
| path: { message_id: messageId }, | ||
| })) as FeishuGetMessageResponse; | ||
| params: { raw_card_content: true }, |
There was a problem hiding this comment.
Type-unsafe SDK cast for
raw_card_content parameter
The double as unknown as (...) cast bypasses TypeScript's type checking entirely for the raw_card_content: true query parameter. If the underlying Feishu SDK version in use does not actually accept or forward this parameter, the call will silently succeed but return a response without the richer card payload — parseInteractiveCardPayload would then fall back to re-parsing whatever the SDK returns, potentially producing the placeholder text without any runtime error or log warning.
If raw_card_content is a documented Feishu API param but simply absent from the SDK's type definitions, consider adding a comment noting the SDK type gap and the Feishu API version that introduced the field:
// raw_card_content is a Feishu IM API v2 param not yet typed in the SDK.
// See: https://open.feishu.cn/document/server-docs/im-v1/message/get
const getMessageWithRawCard = client.im.message.get as unknown as (
opts: FeishuMessageGetRequestWithRawCard,
) => Promise<FeishuGetMessageResponse>;Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/send.ts
Line: 267-274
Comment:
**Type-unsafe SDK cast for `raw_card_content` parameter**
The double `as unknown as (...)` cast bypasses TypeScript's type checking entirely for the `raw_card_content: true` query parameter. If the underlying Feishu SDK version in use does not actually accept or forward this parameter, the call will silently succeed but return a response without the richer card payload — `parseInteractiveCardPayload` would then fall back to re-parsing whatever the SDK returns, potentially producing the placeholder text without any runtime error or log warning.
If `raw_card_content` is a documented Feishu API param but simply absent from the SDK's type definitions, consider adding a comment noting the SDK type gap and the Feishu API version that introduced the field:
```typescript
// raw_card_content is a Feishu IM API v2 param not yet typed in the SDK.
// See: https://open.feishu.cn/document/server-docs/im-v1/message/get
const getMessageWithRawCard = client.im.message.get as unknown as (
opts: FeishuMessageGetRequestWithRawCard,
) => Promise<FeishuGetMessageResponse>;
```
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: aac1f455b0
ℹ️ 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 (typeof asRecord.card === "string") { | ||
| return parseJsonObject(asRecord.card); | ||
| } |
There was a problem hiding this comment.
Fall back to raw_card_content when card string is not JSON
Do not return immediately from the card string branch when parsing fails. Right now resolveCardObject returns parseJsonObject(asRecord.card) directly, so if card is a non-JSON placeholder string but raw_card_content contains the real card payload, the function exits early and skips the raw_card_content fallback, leaving interactive messages stuck as [Interactive Card] and dropping actionable card content.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
added fallback from non-JSON card to raw_card_content
| isRecord(card.i18n_elements) && isRecord((card.i18n_elements as Record<string, unknown>).zh_cn) | ||
| ? ((card.i18n_elements as Record<string, unknown>).zh_cn as unknown[]) |
There was a problem hiding this comment.
Require array shape before spreading i18n card elements
collectCardText accepts any object for i18n_elements.zh_cn (isRecord) and later spreads it as an array. If that field is object-shaped instead of array-shaped, the spread throws a TypeError and message handling aborts instead of gracefully falling back. This should check Array.isArray(...) (or normalize object forms) before treating it as iterable.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 362e2bb72c
ℹ️ 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".
| isRecord(card.i18n_elements) && | ||
| Array.isArray((card.i18n_elements as Record<string, unknown>).zh_cn) | ||
| ? ((card.i18n_elements as Record<string, unknown>).zh_cn as unknown[]) |
There was a problem hiding this comment.
Support non-zh_cn i18n card elements
collectCardText only reads card.i18n_elements.zh_cn, so interactive cards that localize content under another locale key (for example en_us) will drop their body text and often collapse back to [Interactive Card]. This is a functional parsing gap introduced by the new interactive-card path: multilingual cards become unreadable unless they also duplicate elements into body.elements or zh_cn, which means agents miss actionable card content for non-Chinese tenants.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 06568d8f0b
ℹ️ 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".
| ReplyToId: ctx.parentId, | ||
| RootMessageId: ctx.rootId, | ||
| RawBody: ctx.content, | ||
| RawBody: ctx.rawContent ?? ctx.content, |
There was a problem hiding this comment.
Preserve RawBody plain text outside interactive cards
RawBody now unconditionally prefers ctx.rawContent, but parseFeishuMessageEvent now populates rawContent with the raw Feishu payload (event.message.content) for all message types, so regular text DMs/groups become JSON envelopes like {"text":"..."} instead of the legacy plain text body. This is a backward-compatibility regression for Feishu users who still consume RawBody directly in hooks/templates (where RawBody is documented as the legacy alias of CommandBody) and can break string/pattern matching flows that previously worked.
Useful? React with 👍 / 👎.
|
This pull request has been automatically marked as stale due to inactivity. |
|
Codex review: needs changes before merge. Summary Reproducibility: yes. A high-confidence source-level reproduction is an inbound Feishu event with Next step before merge Security Review findings
Review detailsBest possible solution: Keep the fix inside the Feishu plugin by fetching card JSON through Feishu's message-content query, preserving the normalized body contract, and reusing current-main interactive-card parser coverage. Do we have a high-confidence way to reproduce the issue? Yes. A high-confidence source-level reproduction is an inbound Feishu event with Is this the best way to solve the issue? No. The PR targets the right Feishu-owned boundary, but it is not the best merge shape until the Feishu query parameter is corrected, RawBody remains compatible for ordinary messages, and the branch is reconciled with current-main parser fixes. Full review comments:
Overall correctness: patch is incorrect Acceptance criteria:
What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 2fd1e7b32dd8. |
Summary
interactivemessages were parsed as[Interactive Card]only, so card header/elements/actions were not available to the agent.raw_card_content, extracts readable text, and forwards raw card JSON to context for advanced parsing.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause / Regression History (if applicable)
interactivepayloads were not parsed beyond placeholder text in the inbound event path.git blame, prior PR, issue, or refactor if known): Existing parsing focused on text/post + outbound card sending.Regression Test Plan (if applicable)
extensions/feishu/src/bot.checkBotMentioned.test.tsextensions/feishu/src/bot.test.tsextensions/feishu/src/send.test.tsRawBody).send.test.tshad partial interactive extraction coverage; extended/leveraged.User-visible / Behavior Changes