@m1heng-clawd/feishu 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +123 -4
  2. package/index.ts +6 -6
  3. package/package.json +13 -3
  4. package/skills/feishu-doc/SKILL.md +40 -2
  5. package/skills/feishu-task/SKILL.md +210 -0
  6. package/src/bitable-tools/index.ts +1 -0
  7. package/src/bot.ts +181 -70
  8. package/src/channel.ts +2 -0
  9. package/src/config-schema.ts +4 -0
  10. package/src/doc-tools/actions.ts +341 -0
  11. package/src/doc-tools/common.ts +33 -0
  12. package/src/doc-tools/index.ts +2 -0
  13. package/src/doc-tools/register.ts +90 -0
  14. package/src/{doc-schema.ts → doc-tools/schemas.ts} +31 -1
  15. package/src/drive-tools/actions.ts +182 -0
  16. package/src/drive-tools/common.ts +18 -0
  17. package/src/drive-tools/index.ts +2 -0
  18. package/src/drive-tools/register.ts +71 -0
  19. package/src/{drive-schema.ts → drive-tools/schemas.ts} +2 -1
  20. package/src/media.ts +5 -3
  21. package/src/perm-tools/actions.ts +111 -0
  22. package/src/perm-tools/common.ts +18 -0
  23. package/src/perm-tools/index.ts +2 -0
  24. package/src/perm-tools/register.ts +65 -0
  25. package/src/policy.ts +13 -0
  26. package/src/reply-dispatcher.ts +6 -4
  27. package/src/send.ts +33 -2
  28. package/src/task-tools/actions.ts +466 -13
  29. package/src/task-tools/constants.ts +13 -0
  30. package/src/task-tools/index.ts +1 -0
  31. package/src/task-tools/register.ts +175 -13
  32. package/src/task-tools/schemas.ts +433 -4
  33. package/src/text/markdown-links.ts +104 -0
  34. package/src/types.ts +1 -0
  35. package/src/wiki-tools/actions.ts +166 -0
  36. package/src/wiki-tools/common.ts +18 -0
  37. package/src/wiki-tools/index.ts +2 -0
  38. package/src/wiki-tools/register.ts +66 -0
  39. package/src/bitable.ts +0 -1
  40. package/src/docx.ts +0 -276
  41. package/src/drive.ts +0 -237
  42. package/src/perm.ts +0 -165
  43. package/src/task.ts +0 -1
  44. package/src/wiki.ts +0 -223
  45. /package/src/{perm-schema.ts → perm-tools/schemas.ts} +0 -0
  46. /package/src/{wiki-schema.ts → wiki-tools/schemas.ts} +0 -0
