Feishu: wire Drive comment threads into routed replies

This commit is contained in:
George Zhang 2026-03-31 20:31:37 -07:00
parent 0a2d17b733
commit 6d981e17ea
No known key found for this signature in database
10 changed files with 452 additions and 296 deletions

View File

@ -2,12 +2,13 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import {
createReplyPrefixContext,
type ClawdbotConfig,
type RuntimeEnv,
type ReplyPayload,
type RuntimeEnv,
} from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { replyComment, type CommentFileType } from "./drive.js";
import type { CommentFileType } from "./comment-target.js";
import { replyComment } from "./drive.js";
import { getFeishuRuntime } from "./runtime.js";
export type CreateFeishuCommentReplyDispatcherParams = {

View File

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

View File

@ -5,10 +5,12 @@ import {
type RuntimeEnv,
} from "../runtime-api.js";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
import { buildFeishuCommentTarget } from "./comment-target.js";
import { replyComment } from "./drive.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import {
parseFeishuDriveCommentNoticeEventPayload,
resolveDriveCommentEventTurn,
type FeishuDriveCommentNoticeEvent,
} from "./monitor.comment.js";
@ -27,8 +29,7 @@ type HandleFeishuCommentEventParams = {
function buildCommentSessionKey(params: {
core: ReturnType<typeof getFeishuRuntime>;
route: ResolvedAgentRoute;
fileToken: string;
commentId: string;
commentTarget: string;
}): string {
return params.core.channel.routing.buildAgentSessionKey({
agentId: params.route.agentId,
@ -36,7 +37,7 @@ function buildCommentSessionKey(params: {
accountId: params.route.accountId,
peer: {
kind: "direct",
id: `comment:${params.fileToken}:${params.commentId}`,
id: params.commentTarget,
},
dmScope: "per-account-channel-peer",
});
@ -72,6 +73,11 @@ export async function handleFeishuCommentEvent(
return;
}
const commentTarget = buildFeishuCommentTarget({
fileType: turn.fileType,
fileToken: turn.fileToken,
commentId: turn.commentId,
});
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const pairing = createChannelPairingController({
@ -89,10 +95,37 @@ export async function handleFeishuCommentEvent(
senderId: turn.senderId,
}).allowed;
if (dmPolicy !== "open" && !senderAllowed) {
log(
`feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` +
`(dmPolicy=${dmPolicy}, comment=${turn.commentId})`,
);
if (dmPolicy === "pairing") {
const client = createFeishuClient(account);
await pairing.issueChallenge({
senderId: turn.senderId,
senderIdLine: `Your Feishu user id: ${turn.senderId}`,
meta: { name: turn.senderId },
onCreated: ({ code }) => {
log(
`feishu[${account.accountId}]: comment pairing request sender=${turn.senderId} code=${code}`,
);
},
sendPairingReply: async (text) => {
await replyComment(client, {
file_token: turn.fileToken,
file_type: turn.fileType,
comment_id: turn.commentId,
content: text,
});
},
onReplyError: (err) => {
log(
`feishu[${account.accountId}]: comment pairing reply failed for ${turn.senderId}: ${String(err)}`,
);
},
});
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized comment sender ${turn.senderId} ` +
`(dmPolicy=${dmPolicy}, comment=${turn.commentId})`,
);
}
return;
}
@ -137,8 +170,7 @@ export async function handleFeishuCommentEvent(
const commentSessionKey = buildCommentSessionKey({
core,
route,
fileToken: turn.fileToken,
commentId: turn.commentId,
commentTarget,
});
const bodyForAgent = `[message_id: ${turn.messageId}]\n${turn.prompt}`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
@ -146,8 +178,8 @@ export async function handleFeishuCommentEvent(
BodyForAgent: bodyForAgent,
RawBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
CommandBody: turn.targetReplyText ?? turn.rootCommentText ?? turn.prompt,
From: `feishu-comment:${turn.senderId}`,
To: `comment:${turn.fileToken}:${turn.commentId}`,
From: `feishu:${turn.senderId}`,
To: commentTarget,
SessionKey: commentSessionKey,
AccountId: route.accountId,
ChatType: "direct",
@ -162,7 +194,8 @@ export async function handleFeishuCommentEvent(
Timestamp: parseTimestampMs(turn.timestamp),
WasMentioned: turn.isMentioned,
CommandAuthorized: false,
OriginatingTo: `comment:${turn.fileToken}:${turn.commentId}`,
OriginatingChannel: "feishu",
OriginatingTo: commentTarget,
});
const storePath = core.channel.session.resolveStorePath(effectiveCfg.session?.store, {

View File

@ -0,0 +1,44 @@
export const FEISHU_COMMENT_FILE_TYPES = ["doc", "docx", "file", "sheet", "slides"] as const;
export type CommentFileType = (typeof FEISHU_COMMENT_FILE_TYPES)[number];
export function normalizeCommentFileType(value: unknown): CommentFileType | undefined {
return typeof value === "string" &&
(FEISHU_COMMENT_FILE_TYPES as readonly string[]).includes(value)
? (value as CommentFileType)
: undefined;
}
export type FeishuCommentTarget = {
fileType: CommentFileType;
fileToken: string;
commentId: string;
};
export function buildFeishuCommentTarget(params: FeishuCommentTarget): string {
return `comment:${params.fileType}:${params.fileToken}:${params.commentId}`;
}
export function parseFeishuCommentTarget(
raw: string | undefined | null,
): FeishuCommentTarget | null {
const trimmed = raw?.trim();
if (!trimmed?.startsWith("comment:")) {
return null;
}
const parts = trimmed.split(":");
if (parts.length !== 4) {
return null;
}
const fileType = normalizeCommentFileType(parts[1]);
const fileToken = parts[2]?.trim();
const commentId = parts[3]?.trim();
if (!fileType || !fileToken || !commentId) {
return null;
}
return {
fileType,
fileToken,
commentId,
};
}

View File

@ -67,7 +67,8 @@ describe("registerFeishuDriveTools", () => {
);
expect(registerTool).toHaveBeenCalledTimes(1);
const tool = registerTool.mock.calls[0]?.[0];
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
expect(tool?.name).toBe("feishu_drive");
requestMock.mockResolvedValueOnce({
@ -271,7 +272,8 @@ describe("registerFeishuDriveTools", () => {
}),
);
const tool = registerTool.mock.calls[0]?.[0];
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
const result = await tool.execute("call-5", {
action: "add_comment",
file_token: "doc_1",

View File

@ -1,6 +1,7 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { type CommentFileType } from "./comment-target.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import {
@ -30,8 +31,6 @@ type FeishuDriveInternalClient = Lark.Client & {
}): Promise<unknown>;
};
export type CommentFileType = "doc" | "docx" | "file" | "sheet" | "slides";
type FeishuDriveApiResponse<T> = {
code: number;
msg?: string;
@ -462,7 +461,7 @@ export async function replyComment(
comment_id: string;
content: string;
},
) {
): Promise<{ success: true; reply_id?: string } & Record<string, unknown>> {
const url =
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
params.comment_id,
@ -553,46 +552,14 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token));
case "delete":
return jsonToolResult(await deleteFile(client, p.file_token, p.type));
case "list_comments": {
api.logger.info?.(
`feishu_drive: list_comments file=${p.file_type}:${p.file_token} page_token=${p.page_token ?? "none"}`,
);
const result = await listComments(client, p);
api.logger.info?.(
`feishu_drive: list_comments resolved count=${result.comments.length} has_more=${result.has_more ? "yes" : "no"}`,
);
return jsonToolResult(result);
}
case "list_comment_replies": {
api.logger.info?.(
`feishu_drive: list_comment_replies file=${p.file_type}:${p.file_token} comment=${p.comment_id} page_token=${p.page_token ?? "none"}`,
);
const result = await listCommentReplies(client, p);
api.logger.info?.(
`feishu_drive: list_comment_replies resolved count=${result.replies.length} has_more=${result.has_more ? "yes" : "no"}`,
);
return jsonToolResult(result);
}
case "add_comment": {
api.logger.info?.(
`feishu_drive: add_comment file=${p.file_type}:${p.file_token} block=${p.block_id ?? "whole"} chars=${p.content.length}`,
);
const result = await addComment(client, p);
api.logger.info?.(
`feishu_drive: add_comment success comment=${String((result as { comment_id?: unknown }).comment_id ?? "unknown")}`,
);
return jsonToolResult(result);
}
case "reply_comment": {
api.logger.info?.(
`feishu_drive: reply_comment file=${p.file_type}:${p.file_token} comment=${p.comment_id} chars=${p.content.length}`,
);
const result = await replyComment(client, p);
api.logger.info?.(
`feishu_drive: reply_comment success reply=${String((result as { reply_id?: unknown }).reply_id ?? "unknown")}`,
);
return jsonToolResult(result);
}
case "list_comments":
return jsonToolResult(await listComments(client, p));
case "list_comment_replies":
return jsonToolResult(await listCommentReplies(client, p));
case "add_comment":
return jsonToolResult(await addComment(client, p));
case "reply_comment":
return jsonToolResult(await replyComment(client, p));
default:
return unknownToolActionResult((p as { action?: unknown }).action);
}

View File

@ -7,11 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/plugins/plugin-runtime-mock.js";
import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
import type { FeishuMessageEvent } from "./bot.js";
import * as dedup from "./dedup.js";
import { monitorSingleAccount } from "./monitor.account.js";
import {
resolveDriveCommentSyntheticEvent,
resolveDriveCommentEventTurn,
type FeishuDriveCommentNoticeEvent,
} from "./monitor.comment.js";
import { setFeishuRuntime } from "./runtime.js";
@ -235,16 +234,11 @@ async function setupCommentMonitorHandler(): Promise<(data: unknown) => Promise<
return handler;
}
function extractSyntheticText(event: FeishuMessageEvent): string {
const content = JSON.parse(event.message.content) as { text?: string };
return content.text ?? "";
}
describe("resolveDriveCommentSyntheticEvent", () => {
it("builds a synthetic Feishu message for add_comment notices", async () => {
describe("resolveDriveCommentEventTurn", () => {
it("builds a real comment-turn prompt for add_comment notices", async () => {
const client = makeOpenApiClient({ includeTargetReplyInBatch: true });
const synthetic = await resolveDriveCommentSyntheticEvent({
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent(),
@ -252,34 +246,20 @@ describe("resolveDriveCommentSyntheticEvent", () => {
createClient: () => client as never,
});
expect(synthetic).not.toBeNull();
expect(synthetic?.sender.sender_id.open_id).toBe("ou_509d4d7ace4a9addec2312676ffcba9b");
expect(synthetic?.message.message_id).toBe("drive-comment:10d9d60b990db39f96a4c2fd357fb877");
expect(synthetic?.message.chat_id).toBe("p2p:ou_509d4d7ace4a9addec2312676ffcba9b");
expect(synthetic?.message.chat_type).toBe("p2p");
expect(synthetic?.message.create_time).toBe("1774951528000");
const text = extractSyntheticText(synthetic as FeishuMessageEvent);
expect(text).toContain(
'I added a comment in "Comment event handling request": Also send it to the agent after receiving the comment event',
expect(turn).not.toBeNull();
expect(turn?.senderId).toBe("ou_509d4d7ace4a9addec2312676ffcba9b");
expect(turn?.messageId).toBe("drive-comment:10d9d60b990db39f96a4c2fd357fb877");
expect(turn?.fileType).toBe("docx");
expect(turn?.fileToken).toBe(TEST_DOC_TOKEN);
expect(turn?.prompt).toContain(
'The user added a comment in "Comment event handling request": Also send it to the agent after receiving the comment event',
);
expect(text).toContain("Also send it to the agent after receiving the comment event");
expect(text).toContain("Quoted content: im.message.receive_v1 message trigger implementation");
expect(text).toContain("This comment mentioned you.");
expect(text).toContain(
"This is a Feishu document comment event, not a normal instant-message conversation.",
expect(turn?.prompt).toContain(
"This is a Feishu document comment-thread event, not a Feishu IM conversation.",
);
expect(text).toContain("feishu_drive.reply_comment");
expect(text).toContain(
"reply with the answer in that comment thread via feishu_drive.reply_comment",
);
expect(text).toContain(
"after finishing also use feishu_drive.reply_comment in that comment thread to tell the user the update is complete",
);
expect(text).toContain(
"keep it in the same language as the user's original comment or reply unless they explicitly ask for another language",
);
expect(text).toContain("output only NO_REPLY at the end");
expect(turn?.prompt).toContain("comment_id: 7623358762119646411");
expect(turn?.prompt).toContain("reply_id: 7623358762136374451");
expect(turn?.prompt).toContain("The system will automatically reply with your final answer");
});
it("falls back to the replies API to resolve add_reply text", async () => {
@ -288,7 +268,7 @@ describe("resolveDriveCommentSyntheticEvent", () => {
targetReplyText: "Please follow up on this comment",
});
const synthetic = await resolveDriveCommentSyntheticEvent({
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent({
@ -302,22 +282,18 @@ describe("resolveDriveCommentSyntheticEvent", () => {
createClient: () => client as never,
});
const text = extractSyntheticText(synthetic as FeishuMessageEvent);
expect(text).toContain(
'I added a reply in "Comment event handling request": Please follow up on this comment',
expect(turn?.prompt).toContain(
'The user added a reply in "Comment event handling request": Please follow up on this comment',
);
expect(text).toContain(
expect(turn?.prompt).toContain(
"Original comment: Also send it to the agent after receiving the comment event",
);
expect(text).toContain(`file_token: ${TEST_DOC_TOKEN}`);
expect(text).toContain("Event type: add_reply");
expect(text).toContain(
"keep it in the same language as the user's original comment or reply unless they explicitly ask for another language",
);
expect(turn?.prompt).toContain(`file_token: ${TEST_DOC_TOKEN}`);
expect(turn?.prompt).toContain("Event type: add_reply");
});
it("ignores self-authored comment notices", async () => {
const synthetic = await resolveDriveCommentSyntheticEvent({
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent({
@ -330,7 +306,7 @@ describe("resolveDriveCommentSyntheticEvent", () => {
createClient: () => makeOpenApiClient({}) as never,
});
expect(synthetic).toBeNull();
expect(turn).toBeNull();
});
});

View File

@ -1,8 +1,8 @@
import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { raceWithTimeoutAndAbort } from "./async.js";
import type { FeishuMessageEvent } from "./bot.js";
import { createFeishuClient } from "./client.js";
import { normalizeCommentFileType, type CommentFileType } from "./comment-target.js";
import type { ResolvedFeishuAccount } from "./types.js";
const FEISHU_COMMENT_VERIFY_TIMEOUT_MS = 3_000;
@ -31,7 +31,7 @@ export type FeishuDriveCommentNoticeEvent = {
type?: string;
};
type ResolveDriveCommentSyntheticEventParams = {
type ResolveDriveCommentEventParams = {
cfg: ClawdbotConfig;
accountId: string;
event: FeishuDriveCommentNoticeEvent;
@ -48,7 +48,7 @@ export type ResolvedDriveCommentEventTurn = {
replyId?: string;
noticeType: "add_comment" | "add_reply";
fileToken: string;
fileType: "doc" | "docx" | "file" | "sheet" | "slides";
fileType: CommentFileType;
senderId: string;
timestamp?: string;
isMentioned?: boolean;
@ -93,7 +93,6 @@ type FeishuDriveCommentReply = {
type FeishuDriveCommentCard = {
comment_id?: string;
has_more?: boolean;
quote?: string;
reply_list?: {
replies?: FeishuDriveCommentReply[];
@ -122,18 +121,6 @@ function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function normalizeDriveCommentFileType(
value: unknown,
): "doc" | "docx" | "file" | "sheet" | "slides" | undefined {
return value === "doc" ||
value === "docx" ||
value === "file" ||
value === "sheet" ||
value === "slides"
? value
: undefined;
}
function encodeQuery(params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@ -148,8 +135,7 @@ function encodeQuery(params: Record<string, string | undefined>): string {
function buildDriveCommentTargetUrl(params: {
fileToken: string;
commentId: string;
fileType: string;
fileType: CommentFileType;
}): string {
return (
`/open-apis/drive/v1/files/${encodeURIComponent(params.fileToken)}/comments/batch_query` +
@ -163,7 +149,7 @@ function buildDriveCommentTargetUrl(params: {
function buildDriveCommentRepliesUrl(params: {
fileToken: string;
commentId: string;
fileType: string;
fileType: CommentFileType;
pageToken?: string;
}): string {
return (
@ -251,34 +237,18 @@ function extractReplyText(reply: FeishuDriveCommentReply | undefined): string |
return undefined;
}
const elements = Array.isArray(reply.content.elements) ? reply.content.elements : [];
const parts = elements
const text = elements
.map(extractCommentElementText)
.filter((part): part is string => Boolean(part && part.trim()));
const text = parts.join("").trim();
.filter((part): part is string => Boolean(part && part.trim()))
.join("")
.trim();
return text || undefined;
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch (error) {
return JSON.stringify({
error: `stringify_failed:${error instanceof Error ? error.message : String(error)}`,
});
}
}
function summarizeReplyForLog(reply: FeishuDriveCommentReply | undefined) {
return {
reply_id: reply?.reply_id,
text: extractReplyText(reply),
};
}
async function fetchDriveCommentReplies(params: {
client: FeishuRequestClient;
fileToken: string;
fileType: "doc" | "docx" | "file" | "sheet" | "slides";
fileType: CommentFileType;
commentId: string;
timeoutMs: number;
logger?: (message: string) => void;
@ -308,10 +278,6 @@ async function fetchDriveCommentReplies(params: {
}
break;
}
params.logger?.(
`feishu[${params.accountId}]: fetched comment replies page=${page + 1} ` +
`comment=${params.commentId} raw=${safeJsonStringify(response?.data?.items ?? [])}`,
);
replies.push(...(response.data?.items ?? []));
if (response.data?.has_more !== true || !response.data.page_token?.trim()) {
break;
@ -324,7 +290,7 @@ async function fetchDriveCommentReplies(params: {
async function fetchDriveCommentContext(params: {
client: FeishuRequestClient;
fileToken: string;
fileType: "doc" | "docx" | "file" | "sheet" | "slides";
fileType: CommentFileType;
commentId: string;
replyId?: string;
timeoutMs: number;
@ -355,7 +321,6 @@ async function fetchDriveCommentContext(params: {
method: "POST",
url: buildDriveCommentTargetUrl({
fileToken: params.fileToken,
commentId: params.commentId,
fileType: params.fileType,
}),
data: {
@ -374,10 +339,6 @@ async function fetchDriveCommentContext(params: {
) ?? commentResponse.data?.items?.[0])
: undefined;
const embeddedReplies = commentCard?.reply_list?.replies ?? [];
params.logger?.(
`feishu[${params.accountId}]: embedded comment replies comment=${params.commentId} ` +
`raw=${safeJsonStringify(embeddedReplies)}`,
);
const embeddedTargetReply = params.replyId
? embeddedReplies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: embeddedReplies.at(-1);
@ -396,24 +357,7 @@ async function fetchDriveCommentContext(params: {
: undefined;
const targetReply = params.replyId
? (embeddedTargetReply ?? fetchedMatchedReply ?? undefined)
: ((replies.at(-1) ?? embeddedTargetReply ?? rootReply) as FeishuDriveCommentReply | undefined);
const matchSource = params.replyId
? embeddedTargetReply
? "embedded"
: fetchedMatchedReply
? "fetched"
: "miss"
: targetReply === rootReply
? "fallback_root"
: targetReply === embeddedTargetReply
? "embedded_latest"
: "fetched_latest";
params.logger?.(
`feishu[${params.accountId}]: comment reply resolution comment=${params.commentId} ` +
`requested_reply=${params.replyId ?? "none"} match_source=${matchSource} ` +
`root=${safeJsonStringify(summarizeReplyForLog(rootReply))} ` +
`target=${safeJsonStringify(summarizeReplyForLog(targetReply))}`,
);
: (replies.at(-1) ?? embeddedTargetReply ?? rootReply);
const meta = metaResponse?.code === 0 ? metaResponse.data?.metas?.[0] : undefined;
return {
@ -425,60 +369,9 @@ async function fetchDriveCommentContext(params: {
};
}
function buildDriveCommentPrompt(params: {
noticeType: "add_comment" | "add_reply";
fileType: "doc" | "docx" | "file" | "sheet" | "slides";
fileToken: string;
isMentioned?: boolean;
documentTitle?: string;
documentUrl?: string;
quoteText?: string;
rootCommentText?: string;
targetReplyText?: string;
}): string {
const documentLabel = params.documentTitle
? `"${params.documentTitle}"`
: `${params.fileType} document ${params.fileToken}`;
const actionLabel = params.noticeType === "add_reply" ? "reply" : "comment";
const firstLine = params.targetReplyText
? `I added a ${actionLabel} in ${documentLabel}: ${params.targetReplyText}`
: `I added a ${actionLabel} in ${documentLabel}.`;
const lines = [firstLine];
if (
params.noticeType === "add_reply" &&
params.rootCommentText &&
params.rootCommentText !== params.targetReplyText
) {
lines.push(`Original comment: ${params.rootCommentText}`);
}
if (params.quoteText) {
lines.push(`Quoted content: ${params.quoteText}`);
}
if (params.isMentioned === true) {
lines.push("This comment mentioned you.");
}
if (params.documentUrl) {
lines.push(`Document link: ${params.documentUrl}`);
}
lines.push(
`Event type: ${params.noticeType}`,
`file_token: ${params.fileToken}`,
`file_type: ${params.fileType}`,
"This is a Feishu document comment event, not a normal instant-message conversation. Do not reply directly in the current Feishu chat, and do not treat this event as something that requires sending an IM text reply.",
"If you need to inspect or handle the comment thread, prefer the feishu_drive tools: use list_comments / list_comment_replies to inspect comments, add_comment to add a new comment, and reply_comment to reply in the thread.",
"If the user asks a question in the comment, reply with the answer in that comment thread via feishu_drive.reply_comment.",
"If you modify the document, after finishing also use feishu_drive.reply_comment in that comment thread to tell the user the update is complete.",
"If you decide to add a new comment in the document, use feishu_drive.add_comment; if you decide to reply in the current comment thread, use feishu_drive.reply_comment.",
"When you produce a user-visible reply, keep it in the same language as the user's original comment or reply unless they explicitly ask for another language.",
"After comment-related tool calls have completed the user-visible action, output only NO_REPLY at the end to avoid sending an extra instant message; do not output the final answer directly as a normal chat reply.",
);
lines.push(`Decide what to do next based on this document ${actionLabel} event.`);
return lines.join("\n");
}
function buildDriveCommentSurfacePrompt(params: {
noticeType: "add_comment" | "add_reply";
fileType: "doc" | "docx" | "file" | "sheet" | "slides";
fileType: CommentFileType;
fileToken: string;
commentId: string;
replyId?: string;
@ -542,15 +435,13 @@ function buildDriveCommentSurfacePrompt(params: {
return lines.join("\n");
}
async function resolveDriveCommentEventCore(
params: ResolveDriveCommentSyntheticEventParams,
): Promise<{
async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventParams): Promise<{
eventId: string;
commentId: string;
replyId?: string;
noticeType: "add_comment" | "add_reply";
fileToken: string;
fileType: "doc" | "docx" | "file" | "sheet" | "slides";
fileType: CommentFileType;
senderId: string;
timestamp?: string;
isMentioned?: boolean;
@ -576,36 +467,27 @@ async function resolveDriveCommentEventCore(
const replyId = event.reply_id?.trim();
const noticeType = event.notice_meta?.notice_type?.trim();
const fileToken = event.notice_meta?.file_token?.trim();
const fileType = normalizeDriveCommentFileType(event.notice_meta?.file_type);
const fileType = normalizeCommentFileType(event.notice_meta?.file_type);
const senderId = event.notice_meta?.from_user_id?.open_id?.trim();
if (!eventId || !commentId || !noticeType || !fileToken || !fileType || !senderId) {
logger?.(
`feishu[${accountId}]: drive comment notice missing required fields ` +
`event=${eventId ?? "unknown"} comment=${commentId ?? "unknown"}`,
`feishu[${accountId}]: drive comment notice missing required fields event=${eventId ?? "unknown"} comment=${commentId ?? "unknown"}`,
);
return null;
}
if (noticeType !== "add_comment" && noticeType !== "add_reply") {
logger?.(
`feishu[${accountId}]: unsupported drive comment notice type ${noticeType} ` +
`for event ${eventId}`,
);
logger?.(`feishu[${accountId}]: unsupported drive comment notice type ${noticeType}`);
return null;
}
if (senderId === botOpenId) {
logger?.(
`feishu[${accountId}]: ignoring self-authored drive comment notice ` +
`event=${eventId} sender=${senderId}`,
`feishu[${accountId}]: ignoring self-authored drive comment notice event=${eventId} sender=${senderId}`,
);
return null;
}
const account = resolveFeishuAccount({ cfg, accountId });
const client = createClient(account);
logger?.(
`feishu[${accountId}]: fetching drive comment context ` +
`event=${eventId} type=${noticeType} file=${fileType}:${fileToken} comment=${commentId} reply=${replyId ?? "none"}`,
);
const context = await fetchDriveCommentContext({
client,
fileToken,
@ -616,12 +498,6 @@ async function resolveDriveCommentEventCore(
logger,
accountId,
});
logger?.(
`feishu[${accountId}]: drive comment context resolved ` +
`event=${eventId} title=${context.documentTitle ?? "unknown"} ` +
`quote=${context.quoteText ? "yes" : "no"} root=${context.rootCommentText ? "yes" : "no"} ` +
`target=${context.targetReplyText ? "yes" : "no"}`,
);
return {
eventId,
commentId,
@ -675,7 +551,7 @@ export function parseFeishuDriveCommentNoticeEventPayload(
}
export async function resolveDriveCommentEventTurn(
params: ResolveDriveCommentSyntheticEventParams,
params: ResolveDriveCommentEventParams,
): Promise<ResolvedDriveCommentEventTurn | null> {
const resolved = await resolveDriveCommentEventCore(params);
if (!resolved) {
@ -695,10 +571,6 @@ export async function resolveDriveCommentEventTurn(
targetReplyText: resolved.context.targetReplyText,
});
const preview = prompt.replace(/\s+/g, " ").slice(0, 160);
params.logger?.(
`feishu[${params.accountId}]: built drive comment prompt ` +
`event=${resolved.eventId} preview=${preview}`,
);
return {
eventId: resolved.eventId,
messageId: `drive-comment:${resolved.eventId}`,
@ -719,43 +591,3 @@ export async function resolveDriveCommentEventTurn(
preview,
};
}
export async function resolveDriveCommentSyntheticEvent(
params: ResolveDriveCommentSyntheticEventParams,
): Promise<FeishuMessageEvent | null> {
const resolved = await resolveDriveCommentEventCore(params);
if (!resolved) {
return null;
}
const prompt = buildDriveCommentPrompt({
noticeType: resolved.noticeType,
fileType: resolved.fileType,
fileToken: resolved.fileToken,
isMentioned: resolved.isMentioned,
documentTitle: resolved.context.documentTitle,
documentUrl: resolved.context.documentUrl,
quoteText: resolved.context.quoteText,
rootCommentText: resolved.context.rootCommentText,
targetReplyText: resolved.context.targetReplyText,
});
const preview = prompt.replace(/\s+/g, " ").slice(0, 160);
params.logger?.(
`feishu[${params.accountId}]: built drive comment synthetic prompt ` +
`event=${resolved.eventId} preview=${preview}`,
);
return {
sender: {
sender_id: { open_id: resolved.senderId },
sender_type: "user",
},
message: {
message_id: `drive-comment:${resolved.eventId}`,
chat_id: `p2p:${resolved.senderId}`,
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: prompt }),
create_time: resolved.timestamp,
},
};
}

View File

@ -8,6 +8,7 @@ const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
const replyCommentMock = vi.hoisted(() => vi.fn());
vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock,
@ -29,6 +30,14 @@ vi.mock("./runtime.js", () => ({
}),
}));
vi.mock("./client.js", () => ({
createFeishuClient: vi.fn(() => ({ request: vi.fn() })),
}));
vi.mock("./drive.js", () => ({
replyComment: replyCommentMock,
}));
import { feishuOutbound } from "./outbound.js";
const sendText = feishuOutbound.sendText!;
const emptyConfig: ClawdbotConfig = {};
@ -46,6 +55,7 @@ function resetOutboundMocks() {
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
replyCommentMock.mockResolvedValue({ reply_id: "reply_msg" });
}
describe("feishuOutbound.sendText local-image auto-convert", () => {
@ -199,6 +209,52 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
});
});
describe("feishuOutbound comment-thread routing", () => {
beforeEach(() => {
resetOutboundMocks();
});
it("routes comment-thread text through replyComment", async () => {
const result = await sendText({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411",
text: "handled in thread",
accountId: "main",
});
expect(replyCommentMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doxcn123",
file_type: "docx",
comment_id: "7623358762119646411",
content: "handled in thread",
}),
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" }));
});
it("falls back to a text-only comment reply for media payloads", async () => {
const result = await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411",
text: "see attachment",
mediaUrl: "https://example.com/file.png",
accountId: "main",
});
expect(replyCommentMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
content: "see attachment\n\nhttps://example.com/file.png",
}),
);
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" }));
});
});
describe("feishuOutbound.sendText replyToId forwarding", () => {
beforeEach(() => {
resetOutboundMocks();

View File

@ -3,6 +3,9 @@ import path from "path";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { parseFeishuCommentTarget } from "./comment-target.js";
import { replyComment } from "./drive.js";
import { sendMediaFeishu } from "./media.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
@ -59,6 +62,31 @@ function resolveReplyToMessageId(params: {
return trimmed || undefined;
}
async function sendCommentThreadReply(params: {
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
to: string;
text: string;
accountId?: string;
}) {
const target = parseFeishuCommentTarget(params.to);
if (!target) {
return null;
}
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const client = createFeishuClient(account);
const result = await replyComment(client, {
file_token: target.fileToken,
file_type: target.fileType,
comment_id: target.commentId,
content: params.text,
});
return {
messageId: typeof result.reply_id === "string" ? result.reply_id : "",
chatId: target.commentId,
result,
};
}
async function sendOutboundText(params: {
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
to: string;
@ -67,6 +95,16 @@ async function sendOutboundText(params: {
accountId?: string;
}) {
const { cfg, to, text, accountId, replyToMessageId } = params;
const commentResult = await sendCommentThreadReply({
cfg,
to,
text,
accountId,
});
if (commentResult) {
return commentResult;
}
const account = resolveFeishuAccount({ cfg, accountId });
const renderMode = account.config?.renderMode ?? "auto";
@ -156,6 +194,18 @@ export const feishuOutbound: ChannelOutboundAdapter = {
threadId,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
const commentTarget = parseFeishuCommentTarget(to);
if (commentTarget) {
const commentText = [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n");
return await sendOutboundText({
cfg,
to,
text: commentText || mediaUrl || text || "",
accountId: accountId ?? undefined,
replyToMessageId,
});
}
// Send text first if provided
if (text?.trim()) {
await sendOutboundText({