From 0e825ece0540e154681f59ee19d7a37244c7bd31 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:12:29 -0500 Subject: [PATCH] test: add Feishu bot-menu lifecycle regression --- extensions/feishu/src/monitor.account.ts | 29 +- .../src/monitor.bot-menu.lifecycle.test.ts | 356 ++++++++++++++++++ .../feishu/src/monitor.bot-menu.test.ts | 81 ++-- 3 files changed, 413 insertions(+), 53 deletions(-) create mode 100644 extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index a15240075d6..ff3a0ba9dc9 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -544,6 +544,15 @@ function registerEventHandlers( }), }, }; + const syntheticMessageId = syntheticEvent.message.message_id; + if (await hasProcessedFeishuMessage(syntheticMessageId, accountId, log)) { + log(`feishu[${accountId}]: dropping duplicate bot-menu event for ${syntheticMessageId}`); + return; + } + if (!tryBeginFeishuMessageProcessing(syntheticMessageId, accountId)) { + log(`feishu[${accountId}]: dropping in-flight bot-menu event for ${syntheticMessageId}`); + return; + } const handleLegacyMenu = () => handleFeishuMessage({ cfg, @@ -553,6 +562,7 @@ function registerEventHandlers( runtime, chatHistories, accountId, + processingClaimHeld: true, }); const promise = maybeHandleFeishuQuickActionMenu({ @@ -561,12 +571,19 @@ function registerEventHandlers( operatorOpenId, runtime, accountId, - }).then((handledMenu) => { - if (handledMenu) { - return; - } - return handleLegacyMenu(); - }); + }) + .then(async (handledMenu) => { + if (handledMenu) { + await recordProcessedFeishuMessage(syntheticMessageId, accountId, log); + releaseFeishuMessageProcessing(syntheticMessageId, accountId); + return; + } + return await handleLegacyMenu(); + }) + .catch((err) => { + releaseFeishuMessageProcessing(syntheticMessageId, accountId); + throw err; + }); if (fireAndForget) { promise.catch((err) => { error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`); diff --git a/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts new file mode 100644 index 00000000000..187d685d919 --- /dev/null +++ b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.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 createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() }))); +const createFeishuReplyDispatcherMock = vi.hoisted(() => vi.fn()); +const resolveBoundConversationMock = vi.hoisted(() => vi.fn(() => null)); +const touchBindingMock = vi.hoisted(() => vi.fn()); +const resolveAgentRouteMock = vi.hoisted(() => vi.fn()); +const dispatchReplyFromConfigMock = vi.hoisted(() => vi.fn()); +const withReplyDispatcherMock = vi.hoisted(() => vi.fn()); +const finalizeInboundContextMock = vi.hoisted(() => vi.fn((ctx) => ctx)); +const sendCardFeishuMock = vi.hoisted(() => + vi.fn(async () => ({ messageId: "om_card_sent", chatId: "p2p:ou_user1" })), +); +const getMessageFeishuMock = vi.hoisted(() => vi.fn(async () => null)); +const listFeishuThreadMessagesMock = vi.hoisted(() => vi.fn(async () => [])); +const sendMessageFeishuMock = vi.hoisted(() => + vi.fn(async () => ({ messageId: "om_sent", chatId: "p2p:ou_user1" })), +); + +let handlers: Record Promise> = {}; +let lastRuntime: RuntimeEnv | null = null; +const originalStateDir = process.env.OPENCLAW_STATE_DIR; + +vi.mock("./client.js", async () => { + const actual = await vi.importActual("./client.js"); + return { + ...actual, + createEventDispatcher: createEventDispatcherMock, + }; +}); + +vi.mock("./monitor.transport.js", () => ({ + monitorWebSocket: monitorWebSocketMock, + monitorWebhook: monitorWebhookMock, +})); + +vi.mock("./thread-bindings.js", () => ({ + createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock, +})); + +vi.mock("./reply-dispatcher.js", () => ({ + createFeishuReplyDispatcher: createFeishuReplyDispatcherMock, +})); + +vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, + getMessageFeishu: getMessageFeishuMock, + listFeishuThreadMessages: listFeishuThreadMessagesMock, + sendMessageFeishu: sendMessageFeishuMock, +})); + +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSessionBindingService: () => ({ + resolveByConversation: resolveBoundConversationMock, + touch: touchBindingMock, + }), + }; +}); + +vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({ + getSessionBindingService: () => ({ + resolveByConversation: resolveBoundConversationMock, + touch: touchBindingMock, + }), +})); + +function createLifecycleConfig(): ClawdbotConfig { + return { + channels: { + feishu: { + enabled: true, + dmPolicy: "open", + requireMention: false, + resolveSenderNames: false, + accounts: { + "acct-menu": { + enabled: true, + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + connectionMode: "websocket", + dmPolicy: "open", + requireMention: false, + resolveSenderNames: false, + }, + }, + }, + }, + messages: { + inbound: { + debounceMs: 0, + byChannel: { + feishu: 0, + }, + }, + }, + } as ClawdbotConfig; +} + +function createLifecycleAccount(): ResolvedFeishuAccount { + return { + accountId: "acct-menu", + enabled: true, + configured: true, + appId: "cli_test", + appSecret: "secret_test", // pragma: allowlist secret + domain: "feishu", + config: { + enabled: true, + connectionMode: "websocket", + dmPolicy: "open", + requireMention: false, + resolveSenderNames: false, + }, + } as ResolvedFeishuAccount; +} + +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as RuntimeEnv; +} + +function createBotMenuEvent(params: { eventKey: string; timestamp: string }) { + return { + event_key: params.eventKey, + timestamp: params.timestamp, + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }; +} + +async function settleAsyncWork(): Promise { + for (let i = 0; i < 6; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +async function setupLifecycleMonitor() { + const register = vi.fn((registered: Record Promise>) => { + handlers = registered; + }); + createEventDispatcherMock.mockReturnValue({ register }); + + lastRuntime = createRuntimeEnv(); + + await monitorSingleAccount({ + cfg: createLifecycleConfig(), + account: createLifecycleAccount(), + runtime: lastRuntime, + botOpenIdSource: { + kind: "prefetched", + botOpenId: "ou_bot_1", + 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 lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks(); + handlers = {}; + lastRuntime = null; + process.env.OPENCLAW_STATE_DIR = `/tmp/openclaw-feishu-bot-menu-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + const dispatcher = { + sendToolResult: vi.fn(() => false), + sendBlockReply: vi.fn(() => false), + sendFinalReply: vi.fn(async () => true), + waitForIdle: vi.fn(async () => {}), + getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), + markComplete: vi.fn(), + }; + + createFeishuReplyDispatcherMock.mockReturnValue({ + dispatcher, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }); + + resolveBoundConversationMock.mockReturnValue({ + bindingId: "binding-menu", + targetSessionKey: "agent:bound-agent:feishu:direct:ou_user1", + }); + + resolveAgentRouteMock.mockReturnValue({ + agentId: "main", + channel: "feishu", + accountId: "acct-menu", + sessionKey: "agent:main:feishu:direct:ou_user1", + mainSessionKey: "agent:main:main", + matchedBy: "default", + }); + + dispatchReplyFromConfigMock.mockImplementation(async ({ dispatcher }) => { + await dispatcher.sendFinalReply({ text: "menu reply once" }); + return { + queuedFinal: false, + counts: { final: 1 }, + }; + }); + + withReplyDispatcherMock.mockImplementation(async ({ run }) => await run()); + + setFeishuRuntime( + createPluginRuntimeMock({ + channel: { + debounce: { + resolveInboundDebounceMs: vi.fn(() => 0), + createInboundDebouncer: (params: { + onFlush?: (items: T[]) => Promise; + onError?: (err: unknown, items: T[]) => void; + }) => ({ + enqueue: async (item: T) => { + try { + await params.onFlush?.([item]); + } catch (err) { + params.onError?.(err, [item]); + } + }, + flushKey: async () => {}, + }), + }, + text: { + hasControlCommand: vi.fn(() => false), + }, + routing: { + resolveAgentRoute: + resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn(() => ({})), + formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), + finalizeInboundContext: + finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + dispatchReplyFromConfig: + dispatchReplyFromConfigMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"], + withReplyDispatcher: + withReplyDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"], + }, + commands: { + shouldComputeCommandAuthorized: vi.fn(() => false), + resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false), + }, + session: { + readSessionUpdatedAt: vi.fn(), + resolveStorePath: vi.fn(() => "/tmp/feishu-bot-menu-sessions.json"), + }, + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue([]), + upsertPairingRequest: vi.fn(), + buildPairingReply: vi.fn(), + }, + }, + media: { + detectMime: vi.fn(async () => "text/plain"), + }, + }) as unknown as PluginRuntime, + ); + }); + + afterEach(() => { + if (originalStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + return; + } + process.env.OPENCLAW_STATE_DIR = originalStateDir; + }); + + it("opens one launcher card across duplicate quick-actions replay", async () => { + const onBotMenu = await setupLifecycleMonitor(); + const event = createBotMenuEvent({ + eventKey: "quick-actions", + timestamp: "1700000000000", + }); + + await onBotMenu(event); + await settleAsyncWork(); + await onBotMenu(event); + await settleAsyncWork(); + + expect(lastRuntime?.error).not.toHaveBeenCalled(); + expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-menu", + to: "user:ou_user1", + }), + ); + expect(dispatchReplyFromConfigMock).not.toHaveBeenCalled(); + expect(createFeishuReplyDispatcherMock).not.toHaveBeenCalled(); + }); + + it("falls back once to the legacy routed reply path when launcher rendering fails", async () => { + const onBotMenu = await setupLifecycleMonitor(); + const event = createBotMenuEvent({ + eventKey: "quick-actions", + timestamp: "1700000000001", + }); + sendCardFeishuMock.mockRejectedValueOnce(new Error("boom")); + + await onBotMenu(event); + await settleAsyncWork(); + await onBotMenu(event); + await settleAsyncWork(); + + expect(lastRuntime?.error).not.toHaveBeenCalled(); + expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1); + expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acct-menu", + chatId: "p2p:ou_user1", + replyToMessageId: "bot-menu:quick-actions:1700000000001", + }), + ); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + AccountId: "acct-menu", + SessionKey: "agent:bound-agent:feishu:direct:ou_user1", + MessageSid: "bot-menu:quick-actions:1700000000001", + }), + ); + expect(touchBindingMock).toHaveBeenCalledWith("binding-menu"); + + const dispatcher = createFeishuReplyDispatcherMock.mock.results[0]?.value.dispatcher as { + sendFinalReply: ReturnType; + }; + expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/feishu/src/monitor.bot-menu.test.ts b/extensions/feishu/src/monitor.bot-menu.test.ts index 5bcba5716d4..d3170757647 100644 --- a/extensions/feishu/src/monitor.bot-menu.test.ts +++ b/extensions/feishu/src/monitor.bot-menu.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { hasControlCommand } from "../../../src/auto-reply/command-detection.js"; import { createInboundDebouncer, @@ -18,6 +18,7 @@ const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1" const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() }))); let handlers: Record Promise> = {}; +const originalStateDir = process.env.OPENCLAW_STATE_DIR; vi.mock("./client.js", () => ({ createEventDispatcher: createEventDispatcherMock, @@ -63,6 +64,20 @@ function buildAccount(): ResolvedFeishuAccount { } as ResolvedFeishuAccount; } +function createBotMenuEvent(params: { eventKey: string; timestamp: string }) { + return { + event_key: params.eventKey, + timestamp: params.timestamp, + operator: { + operator_id: { + open_id: "ou_user1", + user_id: "user_1", + union_id: "union_1", + }, + }, + }; +} + async function registerHandlers() { setFeishuRuntime( createPluginRuntimeMock({ @@ -108,22 +123,21 @@ describe("Feishu bot menu handler", () => { beforeEach(() => { handlers = {}; vi.clearAllMocks(); + process.env.OPENCLAW_STATE_DIR = `/tmp/openclaw-feishu-bot-menu-test-${Date.now()}-${Math.random().toString(36).slice(2)}`; + }); + + afterEach(() => { + if (originalStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + return; + } + process.env.OPENCLAW_STATE_DIR = originalStateDir; }); 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", - }, - }, - }); + await onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000000" })); expect(sendCardFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -148,24 +162,17 @@ describe("Feishu bot menu handler", () => { }), ); - const pending = onBotMenu({ - event_key: "quick-actions", - timestamp: "1700000000000", - operator: { - operator_id: { - open_id: "ou_user1", - user_id: "user_1", - union_id: "union_1", - }, - }, - }); + const pending = onBotMenu( + createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000001" }), + ); let settled = false; pending.finally(() => { settled = true; }); - await Promise.resolve(); - expect(settled).toBe(true); + await vi.waitFor(() => { + expect(settled).toBe(true); + }); resolveSend?.(); await pending; @@ -174,17 +181,7 @@ describe("Feishu bot menu handler", () => { 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", - }, - }, - }); + await onBotMenu(createBotMenuEvent({ eventKey: "custom-key", timestamp: "1700000000002" })); expect(handleFeishuMessageMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -202,17 +199,7 @@ describe("Feishu bot menu handler", () => { 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 onBotMenu(createBotMenuEvent({ eventKey: "quick-actions", timestamp: "1700000000003" })); await vi.waitFor(() => { expect(handleFeishuMessageMock).toHaveBeenCalledWith(