feishu: add structured card actions and interactive approval flows (#47873)

* feishu: add structured card actions and interactive approval flows

* feishu: address review fixes and test-gate regressions

* feishu: hold inflight card dedup until completion

* feishu: restore fire-and-forget bot menu handling

* feishu: format card interaction helpers

* Feishu: add changelog entry for card interactions

* Feishu: add changelog entry for ACP session binding
This commit is contained in:
Tak Hoffman
2026-03-16 01:07:09 -05:00
committed by GitHub
parent aa97368f7d
commit fa62231afc
12 changed files with 1485 additions and 52 deletions

View File

@@ -1,5 +1,15 @@
import { describe, it, expect, vi } from "vitest";
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
handleFeishuCardAction,
resetProcessedFeishuCardActionTokensForTests,
type FeishuCardActionEvent,
} from "./card-action.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import {
FEISHU_APPROVAL_CANCEL_ACTION,
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
// Mock resolveFeishuAccount
vi.mock("./accounts.js", () => ({
@@ -11,12 +21,25 @@ vi.mock("./bot.js", () => ({
handleFeishuMessage: vi.fn(),
}));
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
}));
import { handleFeishuMessage } from "./bot.js";
describe("Feishu Card Action Handler", () => {
const cfg = {} as any; // Minimal mock
const runtime = { log: vi.fn(), error: vi.fn() } as any;
beforeEach(() => {
vi.clearAllMocks();
resetProcessedFeishuCardActionTokensForTests();
});
it("handles card action with text payload", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
@@ -60,4 +83,321 @@ describe("Feishu Card Action Handler", () => {
}),
);
});
it("routes quick command actions with operator and conversation context", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok3",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
sender: expect.objectContaining({
sender_id: expect.objectContaining({
open_id: "u123",
user_id: "uid1",
union_id: "un1",
}),
}),
message: expect.objectContaining({
chat_id: "chat1",
content: '{"text":"/help"}',
}),
}),
}),
);
});
it("opens an approval card for metadata actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok4",
action: {
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
e: Date.now() + 60_000,
},
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
accountId: "main",
card: expect.objectContaining({
header: expect.objectContaining({
title: expect.objectContaining({ content: "Confirm action" }),
}),
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("runs approval confirmation through the normal message path", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok5",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: FEISHU_APPROVAL_CONFIRM_ACTION,
q: "/new",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/new"}',
}),
}),
}),
);
});
it("safely rejects stale structured actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok6",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: expect.stringContaining("expired"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("safely rejects wrong-user structured actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u999", user_id: "uid1", union_id: "un1" },
token: "tok7",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u999", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("different user"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("sends a lightweight cancellation notice", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok8",
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: FEISHU_APPROVAL_CANCEL_ACTION,
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: "Cancelled.",
}),
);
});
it("preserves p2p callbacks for DM quick actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok9",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "p2p-chat-1", t: "p2p", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "p2p-chat-1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "p2p-chat-1",
chat_type: "p2p",
}),
}),
}),
);
});
it("drops duplicate structured callback tokens", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok10",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
});
it("releases a claimed token when dispatch fails so retries can succeed", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok11",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
vi.mocked(handleFeishuMessage)
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValueOnce(undefined as never);
await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient");
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
});
it("keeps an in-flight token claimed while a slow dispatch is still running", async () => {
vi.useFakeTimers();
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok12",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
let resolveDispatch: (() => void) | undefined;
vi.mocked(handleFeishuMessage).mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveDispatch = resolve;
}) as never,
);
const first = handleFeishuCardAction({ cfg, event, runtime });
await vi.advanceTimersByTimeAsync(61_000);
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
resolveDispatch?.();
await first;
vi.useRealTimers();
});
});

View File

