mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(feishu): send card JSON message params as cards (#100883)
* fix(feishu): send plain card JSON as interactive cards Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com> * fix(clownfish): address review for gitcrawl-21-autonomous-drip-20260706 (1) Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com> * fix(feishu): preserve native card compatibility Reported-by: @ZenoRewn Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com> * fix(feishu): fix native card fallback precedence * fix(feishu): fix native card fallback precedence --------- Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com> Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com>
This commit is contained in:
parent
bab1415b5c
commit
25a7708f19
7 changed files with 906 additions and 196 deletions
|
|
@ -356,6 +356,217 @@ describe("feishuPlugin actions", () => {
|
|||
expect(details.chatId).toBe("oc_group_1");
|
||||
});
|
||||
|
||||
it("sends plain message card JSON as a native Feishu card", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: JSON.stringify({
|
||||
schema: "2.0",
|
||||
header: {
|
||||
title: { tag: "plain_text", content: "Plain JSON card" },
|
||||
template: "green",
|
||||
},
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "Card body" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
const sendCardArgs = requireRecord(
|
||||
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
|
||||
"send card args",
|
||||
);
|
||||
expect(sendCardArgs.cfg).toBe(cfg);
|
||||
expect(sendCardArgs.to).toBe("chat:oc_group_1");
|
||||
expect(sendCardArgs.accountId).toBeUndefined();
|
||||
expect(sendCardArgs.replyToMessageId).toBeUndefined();
|
||||
expect(sendCardArgs.replyInThread).toBe(false);
|
||||
const card = requireRecord(sendCardArgs.card, "card");
|
||||
expect(card.header).toEqual({
|
||||
title: { tag: "plain_text", content: "Plain JSON card" },
|
||||
template: "green",
|
||||
});
|
||||
expect(card.body).toEqual({
|
||||
elements: [{ tag: "markdown", content: "Card body" }],
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
const details = resultDetails(result);
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.messageId).toBe("om_card");
|
||||
expect(details.chatId).toBe("oc_group_1");
|
||||
});
|
||||
|
||||
it("sends legacy top-level elements card JSON as a native Feishu card", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: JSON.stringify({
|
||||
header: {
|
||||
title: { tag: "plain_text", content: "Legacy JSON card" },
|
||||
template: "green",
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
tag: "div",
|
||||
text: { tag: "lark_md", content: '**Legacy** <at id="ou_1">body</at>' },
|
||||
},
|
||||
{
|
||||
tag: "div",
|
||||
text: { tag: "plain_text", content: "Literal *text*" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
const sendCardArgs = requireRecord(
|
||||
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
|
||||
"send card args",
|
||||
);
|
||||
const card = requireRecord(sendCardArgs.card, "card");
|
||||
expect(card.header).toEqual({
|
||||
title: { tag: "plain_text", content: "Legacy JSON card" },
|
||||
template: "green",
|
||||
});
|
||||
expect(card.body).toEqual({
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: '**Legacy** <at id="ou_1">body</at>',
|
||||
},
|
||||
{ tag: "markdown", content: "Literal \\*text\\*" },
|
||||
],
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("detects message card JSON after the configured response prefix", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
const cardJson = JSON.stringify({
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "Prefixed card" }],
|
||||
},
|
||||
});
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: `[Nexus] ${cardJson}`,
|
||||
},
|
||||
cfg: {
|
||||
...cfg,
|
||||
messages: { responsePrefix: "[Nexus]" },
|
||||
},
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
const sendCardArgs = requireRecord(
|
||||
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
|
||||
"send card args",
|
||||
);
|
||||
const card = requireRecord(sendCardArgs.card, "card");
|
||||
expect(card.body).toEqual({
|
||||
elements: [{ tag: "markdown", content: "Prefixed card" }],
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends wrapped interactive card JSON as a Feishu thread reply card", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "thread-reply",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
messageId: "om_parent",
|
||||
text: JSON.stringify({
|
||||
type: "interactive",
|
||||
card: {
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "Reply card" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
const sendCardArgs = requireRecord(
|
||||
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
|
||||
"send card args",
|
||||
);
|
||||
expect(sendCardArgs.replyToMessageId).toBe("om_parent");
|
||||
expect(sendCardArgs.replyInThread).toBe(true);
|
||||
const card = requireRecord(sendCardArgs.card, "card");
|
||||
expect(card.body).toEqual({
|
||||
elements: [{ tag: "markdown", content: "Reply card" }],
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps ordinary JSON messages on the text path", async () => {
|
||||
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_sent", chatId: "oc_group_1" });
|
||||
const message = JSON.stringify({ ok: true, elements: "not-a-card" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", message },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
text: message,
|
||||
accountId: undefined,
|
||||
replyToMessageId: undefined,
|
||||
replyInThread: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects card JSON sent with media", async () => {
|
||||
await expect(
|
||||
feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: JSON.stringify({
|
||||
elements: [{ tag: "markdown", content: "Card body" }],
|
||||
}),
|
||||
media: "/tmp/image.png",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
} as never),
|
||||
).rejects.toThrow("Feishu send does not support card with media.");
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(feishuOutboundSendMediaMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders presentation messages as cards", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
|
|
@ -402,6 +613,72 @@ describe("feishuPlugin actions", () => {
|
|||
expect(details.chatId).toBe("oc_group_1");
|
||||
});
|
||||
|
||||
it("prefers structured presentation over raw card JSON text", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: JSON.stringify({
|
||||
header: { title: { tag: "plain_text", content: "Raw card" } },
|
||||
elements: [{ tag: "markdown", content: "Raw body" }],
|
||||
}),
|
||||
presentation: {
|
||||
title: "Structured card",
|
||||
blocks: [{ type: "text", text: "Structured body" }],
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
const sendCardArgs = requireRecord(
|
||||
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
|
||||
"send card args",
|
||||
);
|
||||
const card = requireRecord(sendCardArgs.card, "card");
|
||||
expect(card.header).toEqual({
|
||||
title: { tag: "plain_text", content: "Structured card" },
|
||||
template: "blue",
|
||||
});
|
||||
expect(card.body).toEqual({
|
||||
elements: [{ tag: "markdown", content: "Structured body" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers structured interactive input over raw card JSON text", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: JSON.stringify({
|
||||
header: { title: { tag: "plain_text", content: "Raw card" } },
|
||||
elements: [{ tag: "markdown", content: "Raw body" }],
|
||||
}),
|
||||
interactive: {
|
||||
blocks: [{ type: "text", text: "Interactive body" }],
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
} as never);
|
||||
|
||||
const sendCardArgs = requireRecord(
|
||||
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
|
||||
"send card args",
|
||||
);
|
||||
const card = requireRecord(sendCardArgs.card, "card");
|
||||
expect(card.header).toBeUndefined();
|
||||
expect(card.body).toEqual({
|
||||
elements: [{ tag: "markdown", content: "Interactive body" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders presentation buttons as native Feishu card buttons", async () => {
|
||||
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ import {
|
|||
createChannelDirectoryAdapter,
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import {
|
||||
interactiveReplyToPresentation,
|
||||
normalizeInteractiveReply,
|
||||
normalizeMessagePresentation,
|
||||
resolveInteractiveTextFallback,
|
||||
} from "openclaw/plugin-sdk/interactive-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
|
||||
|
|
@ -72,6 +77,7 @@ import {
|
|||
import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js";
|
||||
import { feishuDoctor } from "./doctor.js";
|
||||
import { messageActionTargetAliases } from "./message-action-contract.js";
|
||||
import { readNativeFeishuCardJson } from "./native-card.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { buildFeishuPresentationCard } from "./presentation-card.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
|
|
@ -584,6 +590,26 @@ function readFirstString(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const UNRESOLVED_RESPONSE_PREFIX_VAR_PATTERN = /\{[a-zA-Z][a-zA-Z0-9.]*\}/;
|
||||
|
||||
function resolveFeishuMessageActionResponsePrefix(ctx: ChannelMessageActionContext) {
|
||||
const configured = ctx.cfg.messages?.responsePrefix;
|
||||
if (!configured) {
|
||||
return undefined;
|
||||
}
|
||||
const agentId = (ctx.agentId?.trim() || "main").toLowerCase();
|
||||
const identityName = ctx.cfg.agents?.list
|
||||
?.find((agent) => agent.id.trim().toLowerCase() === agentId)
|
||||
?.identity?.name?.trim();
|
||||
const resolved =
|
||||
configured === "auto"
|
||||
? identityName
|
||||
? `[${identityName}]`
|
||||
: undefined
|
||||
: configured.replace(/\{(?:identity\.name|identityname)\}/gi, identityName ?? "$&");
|
||||
return resolved && !UNRESOLVED_RESPONSE_PREFIX_VAR_PATTERN.test(resolved) ? resolved : undefined;
|
||||
}
|
||||
|
||||
function readOptionalPositiveInteger(
|
||||
params: Record<string, unknown>,
|
||||
keys: string[],
|
||||
|
|
@ -801,13 +827,24 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
|||
if (ctx.action === "thread-reply" && !replyToMessageId) {
|
||||
throw new Error("Feishu thread-reply requires messageId.");
|
||||
}
|
||||
const presentation = normalizeMessagePresentation(ctx.params.presentation);
|
||||
const text = readFirstString(ctx.params, ["text", "message"]);
|
||||
const textCard = readNativeFeishuCardJson(text, {
|
||||
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
|
||||
});
|
||||
const interactive = normalizeInteractiveReply(ctx.params.interactive);
|
||||
const presentation =
|
||||
normalizeMessagePresentation(ctx.params.presentation) ??
|
||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
||||
const mediaUrl = readFeishuMediaParam(ctx.params);
|
||||
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
|
||||
const card = presentation
|
||||
? buildFeishuPresentationCard({ presentation, fallbackText: text })
|
||||
: undefined;
|
||||
? buildFeishuPresentationCard({
|
||||
presentation,
|
||||
fallbackText: textCard
|
||||
? undefined
|
||||
: resolveInteractiveTextFallback({ text, interactive }),
|
||||
})
|
||||
: textCard;
|
||||
if (card && mediaUrl) {
|
||||
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
|
||||
}
|
||||
|
|
|
|||
230
extensions/feishu/src/native-card.ts
Normal file
230
extensions/feishu/src/native-card.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
// Feishu native interactive card helpers shared by action and outbound delivery.
|
||||
import {
|
||||
isRecord,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const FEISHU_CARD_TEMPLATES = new Set([
|
||||
"blue",
|
||||
"green",
|
||||
"red",
|
||||
"orange",
|
||||
"purple",
|
||||
"indigo",
|
||||
"wathet",
|
||||
"turquoise",
|
||||
"yellow",
|
||||
"grey",
|
||||
"carmine",
|
||||
"violet",
|
||||
"lime",
|
||||
]);
|
||||
|
||||
export function resolveFeishuCardTemplate(template?: string): string | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(template);
|
||||
if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function escapeFeishuCardMarkdownText(text: string): string {
|
||||
return text.replace(/[&<>]/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeFeishuCardPlainText(text: string): string {
|
||||
return escapeFeishuCardMarkdownText(text).replace(/([\\`*_{}[\]()#+\-!|>~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function resolveSafeFeishuButtonUrl(url: unknown): string | undefined {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:" ? trimmed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeNativeFeishuButtonBehavior(
|
||||
behavior: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!isRecord(behavior)) {
|
||||
return undefined;
|
||||
}
|
||||
if (behavior.type === "open_url") {
|
||||
const safeUrl =
|
||||
resolveSafeFeishuButtonUrl(behavior.default_url) ?? resolveSafeFeishuButtonUrl(behavior.url);
|
||||
return safeUrl ? { type: "open_url", default_url: safeUrl } : undefined;
|
||||
}
|
||||
if (behavior.type === "callback" && isRecord(behavior.value) && behavior.value.oc === "ocf1") {
|
||||
return { type: "callback", value: behavior.value };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sanitizeNativeFeishuCardButton(button: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(button)) {
|
||||
return undefined;
|
||||
}
|
||||
const text =
|
||||
isRecord(button.text) && typeof button.text.content === "string"
|
||||
? button.text.content
|
||||
: undefined;
|
||||
if (!text?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const style =
|
||||
button.type === "danger"
|
||||
? "danger"
|
||||
: button.type === "primary" || button.type === "success"
|
||||
? "primary"
|
||||
: undefined;
|
||||
const behaviors = Array.isArray(button.behaviors)
|
||||
? button.behaviors
|
||||
.map((behavior) => sanitizeNativeFeishuButtonBehavior(behavior))
|
||||
.filter((behavior): behavior is Record<string, unknown> => Boolean(behavior))
|
||||
: [];
|
||||
const rootSafeUrl = resolveSafeFeishuButtonUrl(button.url);
|
||||
if (rootSafeUrl) {
|
||||
behaviors.push({ type: "open_url", default_url: rootSafeUrl });
|
||||
}
|
||||
if (isRecord(button.value) && button.value.oc === "ocf1") {
|
||||
behaviors.push({ type: "callback", value: button.value });
|
||||
}
|
||||
if (behaviors.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: text },
|
||||
type: style === "danger" ? "danger" : style === "primary" ? "primary" : "default",
|
||||
behaviors,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeNativeFeishuCardElements(element: unknown): Record<string, unknown>[] {
|
||||
if (!isRecord(element) || typeof element.tag !== "string") {
|
||||
return [];
|
||||
}
|
||||
if (element.tag === "hr") {
|
||||
return [{ tag: "hr" }];
|
||||
}
|
||||
if (element.tag === "markdown" && typeof element.content === "string") {
|
||||
return [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: escapeFeishuCardMarkdownText(element.content),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (element.tag === "div" && isRecord(element.text)) {
|
||||
const text = element.text;
|
||||
if (text.tag === "lark_md" && typeof text.content === "string") {
|
||||
return [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: escapeFeishuCardMarkdownText(text.content),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (text.tag === "plain_text" && typeof text.content === "string") {
|
||||
return [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: escapeFeishuCardPlainText(text.content),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (element.tag === "button") {
|
||||
const button = sanitizeNativeFeishuCardButton(element);
|
||||
return button ? [button] : [];
|
||||
}
|
||||
if (element.tag === "action" && Array.isArray(element.actions)) {
|
||||
return element.actions
|
||||
.map((action) => sanitizeNativeFeishuCardButton(action))
|
||||
.filter((action): action is Record<string, unknown> => Boolean(action));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function sanitizeNativeFeishuCard(
|
||||
card: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const normalizedCard = card.type === "interactive" && isRecord(card.card) ? card.card : card;
|
||||
const body = isRecord(normalizedCard.body) ? normalizedCard.body : undefined;
|
||||
const rawElements = Array.isArray(body?.elements)
|
||||
? body.elements
|
||||
: Array.isArray(normalizedCard.elements)
|
||||
? normalizedCard.elements
|
||||
: [];
|
||||
const elements = rawElements
|
||||
.flatMap((element) => sanitizeNativeFeishuCardElements(element))
|
||||
.filter((element): element is Record<string, unknown> => Boolean(element));
|
||||
if (elements.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const header = isRecord(normalizedCard.header) ? normalizedCard.header : undefined;
|
||||
const title =
|
||||
isRecord(header?.title) && typeof header.title.content === "string"
|
||||
? header.title.content
|
||||
: undefined;
|
||||
return {
|
||||
schema: "2.0",
|
||||
config: { width_mode: "fill" },
|
||||
...(title?.trim()
|
||||
? {
|
||||
header: {
|
||||
title: { tag: "plain_text", content: title },
|
||||
template:
|
||||
resolveFeishuCardTemplate(
|
||||
typeof header?.template === "string" ? header.template : undefined,
|
||||
) ?? "blue",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
body: { elements },
|
||||
};
|
||||
}
|
||||
|
||||
export function readNativeFeishuCardJson(
|
||||
text: string | undefined,
|
||||
options?: { responsePrefix?: string },
|
||||
): Record<string, unknown> | undefined {
|
||||
let trimmed = text?.trim();
|
||||
const responsePrefix = options?.responsePrefix;
|
||||
if (trimmed && responsePrefix && trimmed.startsWith(responsePrefix)) {
|
||||
const suffix = trimmed.slice(responsePrefix.length);
|
||||
// The runner inserts one separator before the original message. Requiring it
|
||||
// avoids treating arbitrary prose before a JSON object as a native card.
|
||||
if (/^\s+\{/.test(suffix)) {
|
||||
trimmed = suffix.trimStart();
|
||||
}
|
||||
}
|
||||
if (!trimmed?.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
return isRecord(parsed) ? sanitizeNativeFeishuCard(parsed) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -329,6 +329,34 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
|||
expect(sendMessageCall()?.accountId).toBe("main");
|
||||
});
|
||||
|
||||
it("sends wrapped interactive card text as a native Feishu card", async () => {
|
||||
const text = JSON.stringify({
|
||||
type: "interactive",
|
||||
card: {
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "Wrapped body" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await sendText({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
replyToId: "om_reply_1",
|
||||
});
|
||||
|
||||
expect(sendCardCall()?.to).toBe("chat_1");
|
||||
expect(sendCardCall()?.accountId).toBe("main");
|
||||
expect(sendCardCall()?.replyToMessageId).toBe("om_reply_1");
|
||||
expect(sendCardCall()?.card?.body?.elements).toEqual([
|
||||
{ tag: "markdown", content: "Wrapped body" },
|
||||
]);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expectFeishuResult(result, "native_card_msg");
|
||||
});
|
||||
|
||||
it("falls back to plain text if local-image media send fails", async () => {
|
||||
const { dir, file } = await createTmpImage();
|
||||
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
|
||||
|
|
@ -790,6 +818,194 @@ describe("feishuOutbound.sendPayload native cards", () => {
|
|||
expect(JSON.stringify(card)).not.toContain("image-secret");
|
||||
});
|
||||
|
||||
it("sends plain payload text card JSON as a native Feishu card", async () => {
|
||||
const text = JSON.stringify({
|
||||
schema: "2.0",
|
||||
header: {
|
||||
title: { tag: "plain_text", content: "Plain JSON card" },
|
||||
template: "green",
|
||||
},
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "Card body" }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
const card = sendCardCall()?.card;
|
||||
expect(card.header).toEqual({
|
||||
title: { tag: "plain_text", content: "Plain JSON card" },
|
||||
template: "green",
|
||||
});
|
||||
expect(card.body.elements).toEqual([{ tag: "markdown", content: "Card body" }]);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expectFeishuResult(result, "native_card_msg");
|
||||
});
|
||||
|
||||
it("sends legacy top-level elements payload text card JSON as a native Feishu card", async () => {
|
||||
const text = JSON.stringify({
|
||||
header: {
|
||||
title: { tag: "plain_text", content: "Legacy JSON card" },
|
||||
template: "green",
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
tag: "div",
|
||||
text: { tag: "lark_md", content: "**Legacy body**" },
|
||||
},
|
||||
{
|
||||
tag: "div",
|
||||
text: { tag: "plain_text", content: "Literal *text*" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
const card = sendCardCall()?.card;
|
||||
expect(card.header).toEqual({
|
||||
title: { tag: "plain_text", content: "Legacy JSON card" },
|
||||
template: "green",
|
||||
});
|
||||
expect(card.body.elements).toEqual([
|
||||
{ tag: "markdown", content: "**Legacy body**" },
|
||||
{ tag: "markdown", content: "Literal \\*text\\*" },
|
||||
]);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expectFeishuResult(result, "native_card_msg");
|
||||
});
|
||||
|
||||
it.each(["lark_md", "plain_text"])(
|
||||
"keeps top-level legacy %s text items on the text fallback path",
|
||||
async (tag) => {
|
||||
const text = JSON.stringify({
|
||||
elements: [{ tag, content: "Not a valid root legacy card element" }],
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageCall()?.text).toBe(text);
|
||||
expectFeishuResult(result, "text_msg");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unsupported legacy element shapes on the text fallback path", async () => {
|
||||
const text = JSON.stringify({
|
||||
elements: [
|
||||
{
|
||||
tag: "div",
|
||||
text: { tag: "unsupported", content: "Not a supported legacy text element" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageCall()?.text).toBe(text);
|
||||
expectFeishuResult(result, "text_msg");
|
||||
});
|
||||
|
||||
it("prefers structured presentation over raw card JSON payload text", async () => {
|
||||
const text = JSON.stringify({
|
||||
header: { title: { tag: "plain_text", content: "Raw card" } },
|
||||
elements: [{ tag: "markdown", content: "Raw body" }],
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: {
|
||||
text,
|
||||
presentation: {
|
||||
title: "Structured card",
|
||||
blocks: [{ type: "text", text: "Structured body" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const card = sendCardCall()?.card;
|
||||
expect(card.header).toEqual({
|
||||
title: { tag: "plain_text", content: "Structured card" },
|
||||
template: "blue",
|
||||
});
|
||||
expect(card.body.elements).toEqual([{ tag: "markdown", content: "Structured body" }]);
|
||||
expectFeishuResult(result, "native_card_msg");
|
||||
});
|
||||
|
||||
it("prefers structured interactive input over raw card JSON payload text", async () => {
|
||||
const text = JSON.stringify({
|
||||
header: { title: { tag: "plain_text", content: "Raw card" } },
|
||||
elements: [{ tag: "markdown", content: "Raw body" }],
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: {
|
||||
text,
|
||||
interactive: {
|
||||
blocks: [{ type: "text", text: "Interactive body" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const card = sendCardCall()?.card;
|
||||
expect(card.header).toBeUndefined();
|
||||
expect(card.body.elements).toEqual([{ tag: "markdown", content: "Interactive body" }]);
|
||||
expectFeishuResult(result, "native_card_msg");
|
||||
});
|
||||
|
||||
it("keeps invalid plain card JSON on the text fallback path", async () => {
|
||||
const text = JSON.stringify({
|
||||
schema: "2.0",
|
||||
body: {
|
||||
elements: [{ tag: "img", img_key: "image-secret" }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "chat_1",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: { text },
|
||||
});
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageCall()?.text).toBe(text);
|
||||
expectFeishuResult(result, "text_msg");
|
||||
});
|
||||
|
||||
it("sends payload media before final native cards", async () => {
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
|
|
@ -867,6 +1083,51 @@ describe("feishuOutbound.sendPayload native cards", () => {
|
|||
expectFeishuResult(result, "reply_msg");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"presentation",
|
||||
{
|
||||
presentation: {
|
||||
title: "Structured card",
|
||||
blocks: [{ type: "text" as const, text: "Structured body" }],
|
||||
},
|
||||
},
|
||||
"Structured card\n\nStructured body",
|
||||
],
|
||||
[
|
||||
"interactive",
|
||||
{
|
||||
interactive: {
|
||||
blocks: [{ type: "text" as const, text: "Interactive body" }],
|
||||
},
|
||||
},
|
||||
"Interactive body",
|
||||
],
|
||||
])(
|
||||
"prefers structured %s over raw card JSON for document comments",
|
||||
async (_kind, structuredPayload, expectedText) => {
|
||||
const text = JSON.stringify({
|
||||
header: { title: { tag: "plain_text", content: "Raw card" } },
|
||||
elements: [{ tag: "markdown", content: "Raw body" }],
|
||||
});
|
||||
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
to: "comment:docx:doxcn123:7623358762119646411",
|
||||
text,
|
||||
accountId: "main",
|
||||
payload: {
|
||||
text,
|
||||
...structuredPayload,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(commentThreadParams()?.content).toBe(expectedText);
|
||||
expectFeishuResult(result, "reply_msg");
|
||||
},
|
||||
);
|
||||
|
||||
it("omits command guidance when all command buttons have URLs overriding the fallback text", async () => {
|
||||
const result = await feishuOutbound.sendPayload?.({
|
||||
cfg: emptyConfig,
|
||||
|
|
|
|||
|
|
@ -34,10 +34,14 @@ import {
|
|||
shouldSuppressFeishuTextForVoiceMedia,
|
||||
type SendMediaResult,
|
||||
} from "./media.js";
|
||||
import {
|
||||
readNativeFeishuCardJson,
|
||||
resolveFeishuCardTemplate,
|
||||
sanitizeNativeFeishuCard,
|
||||
} from "./native-card.js";
|
||||
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
|
||||
import { buildFeishuPresentationCardElements } from "./presentation-card.js";
|
||||
import {
|
||||
resolveFeishuCardTemplate,
|
||||
sendCardFeishu,
|
||||
sendMarkdownCardFeishu,
|
||||
sendMessageFeishu,
|
||||
|
|
@ -99,159 +103,6 @@ function markRenderedFeishuCard(card: Record<string, unknown>): Record<string, u
|
|||
return card;
|
||||
}
|
||||
|
||||
function escapeFeishuCardMarkdownText(text: string): string {
|
||||
return text.replace(/[&<>]/g, (char) => {
|
||||
switch (char) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSafeFeishuButtonUrl(url: unknown): string | undefined {
|
||||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:" ? trimmed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeNativeFeishuButtonBehavior(
|
||||
behavior: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!isRecord(behavior)) {
|
||||
return undefined;
|
||||
}
|
||||
if (behavior.type === "open_url") {
|
||||
const safeUrl =
|
||||
resolveSafeFeishuButtonUrl(behavior.default_url) ?? resolveSafeFeishuButtonUrl(behavior.url);
|
||||
return safeUrl ? { type: "open_url", default_url: safeUrl } : undefined;
|
||||
}
|
||||
if (behavior.type === "callback" && isRecord(behavior.value) && behavior.value.oc === "ocf1") {
|
||||
return { type: "callback", value: behavior.value };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sanitizeNativeFeishuCardButton(button: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(button)) {
|
||||
return undefined;
|
||||
}
|
||||
const text =
|
||||
isRecord(button.text) && typeof button.text.content === "string"
|
||||
? button.text.content
|
||||
: undefined;
|
||||
if (!text?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const style =
|
||||
button.type === "danger"
|
||||
? "danger"
|
||||
: button.type === "primary" || button.type === "success"
|
||||
? "primary"
|
||||
: undefined;
|
||||
const behaviors = Array.isArray(button.behaviors)
|
||||
? button.behaviors
|
||||
.map((behavior) => sanitizeNativeFeishuButtonBehavior(behavior))
|
||||
.filter((behavior): behavior is Record<string, unknown> => Boolean(behavior))
|
||||
: [];
|
||||
const rootSafeUrl = resolveSafeFeishuButtonUrl(button.url);
|
||||
if (rootSafeUrl) {
|
||||
behaviors.push({ type: "open_url", default_url: rootSafeUrl });
|
||||
}
|
||||
if (isRecord(button.value) && button.value.oc === "ocf1") {
|
||||
behaviors.push({ type: "callback", value: button.value });
|
||||
}
|
||||
if (behaviors.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const rendered: Record<string, unknown> = {
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: text },
|
||||
type:
|
||||
style === "danger"
|
||||
? "danger"
|
||||
: style === "primary" || style === "success"
|
||||
? "primary"
|
||||
: "default",
|
||||
behaviors,
|
||||
};
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function sanitizeNativeFeishuCardElements(element: unknown): Record<string, unknown>[] {
|
||||
if (!isRecord(element) || typeof element.tag !== "string") {
|
||||
return [];
|
||||
}
|
||||
if (element.tag === "hr") {
|
||||
return [{ tag: "hr" }];
|
||||
}
|
||||
if (element.tag === "markdown" && typeof element.content === "string") {
|
||||
return [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: escapeFeishuCardMarkdownText(element.content),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (element.tag === "button") {
|
||||
const button = sanitizeNativeFeishuCardButton(element);
|
||||
return button ? [button] : [];
|
||||
}
|
||||
if (element.tag === "action" && Array.isArray(element.actions)) {
|
||||
return element.actions
|
||||
.map((action) => sanitizeNativeFeishuCardButton(action))
|
||||
.filter((action): action is Record<string, unknown> => Boolean(action));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function sanitizeNativeFeishuCard(
|
||||
card: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const body = isRecord(card.body) ? card.body : undefined;
|
||||
const rawElements = Array.isArray(body?.elements) ? body.elements : [];
|
||||
const elements = rawElements
|
||||
.flatMap((element) => sanitizeNativeFeishuCardElements(element))
|
||||
.filter((element): element is Record<string, unknown> => Boolean(element));
|
||||
if (elements.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const header = isRecord(card.header) ? card.header : undefined;
|
||||
const title =
|
||||
isRecord(header?.title) && typeof header.title.content === "string"
|
||||
? header.title.content
|
||||
: undefined;
|
||||
return markRenderedFeishuCard({
|
||||
schema: "2.0",
|
||||
config: { width_mode: "fill" },
|
||||
...(title?.trim()
|
||||
? {
|
||||
header: {
|
||||
title: { tag: "plain_text", content: title },
|
||||
template:
|
||||
resolveFeishuCardTemplate(
|
||||
typeof header?.template === "string" ? header.template : undefined,
|
||||
) ?? "blue",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
body: { elements },
|
||||
});
|
||||
}
|
||||
|
||||
function readNativeFeishuCard(payload: { channelData?: Record<string, unknown> }) {
|
||||
const feishuData = payload.channelData?.feishu;
|
||||
if (!isRecord(feishuData)) {
|
||||
|
|
@ -264,7 +115,8 @@ function readNativeFeishuCard(payload: { channelData?: Record<string, unknown> }
|
|||
if ((card as { [RENDERED_FEISHU_CARD]?: true })[RENDERED_FEISHU_CARD] === true) {
|
||||
return card;
|
||||
}
|
||||
return sanitizeNativeFeishuCard(card);
|
||||
const sanitizedCard = sanitizeNativeFeishuCard(card);
|
||||
return sanitizedCard ? markRenderedFeishuCard(sanitizedCard) : undefined;
|
||||
}
|
||||
|
||||
function buildFeishuPayloadCard(params: {
|
||||
|
|
@ -277,18 +129,22 @@ function buildFeishuPayloadCard(params: {
|
|||
return nativeCard;
|
||||
}
|
||||
|
||||
const rawText = params.text ?? params.payload.text;
|
||||
const textCard = readNativeFeishuCardJson(rawText);
|
||||
const interactive = normalizeInteractiveReply(params.payload.interactive);
|
||||
const presentation =
|
||||
normalizeMessagePresentation(params.payload.presentation) ??
|
||||
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
|
||||
if (!presentation && !interactive) {
|
||||
return undefined;
|
||||
return textCard ? markRenderedFeishuCard(textCard) : undefined;
|
||||
}
|
||||
|
||||
const text = resolveInteractiveTextFallback({
|
||||
text: params.text ?? params.payload.text,
|
||||
interactive,
|
||||
});
|
||||
const text = textCard
|
||||
? undefined
|
||||
: resolveInteractiveTextFallback({
|
||||
text: rawText,
|
||||
interactive,
|
||||
});
|
||||
const elements = presentation
|
||||
? buildFeishuPresentationCardElements({ presentation, fallbackText: text })
|
||||
: [
|
||||
|
|
@ -540,8 +396,14 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|||
const interactive = normalizeInteractiveReply(ctx.payload.interactive);
|
||||
return interactive ? interactiveReplyToPresentation(interactive) : undefined;
|
||||
})();
|
||||
// Structured content replaces raw card JSON; document comments should
|
||||
// render only the usable text fallback instead of exposing both forms.
|
||||
const fallbackSourceText =
|
||||
normalizedPresentation && readNativeFeishuCardJson(ctx.payload.text)
|
||||
? undefined
|
||||
: ctx.payload.text;
|
||||
const presentationFallbackText = renderMessagePresentationFallbackText({
|
||||
text: ctx.payload.text,
|
||||
text: fallbackSourceText,
|
||||
presentation: normalizedPresentation,
|
||||
});
|
||||
// Direct delivery retains blocks; core-rendered delivery carries the fact.
|
||||
|
|
@ -653,6 +515,18 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
|||
});
|
||||
}
|
||||
|
||||
const card = readNativeFeishuCardJson(text);
|
||||
if (card) {
|
||||
return await sendCardFeishu({
|
||||
cfg,
|
||||
to,
|
||||
card: markRenderedFeishuCard(card),
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
}
|
||||
|
||||
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
|
||||
const renderMode = account.config?.renderMode ?? "auto";
|
||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtim
|
|||
import {
|
||||
isRecord,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
|
|
@ -13,6 +12,7 @@ import { createFeishuClient } from "./client.js";
|
|||
import { requestFeishuApi } from "./comment-shared.js";
|
||||
import type { MentionTarget } from "./mention-target.types.js";
|
||||
import { buildMentionedCardContent } from "./mention.js";
|
||||
import { resolveFeishuCardTemplate } from "./native-card.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
import {
|
||||
assertFeishuMessageApiSuccess,
|
||||
|
|
@ -22,25 +22,11 @@ import {
|
|||
import { resolveFeishuSendTarget } from "./send-target.js";
|
||||
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
|
||||
|
||||
export { resolveFeishuCardTemplate };
|
||||
|
||||
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",
|
||||
"red",
|
||||
"orange",
|
||||
"purple",
|
||||
"indigo",
|
||||
"wathet",
|
||||
"turquoise",
|
||||
"yellow",
|
||||
"grey",
|
||||
"carmine",
|
||||
"violet",
|
||||
"lime",
|
||||
]);
|
||||
|
||||
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
|
||||
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
|
||||
return true;
|
||||
|
|
@ -745,14 +731,6 @@ export type CardHeaderConfig = {
|
|||
template?: string;
|
||||
};
|
||||
|
||||
export function resolveFeishuCardTemplate(template?: string): string | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(template);
|
||||
if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Feishu interactive card with optional header and note footer.
|
||||
* When header/note are omitted, behaves identically to buildMarkdownCard.
|
||||
|
|
|
|||
|
|
@ -1657,12 +1657,27 @@ describe("runMessageAction plugin dispatch", () => {
|
|||
});
|
||||
|
||||
describe("presentation-only send behavior", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
ok: true,
|
||||
presentation: params.presentation ?? null,
|
||||
message: params.message ?? null,
|
||||
}),
|
||||
const handleAction = vi.fn(
|
||||
async ({ cfg, params }: { cfg: OpenClawConfig; params: Record<string, unknown> }) => {
|
||||
const message = typeof params.message === "string" ? params.message : "";
|
||||
const responsePrefix = cfg.messages?.responsePrefix;
|
||||
const rawMessage =
|
||||
responsePrefix && message.startsWith(`${responsePrefix} `)
|
||||
? message.slice(responsePrefix.length + 1)
|
||||
: message;
|
||||
let detectedCard = false;
|
||||
try {
|
||||
detectedCard = isRecord((JSON.parse(rawMessage) as { body?: unknown }).body);
|
||||
} catch {
|
||||
// Non-JSON text remains a normal plugin message.
|
||||
}
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
presentation: params.presentation ?? null,
|
||||
message: params.message ?? null,
|
||||
detectedCard,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const cardPlugin: ChannelPlugin = {
|
||||
|
|
@ -1737,6 +1752,44 @@ describe("runMessageAction plugin dispatch", () => {
|
|||
"result payload",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps prefixed JSON recoverable by plugin-owned card detection", async () => {
|
||||
const cardJson = JSON.stringify({
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "Card body" }],
|
||||
},
|
||||
});
|
||||
const result = await runMessageAction({
|
||||
cfg: {
|
||||
channels: {
|
||||
cardchat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
messages: { responsePrefix: "[Nexus]" },
|
||||
} as OpenClawConfig,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "cardchat",
|
||||
target: "channel:test-card",
|
||||
message: cardJson,
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(result.handledBy).toBe("plugin");
|
||||
expectRecordFields(
|
||||
readRecordField(result, "payload", "result payload"),
|
||||
{
|
||||
ok: true,
|
||||
detectedCard: true,
|
||||
},
|
||||
"result payload",
|
||||
);
|
||||
const pluginParams = readRecordField(readFirstPluginCall(handleAction), "params", "params");
|
||||
expect(pluginParams.message).toBe(`[Nexus] ${cardJson}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("poll plugin forwarding", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue