@greatlhd/ailo-desktop 1.0.0

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 (73) hide show
  1. package/copy-static.mjs +11 -0
  2. package/dist/browser_control.js +767 -0
  3. package/dist/browser_snapshot.js +174 -0
  4. package/dist/cli.js +36 -0
  5. package/dist/code_executor.js +95 -0
  6. package/dist/config_server.js +658 -0
  7. package/dist/connection_util.js +14 -0
  8. package/dist/constants.js +2 -0
  9. package/dist/desktop_state_store.js +57 -0
  10. package/dist/desktop_types.js +1 -0
  11. package/dist/desktop_verifier.js +40 -0
  12. package/dist/dingtalk-handler.js +173 -0
  13. package/dist/dingtalk-types.js +1 -0
  14. package/dist/email_handler.js +501 -0
  15. package/dist/exec_tool.js +90 -0
  16. package/dist/feishu-handler.js +620 -0
  17. package/dist/feishu-types.js +8 -0
  18. package/dist/feishu-utils.js +162 -0
  19. package/dist/fs_tools.js +398 -0
  20. package/dist/index.js +433 -0
  21. package/dist/mcp/config-manager.js +64 -0
  22. package/dist/mcp/index.js +3 -0
  23. package/dist/mcp/rpc.js +109 -0
  24. package/dist/mcp/session.js +140 -0
  25. package/dist/mcp_manager.js +253 -0
  26. package/dist/mouse_keyboard.js +516 -0
  27. package/dist/qq-handler.js +153 -0
  28. package/dist/qq-types.js +15 -0
  29. package/dist/qq-ws.js +178 -0
  30. package/dist/screenshot.js +271 -0
  31. package/dist/skills_hub.js +212 -0
  32. package/dist/skills_manager.js +103 -0
  33. package/dist/static/AGENTS.md +25 -0
  34. package/dist/static/app.css +539 -0
  35. package/dist/static/app.html +292 -0
  36. package/dist/static/app.js +380 -0
  37. package/dist/static/chat.html +994 -0
  38. package/dist/time_tool.js +22 -0
  39. package/dist/utils.js +15 -0
  40. package/package.json +38 -0
  41. package/src/browser_control.ts +739 -0
  42. package/src/browser_snapshot.ts +196 -0
  43. package/src/cli.ts +44 -0
  44. package/src/code_executor.ts +101 -0
  45. package/src/config_server.ts +723 -0
  46. package/src/connection_util.ts +23 -0
  47. package/src/constants.ts +2 -0
  48. package/src/desktop_state_store.ts +64 -0
  49. package/src/desktop_types.ts +44 -0
  50. package/src/desktop_verifier.ts +45 -0
  51. package/src/dingtalk-types.ts +26 -0
  52. package/src/exec_tool.ts +93 -0
  53. package/src/feishu-handler.ts +722 -0
  54. package/src/feishu-types.ts +66 -0
  55. package/src/feishu-utils.ts +174 -0
  56. package/src/fs_tools.ts +411 -0
  57. package/src/index.ts +474 -0
  58. package/src/mcp/config-manager.ts +85 -0
  59. package/src/mcp/index.ts +7 -0
  60. package/src/mcp/rpc.ts +131 -0
  61. package/src/mcp/session.ts +182 -0
  62. package/src/mcp_manager.ts +273 -0
  63. package/src/mouse_keyboard.ts +526 -0
  64. package/src/qq-types.ts +49 -0
  65. package/src/qq-ws.ts +223 -0
  66. package/src/screenshot.ts +297 -0
  67. package/src/static/app.css +539 -0
  68. package/src/static/app.html +292 -0
  69. package/src/static/app.js +380 -0
  70. package/src/static/chat.html +994 -0
  71. package/src/time_tool.ts +24 -0
  72. package/src/utils.ts +22 -0
  73. package/tsconfig.json +13 -0
