diff --git a/extensions/feishu/src/comment-dispatcher.ts b/extensions/feishu/src/comment-dispatcher.ts index 3d28a493b4a..a06fafcd49f 100644 --- a/extensions/feishu/src/comment-dispatcher.ts +++ b/extensions/feishu/src/comment-dispatcher.ts @@ -2,12 +2,13 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay import { createReplyPrefixContext, type ClawdbotConfig, - type RuntimeEnv, type ReplyPayload, + type RuntimeEnv, } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; -import { replyComment, type CommentFileType } from "./drive.js"; +import type { CommentFileType } from "./comment-target.js"; +import { replyComment } from "./drive.js"; import { getFeishuRuntime } from "./runtime.js"; export type CreateFeishuCommentReplyDispatcherParams = { diff --git a/extensions/feishu/src/comment-handler.test.ts b/extensions/feishu/src/comment-handler.test.ts new file mode 100644 index 00000000000..ee274a71954 --- /dev/null +++ b/extensions/feishu/src/comment-handler.test.ts @@ -0,0 +1,195 @@ +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 { + 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", + 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("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(); + }); +}); diff --git a/extensions/feishu/src/comment-handler.ts b/extensions/feishu/src/comment-handler.ts index 32bcae8682f..b14a4e8938a 100644 --- a/extensions/feishu/src/comment-handler.ts +++ b/extensions/feishu/src/comment-handler.ts @@ -5,10 +5,12 @@ import { type RuntimeEnv, } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js"; +import { buildFeishuCommentTarget } from "./comment-target.js"; +import { replyComment } from "./drive.js"; import { maybeCreateDynamicAgent } from "./dynamic-agent.js"; import { - parseFeishuDriveCommentNoticeEventPayload, resolveDriveCommentEventTurn, type FeishuDriveCommentNoticeEvent, } from "./monitor.comment.js"; @@ -27,8 +29,7 @@ type HandleFeishuCommentEventParams = { function buildCommentSessionKey(params: { core: ReturnType; route: ResolvedAgentRoute; - fileToken: string; - commentId: string; + commentTarget: string; }): string { return params.core.channel.routing.buildAgentSessionKey({ agentId: params.route.agentId, @@ -36,7 +37,7 @@ function buildCommentSessionKey(params: { accountId: params.route.accountId, peer: { kind: "direct", - id: `comment:${params.fileToken}:${params.commentId}`, + id: params.commentTarget, }, dmScope: "per-account-channel-peer", }); @@ -72,6 +73,11 @@ export async function handleFeishuCommentEvent( return; } + const commentTarget = buildFeishuCommentTarget({ + fileType: turn.fileType, + fileToken: turn.fileToken, + commentId: turn.commentId, + }); const dmPolicy = feishuCfg?.dmPolicy ?? "pairing"; const configAllowFrom = feishuCfg?.allowFrom ?? []; const pairing = createChannelPairingController({ @@ -89,10 +95,37 @@ export async function handleFeishuCommentEvent( senderId: turn.senderId, }).allowed; if (dmPolicy !== "open" && !senderAllowed) { - log( - `feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` + - `(dmPolicy=${dmPolicy}, comment=${turn.commentId})`, - ); + if (dmPolicy === "pairing") { + const client = createFeishuClient(account); + await pairing.issueChallenge({ + senderId: turn.senderId, + senderIdLine: `Your Feishu user id: ${turn.senderId}`, + meta: { name: turn.senderId }, + onCreated: ({ code }) => { + log( + `feishu[${account.accountId}]: comment pairing request sender=${turn.senderId} code=${code}`, + ); + }, + sendPairingReply: async (text) => { + await replyComment(client, { + file_token: turn.fileToken, + file_type: turn.fileType, + comment_id: turn.commentId, + content: text, + }); + }, + onReplyError: (err) => { + log( + `feishu[${account.accountId}]: comment pairing reply failed for ${turn.senderId}: ${String(err)}`, + ); + }, + }); + } else { + log( + `feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` + + `(dmPolicy=${dmPolicy}, comment=${turn.commentId})`, + ); + } return; } @@ -137,8 +170,7 @@ export async function handleFeishuCommentEvent( const commentSessionKey = buildCommentSessionKey({ core, route, - fileToken: turn.fileToken, - commentId: turn.commentId, + commentTarget, }); const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`; const ctxPayload = core.channel.reply.finalizeInboundContext({ @@ -146,8 +178,8 @@ export async function handleFeishuCommentEvent( BodyForAgent: bodyForAgent, RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, - From: `feishu-comment:${turn.senderId}`, - To: `comment:${turn.fileToken}:${turn.commentId}`, + From: `feishu:${turn.senderId}`, + To: commentTarget, SessionKey: commentSessionKey, AccountId: route.accountId, ChatType: "direct", @@ -162,7 +194,8 @@ export async function handleFeishuCommentEvent( Timestamp: parseTimestampMs(turn.timestamp), WasMentioned: turn.isMentioned, CommandAuthorized: false, - OriginatingTo: `comment:${turn.fileToken}:${turn.commentId}`, + OriginatingChannel: "feishu", + OriginatingTo: commentTarget, }); const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, { diff --git a/extensions/feishu/src/comment-target.ts b/extensions/feishu/src/comment-target.ts new file mode 100644 index 00000000000..d122deba0e8 --- /dev/null +++ b/extensions/feishu/src/comment-target.ts @@ -0,0 +1,44 @@ +export const FEISHU_COMMENT_FILE_TYPES = ["doc", "docx", "file", "sheet", "slides"] as const; + +export type CommentFileType = (typeof FEISHU_COMMENT_FILE_TYPES)[number]; + +export function normalizeCommentFileType(value: unknown): CommentFileType | undefined { + return typeof value === "string" && + (FEISHU_COMMENT_FILE_TYPES as readonly string[]).includes(value) + ? (value as CommentFileType) + : undefined; +} + +export type FeishuCommentTarget = { + fileType: CommentFileType; + fileToken: string; + commentId: string; +}; + +export function buildFeishuCommentTarget(params: FeishuCommentTarget): string { + return `comment:${params.fileType}:${params.fileToken}:${params.commentId}`; +} + +export function parseFeishuCommentTarget( + raw: string | undefined | null, +): FeishuCommentTarget | null { + const trimmed = raw?.trim(); + if (!trimmed?.startsWith("comment:")) { + return null; + } + const parts = trimmed.split(":"); + if (parts.length !== 4) { + return null; + } + const fileType = normalizeCommentFileType(parts[1]); + const fileToken = parts[2]?.trim(); + const commentId = parts[3]?.trim(); + if (!fileType || !fileToken || !commentId) { + return null; + } + return { + fileType, + fileToken, + commentId, + }; +} diff --git a/extensions/feishu/src/drive.test.ts b/extensions/feishu/src/drive.test.ts index 15937b96224..81375555d87 100644 --- a/extensions/feishu/src/drive.test.ts +++ b/extensions/feishu/src/drive.test.ts @@ -67,7 +67,8 @@ describe("registerFeishuDriveTools", () => { ); expect(registerTool).toHaveBeenCalledTimes(1); - const tool = registerTool.mock.calls[0]?.[0]; + const toolFactory = registerTool.mock.calls[0]?.[0]; + const tool = toolFactory?.({ agentAccountId: undefined }); expect(tool?.name).toBe("feishu_drive"); requestMock.mockResolvedValueOnce({ @@ -271,7 +272,8 @@ describe("registerFeishuDriveTools", () => { }), ); - const tool = registerTool.mock.calls[0]?.[0]; + const toolFactory = registerTool.mock.calls[0]?.[0]; + const tool = toolFactory?.({ agentAccountId: undefined }); const result = await tool.execute("call-5", { action: "add_comment", file_token: "doc_1", diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index 36884273bc4..8e747169374 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -1,6 +1,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; +import { type CommentFileType } from "./comment-target.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; import { @@ -30,8 +31,6 @@ type FeishuDriveInternalClient = Lark.Client & { }): Promise; }; -export type CommentFileType = "doc" | "docx" | "file" | "sheet" | "slides"; - type FeishuDriveApiResponse = { code: number; msg?: string; @@ -462,7 +461,7 @@ export async function replyComment( comment_id: string; content: string; }, -) { +): Promise<{ success: true; reply_id?: string } & Record> { const url = `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent( params.comment_id, @@ -553,46 +552,14 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token)); case "delete": return jsonToolResult(await deleteFile(client, p.file_token, p.type)); - case "list_comments": { - api.logger.info?.( - `feishu_drive: list_comments file=${p.file_type}:${p.file_token} page_token=${p.page_token ?? "none"}`, - ); - const result = await listComments(client, p); - api.logger.info?.( - `feishu_drive: list_comments resolved count=${result.comments.length} has_more=${result.has_more ? "yes" : "no"}`, - ); - return jsonToolResult(result); - } - case "list_comment_replies": { - api.logger.info?.( - `feishu_drive: list_comment_replies file=${p.file_type}:${p.file_token} comment=${p.comment_id} page_token=${p.page_token ?? "none"}`, - ); - const result = await listCommentReplies(client, p); - api.logger.info?.( - `feishu_drive: list_comment_replies resolved count=${result.replies.length} has_more=${result.has_more ? "yes" : "no"}`, - ); - return jsonToolResult(result); - } - case "add_comment": { - api.logger.info?.( - `feishu_drive: add_comment file=${p.file_type}:${p.file_token} block=${p.block_id ?? "whole"} chars=${p.content.length}`, - ); - const result = await addComment(client, p); - api.logger.info?.( - `feishu_drive: add_comment success comment=${String((result as { comment_id?: unknown }).comment_id ?? "unknown")}`, - ); - return jsonToolResult(result); - } - case "reply_comment": { - api.logger.info?.( - `feishu_drive: reply_comment file=${p.file_type}:${p.file_token} comment=${p.comment_id} chars=${p.content.length}`, - ); - const result = await replyComment(client, p); - api.logger.info?.( - `feishu_drive: reply_comment success reply=${String((result as { reply_id?: unknown }).reply_id ?? "unknown")}`, - ); - return jsonToolResult(result); - } + case "list_comments": + return jsonToolResult(await listComments(client, p)); + case "list_comment_replies": + return jsonToolResult(await listCommentReplies(client, p)); + case "add_comment": + return jsonToolResult(await addComment(client, p)); + case "reply_comment": + return jsonToolResult(await replyComment(client, p)); default: return unknownToolActionResult((p as { action?: unknown }).action); } diff --git a/extensions/feishu/src/monitor.comment.test.ts b/extensions/feishu/src/monitor.comment.test.ts index bbae951c0f2..09136e86916 100644 --- a/extensions/feishu/src/monitor.comment.test.ts +++ b/extensions/feishu/src/monitor.comment.test.ts @@ -7,11 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js"; import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js"; import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; -import type { FeishuMessageEvent } from "./bot.js"; import * as dedup from "./dedup.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { - resolveDriveCommentSyntheticEvent, + resolveDriveCommentEventTurn, type FeishuDriveCommentNoticeEvent, } from "./monitor.comment.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -235,16 +234,11 @@ async function setupCommentMonitorHandler(): Promise<(data: unknown) => Promise< return handler; } -function extractSyntheticText(event: FeishuMessageEvent): string { - const content = JSON.parse(event.message.content) as { text?: string }; - return content.text ?? ""; -} - -describe("resolveDriveCommentSyntheticEvent", () => { - it("builds a synthetic Feishu message for add_comment notices", async () => { +describe("resolveDriveCommentEventTurn", () => { + it("builds a real comment-turn prompt for add_comment notices", async () => { const client = makeOpenApiClient({ includeTargetReplyInBatch: true }); - const synthetic = await resolveDriveCommentSyntheticEvent({ + const turn = await resolveDriveCommentEventTurn({ cfg: buildMonitorConfig(), accountId: "default", event: makeDriveCommentEvent(), @@ -252,34 +246,20 @@ describe("resolveDriveCommentSyntheticEvent", () => { createClient: () => client as never, }); - expect(synthetic).not.toBeNull(); - expect(synthetic?.sender.sender_id.open_id).toBe("ou_509d4d7ace4a9addec2312676ffcba9b"); - expect(synthetic?.message.message_id).toBe("drive-comment:10d9d60b990db39f96a4c2fd357fb877"); - expect(synthetic?.message.chat_id).toBe("p2p:ou_509d4d7ace4a9addec2312676ffcba9b"); - expect(synthetic?.message.chat_type).toBe("p2p"); - expect(synthetic?.message.create_time).toBe("1774951528000"); - - const text = extractSyntheticText(synthetic as FeishuMessageEvent); - expect(text).toContain( - 'I added a comment in "Comment event handling request": Also send it to the agent after receiving the comment event', + 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(text).toContain("Also send it to the agent after receiving the comment event"); - expect(text).toContain("Quoted content: im.message.receive_v1 message trigger implementation"); - expect(text).toContain("This comment mentioned you."); - expect(text).toContain( - "This is a Feishu document comment event, not a normal instant-message conversation.", + expect(turn?.prompt).toContain( + "This is a Feishu document comment-thread event, not a Feishu IM conversation.", ); - expect(text).toContain("feishu_drive.reply_comment"); - expect(text).toContain( - "reply with the answer in that comment thread via feishu_drive.reply_comment", - ); - expect(text).toContain( - "after finishing also use feishu_drive.reply_comment in that comment thread to tell the user the update is complete", - ); - expect(text).toContain( - "keep it in the same language as the user's original comment or reply unless they explicitly ask for another language", - ); - expect(text).toContain("output only NO_REPLY at the end"); + 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("falls back to the replies API to resolve add_reply text", async () => { @@ -288,7 +268,7 @@ describe("resolveDriveCommentSyntheticEvent", () => { targetReplyText: "Please follow up on this comment", }); - const synthetic = await resolveDriveCommentSyntheticEvent({ + const turn = await resolveDriveCommentEventTurn({ cfg: buildMonitorConfig(), accountId: "default", event: makeDriveCommentEvent({ @@ -302,22 +282,18 @@ describe("resolveDriveCommentSyntheticEvent", () => { createClient: () => client as never, }); - const text = extractSyntheticText(synthetic as FeishuMessageEvent); - expect(text).toContain( - 'I added a reply in "Comment event handling request": Please follow up on this comment', + expect(turn?.prompt).toContain( + 'The user added a reply in "Comment event handling request": Please follow up on this comment', ); - expect(text).toContain( + expect(turn?.prompt).toContain( "Original comment: Also send it to the agent after receiving the comment event", ); - expect(text).toContain(`file_token: ${TEST_DOC_TOKEN}`); - expect(text).toContain("Event type: add_reply"); - expect(text).toContain( - "keep it in the same language as the user's original comment or reply unless they explicitly ask for another language", - ); + expect(turn?.prompt).toContain(`file_token: ${TEST_DOC_TOKEN}`); + expect(turn?.prompt).toContain("Event type: add_reply"); }); it("ignores self-authored comment notices", async () => { - const synthetic = await resolveDriveCommentSyntheticEvent({ + const turn = await resolveDriveCommentEventTurn({ cfg: buildMonitorConfig(), accountId: "default", event: makeDriveCommentEvent({ @@ -330,7 +306,7 @@ describe("resolveDriveCommentSyntheticEvent", () => { createClient: () => makeOpenApiClient({}) as never, }); - expect(synthetic).toBeNull(); + expect(turn).toBeNull(); }); }); diff --git a/extensions/feishu/src/monitor.comment.ts b/extensions/feishu/src/monitor.comment.ts index 99a94415c00..764e3b18258 100644 --- a/extensions/feishu/src/monitor.comment.ts +++ b/extensions/feishu/src/monitor.comment.ts @@ -1,8 +1,8 @@ import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { raceWithTimeoutAndAbort } from "./async.js"; -import type { FeishuMessageEvent } from "./bot.js"; import { createFeishuClient } from "./client.js"; +import { normalizeCommentFileType, type CommentFileType } from "./comment-target.js"; import type { ResolvedFeishuAccount } from "./types.js"; const FEISHU_COMMENT_VERIFY_TIMEOUT_MS = 3_000; @@ -31,7 +31,7 @@ export type FeishuDriveCommentNoticeEvent = { type?: string; }; -type ResolveDriveCommentSyntheticEventParams = { +type ResolveDriveCommentEventParams = { cfg: ClawdbotConfig; accountId: string; event: FeishuDriveCommentNoticeEvent; @@ -48,7 +48,7 @@ export type ResolvedDriveCommentEventTurn = { replyId?: string; noticeType: "add_comment" | "add_reply"; fileToken: string; - fileType: "doc" | "docx" | "file" | "sheet" | "slides"; + fileType: CommentFileType; senderId: string; timestamp?: string; isMentioned?: boolean; @@ -93,7 +93,6 @@ type FeishuDriveCommentReply = { type FeishuDriveCommentCard = { comment_id?: string; - has_more?: boolean; quote?: string; reply_list?: { replies?: FeishuDriveCommentReply[]; @@ -122,18 +121,6 @@ function readBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } -function normalizeDriveCommentFileType( - value: unknown, -): "doc" | "docx" | "file" | "sheet" | "slides" | undefined { - return value === "doc" || - value === "docx" || - value === "file" || - value === "sheet" || - value === "slides" - ? value - : undefined; -} - function encodeQuery(params: Record): string { const query = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { @@ -148,8 +135,7 @@ function encodeQuery(params: Record): string { function buildDriveCommentTargetUrl(params: { fileToken: string; - commentId: string; - fileType: string; + fileType: CommentFileType; }): string { return ( `/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments/batch_query` + @@ -163,7 +149,7 @@ function buildDriveCommentTargetUrl(params: { function buildDriveCommentRepliesUrl(params: { fileToken: string; commentId: string; - fileType: string; + fileType: CommentFileType; pageToken?: string; }): string { return ( @@ -251,34 +237,18 @@ function extractReplyText(reply: FeishuDriveCommentReply | undefined): string | return undefined; } const elements = Array.isArray(reply.content.elements) ? reply.content.elements : []; - const parts = elements + const text = elements .map(extractCommentElementText) - .filter((part): part is string => Boolean(part && part.trim())); - const text = parts.join("").trim(); + .filter((part): part is string => Boolean(part && part.trim())) + .join("") + .trim(); return text || undefined; } -function safeJsonStringify(value: unknown): string { - try { - return JSON.stringify(value); - } catch (error) { - return JSON.stringify({ - error: `stringify_failed:${error instanceof Error ? error.message : String(error)}`, - }); - } -} - -function summarizeReplyForLog(reply: FeishuDriveCommentReply | undefined) { - return { - reply_id: reply?.reply_id, - text: extractReplyText(reply), - }; -} - async function fetchDriveCommentReplies(params: { client: FeishuRequestClient; fileToken: string; - fileType: "doc" | "docx" | "file" | "sheet" | "slides"; + fileType: CommentFileType; commentId: string; timeoutMs: number; logger?: (message: string) => void; @@ -308,10 +278,6 @@ async function fetchDriveCommentReplies(params: { } break; } - params.logger?.( - `feishu[${params.accountId}]: fetched comment replies page=${page + 1} ` + - `comment=${params.commentId} raw=${safeJsonStringify(response?.data?.items ?? [])}`, - ); replies.push(...(response.data?.items ?? [])); if (response.data?.has_more !== true || !response.data.page_token?.trim()) { break; @@ -324,7 +290,7 @@ async function fetchDriveCommentReplies(params: { async function fetchDriveCommentContext(params: { client: FeishuRequestClient; fileToken: string; - fileType: "doc" | "docx" | "file" | "sheet" | "slides"; + fileType: CommentFileType; commentId: string; replyId?: string; timeoutMs: number; @@ -355,7 +321,6 @@ async function fetchDriveCommentContext(params: { method: "POST", url: buildDriveCommentTargetUrl({ fileToken: params.fileToken, - commentId: params.commentId, fileType: params.fileType, }), data: { @@ -374,10 +339,6 @@ async function fetchDriveCommentContext(params: { ) ?? commentResponse.data?.items?.[0]) : undefined; const embeddedReplies = commentCard?.reply_list?.replies ?? []; - params.logger?.( - `feishu[${params.accountId}]: embedded comment replies comment=${params.commentId} ` + - `raw=${safeJsonStringify(embeddedReplies)}`, - ); const embeddedTargetReply = params.replyId ? embeddedReplies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim()) : embeddedReplies.at(-1); @@ -396,24 +357,7 @@ async function fetchDriveCommentContext(params: { : undefined; const targetReply = params.replyId ? (embeddedTargetReply ?? fetchedMatchedReply ?? undefined) - : ((replies.at(-1) ?? embeddedTargetReply ?? rootReply) as FeishuDriveCommentReply | undefined); - const matchSource = params.replyId - ? embeddedTargetReply - ? "embedded" - : fetchedMatchedReply - ? "fetched" - : "miss" - : targetReply === rootReply - ? "fallback_root" - : targetReply === embeddedTargetReply - ? "embedded_latest" - : "fetched_latest"; - params.logger?.( - `feishu[${params.accountId}]: comment reply resolution comment=${params.commentId} ` + - `requested_reply=${params.replyId ?? "none"} match_source=${matchSource} ` + - `root=${safeJsonStringify(summarizeReplyForLog(rootReply))} ` + - `target=${safeJsonStringify(summarizeReplyForLog(targetReply))}`, - ); + : (replies.at(-1) ?? embeddedTargetReply ?? rootReply); const meta = metaResponse?.code === 0 ? metaResponse.data?.metas?.[0] : undefined; return { @@ -425,60 +369,9 @@ async function fetchDriveCommentContext(params: { }; } -function buildDriveCommentPrompt(params: { - noticeType: "add_comment" | "add_reply"; - fileType: "doc" | "docx" | "file" | "sheet" | "slides"; - fileToken: string; - isMentioned?: boolean; - documentTitle?: string; - documentUrl?: string; - quoteText?: string; - rootCommentText?: string; - targetReplyText?: string; -}): string { - const documentLabel = params.documentTitle - ? `"${params.documentTitle}"` - : `${params.fileType} document ${params.fileToken}`; - const actionLabel = params.noticeType === "add_reply" ? "reply" : "comment"; - const firstLine = params.targetReplyText - ? `I added a ${actionLabel} in ${documentLabel}: ${params.targetReplyText}` - : `I added a ${actionLabel} in ${documentLabel}.`; - const lines = [firstLine]; - if ( - params.noticeType === "add_reply" && - params.rootCommentText && - params.rootCommentText !== params.targetReplyText - ) { - lines.push(`Original comment: ${params.rootCommentText}`); - } - if (params.quoteText) { - lines.push(`Quoted content: ${params.quoteText}`); - } - if (params.isMentioned === true) { - lines.push("This comment mentioned you."); - } - if (params.documentUrl) { - lines.push(`Document link: ${params.documentUrl}`); - } - lines.push( - `Event type: ${params.noticeType}`, - `file_token: ${params.fileToken}`, - `file_type: ${params.fileType}`, - "This is a Feishu document comment event, not a normal instant-message conversation. Do not reply directly in the current Feishu chat, and do not treat this event as something that requires sending an IM text reply.", - "If you need to inspect or handle the comment thread, prefer the feishu_drive tools: use list_comments / list_comment_replies to inspect comments, add_comment to add a new comment, and reply_comment to reply in the thread.", - "If the user asks a question in the comment, reply with the answer in that comment thread via feishu_drive.reply_comment.", - "If you modify the document, after finishing also use feishu_drive.reply_comment in that comment thread to tell the user the update is complete.", - "If you decide to add a new comment in the document, use feishu_drive.add_comment; if you decide to reply in the current comment thread, use feishu_drive.reply_comment.", - "When you produce a user-visible reply, keep it in the same language as the user's original comment or reply unless they explicitly ask for another language.", - "After comment-related tool calls have completed the user-visible action, output only NO_REPLY at the end to avoid sending an extra instant message; do not output the final answer directly as a normal chat reply.", - ); - lines.push(`Decide what to do next based on this document ${actionLabel} event.`); - return lines.join("\n"); -} - function buildDriveCommentSurfacePrompt(params: { noticeType: "add_comment" | "add_reply"; - fileType: "doc" | "docx" | "file" | "sheet" | "slides"; + fileType: CommentFileType; fileToken: string; commentId: string; replyId?: string; @@ -542,15 +435,13 @@ function buildDriveCommentSurfacePrompt(params: { return lines.join("\n"); } -async function resolveDriveCommentEventCore( - params: ResolveDriveCommentSyntheticEventParams, -): Promise<{ +async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventParams): Promise<{ eventId: string; commentId: string; replyId?: string; noticeType: "add_comment" | "add_reply"; fileToken: string; - fileType: "doc" | "docx" | "file" | "sheet" | "slides"; + fileType: CommentFileType; senderId: string; timestamp?: string; isMentioned?: boolean; @@ -576,36 +467,27 @@ async function resolveDriveCommentEventCore( const replyId = event.reply_id?.trim(); const noticeType = event.notice_meta?.notice_type?.trim(); const fileToken = event.notice_meta?.file_token?.trim(); - const fileType = normalizeDriveCommentFileType(event.notice_meta?.file_type); + const fileType = normalizeCommentFileType(event.notice_meta?.file_type); const senderId = event.notice_meta?.from_user_id?.open_id?.trim(); if (!eventId || !commentId || !noticeType || !fileToken || !fileType || !senderId) { logger?.( - `feishu[${accountId}]: drive comment notice missing required fields ` + - `event=${eventId ?? "unknown"} comment=${commentId ?? "unknown"}`, + `feishu[${accountId}]: drive comment notice missing required fields event=${eventId ?? "unknown"} comment=${commentId ?? "unknown"}`, ); return null; } if (noticeType !== "add_comment" && noticeType !== "add_reply") { - logger?.( - `feishu[${accountId}]: unsupported drive comment notice type ${noticeType} ` + - `for event ${eventId}`, - ); + logger?.(`feishu[${accountId}]: unsupported drive comment notice type ${noticeType}`); return null; } if (senderId === botOpenId) { logger?.( - `feishu[${accountId}]: ignoring self-authored drive comment notice ` + - `event=${eventId} sender=${senderId}`, + `feishu[${accountId}]: ignoring self-authored drive comment notice event=${eventId} sender=${senderId}`, ); return null; } const account = resolveFeishuAccount({ cfg, accountId }); const client = createClient(account); - logger?.( - `feishu[${accountId}]: fetching drive comment context ` + - `event=${eventId} type=${noticeType} file=${fileType}:${fileToken} comment=${commentId} reply=${replyId ?? "none"}`, - ); const context = await fetchDriveCommentContext({ client, fileToken, @@ -616,12 +498,6 @@ async function resolveDriveCommentEventCore( logger, accountId, }); - logger?.( - `feishu[${accountId}]: drive comment context resolved ` + - `event=${eventId} title=${context.documentTitle ?? "unknown"} ` + - `quote=${context.quoteText ? "yes" : "no"} root=${context.rootCommentText ? "yes" : "no"} ` + - `target=${context.targetReplyText ? "yes" : "no"}`, - ); return { eventId, commentId, @@ -675,7 +551,7 @@ export function parseFeishuDriveCommentNoticeEventPayload( } export async function resolveDriveCommentEventTurn( - params: ResolveDriveCommentSyntheticEventParams, + params: ResolveDriveCommentEventParams, ): Promise { const resolved = await resolveDriveCommentEventCore(params); if (!resolved) { @@ -695,10 +571,6 @@ export async function resolveDriveCommentEventTurn( targetReplyText: resolved.context.targetReplyText, }); const preview = prompt.replace(/\s+/g, " ").slice(0, 160); - params.logger?.( - `feishu[${params.accountId}]: built drive comment prompt ` + - `event=${resolved.eventId} preview=${preview}`, - ); return { eventId: resolved.eventId, messageId: `drive-comment:${resolved.eventId}`, @@ -719,43 +591,3 @@ export async function resolveDriveCommentEventTurn( preview, }; } - -export async function resolveDriveCommentSyntheticEvent( - params: ResolveDriveCommentSyntheticEventParams, -): Promise { - const resolved = await resolveDriveCommentEventCore(params); - if (!resolved) { - return null; - } - const prompt = buildDriveCommentPrompt({ - noticeType: resolved.noticeType, - fileType: resolved.fileType, - fileToken: resolved.fileToken, - isMentioned: resolved.isMentioned, - documentTitle: resolved.context.documentTitle, - documentUrl: resolved.context.documentUrl, - quoteText: resolved.context.quoteText, - rootCommentText: resolved.context.rootCommentText, - targetReplyText: resolved.context.targetReplyText, - }); - const preview = prompt.replace(/\s+/g, " ").slice(0, 160); - params.logger?.( - `feishu[${params.accountId}]: built drive comment synthetic prompt ` + - `event=${resolved.eventId} preview=${preview}`, - ); - - return { - sender: { - sender_id: { open_id: resolved.senderId }, - sender_type: "user", - }, - message: { - message_id: `drive-comment:${resolved.eventId}`, - chat_id: `p2p:${resolved.senderId}`, - chat_type: "p2p", - message_type: "text", - content: JSON.stringify({ text: prompt }), - create_time: resolved.timestamp, - }, - }; -} diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 53bdadc87f3..5a3bb01ddb7 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -8,6 +8,7 @@ const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn()); +const replyCommentMock = vi.hoisted(() => vi.fn()); vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock, @@ -29,6 +30,14 @@ vi.mock("./runtime.js", () => ({ }), })); +vi.mock("./client.js", () => ({ + createFeishuClient: vi.fn(() => ({ request: vi.fn() })), +})); + +vi.mock("./drive.js", () => ({ + replyComment: replyCommentMock, +})); + import { feishuOutbound } from "./outbound.js"; const sendText = feishuOutbound.sendText!; const emptyConfig: ClawdbotConfig = {}; @@ -46,6 +55,7 @@ function resetOutboundMocks() { sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + replyCommentMock.mockResolvedValue({ reply_id: "reply_msg" }); } describe("feishuOutbound.sendText local-image auto-convert", () => { @@ -199,6 +209,52 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { }); }); +describe("feishuOutbound comment-thread routing", () => { + beforeEach(() => { + resetOutboundMocks(); + }); + + it("routes comment-thread text through replyComment", async () => { + const result = await sendText({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "handled in thread", + accountId: "main", + }); + + expect(replyCommentMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + file_token: "doxcn123", + file_type: "docx", + comment_id: "7623358762119646411", + content: "handled in thread", + }), + ); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" })); + }); + + it("falls back to a text-only comment reply for media payloads", async () => { + const result = await feishuOutbound.sendMedia?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "see attachment", + mediaUrl: "https://example.com/file.png", + accountId: "main", + }); + + expect(replyCommentMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + content: "see attachment\n\nhttps://example.com/file.png", + }), + ); + expect(sendMediaFeishuMock).not.toHaveBeenCalled(); + expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" })); + }); +}); + describe("feishuOutbound.sendText replyToId forwarding", () => { beforeEach(() => { resetOutboundMocks(); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 1c6f21e656a..97b38002f47 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -3,6 +3,9 @@ import path from "path"; import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import { chunkTextForOutbound, type ChannelOutboundAdapter } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { parseFeishuCommentTarget } from "./comment-target.js"; +import { replyComment } from "./drive.js"; import { sendMediaFeishu } from "./media.js"; import { getFeishuRuntime } from "./runtime.js"; import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js"; @@ -59,6 +62,31 @@ function resolveReplyToMessageId(params: { return trimmed || undefined; } +async function sendCommentThreadReply(params: { + cfg: Parameters[0]["cfg"]; + to: string; + text: string; + accountId?: string; +}) { + const target = parseFeishuCommentTarget(params.to); + if (!target) { + return null; + } + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const client = createFeishuClient(account); + const result = await replyComment(client, { + file_token: target.fileToken, + file_type: target.fileType, + comment_id: target.commentId, + content: params.text, + }); + return { + messageId: typeof result.reply_id === "string" ? result.reply_id : "", + chatId: target.commentId, + result, + }; +} + async function sendOutboundText(params: { cfg: Parameters[0]["cfg"]; to: string; @@ -67,6 +95,16 @@ async function sendOutboundText(params: { accountId?: string; }) { const { cfg, to, text, accountId, replyToMessageId } = params; + const commentResult = await sendCommentThreadReply({ + cfg, + to, + text, + accountId, + }); + if (commentResult) { + return commentResult; + } + const account = resolveFeishuAccount({ cfg, accountId }); const renderMode = account.config?.renderMode ?? "auto"; @@ -156,6 +194,18 @@ export const feishuOutbound: ChannelOutboundAdapter = { threadId, }) => { const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + const commentTarget = parseFeishuCommentTarget(to); + if (commentTarget) { + const commentText = [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n"); + return await sendOutboundText({ + cfg, + to, + text: commentText || mediaUrl || text || "", + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + // Send text first if provided if (text?.trim()) { await sendOutboundText({