@m1heng-clawd/feishu 0.1.13 → 0.1.15

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.
@@ -5,6 +5,30 @@ 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
+ async function getFileType(client: DriveClient, fileToken: string): Promise<string> {
9
+ let pageToken: string | undefined;
10
+
11
+ do {
12
+ const listRes = await runDriveApiCall("drive.file.list", () =>
13
+ client.drive.file.list({
14
+ params: pageToken ? { page_token: pageToken } : {},
15
+ }),
16
+ );
17
+
18
+ const file = listRes.data?.files?.find((f: any) => f.token === fileToken);
19
+ if (file) {
20
+ if (!file.type) {
21
+ throw new Error(`File found but no type for ${fileToken}. Please provide the 'type' parameter explicitly.`);
22
+ }
23
+ return file.type;
24
+ }
25
+
26
+ pageToken = listRes.data?.next_page_token;
27
+ } while (pageToken);
28
+
29
+ throw new Error(`File not found: ${fileToken}. Please provide the 'type' parameter explicitly.`);
30
+ }
31
+
8
32
  async function getRootFolderToken(client: DriveClient): Promise<string> {
9
33
  // Use generic HTTP client to call the root folder meta API
10
34
  // as it's not directly exposed in the SDK.
@@ -46,27 +70,32 @@ async function listFolder(client: DriveClient, folderToken?: string) {
46
70
  }
47
71
 
48
72
  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
- );
73
+ let pageToken: string | undefined;
74
+
75
+ do {
76
+ const res = await runDriveApiCall("drive.file.list", () =>
77
+ client.drive.file.list({
78
+ params: folderToken ? { folder_token: folderToken, page_token: pageToken } : { page_token: pageToken },
79
+ }),
80
+ );
81
+
82
+ const file = res.data?.files?.find((f: any) => f.token === fileToken);
83
+ if (file) {
84
+ return {
85
+ token: file.token,
86
+ name: file.name,
87
+ type: file.type,
88
+ url: file.url,
89
+ created_time: file.created_time,
90
+ modified_time: file.modified_time,
91
+ owner_id: file.owner_id,
92
+ };
93
+ }
55
94
 
56
- const file = res.data?.files?.find((f) => f.token === fileToken);
57
- if (!file) {
58
- throw new Error(`File not found: ${fileToken}`);
59
- }
95
+ pageToken = res.data?.next_page_token;
96
+ } while (pageToken);
60
97
 
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
- };
98
+ throw new Error(`File not found: ${fileToken}`);
70
99
  }
71
100
 
72
101
  async function createFolder(client: DriveClient, name: string, folderToken?: string) {
@@ -119,12 +148,18 @@ async function moveFile(
119
148
  };
120
149
  }
121
150
 
