diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts new file mode 100644 index 00000000000..90967b593bd --- /dev/null +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; + +// Mock resolveFeishuAccount +vi.mock("./accounts.js", () => ({ + resolveFeishuAccount: vi.fn().mockReturnValue({ accountId: "mock-account" }), +})); + +// Mock bot.js to verify handleFeishuMessage call +vi.mock("./bot.js", () => ({ + handleFeishuMessage: vi.fn(), +})); + +import { handleFeishuMessage } from "./bot.js"; + +describe("Feishu Card Action Handler", () => { + const cfg = {} as any; // Minimal mock + const runtime = { log: vi.fn(), error: vi.fn() } as any; + + it("handles card action with text payload", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok1", + action: { value: { text: "/ping" }, tag: "button" }, + context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"/ping"}', + chat_id: "chat1", + }), + }), + }), + ); + }); + + it("handles card action with JSON object payload", async () => { + const event: FeishuCardActionEvent = { + operator: { open_id: "u123", user_id: "uid1", union_id: "un1" }, + token: "tok2", + action: { value: { key: "val" }, tag: "button" }, + context: { open_id: "u123", user_id: "uid1", chat_id: "" }, + }; + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + content: '{"text":"{\\"key\\":\\"val\\"}"}', + chat_id: "u123", // Fallback to open_id + }), + }), + }), + ); + }); +}); diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts new file mode 100644 index 00000000000..73ea2112fec --- /dev/null +++ b/extensions/feishu/src/card-action.ts @@ -0,0 +1,77 @@ +import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk"; +import { resolveFeishuAccount } from "./accounts.js"; +import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; + +export type FeishuCardActionEvent = { + operator: { + open_id: string; + user_id: string; + union_id: string; + }; + token: string; + action: { + value: Record; + tag: string; + }; + context: { + open_id: string; + user_id: string; + chat_id: string; + }; +}; + +export async function handleFeishuCardAction(params: { + cfg: ClawdbotConfig; + event: FeishuCardActionEvent; + botOpenId?: string; + runtime?: RuntimeEnv; + accountId?: string; +}): Promise { + const { cfg, event, runtime, accountId } = params; + const account = resolveFeishuAccount({ cfg, accountId }); + const log = runtime?.log ?? console.log; + + // Extract action value + const actionValue = event.action.value; + let content = ""; + 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 = { + sender: { + sender_id: { + open_id: event.operator.open_id, + user_id: event.operator.user_id, + union_id: event.operator.union_id, + }, + }, + message: { + message_id: `card-action-${event.token}`, + chat_id: event.context.chat_id || event.operator.open_id, + chat_type: event.context.chat_id ? "group" : "p2p", + message_type: "text", + content: JSON.stringify({ text: content }), + }, + }; + + log(`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`); + + // Dispatch as normal message + await handleFeishuMessage({ + cfg, + event: messageEvent, + botOpenId: params.botOpenId, + runtime, + accountId, + }); +} diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index c5217e65f39..bf78240871f 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -9,6 +9,7 @@ import { } from "openclaw/plugin-sdk"; import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js"; import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js"; +import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; import { createFeishuWSClient, createEventDispatcher } from "./client.js"; import { probeFeishu } from "./probe.js"; import { getMessageFeishu } from "./send.js"; @@ -350,6 +351,27 @@ function registerEventHandlers( "im.message.reaction.deleted_v1": async () => { // Ignore reaction removals }, + "card.action.trigger": async (data) => { + try { + const event = data as unknown as FeishuCardActionEvent; + const promise = handleFeishuCardAction({ + cfg, + event, + botOpenId: botOpenIds.get(accountId), + runtime, + accountId, + }); + if (fireAndForget) { + promise.catch((err) => { + error(`feishu[${accountId}]: error handling card action: ${String(err)}`); + }); + } else { + await promise; + } + } catch (err) { + error(`feishu[${accountId}]: error handling card action: ${String(err)}`); + } + }, }); }