feat: add interactive approval/confirmation cards

- sendApprovalCard: interactive card with configurable buttons
- resolveCard: patch card to show resolution state (green/red)
- ApprovalManager: register/resolve lifecycle with 5min timeout
- request_approval MCP tool: agent can ask user for confirmation
- Wire onCardAction in trigger handler and server startup
- Add unit tests (5) for card JSON, manager, and routing
This commit is contained in:
2026-07-08 16:47:31 +08:00
parent ad65d93b2e
commit 4736610cf3
6 changed files with 435 additions and 6 deletions
+67
View File
@@ -180,6 +180,73 @@ export async function patchTextMessage(rt: FeishuRuntime, messageId: string, tex
}
}
/** Send an approval card with buttons. Returns message_id on success. */
export async function sendApprovalCard(
rt: FeishuRuntime,
chatId: string,
title: string,
body: string,
buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>,
): Promise<string | null> {
const card = {
config: { wide_screen_mode: true },
header: { title: { content: title, tag: "plain_text" }, template: "orange" },
elements: [
{ tag: "markdown", content: body },
{
tag: "action",
actions: buttons.map((button) => ({
tag: "button",
text: { tag: "plain_text", content: button.label },
type: button.style ?? "default",
value: { approval_action: button.value },
})),
},
],
};
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return messageIdFromResponse(res);
} catch (e) {
rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed");
return null;
}
}
/** Update a card to show it has been resolved. */
export async function resolveCard(
rt: FeishuRuntime,
messageId: string,
icon: string,
label: string,
template: "green" | "red",
resolvedBy: string,
): Promise<void> {
const card = {
config: { wide_screen_mode: true },
header: { title: { content: `${icon} ${label}`, tag: "plain_text" }, template },
elements: [{ tag: "markdown", content: `${icon} **${label}** by ${resolvedBy}` }],
};
const client = rt.client as unknown as {
im: { v1: { message: { patch: (p: unknown) => Promise<unknown> } } };
};
try {
await client.im.v1.message.patch({
path: { message_id: messageId },
params: { msg_type: "interactive" },
data: { content: JSON.stringify(card) },
});
} catch (e) {
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "resolveCard failed");
}
}
/** React to a message with an emoji. */
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
try {