122
- async function deleteFile(client: DriveClient, fileToken: string, type: string) {
151
+ async function deleteFile(client: DriveClient, fileToken: string, type?: string) {
152
+ let effectiveType = type;
153
+
154
+ if (!effectiveType) {
155
+ effectiveType = await getFileType(client, fileToken);
156
+ }
157
+
123
158
  const res = await runDriveApiCall("drive.file.delete", () =>
124
159
  client.drive.file.delete({
125
160
  path: { file_token: fileToken },
126
161
  params: {
127
- type: type as DriveDeleteType,
162
+ type: effectiveType as DriveDeleteType,
128
163
  },
129
164
  }),
130
165
  );
@@ -132,6 +167,7 @@ async function deleteFile(client: DriveClient, fileToken: string, type: string)
132
167
  return {
133
168
  success: true,
134
169
  task_id: res.data?.task_id,
170
+ type_used: effectiveType,
135
171
  };
136
172
  }
137
173
 
@@ -26,7 +26,7 @@ export const FeishuDriveSchema = Type.Union([
26
26
  Type.Object({
27
27
  action: Type.Literal("info"),
28
28
  file_token: Type.String({ description: "File or folder token" }),
29
- type: FileType,
29
+ type: Type.Optional(FileType),
30
30
  }),
31
31
  Type.Object({
32
32
  action: Type.Literal("create_folder"),
@@ -44,7 +44,7 @@ export const FeishuDriveSchema = Type.Union([
44
44
  Type.Object({
45
45
  action: Type.Literal("delete"),
46
46
  file_token: Type.String({ description: "File token to delete" }),
47
- type: FileType,
47
+ type: Type.Optional(FileType),
48
48
  }),
49
49
  Type.Object({
50
50
  action: Type.Literal("import_document"),
@@ -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,24 @@ export async function uploadImageFeishu(params: {
236
237
  return { imageKey };
237
238
  }
238
239
 
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
+
239
258
  /**
240
259
  * Upload a file to Feishu and get a file_key for sending.
241
260
  * Max file size: 30MB
@@ -261,10 +280,12 @@ export async function uploadFileFeishu(params: {
261
280
  const fileStream =
262
281
  typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
263
282
 
283
+ const safeFileName = sanitizeFileNameForUpload(fileName);
284
+
264
285
  const response = await client.im.file.create({
265
286
  data: {
266
287
  file_type: fileType,
267
- file_name: fileName,
288
+ file_name: safeFileName,
268
289
  file: fileStream as any,
269
290
  ...(duration !== undefined && { duration }),
270
291
  },
@@ -356,10 +377,12 @@ export async function sendFileFeishu(params: {
356
377
  fileKey: string;
357
378
  /** Use "audio" for audio, "media" for video, "file" for documents */
358
379
  msgType?: "file" | "audio" | "media";
380
+ /** Optional cover image key for video (msg_type "media") messages */
381
+ imageKey?: string;
359
382
  replyToMessageId?: string;
360
383
  accountId?: string;
361
384
  }): Promise<SendMediaResult> {
362
- const { cfg, to, fileKey, replyToMessageId, accountId } = params;
385
+ const { cfg, to, fileKey, imageKey, replyToMessageId, accountId } = params;
363
386
  const msgType = params.msgType ?? "file";
364
387
  const account = resolveFeishuAccount({ cfg, accountId });
365
388
  if (!account.configured) {
@@ -373,7 +396,10 @@ export async function sendFileFeishu(params: {
373
396
  }
374
397
 
375
398
  const receiveIdType = resolveReceiveIdType(receiveId);
376
- const content = JSON.stringify({ file_key: fileKey });
399
+ const content = JSON.stringify({
400
+ file_key: fileKey,
401
+ ...(imageKey && { image_key: imageKey }),
402
+ });
377
403
 
378
404
  if (replyToMessageId) {
379
405
  const response = await client.im.message.reply({
@@ -421,12 +447,27 @@ export function detectFileType(
421
447
  ): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
422
448
  const ext = path.extname(fileName).toLowerCase();
423
449
  switch (ext) {
450
+ // Audio formats → Feishu "opus" category
424
451
  case ".opus":
425
452
  case ".ogg":
453
+ case ".mp3":
454
+ case ".m4a":
455
+ case ".aac":
456
+ case ".wav":
457
+ case ".flac":
458
+ case ".wma":
459
+ case ".amr":
426
460
  return "opus";
461
+ // Video formats → Feishu "mp4" category
427
462
  case ".mp4":
428
463
  case ".mov":
429
464
  case ".avi":
465
+ case ".mkv":
466
+ case ".webm":
467
+ case ".flv":
468
+ case ".wmv":
469
+ case ".m4v":
470
+ case ".3gp":
430
471
  return "mp4";
431
472
  case ".pdf":
432
473
  return "pdf";
@@ -444,6 +485,7 @@ export function detectFileType(
444
485
  }
445
486
  }
446
487
 
488
+
447
489
  /**
448
490
  * Upload and send media (image or file) from URL, local path, or buffer
449
491
  */
@@ -465,6 +507,7 @@ export async function sendMediaFeishu(params: {
465
507
 
466
508
  let buffer: Buffer;
467
509
  let name: string;
510
+ let contentType: string | undefined;
468
511
 
469
512
  if (mediaBuffer) {
470
513
  buffer = mediaBuffer;
@@ -496,6 +539,7 @@ export async function sendMediaFeishu(params: {
496
539
  })();
497
540
  buffer = loaded.buffer;
498
541
  name = fileName ?? loaded.fileName ?? "file";
542
+ contentType = loaded.contentType;
499
543
  } else {
500
544
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
501
545
  }
@@ -508,16 +552,27 @@ export async function sendMediaFeishu(params: {
508
552
  const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
509
553
  return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId });
510
554
  } else {
511
- const fileType = detectFileType(name);
555
+ // Determine file type from extension; fall back to MIME type for files without
556
+ // a recognized extension (e.g. URLs with no filename, or buffers without fileName).
557
+ let fileType = detectFileType(name);
558
+ if (fileType === "stream" && contentType) {
559
+ if (contentType.startsWith("video/")) fileType = "mp4";
560
+ else if (contentType.startsWith("audio/")) fileType = "opus";
561
+ }
562
+ const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
563
+ const duration =
564
+ fileType === "opus" || fileType === "mp4"
565
+ ? parseFeishuMediaDurationMs(buffer, fileType)
566
+ : undefined;
512
567
  const { fileKey } = await uploadFileFeishu({
513
568
  cfg,
514
569
  file: buffer,
515
570
  fileName: name,
516
571
  fileType,
572
+ duration,
517
573
  accountId,
518
574
  });
519
575
  // Feishu requires msg_type "audio" for audio, "media" for video, "file" for documents
520
- const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
521
576
  return sendFileFeishu({
522
577
  cfg,
523
578
  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/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: {