@ian2018cs/agenthub 0.1.30 → 0.1.32

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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * server/services/feishu/index.js — 飞书服务入口
3
+ *
4
+ * 读取环境变量,初始化 LarkClient + FeishuEngine,
5
+ * 对外暴露 startFeishuService() / stopFeishuService()。
6
+ *
7
+ * 必需环境变量:
8
+ * FEISHU_APP_ID
9
+ * FEISHU_APP_SECRET
10
+ *
11
+ * 可选(语音转文字):
12
+ * WHISPER_API_KEY WHISPER_BASE_URL WHISPER_MODEL WHISPER_LANGUAGE
13
+ */
14
+
15
+ import { LarkClient } from './lark-client.js';
16
+ import { FeishuEngine } from './feishu-engine.js';
17
+ import { checkFfmpeg } from './speech.js';
18
+
19
+ let larkClientInstance = null;
20
+
21
+ /**
22
+ * 启动飞书服务
23
+ * 由 server/index.js 在服务器启动时调用(仅当 FEISHU_APP_ID 和 FEISHU_APP_SECRET 已配置)
24
+ */
25
+ async function startFeishuService() {
26
+ const appId = process.env.FEISHU_APP_ID;
27
+ const appSecret = process.env.FEISHU_APP_SECRET;
28
+
29
+ if (!appId || !appSecret) {
30
+ console.log('[Feishu] Skipped: FEISHU_APP_ID or FEISHU_APP_SECRET not set');
31
+ return null;
32
+ }
33
+
34
+ // 检查可选依赖
35
+ const hasFfmpeg = await checkFfmpeg();
36
+ if (!hasFfmpeg) {
37
+ console.warn('[Feishu] ffmpeg not found — voice messages will not be transcribed');
38
+ }
39
+
40
+ const hasWhisper = !!(process.env.WHISPER_API_KEY || process.env.OPENAI_API_KEY);
41
+ if (!hasWhisper) {
42
+ console.warn('[Feishu] WHISPER_API_KEY not set — voice messages will not be transcribed');
43
+ }
44
+
45
+ try {
46
+ // Engine 先创建(LarkClient 需要引用它)
47
+ // LarkClient 构造时需要 engine,engine 构造时需要 larkClient
48
+ // 用延迟注入:先 new engine(null),再注入 larkClient
49
+ const engine = new FeishuEngine(null);
50
+ const client = new LarkClient(appId, appSecret, engine);
51
+ engine.larkClient = client;
52
+ engine.commandHandler.larkClient = client;
53
+
54
+ await client.start();
55
+ larkClientInstance = client;
56
+
57
+ console.log('[Feishu] Service started successfully');
58
+ return client;
59
+ } catch (err) {
60
+ console.error('[Feishu] Failed to start service:', err.message);
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 停止飞书服务(用于优雅关闭)
67
+ */
68
+ function stopFeishuService() {
69
+ if (larkClientInstance) {
70
+ larkClientInstance.stop();
71
+ larkClientInstance = null;
72
+ console.log('[Feishu] Service stopped');
73
+ }
74
+ }
75
+
76
+ export { startFeishuService, stopFeishuService };
@@ -0,0 +1,398 @@
1
+ /**
2
+ * lark-client.js — 飞书 Lark SDK 封装
3
+ *
4
+ * 负责:
5
+ * - WebSocket 长连接(无需公网 IP)
6
+ * - 接收 im.message.receive_v1 / card.action.trigger / application.bot.menu_v6 事件
7
+ * - 下载图片 / 音频资源
8
+ * - 发送 / 回复 / 更新 消息(文本 + 卡片模板)
9
+ *
10
+ * 仅处理单聊(chat_type === 'p2p'),群聊消息直接忽略。
11
+ */
12
+
13
+ import * as lark from '@larksuiteoapi/node-sdk';
14
+ import path from 'path';
15
+ import { buildTextOrMarkdownMessage } from './card-builder.js';
16
+
17
+ /** 检测图片 Buffer 的 MIME 类型(通过魔数) */
18
+ function detectMimeType(buf) {
19
+ if (buf.length < 4) return 'image/jpeg';
20
+ if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png';
21
+ if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg';
22
+ if (buf.slice(0, 4).toString('ascii') === 'GIF8') return 'image/gif';
23
+ if (buf.slice(0, 4).toString('ascii') === 'RIFF' && buf.slice(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
24
+ return 'image/jpeg';
25
+ }
26
+
27
+ /** 将 ReadableStream 读取为 Buffer */
28
+ async function streamToBuffer(stream) {
29
+ const chunks = [];
30
+ for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
31
+ return Buffer.concat(chunks);
32
+ }
33
+
34
+ export class LarkClient {
35
+ constructor(appId, appSecret, engine) {
36
+ this.appId = appId;
37
+ this.appSecret = appSecret;
38
+ this.engine = engine;
39
+ this.wsClient = null;
40
+
41
+ // REST 客户端(用于发消息、下载资源)
42
+ this.client = new lark.Client({ appId, appSecret, disableTokenCache: false });
43
+ }
44
+
45
+ async start() {
46
+ this.wsClient = new lark.WSClient({ appId: this.appId, appSecret: this.appSecret });
47
+
48
+ const dispatcher = new lark.EventDispatcher({}).register({
49
+ // 接收消息事件
50
+ 'im.message.receive_v1': async (data) => {
51
+ try {
52
+ await this._onMessage(data);
53
+ } catch (err) {
54
+ console.error('[Feishu:lark-client] onMessage error:', err.message);
55
+ }
56
+ },
57
+ // 卡片交互回调
58
+ 'card.action.trigger': async (data) => {
59
+ try {
60
+ return await this._onCardAction(data);
61
+ } catch (err) {
62
+ console.error('[Feishu:lark-client] onCardAction error:', err.message);
63
+ }
64
+ },
65
+ // 机器人菜单点击回调
66
+ 'application.bot.menu_v6': async (data) => {
67
+ try {
68
+ await this._onMenuAction(data);
69
+ } catch (err) {
70
+ console.error('[Feishu:lark-client] onMenuAction error:', err.message);
71
+ }
72
+ },
73
+ });
74
+
75
+ this.wsClient.start({ eventDispatcher: dispatcher });
76
+ console.log('[Feishu] Lark WebSocket client started');
77
+ }
78
+
79
+ stop() {
80
+ try { this.wsClient?.close?.(); } catch (_) {}
81
+ }
82
+
83
+ // ─── 内部事件处理 ────────────────────────────────────────────────────────────
84
+
85
+ async _onMessage(data) {
86
+ const { message, sender } = data;
87
+ if (!message || !sender) return;
88
+
89
+ const chatType = message.chat_type;
90
+ const feishuOpenId = sender?.sender_id?.open_id;
91
+
92
+ // 只处理单聊
93
+ if (chatType !== 'p2p') return;
94
+ if (!feishuOpenId) return;
95
+
96
+ const chatId = message.chat_id;
97
+ const messageId = message.message_id;
98
+ const msgType = message.message_type;
99
+
100
+ let content = '';
101
+ let images = null;
102
+ let audio = null;
103
+ let files = null;
104
+
105
+ switch (msgType) {
106
+ case 'text': {
107
+ const body = JSON.parse(message.content || '{}');
108
+ content = (body.text || '').trim();
109
+ break;
110
+ }
111
+ case 'image': {
112
+ const body = JSON.parse(message.content || '{}');
113
+ const img = await this._downloadImage(messageId, body.image_key).catch(err => {
114
+ console.error('[Feishu] Failed to download image:', err.message);
115
+ return null;
116
+ });
117
+ if (img) images = [img];
118
+ break;
119
+ }
120
+ case 'audio': {
121
+ const body = JSON.parse(message.content || '{}');
122
+ const buf = await this._downloadResource(messageId, body.file_key, 'file').catch(err => {
123
+ console.error('[Feishu] Failed to download audio:', err.message);
124
+ return null;
125
+ });
126
+ if (buf) audio = { data: buf, format: 'ogg', mimeType: 'audio/ogg' };
127
+ break;
128
+ }
129
+ case 'file': {
130
+ const body = JSON.parse(message.content || '{}');
131
+ const buf = await this._downloadResource(messageId, body.file_key, 'file').catch(err => {
132
+ console.error('[Feishu] Failed to download file:', err.message);
133
+ return null;
134
+ });
135
+ if (buf) files = [{ data: buf, filename: body.file_name || 'attachment', fileKey: body.file_key }];
136
+ break;
137
+ }
138
+ case 'media': {
139
+ const body = JSON.parse(message.content || '{}');
140
+ const buf = await this._downloadResource(messageId, body.file_key, 'file').catch(err => {
141
+ console.error('[Feishu] Failed to download media:', err.message);
142
+ return null;
143
+ });
144
+ if (buf) files = [{ data: buf, filename: body.file_name || 'media', fileKey: body.file_key }];
145
+ break;
146
+ }
147
+ default:
148
+ console.log(`[Feishu] Unsupported message type: ${msgType}, ignoring`);
149
+ return;
150
+ }
151
+
152
+ // 引用消息处理:提取父消息中的文件元数据(不重复下载已保存的文件)
153
+ let quotedFiles = null;
154
+ if (message.parent_id) {
155
+ quotedFiles = await this._getQuotedFilesMeta(message.parent_id).catch(err => {
156
+ console.error('[Feishu] Failed to fetch quoted message meta:', err.message);
157
+ return null;
158
+ });
159
+ }
160
+
161
+ await this.engine.handleMessage({ feishuOpenId, chatId, messageId, content, images, audio, files, quotedFiles });
162
+ }
163
+
164
+ async _onCardAction(data) {
165
+ // card.action.trigger v2 事件:open_id 直接在 operator 下
166
+ const openId = data?.operator?.open_id;
167
+ const action = data?.action?.value || {};
168
+ // host 是字符串(如 "im_message"),消息 ID 在 context.open_message_id
169
+ const messageId = data?.context?.open_message_id;
170
+ const formValue = data?.action?.form_value || {};
171
+
172
+ if (!openId) {
173
+ console.warn('[Feishu:lark-client] _onCardAction: missing open_id, raw data.operator =', JSON.stringify(data?.operator));
174
+ return {};
175
+ }
176
+ // 返回值会被飞书 WS SDK 作为同步响应发回,包含 card 字段时飞书直接更新卡片,
177
+ // 避免不返回导致飞书把卡片恢复到交互前的状态
178
+ return await this.engine.handleCardAction({ feishuOpenId: openId, action, messageId, formValue });
179
+ }
180
+
181
+ async _onMenuAction(data) {
182
+ // application.bot.menu_v6:SDK 传入的 data 已是 event body,无需再访问 .event
183
+ const openId = data?.operator?.operator_id?.open_id;
184
+ const eventKey = data?.event_key;
185
+
186
+ if (!openId || !eventKey) {
187
+ console.warn('[Feishu:lark-client] _onMenuAction: missing open_id or event_key, raw data =', JSON.stringify(data));
188
+ return;
189
+ }
190
+
191
+ console.log(`[Feishu:lark-client] Menu click: openId=${openId} eventKey=${eventKey}`);
192
+ await this.engine.handleMenuAction({ feishuOpenId: openId, eventKey });
193
+ }
194
+
195
+ // ─── 资源下载 ─────────────────────────────────────────────────────────────────
196
+
197
+ async _downloadImage(messageId, imageKey) {
198
+ const resp = await this.client.im.messageResource.get({
199
+ path: { message_id: messageId, file_key: imageKey },
200
+ params: { type: 'image' },
201
+ });
202
+ const buf = await streamToBuffer(resp.getReadableStream());
203
+ return { data: buf, mimeType: detectMimeType(buf) };
204
+ }
205
+
206
+ async _downloadResource(messageId, fileKey, type = 'file') {
207
+ const resp = await this.client.im.messageResource.get({
208
+ path: { message_id: messageId, file_key: fileKey },
209
+ params: { type },
210
+ });
211
+ return streamToBuffer(resp.getReadableStream());
212
+ }
213
+
214
+ // ─── 消息发送 API ─────────────────────────────────────────────────────────────
215
+
216
+ /** 发送消息到指定会话(chat_id) */
217
+ async sendMessage(chatId, content, msgType) {
218
+ await this.client.im.message.create({
219
+ params: { receive_id_type: 'chat_id' },
220
+ data: { receive_id: chatId, msg_type: msgType, content },
221
+ });
222
+ }
223
+
224
+ /** 回复某条消息(线程化) */
225
+ async replyMessage(messageId, content, msgType) {
226
+ await this.client.im.message.reply({
227
+ path: { message_id: messageId },
228
+ data: { msg_type: msgType, content },
229
+ });
230
+ }
231
+
232
+ /**
233
+ * 发送普通文本(自动处理 Markdown 格式)
234
+ */
235
+ async sendText(chatId, text) {
236
+ const { msgType, content } = buildTextOrMarkdownMessage(text);
237
+ return this.sendMessage(chatId, content, msgType);
238
+ }
239
+
240
+ async replyText(messageId, text) {
241
+ const { msgType, content } = buildTextOrMarkdownMessage(text);
242
+ return this.replyMessage(messageId, content, msgType);
243
+ }
244
+
245
+ /**
246
+ * 通过 open_id 直接发送消息给用户(菜单事件等无 chat_id 的场景)
247
+ */
248
+ async sendMessageToUser(openId, content, msgType) {
249
+ await this.client.im.message.create({
250
+ params: { receive_id_type: 'open_id' },
251
+ data: { receive_id: openId, msg_type: msgType, content },
252
+ });
253
+ }
254
+
255
+ async sendTextToUser(openId, text) {
256
+ const { msgType, content } = buildTextOrMarkdownMessage(text);
257
+ return this.sendMessageToUser(openId, content, msgType);
258
+ }
259
+
260
+ /**
261
+ * 更新已发送的卡片消息
262
+ * @param {string} messageId 需要更新的消息 ID
263
+ * @param {string} cardContent 新的卡片内容 JSON 字符串
264
+ */
265
+ async updateCard(messageId, cardContent) {
266
+ await this.client.im.message.patch({
267
+ path: { message_id: messageId },
268
+ data: { content: cardContent },
269
+ });
270
+ }
271
+
272
+ /** 发送直接 JSON 交互卡片并返回消息 ID(用于 AskUserQuestion 等动态卡片) */
273
+ async sendInteractiveAndGetMsgId(chatId, cardContent) {
274
+ const resp = await this.client.im.message.create({
275
+ params: { receive_id_type: 'chat_id' },
276
+ data: { receive_id: chatId, msg_type: 'interactive', content: cardContent },
277
+ });
278
+ return resp?.data?.message_id || null;
279
+ }
280
+
281
+ /**
282
+ * 给消息添加表情回复
283
+ * @param {string} messageId 消息 ID
284
+ * @param {string} emojiType 表情类型,如 'OneSecond'
285
+ * @returns {Promise<string|null>} reaction_id,失败时返回 null
286
+ */
287
+ async addReaction(messageId, emojiType = 'OneSecond') {
288
+ const resp = await this.client.im.messageReaction.create({
289
+ path: { message_id: messageId },
290
+ data: { reaction_type: { emoji_type: emojiType } },
291
+ });
292
+ return resp?.data?.reaction_id || null;
293
+ }
294
+
295
+ /**
296
+ * 删除消息表情回复
297
+ * @param {string} messageId 消息 ID
298
+ * @param {string} reactionId reaction_id(addReaction 返回的)
299
+ */
300
+ async deleteReaction(messageId, reactionId) {
301
+ await this.client.im.messageReaction.delete({
302
+ path: { message_id: messageId, reaction_id: reactionId },
303
+ });
304
+ }
305
+
306
+ // ─── 文件 / 图片发送 API ───────────────────────────────────────────────────
307
+
308
+ /**
309
+ * 上传图片 Buffer 到飞书,返回 image_key
310
+ * @param {Buffer} imageBuffer
311
+ * @returns {Promise<string>} image_key
312
+ */
313
+ async uploadImage(imageBuffer) {
314
+ const resp = await this.client.im.image.create({
315
+ data: { image_type: 'message', image: imageBuffer },
316
+ });
317
+ const imageKey = resp?.image_key;
318
+ if (!imageKey) throw new Error(`Upload image failed: code=${resp?.code}, msg=${resp?.msg}`);
319
+ return imageKey;
320
+ }
321
+
322
+ /**
323
+ * 上传文件 Buffer 到飞书,返回 file_key
324
+ * @param {Buffer} fileBuffer
325
+ * @param {string} filename 带扩展名的文件名
326
+ * @returns {Promise<string>} file_key
327
+ */
328
+ async uploadFile(fileBuffer, filename) {
329
+ const ext = path.extname(filename).toLowerCase().slice(1);
330
+ const FILE_TYPE_MAP = {
331
+ pdf: 'pdf', doc: 'doc', docx: 'doc',
332
+ xls: 'xls', xlsx: 'xls',
333
+ ppt: 'ppt', pptx: 'ppt',
334
+ mp4: 'mp4', opus: 'opus',
335
+ };
336
+ const fileType = FILE_TYPE_MAP[ext] || 'stream';
337
+ const resp = await this.client.im.file.create({
338
+ data: { file_type: fileType, file_name: filename, file: fileBuffer },
339
+ });
340
+ const fileKey = resp?.file_key;
341
+ if (!fileKey) throw new Error(`Upload file failed: code=${resp?.code}, msg=${resp?.msg}`);
342
+ return fileKey;
343
+ }
344
+
345
+ /**
346
+ * 发送图片消息(Buffer → 上传 → 发送)
347
+ * @param {string} chatId
348
+ * @param {Buffer} imageBuffer
349
+ */
350
+ async sendImageBuffer(chatId, imageBuffer) {
351
+ const imageKey = await this.uploadImage(imageBuffer);
352
+ await this.sendMessage(chatId, JSON.stringify({ image_key: imageKey }), 'image');
353
+ }
354
+
355
+ /**
356
+ * 发送文件消息(Buffer → 上传 → 发送)
357
+ * @param {string} chatId
358
+ * @param {Buffer} fileBuffer
359
+ * @param {string} filename
360
+ */
361
+ async sendFileBuffer(chatId, fileBuffer, filename) {
362
+ const fileKey = await this.uploadFile(fileBuffer, filename);
363
+ await this.sendMessage(chatId, JSON.stringify({ file_key: fileKey }), 'file');
364
+ }
365
+
366
+ /**
367
+ * 获取引用(父)消息中的附件元数据,不下载内容
368
+ * 支持 file、media、image、audio 类型;其他类型返回 null
369
+ * 返回数组,每项包含 type 字段用于 engine 区分处理方式
370
+ */
371
+ async _getQuotedFilesMeta(parentId) {
372
+ const resp = await this.client.im.message.get({ path: { message_id: parentId } });
373
+ // SDK 返回结构:resp.data.items[0] 或 resp.data.message(兼容两种格式)
374
+ const item = resp?.data?.items?.[0] ?? resp?.data?.message;
375
+ if (!item) return null;
376
+
377
+ // im.message.get 返回 msg_type(非事件的 message_type),content 在 body.content 下
378
+ const msgType = item.msg_type;
379
+ const body = JSON.parse(item.body?.content || '{}');
380
+ switch (msgType) {
381
+ case 'file':
382
+ return [{ type: 'file', messageId: item.message_id, fileKey: body.file_key, filename: body.file_name || 'attachment' }];
383
+ case 'media':
384
+ return [{ type: 'file', messageId: item.message_id, fileKey: body.file_key, filename: body.file_name || 'media' }];
385
+ case 'image':
386
+ return [{ type: 'image', messageId: item.message_id, fileKey: body.image_key }];
387
+ case 'audio':
388
+ return [{ type: 'audio', messageId: item.message_id, fileKey: body.file_key }];
389
+ default:
390
+ return null;
391
+ }
392
+ }
393
+
394
+ /** 供 engine 调用:下载飞书资源原始数据(Buffer),type 默认 'file',图片传 'image' */
395
+ async downloadFile(messageId, fileKey, type = 'file') {
396
+ return this._downloadResource(messageId, fileKey, type);
397
+ }
398
+ }