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