@m1heng-clawd/feishu 0.1.15 → 0.1.17

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 (39) hide show
  1. package/README.md +28 -0
  2. package/index.ts +4 -0
  3. package/package.json +2 -1
  4. package/skills/feishu-message/SKILL.md +111 -0
  5. package/skills/feishu-reaction/SKILL.md +120 -0
  6. package/src/bot.ts +4 -0
  7. package/src/channel.ts +2 -0
  8. package/src/chat-tools/actions.ts +102 -33
  9. package/src/chat-tools/schemas.ts +57 -62
  10. package/src/config-schema.ts +2 -0
  11. package/src/doc-tools/actions.ts +63 -14
  12. package/src/doc-tools/schemas.ts +41 -77
  13. package/src/doc-write-service.ts +19 -0
  14. package/src/drive-tools/actions.ts +18 -6
  15. package/src/drive-tools/register.ts +1 -1
  16. package/src/drive-tools/schemas.ts +45 -58
  17. package/src/media-duration.ts +43 -8
  18. package/src/media.ts +31 -53
  19. package/src/message-tools/actions.ts +154 -0
  20. package/src/message-tools/index.ts +2 -0
  21. package/src/message-tools/register.ts +58 -0
  22. package/src/message-tools/schemas.ts +56 -0
  23. package/src/outbound.ts +2 -2
  24. package/src/perm-tools/actions.ts +27 -3
  25. package/src/perm-tools/schemas.ts +39 -45
  26. package/src/reaction-tools/actions.ts +127 -0
  27. package/src/reaction-tools/index.ts +2 -0
  28. package/src/reaction-tools/register.ts +51 -0
  29. package/src/reaction-tools/schemas.ts +34 -0
  30. package/src/reply-dispatcher.ts +23 -33
  31. package/src/streaming-card.ts +34 -11
  32. package/src/task-tools/actions.ts +145 -10
  33. package/src/task-tools/register.ts +56 -1
  34. package/src/task-tools/schemas.ts +23 -18
  35. package/src/tools-common/tool-context.ts +2 -0
  36. package/src/tools-config.ts +2 -0
  37. package/src/types.ts +4 -0
  38. package/src/wiki-tools/actions.ts +24 -6
  39. package/src/wiki-tools/schemas.ts +32 -51
@@ -1,66 +1,61 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
 
