feat: feishu comment event (#58497)

Merged via squash.

Prepared head SHA: a9dfeb0d62
Co-authored-by: wittam-01 <271711640+wittam-01@users.noreply.github.com>
Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com>
Reviewed-by: @odysseus0
This commit is contained in:
wittam-01
2026-04-01 15:12:38 +08:00
committed by GitHub
parent cad3da52c9
commit 1b94e8ca14
13 changed files with 2514 additions and 1 deletions

View File

@@ -0,0 +1,248 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js";
import type { ClawdbotConfig } from "../runtime-api.js";
import { handleFeishuCommentEvent } from "./comment-handler.js";
import { setFeishuRuntime } from "./runtime.js";
const resolveDriveCommentEventTurnMock = vi.hoisted(() => vi.fn());
const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
const replyCommentMock = vi.hoisted(() => vi.fn());
vi.mock("./monitor.comment.js", () => ({
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
}));
vi.mock("./comment-dispatcher.js", () => ({
createFeishuCommentReplyDispatcher: createFeishuCommentReplyDispatcherMock,
}));
vi.mock("./dynamic-agent.js", () => ({
maybeCreateDynamicAgent: maybeCreateDynamicAgentMock,
}));
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./drive.js", () => ({
replyComment: replyCommentMock,
}));
function buildConfig(overrides?: Partial<ClawdbotConfig>): ClawdbotConfig {
return {
channels: {
feishu: {
enabled: true,
dmPolicy: "open",
},
},
...overrides,
} as ClawdbotConfig;
}
function buildResolvedRoute(matchedBy: "binding.channel" | "default" = "binding.channel") {
return {
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:feishu:direct:ou_sender",
mainSessionKey: "agent:main:feishu",
lastRoutePolicy: "session" as const,
matchedBy,
};
}
describe("handleFeishuCommentEvent", () => {
beforeEach(() => {
vi.clearAllMocks();
maybeCreateDynamicAgentMock.mockResolvedValue({ created: false });
resolveDriveCommentEventTurnMock.mockResolvedValue({
eventId: "evt_1",
messageId: "drive-comment:evt_1",
commentId: "comment_1",
replyId: "reply_1",
noticeType: "add_comment",
fileToken: "doc_token_1",
fileType: "docx",
senderId: "ou_sender",
senderUserId: "on_sender_user",
timestamp: "1774951528000",
isMentioned: true,
documentTitle: "Project review",
prompt: "prompt body",
preview: "prompt body",
rootCommentText: "root comment",
targetReplyText: "latest reply",
});
replyCommentMock.mockResolvedValue({ reply_id: "r1" });
const runtime = createPluginRuntimeMock({
channel: {
routing: {
resolveAgentRoute: vi.fn(() => buildResolvedRoute()),
},
reply: {
dispatchReplyFromConfig: vi.fn(async () => ({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
})),
withReplyDispatcher: vi.fn(async ({ run, onSettled }) => {
try {
return await run();
} finally {
await onSettled?.();
}
}),
},
},
});
setFeishuRuntime(runtime);
createFeishuCommentReplyDispatcherMock.mockReturnValue({
dispatcher: {
markComplete: vi.fn(),
waitForIdle: vi.fn(async () => {}),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
});
});
it("records a comment-thread inbound context with a routable Feishu origin", async () => {
await handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
const runtime = (await import("./runtime.js")).getFeishuRuntime();
const finalizeInboundContext = runtime.channel.reply.finalizeInboundContext as ReturnType<
typeof vi.fn
>;
const recordInboundSession = runtime.channel.session.recordInboundSession as ReturnType<
typeof vi.fn
>;
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
From: "feishu:ou_sender",
To: "comment:docx:doc_token_1:comment_1",
Surface: "feishu-comment",
OriginatingChannel: "feishu",
OriginatingTo: "comment:docx:doc_token_1:comment_1",
MessageSid: "drive-comment:evt_1",
}),
);
expect(recordInboundSession).toHaveBeenCalledTimes(1);
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("allows comment senders matched by user_id allowlist entries", async () => {
const runtime = createPluginRuntimeMock({
channel: {
pairing: {
readAllowFromStore: vi.fn(async () => []),
},
routing: {
resolveAgentRoute: vi.fn(() => buildResolvedRoute()),
},
reply: {
dispatchReplyFromConfig: vi.fn(async () => ({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 1 },
})),
withReplyDispatcher: vi.fn(async ({ run, onSettled }) => {
try {
return await run();
} finally {
await onSettled?.();
}
}),
},
},
});
setFeishuRuntime(runtime);
await handleFeishuCommentEvent({
cfg: buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["on_sender_user"],
},
},
}),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(replyCommentMock).not.toHaveBeenCalled();
});
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
const runtime = createPluginRuntimeMock({
channel: {
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => ({ code: "TESTCODE", created: true })),
},
routing: {
resolveAgentRoute: vi.fn(() => buildResolvedRoute()),
},
},
});
setFeishuRuntime(runtime);
await handleFeishuCommentEvent({
cfg: buildConfig({
channels: {
feishu: {
enabled: true,
dmPolicy: "pairing",
allowFrom: [],
},
},
}),
accountId: "default",
event: { event_id: "evt_1" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
expect(replyCommentMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doc_token_1",
file_type: "docx",
comment_id: "comment_1",
}),
);
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
typeof vi.fn
>;
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
});
});