Skip to content

Commit 25a7708

Browse files
vincentkocopenclaw-clownfish[bot]martingarramon
authored
fix(feishu): send card JSON message params as cards (#100883)
* fix(feishu): send plain card JSON as interactive cards Co-authored-by: martingarramon <[email protected]> * fix(clownfish): address review for gitcrawl-21-autonomous-drip-20260706 (1) Co-authored-by: martingarramon <[email protected]> * fix(feishu): preserve native card compatibility Reported-by: @ZenoRewn Co-authored-by: martingarramon <[email protected]> * 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 <[email protected]>
1 parent bab1415 commit 25a7708

7 files changed

Lines changed: 906 additions & 196 deletions

File tree

extensions/feishu/src/channel.test.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,217 @@ describe("feishuPlugin actions", () => {
356356
expect(details.chatId).toBe("oc_group_1");
357357
});
358358

359+
it("sends plain message card JSON as a native Feishu card", async () => {
360+
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
361+
362+
const result = await feishuPlugin.actions?.handleAction?.({
363+
action: "send",
364+
params: {
365+
to: "chat:oc_group_1",
366+
message: JSON.stringify({
367+
schema: "2.0",
368+
header: {
369+
title: { tag: "plain_text", content: "Plain JSON card" },
370+
template: "green",
371+
},
372+
body: {
373+
elements: [{ tag: "markdown", content: "Card body" }],
374+
},
375+
}),
376+
},
377+
cfg,
378+
accountId: undefined,
379+
toolContext: {},
380+
} as never);
381+
382+
const sendCardArgs = requireRecord(
383+
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
384+
"send card args",
385+
);
386+
expect(sendCardArgs.cfg).toBe(cfg);
387+
expect(sendCardArgs.to).toBe("chat:oc_group_1");
388+
expect(sendCardArgs.accountId).toBeUndefined();
389+
expect(sendCardArgs.replyToMessageId).toBeUndefined();
390+
expect(sendCardArgs.replyInThread).toBe(false);
391+
const card = requireRecord(sendCardArgs.card, "card");
392+
expect(card.header).toEqual({
393+
title: { tag: "plain_text", content: "Plain JSON card" },
394+
template: "green",
395+
});
396+
expect(card.body).toEqual({
397+
elements: [{ tag: "markdown", content: "Card body" }],
398+
});
399+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
400+
const details = resultDetails(result);
401+
expect(details.ok).toBe(true);
402+
expect(details.messageId).toBe("om_card");
403+
expect(details.chatId).toBe("oc_group_1");
404+
});
405+
406+
it("sends legacy top-level elements card JSON as a native Feishu card", async () => {
407+
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
408+
409+
await feishuPlugin.actions?.handleAction?.({
410+
action: "send",
411+
params: {
412+
to: "chat:oc_group_1",
413+
message: JSON.stringify({
414+
header: {
415+
title: { tag: "plain_text", content: "Legacy JSON card" },
416+
template: "green",
417+
},
418+
elements: [
419+
{
420+
tag: "div",
421+
text: { tag: "lark_md", content: '**Legacy** <at id="ou_1">body</at>' },
422+
},
423+
{
424+
tag: "div",
425+
text: { tag: "plain_text", content: "Literal *text*" },
426+
},
427+
],
428+
}),
429+
},
430+
cfg,
431+
accountId: undefined,
432+
toolContext: {},
433+
} as never);
434+
435+
const sendCardArgs = requireRecord(
436+
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
437+
"send card args",
438+
);
439+
const card = requireRecord(sendCardArgs.card, "card");
440+
expect(card.header).toEqual({
441+
title: { tag: "plain_text", content: "Legacy JSON card" },
442+
template: "green",
443+
});
444+
expect(card.body).toEqual({
445+
elements: [
446+
{
447+
tag: "markdown",
448+
content: '**Legacy** &lt;at id="ou_1"&gt;body&lt;/at&gt;',
449+
},
450+
{ tag: "markdown", content: "Literal \\*text\\*" },
451+
],
452+
});
453+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
454+
});
455+
456+
it("detects message card JSON after the configured response prefix", async () => {
457+
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
458+
const cardJson = JSON.stringify({
459+
body: {
460+
elements: [{ tag: "markdown", content: "Prefixed card" }],
461+
},
462+
});
463+
464+
await feishuPlugin.actions?.handleAction?.({
465+
action: "send",
466+
params: {
467+
to: "chat:oc_group_1",
468+
message: `[Nexus] ${cardJson}`,
469+
},
470+
cfg: {
471+
...cfg,
472+
messages: { responsePrefix: "[Nexus]" },
473+
},
474+
accountId: undefined,
475+
toolContext: {},
476+
} as never);
477+
478+
const sendCardArgs = requireRecord(
479+
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
480+
"send card args",
481+
);
482+
const card = requireRecord(sendCardArgs.card, "card");
483+
expect(card.body).toEqual({
484+
elements: [{ tag: "markdown", content: "Prefixed card" }],
485+
});
486+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
487+
});
488+
489+
it("sends wrapped interactive card JSON as a Feishu thread reply card", async () => {
490+
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
491+
492+
await feishuPlugin.actions?.handleAction?.({
493+
action: "thread-reply",
494+
params: {
495+
to: "chat:oc_group_1",
496+
messageId: "om_parent",
497+
text: JSON.stringify({
498+
type: "interactive",
499+
card: {
500+
body: {
501+
elements: [{ tag: "markdown", content: "Reply card" }],
502+
},
503+
},
504+
}),
505+
},
506+
cfg,
507+
accountId: undefined,
508+
toolContext: {},
509+
} as never);
510+
511+
const sendCardArgs = requireRecord(
512+
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
513+
"send card args",
514+
);
515+
expect(sendCardArgs.replyToMessageId).toBe("om_parent");
516+
expect(sendCardArgs.replyInThread).toBe(true);
517+
const card = requireRecord(sendCardArgs.card, "card");
518+
expect(card.body).toEqual({
519+
elements: [{ tag: "markdown", content: "Reply card" }],
520+
});
521+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
522+
});
523+
524+
it("keeps ordinary JSON messages on the text path", async () => {
525+
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_sent", chatId: "oc_group_1" });
526+
const message = JSON.stringify({ ok: true, elements: "not-a-card" });
527+
528+
await feishuPlugin.actions?.handleAction?.({
529+
action: "send",
530+
params: { to: "chat:oc_group_1", message },
531+
cfg,
532+
accountId: undefined,
533+
toolContext: {},
534+
} as never);
535+
536+
expect(sendCardFeishuMock).not.toHaveBeenCalled();
537+
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
538+
cfg,
539+
to: "chat:oc_group_1",
540+
text: message,
541+
accountId: undefined,
542+
replyToMessageId: undefined,
543+
replyInThread: false,
544+
});
545+
});
546+
547+
it("rejects card JSON sent with media", async () => {
548+
await expect(
549+
feishuPlugin.actions?.handleAction?.({
550+
action: "send",
551+
params: {
552+
to: "chat:oc_group_1",
553+
message: JSON.stringify({
554+
elements: [{ tag: "markdown", content: "Card body" }],
555+
}),
556+
media: "/tmp/image.png",
557+
},
558+
cfg,
559+
accountId: undefined,
560+
toolContext: {},
561+
mediaLocalRoots: ["/tmp"],
562+
} as never),
563+
).rejects.toThrow("Feishu send does not support card with media.");
564+
565+
expect(sendCardFeishuMock).not.toHaveBeenCalled();
566+
expect(feishuOutboundSendMediaMock).not.toHaveBeenCalled();
567+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
568+
});
569+
359570
it("renders presentation messages as cards", async () => {
360571
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
361572

@@ -402,6 +613,72 @@ describe("feishuPlugin actions", () => {
402613
expect(details.chatId).toBe("oc_group_1");
403614
});
404615

616+
it("prefers structured presentation over raw card JSON text", async () => {
617+
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
618+
619+
await feishuPlugin.actions?.handleAction?.({
620+
action: "send",
621+
params: {
622+
to: "chat:oc_group_1",
623+
message: JSON.stringify({
624+
header: { title: { tag: "plain_text", content: "Raw card" } },
625+
elements: [{ tag: "markdown", content: "Raw body" }],
626+
}),
627+
presentation: {
628+
title: "Structured card",
629+
blocks: [{ type: "text", text: "Structured body" }],
630+
},
631+
},
632+
cfg,
633+
accountId: undefined,
634+
toolContext: {},
635+
} as never);
636+
637+
const sendCardArgs = requireRecord(
638+
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
639+
"send card args",
640+
);
641+
const card = requireRecord(sendCardArgs.card, "card");
642+
expect(card.header).toEqual({
643+
title: { tag: "plain_text", content: "Structured card" },
644+
template: "blue",
645+
});
646+
expect(card.body).toEqual({
647+
elements: [{ tag: "markdown", content: "Structured body" }],
648+
});
649+
});
650+
651+
it("prefers structured interactive input over raw card JSON text", async () => {
652+
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
653+
654+
await feishuPlugin.actions?.handleAction?.({
655+
action: "send",
656+
params: {
657+
to: "chat:oc_group_1",
658+
message: JSON.stringify({
659+
header: { title: { tag: "plain_text", content: "Raw card" } },
660+
elements: [{ tag: "markdown", content: "Raw body" }],
661+
}),
662+
interactive: {
663+
blocks: [{ type: "text", text: "Interactive body" }],
664+
},
665+
},
666+
cfg,
667+
accountId: undefined,
668+
toolContext: {},
669+
} as never);
670+
671+
const sendCardArgs = requireRecord(
672+
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
673+
"send card args",
674+
);
675+
const card = requireRecord(sendCardArgs.card, "card");
676+
expect(card.header).toBeUndefined();
677+
expect(card.body).toEqual({
678+
elements: [{ tag: "markdown", content: "Interactive body" }],
679+
});
680+
});
681+
405682
it("renders presentation buttons as native Feishu card buttons", async () => {
406683
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
407684

extensions/feishu/src/channel.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ import {
2828
createChannelDirectoryAdapter,
2929
createRuntimeDirectoryLiveAdapter,
3030
} from "openclaw/plugin-sdk/directory-runtime";
31-
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
31+
import {
32+
interactiveReplyToPresentation,
33+
normalizeInteractiveReply,
34+
normalizeMessagePresentation,
35+
resolveInteractiveTextFallback,
36+
} from "openclaw/plugin-sdk/interactive-runtime";
3237
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
3338
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
3439
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
@@ -72,6 +77,7 @@ import {
7277
import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js";
7378
import { feishuDoctor } from "./doctor.js";
7479
import { messageActionTargetAliases } from "./message-action-contract.js";
80+
import { readNativeFeishuCardJson } from "./native-card.js";
7581
import { resolveFeishuGroupToolPolicy } from "./policy.js";
7682
import { buildFeishuPresentationCard } from "./presentation-card.js";
7783
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
@@ -584,6 +590,26 @@ function readFirstString(
584590
return undefined;
585591
}
586592

593+
const UNRESOLVED_RESPONSE_PREFIX_VAR_PATTERN = /\{[a-zA-Z][a-zA-Z0-9.]*\}/;
594+
595+
function resolveFeishuMessageActionResponsePrefix(ctx: ChannelMessageActionContext) {
596+
const configured = ctx.cfg.messages?.responsePrefix;
597+
if (!configured) {
598+
return undefined;
599+
}
600+
const agentId = (ctx.agentId?.trim() || "main").toLowerCase();
601+
const identityName = ctx.cfg.agents?.list
602+
?.find((agent) => agent.id.trim().toLowerCase() === agentId)
603+
?.identity?.name?.trim();
604+
const resolved =
605+
configured === "auto"
606+
? identityName
607+
? `[${identityName}]`
608+
: undefined
609+
: configured.replace(/\{(?:identity\.name|identityname)\}/gi, identityName ?? "$&");
610+
return resolved && !UNRESOLVED_RESPONSE_PREFIX_VAR_PATTERN.test(resolved) ? resolved : undefined;
611+
}
612+
587613
function readOptionalPositiveInteger(
588614
params: Record<string, unknown>,
589615
keys: string[],
@@ -801,13 +827,24 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
801827
if (ctx.action === "thread-reply" && !replyToMessageId) {
802828
throw new Error("Feishu thread-reply requires messageId.");
803829
}
804-
const presentation = normalizeMessagePresentation(ctx.params.presentation);
805830
const text = readFirstString(ctx.params, ["text", "message"]);
831+
const textCard = readNativeFeishuCardJson(text, {
832+
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
833+
});
834+
const interactive = normalizeInteractiveReply(ctx.params.interactive);
835+
const presentation =
836+
normalizeMessagePresentation(ctx.params.presentation) ??
837+
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
806838
const mediaUrl = readFeishuMediaParam(ctx.params);
807839
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
808840
const card = presentation
809-
? buildFeishuPresentationCard({ presentation, fallbackText: text })
810-
: undefined;
841+
? buildFeishuPresentationCard({
842+
presentation,
843+
fallbackText: textCard
844+
? undefined
845+
: resolveInteractiveTextFallback({ text, interactive }),
846+
})
847+
: textCard;
811848
if (card && mediaUrl) {
812849
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
813850
}

0 commit comments

Comments
 (0)