mirror of https://github.com/openclaw/openclaw.git
Feishu: wire Drive comment threads into routed replies
This commit is contained in:
parent
0a2d17b733
commit
6d981e17ea
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue