From c6cf37068cae6524e119697ad94780ea1fff11f3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 15:26:53 -0700 Subject: [PATCH] fix(feishu): repair interactive card content extraction (#72397) --- CHANGELOG.md | 1 + extensions/feishu/src/post.ts | 3 + extensions/feishu/src/send.test.ts | 89 ++++++++++++++++++ extensions/feishu/src/send.ts | 142 +++++++++++++++++++++++------ 4 files changed, 207 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa35dd36768..81e1a04fcb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: extract quoted/replied interactive-card text across schema 1.0, schema 2.0, i18n, template-variable, and post-format fallback shapes without carrying broad generated/config churn from related parser experiments. (#38776, #60383, #42218, #45936) Thanks @lishuaigit, @lskun, @just2gooo, and @Br1an67. - Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim. - Logging: add top-level `hostname`, flattened `message`, and available `agent_id`, `session_id`, and `channel` fields to file-log JSONL records for multi-agent filtering without removing existing structured log arguments. Fixes #51075. Thanks @stevengonsalvez. - ACP: route server logs to stderr before Gateway config/bootstrap work so ACP stdout remains JSON-RPC only for IDE integrations. Fixes #49060. Thanks @Hollychou924. diff --git a/extensions/feishu/src/post.ts b/extensions/feishu/src/post.ts index 448e9b0f719..a56ed4f300c 100644 --- a/extensions/feishu/src/post.ts +++ b/extensions/feishu/src/post.ts @@ -166,6 +166,9 @@ function renderElement( } case "emotion": return renderEmotionElement(element); + case "md": + case "lark_md": + return toStringOrEmpty(element.text) || toStringOrEmpty(element.content); case "br": return "\n"; case "hr": diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts index bc461e7d824..87bb596bdb8 100644 --- a/extensions/feishu/src/send.test.ts +++ b/extensions/feishu/src/send.test.ts @@ -168,6 +168,95 @@ describe("getMessageFeishu", () => { ); }); + it("falls through empty interactive card element arrays and locale variants", async () => { + mockClientGet.mockResolvedValueOnce({ + code: 0, + data: { + items: [ + { + message_id: "om_i18n_card", + chat_id: "oc_i18n_card", + msg_type: "interactive", + body: { + content: JSON.stringify({ + elements: [], + body: { elements: [] }, + i18n_elements: { + zh_cn: [], + en_us: [ + { + tag: "markdown", + content: "hello ${count} {{label}} {{metadata}}", + }, + ], + }, + template_variable: { + count: 2, + label: "tasks", + metadata: { ignored: true }, + }, + }), + }, + }, + ], + }, + }); + + const result = await getMessageFeishu({ + cfg: {} as ClawdbotConfig, + messageId: "om_i18n_card", + }); + + expect(result).toEqual( + expect.objectContaining({ + messageId: "om_i18n_card", + chatId: "oc_i18n_card", + contentType: "interactive", + content: "hello 2 tasks {{metadata}}", + }), + ); + }); + + it("falls back to post-format content when interactive card elements are empty", async () => { + mockClientGet.mockResolvedValueOnce({ + code: 0, + data: { + items: [ + { + message_id: "om_post_card", + chat_id: "oc_post_card", + msg_type: "interactive", + body: { + content: JSON.stringify({ + elements: [], + post: { + zh_cn: { + title: "Card summary", + content: [[{ tag: "md", text: "**fallback** body" }]], + }, + }, + }), + }, + }, + ], + }, + }); + + const result = await getMessageFeishu({ + cfg: {} as ClawdbotConfig, + messageId: "om_post_card", + }); + + expect(result).toEqual( + expect.objectContaining({ + messageId: "om_post_card", + chatId: "oc_post_card", + contentType: "interactive", + content: "Card summary\n\n**fallback** body", + }), + ); + }); + it("extracts text content from post messages", async () => { mockClientGet.mockResolvedValueOnce({ code: 0, diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 338565167d0..ccaeb908c00 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -15,6 +15,8 @@ import { resolveFeishuSendTarget } from "./send-target.js"; import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js"; const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]); +const INTERACTIVE_CARD_FALLBACK_TEXT = "[Interactive Card]"; +const POST_FALLBACK_TEXT = "[Rich text message]"; const FEISHU_CARD_TEMPLATES = new Set([ "blue", "green", @@ -60,6 +62,10 @@ function isWithdrawnReplyError(err: unknown): boolean { return false; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + type FeishuCreateMessageClient = { im: { message: { @@ -179,41 +185,121 @@ async function sendReplyOrFallbackDirect( return toFeishuSendResult(response, params.directParams.receiveId); } -function parseInteractiveCardContent(parsed: unknown): string { - if (!parsed || typeof parsed !== "object") { - return "[Interactive Card]"; +function normalizeCardTemplateVariable(value: unknown): string | undefined { + if (typeof value === "string") { + return value; } - - // Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`). - const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } }; - const elements = Array.isArray(candidate.elements) - ? candidate.elements - : Array.isArray(candidate.body?.elements) - ? candidate.body.elements - : null; - if (!elements) { - return "[Interactive Card]"; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); } + return undefined; +} +function readCardTemplateVariables(parsed: Record): Map { + const variables = new Map(); + for (const source of [parsed.template_variable, parsed.template_variables]) { + if (!isRecord(source)) { + continue; + } + for (const [key, value] of Object.entries(source)) { + const normalized = normalizeCardTemplateVariable(value); + if (normalized !== undefined) { + variables.set(key, normalized); + } + } + } + return variables; +} + +function applyCardTemplateVariables(text: string, variables: Map): string { + if (variables.size === 0) { + return text; + } + return text.replace(/\$\{([A-Za-z0-9_.-]+)\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (match, a, b) => { + const variableName = typeof a === "string" ? a : b; + return variables.get(variableName) ?? match; + }); +} + +function extractInteractiveElementText( + element: unknown, + variables: Map, +): string | undefined { + if (!isRecord(element)) { + return undefined; + } + const tag = typeof element.tag === "string" ? element.tag : ""; + const text = isRecord(element.text) ? element.text : undefined; + + if (tag === "div" && typeof text?.content === "string") { + return applyCardTemplateVariables(text.content, variables); + } + if ((tag === "markdown" || tag === "lark_md") && typeof element.content === "string") { + return applyCardTemplateVariables(element.content, variables); + } + if (tag === "plain_text" && typeof element.content === "string") { + return applyCardTemplateVariables(element.content, variables); + } + return undefined; +} + +function extractInteractiveElementsText( + elements: unknown[], + variables: Map, +): string { const texts: string[] = []; for (const element of elements) { - if (!element || typeof element !== "object") { - continue; - } - const item = element as { - tag?: string; - content?: string; - text?: { content?: string }; - }; - if (item.tag === "div" && typeof item.text?.content === "string") { - texts.push(item.text.content); - continue; - } - if (item.tag === "markdown" && typeof item.content === "string") { - texts.push(item.content); + const text = extractInteractiveElementText(element, variables); + if (text !== undefined) { + texts.push(text); } } - return texts.join("\n").trim() || "[Interactive Card]"; + return texts.join("\n").trim(); +} + +function readInteractiveElementArrays(parsed: Record): unknown[][] { + const body = isRecord(parsed.body) ? parsed.body : undefined; + const elementArrays: unknown[][] = []; + + for (const candidate of [parsed.elements, body?.elements]) { + if (Array.isArray(candidate)) { + elementArrays.push(candidate); + } + } + + for (const candidate of [parsed.i18n_elements, body?.i18n_elements]) { + if (!isRecord(candidate)) { + continue; + } + for (const localeElements of Object.values(candidate)) { + if (Array.isArray(localeElements)) { + elementArrays.push(localeElements); + } + } + } + + return elementArrays; +} + +function parseInteractivePostFallback(parsed: unknown): string | undefined { + const textContent = parsePostContent(JSON.stringify(parsed)).textContent.trim(); + return textContent && textContent !== POST_FALLBACK_TEXT ? textContent : undefined; +} + +function parseInteractiveCardContent(parsed: unknown): string { + if (!isRecord(parsed)) { + return INTERACTIVE_CARD_FALLBACK_TEXT; + } + + const variables = readCardTemplateVariables(parsed); + for (const elements of readInteractiveElementArrays(parsed)) { + const text = extractInteractiveElementsText(elements, variables); + if (text) { + return text; + } + } + + return parseInteractivePostFallback(parsed) ?? INTERACTIVE_CARD_FALLBACK_TEXT; } function parseFeishuMessageContent(rawContent: string, msgType: string): string {