@@ -0,0 +1,620 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import * as Lark from "@larksuiteoapi/node-sdk";
5
+ import { textPart, mediaPart, } from "@greatlhd/ailo-endpoint-sdk";
6
+ import { MEDIA_MESSAGE_CONFIG, STALE_MESSAGE_THRESHOLD_MS, } from "./feishu-types.js";
7
+ import { adaptMarkdownForFeishu, convertMarkdownTablesToCodeBlock, extractImageKeysFromPostContent, extractMentionElements, extractTextFromPostContent, streamToBuffer, } from "./feishu-utils.js";
8
+ import { createChannelLogger } from "./utils.js";
9
+ export class FeishuHandler {
10
+ config;
11
+ client;
12
+ wsClient = null;
13
+ ctx = null;
14
+ botOpenId = "";
15
+ mentionNameToId = new Map();
16
+ userCache = new Map();
17
+ chatCache = new Map();
18
+ externalUserCounter = 0;
19
+ externalUserLabels = new Map();
20
+ /** 串行化外部用户持久化,避免并发 getData→parse→setData 导致互相覆盖、数据丢失 */
21
+ externalUserSaveQueue = Promise.resolve();
22
+ cacheCleanupTimer = null;
23
+ // 缓存过期时间:24小时
24
+ CACHE_TTL = 24 * 60 * 60 * 1000;
25
+ constructor(config) {
26
+ this.config = config;
27
+ this.client = new Lark.Client({
28
+ appId: config.appId,
29
+ appSecret: config.appSecret,
30
+ appType: Lark.AppType.SelfBuild,
31
+ domain: Lark.Domain.Feishu,
32
+ loggerLevel: Lark.LoggerLevel.fatal,
33
+ });
34
+ }
35
+ get storage() {
36
+ return this.ctx?.storage ?? null;
37
+ }
38
+ _log = createChannelLogger("feishu", () => this.ctx);
39
+ /** 外部用户数据单 key:{ _counter, [userId]: label } */
40
+ static EXTERNAL_USERS_KEY = "external_users";
41
+ loadExternalUserLabels() {
42
+ if (!this.storage)
43
+ return;
44
+ this.storage
45
+ .getData(FeishuHandler.EXTERNAL_USERS_KEY)
46
+ .then((val) => {
47
+ if (!val)
48
+ return;
49
+ try {
50
+ const obj = JSON.parse(val);
51
+ if (typeof obj._counter === "number")
52
+ this.externalUserCounter = obj._counter;
53
+ for (const [k, v] of Object.entries(obj)) {
54
+ if (k !== "_counter" && typeof v === "string")
55
+ this.externalUserLabels.set(k, v);
56
+ }
57
+ this._log("info", `已加载外部用户映射: ${this.externalUserLabels.size} 条, counter=${this.externalUserCounter}`);
58
+ }
59
+ catch {
60
+ this._log("warn", "解析外部用户数据失败,将从零开始");
61
+ }
62
+ })
63
+ .catch((err) => {
64
+ this._log("warn", "加载外部用户映射失败,将从零开始", { err: String(err) });
65
+ });
66
+ }
67
+ saveExternalUserLabel(userId, label) {
68
+ if (!this.storage)
69
+ return;
70
+ this.externalUserSaveQueue = this.externalUserSaveQueue
71
+ .then(() => this.storage
72
+ .getData(FeishuHandler.EXTERNAL_USERS_KEY)
73
+ .then((val) => {
74
+ const obj = val ? JSON.parse(val) : {};
75
+ obj._counter = this.externalUserCounter;
76
+ obj[userId] = label;
77
+ return this.storage.setData(FeishuHandler.EXTERNAL_USERS_KEY, JSON.stringify(obj));
78
+ }))
79
+ .catch((err) => {
80
+ this._log("warn", "保存外部用户映射失败", { err: String(err) });
81
+ });
82
+ }
83
+ async fetchBotOpenId() {
84
+ try {
85
+ const res = (await this.client.request({
86
+ method: "GET",
87
+ url: "/open-apis/bot/v3/info/",
88
+ data: {},
89
+ }));
90
+ const bot = res.data?.bot;
91
+ this.botOpenId = bot?.open_id ?? bot?.user_id ?? "";
92
+ if (this.botOpenId) {
93
+ this._log("info", `bot open_id: ${this.botOpenId}`);
94
+ }
95
+ }
96
+ catch (err) {
97
+ this._log("warn", "failed to fetch bot info", { err: String(err) });
98
+ }
99
+ }
100
+ resolveMentions(text, mentions) {
101
+ if (!mentions || mentions.length === 0) {
102
+ return { text, mentionsSelf: false };
103
+ }
104
+ let mentionsSelf = false;
105
+ let resolved = text;
106
+ for (const m of mentions) {
107
+ const openId = m.id?.open_id ?? m.id?.user_id ?? "";
108
+ const displayName = m.name || openId;
109
+ if (openId && this.botOpenId && openId === this.botOpenId) {
110
+ mentionsSelf = true;
111
+ }
112
+ if (openId) {
113
+ this.mentionNameToId.set(displayName, openId);
114
+ resolved = resolved.replaceAll(m.key, `@${displayName}(${openId})`);
115
+ }
116
+ else {
117
+ resolved = resolved.replaceAll(m.key, `@${displayName}`);
118
+ }
119
+ }
120
+ return { text: resolved, mentionsSelf };
121
+ }
122
+ acceptMessage(msg) {
123
+ if (!this.ctx)
124
+ return;
125
+ this.ctx.accept(msg).catch((err) => this._log("error", "accept failed", { err: String(err) }));
126
+ }
127
+ buildAcceptMessage(opts) {
128
+ const { chatId, text, chatType, senderId = "", senderName = "", chatName, attachments } = opts;
129
+ const isPrivate = chatType === "私聊";
130
+ const tags = [
131
+ { kind: "channel", value: "feishu", groupWith: true },
132
+ { kind: "conv_type", value: chatType, groupWith: false },
133
+ { kind: "chat_id", value: chatId, groupWith: true, passToTool: true },
134
+ ];
135
+ if (!isPrivate) {
136
+ const groupName = chatName || `群${chatId.slice(-8)}`;
137
+ tags.push({ kind: "group", value: groupName, groupWith: false });
138
+ }
139
+ if (senderName) {
140
+ tags.push({ kind: "participant", value: senderName, groupWith: false });
141
+ }
142
+ if (senderId) {
143
+ tags.push({ kind: "sender_id", value: senderId, groupWith: false, passToTool: true });
144
+ }
145
+ const content = [];
146
+ if (text)
147
+ content.push(textPart(text));
148
+ for (const a of attachments ?? []) {
149
+ const typ = (a.type ?? "file").toLowerCase();
150
+ const mediaType = ["image", "audio", "video", "pdf", "file"].includes(typ) ? typ : "file";
151
+ content.push(mediaPart(mediaType, {
152
+ type: a.type ?? "file",
153
+ path: a.path,
154
+ url: a.url,
155
+ mime: a.mime,
156
+ name: a.name,
157
+ }));
158
+ }
159
+ return { content, contextTags: tags };
160
+ }
161
+ extractFeishuErrorCode(err) {
162
+ if (Array.isArray(err)) {
163
+ for (const item of err) {
164
+ if (item && typeof item === "object" && typeof item.code === "number") {
165
+ return item.code;
166
+ }
167
+ }
168
+ }
169
+ if (err && typeof err === "object") {
170
+ if (typeof err.code === "number")
171
+ return err.code;
172
+ const respCode = err?.response?.data?.code;
173
+ if (typeof respCode === "number")
174
+ return respCode;
175
+ }
176
+ return null;
177
+ }
178
+ async cachedFetch(cache, key, fetcher, fallback, errorHandler) {
179
+ const cached = cache.get(key);
180
+ if (cached && Date.now() - cached.ts < this.CACHE_TTL)
181
+ return cached.value;
182
+ try {
183
+ const value = await fetcher();
184
+ cache.set(key, { value, ts: Date.now() });
185
+ return value;
186
+ }
187
+ catch (err) {
188
+ errorHandler?.(err);
189
+ const fb = fallback();
190
+ cache.set(key, { value: fb, ts: Date.now() });
191
+ return fb;
192
+ }
193
+ }
194
+ async getUserInfo(userId) {
195
+ return this.cachedFetch(this.userCache, userId, async () => {
196
+ const res = await this.client.contact.v3.user.get({
197
+ path: { user_id: userId },
198
+ params: { user_id_type: "open_id" },
199
+ });
200
+ const user = res.data?.user;
201
+ const resolvedName = user?.name || user?.en_name || user?.nickname || "";
202
+ if (!resolvedName) {
203
+ this._log("warn", `getUserInfo(${userId}): 所有名称字段为空,应用可能缺少 contact:user.base:readonly 权限`);
204
+ }
205
+ return { name: resolvedName, openId: user?.open_id };
206
+ }, () => {
207
+ let label = this.externalUserLabels.get(userId);
208
+ if (!label) {
209
+ this.externalUserCounter++;
210
+ label = `外部用户${this.externalUserCounter}`;
211
+ this.externalUserLabels.set(userId, label);
212
+ this.saveExternalUserLabel(userId, label);
213
+ }
214
+ return { name: label };
215
+ }, (err) => {
216
+ const errCode = this.extractFeishuErrorCode(err);
217
+ if (errCode === 41050) {
218
+ this._log("info", `getUserInfo(${userId}): 外部用户,无权限获取通讯录信息 (41050)`);
219
+ }
220
+ else {
221
+ const detail = err?.response?.data ?? err.message;
222
+ this._log("warn", `failed to get user ${userId}`, { detail });
223
+ }
224
+ });
225
+ }
226
+ async getChatInfo(chatId) {
227
+ if (!chatId)
228
+ return null;
229
+ return this.cachedFetch(this.chatCache, chatId, async () => {
230
+ const res = await this.client.im.v1.chat.get({ path: { chat_id: chatId } });
231
+ return { name: res.data?.name || chatId };
232
+ }, () => ({ name: chatId }), (err) => {
233
+ const errCode = this.extractFeishuErrorCode(err);
234
+ if (errCode === 41050) {
235
+ this._log("info", `getChatInfo(${chatId}): 无权限获取群信息 (41050)`);
236
+ }
237
+ else {
238
+ const detail = err?.response?.data ?? err.message;
239
+ this._log("warn", `failed to get chat ${chatId}`, { detail });
240
+ }
241
+ });
242
+ }
243
+ cleanExpiredCache() {
244
+ const now = Date.now();
245
+ for (const [key, entry] of this.userCache) {
246
+ if (now - entry.ts > this.CACHE_TTL) {
247
+ this.userCache.delete(key);
248
+ }
249
+ }
250
+ for (const [key, entry] of this.chatCache) {
251
+ if (now - entry.ts > this.CACHE_TTL) {
252
+ this.chatCache.delete(key);
253
+ }
254
+ }
255
+ this._log("debug", `cache cleaned: users=${this.userCache.size}, chats=${this.chatCache.size}`);
256
+ }
257
+ async saveResourceToLocal(messageId, fileKey, resourceType, ailoType, fileName) {
258
+ const workDir = path.join(os.tmpdir(), "ailo-feishu-blobs");
259
+ const now = new Date();
260
+ const cacheDir = path.join(workDir, "blobs", String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0"));
261
+ await fs.promises.mkdir(cacheDir, { recursive: true });
262
+ const sanitized = fileName.replace(/[/\\?*:|"<>]/g, "_").slice(0, 200);
263
+ const outPath = path.join(cacheDir, `${Date.now()}_${sanitized}`);
264
+ let buffer = null;
265
+ try {
266
+ const res = await this.client.im.v1.messageResource.get({
267
+ params: { type: resourceType },
268
+ path: { message_id: messageId, file_key: fileKey },
269
+ });
270
+ if (res?.getReadableStream)
271
+ buffer = await streamToBuffer(res.getReadableStream());
272
+ }
273
+ catch {
274
+ // messageResource 可能失败,图片可尝试 image.get
275
+ }
276
+ if (!buffer && resourceType === "image" && ailoType === "image") {
277
+ try {
278
+ const res = await this.client.im.v1.image.get({ path: { image_key: fileKey } });
279
+ if (res?.getReadableStream)
280
+ buffer = await streamToBuffer(res.getReadableStream());
281
+ }
282
+ catch {
283
+ // ignore
284
+ }
285
+ }
286
+ if (!buffer)
287
+ return null;
288
+ await fs.promises.writeFile(outPath, buffer);
289
+ return path.resolve(outPath);
290
+ }
291
+ async start(ctx) {
292
+ this.ctx = ctx;
293
+ this.loadExternalUserLabels();
294
+ this.fetchBotOpenId();
295
+ const sink = (level) => (...args) => {
296
+ const msg = args.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a))).join(" ");
297
+ this._log(level, msg);
298
+ };
299
+ const stderrLogger = {
300
+ error: sink("error"),
301
+ warn: sink("warn"),
302
+ info: sink("info"),
303
+ debug: sink("debug"),
304
+ trace: sink("debug"),
305
+ };
306
+ const wsClient = new Lark.WSClient({
307
+ appId: this.config.appId,
308
+ appSecret: this.config.appSecret,
309
+ domain: Lark.Domain.Feishu,
310
+ logger: stderrLogger,
311
+ loggerLevel: Lark.LoggerLevel.info,
312
+ });
313
+ this.wsClient = wsClient;
314
+ const eventDispatcher = new Lark.EventDispatcher({
315
+ verificationToken: "",
316
+ encryptKey: undefined,
317
+ });
318
+ if (this.cacheCleanupTimer)
319
+ clearInterval(this.cacheCleanupTimer);
320
+ this.cacheCleanupTimer = setInterval(() => this.cleanExpiredCache(), 60 * 60 * 1000);
321
+ const wsClientAny = wsClient;
322
+ wsClientAny.eventDispatcher = eventDispatcher;
323
+ eventDispatcher.register({
324
+ "im.message.receive_v1": async (data) => {
325
+ const event = data;
326
+ if (!event.message || !this.ctx)
327
+ return;
328
+ const msg = event.message;
329
+ const rawContent = msg.content ?? "";
330
+ const chatId = msg.chat_id ?? "";
331
+ const messageId = msg.message_id ?? "";
332
+ const chatType = msg.chat_type === "group" ? "group" : "p2p";
333
+ const senderId = event.sender?.sender_id?.open_id ?? event.sender?.sender_id?.user_id ?? "";
334
+ const messageType = msg.message_type ?? "";
335
+ let createTimeMs = msg.create_time ? parseInt(msg.create_time, 10) : NaN;
336
+ if (!isNaN(createTimeMs) && createTimeMs < 1e12)
337
+ createTimeMs *= 1000;
338
+ const timestamp = !isNaN(createTimeMs) ? createTimeMs : Date.now();
339
+ if (!isNaN(createTimeMs) && Date.now() - createTimeMs > STALE_MESSAGE_THRESHOLD_MS) {
340
+ this._log("info", `dropped stale message ${messageId}`, {
341
+ create_time: createTimeMs,
342
+ age_min: Math.round((Date.now() - createTimeMs) / 60000),
343
+ });
344
+ return;
345
+ }
346
+ this._log("debug", `received ${messageType} ${chatType} ${chatId} from ${senderId}`, {
347
+ content_len: rawContent.length,
348
+ preview: rawContent.length > 0 ? rawContent.slice(0, 200) : "",
349
+ });
350
+ const [userInfo, chatInfo] = await Promise.all([
351
+ senderId ? this.getUserInfo(senderId) : Promise.resolve(null),
352
+ chatType === "group" ? this.getChatInfo(chatId) : Promise.resolve(null),
353
+ ]);
354
+ this._log("debug", `sender=${senderId} name=${userInfo?.name ?? "(empty)"} chatType=${chatType} chatName=${chatInfo?.name ?? "(none)"}`);
355
+ if (senderId && userInfo?.name) {
356
+ this.mentionNameToId.set(userInfo.name, senderId);
357
+ }
358
+ let text = "";
359
+ const attachments = [];
360
+ if (messageType === "text") {
361
+ try {
362
+ const content = JSON.parse(rawContent || "{}");
363
+ text = content.text ?? "";
364
+ }
365
+ catch {
366
+ text = rawContent;
367
+ }
368
+ }
369
+ else if (messageType === "post") {
370
+ text = extractTextFromPostContent(rawContent);
371
+ const postImageKeys = [...new Set(extractImageKeysFromPostContent(rawContent))];
372
+ for (const imageKey of postImageKeys) {
373
+ const fileName = `image_${imageKey.slice(-12)}.png`;
374
+ const absPath = await this.saveResourceToLocal(messageId, imageKey, "image", "image", fileName);
375
+ if (absPath)
376
+ attachments.push({ type: "image", path: absPath, name: path.basename(absPath) });
377
+ }
378
+ }
379
+ else {
380
+ const mediaConfig = MEDIA_MESSAGE_CONFIG[messageType];
381
+ if (mediaConfig) {
382
+ try {
383
+ const content = JSON.parse(rawContent || "{}");
384
+ const fileKey = content[mediaConfig.contentKey];
385
+ if (fileKey) {
386
+ const fileName = content["file_name"] ??
387
+ content["fileName"] ??
388
+ `${mediaConfig.ailoType}_${fileKey.slice(-12)}.${mediaConfig.ailoType === "image" ? "png" : mediaConfig.ailoType === "video" ? "mp4" : mediaConfig.ailoType === "audio" ? "mp3" : "bin"}`;
389
+ const absPath = await this.saveResourceToLocal(messageId, fileKey, mediaConfig.resourceType, mediaConfig.ailoType, fileName);
390
+ if (absPath)
391
+ attachments.push({ type: mediaConfig.ailoType, path: absPath, name: path.basename(absPath) });
392
+ else
393
+ text = `[无法获取${mediaConfig.ailoType}资源]`;
394
+ }
395
+ else {
396
+ text = "[无法解析的媒体消息]";
397
+ }
398
+ }
399
+ catch {
400
+ text = "[无法解析的媒体消息]";
401
+ }
402
+ }
403
+ }
404
+ if (senderId && this.botOpenId && senderId === this.botOpenId) {
405
+ this._log("debug", `skipped own message ${messageId}`);
406
+ return;
407
+ }
408
+ if (!chatId) {
409
+ this._log("warn", `dropped message ${messageId}: chat_id 为空,无法路由回复(飞书事件可能异常)`);
410
+ return;
411
+ }
412
+ const mentions = msg.mentions;
413
+ const { text: resolvedText, mentionsSelf } = this.resolveMentions(text, mentions);
414
+ text = resolvedText;
415
+ if (msg.parent_id) {
416
+ text = `[回复消息 ${msg.parent_id}] ${text}`;
417
+ }
418
+ if (!text.trim() && attachments.length === 0) {
419
+ text = messageType ? `[${messageType} 类型消息,暂不支持解析]` : "[未知类型消息]";
420
+ }
421
+ const isP2p = chatType === "p2p";
422
+ this.acceptMessage(this.buildAcceptMessage({
423
+ chatId,
424
+ text,
425
+ chatType: isP2p ? "私聊" : "群聊",
426
+ senderId,
427
+ senderName: userInfo?.name || "获取昵称失败",
428
+ chatName: chatInfo?.name,
429
+ mentionsSelf,
430
+ attachments,
431
+ timestamp,
432
+ }));
433
+ },
434
+ });
435
+ await wsClientAny.reConnect(true);
436
+ this.ctx?.reportHealth("connected");
437
+ }
438
+ inferFileType(fileName, mime) {
439
+ const ext = path.extname(fileName || "").toLowerCase().slice(1);
440
+ const m = (mime ?? "").toLowerCase();
441
+ if (["mp4", "mov", "avi", "mkv", "webm", "m4v"].includes(ext) || m.includes("video"))
442
+ return "mp4";
443
+ if (["opus", "mp3", "wav", "m4a", "aac", "ogg", "flac"].includes(ext) || m.includes("audio"))
444
+ return "opus";
445
+ if (ext === "pdf" || m.includes("pdf"))
446
+ return "pdf";
447
+ if (["doc", "docx"].includes(ext) || m.includes("msword") || m.includes("document"))
448
+ return "doc";
449
+ if (["xls", "xlsx"].includes(ext) || m.includes("spreadsheet") || m.includes("excel"))
450
+ return "xls";
451
+ if (["ppt", "pptx"].includes(ext) || m.includes("presentation") || m.includes("powerpoint"))
452
+ return "ppt";
453
+ return "stream";
454
+ }
455
+ async uploadFileToFeishu(opts) {
456
+ if (!fs.existsSync(opts.filePath)) {
457
+ throw new Error(`文件不存在: ${opts.filePath}`);
458
+ }
459
+ const fileData = fs.readFileSync(opts.filePath);
460
+ const fileType = opts.fileType ?? this.inferFileType(opts.fileName, opts.mime);
461
+ let res;
462
+ try {
463
+ res = (await this.client.im.file.create({
464
+ data: {
465
+ file_type: fileType,
466
+ file_name: opts.fileName,
467
+ file: fileData,
468
+ ...(opts.duration != null && opts.duration > 0 ? { duration: opts.duration } : {}),
469
+ },
470
+ }));
471
+ }
472
+ catch (e) {
473
+ const err = e;
474
+ const detail = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
475
+ throw new Error(`飞书文件上传失败 (${err?.response?.status ?? "unknown"}): ${detail}`);
476
+ }
477
+ if (res?.file_key)
478
+ return res.file_key;
479
+ throw new Error(`飞书文件上传失败(无 file_key): ${JSON.stringify(res)}`);
480
+ }
481
+ async uploadImageToFeishu(opts) {
482
+ if (!fs.existsSync(opts.filePath)) {
483
+ throw new Error(`图片文件不存在: ${opts.filePath}`);
484
+ }
485
+ const imageData = fs.readFileSync(opts.filePath);
486
+ let res;
487
+ try {
488
+ res = (await this.client.im.image.create({
489
+ data: {
490
+ image_type: "message",
491
+ image: imageData,
492
+ },
493
+ }));
494
+ }
495
+ catch (e) {
496
+ const err = e;
497
+ const respData = err?.response?.data;
498
+ const detail = respData ? JSON.stringify(respData) : err?.message;
499
+ throw new Error(`飞书图片上传失败 (${err?.response?.status ?? "unknown"}): ${detail}`);
500
+ }
501
+ const imageKey = res?.image_key;
502
+ if (imageKey) {
503
+ return imageKey;
504
+ }
505
+ throw new Error(`飞书图片上传失败(无 image_key): ${JSON.stringify(res)}`);
506
+ }
507
+ async sendText(chatId, text, attachments) {
508
+ const trimmed = (text ?? "").trim();
509
+ const allAttachments = attachments ?? [];
510
+ const imageAttachments = allAttachments.filter((a) => (a.type ?? "").toLowerCase() === "image");
511
+ const fileAttachments = allAttachments.filter((a) => {
512
+ const t = (a.type ?? "").toLowerCase();
513
+ if (t === "image")
514
+ return false;
515
+ if (["file", "audio", "video"].includes(t))
516
+ return true;
517
+ return !!a.file_path;
518
+ });
519
+ if (!trimmed && imageAttachments.length === 0 && fileAttachments.length === 0) {
520
+ return;
521
+ }
522
+ const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
523
+ const imageKeys = [];
524
+ for (const att of imageAttachments) {
525
+ if (att.file_path) {
526
+ const key = await this.uploadImageToFeishu({ filePath: att.file_path });
527
+ imageKeys.push(key);
528
+ }
529
+ }
530
+ const contentRows = [];
531
+ if (trimmed) {
532
+ const { cleanText, atElements } = extractMentionElements(trimmed, this.mentionNameToId);
533
+ const adapted = adaptMarkdownForFeishu(cleanText);
534
+ const processed = convertMarkdownTablesToCodeBlock(adapted);
535
+ const paragraphs = processed.split(/\n{2,}/).filter((p) => p.trim());
536
+ if (paragraphs.length === 0) {
537
+ contentRows.push([{ tag: "md", text: processed }]);
538
+ }
539
+ else {
540
+ const firstRow = [];
541
+ for (const at of atElements) {
542
+ firstRow.push({ tag: "at", user_id: at.userId });
543
+ firstRow.push({ tag: "text", text: " " });
544
+ }
545
+ firstRow.push({ tag: "md", text: paragraphs[0].trim() });
546
+ contentRows.push(firstRow);
547
+ for (let i = 1; i < paragraphs.length; i++) {
548
+ contentRows.push([{ tag: "md", text: paragraphs[i].trim() }]);
549
+ }
550
+ }
551
+ }
552
+ for (const key of imageKeys) {
553
+ contentRows.push([{ tag: "img", image_key: key }]);
554
+ }
555
+ if (contentRows.length > 0) {
556
+ await this.client.im.v1.message.create({
557
+ params: { receive_id_type: receiveIdType },
558
+ data: {
559
+ receive_id: chatId,
560
+ msg_type: "post",
561
+ content: JSON.stringify({
562
+ zh_cn: {
563
+ content: contentRows,
564
+ },
565
+ }),
566
+ },
567
+ });
568
+ }
569
+ for (const att of fileAttachments) {
570
+ if (!att.file_path)
571
+ continue;
572
+ const fileName = att.name?.trim() || path.basename(att.file_path);
573
+ const fileKey = await this.uploadFileToFeishu({
574
+ filePath: att.file_path,
575
+ fileName,
576
+ mime: att.mime,
577
+ duration: att.duration,
578
+ });
579
+ const msgType = (att.type ?? "file").toLowerCase();
580
+ let content;
581
+ if (msgType === "audio") {
582
+ content = JSON.stringify({ file_key: fileKey, duration: att.duration ?? 0 });
583
+ }
584
+ else if (msgType === "video") {
585
+ content = JSON.stringify({
586
+ file_key: fileKey,
587
+ file_name: fileName,
588
+ duration: att.duration ?? 0,
589
+ });
590
+ }
591
+ else {
592
+ content = JSON.stringify({ file_key: fileKey, file_name: fileName });
593
+ }
594
+ await this.client.im.v1.message.create({
595
+ params: { receive_id_type: receiveIdType },
596
+ data: {
597
+ receive_id: chatId,
598
+ msg_type: msgType === "video" ? "media" : msgType,
599
+ content,
600
+ },
601
+ });
602
+ }
603
+ }
604
+ async stop() {
605
+ if (this.cacheCleanupTimer) {
606
+ clearInterval(this.cacheCleanupTimer);
607
+ this.cacheCleanupTimer = null;
608
+ }
609
+ if (this.wsClient) {
610
+ try {
611
+ const wsAny = this.wsClient;
612
+ if (wsAny.ws?.close)
613
+ wsAny.ws.close();
614
+ }
615
+ catch { /* best-effort */ }
616
+ this.wsClient = null;
617
+ }
618
+ this.ctx = null;
619
+ }
620
+ }
@@ -0,0 +1,8 @@
1
+ export const STALE_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000;
2
+ export const MEDIA_MESSAGE_CONFIG = {
3
+ image: { resourceType: "image", ailoType: "image", contentKey: "image_key" },
4
+ file: { resourceType: "file", ailoType: "file", contentKey: "file_key" },
5
+ audio: { resourceType: "audio", ailoType: "audio", contentKey: "file_key" },
6
+ media: { resourceType: "video", ailoType: "video", contentKey: "file_key" },
7
+ video: { resourceType: "video", ailoType: "video", contentKey: "file_key" },
8
+ };