openclaw/extensions/feishu/src/monitor.comment.test.ts

537 lines
18 KiB
TypeScript

import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "openclaw/plugin-sdk/reply-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import * as dedup from "./dedup.js";
import { monitorSingleAccount } from "./monitor.account.js";
import {
resolveDriveCommentEventTurn,
type FeishuDriveCommentNoticeEvent,
} from "./monitor.comment.js";
import { setFeishuRuntime } from "./runtime.js";
import type { ResolvedFeishuAccount } from "./types.js";
const handleFeishuCommentEventMock = vi.hoisted(() => vi.fn(async () => {}));
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = 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() })));
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
const TEST_DOC_TOKEN = "doxxxxxxx";
vi.mock("./client.js", () => ({
createEventDispatcher: createEventDispatcherMock,
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./comment-handler.js", () => ({
handleFeishuCommentEvent: handleFeishuCommentEventMock,
}));
vi.mock("./monitor.transport.js", () => ({
monitorWebSocket: monitorWebSocketMock,
monitorWebhook: monitorWebhookMock,
}));
vi.mock("./thread-bindings.js", () => ({
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
}));
function buildMonitorConfig(): ClawdbotConfig {
return {
channels: {
feishu: {
enabled: true,
},
},
} as ClawdbotConfig;
}
function buildMonitorAccount(): 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;
}
function createFeishuMonitorRuntime(params?: {
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
}): PluginRuntime {
return {
channel: {
debounce: {
createInboundDebouncer: params?.createInboundDebouncer ?? createInboundDebouncer,
resolveInboundDebounceMs: params?.resolveInboundDebounceMs ?? resolveInboundDebounceMs,
},
text: {
hasControlCommand: params?.hasControlCommand ?? hasControlCommand,
},
},
} as unknown as PluginRuntime;
}
function makeDriveCommentEvent(
overrides: Partial<FeishuDriveCommentNoticeEvent> = {},
): FeishuDriveCommentNoticeEvent {
return {
comment_id: "7623358762119646411",
event_id: "10d9d60b990db39f96a4c2fd357fb877",
is_mentioned: true,
notice_meta: {
file_token: TEST_DOC_TOKEN,
file_type: "docx",
from_user_id: {
open_id: "ou_509d4d7ace4a9addec2312676ffcba9b",
},
notice_type: "add_comment",
to_user_id: {
open_id: "ou_bot",
},
},
reply_id: "7623358762136374451",
timestamp: "1774951528000",
type: "drive.notice.comment_add_v1",
...overrides,
};
}
function makeOpenApiClient(params: {
documentTitle?: string;
documentUrl?: string;
isWholeComment?: boolean;
batchCommentId?: string;
quoteText?: string;
rootReplyText?: string;
targetReplyText?: string;
includeTargetReplyInBatch?: boolean;
repliesSequence?: Array<Array<{ reply_id: string; text: string }>>;
}) {
const remainingReplyBatches = [...(params.repliesSequence ?? [])];
return {
request: vi.fn(async (request: { method: "GET" | "POST"; url: string; data: unknown }) => {
if (request.url === "/open-apis/drive/v1/metas/batch_query") {
return {
code: 0,
data: {
metas: [
{
doc_token: TEST_DOC_TOKEN,
title: params.documentTitle ?? "Comment event handling request",
url: params.documentUrl ?? `https://www.larksuite.com/docx/${TEST_DOC_TOKEN}`,
},
],
},
};
}
if (request.url.includes("/comments/batch_query")) {
return {
code: 0,
data: {
items: [
{
comment_id: params.batchCommentId ?? "7623358762119646411",
is_whole: params.isWholeComment,
quote: params.quoteText ?? "im.message.receive_v1 message trigger implementation",
reply_list: {
replies: [
{
reply_id: "7623358762136374451",
content: {
elements: [
{
type: "text_run",
text_run: {
content:
params.rootReplyText ??
"Also send it to the agent after receiving the comment event",
},
},
],
},
},
...(params.includeTargetReplyInBatch
? [
{
reply_id: "7623359125036043462",
content: {
elements: [
{
type: "text_run",
text_run: {
content:
params.targetReplyText ?? "Please follow up on this comment",
},
},
],
},
},
]
: []),
],
},
},
],
},
};
}
if (request.url.includes("/replies")) {
const replyBatch = remainingReplyBatches.shift();
const items = replyBatch?.map((reply) => ({
reply_id: reply.reply_id,
content: {
elements: [
{
type: "text_run",
text_run: {
content: reply.text,
},
},
],
},
})) ?? [
{
reply_id: "7623358762136374451",
content: {
elements: [
{
type: "text_run",
text_run: {
content:
params.rootReplyText ??
"Also send it to the agent after receiving the comment event",
},
},
],
},
},
{
reply_id: "7623359125036043462",
content: {
elements: [
{
type: "text_run",
text_run: {
content: params.targetReplyText ?? "Please follow up on this comment",
},
},
],
},
},
];
return {
code: 0,
data: {
has_more: false,
items,
},
};
}
throw new Error(`unexpected request: ${request.method} ${request.url}`);
}),
};
}
async function setupCommentMonitorHandler(): Promise<(data: unknown) => Promise<void>> {
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
});
createEventDispatcherMock.mockReturnValue({ register });
await monitorSingleAccount({
cfg: buildMonitorConfig(),
account: buildMonitorAccount(),
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",
},
});
const handler = handlers["drive.notice.comment_add_v1"];
if (!handler) {
throw new Error("missing drive.notice.comment_add_v1 handler");
}
return handler;
}
describe("resolveDriveCommentEventTurn", () => {
it("builds a real comment-turn prompt for add_comment notices", async () => {
const client = makeOpenApiClient({ includeTargetReplyInBatch: true });
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent(),
botOpenId: "ou_bot",
createClient: () => client as never,
});
expect(turn).not.toBeNull();
expect(turn?.senderId).toBe("ou_509d4d7ace4a9addec2312676ffcba9b");
expect(turn?.messageId).toBe("drive-comment:10d9d60b990db39f96a4c2fd357fb877");
expect(turn?.fileType).toBe("docx");
expect(turn?.fileToken).toBe(TEST_DOC_TOKEN);
expect(turn?.prompt).toContain(
'The user added a comment in "Comment event handling request": Also send it to the agent after receiving the comment event',
);
expect(turn?.prompt).toContain(
"This is a Feishu document comment-thread event, not a Feishu IM conversation.",
);
expect(turn?.prompt).toContain("Prefer plain text suitable for a comment thread.");
expect(turn?.prompt).toContain("Do not include internal reasoning");
expect(turn?.prompt).toContain("Do not narrate your plan or execution process");
expect(turn?.prompt).toContain("reply only with the user-facing result itself");
expect(turn?.prompt).toContain("comment_id: 7623358762119646411");
expect(turn?.prompt).toContain("reply_id: 7623358762136374451");
expect(turn?.prompt).toContain("The system will automatically reply with your final answer");
});
it("preserves whole-document comment metadata for downstream delivery mode selection", async () => {
const client = makeOpenApiClient({
includeTargetReplyInBatch: true,
isWholeComment: true,
});
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent(),
botOpenId: "ou_bot",
createClient: () => client as never,
});
expect(turn?.isWholeComment).toBe(true);
expect(turn?.prompt).toContain("This is a whole-document comment.");
expect(turn?.prompt).toContain("Whole-document comments do not support direct replies.");
});
it("does not trust whole-comment metadata from a mismatched batch_query item", async () => {
const client = makeOpenApiClient({
includeTargetReplyInBatch: true,
isWholeComment: true,
batchCommentId: "different_comment_id",
});
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent(),
botOpenId: "ou_bot",
createClient: () => client as never,
});
expect(turn?.isWholeComment).toBeUndefined();
expect(turn?.prompt).not.toContain("This is a whole-document comment.");
});
it("preserves sender user_id for downstream allowlist checks", async () => {
const client = makeOpenApiClient({ includeTargetReplyInBatch: true });
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent({
notice_meta: {
...makeDriveCommentEvent().notice_meta,
from_user_id: {
open_id: "ou_509d4d7ace4a9addec2312676ffcba9b",
user_id: "on_comment_user_1",
},
},
}),
botOpenId: "ou_bot",
createClient: () => client as never,
});
expect(turn?.senderId).toBe("ou_509d4d7ace4a9addec2312676ffcba9b");
expect(turn?.senderUserId).toBe("on_comment_user_1");
});
it("falls back to the replies API to resolve add_reply text", async () => {
const client = makeOpenApiClient({
includeTargetReplyInBatch: false,
targetReplyText: "Please follow up on this comment",
});
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent({
notice_meta: {
...makeDriveCommentEvent().notice_meta,
notice_type: "add_reply",
},
reply_id: "7623359125036043462",
}),
botOpenId: "ou_bot",
createClient: () => client as never,
});
expect(turn?.prompt).toContain(
'The user added a reply in "Comment event handling request": Please follow up on this comment',
);
expect(turn?.prompt).toContain(
"Original comment: Also send it to the agent after receiving the comment event",
);
expect(turn?.prompt).toContain(`file_token: ${TEST_DOC_TOKEN}`);
expect(turn?.prompt).toContain("Event type: add_reply");
expect(client.request).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
url: expect.stringContaining(
`/comments/7623358762119646411/replies?file_type=docx&page_size=100&user_id_type=open_id`,
),
}),
);
});
it("retries comment reply lookup when the requested reply is not immediately visible", async () => {
const waitMs = vi.fn(async () => {});
const client = makeOpenApiClient({
includeTargetReplyInBatch: false,
repliesSequence: [
[
{
reply_id: "7623358762136374451",
text: "Also send it to the agent after receiving the comment event",
},
{ reply_id: "7623358762999999999", text: "Earlier assistant summary" },
],
[
{
reply_id: "7623358762136374451",
text: "Also send it to the agent after receiving the comment event",
},
{ reply_id: "7623358762999999999", text: "Earlier assistant summary" },
],
[
{
reply_id: "7623358762136374451",
text: "Also send it to the agent after receiving the comment event",
},
{ reply_id: "7623359125999999999", text: "Insert a sentence below this paragraph" },
],
],
});
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent({
notice_meta: {
...makeDriveCommentEvent().notice_meta,
notice_type: "add_reply",
},
reply_id: "7623359125999999999",
}),
botOpenId: "ou_bot",
createClient: () => client as never,
waitMs,
});
expect(turn?.targetReplyText).toBe("Insert a sentence below this paragraph");
expect(turn?.prompt).toContain("Insert a sentence below this paragraph");
expect(waitMs).toHaveBeenCalledTimes(2);
expect(waitMs).toHaveBeenNthCalledWith(1, 1000);
expect(waitMs).toHaveBeenNthCalledWith(2, 1000);
expect(
client.request.mock.calls.filter(
([request]: [{ method: string; url: string }]) =>
request.method === "GET" && request.url.includes("/replies"),
),
).toHaveLength(3);
});
it("ignores self-authored comment notices", async () => {
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent({
notice_meta: {
...makeDriveCommentEvent().notice_meta,
from_user_id: { open_id: "ou_bot" },
},
}),
botOpenId: "ou_bot",
createClient: () => makeOpenApiClient({}) as never,
});
expect(turn).toBeNull();
});
it("skips comment notices when bot open_id is unavailable", async () => {
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent(),
botOpenId: undefined,
createClient: () => makeOpenApiClient({}) as never,
});
expect(turn).toBeNull();
});
});
describe("drive.notice.comment_add_v1 monitor handler", () => {
beforeEach(() => {
handlers = {};
handleFeishuCommentEventMock.mockClear();
createEventDispatcherMock.mockReset();
createFeishuClientMock.mockReset().mockReturnValue(makeOpenApiClient({}) as never);
createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
stop: vi.fn(),
}));
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(false);
setFeishuRuntime(createFeishuMonitorRuntime());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("dispatches comment notices through handleFeishuCommentEvent", async () => {
const onComment = await setupCommentMonitorHandler();
await onComment(makeDriveCommentEvent());
expect(handleFeishuCommentEventMock).toHaveBeenCalledTimes(1);
expect(handleFeishuCommentEventMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
botOpenId: "ou_bot",
event: expect.objectContaining({
event_id: "10d9d60b990db39f96a4c2fd357fb877",
comment_id: "7623358762119646411",
}),
}),
);
});
it("drops duplicate comment events before dispatch", async () => {
vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(true);
const onComment = await setupCommentMonitorHandler();
await onComment(makeDriveCommentEvent());
expect(handleFeishuCommentEventMock).not.toHaveBeenCalled();
});
});