diff --git a/hub/src/feishu/approval.ts b/hub/src/feishu/approval.ts new file mode 100644 index 0000000..e98b9de --- /dev/null +++ b/hub/src/feishu/approval.ts @@ -0,0 +1,54 @@ +export interface ApprovalResult { + readonly action: string; + readonly resolvedBy: string; +} + +export interface PendingApproval { + readonly messageId: string; + readonly chatId: string; + readonly resolve: (result: ApprovalResult) => void; +} + +export interface ApprovalManagerOptions { + readonly timeoutMs?: number; +} + +export class ApprovalManager { + private readonly timeoutMs: number; + private pending = new Map(); + + constructor(options: ApprovalManagerOptions = {}) { + this.timeoutMs = options.timeoutMs ?? 5 * 60 * 1000; + } + + register(messageId: string, chatId: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(messageId); + reject(new Error(`approval timed out after ${this.timeoutMs}ms`)); + }, this.timeoutMs); + const unref = (timeout as { unref?: () => void }).unref; + if (unref !== undefined) unref.call(timeout); + + this.pending.set(messageId, { + messageId, + chatId, + resolve: (result) => { + clearTimeout(timeout); + resolve(result); + }, + }); + }); + } + + resolve(messageId: string, action: string, resolvedBy: string): void { + const pending = this.pending.get(messageId); + if (pending === undefined) return; + this.pending.delete(messageId); + pending.resolve({ action, resolvedBy }); + } + + hasPending(messageId: string): boolean { + return this.pending.has(messageId); + } +} diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 18f86ec..d49e4ea 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -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 { + 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 } } }; + }; + 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 { + 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 } } }; + }; + 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 { try { diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index 54b7ec1..5a0ea4b 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -1,8 +1,9 @@ import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; -import { sendFile, type FeishuRuntime } from "./client.js"; +import { sendApprovalCard, sendFile, type FeishuRuntime } from "./client.js"; import { resolveDeliverableFile } from "./fileDelivery.js"; import { readFeishuContext } from "./read.js"; +import type { ApprovalManager } from "./approval.js"; export interface FileDeliveryToolOptions { readonly rt: FeishuRuntime; @@ -10,6 +11,7 @@ export interface FileDeliveryToolOptions { readonly projectId: string; readonly runId: string; readonly workspaceDir: string; + readonly approvalManager: ApprovalManager; readonly onDelivered?: (path: string) => void; } @@ -22,7 +24,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M "Use send_file when the user asks to receive, resend, download, or attach a file. " + "Do not claim a file was sent unless send_file returns success. " + "If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path. " + - "Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed.", + "Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed. " + + "Use request_approval when explicit human approval or confirmation is required before continuing.", tools: [ tool( "send_file", @@ -77,6 +80,52 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M }, { alwaysLoad: true }, ), + tool( + "request_approval", + "Send an interactive Feishu approval/confirmation card to the current chat and wait for a button click.", + { + title: z.string().describe("Card title."), + body: z.string().describe("Markdown card body explaining what needs approval or confirmation."), + options: z + .array(z.object({ + label: z.string().describe("Button label shown to the user."), + value: z.string().describe("Action value returned to the agent when this button is clicked."), + style: z.enum(["primary", "default", "danger"]).optional().describe("Optional Feishu button style."), + })) + .min(1) + .describe("Button choices for the approval card."), + }, + async (args) => { + const messageId = await sendApprovalCard( + options.rt, + options.chatId, + args.title, + args.body, + args.options.map((option) => ({ + label: option.label, + value: option.value, + ...(option.style === undefined ? {} : { style: option.style }), + })), + ); + if (messageId === null) { + return { + isError: true, + content: [{ type: "text", text: "Failed to send approval card." }], + }; + } + + try { + const result = await options.approvalManager.register(messageId, options.chatId); + return { content: [{ type: "text", text: JSON.stringify(result) }] }; + } catch (e) { + return { + isError: true, + content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], + }; + } + }, + { alwaysLoad: true }, + ), ], }); } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 7c96cbd..d95b9dd 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -11,7 +11,7 @@ import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; import type { FastifyBaseLogger } from "fastify"; -import { sendText, sendTextMessage, patchTextMessage, reactToMessage, addReaction, removeReaction, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; +import { sendText, sendTextMessage, patchTextMessage, reactToMessage, addReaction, removeReaction, downloadMessageFile, resolveCard, type CardActionEvent, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js"; import type { RuntimeSettings } from "../settings/runtime.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; @@ -21,6 +21,10 @@ import { PatchableTextStream } from "./textStream.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; import { readFeishuContext } from "./read.js"; import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js"; +import { ApprovalManager } from "./approval.js"; + +export { ApprovalManager } from "./approval.js"; +export type { ApprovalResult, PendingApproval } from "./approval.js"; interface TriggerDeps { readonly prisma: PrismaClient; @@ -45,14 +49,21 @@ interface TriggerRunContext { readonly actor: TriggerActor; } +export interface TriggerHandler { + (event: MessageReceiveEvent, rt: FeishuRuntime): Promise; + readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise; + readonly approvalManager: ApprovalManager; +} + /** * Build the message handler. Returns a function suitable for the lark * `im.message.receive_v1` event. The handler is async and long-running (the * agent run is the long leg); the lock guarantees one run per project at a time. */ -export function makeTriggerHandler(deps: TriggerDeps) { +export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma); const runAgent = deps.runAgent ?? defaultRunAgent; + const approvalManager = new ApprovalManager(); const batchContexts = new Map(); const messageBatcher = new MessageBatcher(async (mergedText, key) => { if (key === undefined) { @@ -227,6 +238,7 @@ export function makeTriggerHandler(deps: TriggerDeps) { projectId, runId: run.id, workspaceDir: project.workspaceDir, + approvalManager, onDelivered: (path) => { deliveredFiles.push(path); }, @@ -309,7 +321,19 @@ export function makeTriggerHandler(deps: TriggerDeps) { }); } - return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise => { + const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise => { + const action = approvalActionFromValue(event.action.value); + if (action === null) return; + const messageId = nonEmpty(event.context?.open_message_id); + if (messageId === undefined || !approvalManager.hasPending(messageId)) return; + + const resolvedBy = nonEmpty(event.operator.open_id) ?? "unknown"; + const resolution = approvalResolutionFor(action); + approvalManager.resolve(messageId, action, resolvedBy); + await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedBy); + }; + + const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise => { const msg = event.message; const sender = event.sender; void sender; // sender→user mapping is OPEN (principal sub-typology) @@ -468,6 +492,8 @@ export function makeTriggerHandler(deps: TriggerDeps) { await startAgentRun(runContext, cleanPrompt); }; + + return Object.assign(onMessage, { onCardAction, approvalManager }); } interface SessionMetadata { @@ -517,6 +543,51 @@ function nonEmpty(value: string | undefined): string | undefined { return value === undefined || value === "" ? undefined : value; } +function approvalActionFromValue(value: unknown): string | null { + if (typeof value === "string") { + try { + return approvalActionFromValue(JSON.parse(value)); + } catch { + return null; + } + } + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const action = (value as Record)["approval_action"]; + return typeof action === "string" && action !== "" ? action : null; +} + +function approvalResolutionFor(action: string): { readonly icon: string; readonly label: string; readonly template: "green" | "red" } { + const normalized = action.trim().toLowerCase(); + switch (normalized) { + case "approve": + case "approved": + return { icon: "✅", label: "Approved", template: "green" }; + case "accept": + case "accepted": + return { icon: "✅", label: "Accepted", template: "green" }; + case "confirm": + case "confirmed": + case "yes": + case "ok": + return { icon: "✅", label: "Confirmed", template: "green" }; + case "reject": + case "rejected": + return { icon: "❌", label: "Rejected", template: "red" }; + case "deny": + case "denied": + case "decline": + case "declined": + return { icon: "❌", label: "Declined", template: "red" }; + case "cancel": + case "canceled": + case "cancelled": + case "no": + return { icon: "❌", label: "Cancelled", template: "red" }; + default: + return { icon: "✅", label: action, template: "green" }; + } +} + interface BuildFeishuTriggerContextArgs { readonly msg: MessageReceiveEvent["message"]; readonly chatId: string; diff --git a/hub/src/server.ts b/hub/src/server.ts index 534238a..e6543bc 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -62,7 +62,7 @@ async function main(): Promise { const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId }; const larkClient = createLarkClient(feishuConfig); const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log }); - startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger); + startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction); } else { app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); } diff --git a/hub/test/unit/feishu-approval.test.ts b/hub/test/unit/feishu-approval.test.ts new file mode 100644 index 0000000..b3da1c3 --- /dev/null +++ b/hub/test/unit/feishu-approval.test.ts @@ -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; + readonly messagePatch?: (payload: unknown) => Promise; +} = {}): 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"]; +}