@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.
- package/README.md +82 -2
- package/index.ts +7 -2
- package/package.json +5 -4
- package/skills/feishu-chat/SKILL.md +139 -0
- package/skills/feishu-urgent/SKILL.md +107 -0
- package/src/bot.ts +391 -45
- package/src/channel.ts +2 -0
- package/src/chat-tools/actions.ts +502 -0
- package/src/chat-tools/common.ts +13 -0
- package/src/chat-tools/index.ts +2 -0
- package/src/chat-tools/register.ts +72 -0
- package/src/chat-tools/schemas.ts +61 -0
- package/src/client.ts +13 -0
- package/src/config-schema.ts +6 -0
- package/src/doc-tools/actions.ts +63 -14
- package/src/doc-tools/schemas.ts +41 -77
- package/src/doc-write-service.ts +19 -0
- package/src/drive-tools/actions.ts +75 -27
- package/src/drive-tools/schemas.ts +45 -58
- package/src/media-duration.ts +190 -0
- package/src/media.ts +50 -27
- package/src/mention.ts +1 -1
- package/src/monitor.ts +32 -8
- package/src/outbound.ts +2 -2
- package/src/perm-tools/actions.ts +27 -3
- package/src/perm-tools/schemas.ts +39 -45
- package/src/policy.ts +7 -3
- package/src/reply-dispatcher.ts +97 -13
- package/src/send.ts +360 -42
- package/src/streaming-card.ts +32 -4
- package/src/task-tools/actions.ts +145 -10
- package/src/task-tools/register.ts +56 -1
- package/src/task-tools/schemas.ts +23 -18
- package/src/tools-common/tool-context.ts +1 -0
- package/src/tools-config.ts +9 -2
- package/src/types.ts +8 -1
- package/src/typing.ts +115 -5
- package/src/urgent-tools/actions.ts +84 -0
- package/src/urgent-tools/index.ts +3 -0
- package/src/urgent-tools/register.ts +64 -0
- package/src/urgent-tools/schemas.ts +25 -0
- package/src/wiki-tools/actions.ts +24 -6
- 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({
|
|
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
|
|
498
|
+
const configLocalRoots = (account.config?.mediaLocalRoots ?? [])
|
|
474
499
|
.map((root) => root.trim())
|
|
475
500
|
.filter((root) => root.length > 0);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
110
|
+
return listMembers(
|
|
111
|
+
client,
|
|
112
|
+
requireString(params.token, "token"),
|
|
113
|
+
requireString(params.type, "type"),
|
|
114
|
+
);
|
|
104
115
|
case "add":
|
|
105
|
-
return addMember(
|
|
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(
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Type.
|
|
8
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
const MEMBER_TYPE_VALUES = [
|
|
22
|
+
"email",
|
|
23
|
+
"openid",
|
|
24
|
+
"userid",
|
|
25
|
+
"unionid",
|
|
26
|
+
"openchat",
|
|
27
|
+
"opendepartmentid",
|
|
28
|
+
] as const;
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}),
|
|
35
|
-
Type.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}),
|
|
43
|
-
|
|
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: {
|