import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; import { createInboundDebouncer, resolveInboundDebounceMs, } from "../../../src/auto-reply/inbound-debounce.js"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; import type { ResolvedFeishuAccount } from "./types.js"; const createEventDispatcherMock = vi.hoisted(() => vi.fn()); const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {})); const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {})); const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {})); const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" }))); const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() }))); let handlers: Record Promise> = {}; vi.mock("./client.js", () => ({ createEventDispatcher: createEventDispatcherMock, })); vi.mock("./monitor.transport.js", () => ({ monitorWebSocket: monitorWebSocketMock, monitorWebhook: monitorWebhookMock, })); vi.mock("./bot.js", async () => { const actual = await vi.importActual("./bot.js"); return { ...actual, handleFeishuMessage: handleFeishuMessageMock, }; }); vi.mock("./send.js", async () => { const actual = await vi.importActual("./send.js"); return { ...actual, sendCardFeishu: sendCardFeishuMock, }; }); vi.mock("./thread-bindings.js", () => ({ createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock, })); function buildAccount(): ResolvedFeishuAccount { return { accountId: "default", enabled: true, configured: true, appId: "cli_test", appSecret: "secret_test", // pragma: allowlist secret domain: "feishu", config: { enabled: true, connectionMode: "websocket", }, } as ResolvedFeishuAccount; } async function registerHandlers() { setFeishuRuntime( createPluginRuntimeMock({ channel: { debounce: { createInboundDebouncer, resolveInboundDebounceMs, }, text: { hasControlCommand, }, }, }), ); const register = vi.fn((registered: Record Promise>) => { handlers = registered; }); createEventDispatcherMock.mockReturnValue({ register }); await monitorSingleAccount({ cfg: {} as ClawdbotConfig, account: buildAccount(), runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn(), } as RuntimeEnv, botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot", botName: "Bot", }, }); const onBotMenu = handlers["application.bot.menu_v6"]; if (!onBotMenu) { throw new Error("missing application.bot.menu_v6 handler"); } return onBotMenu; } describe("Feishu bot menu handler", () => { beforeEach(() => { handlers = {}; vi.clearAllMocks(); }); it("opens the quick-action launcher card at the webhook/event layer", async () => { const onBotMenu = await registerHandlers(); await onBotMenu({ event_key: "quick-actions", timestamp: "1700000000000", operator: { operator_id: { open_id: "ou_user1", user_id: "user_1", union_id: "union_1", }, }, }); expect(sendCardFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ to: "user:ou_user1", card: expect.objectContaining({ header: expect.objectContaining({ title: expect.objectContaining({ content: "Quick actions" }), }), }), }), ); expect(handleFeishuMessageMock).not.toHaveBeenCalled(); }); it("does not block bot-menu handling on quick-action launcher send", async () => { const onBotMenu = await registerHandlers(); let resolveSend: (() => void) | undefined; sendCardFeishuMock.mockImplementationOnce( () => new Promise((resolve) => { resolveSend = () => resolve({ messageId: "m1", chatId: "c1" }); }), ); const pending = onBotMenu({ event_key: "quick-actions", timestamp: "1700000000000", operator: { operator_id: { open_id: "ou_user1", user_id: "user_1", union_id: "union_1", }, }, }); let settled = false; pending.finally(() => { settled = true; }); await Promise.resolve(); expect(settled).toBe(true); resolveSend?.(); await pending; }); it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => { const onBotMenu = await registerHandlers(); await onBotMenu({ event_key: "custom-key", timestamp: "1700000000000", operator: { operator_id: { open_id: "ou_user1", user_id: "user_1", union_id: "union_1", }, }, }); expect(handleFeishuMessageMock).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ message: expect.objectContaining({ content: '{"text":"/menu custom-key"}', }), }), }), ); expect(sendCardFeishuMock).not.toHaveBeenCalled(); }); it("falls back to the legacy /menu path when launcher rendering fails", async () => { const onBotMenu = await registerHandlers(); sendCardFeishuMock.mockRejectedValueOnce(new Error("boom")); await onBotMenu({ event_key: "quick-actions", timestamp: "1700000000000", operator: { operator_id: { open_id: "ou_user1", user_id: "user_1", union_id: "union_1", }, }, }); await vi.waitFor(() => { expect(handleFeishuMessageMock).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ message: expect.objectContaining({ content: '{"text":"/menu quick-actions"}', }), }), }), ); }); }); });