Skip to content

Commit a380eb1

Browse files
edyedy
authored andcommitted
fix(feishu): parse nested interactive card fallback text
1 parent 7789021 commit a380eb1

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

extensions/feishu/src/send.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,122 @@ describe("getMessageFeishu", () => {
257257
);
258258
});
259259

260+
it("extracts text from interactive cards with top-level nested post-format fallback elements", async () => {
261+
mockClientGet.mockResolvedValueOnce({
262+
code: 0,
263+
data: {
264+
items: [
265+
{
266+
message_id: "om_card_fallback",
267+
chat_id: "oc_card_fallback",
268+
msg_type: "interactive",
269+
body: {
270+
content: JSON.stringify({
271+
title: "🤖 clawdbot",
272+
elements: [
273+
[
274+
{ tag: "img", image_key: "img_v3_example" },
275+
{ tag: "text", text: "Daily report summary" },
276+
{ tag: "text", text: "13 people missing reports" },
277+
],
278+
],
279+
}),
280+
},
281+
},
282+
],
283+
},
284+
});
285+
286+
const result = await getMessageFeishu({
287+
cfg: {} as ClawdbotConfig,
288+
messageId: "om_card_fallback",
289+
});
290+
291+
expect(result).toEqual(
292+
expect.objectContaining({
293+
messageId: "om_card_fallback",
294+
chatId: "oc_card_fallback",
295+
contentType: "interactive",
296+
content: "🤖 clawdbot\nDaily report summary\n13 people missing reports",
297+
}),
298+
);
299+
});
300+
301+
it("extracts header.title.content from interactive card templates", async () => {
302+
mockClientGet.mockResolvedValueOnce({
303+
code: 0,
304+
data: {
305+
items: [
306+
{
307+
message_id: "om_card_header",
308+
chat_id: "oc_card_header",
309+
msg_type: "interactive",
310+
body: {
311+
content: JSON.stringify({
312+
header: { title: { content: "Card Title" } },
313+
elements: [{ tag: "markdown", content: "body text" }],
314+
}),
315+
},
316+
},
317+
],
318+
},
319+
});
320+
321+
const result = await getMessageFeishu({
322+
cfg: {} as ClawdbotConfig,
323+
messageId: "om_card_header",
324+
});
325+
326+
expect(result).toEqual(
327+
expect.objectContaining({
328+
messageId: "om_card_header",
329+
chatId: "oc_card_header",
330+
contentType: "interactive",
331+
content: "Card Title\nbody text",
332+
}),
333+
);
334+
});
335+
336+
it("trims post-format text fallback nodes before joining anchor text", async () => {
337+
mockClientGet.mockResolvedValueOnce({
338+
code: 0,
339+
data: {
340+
items: [
341+
{
342+
message_id: "om_card_links",
343+
chat_id: "oc_card_links",
344+
msg_type: "interactive",
345+
body: {
346+
content: JSON.stringify({
347+
title: "Report",
348+
elements: [
349+
[
350+
{ tag: "text", text: "See: " },
351+
{ tag: "a", text: "Weekly Report", href: "https://example.com/report" },
352+
],
353+
],
354+
}),
355+
},
356+
},
357+
],
358+
},
359+
});
360+
361+
const result = await getMessageFeishu({
362+
cfg: {} as ClawdbotConfig,
363+
messageId: "om_card_links",
364+
});
365+
366+
expect(result).toEqual(
367+
expect.objectContaining({
368+
messageId: "om_card_links",
369+
chatId: "oc_card_links",
370+
contentType: "interactive",
371+
content: "Report\nSee:\nWeekly Report (https://example.com/report)",
372+
}),
373+
);
374+
});
375+
260376
it("extracts text content from post messages", async () => {
261377
mockClientGet.mockResolvedValueOnce({
262378
code: 0,

extensions/feishu/src/send.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ function extractInteractiveElementText(
225225
element: unknown,
226226
variables: Map<string, string>,
227227
): string | undefined {
228+
if (Array.isArray(element)) {
229+
return extractInteractiveElementsText(element, variables) || undefined;
230+
}
228231
if (!isRecord(element)) {
229232
return undefined;
230233
}
@@ -240,6 +243,18 @@ function extractInteractiveElementText(
240243
if (tag === "plain_text" && typeof element.content === "string") {
241244
return applyCardTemplateVariables(element.content, variables);
242245
}
246+
if (tag === "text" && typeof element.text === "string") {
247+
const renderedText = applyCardTemplateVariables(element.text, variables).trim();
248+
return renderedText || undefined;
249+
}
250+
if (tag === "a" && typeof element.text === "string") {
251+
const label = applyCardTemplateVariables(element.text, variables).trim();
252+
if (!label) {
253+
return undefined;
254+
}
255+
const href = typeof element.href === "string" ? element.href.trim() : "";
256+
return href ? `${label} (${href})` : label;
257+
}
243258
return undefined;
244259
}
245260

@@ -250,13 +265,35 @@ function extractInteractiveElementsText(
250265
const texts: string[] = [];
251266
for (const element of elements) {
252267
const text = extractInteractiveElementText(element, variables);
253-
if (text !== undefined) {
268+
if (text) {
254269
texts.push(text);
255270
}
256271
}
257272
return texts.join("\n").trim();
258273
}
259274

275+
function readInteractiveTitleTexts(
276+
parsed: Record<string, unknown>,
277+
variables: Map<string, string>,
278+
): string[] {
279+
const header = isRecord(parsed.header) ? parsed.header : undefined;
280+
const headerTitle = isRecord(header?.title) ? header.title : undefined;
281+
const candidates = [headerTitle?.content, parsed.title];
282+
283+
const titles: string[] = [];
284+
for (const candidate of candidates) {
285+
if (typeof candidate !== "string") {
286+
continue;
287+
}
288+
const title = applyCardTemplateVariables(candidate, variables).trim();
289+
if (title) {
290+
titles.push(title);
291+
break;
292+
}
293+
}
294+
return titles;
295+
}
296+
260297
function readInteractiveElementArrays(parsed: Record<string, unknown>): unknown[][] {
261298
const body = isRecord(parsed.body) ? parsed.body : undefined;
262299
const elementArrays: unknown[][] = [];
@@ -292,14 +329,17 @@ function parseInteractiveCardContent(parsed: unknown): string {
292329
}
293330

294331
const variables = readCardTemplateVariables(parsed);
332+
const titleTexts = readInteractiveTitleTexts(parsed, variables);
295333
for (const elements of readInteractiveElementArrays(parsed)) {
296-
const text = extractInteractiveElementsText(elements, variables);
334+
const elementText = extractInteractiveElementsText(elements, variables);
335+
const text = [...titleTexts, elementText].filter(Boolean).join("\n").trim();
297336
if (text) {
298337
return text;
299338
}
300339
}
301340

302-
return parseInteractivePostFallback(parsed) ?? INTERACTIVE_CARD_FALLBACK_TEXT;
341+
const titleText = titleTexts.join("\n").trim();
342+
return titleText || parseInteractivePostFallback(parsed) || INTERACTIVE_CARD_FALLBACK_TEXT;
303343
}
304344

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

0 commit comments

Comments
 (0)