@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.
- 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 +569 -59
- package/src/channel.ts +2 -0
- package/src/chat-tools/actions.ts +433 -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 +66 -0
- package/src/client.ts +13 -0
- package/src/config-schema.ts +6 -0
- package/src/drive-tools/actions.ts +57 -21
- package/src/drive-tools/schemas.ts +2 -2
- package/src/media-duration.ts +190 -0
- package/src/media.ts +60 -5
- package/src/mention.ts +1 -1
- package/src/monitor.ts +32 -8
- 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/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
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
throw new Error(`File not found: ${fileToken}`);
|
|
59
|
-
}
|
|
95
|
+
pageToken = res.data?.next_page_token;
|
|
96
|
+
} while (pageToken);
|
|
60
97
|
|
|
61
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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({
|
|
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
|
-
|
|
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
|
|
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/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: {
|