Skip to content

Commit fa62231

Browse files
authored
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
1 parent aa97368 commit fa62231

12 files changed

Lines changed: 1476 additions & 43 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Docs: https://docs.openclaw.ai
2424
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
2525
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
2626
- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode.
27+
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873)
28+
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819)
2729

2830
### Fixes
2931

extensions/feishu/src/bot.card-action.test.ts

Lines changed: 342 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
import { describe, it, expect, vi } from "vitest";
2-
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import {
3+
handleFeishuCardAction,
4+
resetProcessedFeishuCardActionTokensForTests,
5+
type FeishuCardActionEvent,
6+
} from "./card-action.js";
7+
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
8+
import {
9+
FEISHU_APPROVAL_CANCEL_ACTION,
10+
FEISHU_APPROVAL_CONFIRM_ACTION,
11+
FEISHU_APPROVAL_REQUEST_ACTION,
12+
} from "./card-ux-approval.js";
313

414
// Mock resolveFeishuAccount
515
vi.mock("./accounts.js", () => ({
@@ -11,12 +21,25 @@ vi.mock("./bot.js", () => ({
1121
handleFeishuMessage: vi.fn(),
1222
}));
1323

24+
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
25+
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
26+
27+
vi.mock("./send.js", () => ({
28+
sendCardFeishu: sendCardFeishuMock,
29+
sendMessageFeishu: sendMessageFeishuMock,
30+
}));
31+
1432
import { handleFeishuMessage } from "./bot.js";
1533

1634
describe("Feishu Card Action Handler", () => {
1735
const cfg = {} as any; // Minimal mock
1836
const runtime = { log: vi.fn(), error: vi.fn() } as any;
1937

38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
resetProcessedFeishuCardActionTokensForTests();
41+
});
42+
2043
it("handles card action with text payload", async () => {
2144
const event: FeishuCardActionEvent = {
2245
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
@@ -60,4 +83,321 @@ describe("Feishu Card Action Handler", () => {
6083
}),
6184
);
6285
});
86+
87+
it("routes quick command actions with operator and conversation context", async () => {
88+
const event: FeishuCardActionEvent = {
89+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
90+
token: "tok3",
91+
action: {
92+
value: createFeishuCardInteractionEnvelope({
93+
k: "quick",
94+
a: "feishu.quick_actions.help",
95+
q: "/help",
96+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
97+
}),
98+
tag: "button",
99+
},
100+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
101+
};
102+
103+
await handleFeishuCardAction({ cfg, event, runtime });
104+
105+
expect(handleFeishuMessage).toHaveBeenCalledWith(
106+
expect.objectContaining({
107+
event: expect.objectContaining({
108+
sender: expect.objectContaining({
109+
sender_id: expect.objectContaining({
110+
open_id: "u123",
111+
user_id: "uid1",
112+
union_id: "un1",
113+
}),
114+
}),
115+
message: expect.objectContaining({
116+
chat_id: "chat1",
117+
content: '{"text":"/help"}',
118+
}),
119+
}),
120+
}),
121+
);
122+
});
123+
124+
it("opens an approval card for metadata actions", async () => {
125+
const event: FeishuCardActionEvent = {
126+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
127+
token: "tok4",
128+
action: {
129+
value: createFeishuCardInteractionEnvelope({
130+
k: "meta",
131+
a: FEISHU_APPROVAL_REQUEST_ACTION,
132+
m: {
133+
command: "/new",
134+
prompt: "Start a fresh session?",
135+
},
136+
c: {
137+
u: "u123",
138+
h: "chat1",
139+
t: "group",
140+
s: "agent:codex:feishu:chat:chat1",
141+
e: Date.now() + 60_000,
142+
},
143+
}),
144+
tag: "button",
145+
},
146+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
147+
};
148+
149+
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
150+
151+
expect(sendCardFeishuMock).toHaveBeenCalledWith(
152+
expect.objectContaining({
153+
to: "chat:chat1",
154+
accountId: "main",
155+
card: expect.objectContaining({
156+
header: expect.objectContaining({
157+
title: expect.objectContaining({ content: "Confirm action" }),
158+
}),
159+
body: expect.objectContaining({
160+
elements: expect.arrayContaining([
161+
expect.objectContaining({
162+
tag: "action",
163+
actions: expect.arrayContaining([
164+
expect.objectContaining({
165+
value: expect.objectContaining({
166+
c: expect.objectContaining({
167+
u: "u123",
168+
h: "chat1",
169+
t: "group",
170+
s: "agent:codex:feishu:chat:chat1",
171+
}),
172+
}),
173+
}),
174+
]),
175+
}),
176+
]),
177+
}),
178+
}),
179+
}),
180+
);
181+
expect(handleFeishuMessage).not.toHaveBeenCalled();
182+
});
183+
184+
it("runs approval confirmation through the normal message path", async () => {
185+
const event: FeishuCardActionEvent = {
186+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
187+
token: "tok5",
188+
action: {
189+
value: createFeishuCardInteractionEnvelope({
190+
k: "quick",
191+
a: FEISHU_APPROVAL_CONFIRM_ACTION,
192+
q: "/new",
193+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
194+
}),
195+
tag: "button",
196+
},
197+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
198+
};
199+
200+
await handleFeishuCardAction({ cfg, event, runtime });
201+
202+
expect(handleFeishuMessage).toHaveBeenCalledWith(
203+
expect.objectContaining({
204+
event: expect.objectContaining({
205+
message: expect.objectContaining({
206+
content: '{"text":"/new"}',
207+
}),
208+
}),
209+
}),
210+
);
211+
});
212+
213+
it("safely rejects stale structured actions", async () => {
214+
const event: FeishuCardActionEvent = {
215+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
216+
token: "tok6",
217+
action: {
218+
value: createFeishuCardInteractionEnvelope({
219+
k: "quick",
220+
a: "feishu.quick_actions.help",
221+
q: "/help",
222+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 },
223+
}),
224+
tag: "button",
225+
},
226+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
227+
};
228+
229+
await handleFeishuCardAction({ cfg, event, runtime });
230+
231+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
232+
expect.objectContaining({
233+
to: "chat:chat1",
234+
text: expect.stringContaining("expired"),
235+
}),
236+
);
237+
expect(handleFeishuMessage).not.toHaveBeenCalled();
238+
});
239+
240+
it("safely rejects wrong-user structured actions", async () => {
241+
const event: FeishuCardActionEvent = {
242+
operator: { open_id: "u999", user_id: "uid1", union_id: "un1" },
243+
token: "tok7",
244+
action: {
245+
value: createFeishuCardInteractionEnvelope({
246+
k: "quick",
247+
a: "feishu.quick_actions.help",
248+
q: "/help",
249+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
250+
}),
251+
tag: "button",
252+
},
253+
context: { open_id: "u999", user_id: "uid1", chat_id: "chat1" },
254+
};
255+
256+
await handleFeishuCardAction({ cfg, event, runtime });
257+
258+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
259+
expect.objectContaining({
260+
text: expect.stringContaining("different user"),
261+
}),
262+
);
263+
expect(handleFeishuMessage).not.toHaveBeenCalled();
264+
});
265+
266+
it("sends a lightweight cancellation notice", async () => {
267+
const event: FeishuCardActionEvent = {
268+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
269+
token: "tok8",
270+
action: {
271+
value: createFeishuCardInteractionEnvelope({
272+
k: "button",
273+
a: FEISHU_APPROVAL_CANCEL_ACTION,
274+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
275+
}),
276+
tag: "button",
277+
},
278+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
279+
};
280+
281+
await handleFeishuCardAction({ cfg, event, runtime });
282+
283+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
284+
expect.objectContaining({
285+
to: "chat:chat1",
286+
text: "Cancelled.",
287+
}),
288+
);
289+
});
290+
291+
it("preserves p2p callbacks for DM quick actions", async () => {
292+
const event: FeishuCardActionEvent = {
293+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
294+
token: "tok9",
295+
action: {
296+
value: createFeishuCardInteractionEnvelope({
297+
k: "quick",
298+
a: "feishu.quick_actions.help",
299+
q: "/help",
300+
c: { u: "u123", h: "p2p-chat-1", t: "p2p", e: Date.now() + 60_000 },
301+
}),
302+
tag: "button",
303+
},
304+
context: { open_id: "u123", user_id: "uid1", chat_id: "p2p-chat-1" },
305+
};
306+
307+
await handleFeishuCardAction({ cfg, event, runtime });
308+
309+
expect(handleFeishuMessage).toHaveBeenCalledWith(
310+
expect.objectContaining({
311+
event: expect.objectContaining({
312+
message: expect.objectContaining({
313+
chat_id: "p2p-chat-1",
314+
chat_type: "p2p",
315+
}),
316+
}),
317+
}),
318+
);
319+
});
320+
321+
it("drops duplicate structured callback tokens", async () => {
322+
const event: FeishuCardActionEvent = {
323+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
324+
token: "tok10",
325+
action: {
326+
value: createFeishuCardInteractionEnvelope({
327+
k: "quick",
328+
a: "feishu.quick_actions.help",
329+
q: "/help",
330+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
331+
}),
332+
tag: "button",
333+
},
334+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
335+
};
336+
337+
await handleFeishuCardAction({ cfg, event, runtime });
338+
await handleFeishuCardAction({ cfg, event, runtime });
339+
340+
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
341+
});
342+
343+
it("releases a claimed token when dispatch fails so retries can succeed", async () => {
344+
const event: FeishuCardActionEvent = {
345+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
346+
token: "tok11",
347+
action: {
348+
value: createFeishuCardInteractionEnvelope({
349+
k: "quick",
350+
a: "feishu.quick_actions.help",
351+
q: "/help",
352+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
353+
}),
354+
tag: "button",
355+
},
356+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
357+
};
358+
vi.mocked(handleFeishuMessage)
359+
.mockRejectedValueOnce(new Error("transient"))
360+
.mockResolvedValueOnce(undefined as never);
361+
362+
await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient");
363+
await handleFeishuCardAction({ cfg, event, runtime });
364+
365+
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
366+
});
367+
368+
it("keeps an in-flight token claimed while a slow dispatch is still running", async () => {
369+
vi.useFakeTimers();
370+
const event: FeishuCardActionEvent = {
371+
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
372+
token: "tok12",
373+
action: {
374+
value: createFeishuCardInteractionEnvelope({
375+
k: "quick",
376+
a: "feishu.quick_actions.help",
377+
q: "/help",
378+
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
379+
}),
380+
tag: "button",
381+
},
382+
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
383+
};
384+
385+
let resolveDispatch: (() => void) | undefined;
386+
vi.mocked(handleFeishuMessage).mockImplementation(
387+
() =>
388+
new Promise<void>((resolve) => {
389+
resolveDispatch = resolve;
390+
}) as never,
391+
);
392+
393+
const first = handleFeishuCardAction({ cfg, event, runtime });
394+
await vi.advanceTimersByTimeAsync(61_000);
395+
await handleFeishuCardAction({ cfg, event, runtime });
396+
397+
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
398+
399+
resolveDispatch?.();
400+
await first;
401+
vi.useRealTimers();
402+
});
63403
});

0 commit comments

Comments
 (0)