openclaw/extensions/feishu/src/monitor.bot-menu.test.ts

230 lines
6.2 KiB
TypeScript

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<string, (data: unknown) => Promise<void>> = {};
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<typeof import("./bot.js")>("./bot.js");
return {
...actual,
handleFeishuMessage: handleFeishuMessageMock,
};
});
vi.mock("./send.js", async () => {
const actual = await vi.importActual<typeof import("./send.js")>("./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<string, (data: unknown) => Promise<void>>) => {
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"}',
}),
}),
}),
);
});
});
});