diff --git a/extensions/feishu/src/comment-dispatcher.test.ts b/extensions/feishu/src/comment-dispatcher.test.ts new file mode 100644 index 00000000000..841a903cd4d --- /dev/null +++ b/extensions/feishu/src/comment-dispatcher.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveFeishuRuntimeAccountMock = vi.hoisted(() => vi.fn()); +const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const createReplyPrefixContextMock = vi.hoisted(() => vi.fn()); +const createCommentTypingReactionLifecycleMock = vi.hoisted(() => vi.fn()); +const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn()); +const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); +const getFeishuRuntimeMock = vi.hoisted(() => vi.fn()); + +vi.mock("./accounts.js", () => ({ + resolveFeishuRuntimeAccount: resolveFeishuRuntimeAccountMock, +})); + +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + +vi.mock("./comment-dispatcher-runtime-api.js", () => ({ + createReplyPrefixContext: createReplyPrefixContextMock, +})); + +vi.mock("./comment-reaction.js", () => ({ + createCommentTypingReactionLifecycle: createCommentTypingReactionLifecycleMock, +})); + +vi.mock("./drive.js", () => ({ + deliverCommentThreadText: deliverCommentThreadTextMock, +})); + +vi.mock("./runtime.js", () => ({ + getFeishuRuntime: getFeishuRuntimeMock, +})); + +import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js"; + +describe("createFeishuCommentReplyDispatcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveFeishuRuntimeAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: {}, + }); + createFeishuClientMock.mockReturnValue({}); + createReplyPrefixContextMock.mockReturnValue({ + responsePrefix: undefined, + responsePrefixContextProvider: undefined, + }); + deliverCommentThreadTextMock.mockResolvedValue({ + delivery_mode: "reply_comment", + reply_id: "reply_1", + }); + createCommentTypingReactionLifecycleMock.mockReturnValue({ + start: vi.fn(async () => {}), + cleanup: vi.fn(async () => {}), + }); + createReplyDispatcherWithTypingMock.mockImplementation(() => ({ + dispatcher: { + markComplete: vi.fn(), + waitForIdle: vi.fn(async () => {}), + }, + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + })); + getFeishuRuntimeMock.mockReturnValue({ + channel: { + text: { + resolveTextChunkLimit: vi.fn(() => 4000), + resolveChunkMode: vi.fn(() => "line"), + chunkTextWithMode: vi.fn((text: string) => [text]), + }, + reply: { + createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, + resolveHumanDelayConfig: vi.fn(() => undefined), + }, + }, + }); + }); + + it("sends final comment text without waiting for typing cleanup", async () => { + let resolveCleanup: (() => void) | undefined; + const cleanup = vi.fn( + () => + new Promise((resolve) => { + resolveCleanup = resolve; + }), + ); + createCommentTypingReactionLifecycleMock.mockReturnValue({ + start: vi.fn(async () => {}), + cleanup, + }); + + createFeishuCommentReplyDispatcher({ + cfg: {} as never, + agentId: "main", + runtime: { log: vi.fn(), error: vi.fn() } as never, + accountId: "main", + fileToken: "doc_token_1", + fileType: "docx", + commentId: "comment_1", + replyId: "reply_1", + isWholeComment: false, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0]; + const deliverPromise = options.deliver({ text: "hello world" }, { kind: "final" }); + const status = await Promise.race([ + deliverPromise.then(() => "done"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + + expect(status).toBe("done"); + expect(deliverCommentThreadTextMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + file_token: "doc_token_1", + file_type: "docx", + comment_id: "comment_1", + content: "hello world", + is_whole_comment: false, + }), + ); + expect(cleanup).not.toHaveBeenCalled(); + + options.onCleanup?.(); + expect(cleanup).toHaveBeenCalledTimes(1); + + resolveCleanup?.(); + await deliverPromise; + }); + + it("starts the typing reaction from dispatcher onReplyStart", async () => { + const start = vi.fn(async () => {}); + createCommentTypingReactionLifecycleMock.mockReturnValue({ + start, + cleanup: vi.fn(async () => {}), + }); + + createFeishuCommentReplyDispatcher({ + cfg: {} as never, + agentId: "main", + runtime: { log: vi.fn(), error: vi.fn() } as never, + accountId: "main", + fileToken: "doc_token_1", + fileType: "docx", + commentId: "comment_1", + replyId: "reply_1", + isWholeComment: false, + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0]; + await options.onReplyStart?.(); + + expect(start).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/feishu/src/comment-dispatcher.ts b/extensions/feishu/src/comment-dispatcher.ts index bc8ca375472..af255f60d84 100644 --- a/extensions/feishu/src/comment-dispatcher.ts +++ b/extensions/feishu/src/comment-dispatcher.ts @@ -7,6 +7,7 @@ import { type ReplyPayload, type RuntimeEnv, } from "./comment-dispatcher-runtime-api.js"; +import { createCommentTypingReactionLifecycle } from "./comment-reaction.js"; import type { CommentFileType } from "./comment-target.js"; import { deliverCommentThreadText } from "./drive.js"; import { getFeishuRuntime } from "./runtime.js"; @@ -19,6 +20,7 @@ export type CreateFeishuCommentReplyDispatcherParams = { fileToken: string; fileType: CommentFileType; commentId: string; + replyId?: string; isWholeComment?: boolean; }; @@ -43,12 +45,23 @@ export function createFeishuCommentReplyDispatcher( }, ); const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "feishu"); + const typingReaction = createCommentTypingReactionLifecycle({ + cfg: params.cfg, + fileToken: params.fileToken, + fileType: params.fileType, + replyId: params.replyId, + accountId: params.accountId, + runtime: params.runtime, + }); - const { dispatcher, replyOptions, markDispatchIdle } = + const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: prefixContext.responsePrefix, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + onReplyStart: async () => { + await typingReaction.start(); + }, deliver: async (payload: ReplyPayload, info) => { if (info.kind !== "final") { return; @@ -78,7 +91,17 @@ export function createFeishuCommentReplyDispatcher( `feishu[${params.accountId ?? "default"}]: comment dispatcher failed kind=${info.kind} comment=${params.commentId}: ${String(err)}`, ); }, + onCleanup: () => { + void typingReaction.cleanup(); + }, }); - return { dispatcher, replyOptions, markDispatchIdle }; + return { + dispatcher, + replyOptions, + markDispatchIdle, + markRunComplete, + startTypingReaction: typingReaction.start, + cleanupTypingReaction: typingReaction.cleanup, + }; } diff --git a/extensions/feishu/src/comment-handler.test.ts b/extensions/feishu/src/comment-handler.test.ts index ce011b70bba..396ee66c215 100644 --- a/extensions/feishu/src/comment-handler.test.ts +++ b/extensions/feishu/src/comment-handler.test.ts @@ -164,6 +164,9 @@ describe("handleFeishuCommentEvent", () => { }, replyOptions: {}, markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + startTypingReaction: vi.fn(async () => {}), + cleanupTypingReaction: vi.fn(async () => {}), }); }); @@ -198,9 +201,15 @@ describe("handleFeishuCommentEvent", () => { OriginatingChannel: "feishu", OriginatingTo: "comment:docx:doc_token_1:comment_1", MessageSid: "drive-comment:evt_1", + MessageThreadId: "reply_1", }), ); expect(recordInboundSession).toHaveBeenCalledTimes(1); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:feishu:direct:comment-doc:docx:doc_token_1", + }), + ); expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); }); @@ -309,8 +318,124 @@ describe("handleFeishuCommentEvent", () => { commentId: "comment_whole", fileToken: "doc_token_1", fileType: "docx", + replyId: "reply_whole", isWholeComment: true, }), ); }); + + it("always finalizes comment typing cleanup even when dispatch fails", async () => { + const dispatchReplyFromConfig = vi.fn(async () => { + throw new Error("dispatch failed"); + }); + const runtime = createTestRuntime({ dispatchReplyFromConfig }); + setFeishuRuntime(runtime); + const markRunComplete = vi.fn(); + const markDispatchIdle = vi.fn(); + const cleanupTypingReaction = vi.fn(async () => {}); + createFeishuCommentReplyDispatcherMock.mockReturnValue({ + dispatcher: { + markComplete: vi.fn(), + waitForIdle: vi.fn(async () => {}), + }, + replyOptions: {}, + markDispatchIdle, + markRunComplete, + startTypingReaction: vi.fn(async () => {}), + cleanupTypingReaction, + }); + + await expect( + handleFeishuCommentEvent({ + cfg: buildConfig(), + accountId: "default", + event: { event_id: "evt_1" }, + botOpenId: "ou_bot", + runtime: { + log: vi.fn(), + error: vi.fn(), + } as never, + }), + ).rejects.toThrow("dispatch failed"); + + expect(markRunComplete).toHaveBeenCalledTimes(1); + expect(markDispatchIdle).toHaveBeenCalledTimes(1); + expect(cleanupTypingReaction).toHaveBeenCalledTimes(1); + }); + + it("does not wait for comment typing cleanup before returning", async () => { + let resolveCleanup: (() => void) | undefined; + const cleanupTypingReaction = vi.fn( + () => + new Promise((resolve) => { + resolveCleanup = resolve; + }), + ); + createFeishuCommentReplyDispatcherMock.mockReturnValue({ + dispatcher: { + markComplete: vi.fn(), + waitForIdle: vi.fn(async () => {}), + }, + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + startTypingReaction: vi.fn(async () => {}), + cleanupTypingReaction, + }); + + const eventPromise = handleFeishuCommentEvent({ + cfg: buildConfig(), + accountId: "default", + event: { event_id: "evt_1" }, + botOpenId: "ou_bot", + runtime: { + log: vi.fn(), + error: vi.fn(), + } as never, + }); + + const status = await Promise.race([ + eventPromise.then(() => "done"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + + expect(status).toBe("done"); + expect(cleanupTypingReaction).toHaveBeenCalledTimes(1); + + resolveCleanup?.(); + await eventPromise; + }); + + it("does not start comment typing reaction before dispatch begins", async () => { + const startTypingReaction = vi.fn(async () => {}); + createFeishuCommentReplyDispatcherMock.mockReturnValue({ + dispatcher: { + markComplete: vi.fn(), + waitForIdle: vi.fn(async () => {}), + }, + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + startTypingReaction, + cleanupTypingReaction: vi.fn(async () => {}), + }); + + await handleFeishuCommentEvent({ + cfg: buildConfig(), + accountId: "default", + event: { event_id: "evt_1" }, + botOpenId: "ou_bot", + runtime: { + log: vi.fn(), + error: vi.fn(), + } as never, + }); + + expect(startTypingReaction).not.toHaveBeenCalled(); + const runtime = (await import("./runtime.js")).getFeishuRuntime(); + const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType< + typeof vi.fn + >; + expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/feishu/src/comment-handler.ts b/extensions/feishu/src/comment-handler.ts index e92ed12c219..59c8c3c64b8 100644 --- a/extensions/feishu/src/comment-handler.ts +++ b/extensions/feishu/src/comment-handler.ts @@ -29,7 +29,8 @@ type HandleFeishuCommentEventParams = { function buildCommentSessionKey(params: { core: ReturnType; route: ResolvedAgentRoute; - commentTarget: string; + fileType: string; + fileToken: string; }): string { return params.core.channel.routing.buildAgentSessionKey({ agentId: params.route.agentId, @@ -37,7 +38,7 @@ function buildCommentSessionKey(params: { accountId: params.route.accountId, peer: { kind: "direct", - id: params.commentTarget, + id: `comment-doc:${params.fileType}:${params.fileToken}`, }, dmScope: "per-account-channel-peer", }); @@ -172,7 +173,8 @@ export async function handleFeishuCommentEvent( const commentSessionKey = buildCommentSessionKey({ core, route, - commentTarget, + fileType: turn.fileType, + fileToken: turn.fileToken, }); const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`; const ctxPayload = core.channel.reply.finalizeInboundContext({ @@ -193,6 +195,9 @@ export async function handleFeishuCommentEvent( Provider: "feishu", Surface: "feishu-comment", MessageSid: turn.messageId, + // For Feishu comment turns, MessageThreadId carries the inbound reply_id so + // comment-aware tools can clean typing reaction before sending visible output. + MessageThreadId: turn.replyId, Timestamp: parseTimestampMs(turn.timestamp), WasMentioned: turn.isMentioned, CommandAuthorized: false, @@ -214,36 +219,41 @@ export async function handleFeishuCommentEvent( }, }); - const { dispatcher, replyOptions, markDispatchIdle } = createFeishuCommentReplyDispatcher({ - cfg: effectiveCfg, - agentId: route.agentId, - runtime, - accountId: account.accountId, - fileToken: turn.fileToken, - fileType: turn.fileType, - commentId: turn.commentId, - isWholeComment: turn.isWholeComment, - }); + const { dispatcher, replyOptions, markDispatchIdle, markRunComplete, cleanupTypingReaction } = + createFeishuCommentReplyDispatcher({ + cfg: effectiveCfg, + agentId: route.agentId, + runtime, + accountId: account.accountId, + fileToken: turn.fileToken, + fileType: turn.fileType, + commentId: turn.commentId, + replyId: turn.replyId, + isWholeComment: turn.isWholeComment, + }); - 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})`, - ); + try { + 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, + 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})`, + ); + } finally { + markRunComplete(); + markDispatchIdle(); + void cleanupTypingReaction(); + } } diff --git a/extensions/feishu/src/comment-reaction.test.ts b/extensions/feishu/src/comment-reaction.test.ts new file mode 100644 index 00000000000..e00f2624791 --- /dev/null +++ b/extensions/feishu/src/comment-reaction.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../runtime-api.js"; +import { + cleanupAmbientCommentTypingReaction, + createCommentTypingReactionLifecycle, +} from "./comment-reaction.js"; + +const resolveFeishuRuntimeAccountMock = vi.hoisted(() => vi.fn()); +const createFeishuClientMock = vi.hoisted(() => vi.fn()); + +vi.mock("./accounts.js", () => ({ + resolveFeishuRuntimeAccount: resolveFeishuRuntimeAccountMock, +})); + +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + +describe("createCommentTypingReactionLifecycle", () => { + const request = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + resolveFeishuRuntimeAccountMock.mockReturnValue({ + accountId: "default", + configured: true, + config: { + typingIndicator: true, + }, + }); + createFeishuClientMock.mockReturnValue({ + request, + }); + request.mockResolvedValue({ + code: 0, + data: {}, + }); + }); + + it("adds and removes a comment typing reaction using reply_id", async () => { + const lifecycle = createCommentTypingReactionLifecycle({ + cfg: {} as ClawdbotConfig, + fileToken: "doc_token_1", + fileType: "docx", + replyId: "reply_1", + runtime: { + log: vi.fn(), + } as never, + }); + + await lifecycle.start(); + await lifecycle.cleanup(); + + expect(request).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: "POST", + url: "/open-apis/drive/v2/files/doc_token_1/comments/reaction?file_type=docx", + data: { + action: "add", + reply_id: "reply_1", + reaction_type: "Typing", + }, + }), + ); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "POST", + url: "/open-apis/drive/v2/files/doc_token_1/comments/reaction?file_type=docx", + data: { + action: "delete", + reply_id: "reply_1", + reaction_type: "Typing", + }, + }), + ); + }); + + it("skips requests when reply_id is missing", async () => { + const lifecycle = createCommentTypingReactionLifecycle({ + cfg: {} as ClawdbotConfig, + fileToken: "doc_token_1", + fileType: "docx", + replyId: undefined, + runtime: { + log: vi.fn(), + } as never, + }); + + await lifecycle.start(); + await lifecycle.cleanup(); + + expect(request).not.toHaveBeenCalled(); + }); + + it("shares cleanup state so ambient cleanup and finally cleanup do not delete twice", async () => { + const lifecycle = createCommentTypingReactionLifecycle({ + cfg: {} as ClawdbotConfig, + fileToken: "doc_token_1", + fileType: "docx", + replyId: "reply_1", + runtime: { + log: vi.fn(), + } as never, + }); + + await lifecycle.start(); + await cleanupAmbientCommentTypingReaction({ + client: { request } as never, + deliveryContext: { + channel: "feishu", + to: "comment:docx:doc_token_1:comment_1", + threadId: "reply_1", + }, + }); + await lifecycle.cleanup(); + + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + data: { + action: "delete", + reply_id: "reply_1", + reaction_type: "Typing", + }, + }), + ); + }); + + it("retries delete during later cleanup after an ambient delete failure", async () => { + request + .mockResolvedValueOnce({ + code: 0, + data: {}, + }) + .mockResolvedValueOnce({ + code: 5001, + msg: "temporary failure", + }) + .mockResolvedValueOnce({ + code: 0, + data: {}, + }); + + const lifecycle = createCommentTypingReactionLifecycle({ + cfg: {} as ClawdbotConfig, + fileToken: "doc_token_1", + fileType: "docx", + replyId: "reply_1", + runtime: { + log: vi.fn(), + } as never, + }); + + await lifecycle.start(); + await cleanupAmbientCommentTypingReaction({ + client: { request } as never, + deliveryContext: { + channel: "feishu", + to: "comment:docx:doc_token_1:comment_1", + threadId: "reply_1", + }, + }); + await lifecycle.cleanup(); + + expect(request).toHaveBeenCalledTimes(3); + expect(request).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + data: { + action: "delete", + reply_id: "reply_1", + reaction_type: "Typing", + }, + }), + ); + expect(request).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + data: { + action: "delete", + reply_id: "reply_1", + reaction_type: "Typing", + }, + }), + ); + }); +}); diff --git a/extensions/feishu/src/comment-reaction.ts b/extensions/feishu/src/comment-reaction.ts new file mode 100644 index 00000000000..3dd054de9eb --- /dev/null +++ b/extensions/feishu/src/comment-reaction.ts @@ -0,0 +1,281 @@ +import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js"; +import { resolveFeishuRuntimeAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { encodeQuery, isRecord, readString } from "./comment-shared.js"; +import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js"; + +const COMMENT_TYPING_REACTION_TYPE = "Typing"; +const COMMENT_REACTION_TIMEOUT_MS = 30_000; +const commentTypingReactionState = new Map< + string, + { + active: boolean; + cleaned: boolean; + cleanupPromise?: Promise; + } +>(); + +type FeishuCommentReactionClient = ReturnType & { + request(params: { + method: "POST"; + url: string; + data: unknown; + timeout: number; + }): Promise; +}; + +function buildCommentTypingReactionKey(params: { + fileToken: string; + fileType: CommentFileType; + replyId: string; +}): string { + return `${params.fileType}:${params.fileToken}:${params.replyId}`; +} + +function ensureCommentTypingReactionState(key: string) { + const existing = commentTypingReactionState.get(key); + if (existing) { + return existing; + } + const created = { + active: false, + cleaned: false, + cleanupPromise: undefined, + }; + commentTypingReactionState.set(key, created); + return created; +} + +async function requestCommentTypingReactionWithClient(params: { + client: FeishuCommentReactionClient; + fileToken: string; + fileType: CommentFileType; + replyId: string; + action: "add" | "delete"; + runtime?: RuntimeEnv; + logPrefix?: string; +}): Promise { + try { + const response = (await params.client.request({ + method: "POST", + url: + `/open-apis/drive/v2/files/${encodeURIComponent(params.fileToken)}/comments/reaction` + + encodeQuery({ + file_type: params.fileType, + }), + data: { + action: params.action, + reply_id: params.replyId, + reaction_type: COMMENT_TYPING_REACTION_TYPE, + }, + timeout: COMMENT_REACTION_TIMEOUT_MS, + })) as { + code?: number; + msg?: string; + log_id?: string; + error?: { log_id?: string }; + }; + if (response.code === 0) { + return true; + } + params.runtime?.log?.( + `${params.logPrefix ?? "[feishu]"}: comment typing reaction ${params.action} failed ` + + `reply=${params.replyId} file=${params.fileType}:${params.fileToken} ` + + `code=${response.code ?? "unknown"} msg=${response.msg ?? "unknown"} ` + + `log_id=${response.log_id ?? response.error?.log_id ?? "unknown"}`, + ); + } catch (error) { + params.runtime?.log?.( + `${params.logPrefix ?? "[feishu]"}: comment typing reaction ${params.action} threw ` + + `reply=${params.replyId} file=${params.fileType}:${params.fileToken} ` + + `error=${formatCommentReactionFailure(error)}`, + ); + } + return false; +} + +function formatCommentReactionFailure(error: unknown): string { + if (!isRecord(error)) { + return typeof error === "string" ? error : JSON.stringify(error); + } + const response = isRecord(error.response) ? error.response : undefined; + const responseData = isRecord(response?.data) ? response?.data : undefined; + return JSON.stringify({ + message: + typeof error.message === "string" + ? error.message + : typeof error === "string" + ? error + : JSON.stringify(error), + code: readString(error.code), + method: readString(isRecord(error.config) ? error.config.method : undefined), + url: readString(isRecord(error.config) ? error.config.url : undefined), + http_status: typeof response?.status === "number" ? response.status : undefined, + feishu_code: + typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code), + feishu_msg: readString(responseData?.msg), + feishu_log_id: + readString(responseData?.log_id) || + readString(isRecord(responseData?.error) ? responseData.error.log_id : undefined), + }); +} + +async function requestCommentTypingReaction(params: { + cfg: ClawdbotConfig; + fileToken: string; + fileType: CommentFileType; + replyId: string; + action: "add" | "delete"; + accountId?: string; + runtime?: RuntimeEnv; +}): Promise { + const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId }); + if (!account.configured || !(account.config.typingIndicator ?? true)) { + return false; + } + const client = createFeishuClient(account) as FeishuCommentReactionClient; + return requestCommentTypingReactionWithClient({ + client, + fileToken: params.fileToken, + fileType: params.fileType, + replyId: params.replyId, + action: params.action, + runtime: params.runtime, + logPrefix: `feishu[${account.accountId}]`, + }); +} + +async function cleanupCommentTypingReactionByKey(params: { + key: string; + performDelete: () => Promise; +}): Promise { + const state = ensureCommentTypingReactionState(params.key); + if (state.cleaned) { + return false; + } + if (state.cleanupPromise) { + return await state.cleanupPromise; + } + const cleanupPromise = (async (): Promise => { + if (!state.active) { + state.cleaned = true; + return false; + } + const deleted = await params.performDelete(); + if (deleted) { + state.cleaned = true; + state.active = false; + } + return deleted; + })(); + state.cleanupPromise = cleanupPromise; + try { + return await cleanupPromise; + } finally { + state.cleanupPromise = undefined; + if (state.cleaned) { + state.active = false; + commentTypingReactionState.delete(params.key); + } + } +} + +export async function cleanupAmbientCommentTypingReaction(params: { + client: FeishuCommentReactionClient; + deliveryContext?: { + channel?: string; + to?: string; + threadId?: string | number; + }; + runtime?: RuntimeEnv; +}): Promise { + const deliveryContext = params.deliveryContext; + if ( + deliveryContext?.channel && + deliveryContext.channel !== "feishu" && + deliveryContext.channel !== "feishu-comment" + ) { + return false; + } + const target = parseFeishuCommentTarget(deliveryContext?.to); + const replyId = + typeof deliveryContext?.threadId === "string" || typeof deliveryContext?.threadId === "number" + ? String(deliveryContext.threadId).trim() + : ""; + if (!target || !replyId) { + return false; + } + const key = buildCommentTypingReactionKey({ + fileToken: target.fileToken, + fileType: target.fileType, + replyId, + }); + return cleanupCommentTypingReactionByKey({ + key, + performDelete: () => + requestCommentTypingReactionWithClient({ + client: params.client, + fileToken: target.fileToken, + fileType: target.fileType, + replyId, + action: "delete", + runtime: params.runtime, + logPrefix: "[feishu]", + }), + }); +} + +export function createCommentTypingReactionLifecycle(params: { + cfg: ClawdbotConfig; + fileToken: string; + fileType: CommentFileType; + replyId?: string; + accountId?: string; + runtime?: RuntimeEnv; +}) { + const key = params.replyId?.trim() + ? buildCommentTypingReactionKey({ + fileToken: params.fileToken, + fileType: params.fileType, + replyId: params.replyId.trim(), + }) + : undefined; + const state = key ? ensureCommentTypingReactionState(key) : undefined; + + return { + start: async (): Promise => { + const replyId = params.replyId?.trim(); + if (!state || state.cleaned || state.active || !replyId) { + return; + } + state.active = await requestCommentTypingReaction({ + cfg: params.cfg, + fileToken: params.fileToken, + fileType: params.fileType, + replyId, + action: "add", + accountId: params.accountId, + runtime: params.runtime, + }); + }, + cleanup: async (): Promise => { + const replyId = params.replyId?.trim(); + if (!key || !replyId) { + return; + } + await cleanupCommentTypingReactionByKey({ + key, + performDelete: () => + requestCommentTypingReaction({ + cfg: params.cfg, + fileToken: params.fileToken, + fileType: params.fileType, + replyId, + action: "delete", + accountId: params.accountId, + runtime: params.runtime, + }), + }); + }, + }; +} diff --git a/extensions/feishu/src/comment-shared.test.ts b/extensions/feishu/src/comment-shared.test.ts new file mode 100644 index 00000000000..7e160cccc42 --- /dev/null +++ b/extensions/feishu/src/comment-shared.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { + parseCommentContentElements, + resolveCommentLinkedDocumentFromUrl, +} from "./comment-shared.js"; + +const VALID_TOKEN_22 = "ABCDEFGHIJKLMNOPQRSTUV"; +const VALID_TOKEN_27 = "ZsJfdxrBFo0RwuxteOLc1Ekvneb"; + +describe("resolveCommentLinkedDocumentFromUrl", () => { + it.each([ + { + label: "doc", + url: `https://example.test/doc/${VALID_TOKEN_22}`, + expectedKind: "doc", + expectedResolvedType: "doc", + expectedToken: VALID_TOKEN_22, + }, + { + label: "docs", + url: `https://example.test/docs/${VALID_TOKEN_22}`, + expectedKind: "doc", + expectedResolvedType: "doc", + expectedToken: VALID_TOKEN_22, + }, + { + label: "space/doc", + url: `https://example.test/space/doc/${VALID_TOKEN_22}`, + expectedKind: "doc", + expectedResolvedType: "doc", + expectedToken: VALID_TOKEN_22, + }, + { + label: "sheet", + url: `https://example.test/sheet/${VALID_TOKEN_22}`, + expectedKind: "sheet", + expectedResolvedType: "sheet", + expectedToken: VALID_TOKEN_22, + }, + { + label: "sheets", + url: `https://example.test/sheets/${VALID_TOKEN_22}`, + expectedKind: "sheet", + expectedResolvedType: "sheet", + expectedToken: VALID_TOKEN_22, + }, + { + label: "space/sheet", + url: `https://example.test/space/sheet/${VALID_TOKEN_22}`, + expectedKind: "sheet", + expectedResolvedType: "sheet", + expectedToken: VALID_TOKEN_22, + }, + { + label: "docx with hash", + url: `https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27}#share-Huggdiqveo5N7NxyA01ck4gLnHh`, + expectedKind: "docx", + expectedResolvedType: "docx", + expectedToken: VALID_TOKEN_27, + }, + { + label: "mindnote", + url: `https://example.test/mindnote/${VALID_TOKEN_22}`, + expectedKind: "mindnote", + expectedResolvedType: "mindnote", + expectedToken: VALID_TOKEN_22, + }, + { + label: "mindnotes", + url: `https://example.test/mindnotes/${VALID_TOKEN_22}`, + expectedKind: "mindnote", + expectedResolvedType: "mindnote", + expectedToken: VALID_TOKEN_22, + }, + { + label: "space/mindnote", + url: `https://example.test/space/mindnote/${VALID_TOKEN_22}`, + expectedKind: "mindnote", + expectedResolvedType: "mindnote", + expectedToken: VALID_TOKEN_22, + }, + { + label: "bitable", + url: `https://example.test/bitable/${VALID_TOKEN_22}?table=tbl_123`, + expectedKind: "bitable", + expectedResolvedType: "bitable", + expectedToken: VALID_TOKEN_22, + }, + { + label: "base", + url: `https://example.test/base/${VALID_TOKEN_22}`, + expectedKind: "base", + expectedResolvedType: "base", + expectedToken: VALID_TOKEN_22, + }, + { + label: "space/bitable", + url: `https://example.test/space/bitable/${VALID_TOKEN_22}`, + expectedKind: "bitable", + expectedResolvedType: "bitable", + expectedToken: VALID_TOKEN_22, + }, + { + label: "file", + url: `https://example.test/file/${VALID_TOKEN_22}`, + expectedKind: "file", + expectedResolvedType: "file", + expectedToken: VALID_TOKEN_22, + }, + { + label: "space/file", + url: `https://example.test/space/file/${VALID_TOKEN_22}`, + expectedKind: "file", + expectedResolvedType: "file", + expectedToken: VALID_TOKEN_22, + }, + { + label: "wiki", + url: `https://example.test/wiki/${VALID_TOKEN_22}`, + expectedKind: "wiki", + expectedResolvedType: undefined, + expectedToken: VALID_TOKEN_22, + }, + { + label: "space/wiki", + url: `https://example.test/space/wiki/${VALID_TOKEN_22}`, + expectedKind: "wiki", + expectedResolvedType: undefined, + expectedToken: VALID_TOKEN_22, + }, + ])("$label", ({ url, expectedKind, expectedResolvedType, expectedToken }) => { + const linked = resolveCommentLinkedDocumentFromUrl({ rawUrl: url }); + + expect(linked.urlKind).toBe(expectedKind); + expect(linked.resolvedObjType).toBe(expectedResolvedType); + expect(linked.resolvedObjToken ?? linked.wikiNodeToken).toBe(expectedToken); + }); + + it("does not resolve doc-like paths with short tokens", () => { + expect( + resolveCommentLinkedDocumentFromUrl({ + rawUrl: "https://www.baidu.com/docx/guide", + }), + ).toEqual({ + rawUrl: "https://www.baidu.com/docx/guide", + urlKind: "unknown", + }); + }); +}); + +describe("parseCommentContentElements", () => { + it("keeps raw external urls in text but excludes unresolved links from structured references", () => { + const parsed = parseCommentContentElements({ + elements: [ + { + type: "docs_link", + docs_link: { url: `https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27}` }, + }, + { + type: "text_run", + text_run: { text: " 和 " }, + }, + { + type: "docs_link", + docs_link: { url: "https://www.baidu.com/docx/guide" }, + }, + ], + }); + + expect(parsed.plainText).toBe( + `https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27} 和 https://www.baidu.com/docx/guide`, + ); + expect(parsed.linkedDocuments).toEqual([ + expect.objectContaining({ + rawUrl: `https://bytedance.larkoffice.com/docx/${VALID_TOKEN_27}`, + urlKind: "docx", + resolvedObjType: "docx", + resolvedObjToken: VALID_TOKEN_27, + }), + ]); + }); +}); diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index d2139aab9de..5b210af640a 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -5,6 +5,7 @@ import { normalizeOptionalString, readStringValue, } from "openclaw/plugin-sdk/text-runtime"; +import { FEISHU_COMMENT_FILE_TYPES, type CommentFileType } from "./comment-target.js"; export function encodeQuery(params: Record): string { const query = new URLSearchParams(); @@ -28,51 +29,309 @@ export const asRecord = asOptionalRecord; export const hasNonEmptyString = sharedHasNonEmptyString; -export function extractCommentElementText(element: unknown): string | undefined { - if (!isRecord(element)) { - return undefined; - } - const type = normalizeString(element.type); - if (type === "text_run" && isRecord(element.text_run)) { - return normalizeString(element.text_run.content) || normalizeString(element.text_run.text); - } - if (type === "mention") { - const mention = isRecord(element.mention) ? element.mention : undefined; - const mentionName = - normalizeString(mention?.name) || - normalizeString(mention?.display_name) || - normalizeString(element.name); - return mentionName ? `@${mentionName}` : "@mention"; - } - if (type === "docs_link") { - const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined; - return ( - normalizeString(docsLink?.text) || - normalizeString(docsLink?.url) || - normalizeString(element.text) || - normalizeString(element.url) || - undefined - ); - } +export type ParsedCommentDocumentRef = { + fileType?: CommentFileType; + fileToken?: string; +}; + +export type ParsedCommentMention = { + userId: string; + displayText: string; + isBotMention: boolean; +}; + +export type ParsedCommentLinkedDocumentKind = + | CommentFileType + | "wiki" + | "mindnote" + | "bitable" + | "base" + | "unknown"; + +export type ParsedCommentResolvedDocumentType = Exclude< + ParsedCommentLinkedDocumentKind, + "wiki" | "unknown" +>; + +export type ParsedCommentLinkedDocument = { + rawUrl: string; + urlKind: ParsedCommentLinkedDocumentKind; + wikiNodeToken?: string; + resolvedObjType?: ParsedCommentResolvedDocumentType; + resolvedObjToken?: string; + isCurrentDocument?: boolean; +}; + +export type ParsedCommentContent = { + plainText?: string; + semanticText?: string; + mentions: ParsedCommentMention[]; + linkedDocuments: ParsedCommentLinkedDocument[]; + botMentioned: boolean; +}; + +function readDocsLinkUrl(element: Record): string | undefined { + const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined; return ( - normalizeString(element.text) || - normalizeString(element.content) || - normalizeString(element.name) || + normalizeString(docsLink?.url) || + normalizeString(docsLink?.link) || + normalizeString(element.url) || + normalizeString(element.link) || undefined ); } +function readMentionUserId(element: Record): string | undefined { + const mention = isRecord(element.mention) ? element.mention : undefined; + const person = isRecord(element.person) ? element.person : undefined; + return ( + normalizeString(person?.user_id) || + normalizeString(mention?.user_id) || + normalizeString(mention?.open_id) || + normalizeString(element.mention_user) || + normalizeString(element.user_id) || + undefined + ); +} + +function readMentionDisplayText(element: Record, userId: string): string { + const mention = isRecord(element.mention) ? element.mention : undefined; + const mentionName = + normalizeString(mention?.name) || + normalizeString(mention?.display_name) || + normalizeString(element.name); + return mentionName ? `@${mentionName}` : `@${userId}`; +} + +function normalizeCommentText(parts: string[]): string | undefined { + const text = parts.join("").trim(); + return text || undefined; +} + +function normalizeCommentSemanticText(parts: string[]): string | undefined { + const text = parts.join("").replace(/\s+/g, " ").trim(); + return text || undefined; +} + +function readElementTextPreservingWhitespace(element: Record): string | undefined { + return ( + (isRecord(element.text_run) + ? readString(element.text_run.content) || readString(element.text_run.text) + : undefined) || + readString(element.text) || + readString(element.content) || + readString(element.name) || + undefined + ); +} + +const FEISHU_LINK_TOKEN_MIN_LENGTH = 22; +const FEISHU_LINK_TOKEN_MAX_LENGTH = 28; +const COMMENT_LINK_KIND_ALIASES = new Map([ + ["doc", "doc"], + ["docs", "doc"], + ["docx", "docx"], + ["sheet", "sheet"], + ["sheets", "sheet"], + ["slide", "slides"], + ["slides", "slides"], + ["file", "file"], + ["files", "file"], + ["wiki", "wiki"], + ["mindnote", "mindnote"], + ["mindnotes", "mindnote"], + ["bitable", "bitable"], + ["base", "base"], +]); + +function isCommentFileType( + value: ParsedCommentResolvedDocumentType | "wiki" | undefined, +): value is CommentFileType { + return ( + typeof value === "string" && (FEISHU_COMMENT_FILE_TYPES as readonly string[]).includes(value) + ); +} + +function isReasonableFeishuLinkToken(token: string | undefined): token is string { + return ( + typeof token === "string" && + token.length >= FEISHU_LINK_TOKEN_MIN_LENGTH && + token.length <= FEISHU_LINK_TOKEN_MAX_LENGTH + ); +} + +function parseCommentLinkedDocumentPath(pathname: string): { + urlKind: ParsedCommentResolvedDocumentType | "wiki"; + token: string; +} | null { + const segments = pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + const offset = segments[0]?.toLowerCase() === "space" ? 1 : 0; + const kind = COMMENT_LINK_KIND_ALIASES.get(segments[offset]?.toLowerCase() ?? ""); + const token = normalizeString(segments[offset + 1]); + if (!kind || !isReasonableFeishuLinkToken(token)) { + return null; + } + return { urlKind: kind, token }; +} + +function hasResolvedLinkedDocumentReference(link: ParsedCommentLinkedDocument): boolean { + return ( + link.urlKind !== "unknown" && (Boolean(link.resolvedObjToken) || Boolean(link.wikiNodeToken)) + ); +} + +export function resolveCommentLinkedDocumentFromUrl(params: { + rawUrl: string; + currentDocument?: ParsedCommentDocumentRef; +}): ParsedCommentLinkedDocument { + const link: ParsedCommentLinkedDocument = { + rawUrl: params.rawUrl, + urlKind: "unknown", + }; + try { + const parsed = new URL(params.rawUrl); + const parsedPath = parseCommentLinkedDocumentPath(parsed.pathname); + if (!parsedPath) { + return link; + } + const { urlKind, token } = parsedPath; + link.urlKind = urlKind; + if (urlKind === "wiki") { + link.urlKind = "wiki"; + link.wikiNodeToken = token; + } else { + link.resolvedObjType = urlKind; + link.resolvedObjToken = token; + } + if ( + link.resolvedObjType && + link.resolvedObjToken && + isCommentFileType(link.resolvedObjType) && + params.currentDocument?.fileType === link.resolvedObjType && + params.currentDocument.fileToken === link.resolvedObjToken + ) { + link.isCurrentDocument = true; + } else if ( + link.resolvedObjType && + link.resolvedObjToken && + isCommentFileType(link.resolvedObjType) + ) { + link.isCurrentDocument = false; + } + } catch { + return link; + } + return link; +} + +export function parseCommentContentElements(params: { + elements?: unknown[]; + botOpenIds?: Iterable; + currentDocument?: ParsedCommentDocumentRef; +}): ParsedCommentContent { + const elements = Array.isArray(params.elements) ? params.elements : []; + const plainTextParts: string[] = []; + const semanticTextParts: string[] = []; + const mentions: ParsedCommentMention[] = []; + const linkedDocuments: ParsedCommentLinkedDocument[] = []; + const botIds = new Set( + Array.from(params.botOpenIds ?? []) + .map((value) => normalizeString(value)) + .filter((value): value is string => Boolean(value)), + ); + const linkedDocumentKeys = new Set(); + let botMentioned = false; + + for (const rawElement of elements) { + if (!isRecord(rawElement)) { + continue; + } + const element = rawElement; + const type = normalizeString(element.type); + const text = + (type === "text_run" ? readElementTextPreservingWhitespace(element) : undefined) || + (type === "text" ? readElementTextPreservingWhitespace(element) : undefined) || + (type === "docs_link" || type === "link" ? readDocsLinkUrl(element) : undefined) || + (type === "mention" || type === "mention_user" || type === "person" + ? (() => { + const userId = readMentionUserId(element); + return userId ? readMentionDisplayText(element, userId) : undefined; + })() + : undefined) || + readElementTextPreservingWhitespace(element) || + undefined; + + if (type === "mention" || type === "mention_user" || type === "person") { + const userId = readMentionUserId(element); + if (userId) { + const displayText = readMentionDisplayText(element, userId); + const isBotMention = botIds.has(userId); + mentions.push({ userId, displayText, isBotMention }); + plainTextParts.push(displayText); + if (!isBotMention) { + semanticTextParts.push(displayText); + } else { + botMentioned = true; + } + continue; + } + } + + if (type === "docs_link" || type === "link") { + const rawUrl = readDocsLinkUrl(element); + if (rawUrl) { + plainTextParts.push(rawUrl); + semanticTextParts.push(rawUrl); + const linkedDocument = resolveCommentLinkedDocumentFromUrl({ + rawUrl, + currentDocument: params.currentDocument, + }); + if (hasResolvedLinkedDocumentReference(linkedDocument)) { + const key = [ + linkedDocument.rawUrl, + linkedDocument.urlKind, + linkedDocument.resolvedObjType, + linkedDocument.resolvedObjToken, + linkedDocument.wikiNodeToken, + ].join(":"); + if (!linkedDocumentKeys.has(key)) { + linkedDocumentKeys.add(key); + linkedDocuments.push(linkedDocument); + } + } + continue; + } + } + + if (text) { + plainTextParts.push(text); + semanticTextParts.push(text); + } + } + + return { + plainText: normalizeCommentText(plainTextParts), + semanticText: normalizeCommentSemanticText(semanticTextParts), + mentions, + linkedDocuments, + botMentioned, + }; +} + +export function extractCommentElementText(element: unknown): string | undefined { + return parseCommentContentElements({ elements: [element] }).plainText; +} + export function extractReplyText( reply: { content?: { elements?: unknown[] } } | 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; + return parseCommentContentElements({ + elements: Array.isArray(reply.content.elements) ? reply.content.elements : [], + }).plainText; } diff --git a/extensions/feishu/src/drive.test.ts b/extensions/feishu/src/drive.test.ts index 56415f48a47..8933fafab03 100644 --- a/extensions/feishu/src/drive.test.ts +++ b/extensions/feishu/src/drive.test.ts @@ -4,12 +4,17 @@ import type { OpenClawPluginApi, PluginRuntime } from "../runtime-api.js"; const createFeishuToolClientMock = vi.hoisted(() => vi.fn()); const resolveAnyEnabledFeishuToolsConfigMock = vi.hoisted(() => vi.fn()); +const cleanupAmbientCommentTypingReactionMock = vi.hoisted(() => vi.fn(async () => false)); vi.mock("./tool-account.js", () => ({ createFeishuToolClient: createFeishuToolClientMock, resolveAnyEnabledFeishuToolsConfig: resolveAnyEnabledFeishuToolsConfigMock, })); +vi.mock("./comment-reaction.js", () => ({ + cleanupAmbientCommentTypingReaction: cleanupAmbientCommentTypingReactionMock, +})); + let registerFeishuDriveTools: typeof import("./drive.js").registerFeishuDriveTools; function createFeishuToolRuntime(): PluginRuntime { @@ -51,6 +56,7 @@ describe("registerFeishuDriveTools", () => { createFeishuToolClientMock.mockReturnValue({ request: requestMock, }); + cleanupAmbientCommentTypingReactionMock.mockResolvedValue(false); }); it("registers feishu_drive and handles comment actions", async () => { @@ -491,7 +497,7 @@ describe("registerFeishuDriveTools", () => { ); }); - it("defaults reply_comment target fields from the ambient Feishu comment delivery context", async () => { + it("does not wait for ambient typing cleanup before reply_comment sends visible output", async () => { const registerTool = vi.fn(); registerFeishuDriveTools( createDriveToolApi({ @@ -515,6 +521,7 @@ describe("registerFeishuDriveTools", () => { deliveryContext: { channel: "feishu", to: "comment:docx:doc_1:c1", + threadId: "reply_ambient_1", }, }); @@ -530,11 +537,24 @@ describe("registerFeishuDriveTools", () => { data: { reply_id: "r6" }, }); - const replyCommentResult = await tool.execute("call-ambient", { + let resolveCleanup: ((value: boolean) => void) | undefined; + cleanupAmbientCommentTypingReactionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCleanup = resolve; + }), + ); + + const replyCommentPromise = tool.execute("call-ambient", { action: "reply_comment", content: "ambient success", }); + const status = await Promise.race([ + replyCommentPromise.then(() => "done"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + expect(status).toBe("done"); expect(requestMock).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -565,9 +585,97 @@ describe("registerFeishuDriveTools", () => { }, }), ); + expect(cleanupAmbientCommentTypingReactionMock).toHaveBeenCalledWith({ + client: expect.anything(), + deliveryContext: { + channel: "feishu", + to: "comment:docx:doc_1:c1", + threadId: "reply_ambient_1", + }, + }); + const replyCommentResult = await replyCommentPromise; expect(replyCommentResult.details).toEqual( expect.objectContaining({ success: true, reply_id: "r6" }), ); + + resolveCleanup?.(false); + }); + + it("does not wait for ambient typing cleanup before add_comment sends visible output", 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, + deliveryContext: { + channel: "feishu", + to: "comment:docx:doc_1:c1", + threadId: "reply_ambient_1", + }, + }); + + requestMock.mockResolvedValueOnce({ + code: 0, + data: { comment_id: "c_add" }, + }); + + let resolveCleanup: ((value: boolean) => void) | undefined; + cleanupAmbientCommentTypingReactionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCleanup = resolve; + }), + ); + + const addCommentPromise = tool.execute("call-add-ambient", { + action: "add_comment", + content: "ambient top-level comment", + }); + const status = await Promise.race([ + addCommentPromise.then(() => "done"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + + expect(status).toBe("done"); + expect(requestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/open-apis/drive/v1/files/doc_1/new_comments", + data: { + file_type: "docx", + reply_elements: [{ type: "text", text: "ambient top-level comment" }], + }, + }), + ); + expect(cleanupAmbientCommentTypingReactionMock).toHaveBeenCalledWith({ + client: expect.anything(), + deliveryContext: { + channel: "feishu", + to: "comment:docx:doc_1:c1", + threadId: "reply_ambient_1", + }, + }); + const addCommentResult = await addCommentPromise; + expect(addCommentResult.details).toEqual( + expect.objectContaining({ success: true, comment_id: "c_add" }), + ); + + resolveCleanup?.(false); }); it("does not inherit non-doc ambient file types for add_comment", async () => { diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index 24f8253adba..9f73a0c67a6 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -2,6 +2,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; +import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js"; import { encodeQuery, extractReplyText, isRecord, readString } from "./comment-shared.js"; import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; @@ -104,6 +105,7 @@ type FeishuDriveToolContext = { deliveryContext?: { channel?: string; to?: string; + threadId?: string | number; }; }; @@ -808,14 +810,28 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) { } case "add_comment": { const resolved = applyAddCommentDefaults(applyAddCommentAmbientDefaults(p, ctx)); - return jsonToolResult(await addComment(client, resolved)); + try { + return jsonToolResult(await addComment(client, resolved)); + } finally { + void cleanupAmbientCommentTypingReaction({ + client: getDriveInternalClient(client), + deliveryContext: ctx.deliveryContext, + }); + } } case "reply_comment": { const resolved = applyCommentFileTypeDefault( applyAmbientCommentDefaults(p, ctx), "reply_comment", ); - return jsonToolResult(await deliverCommentThreadText(client, resolved)); + try { + return jsonToolResult(await deliverCommentThreadText(client, resolved)); + } finally { + void cleanupAmbientCommentTypingReaction({ + client: getDriveInternalClient(client), + deliveryContext: ctx.deliveryContext, + }); + } } 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 d24ed1ed03f..c3a012012f0 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -292,6 +292,16 @@ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEven }; } +function buildCommentNoticeQueueKey(event: { + notice_meta?: { + file_type?: string; + file_token?: string; + }; +}): string { + const fileType = event.notice_meta?.file_type?.trim() || "unknown"; + const fileToken = event.notice_meta?.file_token?.trim() || "unknown"; + return `comment-doc:${fileType}:${fileToken}`; +} function mergeFeishuDebounceMentions( entries: FeishuMessageEvent[], ): FeishuMessageEvent["message"]["mentions"] | undefined { @@ -619,12 +629,14 @@ function registerEventHandlers( `mentioned=${event.is_mentioned === true ? "yes" : "no"}`, ); try { - await handleFeishuCommentEvent({ - cfg, - accountId, - event, - botOpenId: botOpenIds.get(accountId), - runtime, + await enqueue(buildCommentNoticeQueueKey(event), async () => { + await handleFeishuCommentEvent({ + cfg, + accountId, + event, + botOpenId: botOpenIds.get(accountId), + runtime, + }); }); if (syntheticMessageId) { await recordProcessedFeishuMessage(syntheticMessageId, accountId, log); diff --git a/extensions/feishu/src/monitor.comment.test.ts b/extensions/feishu/src/monitor.comment.test.ts index 3f91816c1b3..faa179096aa 100644 --- a/extensions/feishu/src/monitor.comment.test.ts +++ b/extensions/feishu/src/monitor.comment.test.ts @@ -23,7 +23,8 @@ 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"; +const TEST_DOC_TOKEN = "ZsJfdxrBFo0RwuxteOLc1Ekvneb"; +const TEST_WIKI_TOKEN = "OtYpd5pKOoMeQzxrzkocv9KIn4H"; vi.mock("./client.js", () => ({ createEventDispatcher: createEventDispatcherMock, @@ -287,19 +288,127 @@ describe("resolveDriveCommentEventTurn", () => { 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".'); 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', + 'Current user comment text: "Also send it to the agent after receiving the comment event"', ); - expect(turn?.prompt).toContain( - "This is a Feishu document comment-thread event, not a Feishu IM conversation.", - ); - expect(turn?.prompt).toContain("Prefer plain text suitable for a comment thread."); - expect(turn?.prompt).toContain("Do not include internal reasoning"); - expect(turn?.prompt).toContain("Do not narrate your plan or execution process"); - expect(turn?.prompt).toContain("reply only with the user-facing result itself"); + expect(turn?.prompt).toContain("Current comment card timeline (primary context"); + expect(turn?.prompt).toContain("This is a Feishu document comment thread."); + expect(turn?.prompt).toContain("It is not a Feishu IM chat."); + expect(turn?.prompt).toContain("Use plain text only."); + expect(turn?.prompt).toContain("Do not show reasoning."); + expect(turn?.prompt).toContain("Do not describe your plan."); + expect(turn?.prompt).toContain("Output only the final user-facing reply."); 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"); + expect(turn?.prompt).toContain( + "Your final text reply will be posted to the current comment thread automatically.", + ); + }); + + it("parses bot mentions plus current and referenced document links from comment content", async () => { + const wikiGetNode = vi.fn(async () => ({ + code: 0, + data: { + node: { + obj_type: "docx", + obj_token: "doc_ref_1", + }, + }, + })); + const client = { + 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: "Comment event handling request", + url: `https://www.larksuite.com/docx/${TEST_DOC_TOKEN}`, + }, + ], + }, + }; + } + if (request.url.includes("/comments/batch_query")) { + return { + code: 0, + data: { + items: [ + { + comment_id: "7623358762119646411", + is_whole: false, + reply_list: { + replies: [ + { + reply_id: "7623358762136374451", + user_id: "ou_509d4d7ace4a9addec2312676ffcba9b", + content: { + elements: [ + { type: "text_run", text_run: { text: "请 " } }, + { type: "person", person: { user_id: "ou_bot" } }, + { type: "text_run", text_run: { text: " 总结下 " } }, + { + type: "docs_link", + docs_link: { + url: `https://www.larksuite.com/docx/${TEST_DOC_TOKEN}`, + }, + }, + { type: "text_run", text_run: { text: " 和 " } }, + { + type: "docs_link", + docs_link: { + url: `https://www.larksuite.com/wiki/${TEST_WIKI_TOKEN}`, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + } + throw new Error(`unexpected request: ${request.method} ${request.url}`); + }), + wiki: { + space: { + getNode: wikiGetNode, + }, + }, + }; + + const turn = await resolveDriveCommentEventTurn({ + cfg: buildMonitorConfig(), + accountId: "default", + event: makeDriveCommentEvent(), + botOpenId: "ou_bot", + createClient: () => client as never, + }); + + expect(turn?.targetReplyText).toBe( + `请 总结下 https://www.larksuite.com/docx/${TEST_DOC_TOKEN} 和 https://www.larksuite.com/wiki/${TEST_WIKI_TOKEN}`, + ); + expect(turn?.prompt).toContain("Bot routing mention detected in the current user comment."); + expect(turn?.prompt).toContain("Referenced documents from current user comment:"); + expect(turn?.prompt).toContain( + `raw_url=https://www.larksuite.com/docx/${TEST_DOC_TOKEN} url_kind=docx`, + ); + expect(turn?.prompt).toContain("same_as_current_document=yes"); + expect(turn?.prompt).toContain( + `raw_url=https://www.larksuite.com/wiki/${TEST_WIKI_TOKEN} url_kind=wiki ` + + `wiki_node_token=${TEST_WIKI_TOKEN} resolved_type=docx ` + + "resolved_token=doc_ref_1 same_as_current_document=no", + ); + expect(wikiGetNode).toHaveBeenCalledWith({ + params: { + token: TEST_WIKI_TOKEN, + }, + }); }); it("preserves whole-document comment metadata for downstream delivery mode selection", async () => { @@ -321,6 +430,277 @@ describe("resolveDriveCommentEventTurn", () => { expect(turn?.prompt).toContain("Whole-document comments do not support direct replies."); }); + it("builds a whole-comment timeline and highlights the nearest bot-authored follow-up", async () => { + const client = { + 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: "Comment event handling request", + url: `https://www.larksuite.com/docx/${TEST_DOC_TOKEN}`, + }, + ], + }, + }; + } + if (request.url.includes("/comments/batch_query")) { + return { + code: 0, + data: { + items: [ + { + comment_id: "7623358762119646411", + is_whole: true, + reply_list: { + replies: [ + { + reply_id: "7623358762136374451", + user_id: "ou_509d4d7ace4a9addec2312676ffcba9b", + create_time: 1775531531, + content: { + elements: [ + { + type: "text_run", + text_run: { + text: "请帮我总结这个文档", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + } + if (request.url.includes("/comments?file_type=docx&is_whole=true")) { + return { + code: 0, + data: { + has_more: false, + items: [ + { + comment_id: "7623358762119646411", + create_time: 1775531531, + user_id: "ou_509d4d7ace4a9addec2312676ffcba9b", + is_whole: true, + reply_list: { + replies: [ + { + reply_id: "reply_a", + user_id: "ou_509d4d7ace4a9addec2312676ffcba9b", + create_time: 1775531531, + content: { + elements: [ + { + type: "text_run", + text_run: { + text: "请帮我总结这个文档", + }, + }, + ], + }, + }, + ], + }, + }, + { + comment_id: "comment_bot_followup", + create_time: 1775531540, + user_id: "ou_bot", + is_whole: true, + reply_list: { + replies: [ + { + reply_id: "reply_b", + user_id: "ou_bot", + create_time: 1775531540, + content: { + elements: [ + { + type: "text_run", + text_run: { + text: "这是刚才的总结结果", + }, + }, + ], + }, + }, + ], + }, + }, + { + comment_id: "comment_other_user", + create_time: 1775531550, + user_id: "ou_other", + is_whole: true, + reply_list: { + replies: [ + { + reply_id: "reply_c", + user_id: "ou_other", + create_time: 1775531550, + content: { + elements: [ + { + type: "text_run", + text_run: { + text: "另一个 whole comment", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + } + throw new Error(`unexpected request: ${request.method} ${request.url}`); + }), + wiki: { + space: { + getNode: vi.fn(async () => ({ code: 0, data: { node: {} } })), + }, + }, + }; + + const turn = await resolveDriveCommentEventTurn({ + cfg: buildMonitorConfig(), + accountId: "default", + event: makeDriveCommentEvent(), + botOpenId: "ou_bot", + createClient: () => client as never, + }); + + expect(turn?.isWholeComment).toBe(true); + expect(turn?.prompt).toContain( + "Whole-document comment timeline (primary context for whole-comment follow-ups):", + ); + expect(turn?.prompt).toContain("comment_id=7623358762119646411"); + expect(turn?.prompt).toContain("comment_id=comment_bot_followup"); + expect(turn?.prompt).toContain( + 'Nearest bot-authored whole-comment after the current comment: comment_id=comment_bot_followup text="这是刚才的总结结果"', + ); + expect(turn?.prompt).toContain("Document-level session history is auxiliary background only."); + }); + + it("treats replies with missing user_id as user-authored even when bot id hints are missing", async () => { + const client = { + 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: "Comment event handling request", + url: `https://www.larksuite.com/docx/${TEST_DOC_TOKEN}`, + }, + ], + }, + }; + } + if (request.url.includes("/comments/batch_query")) { + return { + code: 0, + data: { + items: [ + { + comment_id: "7623358762119646411", + is_whole: true, + reply_list: { + replies: [ + { + reply_id: "reply_missing_user", + create_time: 1775531531, + content: { + elements: [ + { + type: "text_run", + text_run: { + text: "reply without user id", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + } + if (request.url.includes("/comments?file_type=docx&is_whole=true")) { + return { + code: 0, + data: { + has_more: false, + items: [ + { + comment_id: "7623358762119646411", + create_time: 1775531531, + is_whole: true, + reply_list: { + replies: [ + { + reply_id: "reply_missing_user", + create_time: 1775531531, + content: { + elements: [ + { + type: "text_run", + text_run: { + text: "reply without user id", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + } + throw new Error(`unexpected request: ${request.method} ${request.url}`); + }), + wiki: { + space: { + getNode: vi.fn(async () => ({ code: 0, data: { node: {} } })), + }, + }, + }; + + const turn = await resolveDriveCommentEventTurn({ + cfg: buildMonitorConfig(), + accountId: "default", + event: makeDriveCommentEvent({ + reply_id: "reply_missing_user", + }), + botOpenId: "ou_bot", + createClient: () => client as never, + }); + + expect(turn?.prompt).toContain( + "comment_id=7623358762119646411 author=user user_id=UNKNOWN current_comment=yes", + ); + expect(turn?.prompt).not.toContain( + "author=assistant user_id=UNKNOWN reply_id=reply_missing_user", + ); + }); + it("does not trust whole-comment metadata from a mismatched batch_query item", async () => { const client = makeOpenApiClient({ includeTargetReplyInBatch: true, @@ -383,11 +763,10 @@ describe("resolveDriveCommentEventTurn", () => { createClient: () => client as never, }); + expect(turn?.prompt).toContain('The user added a reply in "Comment event handling request".'); + expect(turn?.prompt).toContain('Current user comment text: "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(turn?.prompt).toContain( - "Original comment: Also send it to the agent after receiving the comment event", + 'Original comment text: "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"); @@ -525,6 +904,52 @@ describe("drive.notice.comment_add_v1 monitor handler", () => { ); }); + it("serializes same-document comment notices before invoking handleFeishuCommentEvent", async () => { + const onComment = await setupCommentMonitorHandler(); + let resolveFirst: (() => void) | undefined; + handleFeishuCommentEventMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockImplementationOnce(async () => {}); + + await onComment( + makeDriveCommentEvent({ + event_id: "evt_1", + reply_id: "reply_1", + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + await onComment( + makeDriveCommentEvent({ + event_id: "evt_2", + reply_id: "reply_2", + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(handleFeishuCommentEventMock).toHaveBeenCalledTimes(1); + + resolveFirst?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(handleFeishuCommentEventMock).toHaveBeenCalledTimes(2); + const firstCallArgs = handleFeishuCommentEventMock.mock.calls.at(0) as + | [{ event?: { event_id?: string } }] + | undefined; + const secondCallArgs = handleFeishuCommentEventMock.mock.calls.at(1) as + | [{ event?: { event_id?: string } }] + | undefined; + const firstCall = firstCallArgs?.[0]; + const secondCall = secondCallArgs?.[0]; + expect(firstCall?.event?.event_id).toBe("evt_1"); + expect(secondCall?.event?.event_id).toBe("evt_2"); + }); + it("drops duplicate comment events before dispatch", async () => { vi.spyOn(dedup, "hasProcessedFeishuMessage").mockResolvedValue(true); const onComment = await setupCommentMonitorHandler(); diff --git a/extensions/feishu/src/monitor.comment.ts b/extensions/feishu/src/monitor.comment.ts index b5b9ec3dd02..76da23c4ea0 100644 --- a/extensions/feishu/src/monitor.comment.ts +++ b/extensions/feishu/src/monitor.comment.ts @@ -8,16 +8,24 @@ import { extractReplyText, isRecord, normalizeString, + parseCommentContentElements, + type ParsedCommentContent, + type ParsedCommentLinkedDocument, readString, } from "./comment-shared.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_LIST_PAGE_SIZE = 100; +const FEISHU_COMMENT_LIST_PAGE_LIMIT = 5; const FEISHU_COMMENT_REPLY_PAGE_SIZE = 100; const FEISHU_COMMENT_REPLY_PAGE_LIMIT = 5; const FEISHU_COMMENT_REPLY_MISS_RETRY_DELAY_MS = 1_000; const FEISHU_COMMENT_REPLY_MISS_RETRY_LIMIT = 6; +const FEISHU_COMMENT_THREAD_PROMPT_LIMIT = 20; +const FEISHU_WHOLE_COMMENT_PROMPT_LIMIT = 12; +const FEISHU_PROMPT_TEXT_LIMIT = 220; type FeishuDriveCommentUserId = { open_id?: string; @@ -100,6 +108,9 @@ type FeishuDriveMetaBatchQueryResponse = FeishuOpenApiResponse<{ type FeishuDriveCommentReply = { reply_id?: string; + user_id?: string; + create_time?: number; + update_time?: number; content?: { elements?: unknown[]; }; @@ -107,7 +118,12 @@ type FeishuDriveCommentReply = { type FeishuDriveCommentCard = { comment_id?: string; + user_id?: string; + create_time?: number; + update_time?: number; is_whole?: boolean; + has_more?: boolean; + page_token?: string; quote?: string; reply_list?: { replies?: FeishuDriveCommentReply[]; @@ -118,12 +134,35 @@ type FeishuDriveCommentBatchQueryResponse = FeishuOpenApiResponse<{ items?: FeishuDriveCommentCard[]; }>; +type FeishuDriveCommentListResponse = FeishuOpenApiResponse<{ + has_more?: boolean; + items?: FeishuDriveCommentCard[]; + page_token?: string; +}>; + type FeishuDriveCommentRepliesListResponse = FeishuOpenApiResponse<{ has_more?: boolean; items?: FeishuDriveCommentReply[]; page_token?: string; }>; +type ResolvedCommentReplyContext = { + replyId?: string; + userId?: string; + createTime?: number; + isBotAuthored: boolean; + content: ParsedCommentContent; +}; + +type ResolvedWholeCommentTimelineEntry = { + commentId: string; + userId?: string; + createTime?: number; + isCurrentComment: boolean; + isBotAuthored: boolean; + content: ParsedCommentContent; +}; + function readBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } @@ -138,6 +177,96 @@ function safeJsonStringify(value: unknown): string { } } +function truncatePromptText( + text: string | undefined, + maxLength = FEISHU_PROMPT_TEXT_LIMIT, +): string { + const normalized = normalizeString(text); + if (!normalized) { + return ""; + } + return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 1)}…` : normalized; +} + +function formatPromptTextValue(text: string | undefined): string { + return safeJsonStringify(truncatePromptText(text) || ""); +} + +function formatPromptBoolean(value: boolean | undefined): string { + return value === true ? "yes" : "no"; +} + +function buildDriveCommentsListUrl(params: { + fileToken: string; + fileType: CommentFileType; + pageToken?: string; + isWholeOnly?: boolean; +}): string { + return ( + `/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments` + + encodeQuery({ + file_type: params.fileType, + is_whole: params.isWholeOnly === true ? "true" : undefined, + page_size: String(FEISHU_COMMENT_LIST_PAGE_SIZE), + page_token: params.pageToken, + user_id_type: "open_id", + }) + ); +} + +function compareCommentTimelineEntries( + left: { createTime?: number; stableId?: string }, + right: { createTime?: number; stableId?: string }, +): number { + const leftTime = left.createTime ?? Number.MAX_SAFE_INTEGER; + const rightTime = right.createTime ?? Number.MAX_SAFE_INTEGER; + if (leftTime !== rightTime) { + return leftTime - rightTime; + } + return (left.stableId ?? "").localeCompare(right.stableId ?? ""); +} + +function formatLinkedDocumentInline(link: ParsedCommentLinkedDocument): string { + const parts = [ + `raw_url=${link.rawUrl}`, + `url_kind=${link.urlKind}`, + link.wikiNodeToken ? `wiki_node_token=${link.wikiNodeToken}` : null, + `resolved_type=${link.resolvedObjType ?? "UNKNOWN"}`, + `resolved_token=${link.resolvedObjToken ?? "UNKNOWN"}`, + `same_as_current_document=${formatPromptBoolean(link.isCurrentDocument)}`, + ].filter((part): part is string => Boolean(part)); + return parts.join(" "); +} + +function formatLinkedDocumentsPromptLines(params: { + title: string; + linkedDocuments: ParsedCommentLinkedDocument[]; +}): string[] { + if (params.linkedDocuments.length === 0) { + return []; + } + return [ + params.title, + ...params.linkedDocuments.map( + (link, index) => `- [${index + 1}] ${formatLinkedDocumentInline(link)}`, + ), + ]; +} + +function formatLinkedDocumentsInlineSummary( + linkedDocuments: ParsedCommentLinkedDocument[], +): string { + if (linkedDocuments.length === 0) { + return "none"; + } + return linkedDocuments + .map( + (link) => + `${link.resolvedObjType ?? link.urlKind}:${link.resolvedObjToken ?? link.wikiNodeToken ?? "UNKNOWN"}`, + ) + .join(","); +} + function summarizeCommentRepliesForLog(replies: FeishuDriveCommentReply[]): string { return safeJsonStringify( replies.map((reply) => ({ @@ -147,6 +276,93 @@ function summarizeCommentRepliesForLog(replies: FeishuDriveCommentReply[]): stri ); } +async function resolveParsedCommentContent(params: { + elements?: unknown[]; + botOpenIds?: Iterable; + currentDocument: { + fileType: CommentFileType; + fileToken: string; + }; + client: FeishuRequestClient; + wikiCache: Map< + string, + Promise<{ + resolvedObjType?: CommentFileType; + resolvedObjToken?: string; + } | null> + >; + logger?: (message: string) => void; + accountId: string; +}): Promise { + const parsed = parseCommentContentElements({ + elements: params.elements, + botOpenIds: params.botOpenIds, + currentDocument: params.currentDocument, + }); + if (!parsed.linkedDocuments.some((link) => link.urlKind === "wiki" && link.wikiNodeToken)) { + return parsed; + } + + const resolvedLinkedDocuments = await Promise.all( + parsed.linkedDocuments.map(async (link) => { + if (link.urlKind !== "wiki" || !link.wikiNodeToken) { + return link; + } + let pending = params.wikiCache.get(link.wikiNodeToken); + if (!pending) { + pending = params.client.wiki.space + .getNode({ + params: { + token: link.wikiNodeToken, + }, + }) + .then((response) => { + if (response.code !== 0) { + params.logger?.( + `feishu[${params.accountId}]: wiki link resolution failed token=${link.wikiNodeToken} ` + + `code=${response.code ?? "unknown"} msg=${response.msg ?? "unknown"}`, + ); + return null; + } + const objType = normalizeCommentFileType(response.data?.node?.obj_type); + const objToken = normalizeString(response.data?.node?.obj_token); + if (!objType || !objToken) { + return null; + } + return { + resolvedObjType: objType, + resolvedObjToken: objToken, + }; + }) + .catch((error) => { + params.logger?.( + `feishu[${params.accountId}]: wiki link resolution threw token=${link.wikiNodeToken} error=${formatErrorMessage(error)}`, + ); + return null; + }); + params.wikiCache.set(link.wikiNodeToken, pending); + } + const resolved = await pending; + if (!resolved) { + return link; + } + return { + ...link, + resolvedObjType: resolved.resolvedObjType, + resolvedObjToken: resolved.resolvedObjToken, + isCurrentDocument: + resolved.resolvedObjType === params.currentDocument.fileType && + resolved.resolvedObjToken === params.currentDocument.fileToken, + }; + }), + ); + + return { + ...parsed, + linkedDocuments: resolvedLinkedDocuments, + }; +} + async function delayMs(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -183,6 +399,49 @@ function buildDriveCommentRepliesUrl(params: { ); } +async function fetchDriveComments(params: { + client: FeishuRequestClient; + fileToken: string; + fileType: CommentFileType; + isWholeOnly?: boolean; + timeoutMs: number; + logger?: (message: string) => void; + accountId: string; +}): Promise { + const comments: FeishuDriveCommentCard[] = []; + let pageToken: string | undefined; + for (let page = 0; page < FEISHU_COMMENT_LIST_PAGE_LIMIT; page += 1) { + const response = await requestFeishuOpenApi({ + client: params.client, + method: "GET", + url: buildDriveCommentsListUrl({ + fileToken: params.fileToken, + fileType: params.fileType, + isWholeOnly: params.isWholeOnly, + pageToken, + }), + timeoutMs: params.timeoutMs, + logger: params.logger, + errorLabel: `feishu[${params.accountId}]: failed to list drive comments for ${params.fileToken}`, + }); + if (response?.code !== 0) { + if (response) { + params.logger?.( + `feishu[${params.accountId}]: failed to list drive comments for ${params.fileToken}: ` + + `${response.msg ?? "unknown error"} log_id=${response.log_id?.trim() || "unknown"}`, + ); + } + break; + } + comments.push(...(response.data?.items ?? [])); + if (response.data?.has_more !== true || !response.data.page_token?.trim()) { + break; + } + pageToken = response.data.page_token.trim(); + } + return comments; +} + async function requestFeishuOpenApi(params: { client: FeishuRequestClient; method: "GET" | "POST"; @@ -285,12 +544,189 @@ async function fetchDriveCommentReplies(params: { return { replies, logIds }; } +async function resolveCommentReplyContext(params: { + reply: FeishuDriveCommentReply; + botOpenIds?: Iterable; + currentDocument: { + fileType: CommentFileType; + fileToken: string; + }; + client: FeishuRequestClient; + wikiCache: Map< + string, + Promise<{ + resolvedObjType?: CommentFileType; + resolvedObjToken?: string; + } | null> + >; + logger?: (message: string) => void; + accountId: string; +}): Promise { + const userId = normalizeString(params.reply.user_id); + const normalizedBotOpenIds = new Set( + Array.from(params.botOpenIds ?? []) + .map((botId) => normalizeString(botId)) + .filter((botId): botId is string => Boolean(botId)), + ); + return { + replyId: normalizeString(params.reply.reply_id), + userId, + createTime: typeof params.reply.create_time === "number" ? params.reply.create_time : undefined, + isBotAuthored: typeof userId === "string" && normalizedBotOpenIds.has(userId), + content: await resolveParsedCommentContent({ + elements: isRecord(params.reply.content) ? params.reply.content.elements : undefined, + botOpenIds: params.botOpenIds, + currentDocument: params.currentDocument, + client: params.client, + wikiCache: params.wikiCache, + logger: params.logger, + accountId: params.accountId, + }), + }; +} + +function selectCommentThreadPromptReplies( + replies: ResolvedCommentReplyContext[], + targetReplyId?: string, +): ResolvedCommentReplyContext[] { + if (replies.length <= FEISHU_COMMENT_THREAD_PROMPT_LIMIT) { + return replies; + } + const targetIndex = replies.findIndex((reply) => reply.replyId === targetReplyId); + const currentIndex = targetIndex >= 0 ? targetIndex : replies.length - 1; + const selected = new Set([0, currentIndex, replies.length - 1]); + for (let radius = 1; selected.size < FEISHU_COMMENT_THREAD_PROMPT_LIMIT; radius += 1) { + const before = currentIndex - radius; + const after = currentIndex + radius; + if (before >= 0) { + selected.add(before); + } + if (selected.size >= FEISHU_COMMENT_THREAD_PROMPT_LIMIT) { + break; + } + if (after < replies.length) { + selected.add(after); + } + if (before < 0 && after >= replies.length) { + break; + } + } + return [...selected] + .toSorted((left, right) => left - right) + .map((index) => replies[index]) + .filter((reply): reply is ResolvedCommentReplyContext => Boolean(reply)); +} + +function formatCommentThreadPromptLines(params: { + replies: ResolvedCommentReplyContext[]; + targetReplyId?: string; +}): string[] { + const promptReplies = selectCommentThreadPromptReplies(params.replies, params.targetReplyId); + return promptReplies.map((reply, index) => { + const text = reply.content.semanticText ?? reply.content.plainText; + return ( + `- [${index + 1}] author=${reply.isBotAuthored ? "assistant" : "user"} ` + + `user_id=${reply.userId ?? "UNKNOWN"} ` + + `reply_id=${reply.replyId ?? "UNKNOWN"} ` + + `current_event=${reply.replyId === params.targetReplyId ? "yes" : "no"} ` + + `text=${formatPromptTextValue(text)} ` + + `referenced_docs=${formatLinkedDocumentsInlineSummary(reply.content.linkedDocuments)}` + ); + }); +} + +function findNearestBotTimelineEntry(params: { + entries: ResolvedWholeCommentTimelineEntry[]; + currentIndex: number; + direction: "before" | "after"; +}): ResolvedWholeCommentTimelineEntry | undefined { + const step = params.direction === "after" ? 1 : -1; + for ( + let index = params.currentIndex + step; + index >= 0 && index < params.entries.length; + index += step + ) { + const candidate = params.entries[index]; + if (candidate?.isBotAuthored) { + return candidate; + } + } + return undefined; +} + +function selectWholeCommentTimelineEntries(params: { + entries: ResolvedWholeCommentTimelineEntry[]; + currentCommentId: string; +}): ResolvedWholeCommentTimelineEntry[] { + if (params.entries.length <= FEISHU_WHOLE_COMMENT_PROMPT_LIMIT) { + return params.entries; + } + const currentIndex = params.entries.findIndex( + (entry) => entry.commentId === params.currentCommentId, + ); + if (currentIndex < 0) { + return params.entries.slice(-FEISHU_WHOLE_COMMENT_PROMPT_LIMIT); + } + const selected = new Set([currentIndex]); + const nearestBotAfter = params.entries.findIndex( + (entry, index) => index > currentIndex && entry.isBotAuthored, + ); + if (nearestBotAfter >= 0) { + selected.add(nearestBotAfter); + } + for (let index = currentIndex - 1; index >= 0; index -= 1) { + if (params.entries[index]?.isBotAuthored) { + selected.add(index); + break; + } + } + for (let radius = 1; selected.size < FEISHU_WHOLE_COMMENT_PROMPT_LIMIT; radius += 1) { + const before = currentIndex - radius; + const after = currentIndex + radius; + if (before >= 0) { + selected.add(before); + } + if (selected.size >= FEISHU_WHOLE_COMMENT_PROMPT_LIMIT) { + break; + } + if (after < params.entries.length) { + selected.add(after); + } + if (before < 0 && after >= params.entries.length) { + break; + } + } + return [...selected] + .toSorted((left, right) => left - right) + .map((index) => params.entries[index]) + .filter((entry): entry is ResolvedWholeCommentTimelineEntry => Boolean(entry)); +} + +function formatWholeCommentTimelinePromptLines(params: { + entries: ResolvedWholeCommentTimelineEntry[]; + currentCommentId: string; +}): string[] { + return selectWholeCommentTimelineEntries(params).map((entry, index) => { + const text = entry.content.semanticText ?? entry.content.plainText; + return ( + `- [${index + 1}] create_time=${entry.createTime ?? "UNKNOWN"} ` + + `comment_id=${entry.commentId} ` + + `author=${entry.isBotAuthored ? "assistant" : "user"} ` + + `user_id=${entry.userId ?? "UNKNOWN"} ` + + `current_comment=${entry.commentId === params.currentCommentId ? "yes" : "no"} ` + + `text=${formatPromptTextValue(text)} ` + + `referenced_docs=${formatLinkedDocumentsInlineSummary(entry.content.linkedDocuments)}` + ); + }); +} + async function fetchDriveCommentContext(params: { client: FeishuRequestClient; fileToken: string; fileType: CommentFileType; commentId: string; replyId?: string; + botOpenIds?: Iterable; timeoutMs: number; logger?: (message: string) => void; accountId: string; @@ -302,6 +738,12 @@ async function fetchDriveCommentContext(params: { quoteText?: string; rootCommentText?: string; targetReplyText?: string; + rootCommentContent?: ParsedCommentContent; + targetReplyContent?: ParsedCommentContent; + currentCommentThreadReplies: ResolvedCommentReplyContext[]; + wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[]; + nearestBotWholeCommentAfter?: ResolvedWholeCommentTimelineEntry; + nearestBotWholeCommentBefore?: ResolvedWholeCommentTimelineEntry; }> { const [metaResponse, commentResponse] = await Promise.all([ requestFeishuOpenApi({ @@ -331,6 +773,13 @@ async function fetchDriveCommentContext(params: { errorLabel: `feishu[${params.accountId}]: failed to fetch drive comment ${params.commentId}`, }), ]); + const wikiCache = new Map< + string, + Promise<{ + resolvedObjType?: CommentFileType; + resolvedObjToken?: string; + } | null> + >(); const commentCard = commentResponse?.code === 0 @@ -351,12 +800,15 @@ async function fetchDriveCommentContext(params: { let fetchedMatchedReply = params.replyId ? replies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim()) : undefined; - if (!embeddedTargetReply || replies.length === 0) { + const needsExtraReplies = + !embeddedTargetReply || replies.length === 0 || commentCard?.has_more === true; + if (needsExtraReplies) { params.logger?.( `feishu[${params.accountId}]: fetching extra comment replies comment=${params.commentId} ` + `requested_reply=${params.replyId ?? "none"} ` + `embedded_count=${embeddedReplies.length} ` + - `embedded_hit=${embeddedTargetReply ? "yes" : "no"}`, + `embedded_hit=${embeddedTargetReply ? "yes" : "no"} ` + + `embedded_has_more=${commentCard?.has_more === true ? "yes" : "no"}`, ); const fetched = await fetchDriveCommentReplies(params); if (fetched.replies.length > 0) { @@ -419,14 +871,137 @@ async function fetchDriveCommentContext(params: { `target=${safeJsonStringify({ reply_id: targetReply?.reply_id, text_len: extractReplyText(targetReply)?.length ?? 0 })}`, ); const meta = metaResponse?.code === 0 ? metaResponse.data?.metas?.[0] : undefined; + const currentDocument = { + fileType: params.fileType, + fileToken: params.fileToken, + }; + const resolvedReplies = await Promise.all( + replies.map((reply) => + resolveCommentReplyContext({ + reply, + botOpenIds: params.botOpenIds, + currentDocument, + client: params.client, + wikiCache, + logger: params.logger, + accountId: params.accountId, + }), + ), + ); + resolvedReplies.sort((left, right) => + compareCommentTimelineEntries( + { + createTime: left.createTime, + stableId: left.replyId, + }, + { + createTime: right.createTime, + stableId: right.replyId, + }, + ), + ); + const rootReplyContext = + resolvedReplies.find((reply) => reply.replyId === normalizeString(rootReply?.reply_id)) ?? + resolvedReplies[0]; + const targetReplyContext = + resolvedReplies.find((reply) => reply.replyId === normalizeString(targetReply?.reply_id)) ?? + (params.replyId ? undefined : (resolvedReplies.at(-1) ?? rootReplyContext)); + + let wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[] = []; + if (commentCard?.is_whole === true) { + const allComments = await fetchDriveComments({ + client: params.client, + fileToken: params.fileToken, + fileType: params.fileType, + isWholeOnly: true, + timeoutMs: params.timeoutMs, + logger: params.logger, + accountId: params.accountId, + }); + const wholeComments = allComments.filter((comment) => comment.is_whole === true); + wholeCommentTimeline = await Promise.all( + wholeComments.map(async (comment) => { + const rootWholeReply = comment.reply_list?.replies?.[0]; + const normalizedBotOpenIds = new Set( + Array.from(params.botOpenIds ?? []) + .map((botId) => normalizeString(botId)) + .filter((botId): botId is string => Boolean(botId)), + ); + const content = await resolveParsedCommentContent({ + elements: isRecord(rootWholeReply?.content) ? rootWholeReply.content.elements : undefined, + botOpenIds: params.botOpenIds, + currentDocument, + client: params.client, + wikiCache, + logger: params.logger, + accountId: params.accountId, + }); + const commentUserId = + normalizeString(rootWholeReply?.user_id) || normalizeString(comment.user_id); + return { + commentId: normalizeString(comment.comment_id) ?? "", + userId: commentUserId, + createTime: + typeof comment.create_time === "number" + ? comment.create_time + : typeof rootWholeReply?.create_time === "number" + ? rootWholeReply.create_time + : undefined, + isCurrentComment: normalizeString(comment.comment_id) === params.commentId, + isBotAuthored: + typeof commentUserId === "string" && normalizedBotOpenIds.has(commentUserId), + content, + }; + }), + ); + wholeCommentTimeline = wholeCommentTimeline + .filter((entry) => Boolean(entry.commentId)) + .toSorted((left, right) => + compareCommentTimelineEntries( + { + createTime: left.createTime, + stableId: left.commentId, + }, + { + createTime: right.createTime, + stableId: right.commentId, + }, + ), + ); + } + + const currentWholeCommentIndex = wholeCommentTimeline.findIndex( + (entry) => entry.commentId === params.commentId, + ); return { documentTitle: normalizeString(meta?.title), documentUrl: normalizeString(meta?.url), isWholeComment: commentCard?.is_whole, quoteText: normalizeString(commentCard?.quote), - rootCommentText: extractReplyText(rootReply), - targetReplyText: extractReplyText(targetReply), + rootCommentText: rootReplyContext?.content.semanticText ?? rootReplyContext?.content.plainText, + targetReplyText: + targetReplyContext?.content.semanticText ?? targetReplyContext?.content.plainText, + rootCommentContent: rootReplyContext?.content, + targetReplyContent: targetReplyContext?.content, + currentCommentThreadReplies: resolvedReplies, + wholeCommentTimeline, + nearestBotWholeCommentAfter: + currentWholeCommentIndex >= 0 + ? findNearestBotTimelineEntry({ + entries: wholeCommentTimeline, + currentIndex: currentWholeCommentIndex, + direction: "after", + }) + : undefined, + nearestBotWholeCommentBefore: + currentWholeCommentIndex >= 0 + ? findNearestBotTimelineEntry({ + entries: wholeCommentTimeline, + currentIndex: currentWholeCommentIndex, + direction: "before", + }) + : undefined, }; } @@ -443,24 +1018,31 @@ function buildDriveCommentSurfacePrompt(params: { quoteText?: string; rootCommentText?: string; targetReplyText?: string; + rootCommentContent?: ParsedCommentContent; + targetReplyContent?: ParsedCommentContent; + currentCommentThreadReplies: ResolvedCommentReplyContext[]; + wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[]; + nearestBotWholeCommentAfter?: ResolvedWholeCommentTimelineEntry; + nearestBotWholeCommentBefore?: ResolvedWholeCommentTimelineEntry; }): 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 firstLine = `The user added a ${actionLabel} in ${documentLabel}.`; const lines = [firstLine]; + if (params.targetReplyText) { + lines.push(`Current user comment text: ${formatPromptTextValue(params.targetReplyText)}`); + } if ( params.noticeType === "add_reply" && params.rootCommentText && params.rootCommentText !== params.targetReplyText ) { - lines.push(`Original comment: ${params.rootCommentText}`); + lines.push(`Original comment text: ${formatPromptTextValue(params.rootCommentText)}`); } if (params.quoteText) { - lines.push(`Quoted content: ${params.quoteText}`); + lines.push(`Quoted content: ${formatPromptTextValue(params.quoteText)}`); } if (params.isMentioned === true) { lines.push("This comment mentioned you."); @@ -468,6 +1050,17 @@ function buildDriveCommentSurfacePrompt(params: { if (params.documentUrl) { lines.push(`Document link: ${params.documentUrl}`); } + lines.push( + "Current commented document:", + `- file_type=${params.fileType}`, + `- file_token=${params.fileToken}`, + ); + if (params.documentTitle) { + lines.push(`- title=${params.documentTitle}`); + } + if (params.documentUrl) { + lines.push(`- url=${params.documentUrl}`); + } lines.push( `Event type: ${params.noticeType}`, `file_token: ${params.fileToken}`, @@ -480,29 +1073,124 @@ function buildDriveCommentSurfacePrompt(params: { if (params.replyId?.trim()) { lines.push(`reply_id: ${params.replyId.trim()}`); } + if (params.targetReplyContent?.semanticText) { + lines.push( + `Current user comment semantic text: ${formatPromptTextValue( + params.targetReplyContent.semanticText, + )}`, + ); + } + if (params.targetReplyContent?.botMentioned) { + lines.push( + "Bot routing mention detected in the current user comment. Treat that mention as routing only, not task content.", + ); + } + const nonBotMentions = (params.targetReplyContent?.mentions ?? []) + .filter((mention) => !mention.isBotMention) + .map((mention) => mention.displayText); + if (nonBotMentions.length > 0) { + lines.push(`Other mentioned users in current comment: ${nonBotMentions.join(", ")}`); + } 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.", - "Whole-document comments do not support direct replies. When the current comment is whole-document, use feishu_drive.add_comment for any user-visible follow-up instead of reply_comment.", - '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.", - "Prefer plain text suitable for a comment thread. Unless the user explicitly asks for Markdown, do not use Markdown headings, bullet lists, numbered lists, tables, blockquotes, or fenced code blocks in the final reply.", - "If source content was read in Markdown form, rewrite it into normal plain-text prose before replying in the comment thread instead of copying Markdown syntax through.", - 'Do not include internal reasoning, analysis, chain-of-thought, scratch work, or any "Reasoning:" / "Thinking:" section in a user-visible reply. Output only the final answer meant for the user, or NO_REPLY when appropriate.', - 'Do not narrate your plan or execution process in the user-visible reply. Avoid meta lead-ins such as "I will...", "I’ll first...", "I need to...", "The user wants...", "I have updated...", or "I am going to...".', - "When the task is complete, reply only with the user-facing result itself, such as the final answer or a concise completion confirmation. Do not include preambles about what you plan to do next.", - "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.", + ...formatLinkedDocumentsPromptLines({ + title: "Referenced documents from current user comment:", + linkedDocuments: params.targetReplyContent?.linkedDocuments ?? [], + }), + ); + if (!params.isWholeComment && params.currentCommentThreadReplies.length > 0) { + lines.push( + "Current comment card timeline (primary context for follow-ups on this comment card):", + ...formatCommentThreadPromptLines({ + replies: params.currentCommentThreadReplies, + targetReplyId: params.replyId, + }), + "For this non-whole comment, use the current comment card timeline above as the primary source for phrases like 'above', 'previous result', 'that summary', or 'insert it'.", + "Document-level session history is auxiliary background only. Do not use another comment card's recent output as the primary referent.", + ); + } + if (params.isWholeComment && params.wholeCommentTimeline.length > 0) { + lines.push( + "Whole-document comment timeline (primary context for whole-comment follow-ups):", + ...formatWholeCommentTimelinePromptLines({ + entries: params.wholeCommentTimeline, + currentCommentId: params.commentId, + }), + ); + if (params.nearestBotWholeCommentAfter) { + lines.push( + `Nearest bot-authored whole-comment after the current comment: comment_id=${params.nearestBotWholeCommentAfter.commentId} text=${formatPromptTextValue( + params.nearestBotWholeCommentAfter.content.semanticText ?? + params.nearestBotWholeCommentAfter.content.plainText, + )}`, + ); + } + if (params.nearestBotWholeCommentBefore) { + lines.push( + `Nearest bot-authored whole-comment before the current comment: comment_id=${params.nearestBotWholeCommentBefore.commentId} text=${formatPromptTextValue( + params.nearestBotWholeCommentBefore.content.semanticText ?? + params.nearestBotWholeCommentBefore.content.plainText, + )}`, + ); + } + lines.push( + "For this whole-document comment, use the whole-comment timeline above as the primary source for phrases like 'just now', 'previous result', 'that summary', or 'write it back'.", + "Document-level session history is auxiliary background only. Do not resolve whole-comment follow-ups by blindly using the most recent document-session output.", + ); + } + lines.push( + "This is a Feishu document comment thread.", + "It is not a Feishu IM chat.", + "Your final text reply will be posted to the current comment thread automatically.", + "Use the thread timeline above as the main context for follow-up requests.", + "Do not use another comment card or document-session output as the main reference.", + "If you need comment thread context, use feishu_drive.list_comments or feishu_drive.list_comment_replies.", + "If you modify the document, post a user-visible follow-up in the comment thread.", + "Use feishu_drive.reply_comment or feishu_drive.add_comment for that follow-up.", + "Whole-document comments do not support direct replies.", + "For whole-document comments, use feishu_drive.add_comment.", + 'Only treat URLs listed under "Referenced documents from current user comment" as structured Feishu document references.', + "URLs that appear only in comment text are plain links unless you verify them.", + "If the user asks about a linked Feishu document or wiki page, treat that linked document as the read target.", + "If the user asks you to use a linked document as guidance, treat the linked document as the reference source and the current commented document as the edit target.", + "If a referenced document resolves to the same file_token and file_type as the current commented document, treat it as the current document.", + "If the user asks you to modify document content, you must use feishu_doc to make the change.", + 'Do not reply with only "done", "I\'ll handle it", or a restated plan without calling tools.', + "If the comment quotes document content, treat the quoted content as the main anchor.", + 'For requests like "insert xxx below this content", locate the quoted content first, then edit the document.', + 'For requests like "summarize the content below", "explain this section", or "continue writing from here", use the quoted content as the main target.', + "If the quote is not enough, use feishu_doc.read or feishu_doc.list_blocks to read nearby context.", + "Do not guess document content from the comment alone.", + "Do not give a vague answer before reading enough context.", + "Unless the user asks for the whole document, handle only the local content around the quoted anchor.", + "If document edits are involved, read the anchor first, then edit.", + "If the edit fails or the anchor cannot be found, say so clearly.", + "If this is a reading task, such as summarization, explanation, or extraction, you may output the final answer directly after confirming the context.", + "Use the same language as the user's comment or reply, unless the user asks for another language.", + "Use plain text only.", + "Do not use Markdown.", + "Do not use headings.", + "Do not use bullet lists.", + "Do not use numbered lists.", + "Do not use tables.", + "Do not use blockquotes.", + "Do not use code blocks.", + "Do not show reasoning.", + "Do not show analysis.", + "Do not show chain-of-thought.", + "Do not show scratch work.", + "Do not describe your plan.", + "Do not describe your steps.", + "Do not describe tool use.", + 'Do not start with phrases like "I will", "I’ll first", "I need to", "The user wants", or "I have updated".', + "Output only the final user-facing reply.", + "If you already sent the user-visible reply with feishu_drive.reply_comment or feishu_drive.add_comment, output exactly NO_REPLY.", + "If no user-visible reply is needed, output exactly NO_REPLY.", + "Be concise.", + "Do not omit requested content.", + ); + lines.push( + "Choose one outcome: output the final plain-text reply, edit the document and then post a user-visible follow-up in the comment thread, or output exactly NO_REPLY.", ); - lines.push(`Decide what to do next based on this document ${actionLabel} event.`); return lines.join("\n"); } @@ -524,6 +1212,12 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara quoteText?: string; rootCommentText?: string; targetReplyText?: string; + rootCommentContent?: ParsedCommentContent; + targetReplyContent?: ParsedCommentContent; + currentCommentThreadReplies: ResolvedCommentReplyContext[]; + wholeCommentTimeline: ResolvedWholeCommentTimelineEntry[]; + nearestBotWholeCommentAfter?: ResolvedWholeCommentTimelineEntry; + nearestBotWholeCommentBefore?: ResolvedWholeCommentTimelineEntry; }; } | null> { const { @@ -576,6 +1270,7 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara fileType, commentId, replyId, + botOpenIds: [botOpenId, event.notice_meta?.to_user_id?.open_id], timeoutMs: verificationTimeoutMs, logger, accountId, @@ -655,6 +1350,12 @@ export async function resolveDriveCommentEventTurn( quoteText: resolved.context.quoteText, rootCommentText: resolved.context.rootCommentText, targetReplyText: resolved.context.targetReplyText, + rootCommentContent: resolved.context.rootCommentContent, + targetReplyContent: resolved.context.targetReplyContent, + currentCommentThreadReplies: resolved.context.currentCommentThreadReplies, + wholeCommentTimeline: resolved.context.wholeCommentTimeline, + nearestBotWholeCommentAfter: resolved.context.nearestBotWholeCommentAfter, + nearestBotWholeCommentBefore: resolved.context.nearestBotWholeCommentBefore, }); const preview = prompt.replace(/\s+/g, " ").slice(0, 160); return { diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 4eb5924fc07..0c070dd6d38 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -8,7 +8,8 @@ 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()); +const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn()); +const cleanupAmbientCommentTypingReactionMock = vi.hoisted(() => vi.fn(async () => false)); vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock, @@ -35,7 +36,11 @@ vi.mock("./client.js", () => ({ })); vi.mock("./drive.js", () => ({ - replyComment: replyCommentMock, + deliverCommentThreadText: deliverCommentThreadTextMock, +})); + +vi.mock("./comment-reaction.js", () => ({ + cleanupAmbientCommentTypingReaction: cleanupAmbientCommentTypingReactionMock, })); import { feishuOutbound } from "./outbound.js"; @@ -55,7 +60,11 @@ function resetOutboundMocks() { sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); - replyCommentMock.mockResolvedValue({ reply_id: "reply_msg" }); + deliverCommentThreadTextMock.mockResolvedValue({ + delivery_mode: "reply_comment", + reply_id: "reply_msg", + }); + cleanupAmbientCommentTypingReactionMock.mockResolvedValue(false); } describe("feishuOutbound.sendText local-image auto-convert", () => { @@ -214,7 +223,7 @@ describe("feishuOutbound comment-thread routing", () => { resetOutboundMocks(); }); - it("routes comment-thread text through replyComment", async () => { + it("routes comment-thread text through deliverCommentThreadText", async () => { const result = await sendText({ cfg: emptyConfig, to: "comment:docx:doxcn123:7623358762119646411", @@ -222,7 +231,7 @@ describe("feishuOutbound comment-thread routing", () => { accountId: "main", }); - expect(replyCommentMock).toHaveBeenCalledWith( + expect(deliverCommentThreadTextMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ file_token: "doxcn123", @@ -235,7 +244,7 @@ describe("feishuOutbound comment-thread routing", () => { expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" })); }); - it("routes comment-thread code-block replies through replyComment instead of IM cards", async () => { + it("routes comment-thread code-block replies through deliverCommentThreadText instead of IM cards", async () => { const result = await sendText({ cfg: emptyConfig, to: "comment:docx:doxcn123:7623358762119646411", @@ -243,7 +252,7 @@ describe("feishuOutbound comment-thread routing", () => { accountId: "main", }); - expect(replyCommentMock).toHaveBeenCalledWith( + expect(deliverCommentThreadTextMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ file_token: "doxcn123", @@ -257,7 +266,7 @@ describe("feishuOutbound comment-thread routing", () => { expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" })); }); - it("routes comment-thread replies through replyComment even when renderMode=card", async () => { + it("routes comment-thread replies through deliverCommentThreadText even when renderMode=card", async () => { const result = await sendText({ cfg: cardRenderConfig, to: "comment:docx:doxcn123:7623358762119646411", @@ -265,7 +274,7 @@ describe("feishuOutbound comment-thread routing", () => { accountId: "main", }); - expect(replyCommentMock).toHaveBeenCalledWith( + expect(deliverCommentThreadTextMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ file_token: "doxcn123", @@ -288,7 +297,7 @@ describe("feishuOutbound comment-thread routing", () => { accountId: "main", }); - expect(replyCommentMock).toHaveBeenCalledWith( + expect(deliverCommentThreadTextMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ content: "see attachment\n\nhttps://example.com/file.png", @@ -297,6 +306,74 @@ describe("feishuOutbound comment-thread routing", () => { expect(sendMediaFeishuMock).not.toHaveBeenCalled(); expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" })); }); + + it("preserves comment-thread routing when deliverCommentThreadText falls back to add_comment", async () => { + deliverCommentThreadTextMock.mockResolvedValueOnce({ + delivery_mode: "add_comment", + comment_id: "comment_msg", + reply_id: "reply_from_add_comment", + }); + + const result = await sendText({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "whole-comment follow-up", + accountId: "main", + }); + + expect(deliverCommentThreadTextMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + file_token: "doxcn123", + file_type: "docx", + comment_id: "7623358762119646411", + content: "whole-comment follow-up", + }), + ); + expect(result).toEqual( + expect.objectContaining({ + channel: "feishu", + messageId: "reply_from_add_comment", + }), + ); + }); + + it("does not wait for ambient comment typing cleanup before sending comment-thread replies", async () => { + let resolveCleanup: ((value: boolean) => void) | undefined; + cleanupAmbientCommentTypingReactionMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCleanup = resolve; + }), + ); + + const sendPromise = sendText({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "handled in thread", + replyToId: "reply_ambient_1", + accountId: "main", + }); + + const status = await Promise.race([ + sendPromise.then(() => "done"), + new Promise((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + + expect(status).toBe("done"); + expect(deliverCommentThreadTextMock).toHaveBeenCalled(); + expect(cleanupAmbientCommentTypingReactionMock).toHaveBeenCalledWith({ + client: expect.anything(), + deliveryContext: { + channel: "feishu", + to: "comment:docx:doxcn123:7623358762119646411", + threadId: "reply_ambient_1", + }, + }); + + resolveCleanup?.(false); + await sendPromise; + }); }); describe("feishuOutbound.sendText replyToId forwarding", () => { diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index c11a183f8ec..8e9f972fbf6 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -4,8 +4,9 @@ import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js"; import { parseFeishuCommentTarget } from "./comment-target.js"; -import { replyComment } from "./drive.js"; +import { deliverCommentThreadText } from "./drive.js"; import { sendMediaFeishu } from "./media.js"; import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js"; import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js"; @@ -80,6 +81,7 @@ async function sendCommentThreadReply(params: { cfg: Parameters[0]["cfg"]; to: string; text: string; + replyId?: string; accountId?: string; }) { const target = parseFeishuCommentTarget(params.to); @@ -88,17 +90,34 @@ async function sendCommentThreadReply(params: { } 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, - }; + const replyId = params.replyId?.trim(); + try { + const result = await deliverCommentThreadText(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) || + (typeof result.comment_id === "string" && result.comment_id) || + "", + chatId: target.commentId, + result, + }; + } finally { + if (replyId) { + void cleanupAmbientCommentTypingReaction({ + client, + deliveryContext: { + channel: "feishu", + to: params.to, + threadId: replyId, + }, + }); + } + } } async function sendOutboundText(params: { @@ -113,6 +132,7 @@ async function sendOutboundText(params: { cfg, to, text, + replyId: replyToMessageId, accountId, }); if (commentResult) {