@@ -1,6 +1,14 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuAccount } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
import {
createApprovalCard,
FEISHU_APPROVAL_CANCEL_ACTION,
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
export type FeishuCardActionEvent = {
operator: {
@@ -20,18 +28,142 @@ export type FeishuCardActionEvent = {
};
};
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
const actionValue = event.action.value;
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
return actionValue.text;
const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000;
const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000;
const processedCardActionTokens = new Map<
string,
{ status: "inflight" | "completed"; expiresAt: number }
>();
export function resetProcessedFeishuCardActionTokensForTests(): void {
processedCardActionTokens.clear();
}
function pruneProcessedCardActionTokens(now: number): void {
for (const [key, entry] of processedCardActionTokens.entries()) {
if (entry.expiresAt <= now) {
processedCardActionTokens.delete(key);
}
if ("command" in actionValue && typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
function beginFeishuCardActionToken(params: {
token: string;
accountId: string;
now?: number;
}): boolean {
const now = params.now ?? Date.now();
pruneProcessedCardActionTokens(now);
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return true;
}
const key = `${params.accountId}:${normalizedToken}`;
const existing = processedCardActionTokens.get(key);
if (existing && existing.expiresAt > now) {
return false;
}
processedCardActionTokens.set(key, {
status: "inflight",
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
return true;
}
function completeFeishuCardActionToken(params: {
token: string;
accountId: string;
now?: number;
}): void {
const now = params.now ?? Date.now();
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return;
}
processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
status: "completed",
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
}
function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void {
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return;
}
processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`);
}
function buildSyntheticMessageEvent(
event: FeishuCardActionEvent,
content: string,
chatType?: "p2p" | "group",
): FeishuMessageEvent {
return {
sender: {
sender_id: {
open_id: event.operator.open_id,
user_id: event.operator.user_id,
union_id: event.operator.union_id,
},
},
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"),
message_type: "text",
content: JSON.stringify({ text: content }),
},
};
}
function resolveCallbackTarget(event: FeishuCardActionEvent): string {
const chatId = event.context.chat_id?.trim();
if (chatId) {
return `chat:${chatId}`;
}
return `user:${event.operator.open_id}`;
}
async function dispatchSyntheticCommand(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
command: string;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
chatType?: "p2p" | "group";
}): Promise<void> {
await handleFeishuMessage({
cfg: params.cfg,
event: buildSyntheticMessageEvent(params.event, params.command, params.chatType),
botOpenId: params.botOpenId,
runtime: params.runtime,
accountId: params.accountId,
});
}
async function sendInvalidInteractionNotice(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation";
accountId?: string;
}): Promise<void> {
const reasonText =
params.reason === "stale"
? "This card action has expired. Open a fresh launcher card and try again."
: params.reason === "wrong_user"
? "This card action belongs to a different user."
: params.reason === "wrong_conversation"
? "This card action belongs to a different conversation."
: "This card action payload is invalid.";
await sendMessageFeishu({
cfg: params.cfg,
to: resolveCallbackTarget(params.event),
text: `⚠️ ${reasonText}`,
accountId: params.accountId,
});
}
export async function handleFeishuCardAction(params: {
@@ -44,36 +176,135 @@ export async function handleFeishuCardAction(params: {
const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log;
const content = buildCardActionTextFallback(event);
// Construct a synthetic message event
const messageEvent: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: event.operator.open_id,
user_id: event.operator.user_id,
union_id: event.operator.union_id,
},
},
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: event.context.chat_id ? "group" : "p2p",
message_type: "text",
content: JSON.stringify({ text: content }),
},
};
log(
`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
);
// Dispatch as normal message
await handleFeishuMessage({
cfg,
event: messageEvent,
botOpenId: params.botOpenId,
runtime,
accountId,
const decoded = decodeFeishuCardAction({ event });
const claimedToken = beginFeishuCardActionToken({
token: event.token,
accountId: account.accountId,
});
if (!claimedToken) {
log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`);
return;
}
try {
if (decoded.kind === "invalid") {
log(
`feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`,
);
await sendInvalidInteractionNotice({
cfg,
event,
reason: decoded.reason,
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (decoded.kind === "structured") {
const { envelope } = decoded;
log(
`feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`,
);
if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) {
const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : "";
if (!command) {
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
const prompt =
typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
? envelope.m.prompt
: `Run \`${command}\` in this Feishu conversation?`;
await sendCardFeishu({
cfg,
to: resolveCallbackTarget(event),
card: createApprovalCard({
operatorOpenId: event.operator.open_id,
chatId: event.context.chat_id || undefined,
command,
prompt,
sessionKey: envelope.c?.s,
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
}),
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) {
await sendMessageFeishu({
cfg,
to: resolveCallbackTarget(event),
text: "Cancelled.",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") {
const command = envelope.q?.trim();
if (!command) {
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
await dispatchSyntheticCommand({
cfg,
event,
command,
botOpenId: params.botOpenId,
runtime,
accountId,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
const content = buildFeishuCardActionTextFallback(event);
log(
`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
);
await dispatchSyntheticCommand({
cfg,
event,
command: content,
botOpenId: params.botOpenId,
runtime,
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
} catch (err) {
releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId });
throw err;
}
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
buildFeishuCardActionTextFallback,
createFeishuCardInteractionEnvelope,
decodeFeishuCardAction,
} from "./card-interaction.js";
describe("feishu card interaction decoder", () => {
it("decodes valid structured payloads", () => {
const result = decodeFeishuCardAction({
now: 1_700_000_000_000,
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: 1_700_000_060_000 },
}),
},
},
});
expect(result).toEqual(
expect.objectContaining({
kind: "structured",
envelope: expect.objectContaining({
q: "/help",
}),
}),
);
});
it("falls back for legacy text-like payloads", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: { value: { text: "/ping" } },
},
});
expect(result).toEqual({ kind: "legacy", text: "/ping" });
expect(
buildFeishuCardActionTextFallback({
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: { value: { command: "/new" } },
}),
).toBe("/new");
});
it("rejects malformed structured payloads", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: {
oc: "ocf1",
k: "quick",
a: "broken",
m: { bad: { nested: true } },
},
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "malformed" });
});
it("rejects stale payloads", () => {
const result = decodeFeishuCardAction({
now: 100,
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: "stale",
c: { e: 99, t: "group" },
}),
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "stale" });
});
it("rejects wrong-conversation payloads when chat context is enforced", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat2" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: "scoped",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "wrong_conversation" });
});
it("rejects malformed chat-type context", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: {
oc: "ocf1",
k: "button",
a: "bad",
c: { t: "private" },
},
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "malformed" });
});
});

View File

@@ -0,0 +1,168 @@
export const FEISHU_CARD_INTERACTION_VERSION = "ocf1";
export type FeishuCardInteractionKind = "button" | "quick" | "meta";
export type FeishuCardInteractionReason =
| "malformed"
| "stale"
| "wrong_user"
| "wrong_conversation";
export type FeishuCardInteractionMetadata = Record<
string,
string | number | boolean | null | undefined
>;
export type FeishuCardInteractionEnvelope = {
oc: typeof FEISHU_CARD_INTERACTION_VERSION;
k: FeishuCardInteractionKind;
a: string;
q?: string;
m?: FeishuCardInteractionMetadata;
c?: {
u?: string;
h?: string;
s?: string;
e?: number;
t?: "p2p" | "group";
};
};
export type FeishuCardActionEventLike = {
operator: {
open_id?: string;
};
action: {
value: unknown;
};
context: {
chat_id?: string;
};
};
export type DecodedFeishuCardAction =
| {
kind: "structured";
envelope: FeishuCardInteractionEnvelope;
}
| {
kind: "legacy";
text: string;
}
| {
kind: "invalid";
reason: FeishuCardInteractionReason;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isInteractionKind(value: unknown): value is FeishuCardInteractionKind {
return value === "button" || value === "quick" || value === "meta";
}
function isMetadataValue(value: unknown): value is string | number | boolean | null | undefined {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}
export function createFeishuCardInteractionEnvelope(
envelope: Omit<FeishuCardInteractionEnvelope, "oc">,
): FeishuCardInteractionEnvelope {
return {
oc: FEISHU_CARD_INTERACTION_VERSION,
...envelope,
};
}
export function buildFeishuCardActionTextFallback(event: FeishuCardActionEventLike): string {
const actionValue = event.action.value;
if (isRecord(actionValue)) {
if (typeof actionValue.text === "string") {
return actionValue.text;
}
if (typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export function decodeFeishuCardAction(params: {
event: FeishuCardActionEventLike;
now?: number;
}): DecodedFeishuCardAction {
const { event, now = Date.now() } = params;
const actionValue = event.action.value;
if (!isRecord(actionValue) || actionValue.oc !== FEISHU_CARD_INTERACTION_VERSION) {
return {
kind: "legacy",
text: buildFeishuCardActionTextFallback(event),
};
}
if (!isInteractionKind(actionValue.k) || typeof actionValue.a !== "string" || !actionValue.a) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.q !== undefined && typeof actionValue.q !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.m !== undefined) {
if (!isRecord(actionValue.m)) {
return { kind: "invalid", reason: "malformed" };
}
for (const value of Object.values(actionValue.m)) {
if (!isMetadataValue(value)) {
return { kind: "invalid", reason: "malformed" };
}
}
}
if (actionValue.c !== undefined) {
if (!isRecord(actionValue.c)) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.u !== undefined && typeof actionValue.c.u !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.h !== undefined && typeof actionValue.c.h !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.s !== undefined && typeof actionValue.c.s !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.e !== undefined && !Number.isFinite(actionValue.c.e)) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.t !== undefined && actionValue.c.t !== "p2p" && actionValue.c.t !== "group") {
return { kind: "invalid", reason: "malformed" };
}
if (typeof actionValue.c.e === "number" && actionValue.c.e < now) {
return { kind: "invalid", reason: "stale" };
}
const expectedUser = actionValue.c.u?.trim();
if (expectedUser && expectedUser !== (event.operator.open_id ?? "").trim()) {
return { kind: "invalid", reason: "wrong_user" };
}
const expectedChat = actionValue.c.h?.trim();
if (expectedChat && expectedChat !== (event.context.chat_id ?? "").trim()) {
return { kind: "invalid", reason: "wrong_conversation" };
}
}
return {
kind: "structured",
envelope: actionValue as FeishuCardInteractionEnvelope,
};
}

View File

@@ -0,0 +1,65 @@
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js";
export const FEISHU_APPROVAL_REQUEST_ACTION = "feishu.quick_actions.request_approval";
export const FEISHU_APPROVAL_CONFIRM_ACTION = "feishu.approval.confirm";
export const FEISHU_APPROVAL_CANCEL_ACTION = "feishu.approval.cancel";
export function createApprovalCard(params: {
operatorOpenId: string;
chatId?: string;
command: string;
prompt: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
confirmLabel?: string;
cancelLabel?: string;
}): Record<string, unknown> {
const context = buildFeishuCardInteractionContext(params);
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
header: {
title: {
tag: "plain_text",
content: "Confirm action",
},
template: "orange",
},
body: {
elements: [
{
tag: "markdown",
content: params.prompt,
},
{
tag: "action",
actions: [
buildFeishuCardButton({
label: params.confirmLabel ?? "Confirm",
type: "primary",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: FEISHU_APPROVAL_CONFIRM_ACTION,
q: params.command,
c: context,
}),
}),
buildFeishuCardButton({
label: params.cancelLabel ?? "Cancel",
value: createFeishuCardInteractionEnvelope({
k: "button",
a: FEISHU_APPROVAL_CANCEL_ACTION,
c: context,
}),
}),
],
},
],
},
};
}

View File

@@ -0,0 +1,98 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
createQuickActionLauncherCard,
isFeishuQuickActionMenuEventKey,
maybeHandleFeishuQuickActionMenu,
} from "./card-ux-launcher.js";
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
}));
describe("feishu quick-action launcher", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("recognizes the quick-actions bot menu key", () => {
expect(isFeishuQuickActionMenuEventKey("quick-actions")).toBe(true);
expect(isFeishuQuickActionMenuEventKey("other")).toBe(false);
});
it("builds a launcher card with interactive actions", () => {
const card = createQuickActionLauncherCard({
operatorOpenId: "u123",
chatId: "chat1",
expiresAt: 123,
sessionKey: "agent:codex:feishu:chat:chat1",
}) as {
body: {
elements: Array<{
tag: string;
actions?: Array<{ value?: { oc?: string; c?: { s?: string; t?: string } } }>;
}>;
};
};
const actionBlock = card.body.elements.find((entry) => entry.tag === "action");
expect(actionBlock?.actions).toHaveLength(3);
expect(actionBlock?.actions?.[0]?.value?.oc).toBe("ocf1");
expect(actionBlock?.actions?.[0]?.value?.c?.s).toBe("agent:codex:feishu:chat:chat1");
expect(actionBlock?.actions?.[0]?.value?.c?.t).toBeUndefined();
});
it("opens the launcher from a supported bot menu event", async () => {
sendCardFeishuMock.mockResolvedValue({ messageId: "m1", chatId: "c1" });
const handled = await maybeHandleFeishuQuickActionMenu({
cfg: {} as any,
eventKey: "quick-actions",
operatorOpenId: "u123",
accountId: "main",
now: 100,
});
expect(handled).toBe(true);
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:u123",
accountId: "main",
card: expect.objectContaining({
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
t: "p2p",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
});
it("falls back to legacy menu handling when launcher send fails", async () => {
sendCardFeishuMock.mockRejectedValueOnce(new Error("network"));
const handled = await maybeHandleFeishuQuickActionMenu({
cfg: {} as any,
eventKey: "quick-actions",
operatorOpenId: "u123",
accountId: "main",
runtime: { log: vi.fn() } as any,
now: 100,
});
expect(handled).toBe(false);
});
});

View File

@@ -0,0 +1,120 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js";
import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js";
import { sendCardFeishu } from "./send.js";
export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000;
const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]);
export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean {
return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase());
}
export function createQuickActionLauncherCard(params: {
operatorOpenId: string;
chatId?: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
}): Record<string, unknown> {
const context = buildFeishuCardInteractionContext(params);
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
header: {
title: {
tag: "plain_text",
content: "Quick actions",
},
template: "indigo",
},
body: {
elements: [
{
tag: "markdown",
content: "Run common actions without typing raw commands.",
},
{
tag: "action",
actions: [
buildFeishuCardButton({
label: "Help",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: context,
}),
}),
buildFeishuCardButton({
label: "New session",
type: "primary",
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session? This will reset the current chat context.",
},
c: context,
}),
}),
buildFeishuCardButton({
label: "Reset",
type: "danger",
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/reset",
prompt: "Reset this session now? Any active conversation state will be cleared.",
},
c: context,
}),
}),
],
},
],
},
};
}
export async function maybeHandleFeishuQuickActionMenu(params: {
cfg: ClawdbotConfig;
eventKey: string;
operatorOpenId: string;
runtime?: RuntimeEnv;
accountId?: string;
now?: number;
}): Promise<boolean> {
if (!isFeishuQuickActionMenuEventKey(params.eventKey)) {
return false;
}
const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS;
try {
await sendCardFeishu({
cfg: params.cfg,
to: `user:${params.operatorOpenId}`,
card: createQuickActionLauncherCard({
operatorOpenId: params.operatorOpenId,
expiresAt,
chatType: "p2p",
}),
accountId: params.accountId,
});
} catch (err) {
params.runtime?.log?.(
`feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: ${String(err)}`,
);
return false;
}
params.runtime?.log?.(
`feishu[${params.accountId ?? "default"}]: opened quick-action launcher for ${params.operatorOpenId}`,
);
return true;
}

View File

@@ -0,0 +1,33 @@
import type { FeishuCardInteractionEnvelope } from "./card-interaction.js";
export function buildFeishuCardButton(params: {
label: string;
value: FeishuCardInteractionEnvelope;
type?: "default" | "primary" | "danger";
}) {
return {
tag: "button",
text: {
tag: "plain_text",
content: params.label,
},
type: params.type ?? "default",
value: params.value,
};
}
export function buildFeishuCardInteractionContext(params: {
operatorOpenId: string;
chatId?: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
}) {
return {
u: params.operatorOpenId,
...(params.chatId ? { h: params.chatId } : {}),
...(params.sessionKey ? { s: params.sessionKey } : {}),
e: params.expiresAt,
...(params.chatType ? { t: params.chatType } : {}),
};
}

View File

@@ -10,6 +10,7 @@ import {
type FeishuBotAddedEvent,
} from "./bot.js";
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js";
import { createEventDispatcher } from "./client.js";
import {
hasProcessedFeishuMessage,
@@ -513,7 +514,7 @@ function registerEventHandlers(
try {
const event = data as {
event_key?: string;
timestamp?: number;
timestamp?: string | number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
@@ -543,14 +544,28 @@ function registerEventHandlers(
}),
},
};
const promise = handleFeishuMessage({
const handleLegacyMenu = () =>
handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
const promise = maybeHandleFeishuQuickActionMenu({
cfg,
event: syntheticEvent,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
eventKey,
operatorOpenId,
runtime,
chatHistories,
accountId,
}).then((handledMenu) => {
if (handledMenu) {
return;
}
return handleLegacyMenu();
});
if (fireAndForget) {
promise.catch((err) => {

View File

@@ -0,0 +1,229 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../../src/auto-reply/inbound-debounce.js";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
import type { ResolvedFeishuAccount } from "./types.js";
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {}));
const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" })));
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
vi.mock("./client.js", () => ({
createEventDispatcher: createEventDispatcherMock,
}));
vi.mock("./monitor.transport.js", () => ({
monitorWebSocket: monitorWebSocketMock,
monitorWebhook: monitorWebhookMock,
}));
vi.mock("./bot.js", async () => {
const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
return {
...actual,
handleFeishuMessage: handleFeishuMessageMock,
};
});
vi.mock("./send.js", async () => {
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
return {
...actual,
sendCardFeishu: sendCardFeishuMock,
};
});
vi.mock("./thread-bindings.js", () => ({
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
}));
function buildAccount(): ResolvedFeishuAccount {
return {
accountId: "default",
enabled: true,
configured: true,
appId: "cli_test",
appSecret: "secret_test", // pragma: allowlist secret
domain: "feishu",
config: {
enabled: true,
connectionMode: "websocket",
},
} as ResolvedFeishuAccount;
}
async function registerHandlers() {
setFeishuRuntime(
createPluginRuntimeMock({
channel: {
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
text: {
hasControlCommand,
},
},
}),
);
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
});
createEventDispatcherMock.mockReturnValue({ register });
await monitorSingleAccount({
cfg: {} as ClawdbotConfig,
account: buildAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",
botName: "Bot",
},
});
const onBotMenu = handlers["application.bot.menu_v6"];
if (!onBotMenu) {
throw new Error("missing application.bot.menu_v6 handler");
}
return onBotMenu;
}
describe("Feishu bot menu handler", () => {
beforeEach(() => {
handlers = {};
vi.clearAllMocks();
});
it("opens the quick-action launcher card at the webhook/event layer", async () => {
const onBotMenu = await registerHandlers();
await onBotMenu({
event_key: "quick-actions",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:ou_user1",
card: expect.objectContaining({
header: expect.objectContaining({
title: expect.objectContaining({ content: "Quick actions" }),
}),
}),
}),
);
expect(handleFeishuMessageMock).not.toHaveBeenCalled();
});
it("does not block bot-menu handling on quick-action launcher send", async () => {
const onBotMenu = await registerHandlers();
let resolveSend: (() => void) | undefined;
sendCardFeishuMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSend = () => resolve({ messageId: "m1", chatId: "c1" });
}),
);
const pending = onBotMenu({
event_key: "quick-actions",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
let settled = false;
pending.finally(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(true);
resolveSend?.();
await pending;
});
it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => {
const onBotMenu = await registerHandlers();
await onBotMenu({
event_key: "custom-key",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
expect(handleFeishuMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/menu custom-key"}',
}),
}),
}),
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
});
it("falls back to the legacy /menu path when launcher rendering fails", async () => {
const onBotMenu = await registerHandlers();
sendCardFeishuMock.mockRejectedValueOnce(new Error("boom"));
await onBotMenu({
event_key: "quick-actions",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
await vi.waitFor(() => {
expect(handleFeishuMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/menu quick-actions"}',
}),
}),
}),
);
});
});
});