diff --git a/CHANGELOG.md b/CHANGELOG.md index 126ad49ba9f..1d28dc24fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg - Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths. - WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr. +- Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) thanks @wittam-01. ### Fixes diff --git a/extensions/feishu/src/comment-dispatcher.ts b/extensions/feishu/src/comment-dispatcher.ts new file mode 100644 index 00000000000..a06fafcd49f --- /dev/null +++ b/extensions/feishu/src/comment-dispatcher.ts @@ -0,0 +1,82 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import { + createReplyPrefixContext, + type ClawdbotConfig, + type ReplyPayload, + type RuntimeEnv, +} from "../runtime-api.js"; +import { resolveFeishuRuntimeAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import type { CommentFileType } from "./comment-target.js"; +import { replyComment } from "./drive.js"; +import { getFeishuRuntime } from "./runtime.js"; + +export type CreateFeishuCommentReplyDispatcherParams = { + cfg: ClawdbotConfig; + agentId: string; + runtime: RuntimeEnv; + accountId?: string; + fileToken: string; + fileType: CommentFileType; + commentId: string; +}; + +export function createFeishuCommentReplyDispatcher( + params: CreateFeishuCommentReplyDispatcherParams, +) { + const core = getFeishuRuntime(); + const prefixContext = createReplyPrefixContext({ + cfg: params.cfg, + agentId: params.agentId, + channel: "feishu", + accountId: params.accountId, + }); + const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId }); + const client = createFeishuClient(account); + const textChunkLimit = core.channel.text.resolveTextChunkLimit( + params.cfg, + "feishu", + params.accountId, + { + fallbackLimit: 4000, + }, + ); + const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "feishu"); + + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + deliver: async (payload: ReplyPayload, info) => { + if (info.kind !== "final") { + return; + } + const reply = resolveSendableOutboundReplyParts(payload); + if (!reply.hasText) { + if (reply.hasMedia) { + params.runtime.log?.( + `feishu[${params.accountId ?? "default"}]: comment reply ignored media-only payload for comment=${params.commentId}`, + ); + } + return; + } + const chunks = core.channel.text.chunkTextWithMode(reply.text, textChunkLimit, chunkMode); + for (const chunk of chunks) { + await replyComment(client, { + file_token: params.fileToken, + file_type: params.fileType, + comment_id: params.commentId, + content: chunk, + }); + } + }, + onError: (err, info) => { + params.runtime.error?.( + `feishu[${params.accountId ?? "default"}]: comment dispatcher failed kind=${info.kind} comment=${params.commentId}: ${String(err)}`, + ); + }, + }); + + return { dispatcher, replyOptions, markDispatchIdle }; +} diff --git a/extensions/feishu/src/comment-handler.test.ts b/extensions/feishu/src/comment-handler.test.ts new file mode 100644 index 00000000000..946430db31b --- /dev/null +++ b/extensions/feishu/src/comment-handler.test.ts @@ -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 { + 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(); + }); +}); diff --git a/extensions/feishu/src/comment-handler.ts b/extensions/feishu/src/comment-handler.ts new file mode 100644 index 00000000000..f566c7875c5 --- /dev/null +++ b/extensions/feishu/src/comment-handler.ts @@ -0,0 +1,247 @@ +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { + createChannelPairingController, + type ClawdbotConfig, + 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 { + resolveDriveCommentEventTurn, + type FeishuDriveCommentNoticeEvent, +} from "./monitor.comment.js"; +import { resolveFeishuAllowlistMatch } from "./policy.js"; +import { getFeishuRuntime } from "./runtime.js"; +import type { DynamicAgentCreationConfig } from "./types.js"; + +type HandleFeishuCommentEventParams = { + cfg: ClawdbotConfig; + accountId: string; + runtime?: RuntimeEnv; + event: FeishuDriveCommentNoticeEvent; + botOpenId?: string; +}; + +function buildCommentSessionKey(params: { + core: ReturnType; + route: ResolvedAgentRoute; + commentTarget: string; +}): string { + return params.core.channel.routing.buildAgentSessionKey({ + agentId: params.route.agentId, + channel: "feishu", + accountId: params.route.accountId, + peer: { + kind: "direct", + id: params.commentTarget, + }, + dmScope: "per-account-channel-peer", + }); +} + +function parseTimestampMs(value: string | undefined): number { + const parsed = value ? Number.parseInt(value, 10) : Number.NaN; + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +export async function handleFeishuCommentEvent( + params: HandleFeishuCommentEventParams, +): Promise { + const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId }); + const feishuCfg = account.config; + const core = getFeishuRuntime(); + const log = params.runtime?.log ?? console.log; + const error = params.runtime?.error ?? console.error; + const runtime = (params.runtime ?? { log, error }) as RuntimeEnv; + + const turn = await resolveDriveCommentEventTurn({ + cfg: params.cfg, + accountId: account.accountId, + event: params.event, + botOpenId: params.botOpenId, + logger: log, + }); + if (!turn) { + log( + `feishu[${account.accountId}]: drive comment notice skipped ` + + `event=${params.event.event_id ?? "unknown"} comment=${params.event.comment_id ?? "unknown"}`, + ); + 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({ + core, + channel: "feishu", + accountId: account.accountId, + }); + const storeAllowFrom = + dmPolicy !== "allowlist" && dmPolicy !== "open" + ? await pairing.readAllowFromStore().catch(() => []) + : []; + const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom]; + const senderAllowed = resolveFeishuAllowlistMatch({ + allowFrom: effectiveDmAllowFrom, + senderId: turn.senderId, + senderIds: [turn.senderUserId], + }).allowed; + if (dmPolicy !== "open" && !senderAllowed) { + 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; + } + + let effectiveCfg = params.cfg; + let route = core.channel.routing.resolveAgentRoute({ + cfg: params.cfg, + channel: "feishu", + accountId: account.accountId, + peer: { + kind: "direct", + id: turn.senderId, + }, + }); + if (route.matchedBy === "default") { + const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined; + if (dynamicCfg?.enabled) { + const dynamicResult = await maybeCreateDynamicAgent({ + cfg: params.cfg, + runtime: core, + senderOpenId: turn.senderId, + dynamicCfg, + log: (message) => log(message), + }); + if (dynamicResult.created) { + effectiveCfg = dynamicResult.updatedCfg; + route = core.channel.routing.resolveAgentRoute({ + cfg: dynamicResult.updatedCfg, + channel: "feishu", + accountId: account.accountId, + peer: { + kind: "direct", + id: turn.senderId, + }, + }); + log( + `feishu[${account.accountId}]: dynamic agent created for comment flow, route=${route.sessionKey}`, + ); + } + } + } + + const commentSessionKey = buildCommentSessionKey({ + core, + route, + commentTarget, + }); + const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: bodyForAgent, + BodyForAgent: bodyForAgent, + RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, + CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt, + From: `feishu:${turn.senderId}`, + To: commentTarget, + SessionKey: commentSessionKey, + AccountId: route.accountId, + ChatType: "direct", + ConversationLabel: turn.documentTitle + ? `Feishu comment ยท ${turn.documentTitle}` + : "Feishu comment", + SenderName: turn.senderId, + SenderId: turn.senderId, + Provider: "feishu", + Surface: "feishu-comment", + MessageSid: turn.messageId, + Timestamp: parseTimestampMs(turn.timestamp), + WasMentioned: turn.isMentioned, + CommandAuthorized: false, + OriginatingChannel: "feishu", + OriginatingTo: commentTarget, + }); + + const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, { + agentId: route.agentId, + }); + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: commentSessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + error( + `feishu[${account.accountId}]: failed to record comment inbound session ${commentSessionKey}: ${String(err)}`, + ); + }, + }); + + const { dispatcher, replyOptions, markDispatchIdle } = createFeishuCommentReplyDispatcher({ + cfg: effectiveCfg, + agentId: route.agentId, + runtime, + accountId: account.accountId, + fileToken: turn.fileToken, + fileType: turn.fileType, + commentId: turn.commentId, + }); + + log( + `feishu[${account.accountId}]: dispatching drive comment to agent ` + + `(session=${commentSessionKey} comment=${turn.commentId} type=${turn.noticeType})`, + ); + const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({ + dispatcher, + onSettled: () => { + markDispatchIdle(); + }, + run: () => + core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg: effectiveCfg, + dispatcher, + replyOptions, + }), + }); + log( + `feishu[${account.accountId}]: drive comment dispatch complete ` + + `(queuedFinal=${queuedFinal}, replies=${counts.final}, session=${commentSessionKey})`, + ); +} 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-schema.ts b/extensions/feishu/src/drive-schema.ts index 4642aad8206..0dc85dc45d1 100644 --- a/extensions/feishu/src/drive-schema.ts +++ b/extensions/feishu/src/drive-schema.ts @@ -11,6 +11,14 @@ const FileType = Type.Union([ Type.Literal("shortcut"), ]); +const CommentFileType = Type.Union([ + Type.Literal("doc"), + Type.Literal("docx"), + Type.Literal("sheet"), + Type.Literal("file"), + Type.Literal("slides"), +]); + export const FeishuDriveSchema = Type.Union([ Type.Object({ action: Type.Literal("list"), @@ -41,6 +49,40 @@ export const FeishuDriveSchema = Type.Union([ file_token: Type.String({ description: "File token to delete" }), type: FileType, }), + Type.Object({ + action: Type.Literal("list_comments"), + file_token: Type.String({ description: "Document token" }), + file_type: CommentFileType, + page_size: Type.Optional(Type.Integer({ minimum: 1, description: "Page size" })), + page_token: Type.Optional(Type.String({ description: "Comment page token" })), + }), + Type.Object({ + action: Type.Literal("list_comment_replies"), + file_token: Type.String({ description: "Document token" }), + file_type: CommentFileType, + comment_id: Type.String({ description: "Comment id" }), + page_size: Type.Optional(Type.Integer({ minimum: 1, description: "Page size" })), + page_token: Type.Optional(Type.String({ description: "Reply page token" })), + }), + Type.Object({ + action: Type.Literal("add_comment"), + file_token: Type.String({ description: "Document token" }), + file_type: Type.Union([Type.Literal("doc"), Type.Literal("docx")]), + content: Type.String({ description: "Comment text content" }), + block_id: Type.Optional( + Type.String({ + description: + "Optional docx block id for a local comment. Omit to create a full-document comment.", + }), + ), + }), + Type.Object({ + action: Type.Literal("reply_comment"), + file_token: Type.String({ description: "Document token" }), + file_type: CommentFileType, + comment_id: Type.String({ description: "Comment id" }), + content: Type.String({ description: "Reply text content" }), + }), ]); export type FeishuDriveParams = Static; diff --git a/extensions/feishu/src/drive.test.ts b/extensions/feishu/src/drive.test.ts new file mode 100644 index 00000000000..81375555d87 --- /dev/null +++ b/extensions/feishu/src/drive.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js"; +import type { OpenClawPluginApi } from "../runtime-api.js"; + +const createFeishuToolClientMock = vi.hoisted(() => vi.fn()); +const resolveAnyEnabledFeishuToolsConfigMock = vi.hoisted(() => vi.fn()); + +vi.mock("./tool-account.js", () => ({ + createFeishuToolClient: createFeishuToolClientMock, + resolveAnyEnabledFeishuToolsConfig: resolveAnyEnabledFeishuToolsConfigMock, +})); + +let registerFeishuDriveTools: typeof import("./drive.js").registerFeishuDriveTools; + +function createDriveToolApi(params: { + config: OpenClawPluginApi["config"]; + registerTool: OpenClawPluginApi["registerTool"]; +}): OpenClawPluginApi { + return createTestPluginApi({ + id: "feishu-test", + name: "Feishu Test", + source: "local", + config: params.config, + runtime: createPluginRuntimeMock(), + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerTool: params.registerTool, + }); +} + +describe("registerFeishuDriveTools", () => { + const requestMock = vi.fn(); + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + ({ registerFeishuDriveTools } = await import("./drive.js")); + resolveAnyEnabledFeishuToolsConfigMock.mockReturnValue({ + doc: false, + chat: false, + wiki: false, + drive: true, + perm: false, + scopes: false, + }); + createFeishuToolClientMock.mockReturnValue({ + request: requestMock, + }); + }); + + it("registers feishu_drive and handles comment actions", async () => { + const registerTool = vi.fn(); + registerFeishuDriveTools( + createDriveToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { drive: true }, + }, + }, + }, + registerTool, + }), + ); + + expect(registerTool).toHaveBeenCalledTimes(1); + const toolFactory = registerTool.mock.calls[0]?.[0]; + const tool = toolFactory?.({ agentAccountId: undefined }); + expect(tool?.name).toBe("feishu_drive"); + + requestMock.mockResolvedValueOnce({ + code: 0, + data: { + has_more: false, + page_token: "0", + items: [ + { + comment_id: "c1", + quote: "quoted text", + reply_list: { + replies: [ + { + reply_id: "r1", + user_id: "ou_author", + content: { + elements: [ + { + type: "text_run", + text_run: { text: "root comment" }, + }, + ], + }, + }, + { + reply_id: "r2", + user_id: "ou_reply", + content: { + elements: [ + { + type: "text_run", + text_run: { text: "reply text" }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + const listResult = await tool.execute("call-1", { + action: "list_comments", + file_token: "doc_1", + file_type: "docx", + }); + expect(requestMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: "GET", + url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&user_id_type=open_id", + }), + ); + expect(listResult.details).toEqual( + expect.objectContaining({ + comments: [ + expect.objectContaining({ + comment_id: "c1", + text: "root comment", + quote: "quoted text", + replies: [expect.objectContaining({ reply_id: "r2", text: "reply text" })], + }), + ], + }), + ); + + requestMock.mockResolvedValueOnce({ + code: 0, + data: { + has_more: false, + page_token: "0", + items: [ + { + reply_id: "r3", + user_id: "ou_reply_2", + content: { + elements: [ + { + type: "text_run", + text_run: { content: "reply from api" }, + }, + ], + }, + }, + ], + }, + }); + const repliesResult = await tool.execute("call-2", { + action: "list_comment_replies", + file_token: "doc_1", + file_type: "docx", + comment_id: "c1", + }); + expect(requestMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "GET", + url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&user_id_type=open_id", + }), + ); + expect(repliesResult.details).toEqual( + expect.objectContaining({ + replies: [expect.objectContaining({ reply_id: "r3", text: "reply from api" })], + }), + ); + + requestMock.mockResolvedValueOnce({ + code: 0, + data: { comment_id: "c2" }, + }); + const addCommentResult = await tool.execute("call-3", { + action: "add_comment", + file_token: "doc_1", + file_type: "docx", + block_id: "blk_1", + content: "please update this section", + }); + expect(requestMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + method: "POST", + url: "/open-apis/drive/v1/files/doc_1/new_comments", + data: { + file_type: "docx", + reply_elements: [{ type: "text", text: "please update this section" }], + anchor: { block_id: "blk_1" }, + }, + }), + ); + expect(addCommentResult.details).toEqual( + expect.objectContaining({ success: true, comment_id: "c2" }), + ); + + requestMock + .mockResolvedValueOnce({ + code: 99991663, + msg: "invalid request body", + }) + .mockResolvedValueOnce({ + code: 0, + data: { reply_id: "r4" }, + }); + const replyCommentResult = await tool.execute("call-4", { + action: "reply_comment", + file_token: "doc_1", + file_type: "docx", + comment_id: "c1", + content: "handled", + }); + expect(requestMock).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + method: "POST", + url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx", + data: { + content: { + elements: [ + { + type: "text_run", + text_run: { + text: "handled", + }, + }, + ], + }, + }, + }), + ); + expect(requestMock).toHaveBeenNthCalledWith( + 5, + expect.objectContaining({ + method: "POST", + url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx", + data: { + reply_elements: [{ type: "text", text: "handled" }], + }, + }), + ); + expect(replyCommentResult.details).toEqual( + expect.objectContaining({ success: true, reply_id: "r4" }), + ); + }); + + it("rejects block-scoped comments for non-docx files", async () => { + const registerTool = vi.fn(); + registerFeishuDriveTools( + createDriveToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { drive: true }, + }, + }, + }, + registerTool, + }), + ); + + 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", + file_type: "doc", + block_id: "blk_1", + content: "invalid", + }); + expect(result.details).toEqual( + expect.objectContaining({ + error: "block_id is only supported for docx comments", + }), + ); + }); +}); diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index 43fe7ee272d..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 { @@ -22,12 +23,188 @@ type FeishuExplorerRootFolderMetaResponse = { type FeishuDriveInternalClient = Lark.Client & { domain?: string; httpInstance: Pick; + request(params: { + method: "GET" | "POST"; + url: string; + data: unknown; + timeout?: number; + }): Promise; }; +type FeishuDriveApiResponse = { + code: number; + msg?: string; + data?: T; +}; + +type FeishuDriveCommentReply = { + reply_id?: string; + user_id?: string; + create_time?: number; + update_time?: number; + content?: { + elements?: unknown[]; + }; +}; + +type FeishuDriveCommentCard = { + comment_id?: string; + user_id?: string; + create_time?: number; + update_time?: number; + is_solved?: boolean; + is_whole?: boolean; + has_more?: boolean; + page_token?: string; + quote?: string; + reply_list?: { + replies?: FeishuDriveCommentReply[]; + }; +}; + +type FeishuDriveListCommentsResponse = FeishuDriveApiResponse<{ + has_more?: boolean; + items?: FeishuDriveCommentCard[]; + page_token?: string; +}>; + +type FeishuDriveListRepliesResponse = FeishuDriveApiResponse<{ + has_more?: boolean; + items?: FeishuDriveCommentReply[]; + page_token?: string; +}>; + +const FEISHU_DRIVE_REQUEST_TIMEOUT_MS = 30_000; + function getDriveInternalClient(client: Lark.Client): FeishuDriveInternalClient { return client as FeishuDriveInternalClient; } +function encodeQuery(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + const trimmed = value?.trim(); + if (trimmed) { + search.set(key, trimmed); + } + } + const query = search.toString(); + return query ? `?${query}` : ""; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function extractCommentElementText(element: unknown): string | undefined { + if (!isRecord(element)) { + return undefined; + } + const type = readString(element.type)?.trim(); + if (type === "text_run" && isRecord(element.text_run)) { + return ( + readString(element.text_run.content)?.trim() || + readString(element.text_run.text)?.trim() || + undefined + ); + } + if (type === "mention") { + const mention = isRecord(element.mention) ? element.mention : undefined; + const mentionName = + readString(mention?.name)?.trim() || + readString(mention?.display_name)?.trim() || + readString(element.name)?.trim(); + return mentionName ? `@${mentionName}` : "@mention"; + } + if (type === "docs_link") { + const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined; + return ( + readString(docsLink?.text)?.trim() || + readString(docsLink?.url)?.trim() || + readString(element.text)?.trim() || + readString(element.url)?.trim() || + undefined + ); + } + return ( + readString(element.text)?.trim() || + readString(element.content)?.trim() || + readString(element.name)?.trim() || + undefined + ); +} + +function extractReplyText(reply: FeishuDriveCommentReply | undefined): string | undefined { + if (!reply || !isRecord(reply.content)) { + return undefined; + } + const elements = Array.isArray(reply.content.elements) ? reply.content.elements : []; + const text = elements + .map(extractCommentElementText) + .filter((part): part is string => Boolean(part && part.trim())) + .join("") + .trim(); + return text || undefined; +} + +function buildReplyElements(content: string) { + return [{ type: "text", text: content }]; +} + +async function requestDriveApi(params: { + client: Lark.Client; + method: "GET" | "POST"; + url: string; + data?: unknown; +}): Promise { + const internalClient = getDriveInternalClient(params.client); + return (await internalClient.request({ + method: params.method, + url: params.url, + data: params.data ?? {}, + timeout: FEISHU_DRIVE_REQUEST_TIMEOUT_MS, + })) as T; +} + +function assertDriveApiSuccess(response: T): T { + if (response.code !== 0) { + throw new Error(response.msg ?? "Feishu Drive API request failed"); + } + return response; +} + +function normalizeCommentReply(reply: FeishuDriveCommentReply) { + return { + reply_id: reply.reply_id, + user_id: reply.user_id, + create_time: reply.create_time, + update_time: reply.update_time, + text: extractReplyText(reply), + }; +} + +function normalizeCommentCard(comment: FeishuDriveCommentCard) { + const replies = comment.reply_list?.replies ?? []; + const rootReply = replies[0]; + return { + comment_id: comment.comment_id, + user_id: comment.user_id, + create_time: comment.create_time, + update_time: comment.update_time, + is_solved: comment.is_solved, + is_whole: comment.is_whole, + quote: comment.quote, + text: extractReplyText(rootReply), + has_more_replies: comment.has_more, + replies_page_token: comment.page_token, + replies: replies.slice(1).map(normalizeCommentReply), + }; +} + async function getRootFolderToken(client: Lark.Client): Promise { // Use generic HTTP client to call the root folder meta API // as it's not directly exposed in the SDK @@ -177,6 +354,154 @@ async function deleteFile(client: Lark.Client, fileToken: string, type: string) }; } +async function listComments( + client: Lark.Client, + params: { + file_token: string; + file_type: CommentFileType; + page_size?: number; + page_token?: string; + }, +) { + const response = assertDriveApiSuccess( + await requestDriveApi({ + client, + method: "GET", + url: + `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments` + + encodeQuery({ + file_type: params.file_type, + page_size: + typeof params.page_size === "number" && Number.isFinite(params.page_size) + ? String(params.page_size) + : undefined, + page_token: params.page_token, + user_id_type: "open_id", + }), + }), + ); + return { + has_more: response.data?.has_more ?? false, + page_token: response.data?.page_token, + comments: (response.data?.items ?? []).map(normalizeCommentCard), + }; +} + +async function listCommentReplies( + client: Lark.Client, + params: { + file_token: string; + file_type: CommentFileType; + comment_id: string; + page_size?: number; + page_token?: string; + }, +) { + const response = assertDriveApiSuccess( + await requestDriveApi({ + client, + method: "GET", + url: + `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent( + params.comment_id, + )}/replies` + + encodeQuery({ + file_type: params.file_type, + page_size: + typeof params.page_size === "number" && Number.isFinite(params.page_size) + ? String(params.page_size) + : undefined, + page_token: params.page_token, + user_id_type: "open_id", + }), + }), + ); + return { + has_more: response.data?.has_more ?? false, + page_token: response.data?.page_token, + replies: (response.data?.items ?? []).map(normalizeCommentReply), + }; +} + +async function addComment( + client: Lark.Client, + params: { + file_token: string; + file_type: "doc" | "docx"; + content: string; + block_id?: string; + }, +) { + if (params.block_id?.trim() && params.file_type !== "docx") { + throw new Error("block_id is only supported for docx comments"); + } + const response = assertDriveApiSuccess( + await requestDriveApi>>({ + client, + method: "POST", + url: `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/new_comments`, + data: { + file_type: params.file_type, + reply_elements: buildReplyElements(params.content), + ...(params.block_id?.trim() ? { anchor: { block_id: params.block_id.trim() } } : {}), + }, + }), + ); + return { + success: true, + ...response.data, + }; +} + +export async function replyComment( + client: Lark.Client, + params: { + file_token: string; + file_type: CommentFileType; + 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, + )}/replies` + encodeQuery({ file_type: params.file_type }); + const attempts: unknown[] = [ + { + content: { + elements: [ + { + type: "text_run", + text_run: { + text: params.content, + }, + }, + ], + }, + }, + { + reply_elements: buildReplyElements(params.content), + }, + ]; + let lastMessage = "Feishu Drive reply comment failed"; + for (const data of attempts) { + const response = (await requestDriveApi>>({ + client, + method: "POST", + url, + data, + })) as FeishuDriveApiResponse>; + if (response.code === 0) { + return { + success: true, + ...response.data, + }; + } + lastMessage = response.msg ?? lastMessage; + } + throw new Error(lastMessage); +} + // ============ Tool Registration ============ export function registerFeishuDriveTools(api: OpenClawPluginApi) { @@ -206,7 +531,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { name: "feishu_drive", label: "Feishu Drive", description: - "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete", + "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete, list_comments, list_comment_replies, add_comment, reply_comment", parameters: FeishuDriveSchema, async execute(_toolCallId, params) { const p = params as FeishuDriveExecuteParams; @@ -227,6 +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": + 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.account.ts b/extensions/feishu/src/monitor.account.ts index 1e14abfcbde..5dc6c622a8a 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -12,6 +12,7 @@ import { import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js"; import { createEventDispatcher } from "./client.js"; +import { handleFeishuCommentEvent } from "./comment-handler.js"; import { hasProcessedFeishuMessage, recordProcessedFeishuMessage, @@ -21,6 +22,7 @@ import { } from "./dedup.js"; import { isMentionForwardRequest } from "./mention.js"; import { applyBotIdentityState, startBotIdentityRecovery } from "./monitor.bot-identity.js"; +import { parseFeishuDriveCommentNoticeEventPayload } from "./monitor.comment.js"; import { fetchBotIdentityForMonitor } from "./monitor.startup.js"; import { botNames, botOpenIds } from "./monitor.state.js"; import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js"; @@ -598,6 +600,60 @@ function registerEventHandlers( error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`); } }, + "drive.notice.comment_add_v1": async (data: unknown) => { + await runFeishuHandler({ + errorMessage: `feishu[${accountId}]: error handling drive comment notice`, + task: async () => { + const event = parseFeishuDriveCommentNoticeEventPayload(data); + if (!event) { + error(`feishu[${accountId}]: ignoring malformed drive comment notice payload`); + return; + } + const eventId = event.event_id?.trim(); + const syntheticMessageId = eventId ? `drive-comment:${eventId}` : undefined; + if ( + syntheticMessageId && + (await hasProcessedFeishuMessage(syntheticMessageId, accountId, log)) + ) { + log(`feishu[${accountId}]: dropping duplicate comment event ${syntheticMessageId}`); + return; + } + if ( + syntheticMessageId && + !tryBeginFeishuMessageProcessing(syntheticMessageId, accountId) + ) { + log(`feishu[${accountId}]: dropping in-flight comment event ${syntheticMessageId}`); + return; + } + log( + `feishu[${accountId}]: received drive comment notice ` + + `event=${event.event_id ?? "unknown"} ` + + `type=${event.notice_meta?.notice_type ?? "unknown"} ` + + `file=${event.notice_meta?.file_type ?? "unknown"}:${event.notice_meta?.file_token ?? "unknown"} ` + + `comment=${event.comment_id ?? "unknown"} ` + + `reply=${event.reply_id ?? "none"} ` + + `from=${event.notice_meta?.from_user_id?.open_id ?? "unknown"} ` + + `mentioned=${event.is_mentioned === true ? "yes" : "no"}`, + ); + try { + await handleFeishuCommentEvent({ + cfg, + accountId, + event, + botOpenId: botOpenIds.get(accountId), + runtime, + }); + if (syntheticMessageId) { + await recordProcessedFeishuMessage(syntheticMessageId, accountId, log); + } + } finally { + if (syntheticMessageId) { + releaseFeishuMessageProcessing(syntheticMessageId, accountId); + } + } + }, + }); + }, "im.message.reaction.created_v1": async (data) => { await runFeishuHandler({ errorMessage: `feishu[${accountId}]: error handling reaction event`, diff --git a/extensions/feishu/src/monitor.comment.test.ts b/extensions/feishu/src/monitor.comment.test.ts new file mode 100644 index 00000000000..17557c1e541 --- /dev/null +++ b/extensions/feishu/src/monitor.comment.test.ts @@ -0,0 +1,405 @@ +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 { 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 * 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 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; + quoteText?: string; + rootReplyText?: string; + targetReplyText?: string; + includeTargetReplyInBatch?: boolean; +}) { + 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: "7623358762119646411", + 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")) { + return { + code: 0, + data: { + has_more: false, + items: [ + { + 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", + }, + }, + ], + }, + }, + ], + }, + }; + } + 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("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 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"); + }); + + 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( + createPluginRuntimeMock({ + channel: { + debounce: { + resolveInboundDebounceMs, + createInboundDebouncer, + }, + text: { + hasControlCommand, + }, + }, + }), + ); + }); + + 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(); + }); +}); diff --git a/extensions/feishu/src/monitor.comment.ts b/extensions/feishu/src/monitor.comment.ts new file mode 100644 index 00000000000..8c8f9332318 --- /dev/null +++ b/extensions/feishu/src/monitor.comment.ts @@ -0,0 +1,605 @@ +import type { ClawdbotConfig } from "../runtime-api.js"; +import { resolveFeishuAccount } from "./accounts.js"; +import { raceWithTimeoutAndAbort } from "./async.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; +const FEISHU_COMMENT_REPLY_PAGE_SIZE = 200; +const FEISHU_COMMENT_REPLY_PAGE_LIMIT = 5; + +type FeishuDriveCommentUserId = { + open_id?: string; + user_id?: string; + union_id?: string; +}; + +export type FeishuDriveCommentNoticeEvent = { + comment_id?: string; + event_id?: string; + is_mentioned?: boolean; + notice_meta?: { + file_token?: string; + file_type?: string; + from_user_id?: FeishuDriveCommentUserId; + notice_type?: string; + to_user_id?: FeishuDriveCommentUserId; + }; + reply_id?: string; + timestamp?: string; + type?: string; +}; + +type ResolveDriveCommentEventParams = { + cfg: ClawdbotConfig; + accountId: string; + event: FeishuDriveCommentNoticeEvent; + botOpenId?: string; + createClient?: (account: ResolvedFeishuAccount) => FeishuRequestClient; + verificationTimeoutMs?: number; + logger?: (message: string) => void; +}; + +export type ResolvedDriveCommentEventTurn = { + eventId: string; + messageId: string; + commentId: string; + replyId?: string; + noticeType: "add_comment" | "add_reply"; + fileToken: string; + fileType: CommentFileType; + senderId: string; + senderUserId?: string; + timestamp?: string; + isMentioned?: boolean; + documentTitle?: string; + documentUrl?: string; + quoteText?: string; + rootCommentText?: string; + targetReplyText?: string; + prompt: string; + preview: string; +}; + +type FeishuRequestClient = ReturnType & { + request(params: { + method: "GET" | "POST"; + url: string; + data: unknown; + timeout: number; + }): Promise; +}; + +type FeishuOpenApiResponse = { + code?: number; + msg?: string; + data?: T; +}; + +type FeishuDriveMetaBatchQueryResponse = FeishuOpenApiResponse<{ + metas?: Array<{ + doc_token?: string; + title?: string; + url?: string; + }>; +}>; + +type FeishuDriveCommentReply = { + reply_id?: string; + content?: { + elements?: unknown[]; + }; +}; + +type FeishuDriveCommentCard = { + comment_id?: string; + quote?: string; + reply_list?: { + replies?: FeishuDriveCommentReply[]; + }; +}; + +type FeishuDriveCommentBatchQueryResponse = FeishuOpenApiResponse<{ + items?: FeishuDriveCommentCard[]; +}>; + +type FeishuDriveCommentRepliesListResponse = FeishuOpenApiResponse<{ + has_more?: boolean; + items?: FeishuDriveCommentReply[]; + page_token?: string; +}>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function encodeQuery(params: Record): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + const trimmed = value?.trim(); + if (trimmed) { + query.set(key, trimmed); + } + } + const queryString = query.toString(); + return queryString ? `?${queryString}` : ""; +} + +function buildDriveCommentTargetUrl(params: { + fileToken: string; + fileType: CommentFileType; +}): string { + return ( + `/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments/batch_query` + + encodeQuery({ + file_type: params.fileType, + user_id_type: "open_id", + }) + ); +} + +function buildDriveCommentRepliesUrl(params: { + fileToken: string; + commentId: string; + fileType: CommentFileType; + pageToken?: string; +}): string { + return ( + `/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments/${encodeURIComponent( + params.commentId, + )}/replies` + + encodeQuery({ + file_type: params.fileType, + page_token: params.pageToken, + page_size: String(FEISHU_COMMENT_REPLY_PAGE_SIZE), + user_id_type: "open_id", + }) + ); +} + +async function requestFeishuOpenApi(params: { + client: FeishuRequestClient; + method: "GET" | "POST"; + url: string; + data?: unknown; + timeoutMs: number; + logger?: (message: string) => void; + errorLabel: string; +}): Promise { + const result = await raceWithTimeoutAndAbort( + params.client.request({ + method: params.method, + url: params.url, + data: params.data ?? {}, + timeout: params.timeoutMs, + }) as Promise, + { timeoutMs: params.timeoutMs }, + ) + .then((resolved) => (resolved.status === "resolved" ? resolved.value : null)) + .catch((error) => { + params.logger?.(`${params.errorLabel}: ${String(error)}`); + return null; + }); + if (!result) { + params.logger?.(`${params.errorLabel}: request timed out or returned no data`); + } + return result; +} + +function extractCommentElementText(element: unknown): string | undefined { + if (!isRecord(element)) { + return undefined; + } + const type = readString(element.type)?.trim(); + if (type === "text_run" && isRecord(element.text_run)) { + return ( + readString(element.text_run.content)?.trim() || + readString(element.text_run.text)?.trim() || + undefined + ); + } + if (type === "mention") { + const mention = isRecord(element.mention) ? element.mention : undefined; + const mentionName = + readString(mention?.name)?.trim() || + readString(mention?.display_name)?.trim() || + readString(element.name)?.trim(); + return mentionName ? `@${mentionName}` : "@mention"; + } + if (type === "docs_link") { + const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined; + return ( + readString(docsLink?.text)?.trim() || + readString(docsLink?.url)?.trim() || + readString(element.text)?.trim() || + readString(element.url)?.trim() || + undefined + ); + } + return ( + readString(element.text)?.trim() || + readString(element.content)?.trim() || + readString(element.name)?.trim() || + undefined + ); +} + +function extractReplyText(reply: FeishuDriveCommentReply | undefined): string | undefined { + if (!reply || !isRecord(reply.content)) { + return undefined; + } + const elements = Array.isArray(reply.content.elements) ? reply.content.elements : []; + const text = elements + .map(extractCommentElementText) + .filter((part): part is string => Boolean(part && part.trim())) + .join("") + .trim(); + return text || undefined; +} + +async function fetchDriveCommentReplies(params: { + client: FeishuRequestClient; + fileToken: string; + fileType: CommentFileType; + commentId: string; + timeoutMs: number; + logger?: (message: string) => void; + accountId: string; +}): Promise { + const replies: FeishuDriveCommentReply[] = []; + let pageToken: string | undefined; + for (let page = 0; page < FEISHU_COMMENT_REPLY_PAGE_LIMIT; page += 1) { + const response = await requestFeishuOpenApi({ + client: params.client, + method: "GET", + url: buildDriveCommentRepliesUrl({ + fileToken: params.fileToken, + commentId: params.commentId, + fileType: params.fileType, + pageToken, + }), + timeoutMs: params.timeoutMs, + logger: params.logger, + errorLabel: `feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}`, + }); + if (response?.code !== 0) { + if (response) { + params.logger?.( + `feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}: ${response.msg ?? "unknown error"}`, + ); + } + break; + } + replies.push(...(response.data?.items ?? [])); + if (response.data?.has_more !== true || !response.data.page_token?.trim()) { + break; + } + pageToken = response.data.page_token.trim(); + } + return replies; +} + +async function fetchDriveCommentContext(params: { + client: FeishuRequestClient; + fileToken: string; + fileType: CommentFileType; + commentId: string; + replyId?: string; + timeoutMs: number; + logger?: (message: string) => void; + accountId: string; +}): Promise<{ + documentTitle?: string; + documentUrl?: string; + quoteText?: string; + rootCommentText?: string; + targetReplyText?: string; +}> { + const [metaResponse, commentResponse] = await Promise.all([ + requestFeishuOpenApi({ + client: params.client, + method: "POST", + url: "/open-apis/drive/v1/metas/batch_query", + data: { + request_docs: [{ doc_token: params.fileToken, doc_type: params.fileType }], + with_url: true, + }, + timeoutMs: params.timeoutMs, + logger: params.logger, + errorLabel: `feishu[${params.accountId}]: failed to fetch drive metadata for ${params.fileToken}`, + }), + requestFeishuOpenApi({ + client: params.client, + method: "POST", + url: buildDriveCommentTargetUrl({ + fileToken: params.fileToken, + fileType: params.fileType, + }), + data: { + comment_ids: [params.commentId], + }, + timeoutMs: params.timeoutMs, + logger: params.logger, + errorLabel: `feishu[${params.accountId}]: failed to fetch drive comment ${params.commentId}`, + }), + ]); + + const commentCard = + commentResponse?.code === 0 + ? ((commentResponse.data?.items ?? []).find( + (item) => item.comment_id?.trim() === params.commentId, + ) ?? commentResponse.data?.items?.[0]) + : undefined; + const embeddedReplies = commentCard?.reply_list?.replies ?? []; + const embeddedTargetReply = params.replyId + ? embeddedReplies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim()) + : embeddedReplies.at(-1); + + let replies = embeddedReplies; + if (!embeddedTargetReply || replies.length === 0) { + const fetchedReplies = await fetchDriveCommentReplies(params); + if (fetchedReplies.length > 0) { + replies = fetchedReplies; + } + } + + const rootReply = replies[0] ?? embeddedReplies[0]; + const fetchedMatchedReply = params.replyId + ? replies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim()) + : undefined; + const targetReply = params.replyId + ? (embeddedTargetReply ?? fetchedMatchedReply ?? undefined) + : (replies.at(-1) ?? embeddedTargetReply ?? rootReply); + const meta = metaResponse?.code === 0 ? metaResponse.data?.metas?.[0] : undefined; + + return { + documentTitle: meta?.title?.trim() || undefined, + documentUrl: meta?.url?.trim() || undefined, + quoteText: commentCard?.quote?.trim() || undefined, + rootCommentText: extractReplyText(rootReply), + targetReplyText: extractReplyText(targetReply), + }; +} + +function buildDriveCommentSurfacePrompt(params: { + noticeType: "add_comment" | "add_reply"; + fileType: CommentFileType; + fileToken: string; + commentId: string; + replyId?: 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 + ? `The user added a ${actionLabel} in ${documentLabel}: ${params.targetReplyText}` + : `The user 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}`, + `comment_id: ${params.commentId}`, + ); + if (params.replyId?.trim()) { + lines.push(`reply_id: ${params.replyId.trim()}`); + } + lines.push( + "This is a Feishu document comment-thread event, not a Feishu IM conversation. Your final text reply will be posted automatically to the current comment thread and will not be sent as an instant message.", + "If you need to inspect or handle the comment thread, prefer the feishu_drive tools: use list_comments / list_comment_replies to inspect comments, and use reply_comment/add_comment to notify the user after modifying the document.", + 'If the comment asks you to modify document content, such as adding, inserting, replacing, or deleting text, tables, or headings, you must first use feishu_doc to actually modify the document. Do not reply with only "done", "I\'ll handle it", or a restated plan without calling tools.', + 'If the comment quotes document content, that quoted text is usually the edit anchor. For requests like "insert xxx below this content", first locate the position around the quoted content, then use feishu_doc to make the change.', + 'If the comment asks you to summarize, explain, rewrite, translate, refine, continue, or review the document content "below", "above", "this paragraph", "this section", or the quoted content, you must also treat the quoted content as the primary target anchor instead of defaulting to the whole document.', + 'For requests like "summarize the content below", "explain this section", or "continue writing from here", first locate the relevant document fragment based on the comment\'s quoted content. If the quote is not sufficient to support the answer, then use feishu_doc.read or feishu_doc.list_blocks to read nearby context.', + "Do not guess document content based only on the comment text, and do not output a vague summary before reading enough context. Unless the user explicitly asks to summarize the entire document, default to handling only the local scope related to the quoted content.", + "When document edits are involved, first use feishu_doc.read or feishu_doc.list_blocks to confirm the context, then use feishu_doc writing or updating capabilities to complete the change. After the edit succeeds, notify the user through feishu_drive.reply_comment.", + "If the document edit fails or you cannot locate the anchor, do not pretend it succeeded. Reply clearly in the comment thread with the reason for failure or the missing information.", + "If this is a reading-comprehension task, such as summarization, explanation, or extraction, you may directly output the final answer text after confirming the context. The system will automatically reply with that answer in the current comment thread.", + "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.", + "If you have already completed the user-visible action through feishu_drive.reply_comment or feishu_drive.add_comment, output NO_REPLY at the end to avoid duplicate sending.", + "If the user directly asks a question in the comment and a plain text answer is sufficient, output the answer text directly. The system will automatically reply with your final answer in the current comment thread.", + "If you determine that the current comment does not require any user-visible action, output NO_REPLY at the end.", + ); + lines.push(`Decide what to do next based on this document ${actionLabel} event.`); + return lines.join("\n"); +} + +async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventParams): Promise<{ + eventId: string; + commentId: string; + replyId?: string; + noticeType: "add_comment" | "add_reply"; + fileToken: string; + fileType: CommentFileType; + senderId: string; + senderUserId?: string; + timestamp?: string; + isMentioned?: boolean; + context: { + documentTitle?: string; + documentUrl?: string; + quoteText?: string; + rootCommentText?: string; + targetReplyText?: string; + }; +} | null> { + const { + cfg, + accountId, + event, + botOpenId, + createClient = (account) => createFeishuClient(account) as FeishuRequestClient, + verificationTimeoutMs = FEISHU_COMMENT_VERIFY_TIMEOUT_MS, + logger, + } = params; + const eventId = event.event_id?.trim(); + const commentId = event.comment_id?.trim(); + const replyId = event.reply_id?.trim(); + const noticeType = event.notice_meta?.notice_type?.trim(); + const fileToken = event.notice_meta?.file_token?.trim(); + const fileType = normalizeCommentFileType(event.notice_meta?.file_type); + const senderId = event.notice_meta?.from_user_id?.open_id?.trim(); + const senderUserId = event.notice_meta?.from_user_id?.user_id?.trim() || undefined; + if (!eventId || !commentId || !noticeType || !fileToken || !fileType || !senderId) { + logger?.( + `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}`); + return null; + } + if (!botOpenId) { + logger?.( + `feishu[${accountId}]: skipping drive comment notice because bot open_id is unavailable ` + + `event=${eventId}`, + ); + return null; + } + if (senderId === botOpenId) { + logger?.( + `feishu[${accountId}]: ignoring self-authored drive comment notice event=${eventId} sender=${senderId}`, + ); + return null; + } + + const account = resolveFeishuAccount({ cfg, accountId }); + const client = createClient(account); + const context = await fetchDriveCommentContext({ + client, + fileToken, + fileType, + commentId, + replyId, + timeoutMs: verificationTimeoutMs, + logger, + accountId, + }); + return { + eventId, + commentId, + replyId, + noticeType, + fileToken, + fileType, + senderId, + senderUserId, + timestamp: event.timestamp, + isMentioned: event.is_mentioned, + context, + }; +} + +export function parseFeishuDriveCommentNoticeEventPayload( + value: unknown, +): FeishuDriveCommentNoticeEvent | null { + if (!isRecord(value) || !isRecord(value.notice_meta)) { + return null; + } + const noticeMeta = value.notice_meta; + const fromUserId = isRecord(noticeMeta.from_user_id) ? noticeMeta.from_user_id : undefined; + const toUserId = isRecord(noticeMeta.to_user_id) ? noticeMeta.to_user_id : undefined; + return { + comment_id: readString(value.comment_id), + event_id: readString(value.event_id), + is_mentioned: readBoolean(value.is_mentioned), + notice_meta: { + file_token: readString(noticeMeta.file_token), + file_type: readString(noticeMeta.file_type), + from_user_id: fromUserId + ? { + open_id: readString(fromUserId.open_id), + user_id: readString(fromUserId.user_id), + union_id: readString(fromUserId.union_id), + } + : undefined, + notice_type: readString(noticeMeta.notice_type), + to_user_id: toUserId + ? { + open_id: readString(toUserId.open_id), + user_id: readString(toUserId.user_id), + union_id: readString(toUserId.union_id), + } + : undefined, + }, + reply_id: readString(value.reply_id), + timestamp: readString(value.timestamp), + type: readString(value.type), + }; +} + +export async function resolveDriveCommentEventTurn( + params: ResolveDriveCommentEventParams, +): Promise { + const resolved = await resolveDriveCommentEventCore(params); + if (!resolved) { + return null; + } + const prompt = buildDriveCommentSurfacePrompt({ + noticeType: resolved.noticeType, + fileType: resolved.fileType, + fileToken: resolved.fileToken, + commentId: resolved.commentId, + replyId: resolved.replyId, + 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); + return { + eventId: resolved.eventId, + messageId: `drive-comment:${resolved.eventId}`, + commentId: resolved.commentId, + replyId: resolved.replyId, + noticeType: resolved.noticeType, + fileToken: resolved.fileToken, + fileType: resolved.fileType, + senderId: resolved.senderId, + senderUserId: resolved.senderUserId, + timestamp: resolved.timestamp, + isMentioned: resolved.isMentioned, + documentTitle: resolved.context.documentTitle, + documentUrl: resolved.context.documentUrl, + quoteText: resolved.context.quoteText, + rootCommentText: resolved.context.rootCommentText, + targetReplyText: resolved.context.targetReplyText, + prompt, + preview, + }; +} diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 53bdadc87f3..4eb5924fc07 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,96 @@ 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("routes comment-thread code-block replies through replyComment instead of IM cards", async () => { + const result = await sendText({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "```ts\nconst x = 1\n```", + accountId: "main", + }); + + expect(replyCommentMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + file_token: "doxcn123", + file_type: "docx", + comment_id: "7623358762119646411", + content: "```ts\nconst x = 1\n```", + }), + ); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" })); + }); + + it("routes comment-thread replies through replyComment even when renderMode=card", async () => { + const result = await sendText({ + cfg: cardRenderConfig, + 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(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).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..b12a5e7357a 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"; @@ -115,6 +153,16 @@ export const feishuOutbound: ChannelOutboundAdapter = { } } + if (parseFeishuCommentTarget(to)) { + return await sendOutboundText({ + cfg, + to, + text, + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); const renderMode = account.config?.renderMode ?? "auto"; const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); @@ -156,6 +204,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({