Skip to content

Commit c6cf370

Browse files
authored
fix(feishu): repair interactive card content extraction (#72397)
1 parent ff6044f commit c6cf370

4 files changed

Lines changed: 203 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9+
- 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.
910
- Exec approvals: accept a symlinked `OPENCLAW_HOME` as the trusted approvals root while still rejecting symlinked `.openclaw` path components below it. (#64663) Thanks @FunJim.
1011
- 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.
1112
- 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.

extensions/feishu/src/post.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ function renderElement(
166166
}
167167
case "emotion":
168168
return renderEmotionElement(element);
169+
case "md":
170+
case "lark_md":
171+
return toStringOrEmpty(element.text) || toStringOrEmpty(element.content);
169172
case "br":
170173
return "\n";
171174
case "hr":

extensions/feishu/src/send.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,95 @@ describe("getMessageFeishu", () => {
168168
);
169169
});
170170

171+
it("falls through empty interactive card element arrays and locale variants", async () => {
172+
mockClientGet.mockResolvedValueOnce({
173+
code: 0,
174+
data: {
175+
items: [
176+
{
177+
message_id: "om_i18n_card",
178+
chat_id: "oc_i18n_card",
179+
msg_type: "interactive",
180+
body: {
181+
content: JSON.stringify({
182+
elements: [],
183+
body: { elements: [] },
184+
i18n_elements: {
185+
zh_cn: [],
186+
en_us: [
187+
{
188+
tag: "markdown",
189+
content: "hello ${count} {{label}} {{metadata}}",
190+
},
191+
],
192+
},
193+
template_variable: {
194+
count: 2,
195+
label: "tasks",
196+
metadata: { ignored: true },
197+
},
198+
}),
199+
},
200+
},
201+
],
202+
},
203+
});
204+
205+
const result = await getMessageFeishu({
206+
cfg: {} as ClawdbotConfig,
207+
messageId: "om_i18n_card",
208+
});
209+
210+
expect(result).toEqual(
211+
expect.objectContaining({
212+
messageId: "om_i18n_card",
213+
chatId: "oc_i18n_card",
214+
contentType: "interactive",
215+
content: "hello 2 tasks {{metadata}}",
216+
}),
217+
);
218+
});
219+
220+
it("falls back to post-format content when interactive card elements are empty", async () => {
221+
mockClientGet.mockResolvedValueOnce({
222+
code: 0,
223+
data: {
224+
items: [
225+
{
226+
message_id: "om_post_card",
227+
chat_id: "oc_post_card",
228+
msg_type: "interactive",
229+
body: {
230+
content: JSON.stringify({
231+
elements: [],
232+
post: {
233+
zh_cn: {
234+
title: "Card summary",
235+
content: [[{ tag: "md", text: "**fallback** body" }]],
236+
},
237+
},
238+
}),
239+
},
240+
},
241+
],
242+
},
243+
});
244+
245+
const result = await getMessageFeishu({
246+
cfg: {} as ClawdbotConfig,
247+
messageId: "om_post_card",
248+
});
249+
250+
expect(result).toEqual(
251+
expect.objectContaining({
252+
messageId: "om_post_card",
253+
chatId: "oc_post_card",
254+
contentType: "interactive",
255+
content: "Card summary\n\n**fallback** body",
256+
}),
257+
);
258+
});
259+
171260
it("extracts text content from post messages", async () => {
172261
mockClientGet.mockResolvedValueOnce({
173262
code: 0,

extensions/feishu/src/send.ts

Lines changed: 110 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { resolveFeishuSendTarget } from "./send-target.js";
1515
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
1616

1717
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
18+
const INTERACTIVE_CARD_FALLBACK_TEXT = "[Interactive Card]";
19+
const POST_FALLBACK_TEXT = "[Rich text message]";
1820
const FEISHU_CARD_TEMPLATES = new Set([
1921
"blue",
2022
"green",
@@ -60,6 +62,10 @@ function isWithdrawnReplyError(err: unknown): boolean {
6062
return false;
6163
}
6264

65+
function isRecord(value: unknown): value is Record<string, unknown> {
66+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
67+
}
68+
6369
type FeishuCreateMessageClient = {
6470
im: {
6571
message: {
@@ -179,41 +185,121 @@ async function sendReplyOrFallbackDirect(
179185
return toFeishuSendResult(response, params.directParams.receiveId);
180186
}
181187

182-
function parseInteractiveCardContent(parsed: unknown): string {
183-
if (!parsed || typeof parsed !== "object") {
184-
return "[Interactive Card]";
188+
function normalizeCardTemplateVariable(value: unknown): string | undefined {
189+
if (typeof value === "string") {
190+
return value;
185191
}
192+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
193+
return String(value);
194+
}
195+
return undefined;
196+
}
197+
198+
function readCardTemplateVariables(parsed: Record<string, unknown>): Map<string, string> {
199+
const variables = new Map<string, string>();
200+
for (const source of [parsed.template_variable, parsed.template_variables]) {
201+
if (!isRecord(source)) {
202+
continue;
203+
}
204+
for (const [key, value] of Object.entries(source)) {
205+
const normalized = normalizeCardTemplateVariable(value);
206+
if (normalized !== undefined) {
207+
variables.set(key, normalized);
208+
}
209+
}
210+
}
211+
return variables;
212+
}
186213

187-
// Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
188-
const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } };
189-
const elements = Array.isArray(candidate.elements)
190-
? candidate.elements
191-
: Array.isArray(candidate.body?.elements)
192-
? candidate.body.elements
193-
: null;
194-
if (!elements) {
195-
return "[Interactive Card]";
214+
function applyCardTemplateVariables(text: string, variables: Map<string, string>): string {
215+
if (variables.size === 0) {
216+
return text;
196217
}
218+
return text.replace(/\$\{([A-Za-z0-9_.-]+)\}|\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (match, a, b) => {
219+
const variableName = typeof a === "string" ? a : b;
220+
return variables.get(variableName) ?? match;
221+
});
222+
}
223+
224+
function extractInteractiveElementText(
225+
element: unknown,
226+
variables: Map<string, string>,
227+
): string | undefined {
228+
if (!isRecord(element)) {
229+
return undefined;
230+
}
231+
const tag = typeof element.tag === "string" ? element.tag : "";
232+
const text = isRecord(element.text) ? element.text : undefined;
233+
234+
if (tag === "div" && typeof text?.content === "string") {
235+
return applyCardTemplateVariables(text.content, variables);
236+
}
237+
if ((tag === "markdown" || tag === "lark_md") && typeof element.content === "string") {
238+
return applyCardTemplateVariables(element.content, variables);
239+
}
240+
if (tag === "plain_text" && typeof element.content === "string") {
241+
return applyCardTemplateVariables(element.content, variables);
242+
}
243+
return undefined;
244+
}
197245

246+
function extractInteractiveElementsText(
247+
elements: unknown[],
248+
variables: Map<string, string>,
249+
): string {
198250
const texts: string[] = [];
199251
for (const element of elements) {
200-
if (!element || typeof element !== "object") {
201-
continue;
252+
const text = extractInteractiveElementText(element, variables);
253+
if (text !== undefined) {
254+
texts.push(text);
202255
}
203-
const item = element as {
204-
tag?: string;
205-
content?: string;
206-
text?: { content?: string };
207-
};
208-
if (item.tag === "div" && typeof item.text?.content === "string") {
209-
texts.push(item.text.content);
256+
}
257+
return texts.join("\n").trim();
258+
}
259+
260+
function readInteractiveElementArrays(parsed: Record<string, unknown>): unknown[][] {
261+
const body = isRecord(parsed.body) ? parsed.body : undefined;
262+
const elementArrays: unknown[][] = [];
263+
264+
for (const candidate of [parsed.elements, body?.elements]) {
265+
if (Array.isArray(candidate)) {
266+
elementArrays.push(candidate);
267+
}
268+
}
269+
270+
for (const candidate of [parsed.i18n_elements, body?.i18n_elements]) {
271+
if (!isRecord(candidate)) {
210272
continue;
211273
}
212-
if (item.tag === "markdown" && typeof item.content === "string") {
213-
texts.push(item.content);
274+
for (const localeElements of Object.values(candidate)) {
275+
if (Array.isArray(localeElements)) {
276+
elementArrays.push(localeElements);
277+
}
278+
}
279+
}
280+
281+
return elementArrays;
282+
}
283+
284+
function parseInteractivePostFallback(parsed: unknown): string | undefined {
285+
const textContent = parsePostContent(JSON.stringify(parsed)).textContent.trim();
286+
return textContent && textContent !== POST_FALLBACK_TEXT ? textContent : undefined;
287+
}
288+
289+
function parseInteractiveCardContent(parsed: unknown): string {
290+
if (!isRecord(parsed)) {
291+
return INTERACTIVE_CARD_FALLBACK_TEXT;
292+
}
293+
294+
const variables = readCardTemplateVariables(parsed);
295+
for (const elements of readInteractiveElementArrays(parsed)) {
296+
const text = extractInteractiveElementsText(elements, variables);
297+
if (text) {
298+
return text;
214299
}
215300
}
216-
return texts.join("\n").trim() || "[Interactive Card]";
301+
302+
return parseInteractivePostFallback(parsed) ?? INTERACTIVE_CARD_FALLBACK_TEXT;
217303
}
218304

219305
function parseFeishuMessageContent(rawContent: string, msgType: string): string {

0 commit comments

Comments
 (0)