mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-08 15:51:06 +00:00
* fix: harden Feishu comment-thread delivery * fix: harden Feishu comment-thread delivery (#59129) (thanks @wittam-01) --------- Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
893 lines
26 KiB
TypeScript
893 lines
26 KiB
TypeScript
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
import type { OpenClawPluginApi } from "../runtime-api.js";
|
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js";
|
|
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
|
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
|
import {
|
|
jsonToolResult,
|
|
toolExecutionErrorResult,
|
|
unknownToolActionResult,
|
|
} from "./tool-result.js";
|
|
|
|
// ============ Actions ============
|
|
|
|
type FeishuExplorerRootFolderMetaResponse = {
|
|
code: number;
|
|
msg?: string;
|
|
data?: {
|
|
token?: string;
|
|
};
|
|
};
|
|
|
|
type FeishuDriveInternalClient = Lark.Client & {
|
|
domain?: string;
|
|
httpInstance: Pick<Lark.HttpInstance, "get">;
|
|
request(params: {
|
|
method: "GET" | "POST";
|
|
url: string;
|
|
params?: Record<string, string | undefined>;
|
|
data: unknown;
|
|
timeout?: number;
|
|
}): Promise<unknown>;
|
|
};
|
|
|
|
type FeishuDriveApiResponse<T> = {
|
|
code: number;
|
|
log_id?: string;
|
|
msg?: string;
|
|
data?: T;
|
|
};
|
|
|
|
class FeishuReplyCommentError extends Error {
|
|
httpStatus?: number;
|
|
feishuCode?: number | string;
|
|
feishuMsg?: string;
|
|
feishuLogId?: string;
|
|
|
|
constructor(params: {
|
|
message: string;
|
|
httpStatus?: number;
|
|
feishuCode?: number | string;
|
|
feishuMsg?: string;
|
|
feishuLogId?: string;
|
|
}) {
|
|
super(params.message);
|
|
this.name = "FeishuReplyCommentError";
|
|
this.httpStatus = params.httpStatus;
|
|
this.feishuCode = params.feishuCode;
|
|
this.feishuMsg = params.feishuMsg;
|
|
this.feishuLogId = params.feishuLogId;
|
|
}
|
|
}
|
|
|
|
type FeishuDriveCommentReply = {
|
|
reply_id?: string;
|
|
user_id?: string;
|
|
create_time?: number;
|
|
update_time?: number;
|
|
content?: {
|
|
elements?: unknown[];
|
|
};
|
|
};
|
|
|
|
type FeishuDriveCommentCard = {
|
|
comment_id?: string;
|
|
user_id?: string;
|
|
create_time?: number;
|
|
update_time?: number;
|
|
is_solved?: boolean;
|
|
is_whole?: boolean;
|
|
has_more?: boolean;
|
|
page_token?: string;
|
|
quote?: string;
|
|
reply_list?: {
|
|
replies?: FeishuDriveCommentReply[];
|
|
};
|
|
};
|
|
|
|
type FeishuDriveListCommentsResponse = FeishuDriveApiResponse<{
|
|
has_more?: boolean;
|
|
items?: FeishuDriveCommentCard[];
|
|
page_token?: string;
|
|
}>;
|
|
|
|
type FeishuDriveListRepliesResponse = FeishuDriveApiResponse<{
|
|
has_more?: boolean;
|
|
items?: FeishuDriveCommentReply[];
|
|
page_token?: string;
|
|
}>;
|
|
|
|
type FeishuDriveToolContext = {
|
|
deliveryContext?: {
|
|
channel?: string;
|
|
to?: string;
|
|
};
|
|
};
|
|
|
|
const FEISHU_DRIVE_REQUEST_TIMEOUT_MS = 30_000;
|
|
|
|
function getDriveInternalClient(client: Lark.Client): FeishuDriveInternalClient {
|
|
return client as FeishuDriveInternalClient;
|
|
}
|
|
|
|
function encodeQuery(params: Record<string, string | undefined>): string {
|
|
const search = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(params)) {
|
|
const trimmed = value?.trim();
|
|
if (trimmed) {
|
|
search.set(key, trimmed);
|
|
}
|
|
}
|
|
const query = search.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
function readString(value: unknown): string | undefined {
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function extractCommentElementText(element: unknown): string | undefined {
|
|
if (!isRecord(element)) {
|
|
return undefined;
|
|
}
|
|
const type = readString(element.type)?.trim();
|
|
if (type === "text_run" && isRecord(element.text_run)) {
|
|
return (
|
|
readString(element.text_run.content)?.trim() ||
|
|
readString(element.text_run.text)?.trim() ||
|
|
undefined
|
|
);
|
|
}
|
|
if (type === "mention") {
|
|
const mention = isRecord(element.mention) ? element.mention : undefined;
|
|
const mentionName =
|
|
readString(mention?.name)?.trim() ||
|
|
readString(mention?.display_name)?.trim() ||
|
|
readString(element.name)?.trim();
|
|
return mentionName ? `@${mentionName}` : "@mention";
|
|
}
|
|
if (type === "docs_link") {
|
|
const docsLink = isRecord(element.docs_link) ? element.docs_link : undefined;
|
|
return (
|
|
readString(docsLink?.text)?.trim() ||
|
|
readString(docsLink?.url)?.trim() ||
|
|
readString(element.text)?.trim() ||
|
|
readString(element.url)?.trim() ||
|
|
undefined
|
|
);
|
|
}
|
|
return (
|
|
readString(element.text)?.trim() ||
|
|
readString(element.content)?.trim() ||
|
|
readString(element.name)?.trim() ||
|
|
undefined
|
|
);
|
|
}
|
|
|
|
function extractReplyText(reply: FeishuDriveCommentReply | undefined): string | undefined {
|
|
if (!reply || !isRecord(reply.content)) {
|
|
return undefined;
|
|
}
|
|
const elements = Array.isArray(reply.content.elements) ? reply.content.elements : [];
|
|
const text = elements
|
|
.map(extractCommentElementText)
|
|
.filter((part): part is string => Boolean(part && part.trim()))
|
|
.join("")
|
|
.trim();
|
|
return text || undefined;
|
|
}
|
|
|
|
function buildReplyElements(content: string) {
|
|
return [{ type: "text", text: content }];
|
|
}
|
|
|
|
async function requestDriveApi<T>(params: {
|
|
client: Lark.Client;
|
|
method: "GET" | "POST";
|
|
url: string;
|
|
query?: Record<string, string | undefined>;
|
|
data?: unknown;
|
|
}): Promise<T> {
|
|
const internalClient = getDriveInternalClient(params.client);
|
|
return (await internalClient.request({
|
|
method: params.method,
|
|
url: params.url,
|
|
params: params.query ?? {},
|
|
data: params.data ?? {},
|
|
timeout: FEISHU_DRIVE_REQUEST_TIMEOUT_MS,
|
|
})) as T;
|
|
}
|
|
|
|
function assertDriveApiSuccess<T extends { code: number; msg?: string }>(response: T): T {
|
|
if (response.code !== 0) {
|
|
throw new Error(response.msg ?? "Feishu Drive API request failed");
|
|
}
|
|
return response;
|
|
}
|
|
|
|
function normalizeCommentReply(reply: FeishuDriveCommentReply) {
|
|
return {
|
|
reply_id: reply.reply_id,
|
|
user_id: reply.user_id,
|
|
create_time: reply.create_time,
|
|
update_time: reply.update_time,
|
|
text: extractReplyText(reply),
|
|
};
|
|
}
|
|
|
|
function normalizeCommentCard(comment: FeishuDriveCommentCard) {
|
|
const replies = comment.reply_list?.replies ?? [];
|
|
const rootReply = replies[0];
|
|
return {
|
|
comment_id: comment.comment_id,
|
|
user_id: comment.user_id,
|
|
create_time: comment.create_time,
|
|
update_time: comment.update_time,
|
|
is_solved: comment.is_solved,
|
|
is_whole: comment.is_whole,
|
|
quote: comment.quote,
|
|
text: extractReplyText(rootReply),
|
|
has_more_replies: comment.has_more,
|
|
replies_page_token: comment.page_token,
|
|
replies: replies.slice(1).map(normalizeCommentReply),
|
|
};
|
|
}
|
|
|
|
function normalizeCommentPageSize(pageSize: number | undefined): string | undefined {
|
|
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
|
|
return undefined;
|
|
}
|
|
return String(Math.min(Math.max(Math.floor(pageSize), 1), 100));
|
|
}
|
|
|
|
function resolveAmbientCommentTarget(context: FeishuDriveToolContext | undefined) {
|
|
const deliveryContext = context?.deliveryContext;
|
|
if (deliveryContext?.channel && deliveryContext.channel !== "feishu") {
|
|
return null;
|
|
}
|
|
return parseFeishuCommentTarget(deliveryContext?.to);
|
|
}
|
|
|
|
function applyAmbientCommentDefaults<
|
|
T extends {
|
|
file_token?: string;
|
|
file_type?: CommentFileType;
|
|
comment_id?: string;
|
|
},
|
|
>(params: T, context: FeishuDriveToolContext | undefined): T {
|
|
const ambient = resolveAmbientCommentTarget(context);
|
|
if (!ambient) {
|
|
return params;
|
|
}
|
|
return {
|
|
...params,
|
|
file_token: params.file_token?.trim() || ambient.fileToken,
|
|
file_type: params.file_type ?? ambient.fileType,
|
|
comment_id: params.comment_id?.trim() || ambient.commentId,
|
|
};
|
|
}
|
|
|
|
function applyAddCommentAmbientDefaults<
|
|
T extends {
|
|
file_token?: string;
|
|
file_type?: "doc" | "docx";
|
|
},
|
|
>(params: T, context: FeishuDriveToolContext | undefined): T {
|
|
const ambient = resolveAmbientCommentTarget(context);
|
|
if (!ambient || (ambient.fileType !== "doc" && ambient.fileType !== "docx")) {
|
|
return params;
|
|
}
|
|
return {
|
|
...params,
|
|
file_token: params.file_token?.trim() || ambient.fileToken,
|
|
file_type: params.file_type ?? ambient.fileType,
|
|
};
|
|
}
|
|
|
|
function applyAddCommentDefaults<
|
|
T extends {
|
|
file_token?: string;
|
|
file_type?: "doc" | "docx";
|
|
},
|
|
>(params: T): T & { file_type: "doc" | "docx" } {
|
|
const fileType = params.file_type ?? "docx";
|
|
if (!params.file_type) {
|
|
console.info(
|
|
`[feishu_drive] add_comment missing file_type; defaulting to docx ` +
|
|
`file_token=${params.file_token ?? "unknown"}`,
|
|
);
|
|
}
|
|
return {
|
|
...params,
|
|
file_type: fileType,
|
|
};
|
|
}
|
|
|
|
function applyCommentFileTypeDefault<
|
|
T extends {
|
|
file_token?: string;
|
|
file_type?: CommentFileType;
|
|
},
|
|
>(
|
|
params: T,
|
|
action: "list_comments" | "list_comment_replies" | "reply_comment",
|
|
): T & {
|
|
file_type: CommentFileType;
|
|
} {
|
|
const fileType = params.file_type ?? "docx";
|
|
if (!params.file_type) {
|
|
console.info(
|
|
`[feishu_drive] ${action} missing file_type; defaulting to docx ` +
|
|
`file_token=${params.file_token ?? "unknown"}`,
|
|
);
|
|
}
|
|
return {
|
|
...params,
|
|
file_type: fileType,
|
|
};
|
|
}
|
|
|
|
function formatDriveApiError(error: unknown): string {
|
|
if (!isRecord(error)) {
|
|
return String(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 : String(error),
|
|
code: readString(error.code),
|
|
method: readString(isRecord(error.config) ? error.config.method : undefined),
|
|
url: readString(isRecord(error.config) ? error.config.url : undefined),
|
|
params: isRecord(error.config) ? error.config.params : 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),
|
|
});
|
|
}
|
|
|
|
function extractDriveApiErrorMeta(error: unknown): {
|
|
message: string;
|
|
httpStatus?: number;
|
|
feishuCode?: number | string;
|
|
feishuMsg?: string;
|
|
feishuLogId?: string;
|
|
} {
|
|
if (!isRecord(error)) {
|
|
return { message: String(error) };
|
|
}
|
|
const response = isRecord(error.response) ? error.response : undefined;
|
|
const responseData = isRecord(response?.data) ? response?.data : undefined;
|
|
return {
|
|
message: typeof error.message === "string" ? error.message : String(error),
|
|
httpStatus: typeof response?.status === "number" ? response.status : undefined,
|
|
feishuCode:
|
|
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
|
|
feishuMsg: readString(responseData?.msg),
|
|
feishuLogId: readString(responseData?.log_id),
|
|
};
|
|
}
|
|
|
|
function isReplyNotAllowedError(error: unknown): boolean {
|
|
if (!(error instanceof FeishuReplyCommentError)) {
|
|
return false;
|
|
}
|
|
return error.feishuCode === 1069302;
|
|
}
|
|
|
|
async function getRootFolderToken(client: Lark.Client): Promise<string> {
|
|
// Use generic HTTP client to call the root folder meta API
|
|
// as it's not directly exposed in the SDK
|
|
const internalClient = getDriveInternalClient(client);
|
|
const domain = internalClient.domain ?? "https://open.feishu.cn";
|
|
const res = (await internalClient.httpInstance.get(
|
|
`${domain}/open-apis/drive/explorer/v2/root_folder/meta`,
|
|
)) as FeishuExplorerRootFolderMetaResponse;
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg ?? "Failed to get root folder");
|
|
}
|
|
const token = res.data?.token;
|
|
if (!token) {
|
|
throw new Error("Root folder token not found");
|
|
}
|
|
return token;
|
|
}
|
|
|
|
async function listFolder(client: Lark.Client, folderToken?: string) {
|
|
// Filter out invalid folder_token values (empty, "0", etc.)
|
|
const validFolderToken = folderToken && folderToken !== "0" ? folderToken : undefined;
|
|
const res = await client.drive.file.list({
|
|
params: validFolderToken ? { folder_token: validFolderToken } : {},
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
|
|
return {
|
|
files:
|
|
res.data?.files?.map((f) => ({
|
|
token: f.token,
|
|
name: f.name,
|
|
type: f.type,
|
|
url: f.url,
|
|
created_time: f.created_time,
|
|
modified_time: f.modified_time,
|
|
owner_id: f.owner_id,
|
|
})) ?? [],
|
|
next_page_token: res.data?.next_page_token,
|
|
};
|
|
}
|
|
|
|
async function getFileInfo(client: Lark.Client, fileToken: string, folderToken?: string) {
|
|
// Use list with folder_token to find file info
|
|
const res = await client.drive.file.list({
|
|
params: folderToken ? { folder_token: folderToken } : {},
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
|
|
const file = res.data?.files?.find((f) => f.token === fileToken);
|
|
if (!file) {
|
|
throw new Error(`File not found: ${fileToken}`);
|
|
}
|
|
|
|
return {
|
|
token: file.token,
|
|
name: file.name,
|
|
type: file.type,
|
|
url: file.url,
|
|
created_time: file.created_time,
|
|
modified_time: file.modified_time,
|
|
owner_id: file.owner_id,
|
|
};
|
|
}
|
|
|
|
async function createFolder(client: Lark.Client, name: string, folderToken?: string) {
|
|
// Feishu supports using folder_token="0" as the root folder.
|
|
// We *try* to resolve the real root token (explorer API), but fall back to "0"
|
|
// because some tenants/apps return 400 for that explorer endpoint.
|
|
let effectiveToken = folderToken && folderToken !== "0" ? folderToken : "0";
|
|
if (effectiveToken === "0") {
|
|
try {
|
|
effectiveToken = await getRootFolderToken(client);
|
|
} catch {
|
|
// ignore and keep "0"
|
|
}
|
|
}
|
|
|
|
const res = await client.drive.file.createFolder({
|
|
data: {
|
|
name,
|
|
folder_token: effectiveToken,
|
|
},
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
|
|
return {
|
|
token: res.data?.token,
|
|
url: res.data?.url,
|
|
};
|
|
}
|
|
|
|
async function moveFile(client: Lark.Client, fileToken: string, type: string, folderToken: string) {
|
|
const res = await client.drive.file.move({
|
|
path: { file_token: fileToken },
|
|
data: {
|
|
type: type as
|
|
| "doc"
|
|
| "docx"
|
|
| "sheet"
|
|
| "bitable"
|
|
| "folder"
|
|
| "file"
|
|
| "mindnote"
|
|
| "slides",
|
|
folder_token: folderToken,
|
|
},
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
task_id: res.data?.task_id,
|
|
};
|
|
}
|
|
|
|
async function deleteFile(client: Lark.Client, fileToken: string, type: string) {
|
|
const res = await client.drive.file.delete({
|
|
path: { file_token: fileToken },
|
|
params: {
|
|
type: type as
|
|
| "doc"
|
|
| "docx"
|
|
| "sheet"
|
|
| "bitable"
|
|
| "folder"
|
|
| "file"
|
|
| "mindnote"
|
|
| "slides"
|
|
| "shortcut",
|
|
},
|
|
});
|
|
if (res.code !== 0) {
|
|
throw new Error(res.msg);
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
task_id: res.data?.task_id,
|
|
};
|
|
}
|
|
|
|
async function listComments(
|
|
client: Lark.Client,
|
|
params: {
|
|
file_token: string;
|
|
file_type: CommentFileType;
|
|
page_size?: number;
|
|
page_token?: string;
|
|
},
|
|
) {
|
|
const response = assertDriveApiSuccess(
|
|
await requestDriveApi<FeishuDriveListCommentsResponse>({
|
|
client,
|
|
method: "GET",
|
|
url:
|
|
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments` +
|
|
encodeQuery({
|
|
file_type: params.file_type,
|
|
page_size: normalizeCommentPageSize(params.page_size),
|
|
page_token: params.page_token,
|
|
user_id_type: "open_id",
|
|
}),
|
|
}),
|
|
);
|
|
return {
|
|
has_more: response.data?.has_more ?? false,
|
|
page_token: response.data?.page_token,
|
|
comments: (response.data?.items ?? []).map(normalizeCommentCard),
|
|
};
|
|
}
|
|
|
|
async function listCommentReplies(
|
|
client: Lark.Client,
|
|
params: {
|
|
file_token: string;
|
|
file_type: CommentFileType;
|
|
comment_id: string;
|
|
page_size?: number;
|
|
page_token?: string;
|
|
},
|
|
) {
|
|
const response = assertDriveApiSuccess(
|
|
await requestDriveApi<FeishuDriveListRepliesResponse>({
|
|
client,
|
|
method: "GET",
|
|
url:
|
|
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
|
|
params.comment_id,
|
|
)}/replies` +
|
|
encodeQuery({
|
|
file_type: params.file_type,
|
|
page_size: normalizeCommentPageSize(params.page_size),
|
|
page_token: params.page_token,
|
|
user_id_type: "open_id",
|
|
}),
|
|
}),
|
|
);
|
|
return {
|
|
has_more: response.data?.has_more ?? false,
|
|
page_token: response.data?.page_token,
|
|
replies: (response.data?.items ?? []).map(normalizeCommentReply),
|
|
};
|
|
}
|
|
|
|
async function addComment(
|
|
client: Lark.Client,
|
|
params: {
|
|
file_token: string;
|
|
file_type: "doc" | "docx";
|
|
content: string;
|
|
block_id?: string;
|
|
},
|
|
): Promise<{ success: true } & Record<string, unknown>> {
|
|
if (params.block_id?.trim() && params.file_type !== "docx") {
|
|
throw new Error("block_id is only supported for docx comments");
|
|
}
|
|
const response = assertDriveApiSuccess(
|
|
await requestDriveApi<FeishuDriveApiResponse<Record<string, unknown>>>({
|
|
client,
|
|
method: "POST",
|
|
url: `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/new_comments`,
|
|
data: {
|
|
file_type: params.file_type,
|
|
reply_elements: buildReplyElements(params.content),
|
|
...(params.block_id?.trim() ? { anchor: { block_id: params.block_id.trim() } } : {}),
|
|
},
|
|
}),
|
|
);
|
|
return {
|
|
success: true,
|
|
...response.data,
|
|
};
|
|
}
|
|
|
|
// Fetch comment metadata via batch_query because the single-comment endpoint
|
|
// does not support partial comments.
|
|
async function queryCommentById(
|
|
client: Lark.Client,
|
|
params: {
|
|
file_token: string;
|
|
file_type: CommentFileType;
|
|
comment_id: string;
|
|
},
|
|
) {
|
|
const response = assertDriveApiSuccess(
|
|
await requestDriveApi<FeishuDriveListCommentsResponse>({
|
|
client,
|
|
method: "POST",
|
|
url:
|
|
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/batch_query` +
|
|
encodeQuery({
|
|
file_type: params.file_type,
|
|
user_id_type: "open_id",
|
|
}),
|
|
data: {
|
|
comment_ids: [params.comment_id],
|
|
},
|
|
}),
|
|
);
|
|
return response.data?.items?.find((comment) => comment.comment_id?.trim() === params.comment_id);
|
|
}
|
|
|
|
export async function replyComment(
|
|
client: Lark.Client,
|
|
params: {
|
|
file_token: string;
|
|
file_type: CommentFileType;
|
|
comment_id: string;
|
|
content: string;
|
|
},
|
|
): Promise<{ success: true; reply_id?: string } & Record<string, unknown>> {
|
|
const url = `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
|
|
params.comment_id,
|
|
)}/replies`;
|
|
const query = { file_type: params.file_type };
|
|
try {
|
|
const response = (await requestDriveApi<FeishuDriveApiResponse<Record<string, unknown>>>({
|
|
client,
|
|
method: "POST",
|
|
url,
|
|
query,
|
|
data: {
|
|
content: {
|
|
elements: [
|
|
{
|
|
type: "text_run",
|
|
text_run: {
|
|
text: params.content,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})) as FeishuDriveApiResponse<Record<string, unknown>>;
|
|
if (response.code === 0) {
|
|
return {
|
|
success: true,
|
|
...response.data,
|
|
};
|
|
}
|
|
console.warn(
|
|
`[feishu_drive] replyComment failed ` +
|
|
`comment=${params.comment_id} file_type=${params.file_type} ` +
|
|
`code=${response.code ?? "unknown"} ` +
|
|
`msg=${response.msg ?? "unknown"} log_id=${response.log_id ?? "unknown"}`,
|
|
);
|
|
throw new FeishuReplyCommentError({
|
|
message: response.msg ?? "Feishu Drive reply comment failed",
|
|
feishuCode: response.code,
|
|
feishuMsg: response.msg,
|
|
feishuLogId: response.log_id,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof FeishuReplyCommentError) {
|
|
throw error;
|
|
}
|
|
const meta = extractDriveApiErrorMeta(error);
|
|
console.warn(
|
|
`[feishu_drive] replyComment threw ` +
|
|
`comment=${params.comment_id} file_type=${params.file_type} ` +
|
|
`error=${formatDriveApiError(error)}`,
|
|
);
|
|
throw new FeishuReplyCommentError({
|
|
message: meta.message,
|
|
httpStatus: meta.httpStatus,
|
|
feishuCode: meta.feishuCode,
|
|
feishuMsg: meta.feishuMsg,
|
|
feishuLogId: meta.feishuLogId,
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function deliverCommentThreadText(
|
|
client: Lark.Client,
|
|
params: {
|
|
file_token: string;
|
|
file_type: CommentFileType;
|
|
comment_id: string;
|
|
content: string;
|
|
is_whole_comment?: boolean;
|
|
},
|
|
): Promise<
|
|
| ({ success: true; reply_id?: string } & Record<string, unknown> & {
|
|
delivery_mode: "reply_comment";
|
|
})
|
|
| ({ success: true; comment_id?: string } & Record<string, unknown> & {
|
|
delivery_mode: "add_comment";
|
|
})
|
|
> {
|
|
let isWholeComment = params.is_whole_comment;
|
|
if (isWholeComment === undefined) {
|
|
try {
|
|
const comment = await queryCommentById(client, params);
|
|
isWholeComment = comment?.is_whole === true;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[feishu_drive] comment metadata preflight failed ` +
|
|
`comment=${params.comment_id} file_type=${params.file_type} ` +
|
|
`error=${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
isWholeComment = false;
|
|
}
|
|
}
|
|
if (isWholeComment) {
|
|
if (params.file_type !== "doc" && params.file_type !== "docx") {
|
|
throw new Error(
|
|
`Whole-document comment follow-ups are only supported for doc/docx (got ${params.file_type})`,
|
|
);
|
|
}
|
|
const wholeCommentFileType: "doc" | "docx" = params.file_type;
|
|
console.info(
|
|
`[feishu_drive] whole-comment compatibility path ` +
|
|
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment`,
|
|
);
|
|
return {
|
|
delivery_mode: "add_comment",
|
|
...(await addComment(client, {
|
|
file_token: params.file_token,
|
|
file_type: wholeCommentFileType,
|
|
content: params.content,
|
|
})),
|
|
};
|
|
}
|
|
try {
|
|
return {
|
|
delivery_mode: "reply_comment",
|
|
...(await replyComment(client, params)),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof FeishuReplyCommentError && isReplyNotAllowedError(error)) {
|
|
if (params.file_type !== "doc" && params.file_type !== "docx") {
|
|
throw error;
|
|
}
|
|
const fallbackFileType: "doc" | "docx" = params.file_type;
|
|
console.info(
|
|
`[feishu_drive] reply-not-allowed compatibility path ` +
|
|
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment ` +
|
|
`log_id=${error.feishuLogId ?? "unknown"}`,
|
|
);
|
|
return {
|
|
delivery_mode: "add_comment",
|
|
...(await addComment(client, {
|
|
file_token: params.file_token,
|
|
file_type: fallbackFileType,
|
|
content: params.content,
|
|
})),
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============ Tool Registration ============
|
|
|
|
export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
|
if (!api.config) {
|
|
api.logger.debug?.("feishu_drive: No config available, skipping drive tools");
|
|
return;
|
|
}
|
|
|
|
const accounts = listEnabledFeishuAccounts(api.config);
|
|
if (accounts.length === 0) {
|
|
api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
|
|
return;
|
|
}
|
|
|
|
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
|
if (!toolsCfg.drive) {
|
|
api.logger.debug?.("feishu_drive: drive tool disabled in config");
|
|
return;
|
|
}
|
|
|
|
type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
|
|
|
|
api.registerTool(
|
|
(ctx) => {
|
|
const defaultAccountId = ctx.agentAccountId;
|
|
return {
|
|
name: "feishu_drive",
|
|
label: "Feishu Drive",
|
|
description:
|
|
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete, list_comments, list_comment_replies, add_comment, reply_comment",
|
|
parameters: FeishuDriveSchema,
|
|
async execute(_toolCallId, params) {
|
|
const p = params as FeishuDriveExecuteParams;
|
|
try {
|
|
const client = createFeishuToolClient({
|
|
api,
|
|
executeParams: p,
|
|
defaultAccountId,
|
|
});
|
|
switch (p.action) {
|
|
case "list":
|
|
return jsonToolResult(await listFolder(client, p.folder_token));
|
|
case "info":
|
|
return jsonToolResult(await getFileInfo(client, p.file_token));
|
|
case "create_folder":
|
|
return jsonToolResult(await createFolder(client, p.name, p.folder_token));
|
|
case "move":
|
|
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": {
|
|
const resolved = applyCommentFileTypeDefault(
|
|
applyAmbientCommentDefaults(p, ctx),
|
|
"list_comments",
|
|
);
|
|
return jsonToolResult(await listComments(client, resolved));
|
|
}
|
|
case "list_comment_replies": {
|
|
const resolved = applyCommentFileTypeDefault(
|
|
applyAmbientCommentDefaults(p, ctx),
|
|
"list_comment_replies",
|
|
);
|
|
return jsonToolResult(await listCommentReplies(client, resolved));
|
|
}
|
|
case "add_comment": {
|
|
const resolved = applyAddCommentDefaults(applyAddCommentAmbientDefaults(p, ctx));
|
|
return jsonToolResult(await addComment(client, resolved));
|
|
}
|
|
case "reply_comment": {
|
|
const resolved = applyCommentFileTypeDefault(
|
|
applyAmbientCommentDefaults(p, ctx),
|
|
"reply_comment",
|
|
);
|
|
return jsonToolResult(await deliverCommentThreadText(client, resolved));
|
|
}
|
|
default:
|
|
return unknownToolActionResult((p as { action?: unknown }).action);
|
|
}
|
|
} catch (err) {
|
|
return toolExecutionErrorResult(err);
|
|
}
|
|
},
|
|
};
|
|
},
|
|
{ name: "feishu_drive" },
|
|
);
|
|
|
|
api.logger.info?.(`feishu_drive: Registered feishu_drive tool`);
|
|
}
|