fix(feishu): repair interactive card content extraction (#72397)

This commit is contained in:
Vincent Koc
2026-04-26 15:26:53 -07:00
committed by GitHub
parent ff6044f441
commit c6cf37068c
4 changed files with 207 additions and 28 deletions

View File

@@ -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.

View File

@@ -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":

View File

@@ -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,

View File

@@ -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<string, unknown> {
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<string, unknown>): Map<string, string> {
const variables = new Map<string, string>();
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, string>): 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, string>,
): 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, string>,
): 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<string, unknown>): 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 {