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 Promise> = {}; 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 { 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>; }) { 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> { const register = vi.fn((registered: Record Promise>) => { handlers = registered; }); createEventDispatcherMock.mockReturnValue({ register }); await monitorSingleAccount({ cfg: buildMonitorConfig(), account: buildMonitorAccount(), runtime: createNonExitingTypedRuntimeEnv(), 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(); }); });