@m1heng-clawd/feishu 0.1.14 → 0.1.16

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 (43) hide show
  1. package/README.md +82 -2
  2. package/index.ts +7 -2
  3. package/package.json +5 -4
  4. package/skills/feishu-chat/SKILL.md +139 -0
  5. package/skills/feishu-urgent/SKILL.md +107 -0
  6. package/src/bot.ts +391 -45
  7. package/src/channel.ts +2 -0
  8. package/src/chat-tools/actions.ts +502 -0
  9. package/src/chat-tools/common.ts +13 -0
  10. package/src/chat-tools/index.ts +2 -0
  11. package/src/chat-tools/register.ts +72 -0
  12. package/src/chat-tools/schemas.ts +61 -0
  13. package/src/client.ts +13 -0
  14. package/src/config-schema.ts +6 -0
  15. package/src/doc-tools/actions.ts +63 -14
  16. package/src/doc-tools/schemas.ts +41 -77
  17. package/src/doc-write-service.ts +19 -0
  18. package/src/drive-tools/actions.ts +75 -27
  19. package/src/drive-tools/schemas.ts +45 -58
  20. package/src/media-duration.ts +190 -0
  21. package/src/media.ts +50 -27
  22. package/src/mention.ts +1 -1
  23. package/src/monitor.ts +32 -8
  24. package/src/outbound.ts +2 -2
  25. package/src/perm-tools/actions.ts +27 -3
  26. package/src/perm-tools/schemas.ts +39 -45
  27. package/src/policy.ts +7 -3
  28. package/src/reply-dispatcher.ts +97 -13
  29. package/src/send.ts +360 -42
  30. package/src/streaming-card.ts +32 -4
  31. package/src/task-tools/actions.ts +145 -10
  32. package/src/task-tools/register.ts +56 -1
  33. package/src/task-tools/schemas.ts +23 -18
  34. package/src/tools-common/tool-context.ts +1 -0
  35. package/src/tools-config.ts +9 -2
  36. package/src/types.ts +8 -1
  37. package/src/typing.ts +115 -5
  38. package/src/urgent-tools/actions.ts +84 -0
  39. package/src/urgent-tools/index.ts +3 -0
  40. package/src/urgent-tools/register.ts +64 -0
  41. package/src/urgent-tools/schemas.ts +25 -0
  42. package/src/wiki-tools/actions.ts +24 -6
  43. package/src/wiki-tools/schemas.ts +32 -51
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Media duration parsers for Feishu file upload.
3
+ *
4
+ * Feishu's im.file.create API requires a `duration` (in ms) for audio and video
5
+ * so that the player shows the correct length and enables seeking.
6
+ *
7
+ * Supported containers:
8
+ * - OGG (Opus / Vorbis) — used by Feishu voice messages and most AI TTS output
9
+ * - MP4 (ISO base media) — MP4, MOV, QuickTime, M4A, M4V, 3GP share this format
10
+ * - WAV (RIFF PCM) — uncompressed audio
11
+ *
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
15
+ */
16
+
17
+ /**
18
+ * Parse duration from an OGG container (Opus or Vorbis).
19
+ * Reads the granule position from the last OGG page and divides by sample rate.
20
+ * Returns duration in milliseconds, or undefined if not OGG or parsing fails.
21
+ */
22
+ export function parseOggDurationMs(buffer: Buffer): number | undefined {
23
+ if (buffer.length < 27) return undefined;
24
+ // Verify OGG capture pattern "OggS"
25
+ if (buffer[0] !== 0x4f || buffer[1] !== 0x67 || buffer[2] !== 0x67 || buffer[3] !== 0x53) {
26
+ return undefined;
27
+ }
28
+
29
+ // Detect codec and sample rate from identification header (scan first 4 KB)
30
+ let sampleRate = 0;
31
+ const searchLen = Math.min(buffer.length, 4096);
32
+ for (let i = 0; i < searchLen - 8; i++) {
33
+ // "OpusHead" — Opus always uses 48000 Hz for granule positions
34
+ if (
35
+ buffer[i] === 0x4f && buffer[i + 1] === 0x70 && buffer[i + 2] === 0x75 &&
36
+ buffer[i + 3] === 0x73 && buffer[i + 4] === 0x48 && buffer[i + 5] === 0x65 &&
37
+ buffer[i + 6] === 0x61 && buffer[i + 7] === 0x64
38
+ ) {
39
+ sampleRate = 48000;
40
+ break;
41
+ }
42
+ // "\x01vorbis" — sample rate is uint32LE at +12 from start of tag
43
+ if (
44
+ buffer[i] === 0x01 && buffer[i + 1] === 0x76 && buffer[i + 2] === 0x6f &&
45
+ buffer[i + 3] === 0x72 && buffer[i + 4] === 0x62 && buffer[i + 5] === 0x69 &&
46
+ buffer[i + 6] === 0x73
47
+ ) {
48
+ if (i + 16 <= buffer.length) sampleRate = buffer.readUInt32LE(i + 12);
49
+ break;
50
+ }
51
+ }
52
+ if (sampleRate === 0) return undefined;
53
+
54
+ // Scan all OGG pages to find the highest valid granule position
55
+ let lastGranule = 0;
56
+ let offset = 0;
57
+ while (offset + 27 <= buffer.length) {
58
+ if (
59
+ buffer[offset] !== 0x4f || buffer[offset + 1] !== 0x67 ||
60
+ buffer[offset + 2] !== 0x67 || buffer[offset + 3] !== 0x53
61
+ ) break;
62
+ // Granule position: 8 bytes LE at offset+6; 0xFFFFFFFFFFFFFFFF means "no position"
63
+ const lo = buffer.readUInt32LE(offset + 6);
64
+ const hi = buffer.readUInt32LE(offset + 10);
65
+ if (lo !== 0xffffffff || hi !== 0xffffffff) {
66
+ const granule = hi * 0x100000000 + lo;
67
+ if (granule > lastGranule) lastGranule = granule;
68
+ }
69
+ const numSegments = buffer.readUInt8(offset + 26);
70
+ if (offset + 27 + numSegments > buffer.length) break;
71
+ let pageDataSize = 0;
72
+ for (let i = 0; i < numSegments; i++) pageDataSize += buffer.readUInt8(offset + 27 + i);
73
+ offset += 27 + numSegments + pageDataSize;
74
+ }
75
+
76
+ if (lastGranule <= 0) return undefined;
77
+ return Math.round((lastGranule / sampleRate) * 1000);
78
+ }
79
+
80
+ /**
81
+ * Parse duration from an ISO base media file format container (MP4 / MOV / M4A / QuickTime / 3GP).
82
+ * Reads the `mvhd` (movie header) box inside `moov`.
83
+ * Returns duration in milliseconds, or undefined if the box is not found.
84
+ */
85
+ export function parseMp4DurationMs(buffer: Buffer): number | undefined {
86
+ // Find 'moov' box at the top level
87
+ let offset = 0;
88
+ let moovStart = -1;
89
+ let moovEnd = -1;
90
+ while (offset + 8 <= buffer.length) {
91
+ const boxSize = buffer.readUInt32BE(offset);
92
+ if (boxSize < 8) break;
93
+ if (buffer.toString("ascii", offset + 4, offset + 8) === "moov") {
94
+ moovStart = offset;
95
+ moovEnd = Math.min(offset + boxSize, buffer.length);
96
+ break;
97
+ }
98
+ offset += boxSize;
99
+ }
100
+ if (moovStart < 0) return undefined;
101
+
102
+ // Find 'mvhd' inside 'moov'
103
+ let inner = moovStart + 8;
104
+ while (inner + 8 <= moovEnd) {
105
+ const boxSize = buffer.readUInt32BE(inner);
106
+ if (boxSize < 8) break;
107
+ if (buffer.toString("ascii", inner + 4, inner + 8) === "mvhd") {
108
+ const version = buffer.readUInt8(inner + 8);
109
+ if (version === 0) {
110
+ // creation(4) + modification(4) + timescale(4) + duration(4)
111
+ if (inner + 28 > buffer.length) return undefined;
112
+ const timeScale = buffer.readUInt32BE(inner + 20);
113
+ const duration = buffer.readUInt32BE(inner + 24);
114
+ if (timeScale === 0) return undefined;
115
+ return Math.round((duration / timeScale) * 1000);
116
+ } else if (version === 1) {
117
+ // creation(8) + modification(8) + timescale(4) + duration(8)
118
+ if (inner + 40 > buffer.length) return undefined;
119
+ const timeScale = buffer.readUInt32BE(inner + 28);
120
+ const durationHi = buffer.readUInt32BE(inner + 32);
121
+ const durationLo = buffer.readUInt32BE(inner + 36);
122
+ const duration = durationHi * 0x100000000 + durationLo;
123
+ if (timeScale === 0) return undefined;
124
+ return Math.round((duration / timeScale) * 1000);
125
+ }
126
+ return undefined;
127
+ }
128
+ inner += boxSize;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * Parse duration from a WAV (RIFF PCM) file.
135
+ * Computes duration from the `data` chunk size and the byte rate in the `fmt ` chunk.
136
+ * Returns duration in milliseconds, or undefined if not WAV or parsing fails.
137
+ */
138
+ export function parseWavDurationMs(buffer: Buffer): number | undefined {
139
+ if (buffer.length < 44) return undefined;
140
+ if (buffer.toString("ascii", 0, 4) !== "RIFF") return undefined;
141
+ if (buffer.toString("ascii", 8, 12) !== "WAVE") return undefined;
142
+
143
+ let offset = 12;
144
+ let byteRate = 0;
145
+ let dataSize = 0;
146
+
147
+ while (offset + 8 <= buffer.length) {
148
+ const chunkId = buffer.toString("ascii", offset, offset + 4);
149
+ const chunkSize = buffer.readUInt32LE(offset + 4);
150
+ if (chunkId === "fmt ") {
151
+ // fmt chunk layout (from chunk start):
152
+ // +0 chunk id "fmt " (4)
153
+ // +4 chunk size (4)
154
+ // +8 audio format (2)
155
+ // +10 num channels (2)
156
+ // +12 sample rate (4)
157
+ // +16 byte rate (4) ← what we need
158
+ if (offset + 20 > buffer.length) return undefined;
159
+ byteRate = buffer.readUInt32LE(offset + 16);
160
+ } else if (chunkId === "data") {
161
+ dataSize = chunkSize;
162
+ break;
163
+ }
164
+ // Advance to next chunk; RIFF chunks are aligned to even byte boundaries
165
+ offset += 8 + chunkSize + (chunkSize % 2);
166
+ }
167
+
168
+ if (byteRate === 0 || dataSize === 0) return undefined;
169
+ return Math.round((dataSize / byteRate) * 1000);
170
+ }
171
+
172
+ /**
173
+ * Parse duration from a media buffer for Feishu upload.
174
+ *
175
+ * Routes to the appropriate parser based on Feishu's file type:
176
+ * - "mp4" → MP4/MOV/QuickTime container parser
177
+ * - "opus" → OGG parser, then MP4 container (covers M4A), then WAV
178
+ *
179
+ * Returns duration in milliseconds, or undefined if not determinable.
180
+ */
181
+ export function parseFeishuMediaDurationMs(
182
+ buffer: Buffer,
183
+ fileType: "opus" | "mp4",
184
+ ): number | undefined {
185
+ if (fileType === "mp4") {
186
+ return parseMp4DurationMs(buffer);
187
+ }
188
+ // fileType === "opus": try each container format in likelihood order
189
+ return parseOggDurationMs(buffer) ?? parseMp4DurationMs(buffer) ?? parseWavDurationMs(buffer);
190
+ }
package/src/media.ts CHANGED
@@ -3,6 +3,7 @@ import { createFeishuClient } from "./client.js";
3
3
  import { resolveFeishuAccount } from "./accounts.js";
4
4
  import { getFeishuRuntime } from "./runtime.js";
5
5
  import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
6
+ import { parseFeishuMediaDurationMs } from "./media-duration.js";
6
7
  import fs from "fs";
7
8
  import path from "path";
8
9
  import os from "os";
@@ -236,6 +237,7 @@ export async function uploadImageFeishu(params: {
236
237
  return { imageKey };
237
238
  }
238
239
 
240
+
239
241
  /**
240
242
  * Upload a file to Feishu and get a file_key for sending.
241
243
  * Max file size: 30MB
@@ -356,10 +358,12 @@ export async function sendFileFeishu(params: {
356
358
  fileKey: string;
357
359
  /** Use "audio" for audio, "media" for video, "file" for documents */
358
360
  msgType?: "file" | "audio" | "media";
361
+ /** Optional cover image key for video (msg_type "media") messages */
362
+ imageKey?: string;
359
363
  replyToMessageId?: string;
360
364
  accountId?: string;
361
365
  }): Promise<SendMediaResult> {
362
- const { cfg, to, fileKey, replyToMessageId, accountId } = params;
366
+ const { cfg, to, fileKey, imageKey, replyToMessageId, accountId } = params;
363
367
  const msgType = params.msgType ?? "file";
364
368
  const account = resolveFeishuAccount({ cfg, accountId });
365
369
  if (!account.configured) {
@@ -373,7 +377,10 @@ export async function sendFileFeishu(params: {
373
377
  }
374
378
 
375
379
  const receiveIdType = resolveReceiveIdType(receiveId);
376
- const content = JSON.stringify({ file_key: fileKey });
380
+ const content = JSON.stringify({
381
+ file_key: fileKey,
382
+ ...(imageKey && { image_key: imageKey }),
383
+ });
377
384
 
378
385
  if (replyToMessageId) {
379
386
  const response = await client.im.message.reply({
@@ -421,12 +428,27 @@ export function detectFileType(
421
428
  ): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
422
429
  const ext = path.extname(fileName).toLowerCase();
423
430
  switch (ext) {
431
+ // Audio formats → Feishu "opus" category
424
432
  case ".opus":
425
433
  case ".ogg":
434
+ case ".mp3":
435
+ case ".m4a":
436
+ case ".aac":
437
+ case ".wav":
438
+ case ".flac":
439
+ case ".wma":
440
+ case ".amr":
426
441
  return "opus";
442
+ // Video formats → Feishu "mp4" category
427
443
  case ".mp4":
428
444
  case ".mov":
429
445
  case ".avi":
446
+ case ".mkv":
447
+ case ".webm":
448
+ case ".flv":
449
+ case ".wmv":
450
+ case ".m4v":
451
+ case ".3gp":
430
452
  return "mp4";
431
453
  case ".pdf":
432
454
  return "pdf";
@@ -444,6 +466,7 @@ export function detectFileType(
444
466
  }
445
467
  }
446
468
 
469
+
447
470
  /**
448
471
  * Upload and send media (image or file) from URL, local path, or buffer
449
472
  */
@@ -454,9 +477,10 @@ export async function sendMediaFeishu(params: {
454
477
  mediaBuffer?: Buffer;
455
478
  fileName?: string;
456
479
  replyToMessageId?: string;
480
+ mediaLocalRoots?: readonly string[];
457
481
  accountId?: string;
458
482
  }): Promise<SendMediaResult> {
459
- const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
483
+ const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, mediaLocalRoots, accountId } = params;
460
484
  const account = resolveFeishuAccount({ cfg, accountId });
461
485
  if (!account.configured) {
462
486
  throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -465,37 +489,25 @@ export async function sendMediaFeishu(params: {
465
489
 
466
490
  let buffer: Buffer;
467
491
  let name: string;
492
+ let contentType: string | undefined;
468
493
 
469
494
  if (mediaBuffer) {
470
495
  buffer = mediaBuffer;
471
496
  name = fileName ?? "file";
472
497
  } else if (mediaUrl) {
473
- const mediaLocalRoots = (account.config?.mediaLocalRoots ?? [])
498
+ const configLocalRoots = (account.config?.mediaLocalRoots ?? [])
474
499
  .map((root) => root.trim())
475
500
  .filter((root) => root.length > 0);
476
- const loaded = await (async () => {
477
- try {
478
- return await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
479
- maxBytes: mediaMaxBytes,
480
- optimizeImages: false,
481
- });
482
- } catch (err) {
483
- const shouldRetryWithCustomRoots =
484
- mediaLocalRoots.length > 0 &&
485
- err instanceof Error &&
486
- err.message.includes("Local media path is not under an allowed directory");
487
- if (!shouldRetryWithCustomRoots) {
488
- throw err;
489
- }
490
- return await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
491
- maxBytes: mediaMaxBytes,
492
- optimizeImages: false,
493
- localRoots: mediaLocalRoots,
494
- });
495
- }
496
- })();
501
+ // Merge context-provided roots (includes agent workspace) with config roots.
502
+ const mergedLocalRoots = [...(mediaLocalRoots ?? []), ...configLocalRoots];
503
+ const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
504
+ maxBytes: mediaMaxBytes,
505
+ optimizeImages: false,
506
+ ...(mergedLocalRoots.length > 0 ? { localRoots: mergedLocalRoots } : {}),
507
+ });
497
508
  buffer = loaded.buffer;
498
509
  name = fileName ?? loaded.fileName ?? "file";
510
+ contentType = loaded.contentType;
499
511
  } else {
500
512
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
501
513
  }
@@ -508,16 +520,27 @@ export async function sendMediaFeishu(params: {
508
520
  const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
509
521
  return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId });
510
522
  } else {
511
- const fileType = detectFileType(name);
523
+ // Determine file type from extension; fall back to MIME type for files without
524
+ // a recognized extension (e.g. URLs with no filename, or buffers without fileName).
525
+ let fileType = detectFileType(name);
526
+ if (fileType === "stream" && contentType) {
527
+ if (contentType.startsWith("video/")) fileType = "mp4";
528
+ else if (contentType.startsWith("audio/")) fileType = "opus";
529
+ }
530
+ const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
531
+ const duration =
532
+ fileType === "opus" || fileType === "mp4"
533
+ ? parseFeishuMediaDurationMs(buffer, fileType)
534
+ : undefined;
512
535
  const { fileKey } = await uploadFileFeishu({
513
536
  cfg,
514
537
  file: buffer,
515
538
  fileName: name,
516
539
  fileType,
540
+ duration,
517
541
  accountId,
518
542
  });
519
543
  // Feishu requires msg_type "audio" for audio, "media" for video, "file" for documents
520
- const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
521
544
  return sendFileFeishu({
522
545
  cfg,
523
546
  to,
package/src/mention.ts CHANGED
@@ -45,7 +45,7 @@ export function isMentionForwardRequest(
45
45
  const mentions = event.message.mentions ?? [];
46
46
  if (mentions.length === 0) return false;
47
47
 
48
- const isDirectMessage = event.message.chat_type === "p2p";
48
+ const isDirectMessage = event.message.chat_type !== "group";
49
49
  const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId);
50
50
 
51
51
  if (isDirectMessage) {
package/src/monitor.ts CHANGED
@@ -37,6 +37,26 @@ async function fetchBotOpenId(
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * Per-chat serial queue that ensures messages from the same chat are processed
42
+ * in arrival order while allowing different chats to run concurrently.
43
+ * Uses .then(task, task) so a failure in one message never blocks the next.
44
+ */
45
+ function createChatQueue() {
46
+ const queues = new Map<string, Promise<void>>();
47
+ return (chatId: string, task: () => Promise<void>): Promise<void> => {
48
+ const prev = queues.get(chatId) ?? Promise.resolve();
49
+ const next = prev.then(task, task);
50
+ queues.set(chatId, next);
51
+ void next.finally(() => {
52
+ if (queues.get(chatId) === next) {
53
+ queues.delete(chatId);
54
+ }
55
+ });
56
+ return next;
57
+ };
58
+ }
59
+
40
60
  /**
41
61
  * Register common event handlers on an EventDispatcher.
42
62
  * When fireAndForget is true (webhook mode), message handling is not awaited
@@ -55,19 +75,23 @@ function registerEventHandlers(
55
75
  const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
56
76
  const log = runtime?.log ?? console.log;
57
77
  const error = runtime?.error ?? console.error;
78
+ const enqueue = createChatQueue();
58
79
 
59
80
  eventDispatcher.register({
60
81
  "im.message.receive_v1": async (data) => {
61
82
  try {
62
83
  const event = data as unknown as FeishuMessageEvent;
63
- const promise = handleFeishuMessage({
64
- cfg,
65
- event,
66
- botOpenId: botOpenIds.get(accountId),
67
- runtime,
68
- chatHistories,
69
- accountId,
70
- });
84
+ const chatId = event.message.chat_id?.trim() || "unknown";
85
+ const task = () =>
86
+ handleFeishuMessage({
87
+ cfg,
88
+ event,
89
+ botOpenId: botOpenIds.get(accountId),
90
+ runtime,
91
+ chatHistories,
92
+ accountId,
93
+ });
94
+ const promise = enqueue(chatId, task);
71
95
  if (fireAndForget) {
72
96
  promise.catch((err) => {
73
97
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
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>;
package/src/policy.ts CHANGED
@@ -82,15 +82,19 @@ export function resolveFeishuReplyPolicy(params: {
82
82
  isDirectMessage: boolean;
83
83
  globalConfig?: FeishuConfig;
84
84
  groupConfig?: FeishuGroupConfig;
85
- }): { requireMention: boolean } {
85
+ }): { requireMention: boolean; allowMentionlessInMultiBotGroup: boolean } {
86
86
  if (params.isDirectMessage) {
87
- return { requireMention: false };
87
+ return { requireMention: false, allowMentionlessInMultiBotGroup: false };
88
88
  }
89
89
 
90
90
  const requireMention =
91
91
  params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
92
+ const allowMentionlessInMultiBotGroup =
93
+ params.groupConfig?.allowMentionlessInMultiBotGroup ??
94
+ params.globalConfig?.allowMentionlessInMultiBotGroup ??
95
+ false;
92
96
 
93
- return { requireMention };
97
+ return { requireMention, allowMentionlessInMultiBotGroup };
94
98
  }
95
99
 
96
100
  export function resolveFeishuGroupCommandMentionBypass(params: {