forked from EduCraft/curriculum-project-hub
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:
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { PermissionAuthorizer } from "../../src/permission.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
import { sendApprovalCard, resolveCard, type CardActionEvent, type FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { ApprovalManager, makeTriggerHandler } from "../../src/feishu/trigger.js";
|
||||
|
||||
describe("Feishu approval cards", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sendApprovalCard builds correct card JSON", async () => {
|
||||
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
|
||||
const rt = mockRuntime({ messageCreate });
|
||||
|
||||
await expect(sendApprovalCard(rt, "chat-1", "Approve?", "Please approve.", [
|
||||
{ label: "Approve", value: "approve", style: "primary" },
|
||||
{ label: "Reject", value: "reject", style: "danger" },
|
||||
{ label: "Later", value: "later" },
|
||||
])).resolves.toBe("message-1");
|
||||
|
||||
expect(messageCreate).toHaveBeenCalledTimes(1);
|
||||
const payload = messageCreate.mock.calls[0]?.[0] as { data: { content: string } };
|
||||
expect(payload).toMatchObject({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: "chat-1", msg_type: "interactive" },
|
||||
});
|
||||
expect(JSON.parse(payload.data.content)).toEqual({
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { content: "Approve?", tag: "plain_text" }, template: "orange" },
|
||||
elements: [
|
||||
{ tag: "markdown", content: "Please approve." },
|
||||
{
|
||||
tag: "action",
|
||||
actions: [
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "Approve" },
|
||||
type: "primary",
|
||||
value: { approval_action: "approve" },
|
||||
},
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "Reject" },
|
||||
type: "danger",
|
||||
value: { approval_action: "reject" },
|
||||
},
|
||||
{
|
||||
tag: "button",
|
||||
text: { tag: "plain_text", content: "Later" },
|
||||
type: "default",
|
||||
value: { approval_action: "later" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveCard patches with resolved state", async () => {
|
||||
const messagePatch = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ messagePatch });
|
||||
|
||||
await resolveCard(rt, "message-1", "OK", "Approved", "green", "ou_user");
|
||||
|
||||
expect(messagePatch).toHaveBeenCalledTimes(1);
|
||||
expect(messagePatch).toHaveBeenCalledWith({
|
||||
path: { message_id: "message-1" },
|
||||
params: { msg_type: "interactive" },
|
||||
data: {
|
||||
content: JSON.stringify({
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { content: "OK Approved", tag: "plain_text" }, template: "green" },
|
||||
elements: [{ tag: "markdown", content: "OK **Approved** by ou_user" }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ApprovalManager register+resolve lifecycle", async () => {
|
||||
const manager = new ApprovalManager({ timeoutMs: 10_000 });
|
||||
const result = manager.register("message-1", "chat-1");
|
||||
|
||||
expect(manager.hasPending("message-1")).toBe(true);
|
||||
manager.resolve("message-1", "approve", "ou_user");
|
||||
|
||||
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
|
||||
expect(manager.hasPending("message-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("ApprovalManager timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const manager = new ApprovalManager({ timeoutMs: 100 });
|
||||
const result = manager.register("message-1", "chat-1");
|
||||
const assertion = expect(result).rejects.toThrow("approval timed out after 100ms");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await assertion;
|
||||
expect(manager.hasPending("message-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("routes card actions through the trigger handler", async () => {
|
||||
const messagePatch = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ messagePatch });
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma: {} as PrismaClient,
|
||||
settings: {} as RuntimeSettings,
|
||||
logger: silentLogger(),
|
||||
authorizer: allowAllAuthorizer(),
|
||||
});
|
||||
const result = trigger.approvalManager.register("message-1", "chat-1");
|
||||
|
||||
await trigger.onCardAction(makeCardActionEvent("message-1", "approve", "ou_user"), rt);
|
||||
|
||||
await expect(result).resolves.toEqual({ action: "approve", resolvedBy: "ou_user" });
|
||||
expect(messagePatch).toHaveBeenCalledTimes(1);
|
||||
const payload = messagePatch.mock.calls[0]?.[0] as { data: { content: string } };
|
||||
expect(payload).toMatchObject({
|
||||
path: { message_id: "message-1" },
|
||||
params: { msg_type: "interactive" },
|
||||
});
|
||||
expect(JSON.parse(payload.data.content)).toEqual({
|
||||
config: { wide_screen_mode: true },
|
||||
header: { title: { content: "✅ Approved", tag: "plain_text" }, template: "green" },
|
||||
elements: [{ tag: "markdown", content: "✅ **Approved** by ou_user" }],
|
||||
});
|
||||
expect(trigger.approvalManager.hasPending("message-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function makeCardActionEvent(messageId: string, action: string, openId: string): CardActionEvent {
|
||||
return {
|
||||
operator: { open_id: openId },
|
||||
action: { value: { approval_action: action }, tag: "button" },
|
||||
context: { open_message_id: messageId, open_chat_id: "chat-1" },
|
||||
};
|
||||
}
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
|
||||
readonly messagePatch?: (payload: unknown) => Promise<unknown>;
|
||||
} = {}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
|
||||
patch: options.messagePatch ?? vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: silentLogger(),
|
||||
};
|
||||
}
|
||||
|
||||
function allowAllAuthorizer(): PermissionAuthorizer {
|
||||
return {
|
||||
async can(req) {
|
||||
return {
|
||||
allowed: true,
|
||||
reason: "test allow",
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
actorUserId: "user-1",
|
||||
principals: [{ type: "USER", id: "ou_user" }],
|
||||
requiredRole: "EDIT",
|
||||
effectiveRole: "EDIT",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function silentLogger(): FeishuRuntime["logger"] {
|
||||
return {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"];
|
||||
}
|
||||
Reference in New Issue
Block a user