mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
fix(feishu): repair interactive card content extraction (#72397)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user