@@ -0,0 +1,341 @@
1
+ import { appendDoc, createAndWriteDoc, createDoc, writeDoc } from "../doc-write-service.js";
2
+ import { detectDocFormat, runDocApiCall, type DocClient } from "./common.js";
3
+ import type { FeishuDocParams } from "./schemas.js";
4
+
5
+ const BLOCK_TYPE_NAMES: Record<number, string> = {
6
+ 1: "Page",
7
+ 2: "Text",
8
+ 3: "Heading1",
9
+ 4: "Heading2",
10
+ 5: "Heading3",
11
+ 12: "Bullet",
12
+ 13: "Ordered",
13
+ 14: "Code",
14
+ 15: "Quote",
15
+ 17: "Todo",
16
+ 18: "Bitable",
17
+ 21: "Diagram",
18
+ 22: "Divider",
19
+ 23: "File",
20
+ 27: "Image",
21
+ 30: "Sheet",
22
+ 31: "Table",
23
+ 32: "TableCell",
24
+ };
25
+
26
+ const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
27
+
28
+ function buildCommentContent(content: string) {
29
+ return {
30
+ elements: [
31
+ {
32
+ text_run: { text: content },
33
+ type: "text_run" as const,
34
+ },
35
+ ],
36
+ };
37
+ }
38
+
39
+ function normalizePageSize(pageSize?: number) {
40
+ if (pageSize === undefined) return 50;
41
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
42
+ throw new Error("page_size must be a positive integer");
43
+ }
44
+ return pageSize;
45
+ }
46
+
47
+ function omitUndefined<T extends Record<string, unknown>>(obj: T): T {
48
+ return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T;
49
+ }
50
+
51
+ async function readLegacyDoc(client: DocClient, docToken: string) {
52
+ const domain = (client as any).domain ?? "https://open.feishu.cn";
53
+ const token = await client.tokenManager.getTenantAccessToken();
54
+ const response = await runDocApiCall("doc.v2.rawContent", () =>
55
+ client.httpInstance.get<{ code?: number; msg?: string; data?: { content?: string } }>(
56
+ `${domain}/open-apis/doc/v2/${docToken}/raw_content`,
57
+ {
58
+ headers: {
59
+ Authorization: `Bearer ${token}`,
60
+ },
61
+ },
62
+ ),
63
+ );
64
+
65
+ return {
66
+ content: response.data?.content,
67
+ format: "doc" as const,
68
+ hint: "Legacy document format. Only plain text content available. Title not included in this API response.",
69
+ };
70
+ }
71
+
72
+ async function readDoc(client: DocClient, docToken: string) {
73
+ const format = detectDocFormat(docToken);
74
+
75
+ if (format === "doc") {
76
+ return readLegacyDoc(client, docToken);
77
+ }
78
+
79
+ const [contentRes, infoRes, blocksRes] = await Promise.all([
80
+ runDocApiCall("docx.document.rawContent", () =>
81
+ client.docx.document.rawContent({ path: { document_id: docToken } }),
82
+ ),
83
+ runDocApiCall("docx.document.get", () =>
84
+ client.docx.document.get({ path: { document_id: docToken } }),
85
+ ),
86
+ runDocApiCall("docx.documentBlock.list", () =>
87
+ client.docx.documentBlock.list({ path: { document_id: docToken } }),
88
+ ),
89
+ ]);
90
+
91
+ const blocks = blocksRes.data?.items ?? [];
92
+ const blockCounts: Record<string, number> = {};
93
+ const structuredTypes: string[] = [];
94
+
95
+ for (const b of blocks) {
96
+ const type = b.block_type ?? 0;
97
+ const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
98
+ blockCounts[name] = (blockCounts[name] || 0) + 1;
99
+
100
+ if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
101
+ structuredTypes.push(name);
102
+ }
103
+ }
104
+
105
+ let hint: string | undefined;
106
+ if (structuredTypes.length > 0) {
107
+ hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
108
+ }
109
+
110
+ return {
111
+ title: infoRes.data?.document?.title,
112
+ content: contentRes.data?.content,
113
+ revision_id: infoRes.data?.document?.revision_id,
114
+ block_count: blocks.length,
115
+ block_types: blockCounts,
116
+ ...(hint && { hint }),
117
+ };
118
+ }
119
+
120
+ async function updateBlock(client: DocClient, docToken: string, blockId: string, content: string) {
121
+ await runDocApiCall("docx.documentBlock.get", () =>
122
+ client.docx.documentBlock.get({
123
+ path: { document_id: docToken, block_id: blockId },
124
+ }),
125
+ );
126
+
127
+ await runDocApiCall("docx.documentBlock.patch", () =>
128
+ client.docx.documentBlock.patch({
129
+ path: { document_id: docToken, block_id: blockId },
130
+ data: {
131
+ update_text_elements: {
132
+ elements: [{ text_run: { content } }],
133
+ },
134
+ },
135
+ }),
136
+ );
137
+
138
+ return { success: true, block_id: blockId };
139
+ }
140
+
141
+ async function deleteBlock(client: DocClient, docToken: string, blockId: string) {
142
+ const blockInfo = await runDocApiCall("docx.documentBlock.get", () =>
143
+ client.docx.documentBlock.get({
144
+ path: { document_id: docToken, block_id: blockId },
145
+ }),
146
+ );
147
+ const parentId = blockInfo.data?.block?.parent_id ?? docToken;
148
+
149
+ const children = await runDocApiCall("docx.documentBlockChildren.get", () =>
150
+ client.docx.documentBlockChildren.get({
151
+ path: { document_id: docToken, block_id: parentId },
152
+ }),
153
+ );
154
+
155
+ const items = children.data?.items ?? [];
156
+ const index = items.findIndex((item: any) => item.block_id === blockId);
157
+ if (index === -1) {
158
+ throw new Error("Block not found");
159
+ }
160
+
161
+ await runDocApiCall("docx.documentBlockChildren.batchDelete", () =>
162
+ client.docx.documentBlockChildren.batchDelete({
163
+ path: { document_id: docToken, block_id: parentId },
164
+ data: { start_index: index, end_index: index + 1 },
165
+ }),
166
+ );
167
+
168
+ return { success: true, deleted_block_id: blockId };
169
+ }
170
+
171
+ async function listBlocks(client: DocClient, docToken: string) {
172
+ const res = await runDocApiCall("docx.documentBlock.list", () =>
173
+ client.docx.documentBlock.list({
174
+ path: { document_id: docToken },
175
+ }),
176
+ );
177
+
178
+ return {
179
+ blocks: res.data?.items ?? [],
180
+ };
181
+ }
182
+
183
+ async function getBlock(client: DocClient, docToken: string, blockId: string) {
184
+ const res = await runDocApiCall("docx.documentBlock.get", () =>
185
+ client.docx.documentBlock.get({
186
+ path: { document_id: docToken, block_id: blockId },
187
+ }),
188
+ );
189
+
190
+ return {
191
+ block: res.data?.block,
192
+ };
193
+ }
194
+
195
+ async function listComments(client: DocClient, docToken: string, pageToken?: string, pageSize?: number) {
196
+ const res = await runDocApiCall("drive.fileComment.list", () =>
197
+ client.drive.fileComment.list({
198
+ path: { file_token: docToken },
199
+ params: omitUndefined({
200
+ file_type: "docx" as const,
201
+ page_token: pageToken,
202
+ page_size: normalizePageSize(pageSize),
203
+ }),
204
+ }),
205
+ );
206
+
207
+ return {
208
+ comments: Array.isArray(res.data?.items) ? res.data.items : [],
209
+ page_token: res.data?.page_token,
210
+ has_more: Boolean(res.data?.has_more),
211
+ };
212
+ }
213
+
214
+ async function createComment(client: DocClient, docToken: string, content: string) {
215
+ const res = await runDocApiCall("drive.fileComment.create", () =>
216
+ client.drive.fileComment.create({
217
+ path: { file_token: docToken },
218
+ params: {
219
+ file_type: "docx",
220
+ },
221
+ data: {
222
+ reply_list: {
223
+ replies: [
224
+ {
225
+ content: buildCommentContent(content),
226
+ },
227
+ ],
228
+ },
229
+ },
230
+ }),
231
+ );
232
+
233
+ if (!res.data?.comment_id) {
234
+ throw new Error("Comment creation failed: No comment ID returned");
235
+ }
236
+
237
+ return {
238
+ comment_id: res.data.comment_id,
239
+ comment: res.data,
240
+ };
241
+ }
242
+
243
+ async function getComment(client: DocClient, docToken: string, commentId: string) {
244
+ const res = await runDocApiCall("drive.fileComment.get", () =>
245
+ client.drive.fileComment.get({
246
+ path: { file_token: docToken, comment_id: commentId },
247
+ params: {
248
+ file_type: "docx",
249
+ },
250
+ }),
251
+ );
252
+
253
+ if (!res.data) {
254
+ throw new Error(`Comment not found: ${commentId}`);
255
+ }
256
+
257
+ return {
258
+ comment: res.data,
259
+ };
260
+ }
261
+
262
+ async function listCommentReplies(
263
+ client: DocClient,
264
+ docToken: string,
265
+ commentId: string,
266
+ pageToken?: string,
267
+ pageSize?: number,
268
+ ) {
269
+ const res = await runDocApiCall("drive.fileCommentReply.list", () =>
270
+ client.drive.fileCommentReply.list({
271
+ path: { file_token: docToken, comment_id: commentId },
272
+ params: omitUndefined({
273
+ file_type: "docx" as const,
274
+ page_token: pageToken,
275
+ page_size: normalizePageSize(pageSize),
276
+ }),
277
+ }),
278
+ );
279
+
280
+ return {
281
+ replies: Array.isArray(res.data?.items) ? res.data.items : [],
282
+ page_token: res.data?.page_token,
283
+ has_more: Boolean(res.data?.has_more),
284
+ };
285
+ }
286
+
287
+ export async function listAppScopes(client: DocClient) {
288
+ const res = await runDocApiCall("application.scope.list", () => client.application.scope.list({}));
289
+ const scopes = res.data?.scopes ?? [];
290
+ const granted = scopes.filter((s) => s.grant_status === 1);
291
+ const pending = scopes.filter((s) => s.grant_status !== 1);
292
+
293
+ return {
294
+ granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })),
295
+ pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })),
296
+ summary: `${granted.length} granted, ${pending.length} pending`,
297
+ };
298
+ }
299
+
300
+ export async function runDocAction(
301
+ client: DocClient,
302
+ params: FeishuDocParams,
303
+ mediaMaxBytes: number,
304
+ ) {
305
+ switch (params.action) {
306
+ case "read":
307
+ return readDoc(client, params.doc_token);
308
+ case "write":
309
+ return writeDoc(client, params.doc_token, params.content, mediaMaxBytes);
310
+ case "append":
311
+ return appendDoc(client, params.doc_token, params.content, mediaMaxBytes);
312
+ case "create":
313
+ return createDoc(client, params.title, params.folder_token);
314
+ case "create_and_write":
315
+ return createAndWriteDoc(
316
+ client,
317
+ params.title,
318
+ params.content,
319
+ mediaMaxBytes,
320
+ params.folder_token,
321
+ );
322
+ case "list_blocks":
323
+ return listBlocks(client, params.doc_token);
324
+ case "get_block":
325
+ return getBlock(client, params.doc_token, params.block_id);
326
+ case "update_block":
327
+ return updateBlock(client, params.doc_token, params.block_id, params.content);
328
+ case "delete_block":
329
+ return deleteBlock(client, params.doc_token, params.block_id);
330
+ case "list_comments":
331
+ return listComments(client, params.doc_token, params.page_token, params.page_size);
332
+ case "create_comment":
333
+ return createComment(client, params.doc_token, params.content);
334
+ case "get_comment":
335
+ return getComment(client, params.doc_token, params.comment_id);
336
+ case "list_comment_replies":
337
+ return listCommentReplies(client, params.doc_token, params.comment_id, params.page_token, params.page_size);
338
+ default:
339
+ return { error: `Unknown action: ${(params as any).action}` };
340
+ }
341
+ }
@@ -0,0 +1,33 @@
1
+ import { createFeishuClient } from "../client.js";
2
+ import {
3
+ errorResult,
4
+ json,
5
+ runFeishuApiCall,
6
+ type FeishuApiResponse,
7
+ } from "../tools-common/feishu-api.js";
8
+
9
+ export type DocClient = ReturnType<typeof createFeishuClient>;
10
+
11
+ export { json, errorResult };
12
+
13
+ export async function runDocApiCall<T extends FeishuApiResponse>(
14
+ context: string,
15
+ fn: () => Promise<T>,
16
+ ): Promise<T> {
17
+ return runFeishuApiCall(context, fn);
18
+ }
19
+
20
+ export type DocFormat = "docx" | "doc";
21
+
22
+ /**
23
+ * Detect document format from token.
24
+ * Legacy doc tokens: usually start with "doccn" and contain only alphanumeric chars.
25
+ * Docx tokens: Various formats that do not match legacy "doccn..." pattern.
26
+ */
27
+ export function detectDocFormat(token: string): DocFormat {
28
+ const normalizedToken = token.trim();
29
+ if (/^doccn[a-zA-Z0-9]+$/.test(normalizedToken)) {
30
+ return "doc";
31
+ }
32
+ return "docx";
33
+ }
@@ -0,0 +1,2 @@
1
+ export { registerFeishuDocTools } from "./register.js";
2
+ export { FeishuDocSchema, type FeishuDocParams } from "./schemas.js";
@@ -0,0 +1,90 @@
1
+ import { Type, type TSchema } from "@sinclair/typebox";
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
+ import type { ResolvedFeishuAccount } from "../types.js";
4
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
5
+ import { listAppScopes, runDocAction } from "./actions.js";
6
+ import { errorResult, json, type DocClient } from "./common.js";
7
+ import { FeishuDocSchema, type FeishuDocParams } from "./schemas.js";
8
+
9
+ type DocToolSpec<P> = {
10
+ name: string;
11
+ label: string;
12
+ description: string;
13
+ parameters: TSchema;
14
+ requiredTool?: "doc" | "scopes";
15
+ run: (args: { client: DocClient; account: ResolvedFeishuAccount }, params: P) => Promise<unknown>;
16
+ };
17
+
18
+ function registerDocTool<P>(api: OpenClawPluginApi, spec: DocToolSpec<P>) {
19
+ api.registerTool(
20
+ {
21
+ name: spec.name,
22
+ label: spec.label,
23
+ description: spec.description,
24
+ parameters: spec.parameters,
25
+ async execute(_toolCallId, params) {
26
+ try {
27
+ return await withFeishuToolClient({
28
+ api,
29
+ toolName: spec.name,
30
+ requiredTool: spec.requiredTool,
31
+ run: async ({ client, account }) =>
32
+ json(await spec.run({ client: client as DocClient, account }, params as P)),
33
+ });
34
+ } catch (err) {
35
+ return errorResult(err);
36
+ }
37
+ },
38
+ },
39
+ { name: spec.name },
40
+ );
41
+ }
42
+
43
+ export function registerFeishuDocTools(api: OpenClawPluginApi) {
44
+ if (!api.config) {
45
+ api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
46
+ return;
47
+ }
48
+
49
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
50
+ api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
51
+ return;
52
+ }
53
+
54
+ const docEnabled = hasFeishuToolEnabledForAnyAccount(api.config, "doc");
55
+ const scopesEnabled = hasFeishuToolEnabledForAnyAccount(api.config, "scopes");
56
+ const registered: string[] = [];
57
+
58
+ if (docEnabled) {
59
+ registerDocTool<FeishuDocParams>(api, {
60
+ name: "feishu_doc",
61
+ label: "Feishu Doc",
62
+ description:
63
+ 'Feishu document operations. Actions: read, write, append, create, create_and_write, list_blocks, get_block, update_block, delete_block, list_comments, create_comment, get_comment, list_comment_replies. Use "create_and_write" for atomic create + content write.',
64
+ parameters: FeishuDocSchema,
65
+ requiredTool: "doc",
66
+ run: async ({ client, account }, params) => {
67
+ const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
68
+ return runDocAction(client, params, mediaMaxBytes);
69
+ },
70
+ });
71
+ registered.push("feishu_doc");
72
+ }
73
+
74
+ if (scopesEnabled) {
75
+ registerDocTool<Record<string, never>>(api, {
76
+ name: "feishu_app_scopes",
77
+ label: "Feishu App Scopes",
78
+ description:
79
+ "List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
80
+ parameters: Type.Object({}),
81
+ requiredTool: "scopes",
82
+ run: async ({ client }) => listAppScopes(client),
83
+ });
84
+ registered.push("feishu_app_scopes");
85
+ }
86
+
87
+ if (registered.length > 0) {
88
+ api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`);
89
+ }
90
+ }
@@ -3,7 +3,10 @@ import { Type, type Static } from "@sinclair/typebox";
3
3
  export const FeishuDocSchema = Type.Union([
4
4
  Type.Object({
5
5
  action: Type.Literal("read"),
6
- doc_token: Type.String({ description: "Document token (extract from URL /docx/XXX)" }),
6
+ doc_token: Type.String({
7
+ description:
8
+ "Document token (extract from URL /docx/XXX or /docs/XXX). Supports both new (docx) and legacy (doc) formats.",
9
+ }),
7
10
  }),
8
11
  Type.Object({
9
12
  action: Type.Literal("write"),
@@ -50,6 +53,33 @@ export const FeishuDocSchema = Type.Union([
50
53
  doc_token: Type.String({ description: "Document token" }),
51
54
  block_id: Type.String({ description: "Block ID" }),
52
55
  }),
56
+ Type.Object({
57
+ action: Type.Literal("list_comments"),
58
+ doc_token: Type.String({ description: "Document token" }),
59
+ page_token: Type.Optional(Type.String({ description: "Page token for pagination" })),
60
+ page_size: Type.Optional(
61
+ Type.Integer({ minimum: 1, description: "Page size, default 50 (positive integer)" }),
62
+ ),
63
+ }),
64
+ Type.Object({
65
+ action: Type.Literal("create_comment"),
66
+ doc_token: Type.String({ description: "Document token" }),
67
+ content: Type.String({ description: "Comment content" }),
68
+ }),
69
+ Type.Object({
70
+ action: Type.Literal("get_comment"),
71
+ doc_token: Type.String({ description: "Document token" }),
72
+ comment_id: Type.String({ description: "Comment ID" }),
73
+ }),
74
+ Type.Object({
75
+ action: Type.Literal("list_comment_replies"),
76
+ doc_token: Type.String({ description: "Document token" }),
77
+ comment_id: Type.String({ description: "Comment ID" }),
78
+ page_token: Type.Optional(Type.String({ description: "Page token for pagination" })),
79
+ page_size: Type.Optional(
80
+ Type.Integer({ minimum: 1, description: "Page size, default 50 (positive integer)" }),
81
+ ),
82
+ }),
53
83
  ]);
54
84
 
55
85
  export type FeishuDocParams = Static<typeof FeishuDocSchema>;
@@ -0,0 +1,182 @@
1
+ import { createAndWriteDoc } from "../doc-write-service.js";
2
+ import { runDriveApiCall, type DriveClient } from "./common.js";
3
+ import type { FeishuDriveParams } from "./schemas.js";
4
+
5
+ type DriveMoveType = "doc" | "docx" | "sheet" | "bitable" | "folder" | "file" | "mindnote" | "slides";
6
+ type DriveDeleteType = DriveMoveType | "shortcut";
7
+
8
+ async function getRootFolderToken(client: DriveClient): Promise<string> {
9
+ // Use generic HTTP client to call the root folder meta API
10
+ // as it's not directly exposed in the SDK.
11
+ const domain = (client as any).domain ?? "https://open.feishu.cn";
12
+ const res = await runDriveApiCall("drive.explorer.v2.root_folder.meta", () =>
13
+ (client as any).httpInstance.get(`${domain}/open-apis/drive/explorer/v2/root_folder/meta`) as Promise<{
14
+ code?: number;
15
+ msg?: string;
16
+ data?: { token?: string };
17
+ }>,
18
+ );
19
+ const token = res.data?.token;
20
+ if (!token) throw new Error("Root folder token not found");
21
+ return token;
22
+ }
23
+
24
+ async function listFolder(client: DriveClient, folderToken?: string) {
25
+ // Filter out invalid folder_token values (empty, "0", etc.)
26
+ const validFolderToken = folderToken && folderToken !== "0" ? folderToken : undefined;
27
+ const res = await runDriveApiCall("drive.file.list", () =>
28
+ client.drive.file.list({
29
+ params: validFolderToken ? { folder_token: validFolderToken } : {},
30
+ }),
31
+ );
32
+
33
+ return {
34
+ files:
35
+ res.data?.files?.map((f) => ({
36
+ token: f.token,
37
+ name: f.name,
38
+ type: f.type,
39
+ url: f.url,
40
+ created_time: f.created_time,
41
+ modified_time: f.modified_time,
42
+ owner_id: f.owner_id,
43
+ })) ?? [],
44
+ next_page_token: res.data?.next_page_token,
45
+ };
46
+ }
47
+
48
+ async function getFileInfo(client: DriveClient, fileToken: string, folderToken?: string) {
49
+ // Use list with folder_token to find file info.
50
+ const res = await runDriveApiCall("drive.file.list", () =>
51
+ client.drive.file.list({
52
+ params: folderToken ? { folder_token: folderToken } : {},
53
+ }),
54
+ );
55
+
56
+ const file = res.data?.files?.find((f) => f.token === fileToken);
57
+ if (!file) {
58
+ throw new Error(`File not found: ${fileToken}`);
59
+ }
60
+
61
+ return {
62
+ token: file.token,
63
+ name: file.name,
64
+ type: file.type,
65
+ url: file.url,
66
+ created_time: file.created_time,
67
+ modified_time: file.modified_time,
68
+ owner_id: file.owner_id,
69
+ };
70
+ }
71
+
72
+ async function createFolder(client: DriveClient, name: string, folderToken?: string) {
73
+ // Feishu supports using folder_token="0" as the root folder.
74
+ // We try to resolve the real root token (explorer API), but fall back to "0"
75
+ // because some tenants/apps return 400 for that explorer endpoint.
76
+ let effectiveToken = folderToken && folderToken !== "0" ? folderToken : "0";
77
+ if (effectiveToken === "0") {
78
+ try {
79
+ effectiveToken = await getRootFolderToken(client);
80
+ } catch {
81
+ // ignore and keep "0"
82
+ }
83
+ }
84
+
85
+ const res = await runDriveApiCall("drive.file.createFolder", () =>
86
+ client.drive.file.createFolder({
87
+ data: {
88
+ name,
89
+ folder_token: effectiveToken,
90
+ },
91
+ }),
92
+ );
93
+
94
+ return {
95
+ token: res.data?.token,
96
+ url: res.data?.url,
97
+ };
98
+ }
99
+
100
+ async function moveFile(
101
+ client: DriveClient,
102
+ fileToken: string,
103
+ type: string,
104
+ folderToken: string,
105
+ ) {
106
+ const res = await runDriveApiCall("drive.file.move", () =>
107
+ client.drive.file.move({
108
+ path: { file_token: fileToken },
109
+ data: {
110
+ type: type as DriveMoveType,
111
+ folder_token: folderToken,
112
+ },
113
+ }),
114
+ );
115
+
116
+ return {
117
+ success: true,
118
+ task_id: res.data?.task_id,
119
+ };
120
+ }
121
+
122
+ async function deleteFile(client: DriveClient, fileToken: string, type: string) {
123
+ const res = await runDriveApiCall("drive.file.delete", () =>
124
+ client.drive.file.delete({
125
+ path: { file_token: fileToken },
126
+ params: {
127
+ type: type as DriveDeleteType,
128
+ },
129
+ }),
130
+ );
131
+
132
+ return {
133
+ success: true,
134
+ task_id: res.data?.task_id,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Import markdown content as a new Feishu document.
140
+ * Uses create + write approach for reliable content import.
141
+ * Note: docType parameter is accepted for API compatibility but docx is always used.
142
+ */
143
+ async function importDocument(
144
+ client: DriveClient,
145
+ title: string,
146
+ content: string,
147
+ mediaMaxBytes: number,
148
+ folderToken?: string,
149
+ _docType?: "docx" | "doc",
150
+ ) {
151
+ return createAndWriteDoc(client, title, content, mediaMaxBytes, folderToken);
152
+ }
153
+
154
+ export async function runDriveAction(
155
+ client: DriveClient,
156
+ params: FeishuDriveParams,
157
+ mediaMaxBytes: number,
158
+ ) {
159
+ switch (params.action) {
160
+ case "list":
161
+ return listFolder(client, params.folder_token);
162
+ case "info":
163
+ return getFileInfo(client, params.file_token);
164
+ case "create_folder":
165
+ return createFolder(client, params.name, params.folder_token);
166
+ case "move":
167
+ return moveFile(client, params.file_token, params.type, params.folder_token);
168
+ case "delete":
169
+ return deleteFile(client, params.file_token, params.type);
170
+ case "import_document":
171
+ return importDocument(
172
+ client,
173
+ params.title,
174
+ params.content,
175
+ mediaMaxBytes,
176
+ params.folder_token,
177
+ params.doc_type || "docx",
178
+ );
179
+ default:
180
+ return { error: `Unknown action: ${(params as any).action}` };
181
+ }
182
+ }