3
- export const FeishuChatSchema = Type.Union([
4
- Type.Object({
5
- action: Type.Literal("get_announcement_info"),
6
- chat_id: Type.String({ description: "Chat ID to get announcement from" }),
7
- }),
8
- Type.Object({
9
- action: Type.Literal("get_announcement"),
10
- chat_id: Type.String({ description: "Chat ID to get announcement from" }),
11
- }),
12
- Type.Object({
13
- action: Type.Literal("write_announcement"),
14
- chat_id: Type.String({ description: "Chat ID to write announcement to" }),
15
- content: Type.String({ description: "Content to write. For doc format: replaces the entire announcement. For docx format: appends a new text block under the page root." }),
16
- }),
17
- Type.Object({
18
- action: Type.Literal("append_announcement"),
19
- chat_id: Type.String({ description: "Chat ID to append announcement to" }),
20
- content: Type.String({ description: "Markdown content to append to announcement" }),
21
- }),
22
- Type.Object({
23
- action: Type.Literal("list_announcement_blocks"),
24
- chat_id: Type.String({ description: "Chat ID to list announcement blocks from" }),
25
- }),
26
- Type.Object({
27
- action: Type.Literal("get_announcement_block"),
28
- chat_id: Type.String({ description: "Chat ID to get announcement block from" }),
29
- block_id: Type.String({ description: "Block ID (from list_announcement_blocks)" }),
30
- }),
31
- Type.Object({
32
- action: Type.Literal("update_announcement_block"),
33
- chat_id: Type.String({ description: "Chat ID to update announcement block in" }),
34
- block_id: Type.String({ description: "Block ID (from list_announcement_blocks)" }),
35
- content: Type.String({ description: "New text content" }),
36
- }),
37
- // ============== New Chat Management Actions ==============
38
- Type.Object({
39
- action: Type.Literal("create_chat"),
40
- name: Type.String({ description: "Group chat name" }),
41
- user_ids: Type.Optional(Type.Array(Type.String(), { description: "List of user IDs to add to the group" })),
42
- description: Type.Optional(Type.String({ description: "Group chat description" })),
43
- }),
44
- Type.Object({
45
- action: Type.Literal("add_members"),
46
- chat_id: Type.String({ description: "Chat ID to add members to" }),
47
- user_ids: Type.Array(Type.String(), { description: "List of user IDs to add" }),
48
- }),
49
- Type.Object({
50
- action: Type.Literal("check_bot_in_chat"),
51
- chat_id: Type.String({ description: "Chat ID to check" }),
52
- }),
53
- Type.Object({
54
- action: Type.Literal("delete_chat"),
55
- chat_id: Type.String({ description: "Chat ID to delete/dismiss" }),
56
- }),
57
- Type.Object({
58
- action: Type.Literal("create_session_chat"),
59
- name: Type.String({ description: "Session group name" }),
60
- user_ids: Type.Array(Type.String(), { description: "List of user IDs to invite" }),
61
- greeting: Type.Optional(Type.String({ description: "Greeting message to send (default: Hello! I've created this group chat for us to collaborate.)" })),
62
- description: Type.Optional(Type.String({ description: "Group description" })),
63
- }),
64
- ]);
3
+ function stringEnum<T extends readonly string[]>(
4
+ values: T,
5
+ options: { description?: string; default?: T[number] } = {},
6
+ ) {
7
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
8
+ }
9
+
10
+ const CHAT_ACTION_VALUES = [
11
+ "get_announcement_info",
12
+ "get_announcement",
13
+ "write_announcement",
14
+ "append_announcement",
15
+ "list_announcement_blocks",
16
+ "get_announcement_block",
17
+ "update_announcement_block",
18
+ "create_chat",
19
+ "add_members",
20
+ "check_bot_in_chat",
21
+ "delete_chat",
22
+ "create_session_chat",
23
+ ] as const;
24
+
25
+ const USER_ID_TYPE_VALUES = ["open_id", "user_id", "union_id"] as const;
26
+ const MEMBER_ID_TYPE_VALUES = ["open_id", "user_id", "union_id", "app_id"] as const;
27
+
28
+ export const FeishuChatSchema = Type.Object({
29
+ action: stringEnum(CHAT_ACTION_VALUES, { description: "Chat action" }),
30
+ chat_id: Type.Optional(Type.String({ description: "Chat ID" })),
31
+ content: Type.Optional(Type.String({ description: "Announcement content / block content" })),
32
+ block_id: Type.Optional(Type.String({ description: "Announcement block ID" })),
33
+
34
+ name: Type.Optional(Type.String({ description: "Group chat name" })),
35
+ user_ids: Type.Optional(
36
+ Type.Array(Type.String(), {
37
+ description: "User/member IDs used by create_chat/add_members/create_session_chat",
38
+ }),
39
+ ),
40
+ user_id_type: Type.Optional(
41
+ stringEnum(USER_ID_TYPE_VALUES, {
42
+ description: "ID type for user_ids in create_chat/create_session_chat",
43
+ default: "open_id",
44
+ }),
45
+ ),
46
+ member_id_type: Type.Optional(
47
+ stringEnum(MEMBER_ID_TYPE_VALUES, {
48
+ description: "ID type for add_members (supports app_id for bots)",
49
+ default: "open_id",
50
+ }),
51
+ ),
52
+ greeting: Type.Optional(
53
+ Type.String({
54
+ description:
55
+ "Greeting message for create_session_chat (default: Hello! I've created this group chat for us to collaborate.)",
56
+ }),
57
+ ),
58
+ description: Type.Optional(Type.String({ description: "Group chat description" })),
59
+ });
65
60
 
66
61
  export type FeishuChatParams = Static<typeof FeishuChatSchema>;
