diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 90967b593bd..396dbaec652 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -60,4 +60,83 @@ describe("Feishu Card Action Handler", () => { }), ); }); + + it("handles select_static dropdown with action.option", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok3", + action: { value: { field: "model_selection" }, tag: "select_static", option: "gpt-4o" }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + const call = vi + .mocked(handleFeishuMessage) + .mock.calls.find( + (c: unknown[]) => + (c[0] as { event: { message: { message_id: string } } }).event.message.message_id === + "card-action-tok3", + ); + expect(call).toBeDefined(); + const inner = JSON.parse( + JSON.parse((call![0] as { event: { message: { content: string } } }).event.message.content) + .text, + ); + expect(inner).toMatchObject({ option: "gpt-4o" }); + }); + + it("handles multi-select checkbox with action.options", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok4", + action: { value: { field: "tags" }, tag: "checkbox", options: ["tag1", "tag2"] }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + const call = vi + .mocked(handleFeishuMessage) + .mock.calls.find( + (c: unknown[]) => + (c[0] as { event: { message: { message_id: string } } }).event.message.message_id === + "card-action-tok4", + ); + expect(call).toBeDefined(); + const inner = JSON.parse( + JSON.parse((call![0] as { event: { message: { content: string } } }).event.message.content) + .text, + ); + expect(inner).toMatchObject({ options: ["tag1", "tag2"] }); + }); + + it("handles form submission with action.form_value", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok5", + action: { + value: { form_id: "approval" }, + tag: "form", + form_value: { name: "Alice", approved: true }, + }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + const call = vi + .mocked(handleFeishuMessage) + .mock.calls.find( + (c: unknown[]) => + (c[0] as { event: { message: { message_id: string } } }).event.message.message_id === + "card-action-tok5", + ); + expect(call).toBeDefined(); + const inner = JSON.parse( + JSON.parse((call![0] as { event: { message: { content: string } } }).event.message.content) + .text, + ); + expect(inner).toMatchObject({ form_value: { name: "Alice", approved: true } }); + }); }); diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index e4f76846316..cc924969e5b 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -12,6 +12,9 @@ export type FeishuCardActionEvent = { action: { value: Record; tag: string; + option?: string; + options?: string[]; + form_value?: Record; }; context: { open_id: string; @@ -19,21 +22,6 @@ export type FeishuCardActionEvent = { chat_id: string; }; }; - -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; - } - if ("command" in actionValue && typeof actionValue.command === "string") { - return actionValue.command; - } - return JSON.stringify(actionValue); - } - return String(actionValue); -} - export async function handleFeishuCardAction(params: { cfg: ClawdbotConfig; event: FeishuCardActionEvent; @@ -44,7 +32,71 @@ 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); + + // Extract action content — check component-specific fields before falling + // through to the generic value.text / value.command path. + const actionValue = event.action.value ?? {}; + let content = ""; + + // Interactive components: when the static value includes a command/text + // routing key, preserve it as the content so downstream command detection + // still works. Attach the dynamic user input as structured metadata that + // the agent can inspect without breaking command probing. + const hasCommand = + typeof actionValue === "object" && + actionValue !== null && + "command" in actionValue && + typeof actionValue.command === "string"; + const hasText = + typeof actionValue === "object" && + actionValue !== null && + "text" in actionValue && + typeof actionValue.text === "string"; + + if (event.action.form_value && typeof event.action.form_value === "object") { + if (hasCommand) { + content = `${actionValue.command} ${JSON.stringify({ form_value: event.action.form_value })}`; + } else if (hasText) { + content = `${actionValue.text} ${JSON.stringify({ form_value: event.action.form_value })}`; + } else { + const merged = { ...actionValue, form_value: event.action.form_value }; + content = JSON.stringify(merged); + } + } + // multi-select (checkbox): merge options array into value + else if (Array.isArray(event.action.options)) { + if (hasCommand) { + content = `${actionValue.command} ${JSON.stringify({ options: event.action.options })}`; + } else if (hasText) { + content = `${actionValue.text} ${JSON.stringify({ options: event.action.options })}`; + } else { + const merged = { ...actionValue, options: event.action.options }; + content = JSON.stringify(merged); + } + } + // single-select (select_static dropdown): merge option into value + else if (typeof event.action.option === "string") { + if (hasCommand) { + content = `${actionValue.command} ${JSON.stringify({ option: event.action.option })}`; + } else if (hasText) { + content = `${actionValue.text} ${JSON.stringify({ option: event.action.option })}`; + } else { + const merged = { ...actionValue, option: event.action.option }; + content = JSON.stringify(merged); + } + } + // button: existing text / command / JSON fallback + else if (typeof actionValue === "object" && actionValue !== null) { + if ("text" in actionValue && typeof actionValue.text === "string") { + content = actionValue.text; + } else if ("command" in actionValue && typeof actionValue.command === "string") { + content = actionValue.command; + } else { + content = JSON.stringify(actionValue); + } + } else { + content = String(actionValue); + } // Construct a synthetic message event const messageEvent: FeishuMessageEvent = {