@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
package/src/media.ts CHANGED
@@ -192,6 +192,12 @@ export type SendMediaResult = {
192
192
  chatId: string;
193
193
  };
194
194
 
195
+ function assertNonEmptyMediaBuffer(buffer: Buffer, name: string): void {
196
+ if (buffer.length === 0) {
197
+ throw new Error(`Feishu media upload failed: "${name}" is empty (0 bytes)`);
198
+ }
199
+ }
200
+
195
201
  /**
196
202
  * Upload an image to Feishu and get an image_key for sending.
197
203
  * Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
@@ -210,15 +216,16 @@ export async function uploadImageFeishu(params: {
210
216
 
211
217
  const client = createFeishuClient(account);
212
218
 
213
- // SDK expects a Readable stream, not a Buffer
214
- // Use type assertion since SDK actually accepts any Readable at runtime
215
- const imageStream =
216
- typeof image === "string" ? fs.createReadStream(image) : Readable.from(image);
219
+ if (Buffer.isBuffer(image)) {
220
+ assertNonEmptyMediaBuffer(image, "image");
221
+ }
222
+
223
+ const imagePayload = typeof image === "string" ? fs.createReadStream(image) : image;
217
224
 
218
225
  const response = await client.im.image.create({
219
226
  data: {
220
227
  image_type: imageType,
221
- image: imageStream as any,
228
+ image: imagePayload as any,
222
229
  },
223
230
  });
224
231
 
@@ -237,23 +244,6 @@ export async function uploadImageFeishu(params: {
237
244
  return { imageKey };
238
245
  }
239
246
 
240
- /**
241
- * Encode a filename for safe use in Feishu multipart/form-data uploads.
242
- * Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
243
- * the upload to silently fail when passed raw through the SDK's form-data
244
- * serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
245
- * Feishu's server decodes and preserves the original display name.
246
- */
247
- export function sanitizeFileNameForUpload(fileName: string): string {
248
- const ASCII_ONLY = /^[\x20-\x7E]+$/;
249
- if (ASCII_ONLY.test(fileName)) {
250
- return fileName;
251
- }
252
- return encodeURIComponent(fileName)
253
- .replace(/'/g, "%27")
254
- .replace(/\(/g, "%28")
255
- .replace(/\)/g, "%29");
256
- }
257
247
 
258
248
  /**
259
249
  * Upload a file to Feishu and get a file_key for sending.
@@ -275,18 +265,17 @@ export async function uploadFileFeishu(params: {
275
265
 
276
266
  const client = createFeishuClient(account);
277
267
 
278
- // SDK expects a Readable stream, not a Buffer
279
- // Use type assertion since SDK actually accepts any Readable at runtime
280
- const fileStream =
281
- typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
268
+ if (Buffer.isBuffer(file)) {
269
+ assertNonEmptyMediaBuffer(file, fileName);
270
+ }
282
271
 
283
- const safeFileName = sanitizeFileNameForUpload(fileName);
272
+ const filePayload = typeof file === "string" ? fs.createReadStream(file) : file;
284
273
 
285
274
  const response = await client.im.file.create({
286
275
  data: {
287
276
  file_type: fileType,
288
- file_name: safeFileName,
289
- file: fileStream as any,
277
+ file_name: fileName,
278
+ file: filePayload as any,
290
279
  ...(duration !== undefined && { duration }),
291
280
  },
292
281
  });
@@ -496,9 +485,10 @@ export async function sendMediaFeishu(params: {
496
485
  mediaBuffer?: Buffer;
497
486
  fileName?: string;
498
487
  replyToMessageId?: string;
488
+ mediaLocalRoots?: readonly string[];
499
489
  accountId?: string;
500
490
  }): Promise<SendMediaResult> {
501
- const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
491
+ const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, mediaLocalRoots, accountId } = params;
502
492
  const account = resolveFeishuAccount({ cfg, accountId });
503
493
  if (!account.configured) {
504
494
  throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -513,30 +503,16 @@ export async function sendMediaFeishu(params: {
513
503
  buffer = mediaBuffer;
514
504
  name = fileName ?? "file";
515
505
  } else if (mediaUrl) {
516
- const mediaLocalRoots = (account.config?.mediaLocalRoots ?? [])
506
+ const configLocalRoots = (account.config?.mediaLocalRoots ?? [])
517
507
  .map((root) => root.trim())
518
508
  .filter((root) => root.length > 0);
519
- const loaded = await (async () => {
520
- try {
521
- return await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
522
- maxBytes: mediaMaxBytes,
523
- optimizeImages: false,
524
- });
525
- } catch (err) {
526
- const shouldRetryWithCustomRoots =
527
- mediaLocalRoots.length > 0 &&
528
- err instanceof Error &&
529
- err.message.includes("Local media path is not under an allowed directory");
530
- if (!shouldRetryWithCustomRoots) {
531
- throw err;
532
- }
533
- return await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
534
- maxBytes: mediaMaxBytes,
535
- optimizeImages: false,
536
- localRoots: mediaLocalRoots,
537
- });
538
- }
539
- })();
509
+ // Merge context-provided roots (includes agent workspace) with config roots.
510
+ const mergedLocalRoots = [...(mediaLocalRoots ?? []), ...configLocalRoots];
511
+ const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
512
+ maxBytes: mediaMaxBytes,
513
+ optimizeImages: false,
514
+ ...(mergedLocalRoots.length > 0 ? { localRoots: mergedLocalRoots } : {}),
515
+ });
540
516
  buffer = loaded.buffer;
541
517
  name = fileName ?? loaded.fileName ?? "file";
542
518
  contentType = loaded.contentType;
@@ -544,6 +520,8 @@ export async function sendMediaFeishu(params: {
544
520
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
545
521
  }
546
522
 
523
+ assertNonEmptyMediaBuffer(buffer, name);
524
+
547
525
  // Determine if it's an image based on extension
548
526
  const ext = path.extname(name).toLowerCase();
549
527
  const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
@@ -562,7 +540,7 @@ export async function sendMediaFeishu(params: {
562
540
  const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
563
541
  const duration =
564
542
  fileType === "opus" || fileType === "mp4"
565
- ? parseFeishuMediaDurationMs(buffer, fileType)
543
+ ? await parseFeishuMediaDurationMs(buffer, fileType, { fileName: name, contentType })
566
544
  : undefined;
567
545
  const { fileKey } = await uploadFileFeishu({
568
546
  cfg,
@@ -0,0 +1,154 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { runFeishuApiCall, type FeishuApiResponse } from "../tools-common/feishu-api.js";
3
+ import type { FeishuMessageParams } from "./schemas.js";
4
+
5
+ interface MessageGetResponse extends FeishuApiResponse {
6
+ data?: {
7
+ items?: Array<{
8
+ message_id?: string;
9
+ root_id?: string;
10
+ parent_id?: string;
11
+ msg_type?: string;
12
+ create_time?: string;
13
+ update_time?: string;
14
+ deleted?: boolean;
15
+ chat_id?: string;
16
+ sender?: { id: string; id_type: string; sender_type: string };
17
+ body?: { content: string };
18
+ mentions?: Array<{ key: string; id: string; id_type: string; name: string }>;
19
+ }>;
20
+ };
21
+ }
22
+
23
+ interface MessageListResponse extends FeishuApiResponse {
24
+ data?: {
25
+ has_more?: boolean;
26
+ page_token?: string;
27
+ items?: Array<{
28
+ message_id?: string;
29
+ root_id?: string;
30
+ parent_id?: string;
31
+ msg_type?: string;
32
+ create_time?: string;
33
+ deleted?: boolean;
34
+ chat_id?: string;
35
+ sender?: { id: string; id_type: string; sender_type: string };
36
+ body?: { content: string };
37
+ }>;
38
+ };
39
+ }
40
+
41
+ export async function runMessageAction(
42
+ client: Lark.Client,
43
+ params: FeishuMessageParams,
44
+ ): Promise<unknown> {
45
+ switch (params.action) {
46
+ case "get":
47
+ return getMessage(client, params);
48
+ case "list":
49
+ return listMessages(client, params);
50
+ }
51
+ }
52
+
53
+ function parseMessageContent(msgType: string | undefined, rawContent: string | undefined): string {
54
+ if (!rawContent) return "";
55
+ try {
56
+ const parsed = JSON.parse(rawContent);
57
+ if (msgType === "text") return (parsed.text as string) ?? rawContent;
58
+ if (msgType === "post") {
59
+ const title = (parsed.title as string) ?? "";
60
+ const lines: string[] = [];
61
+ for (const paragraph of (parsed.content as Array<Array<{ tag: string; text?: string }>>) ?? []) {
62
+ lines.push(paragraph.map((el) => el.text ?? "").join(""));
63
+ }
64
+ return title ? `${title}\n${lines.join("\n")}` : lines.join("\n");
65
+ }
66
+ if (msgType === "image") return "[image]";
67
+ if (msgType === "file") return `[file: ${(parsed.file_name as string) ?? ""}]`;
68
+ if (msgType === "audio") return "[audio]";
69
+ if (msgType === "sticker") return "[sticker]";
70
+ if (msgType === "share_chat") return "[share_chat]";
71
+ if (msgType === "share_user") return "[share_user]";
72
+ return `[${msgType ?? "unknown"}]`;
73
+ } catch {
74
+ return rawContent;
75
+ }
76
+ }
77
+
78
+ async function getMessage(client: Lark.Client, params: FeishuMessageParams) {
79
+ if (!params.message_id) {
80
+ throw new Error("message_id is required for action=get");
81
+ }
82
+
83
+ const response = await runFeishuApiCall<MessageGetResponse>(
84
+ "Feishu get message",
85
+ () =>
86
+ client.im.message.get({
87
+ path: { message_id: params.message_id! },
88
+ }) as Promise<MessageGetResponse>,
89
+ );
90
+
91
+ const item = response.data?.items?.[0];
92
+ if (!item) {
93
+ return { ok: true, action: "get", message_id: params.message_id, found: false };
94
+ }
95
+
96
+ return {
97
+ ok: true,
98
+ action: "get",
99
+ found: true,
100
+ message_id: item.message_id ?? params.message_id,
101
+ msg_type: item.msg_type ?? "",
102
+ content: parseMessageContent(item.msg_type, item.body?.content),
103
+ sender_id: item.sender?.id ?? "",
104
+ sender_type: item.sender?.sender_type ?? "",
105
+ chat_id: item.chat_id ?? "",
106
+ create_time: item.create_time ?? "",
107
+ update_time: item.update_time ?? "",
108
+ mentions: item.mentions?.map((m) => ({ id: m.id, name: m.name })) ?? [],
109
+ };
110
+ }
111
+
112
+ async function listMessages(client: Lark.Client, params: FeishuMessageParams) {
113
+ if (!params.chat_id) {
114
+ throw new Error("chat_id is required for action=list");
115
+ }
116
+
117
+ const pageSize = Math.min(Math.max(params.page_size ?? 10, 1), 50);
118
+ const sortType = params.sort_type ?? "ByCreateTimeDesc";
119
+
120
+ const response = await runFeishuApiCall<MessageListResponse>(
121
+ "Feishu list messages",
122
+ () =>
123
+ client.im.message.list({
124
+ params: {
125
+ container_id_type: "chat",
126
+ container_id: params.chat_id!,
127
+ sort_type: sortType,
128
+ page_size: pageSize,
129
+ ...(params.start_time ? { start_time: params.start_time } : {}),
130
+ ...(params.end_time ? { end_time: params.end_time } : {}),
131
+ },
132
+ }) as Promise<MessageListResponse>,
133
+ );
134
+
135
+ const messages = (response.data?.items ?? [])
136
+ .filter((item) => !item.deleted)
137
+ .map((item) => ({
138
+ message_id: item.message_id ?? "",
139
+ msg_type: item.msg_type ?? "",
140
+ content_preview: parseMessageContent(item.msg_type, item.body?.content),
141
+ sender_id: item.sender?.id ?? "",
142
+ sender_type: item.sender?.sender_type ?? "",
143
+ create_time: item.create_time ?? "",
144
+ chat_id: item.chat_id ?? "",
145
+ }));
146
+
147
+ return {
148
+ ok: true,
149
+ action: "list",
150
+ chat_id: params.chat_id,
151
+ total: messages.length,
152
+ messages,
153
+ };
154
+ }
@@ -0,0 +1,2 @@
1
+ export { registerFeishuMessageTools } from "./register.js";
2
+ export { FeishuMessageSchema, type FeishuMessageParams } from "./schemas.js";
@@ -0,0 +1,58 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
3
+ import { getCurrentFeishuToolContext } from "../tools-common/tool-context.js";
4
+ import { errorResult, json } from "../tools-common/feishu-api.js";
5
+ import { runMessageAction } from "./actions.js";
6
+ import { FeishuMessageSchema, type FeishuMessageParams } from "./schemas.js";
7
+
8
+ export function registerFeishuMessageTools(api: OpenClawPluginApi) {
9
+ if (!api.config) {
10
+ api.logger.debug?.("feishu_message: No config available, skipping");
11
+ return;
12
+ }
13
+
14
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
15
+ api.logger.debug?.("feishu_message: No Feishu accounts configured, skipping");
16
+ return;
17
+ }
18
+
19
+ if (!hasFeishuToolEnabledForAnyAccount(api.config, "message")) {
20
+ api.logger.debug?.("feishu_message: message tool disabled in config");
21
+ return;
22
+ }
23
+
24
+ api.registerTool(
25
+ {
26
+ name: "feishu_message",
27
+ label: "Feishu Message",
28
+ description:
29
+ "Read Feishu messages. Actions: get (get a single message by message_id), list (list recent messages in a chat). " +
30
+ "Use list to discover message_ids for use with other tools (e.g., feishu_reaction).",
31
+ parameters: FeishuMessageSchema,
32
+ async execute(_toolCallId, params) {
33
+ const p = params as FeishuMessageParams;
34
+ if (p.action === "list") {
35
+ const isValidChatId = p.chat_id?.startsWith("oc_");
36
+ if (!isValidChatId) {
37
+ const ctx = getCurrentFeishuToolContext();
38
+ if (ctx?.chatId) p.chat_id = ctx.chatId;
39
+ }
40
+ }
41
+
42
+ try {
43
+ return await withFeishuToolClient({
44
+ api,
45
+ toolName: "feishu_message",
46
+ requiredTool: "message",
47
+ run: async ({ client }) => json(await runMessageAction(client, p)),
48
+ });
49
+ } catch (err) {
50
+ return errorResult(err);
51
+ }
52
+ },
53
+ },
54
+ { name: "feishu_message" },
55
+ );
56
+
57
+ api.logger.debug?.("feishu_message: Registered feishu_message tool");
58
+ }
@@ -0,0 +1,56 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
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 ACTION_VALUES = ["get", "list"] as const;
11
+ const SORT_VALUES = ["ByCreateTimeAsc", "ByCreateTimeDesc"] as const;
12
+
13
+ export const FeishuMessageSchema = Type.Object({
14
+ action: stringEnum(ACTION_VALUES, {
15
+ description:
16
+ "Action to perform: get (get a single message by message_id), list (list recent messages in a chat by chat_id).",
17
+ }),
18
+ message_id: Type.Optional(
19
+ Type.String({
20
+ description: "Feishu message ID (e.g. om_xxx). Required for action=get.",
21
+ }),
22
+ ),
23
+ chat_id: Type.Optional(
24
+ Type.String({
25
+ description: "Chat ID (e.g. oc_xxx). Required for action=list. Omit to use the current conversation's chat.",
26
+ }),
27
+ ),
28
+ page_size: Type.Optional(
29
+ Type.Integer({
30
+ description: "Number of messages to fetch for action=list (default: 10, max: 50).",
31
+ minimum: 1,
32
+ maximum: 50,
33
+ default: 10,
34
+ }),
35
+ ),
36
+ sort_type: Type.Optional(
37
+ stringEnum(SORT_VALUES, {
38
+ description: "Sort order for action=list. Default: ByCreateTimeDesc (newest first).",
39
+ default: "ByCreateTimeDesc",
40
+ }),
41
+ ),
42
+ start_time: Type.Optional(
43
+ Type.String({
44
+ description:
45
+ "Start of time range for action=list, as Unix timestamp in seconds (e.g. \"1710000000\"). Omit for no lower bound.",
46
+ }),
47
+ ),
48
+ end_time: Type.Optional(
49
+ Type.String({
50
+ description:
51
+ "End of time range for action=list, as Unix timestamp in seconds (e.g. \"1710086400\"). Omit for no upper bound.",
52
+ }),
53
+ ),
54
+ });
55
+
56
+ export type FeishuMessageParams = Static<typeof FeishuMessageSchema>;
package/src/outbound.ts CHANGED
@@ -12,7 +12,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
12
12
  const result = await sendMessageFeishu({ cfg, to, text, accountId });
13
13
  return { channel: "feishu", ...result };
14
14
  },
15
- sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
15
+ sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId }) => {
16
16
  // Send text first if provided
17
17
  if (text?.trim()) {
18
18
  await sendMessageFeishu({ cfg, to, text, accountId });
@@ -21,7 +21,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
21
21
  // Upload and send media if URL provided
22
22
  if (mediaUrl) {
23
23
  try {
24
- const result = await sendMediaFeishu({ cfg, to, mediaUrl, accountId });
24
+ const result = await sendMediaFeishu({ cfg, to, mediaUrl, mediaLocalRoots, accountId });
25
25
  return { channel: "feishu", ...result };
26
26
  } catch (err) {
27
27
  // Log the error for debugging
@@ -33,6 +33,13 @@ type MemberType =
33
33
  | "wikispaceid";
34
34
  type PermType = "view" | "edit" | "full_access";
35
35
 
36
+ function requireString(value: unknown, field: string): string {
37
+ if (typeof value !== "string" || value.trim().length === 0) {
38
+ throw new Error(`${field} is required`);
39
+ }
40
+ return value;
41
+ }
42
+
36
43
  async function listMembers(client: PermClient, token: string, type: string) {
37
44
  const res = await runPermApiCall("drive.permissionMember.list", () =>
38
45
  client.drive.permissionMember.list({
@@ -100,11 +107,28 @@ async function removeMember(
100
107
  export async function runPermAction(client: PermClient, params: FeishuPermParams) {
101
108
  switch (params.action) {
102
109
  case "list":
103
- return listMembers(client, params.token, params.type);
110
+ return listMembers(
111
+ client,
112
+ requireString(params.token, "token"),
113
+ requireString(params.type, "type"),
114
+ );
104
115
  case "add":
105
- return addMember(client, params.token, params.type, params.member_type, params.member_id, params.perm);
116
+ return addMember(
117
+ client,
118
+ requireString(params.token, "token"),
119
+ requireString(params.type, "type"),
120
+ requireString(params.member_type, "member_type"),
121
+ requireString(params.member_id, "member_id"),
122
+ requireString(params.perm, "perm"),
123
+ );
106
124
  case "remove":
107
- return removeMember(client, params.token, params.type, params.member_type, params.member_id);
125
+ return removeMember(
126
+ client,
127
+ requireString(params.token, "token"),
128
+ requireString(params.type, "type"),
129
+ requireString(params.member_type, "member_type"),
130
+ requireString(params.member_id, "member_id"),
131
+ );
108
132
  default:
109
133
  return { error: `Unknown action: ${(params as any).action}` };
110
134
  }
@@ -1,52 +1,46 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
 
3
- const TokenType = 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("wiki"),
11
- Type.Literal("mindnote"),
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 MemberType = Type.Union([
15
- Type.Literal("email"),
16
- Type.Literal("openid"),
17
- Type.Literal("userid"),
18
- Type.Literal("unionid"),
19
- Type.Literal("openchat"),
20
- Type.Literal("opendepartmentid"),
21
- ]);
10
+ const TOKEN_TYPE_VALUES = [
11
+ "doc",
12
+ "docx",
13
+ "sheet",
14
+ "bitable",
15
+ "folder",
16
+ "file",
17
+ "wiki",
18
+ "mindnote",
19
+ ] as const;
22
20
 
23
- const Permission = Type.Union([
24
- Type.Literal("view"),
25
- Type.Literal("edit"),
26
- Type.Literal("full_access"),
27
- ]);
21
+ const MEMBER_TYPE_VALUES = [
22
+ "email",
23
+ "openid",
24
+ "userid",
25
+ "unionid",
26
+ "openchat",
27
+ "opendepartmentid",
28
+ ] as const;
28
29
 
29
- export const FeishuPermSchema = Type.Union([
30
- Type.Object({
31
- action: Type.Literal("list"),
32
- token: Type.String({ description: "File token" }),
33
- type: TokenType,
34
- }),
35
- Type.Object({
36
- action: Type.Literal("add"),
37
- token: Type.String({ description: "File token" }),
38
- type: TokenType,
39
- member_type: MemberType,
40
- member_id: Type.String({ description: "Member ID (email, open_id, user_id, etc.)" }),
41
- perm: Permission,
42
- }),
43
- Type.Object({
44
- action: Type.Literal("remove"),
45
- token: Type.String({ description: "File token" }),
46
- type: TokenType,
47
- member_type: MemberType,
48
- member_id: Type.String({ description: "Member ID to remove" }),
49
- }),
50
- ]);
30
+ const PERMISSION_VALUES = ["view", "edit", "full_access"] as const;
31
+ const PERM_ACTION_VALUES = ["list", "add", "remove"] as const;
32
+
33
+ export const FeishuPermSchema = Type.Object({
34
+ action: stringEnum(PERM_ACTION_VALUES, { description: "Permission action" }),
35
+ token: Type.Optional(Type.String({ description: "File token" })),
36
+ type: Type.Optional(stringEnum(TOKEN_TYPE_VALUES, { description: "File token type" })),
37
+ member_type: Type.Optional(
38
+ stringEnum(MEMBER_TYPE_VALUES, {
39
+ description: "Member ID type (email/openid/userid/unionid/openchat/opendepartmentid)",
40
+ }),
41
+ ),
42
+ member_id: Type.Optional(Type.String({ description: "Member ID" })),
43
+ perm: Type.Optional(stringEnum(PERMISSION_VALUES, { description: "Permission level" })),
44
+ });
51
45
 
52
46
  export type FeishuPermParams = Static<typeof FeishuPermSchema>;