@@ -91,6 +91,8 @@ const FeishuToolsConfigSchema = z
91
91
  task: z.boolean().optional(), // Task operations (default: true)
92
92
  chat: z.boolean().optional(), // Chat management operations (default: true)
93
93
  urgent: z.boolean().optional(), // Buzz/urgent notifications (default: true)
94
+ message: z.boolean().optional(), // Message reading (default: true)
95
+ reaction: z.boolean().optional(), // Emoji reactions (default: true)
94
96
  })
95
97
  .strict()
96
98
  .optional();
@@ -44,6 +44,13 @@ function normalizePageSize(pageSize?: number) {
44
44
  return pageSize;
45
45
  }
46
46
 
47
+ function requireString(value: unknown, field: string): string {
48
+ if (typeof value !== "string" || value.trim().length === 0) {
49
+ throw new Error(`${field} is required`);
50
+ }
51
+ return value;
52
+ }
53
+
47
54
  function omitUndefined<T extends Record<string, unknown>>(obj: T): T {
48
55
  return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as T;
49
56
  }
@@ -304,37 +311,79 @@ export async function runDocAction(
304
311
  ) {
305
312
  switch (params.action) {
306
313
  case "read":
307
- return readDoc(client, params.doc_token);
314
+ return readDoc(client, requireString(params.doc_token, "doc_token"));
308
315
  case "write":
309
- return writeDoc(client, params.doc_token, params.content, mediaMaxBytes);
316
+ return writeDoc(
317
+ client,
318
+ requireString(params.doc_token, "doc_token"),
319
+ requireString(params.content, "content"),
320
+ mediaMaxBytes,
321
+ );
310
322
  case "append":
311
- return appendDoc(client, params.doc_token, params.content, mediaMaxBytes);
323
+ return appendDoc(
324
+ client,
325
+ requireString(params.doc_token, "doc_token"),
326
+ requireString(params.content, "content"),
327
+ mediaMaxBytes,
328
+ );
312
329
  case "create":
313
- return createDoc(client, params.title, params.folder_token);
330
+ return createDoc(client, requireString(params.title, "title"), params.folder_token);
314
331
  case "create_and_write":
315
332
  return createAndWriteDoc(
316
333
  client,
317
- params.title,
318
- params.content,
334
+ requireString(params.title, "title"),
335
+ requireString(params.content, "content"),
319
336
  mediaMaxBytes,
320
337
  params.folder_token,
321
338
  );
322
339
  case "list_blocks":
323
- return listBlocks(client, params.doc_token);
340
+ return listBlocks(client, requireString(params.doc_token, "doc_token"));
324
341
  case "get_block":
325
- return getBlock(client, params.doc_token, params.block_id);
342
+ return getBlock(
343
+ client,
344
+ requireString(params.doc_token, "doc_token"),
345
+ requireString(params.block_id, "block_id"),
346
+ );
326
347
  case "update_block":
327
- return updateBlock(client, params.doc_token, params.block_id, params.content);
348
+ return updateBlock(
349
+ client,
350
+ requireString(params.doc_token, "doc_token"),
351
+ requireString(params.block_id, "block_id"),
352
+ requireString(params.content, "content"),
353
+ );
328
354
  case "delete_block":
329
- return deleteBlock(client, params.doc_token, params.block_id);
355
+ return deleteBlock(
356
+ client,
357
+ requireString(params.doc_token, "doc_token"),
358
+ requireString(params.block_id, "block_id"),
359
+ );
330
360
  case "list_comments":
331
- return listComments(client, params.doc_token, params.page_token, params.page_size);
361
+ return listComments(
362
+ client,
363
+ requireString(params.doc_token, "doc_token"),
364
+ params.page_token,
365
+ params.page_size,
366
+ );
332
367
  case "create_comment":
333
- return createComment(client, params.doc_token, params.content);
368
+ return createComment(
369
+ client,
370
+ requireString(params.doc_token, "doc_token"),
371
+ requireString(params.content, "content"),
372
+ );
334
373
  case "get_comment":
335
- return getComment(client, params.doc_token, params.comment_id);
374
+ return getComment(
375
+ client,
376
+ requireString(params.doc_token, "doc_token"),
377
+ requireString(params.comment_id, "comment_id"),
378
+ );
336
379
  case "list_comment_replies":
337
- return listCommentReplies(client, params.doc_token, params.comment_id, params.page_token, params.page_size);
380
+ return listCommentReplies(
381
+ client,
382
+ requireString(params.doc_token, "doc_token"),
383
+ requireString(params.comment_id, "comment_id"),
384
+ params.page_token,
385
+ params.page_size,
386
+ );
338
387
  default:
339
388
  return { error: `Unknown action: ${(params as any).action}` };
340
389
  }
@@ -1,85 +1,49 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
 
3
- export const FeishuDocSchema = Type.Union([
4
- Type.Object({
5
- action: Type.Literal("read"),
6
- doc_token: Type.String({
3
+ function stringEnum<T extends readonly string[]>(
4
+ values: T,
5
+ options: { description?: string; default?: T[number] } = {},
6
+ ) {
7
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
8
+ }
9
+
10
+ const DOC_ACTION_VALUES = [
11
+ "read",
12
+ "write",
13
+ "append",
14
+ "create",
15
+ "create_and_write",
16
+ "list_blocks",
17
+ "get_block",
18
+ "update_block",
19
+ "delete_block",
20
+ "list_comments",
21
+ "create_comment",
22
+ "get_comment",
23
+ "list_comment_replies",
24
+ ] as const;
25
+
26
+ export const FeishuDocSchema = Type.Object({
27
+ action: stringEnum(DOC_ACTION_VALUES, { description: "Document action" }),
28
+ doc_token: Type.Optional(
29
+ Type.String({
7
30
  description:
8
31
  "Document token (extract from URL /docx/XXX or /docs/XXX). Supports both new (docx) and legacy (doc) formats.",
9
32
  }),
10
- }),
11
- Type.Object({
12
- action: Type.Literal("write"),
13
- doc_token: Type.String({ description: "Document token" }),
14
- content: Type.String({
15
- description: "Markdown content to write (replaces entire document content)",
16
- }),
17
- }),
18
- Type.Object({
19
- action: Type.Literal("append"),
20
- doc_token: Type.String({ description: "Document token" }),
21
- content: Type.String({ description: "Markdown content to append to end of document" }),
22
- }),
23
- Type.Object({
24
- action: Type.Literal("create"),
25
- title: Type.String({ description: "Document title" }),
26
- folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
27
- }),
28
- Type.Object({
29
- action: Type.Literal("create_and_write"),
30
- title: Type.String({ description: "Document title" }),
31
- content: Type.String({
32
- description: "Markdown content to write immediately after document creation",
33
+ ),
34
+ content: Type.Optional(
35
+ Type.String({
36
+ description: "Markdown content for write/append/comment/update operations",
33
37
  }),
34
- folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
35
- }),
36
- Type.Object({
37
- action: Type.Literal("list_blocks"),
38
- doc_token: Type.String({ description: "Document token" }),
39
- }),
40
- Type.Object({
41
- action: Type.Literal("get_block"),
42
- doc_token: Type.String({ description: "Document token" }),
43
- block_id: Type.String({ description: "Block ID (from list_blocks)" }),
44
- }),
45
- Type.Object({
46
- action: Type.Literal("update_block"),
47
- doc_token: Type.String({ description: "Document token" }),
48
- block_id: Type.String({ description: "Block ID (from list_blocks)" }),
49
- content: Type.String({ description: "New text content" }),
50
- }),
51
- Type.Object({
52
- action: Type.Literal("delete_block"),
53
- doc_token: Type.String({ description: "Document token" }),
54
- block_id: Type.String({ description: "Block ID" }),
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
- }),
83
- ]);
38
+ ),
39
+ title: Type.Optional(Type.String({ description: "Document title (for create/create_and_write)" })),
40
+ folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
41
+ block_id: Type.Optional(Type.String({ description: "Block ID (from list_blocks)" })),
42
+ comment_id: Type.Optional(Type.String({ description: "Comment ID" })),
43
+ page_token: Type.Optional(Type.String({ description: "Page token for pagination" })),
44
+ page_size: Type.Optional(
45
+ Type.Integer({ minimum: 1, description: "Page size, default 50 (positive integer)" }),
46
+ ),
47
+ });
84
48
 
85
49
  export type FeishuDocParams = Static<typeof FeishuDocSchema>;
@@ -1,6 +1,7 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
2
  import { Readable } from "stream";
3
3
  import { getFeishuRuntime } from "./runtime.js";
4
+ import { getCurrentFeishuToolContext } from "./tools-common/tool-context.js";
4
5
 
5
6
  const BLOCK_TYPE_NAMES: Record<number, string> = {
6
7
  1: "Page",
@@ -572,6 +573,24 @@ export async function createDoc(
572
573
  });
573
574
  if (res.code !== 0) throw new Error(res.msg);
574
575
  const doc = res.data?.document;
576
+
577
+ // Auto-share with the requesting user so they can edit the document.
578
+ const senderOpenId = getCurrentFeishuToolContext()?.senderOpenId;
579
+ if (doc?.document_id && senderOpenId) {
580
+ try {
581
+ const permRes = await client.drive.permissionMember.create({
582
+ path: { token: doc.document_id },
583
+ params: { type: "docx", need_notification: false },
584
+ data: { member_type: "openid", member_id: senderOpenId, perm: "edit" },
585
+ });
586
+ if (permRes.code !== 0) {
587
+ console.warn(`[feishu_doc] Failed to auto-share doc ${doc.document_id}: ${permRes.msg}`);
588
+ }
589
+ } catch (err) {
590
+ console.warn(`[feishu_doc] Failed to auto-share doc ${doc.document_id}:`, err);
591
+ }
592
+ }
593
+
575
594
  return {
576
595
  document_id: doc?.document_id,
577
596
  title: doc?.title,
@@ -5,6 +5,13 @@ import type { FeishuDriveParams } from "./schemas.js";
5
5
  type DriveMoveType = "doc" | "docx" | "sheet" | "bitable" | "folder" | "file" | "mindnote" | "slides";
6
6
  type DriveDeleteType = DriveMoveType | "shortcut";
7
7
 
8
+ function requireString(value: unknown, field: string): string {
9
+ if (typeof value !== "string" || value.trim().length === 0) {
10
+ throw new Error(`${field} is required`);
11
+ }
12
+ return value;
13
+ }
14
+
8
15
  async function getFileType(client: DriveClient, fileToken: string): Promise<string> {
9
16
  let pageToken: string | undefined;
10
17
 
@@ -196,18 +203,23 @@ export async function runDriveAction(
196
203
  case "list":
197
204
  return listFolder(client, params.folder_token);
198
205
  case "info":
199
- return getFileInfo(client, params.file_token);
206
+ return getFileInfo(client, requireString(params.file_token, "file_token"));
200
207
  case "create_folder":
201
- return createFolder(client, params.name, params.folder_token);
208
+ return createFolder(client, requireString(params.name, "name"), params.folder_token);
202
209
  case "move":
203
- return moveFile(client, params.file_token, params.type, params.folder_token);
210
+ return moveFile(
211
+ client,
212
+ requireString(params.file_token, "file_token"),
213
+ requireString(params.type, "type"),
214
+ requireString(params.folder_token, "folder_token"),
215
+ );
204
216
  case "delete":
205
- return deleteFile(client, params.file_token, params.type);
217
+ return deleteFile(client, requireString(params.file_token, "file_token"), params.type);
206
218
  case "import_document":
207
219
  return importDocument(
208
220
  client,
209
- params.title,
210
- params.content,
221
+ requireString(params.title, "title"),
222
+ requireString(params.content, "content"),
211
223
  mediaMaxBytes,
212
224
  params.folder_token,
213
225
  params.doc_type || "docx",
@@ -59,7 +59,7 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
59
59
  name: "feishu_drive",
60
60
  label: "Feishu Drive",
61
61
  description:
62
- "Feishu cloud storage operations. Actions: list, info, create_folder, move, delete, import_document. Use 'import_document' to create documents from Markdown with better structure preservation than block-by-block writing.",
62
+ "Feishu cloud Drive operations. This tool works with Feishu Drive folders/files/documents, not the OpenClaw agent's local filesystem workspace. Actions: list, info, create_folder, move, delete, import_document. Use 'import_document' to create documents from Markdown with better structure preservation than block-by-block writing.",
63
63
  parameters: FeishuDriveSchema,
64
64
  run: async ({ client, account }, params) => {
65
65
  const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
@@ -1,67 +1,54 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
 
3
- const FileType = Type.Union([
4
- Type.Literal("doc"),
5
- Type.Literal("docx"),
6
- Type.Literal("sheet"),
7
- Type.Literal("bitable"),
8
- Type.Literal("folder"),
9
- Type.Literal("file"),
10
- Type.Literal("mindnote"),
11
- Type.Literal("shortcut"),
12
- ]);
3
+ function stringEnum<T extends readonly string[]>(
4
+ values: T,
5
+ options: { description?: string; default?: T[number] } = {},
6
+ ) {
7
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
8
+ }
13
9
 
14
- const DocType = Type.Union([
15
- Type.Literal("docx", { description: "New generation document (default)" }),
16
- Type.Literal("doc", { description: "Legacy document" }),
17
- ]);
10
+ const FILE_TYPE_VALUES = [
11
+ "doc",
12
+ "docx",
13
+ "sheet",
14
+ "bitable",
15
+ "folder",
16
+ "file",
17
+ "mindnote",
18
+ "shortcut",
19
+ ] as const;
18
20
 
19
- export const FeishuDriveSchema = Type.Union([
20
- Type.Object({
21
- action: Type.Literal("list"),
22
- folder_token: Type.Optional(
23
- Type.String({ description: "Folder token (optional, omit for root directory)" }),
24
- ),
25
- }),
26
- Type.Object({
27
- action: Type.Literal("info"),
28
- file_token: Type.String({ description: "File or folder token" }),
29
- type: Type.Optional(FileType),
30
- }),
31
- Type.Object({
32
- action: Type.Literal("create_folder"),
33
- name: Type.String({ description: "Folder name" }),
34
- folder_token: Type.Optional(
35
- Type.String({ description: "Parent folder token (optional, omit for root)" }),
36
- ),
37
- }),
38
- Type.Object({
39
- action: Type.Literal("move"),
40
- file_token: Type.String({ description: "File token to move" }),
41
- type: FileType,
42
- folder_token: Type.String({ description: "Target folder token" }),
43
- }),
44
- Type.Object({
45
- action: Type.Literal("delete"),
46
- file_token: Type.String({ description: "File token to delete" }),
47
- type: Type.Optional(FileType),
48
- }),
49
- Type.Object({
50
- action: Type.Literal("import_document"),
51
- title: Type.String({
52
- description: "Document title",
53
- }),
54
- content: Type.String({
21
+ const DOC_TYPE_VALUES = ["docx", "doc"] as const;
22
+
23
+ const DRIVE_ACTION_VALUES = [
24
+ "list",
25
+ "info",
26
+ "create_folder",
27
+ "move",
28
+ "delete",
29
+ "import_document",
30
+ ] as const;
31
+
32
+ export const FeishuDriveSchema = Type.Object({
33
+ action: stringEnum(DRIVE_ACTION_VALUES, { description: "Drive action" }),
34
+ folder_token: Type.Optional(
35
+ Type.String({ description: "Folder token (optional, omit for root directory)" }),
36
+ ),
37
+ file_token: Type.Optional(Type.String({ description: "File or folder token" })),
38
+ type: Type.Optional(stringEnum(FILE_TYPE_VALUES, { description: "File type" })),
39
+ name: Type.Optional(Type.String({ description: "Folder name (for create_folder)" })),
40
+ title: Type.Optional(Type.String({ description: "Document title (for import_document)" })),
41
+ content: Type.Optional(
42
+ Type.String({
55
43
  description:
56
44
  "Markdown content to import. Supports full Markdown syntax including tables, lists, code blocks, etc.",
57
45
  }),
58
- folder_token: Type.Optional(
59
- Type.String({
60
- description: "Target folder token (optional, defaults to root). Use 'list' to find folder tokens.",
61
- }),
62
- ),
63
- doc_type: Type.Optional(DocType),
64
- }),
65
- ]);
46
+ ),
47
+ doc_type: Type.Optional(
48
+ stringEnum(DOC_TYPE_VALUES, {
49
+ description: "Document type for import_document (docx default, doc legacy)",
50
+ }),
51
+ ),
52
+ });
66
53
 
67
54
  export type FeishuDriveParams = Static<typeof FeishuDriveSchema>;
@@ -1,3 +1,5 @@
1
+ import { parseBuffer } from "music-metadata";
2
+
1
3
  /**
2
4
  * Media duration parsers for Feishu file upload.
3
5
  *
@@ -9,11 +11,38 @@
9
11
  * - MP4 (ISO base media) — MP4, MOV, QuickTime, M4A, M4V, 3GP share this format
10
12
  * - WAV (RIFF PCM) — uncompressed audio
11
13
  *
12
- * Formats intentionally skipped (complex frame-level parsing, rare in practice):
13
- * - MP3 (VBR makes byte-count estimation unreliable without scanning all frames)
14
- * - Raw AAC / ADTS
14
+ * Raw AAC / ADTS is delegated to music-metadata on the audio path.
15
15
  */
16
16
 
17
+ async function parseAudioDurationWithMusicMetadata(params: {
18
+ buffer: Buffer;
19
+ fileName?: string;
20
+ contentType?: string;
21
+ }): Promise<number | undefined> {
22
+ const { buffer, fileName, contentType } = params;
23
+ try {
24
+ const metadata = await parseBuffer(
25
+ buffer,
26
+ {
27
+ mimeType: contentType,
28
+ path: fileName,
29
+ size: buffer.length,
30
+ },
31
+ {
32
+ duration: true,
33
+ skipCovers: true,
34
+ },
35
+ );
36
+ const seconds = metadata.format.duration;
37
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) {
38
+ return undefined;
39
+ }
40
+ return Math.round(seconds * 1000);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
17
46
  /**
18
47
  * Parse duration from an OGG container (Opus or Vorbis).
19
48
  * Reads the granule position from the last OGG page and divides by sample rate.
@@ -174,17 +203,23 @@ export function parseWavDurationMs(buffer: Buffer): number | undefined {
174
203
  *
175
204
  * Routes to the appropriate parser based on Feishu's file type:
176
205
  * - "mp4" → MP4/MOV/QuickTime container parser
177
- * - "opus" → OGG parser, then MP4 container (covers M4A), then WAV
206
+ * - "opus" → music-metadata first, then OGG parser, then MP4 container, then WAV
178
207
  *
179
208
  * Returns duration in milliseconds, or undefined if not determinable.
180
209
  */
181
- export function parseFeishuMediaDurationMs(
210
+ export async function parseFeishuMediaDurationMs(
182
211
  buffer: Buffer,
183
212
  fileType: "opus" | "mp4",
184
- ): number | undefined {
213
+ options?: { fileName?: string; contentType?: string },
214
+ ): Promise<number | undefined> {
185
215
  if (fileType === "mp4") {
186
216
  return parseMp4DurationMs(buffer);
187
217
  }
188
- // fileType === "opus": try each container format in likelihood order
189
- return parseOggDurationMs(buffer) ?? parseMp4DurationMs(buffer) ?? parseWavDurationMs(buffer);
218
+ return (
219
+ await parseAudioDurationWithMusicMetadata({
220
+ buffer,
221
+ fileName: options?.fileName,
222
+ contentType: options?.contentType,
223
+ })
224
+ ) ?? parseOggDurationMs(buffer) ?? parseMp4DurationMs(buffer) ?? parseWavDurationMs(buffer);
190
225
  }