@ian2018cs/agenthub 0.1.30 → 0.1.31

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,884 @@
1
+ /**
2
+ * feishu-engine.js — 消息路由引擎
3
+ *
4
+ * 职责:
5
+ * - 接收来自 LarkClient 的消息事件和卡片交互事件
6
+ * - 按优先级路由:音频转写 → 斜杠命令 → 卡片回调 → 权限文本响应 → 普通对话
7
+ * - 维护 pendingApprovals(工具审批挂起状态)
8
+ * - 维护 processingLocks(防止单用户并发)
9
+ */
10
+
11
+ import { feishuDb } from './feishu-db.js';
12
+ import { transcribeAudio } from './speech.js';
13
+ import { CommandHandler } from './command-handler.js';
14
+ import { runQuery } from './sdk-bridge.js';
15
+ import { resolveToolApproval } from '../../claude-sdk.js';
16
+ import {
17
+ buildToolApprovalResultCardDirect,
18
+ buildModeSelectCardDirect,
19
+ buildStatusCardDirect,
20
+ buildListCardDirect,
21
+ buildDeleteResultCard,
22
+ buildAskUserQuestionResultCard,
23
+ buildCreateProjectResultCard,
24
+ stripProjectPrefix,
25
+ } from './card-builder.js';
26
+ import { getProjects, addProjectManually, deleteProject, deleteSession } from '../../projects.js';
27
+ import { getUserPaths } from '../../services/user-directories.js';
28
+ import { promises as fs } from 'fs';
29
+ import path from 'path';
30
+ import { spawn } from 'child_process';
31
+
32
+ /**
33
+ * 飞书机器人菜单 event_key → 斜杠命令映射
34
+ *
35
+ * 通过环境变量 FEISHU_MENU_KEY_MAP(JSON 字符串)配置,示例:
36
+ * FEISHU_MENU_KEY_MAP='{"key_help":"/help","key_new":"/new","key_project":"/project","key_mode":"/mode","key_list":"/list","key_stop":"/stop"}'
37
+ */
38
+ const MENU_KEY_MAP = (() => {
39
+ try {
40
+ console.log('[Feishu:engine] Loaded MENU_KEY_MAP:', process.env.FEISHU_MENU_KEY_MAP);
41
+ return JSON.parse(process.env.FEISHU_MENU_KEY_MAP || '{}');
42
+ } catch {
43
+ return {};
44
+ }
45
+ })();
46
+
47
+ // 文本权限响应关键词(不区分大小写)
48
+ const ALLOW_KEYWORDS = new Set(['允许', '同意', '可以', '好', 'y', 'yes', 'allow', 'ok']);
49
+ const DENY_KEYWORDS = new Set(['拒绝', '不允许', '否', '不', 'n', 'no', 'deny', 'reject']);
50
+ const ALLOW_ALL_KEYWORDS = new Set(['允许所有', '全部允许', 'allow all', 'allow_all', 'allowall']);
51
+
52
+ /**
53
+ * 将卡片 JSON 字符串转为飞书 WS 回调同步响应格式
54
+ */
55
+ function toCardResponse(cardStr) {
56
+ return { card: { type: 'raw', data: JSON.parse(cardStr) } };
57
+ }
58
+
59
+ export class FeishuEngine {
60
+ /**
61
+ * @param {Object} larkClient LarkClient 实例(由 index.js 注入)
62
+ */
63
+ constructor(larkClient) {
64
+ this.larkClient = larkClient;
65
+
66
+ // Map<feishuOpenId, { requestId, cardMessageId, toolName }>
67
+ this.pendingApprovals = new Map();
68
+
69
+ // Map<feishuOpenId, true> — 防并发处理锁
70
+ this.processingLocks = new Map();
71
+
72
+ // Map<feishuOpenId, Array<{chatId, messageId, content, images}>> — 消息队列
73
+ this.messageQueues = new Map();
74
+
75
+ this.commandHandler = new CommandHandler(larkClient);
76
+ }
77
+
78
+ // ─── 主入口(普通消息) ───────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * @param {Object} params
82
+ * @param {string} params.feishuOpenId
83
+ * @param {string} params.chatId
84
+ * @param {string} params.messageId
85
+ * @param {string} params.content
86
+ * @param {Array|null} params.images [{data: Buffer, mimeType: string}]
87
+ * @param {Object|null} params.audio {data: Buffer, format: string, mimeType: string}
88
+ * @param {Array|null} params.files [{data: Buffer, filename: string, fileKey: string}]
89
+ * @param {Array|null} params.quotedFiles [{messageId: string, fileKey: string, filename: string}]
90
+ */
91
+ async handleMessage({ feishuOpenId, chatId, messageId, content, images, audio, files, quotedFiles }) {
92
+ // 0. 消息去重(飞书 at-least-once 投递,同一 messageId 可能多次到达;持久化到 DB 防重启后重复)
93
+ if (messageId && feishuDb.isProcessed(messageId)) {
94
+ console.log(`[Feishu:engine] Duplicate messageId ${messageId}, skipping`);
95
+ return;
96
+ }
97
+ if (messageId) {
98
+ feishuDb.markProcessed(messageId);
99
+ }
100
+
101
+ // 每次收到消息时更新 chat_id,供菜单点击等无消息 ID 的场景使用
102
+ if (chatId) {
103
+ feishuDb.updateSessionState(feishuOpenId, { chat_id: chatId });
104
+ }
105
+
106
+ // 1. 检查绑定(/auth 命令本身不需要绑定)
107
+ const rawText = content?.trim() || '';
108
+ const isAuthCmd = rawText.toLowerCase().startsWith('/auth');
109
+
110
+ if (!isAuthCmd) {
111
+ const binding = feishuDb.getBinding(feishuOpenId);
112
+ if (!binding) {
113
+ await this.larkClient.replyText(messageId,
114
+ '👋 你好!请先完成账号绑定:\n\n1. 登录 Claude Code UI 网页\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
115
+ );
116
+ return;
117
+ }
118
+ }
119
+
120
+ // 添加"处理中"表情回复
121
+ let reactionId = null;
122
+ if (messageId) {
123
+ reactionId = await this.larkClient.addReaction(messageId, 'OneSecond').catch(err => {
124
+ console.warn('[Feishu:engine] addReaction failed:', err.message);
125
+ return null;
126
+ });
127
+ }
128
+
129
+ try {
130
+ // 2. 音频转文字
131
+ if (audio) {
132
+ await this.larkClient.replyText(messageId, '🎙️ 正在转写语音…');
133
+ let transcribed;
134
+ try {
135
+ transcribed = await transcribeAudio(audio.data, audio.format);
136
+ } catch (err) {
137
+ console.error('[Feishu:engine] transcribeAudio error:', err.message);
138
+ await this.larkClient.sendText(chatId, '❌ 语音转写失败,请检查 Whisper API 配置或重新发送。');
139
+ return;
140
+ }
141
+ if (!transcribed) {
142
+ await this.larkClient.sendText(chatId, '🔇 未识别到语音内容,请重新发送语音或改用文字输入。');
143
+ return;
144
+ }
145
+ await this.larkClient.sendText(chatId, `📝 已转写:「${transcribed}」`);
146
+ // 以转写文字继续处理
147
+ content = transcribed;
148
+ }
149
+
150
+ const normalizedText = (content || '').trim();
151
+
152
+ // 3. 斜杠命令
153
+ if (!images && normalizedText.startsWith('/')) {
154
+ await this.commandHandler.dispatch(feishuOpenId, chatId, messageId, normalizedText);
155
+ return;
156
+ }
157
+
158
+ // 4. 文本权限响应(兼容不点卡片的用户)
159
+ if (this.pendingApprovals.has(feishuOpenId) && !images) {
160
+ const handled = await this._handleTextPermissionResponse(feishuOpenId, messageId, normalizedText);
161
+ if (handled) return;
162
+ }
163
+
164
+ // 5. 普通对话 → 队列处理
165
+ if (this.processingLocks.get(feishuOpenId)) {
166
+ const queue = this.messageQueues.get(feishuOpenId) || [];
167
+ const MAX_QUEUE = 5;
168
+ if (queue.length >= MAX_QUEUE) {
169
+ await this.larkClient.replyText(messageId, `⚠️ 队列已满(最多 ${MAX_QUEUE} 条),请等待处理完成后再发送。`);
170
+ return;
171
+ }
172
+ queue.push({ chatId, messageId, content: normalizedText, images, files, quotedFiles });
173
+ this.messageQueues.set(feishuOpenId, queue);
174
+ await this.larkClient.replyText(messageId, `⏳ 已排队(第 ${queue.length} 条),稍后处理…`);
175
+ return;
176
+ }
177
+
178
+ this.processingLocks.set(feishuOpenId, true);
179
+ try {
180
+ await this._runChat(feishuOpenId, chatId, messageId, normalizedText, images, files, quotedFiles);
181
+ } finally {
182
+ // 有排队消息则继续处理(保持锁),否则释放锁
183
+ const queue = this.messageQueues.get(feishuOpenId);
184
+ if (queue && queue.length > 0) {
185
+ this._drainQueue(feishuOpenId).catch(err =>
186
+ console.error('[Feishu:engine] drainQueue error:', err.message)
187
+ );
188
+ } else {
189
+ this.processingLocks.delete(feishuOpenId);
190
+ }
191
+ }
192
+ } finally {
193
+ // 处理完成后删除"处理中"表情
194
+ if (reactionId && messageId) {
195
+ this.larkClient.deleteReaction(messageId, reactionId).catch(err => {
196
+ console.warn('[Feishu:engine] deleteReaction failed:', err.message);
197
+ });
198
+ }
199
+ }
200
+ }
201
+
202
+ // ─── 菜单点击回调入口 ─────────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * 处理飞书机器人菜单点击事件(application.bot.menu_v6)
206
+ * event_key → 斜杠命令的映射通过 FEISHU_MENU_KEY_MAP 环境变量配置(JSON 字符串)
207
+ *
208
+ * @param {Object} params
209
+ * @param {string} params.feishuOpenId 点击者 open_id(p2p 单聊中同时作为 chat_id)
210
+ * @param {string} params.eventKey 飞书菜单配置的 event_key
211
+ */
212
+ async handleMenuAction({ feishuOpenId, eventKey }) {
213
+ const commandText = MENU_KEY_MAP[eventKey];
214
+ if (!commandText) {
215
+ console.warn(`[Feishu:engine] Unknown menu event_key: "${eventKey}". Configure via FEISHU_MENU_KEY_MAP env var.`);
216
+ return;
217
+ }
218
+
219
+ console.log(`[Feishu:engine] Menu: eventKey="${eventKey}" → command="${commandText}" for ${feishuOpenId}`);
220
+
221
+ const binding = feishuDb.getBinding(feishuOpenId);
222
+ if (!binding && !commandText.toLowerCase().startsWith('/auth')) {
223
+ await this.larkClient.sendTextToUser(feishuOpenId,
224
+ '👋 你好!请先完成账号绑定:\n\n1. 登录 Claude Code UI 网页\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
225
+ );
226
+ return;
227
+ }
228
+
229
+ // 优先使用 DB 中存储的 chat_id(由历史消息写入),保证 sendMessage 时 receive_id_type 可用 chat_id
230
+ // 若用户从未发过消息(chat_id 尚未入库),则回退到 feishuOpenId(open_id),
231
+ // command-handler 的 _reply/_replyCard 会自动检测 ou_ 前缀并使用 sendTextToUser
232
+ const state = feishuDb.getSessionState(feishuOpenId);
233
+ const chatId = state?.chat_id || feishuOpenId;
234
+
235
+ await this.commandHandler.dispatch(feishuOpenId, chatId, null, commandText);
236
+ }
237
+
238
+ // ─── 卡片交互回调入口 ─────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * @param {Object} params
242
+ * @param {string} params.feishuOpenId
243
+ * @param {Object} params.action 按钮 value 对象
244
+ * @param {string} params.messageId 卡片消息 ID(用于更新卡片)
245
+ * @param {Object} params.formValue 输入框值
246
+ */
247
+ async handleCardAction({ feishuOpenId, action, messageId, formValue }) {
248
+ console.log('[Feishu:engine] handleCardAction called, action:', JSON.stringify(action), 'messageId:', messageId);
249
+ const actionType = action?.action;
250
+ if (!actionType) {
251
+ console.log('[Feishu:engine] handleCardAction: no actionType, returning');
252
+ return {};
253
+ }
254
+
255
+ const binding = feishuDb.getBinding(feishuOpenId);
256
+ if (!binding) {
257
+ console.log('[Feishu:engine] handleCardAction: no binding for', feishuOpenId);
258
+ return {};
259
+ }
260
+
261
+ switch (actionType) {
262
+ case 'tool_approve':
263
+ return await this._handleToolApproveAction(feishuOpenId, action);
264
+
265
+ case 'set_mode':
266
+ return await this._handleSetModeAction(feishuOpenId, action);
267
+
268
+ case 'show_projects':
269
+ await this._handleShowProjectsAction(feishuOpenId, binding.user_uuid);
270
+ return {};
271
+
272
+ case 'show_modes': {
273
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
274
+ const cardStr = buildModeSelectCardDirect(state.permission_mode || 'default');
275
+ return toCardResponse(cardStr);
276
+ }
277
+
278
+ case 'new_session': {
279
+ feishuDb.clearSession(feishuOpenId);
280
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
281
+ const cardStr = buildStatusCardDirect({ ...state, claude_session_id: null });
282
+ if (messageId) {
283
+ this.larkClient.replyText(messageId, '✅ 新建会话成功,直接发消息即可开始新对话。').catch(() => {});
284
+ }
285
+ return toCardResponse(cardStr);
286
+ }
287
+
288
+ case 'list_select': {
289
+ console.log('[Feishu:engine] list_select formValue:', JSON.stringify(formValue), 'action:', JSON.stringify(action));
290
+ const index = parseInt(formValue?.list_select, 10);
291
+ return await this._handleListSelect(feishuOpenId, action.type, index);
292
+ }
293
+
294
+ case 'ask_user_submit':
295
+ return await this._handleAskUserSubmit(feishuOpenId, action, formValue);
296
+
297
+ case 'ask_user_skip':
298
+ return await this._handleAskUserSkip(feishuOpenId, action);
299
+
300
+ case 'create_project_submit':
301
+ return await this._handleCreateProjectSubmit(feishuOpenId, messageId, binding.user_uuid, formValue);
302
+
303
+ case 'create_project_cancel': {
304
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
305
+ return toCardResponse(buildStatusCardDirect(state));
306
+ }
307
+
308
+ case 'delete_project':
309
+ return await this._handleDeleteProject(feishuOpenId, messageId, binding.user_uuid, formValue, action);
310
+
311
+ case 'delete_session':
312
+ return await this._handleDeleteSession(feishuOpenId, messageId, binding.user_uuid, formValue);
313
+
314
+ case 'delete_cancel': {
315
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
316
+ return toCardResponse(buildStatusCardDirect(state));
317
+ }
318
+
319
+ default:
320
+ console.log(`[Feishu:engine] Unknown card action: ${actionType}`);
321
+ return {};
322
+ }
323
+ }
324
+
325
+ // ─── 工具审批 ─────────────────────────────────────────────────────────────────
326
+
327
+ async _handleToolApproveAction(feishuOpenId, action) {
328
+ const { decision, request_id: requestId, tool_name: toolName } = action;
329
+ const pending = this.pendingApprovals.get(feishuOpenId);
330
+
331
+ // 用 action 中的 requestId 或 pendingApprovals 中的 requestId
332
+ const resolveId = requestId || pending?.requestId;
333
+ if (!resolveId) return {};
334
+
335
+ let result;
336
+ if (decision === 'allow_remember') {
337
+ result = { allow: true, rememberEntry: toolName || pending?.toolName };
338
+ } else if (decision === 'allow') {
339
+ result = { allow: true };
340
+ } else {
341
+ result = { allow: false, message: '用户拒绝' };
342
+ }
343
+
344
+ resolveToolApproval(resolveId, result);
345
+ this.pendingApprovals.delete(feishuOpenId);
346
+
347
+ // 通过 WS 同步响应更新卡片
348
+ const updatedCardStr = buildToolApprovalResultCardDirect(decision === 'deny' ? 'deny' : 'allow');
349
+ return toCardResponse(updatedCardStr);
350
+ }
351
+
352
+ // ─── AskUserQuestion 卡片提交处理 ─────────────────────────────────────────────
353
+
354
+ async _handleAskUserSubmit(feishuOpenId, action, formValue) {
355
+ const { request_id: requestId } = action;
356
+ const pending = this.pendingApprovals.get(feishuOpenId);
357
+ const resolveId = requestId || pending?.requestId;
358
+ if (!resolveId) return {};
359
+
360
+ const questions = pending?.questions || [];
361
+ const answers = {};
362
+ questions.forEach((q, qi) => {
363
+ const fieldValue = formValue?.[`q_${qi}`];
364
+ // multi_select_static 返回数组,select_static 返回字符串
365
+ answers[q.question] = Array.isArray(fieldValue)
366
+ ? fieldValue.join(', ')
367
+ : (fieldValue || '');
368
+ });
369
+
370
+ resolveToolApproval(resolveId, {
371
+ allow: true,
372
+ updatedInput: { ...(pending?.originalInput || {}), answers },
373
+ });
374
+ this.pendingApprovals.delete(feishuOpenId);
375
+
376
+ const resultCardStr = buildAskUserQuestionResultCard(answers, false);
377
+ return toCardResponse(resultCardStr);
378
+ }
379
+
380
+ async _handleAskUserSkip(feishuOpenId, action) {
381
+ const { request_id: requestId } = action;
382
+ const pending = this.pendingApprovals.get(feishuOpenId);
383
+ const resolveId = requestId || pending?.requestId;
384
+ if (!resolveId) return {};
385
+
386
+ resolveToolApproval(resolveId, { allow: false, message: '用户跳过问题' });
387
+ this.pendingApprovals.delete(feishuOpenId);
388
+
389
+ const resultCardStr = buildAskUserQuestionResultCard({}, true);
390
+ return toCardResponse(resultCardStr);
391
+ }
392
+
393
+ async _handleTextPermissionResponse(feishuOpenId, messageId, text) {
394
+ const lower = text.toLowerCase().trim();
395
+ const pending = this.pendingApprovals.get(feishuOpenId);
396
+ if (!pending) return false;
397
+
398
+ // AskUserQuestion:解析选项字母/数字回复
399
+ if (pending.toolName === 'AskUserQuestion') {
400
+ return await this._handleAskUserQuestionResponse(feishuOpenId, messageId, text, pending);
401
+ }
402
+
403
+ let decision = null;
404
+ if (ALLOW_ALL_KEYWORDS.has(lower)) {
405
+ decision = 'allow_remember';
406
+ } else if (ALLOW_KEYWORDS.has(lower)) {
407
+ decision = 'allow';
408
+ } else if (DENY_KEYWORDS.has(lower)) {
409
+ decision = 'deny';
410
+ }
411
+
412
+ if (!decision) return false;
413
+
414
+ let result;
415
+ if (decision === 'allow_remember') {
416
+ result = { allow: true, rememberEntry: pending.toolName };
417
+ } else if (decision === 'allow') {
418
+ result = { allow: true };
419
+ } else {
420
+ result = { allow: false, message: '用户拒绝' };
421
+ }
422
+
423
+ resolveToolApproval(pending.requestId, result);
424
+ this.pendingApprovals.delete(feishuOpenId);
425
+
426
+ // 更新挂起的卡片
427
+ if (pending.cardMessageId) {
428
+ const updatedCard = buildToolApprovalResultCardDirect(decision === 'deny' ? 'deny' : 'allow');
429
+ await this.larkClient.updateCard(pending.cardMessageId, updatedCard).catch(() => {});
430
+ } else {
431
+ const label = decision === 'deny' ? '❌ 已拒绝' : '✅ 已允许';
432
+ await this.larkClient.replyText(messageId, label);
433
+ }
434
+
435
+ return true;
436
+ }
437
+
438
+ async _handleAskUserQuestionResponse(feishuOpenId, messageId, text, pending) {
439
+ const lower = text.toLowerCase().trim();
440
+ const questions = pending.questions || [];
441
+ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
442
+
443
+ // 跳过
444
+ if (DENY_KEYWORDS.has(lower) || lower === '跳过' || lower === 'skip') {
445
+ resolveToolApproval(pending.requestId, { allow: false, message: '用户跳过问题' });
446
+ this.pendingApprovals.delete(feishuOpenId);
447
+ if (pending.cardMessageId) {
448
+ const resultCard = buildAskUserQuestionResultCard({}, true);
449
+ await this.larkClient.updateCard(pending.cardMessageId, resultCard).catch(() => {});
450
+ } else {
451
+ await this.larkClient.replyText(messageId, '⏭️ 已跳过问卷').catch(() => {});
452
+ }
453
+ return true;
454
+ }
455
+
456
+ // 解析:多题用逗号分隔,多选用 + 连接;同时支持字母(A)和数字(1)
457
+ const rawParts = text.trim().toUpperCase().split(',').map(s => s.trim());
458
+
459
+ // 将数字转换为字母("1" → "A","2" → "B" …)
460
+ const normalize = (token) => {
461
+ const n = parseInt(token, 10);
462
+ return (!isNaN(n) && n >= 1 && n <= 26) ? LETTERS[n - 1] : token;
463
+ };
464
+
465
+ const isValidLetter = (t) => /^[A-Z]$/.test(t);
466
+ const parsePart = (part) => part.split('+').map(t => normalize(t.trim()));
467
+ const allValid = rawParts.every(p => parsePart(p).every(isValidLetter));
468
+
469
+ if (!allValid || (questions.length > 1 && rawParts.length !== questions.length)) {
470
+ const hint = questions.length > 1
471
+ ? `请按题目顺序输入选项,多题用逗号分隔(如 A,B,A),多选用 + 连接`
472
+ : `请输入选项字母(如 A)或数字(如 1),多选用 + 连接(如 A+B)`;
473
+ await this.larkClient.replyText(messageId, `❌ 格式不正确,${hint},或回复"跳过"跳过此问卷。`).catch(() => {});
474
+ return true; // 消耗此消息,等待用户重新输入
475
+ }
476
+
477
+ // 构建 answers 对象
478
+ const answers = {};
479
+ questions.forEach((q, qi) => {
480
+ const part = rawParts.length === 1 ? rawParts[0] : rawParts[qi];
481
+ const selectedLetters = parsePart(part);
482
+ const options = q.options || [];
483
+ const selectedLabels = selectedLetters
484
+ .map(l => options[LETTERS.indexOf(l)])
485
+ .filter(Boolean)
486
+ .map(opt => opt.label);
487
+ answers[q.question] = selectedLabels.join(', ');
488
+ });
489
+
490
+ resolveToolApproval(pending.requestId, {
491
+ allow: true,
492
+ updatedInput: { ...pending.originalInput, answers },
493
+ });
494
+ this.pendingApprovals.delete(feishuOpenId);
495
+
496
+ // 更新卡片(若通过文字回复了已有的交互卡片)
497
+ if (pending.cardMessageId) {
498
+ const resultCard = buildAskUserQuestionResultCard(answers, false);
499
+ await this.larkClient.updateCard(pending.cardMessageId, resultCard).catch(() => {});
500
+ } else {
501
+ const summary = Object.entries(answers)
502
+ .map(([q, a]) => `• ${q}:${a || '(未选中)'}`)
503
+ .join('\n');
504
+ await this.larkClient.replyText(messageId, `✅ 已提交\n${summary}`).catch(() => {});
505
+ }
506
+ return true;
507
+ }
508
+
509
+ // ─── 卡片按钮 action 处理 ─────────────────────────────────────────────────────
510
+
511
+ async _handleSetModeAction(feishuOpenId, action) {
512
+ const mode = action.mode;
513
+ const validModes = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions']);
514
+ if (!validModes.has(mode)) return {};
515
+
516
+ feishuDb.updateSessionState(feishuOpenId, { permission_mode: mode });
517
+
518
+ const cardStr = buildModeSelectCardDirect(mode);
519
+ return toCardResponse(cardStr);
520
+ }
521
+
522
+ // ─── 创建项目 ─────────────────────────────────────────────────────────────────
523
+
524
+ async _handleCreateProjectSubmit(feishuOpenId, messageId, userUuid, formValue) {
525
+ const PROJECT_NAME_REGEX = /^[a-zA-Z0-9_-]{1,100}$/;
526
+ const TRUSTED_GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org'];
527
+
528
+ const projectName = (formValue?.project_name || '').trim();
529
+ const githubUrl = (formValue?.github_url || '').trim();
530
+
531
+ // —— 同步校验 ——
532
+ if (!projectName) {
533
+ return toCardResponse(buildCreateProjectResultCard(false, '项目名称不能为空'));
534
+ }
535
+ if (!PROJECT_NAME_REGEX.test(projectName)) {
536
+ return toCardResponse(buildCreateProjectResultCard(false,
537
+ '项目名称只能包含字母、数字、连字符(`-`)和下划线(`_`),长度 1-100 位'));
538
+ }
539
+ if (githubUrl) {
540
+ let parsedUrl;
541
+ try { parsedUrl = new URL(githubUrl); } catch {
542
+ return toCardResponse(buildCreateProjectResultCard(false, 'GitHub 仓库地址格式不正确'));
543
+ }
544
+ if (parsedUrl.protocol !== 'https:' || !TRUSTED_GIT_HOSTS.includes(parsedUrl.hostname.toLowerCase())) {
545
+ return toCardResponse(buildCreateProjectResultCard(false,
546
+ `仅支持 ${TRUSTED_GIT_HOSTS.join(', ')} 的 HTTPS 地址`));
547
+ }
548
+ }
549
+
550
+ // —— 立即返回"处理中"卡片,异步完成创建后更新卡片 ——
551
+ const processingCard = buildCreateProjectResultCard(true,
552
+ githubUrl
553
+ ? `正在创建项目 **${projectName}** 并克隆仓库,请稍候…`
554
+ : `正在创建项目 **${projectName}**…`
555
+ );
556
+
557
+ setImmediate(async () => {
558
+ try {
559
+ const { projectsDir } = getUserPaths(userUuid);
560
+ const absolutePath = path.join(projectsDir, projectName);
561
+
562
+ // 检查文件夹是否已存在(仅删记录保留文件夹的情况允许重新注册)
563
+ let folderExists = false;
564
+ try {
565
+ await fs.access(absolutePath);
566
+ folderExists = true;
567
+ } catch (err) {
568
+ if (err.code !== 'ENOENT') throw err;
569
+ }
570
+
571
+ if (!folderExists) {
572
+ if (githubUrl) {
573
+ // 让 git clone 自行创建目录
574
+ await new Promise((resolve, reject) => {
575
+ const git = spawn('git', ['clone', '--depth', '1', githubUrl, absolutePath], {
576
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
577
+ stdio: 'pipe',
578
+ });
579
+ let stderr = '';
580
+ git.stderr.on('data', d => { stderr += d.toString(); });
581
+ git.on('close', code => code === 0 ? resolve() : reject(new Error(stderr.trim() || 'git clone 失败')));
582
+ git.on('error', reject);
583
+ setTimeout(() => { git.kill(); reject(new Error('git clone 超时(60s)')); }, 60000);
584
+ });
585
+ } else {
586
+ await fs.mkdir(absolutePath, { recursive: true });
587
+ }
588
+ }
589
+
590
+ try {
591
+ await addProjectManually(absolutePath, projectName, userUuid);
592
+ } catch (err) {
593
+ if (err.message?.includes('already configured')) {
594
+ await this.larkClient.updateCard(messageId,
595
+ buildCreateProjectResultCard(false, `项目 **${projectName}** 已存在,请换个名称`)
596
+ ).catch(() => {});
597
+ return;
598
+ }
599
+ throw err;
600
+ }
601
+
602
+ // 切换到新项目并清空会话
603
+ feishuDb.updateSessionState(feishuOpenId, { cwd: absolutePath, claude_session_id: null });
604
+ const newState = feishuDb.getSessionState(feishuOpenId) || {};
605
+ await this.larkClient.updateCard(messageId,
606
+ buildStatusCardDirect({ ...newState, cwd: absolutePath })
607
+ ).catch(() => {});
608
+ } catch (err) {
609
+ console.error('[Feishu:engine] createProject error:', err.message);
610
+ await this.larkClient.updateCard(messageId,
611
+ buildCreateProjectResultCard(false, `创建失败:${err.message}`)
612
+ ).catch(() => {});
613
+ }
614
+ });
615
+
616
+ return toCardResponse(processingCard);
617
+ }
618
+
619
+ async _handleShowProjectsAction(feishuOpenId, userUuid) {
620
+ let projects = [];
621
+ try {
622
+ projects = await getProjects(userUuid);
623
+ } catch (err) {
624
+ console.error('[Feishu:engine] getProjects error:', err.message);
625
+ }
626
+
627
+ const sliced = projects.slice(0, 20);
628
+ const items = sliced.map(p => ({
629
+ label: p.displayName || p.name,
630
+ sub: `\`${stripProjectPrefix(p.path || p.name)}\``,
631
+ }));
632
+
633
+ this.commandHandler._storeProjectList(feishuOpenId, sliced);
634
+
635
+ const card = buildListCardDirect({
636
+ title: '📁 项目列表',
637
+ items,
638
+ actionType: 'project',
639
+ });
640
+ await this.larkClient.sendMessageToUser(feishuOpenId, card, 'interactive').catch(() =>
641
+ this.larkClient.sendTextToUser(feishuOpenId, sliced.map((p, i) => `${i + 1}. ${p.displayName || p.name}`).join('\n'))
642
+ );
643
+ }
644
+
645
+ // ─── 删除项目 ──────────────────────────────────────────────────────────────────
646
+
647
+ async _handleDeleteProject(feishuOpenId, messageId, userUuid, formValue, action) {
648
+ const index = parseInt(formValue?.delete_select, 10);
649
+ const projects = this.commandHandler.getListCache(feishuOpenId, 'projects');
650
+ const project = !isNaN(index) && index >= 1 ? projects[index - 1] : null;
651
+
652
+ if (!project) {
653
+ return toCardResponse(buildDeleteResultCard(false, '请先从下拉列表中选择要删除的项目。'));
654
+ }
655
+
656
+ try {
657
+ const deleteFolder = action?.delete_folder === 'true';
658
+ await deleteProject(project.name, userUuid, { deleteFolder });
659
+
660
+ // 若删除的是当前项目,清空 cwd 和会话
661
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
662
+ if (state.cwd && (state.cwd === project.path || state.cwd === project.name)) {
663
+ feishuDb.updateSessionState(feishuOpenId, { cwd: '', claude_session_id: null });
664
+ }
665
+
666
+ const displayName = project.displayName || project.name;
667
+ const extra = deleteFolder ? '(含文件夹)' : '';
668
+ if (messageId) {
669
+ this.larkClient.replyText(messageId, `✅ 项目 **${displayName}** 已删除${extra}`).catch(() => {});
670
+ }
671
+ return toCardResponse(buildDeleteResultCard(true, `项目 **${displayName}** 已删除${extra}`));
672
+ } catch (err) {
673
+ console.error('[Feishu:engine] deleteProject error:', err.message);
674
+ return toCardResponse(buildDeleteResultCard(false, `删除失败:${err.message}`));
675
+ }
676
+ }
677
+
678
+ // ─── 删除会话 ──────────────────────────────────────────────────────────────────
679
+
680
+ async _handleDeleteSession(feishuOpenId, messageId, userUuid, formValue) {
681
+ const index = parseInt(formValue?.delete_select, 10);
682
+ const sessions = this.commandHandler.getListCache(feishuOpenId, 'sessions');
683
+ const session = !isNaN(index) && index >= 1 ? sessions[index - 1] : null;
684
+
685
+ if (!session) {
686
+ return toCardResponse(buildDeleteResultCard(false, '请先从下拉列表中选择要删除的会话。'));
687
+ }
688
+
689
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
690
+ const cwd = state.cwd;
691
+ if (!cwd) {
692
+ return toCardResponse(buildDeleteResultCard(false, '未设置当前项目,无法删除会话。'));
693
+ }
694
+
695
+ const projectName = cwd.replace(/\//g, '-');
696
+
697
+ try {
698
+ await deleteSession(projectName, session.id, userUuid);
699
+
700
+ // 若删除的是当前会话,清空 session id
701
+ if (state.claude_session_id === session.id) {
702
+ feishuDb.clearSession(feishuOpenId);
703
+ }
704
+
705
+ const shortId = session.id?.slice(0, 8) || session.id;
706
+ if (messageId) {
707
+ this.larkClient.replyText(messageId, `✅ 会话 \`${shortId}\` 已删除`).catch(() => {});
708
+ }
709
+ return toCardResponse(buildDeleteResultCard(true, `会话 \`${shortId}\` 已删除`));
710
+ } catch (err) {
711
+ console.error('[Feishu:engine] deleteSession error:', err.message);
712
+ return toCardResponse(buildDeleteResultCard(false, `删除失败:${err.message}`));
713
+ }
714
+ }
715
+
716
+ async _handleListSelect(feishuOpenId, type, index) {
717
+ const idx = typeof index === 'number' ? index : parseInt(index, 10);
718
+
719
+ if (type === 'project') {
720
+ const projects = this.commandHandler.getListCache(feishuOpenId, 'projects');
721
+ const project = !isNaN(idx) && idx >= 1 ? projects[idx - 1] : null;
722
+ if (project) {
723
+ feishuDb.updateSessionState(feishuOpenId, { cwd: project.path || project.name, claude_session_id: null });
724
+ }
725
+ } else if (type === 'session') {
726
+ const sessions = this.commandHandler.getListCache(feishuOpenId, 'sessions');
727
+ const session = !isNaN(idx) && idx >= 1 ? sessions[idx - 1] : null;
728
+ if (session) {
729
+ feishuDb.updateSessionState(feishuOpenId, { claude_session_id: session.id });
730
+ }
731
+ }
732
+
733
+ // 通过 WS 同步响应返回状态卡片
734
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
735
+ const cardStr = buildStatusCardDirect(state);
736
+ return toCardResponse(cardStr);
737
+ }
738
+
739
+ // ─── 普通对话 ─────────────────────────────────────────────────────────────────
740
+
741
+ async _runChat(feishuOpenId, chatId, messageId, content, images, files, quotedFiles) {
742
+ const binding = feishuDb.getBinding(feishuOpenId);
743
+ if (!binding) return;
744
+
745
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
746
+
747
+ // 未选择项目路径时,拒绝对话并提示用户
748
+ if (!state.cwd) {
749
+ await this.larkClient.sendText(chatId,
750
+ '📁 请先选择一个项目再开始对话。\n\n发送 `/project` 查看并选择项目,或发送 `/project create` 创建新项目。'
751
+ );
752
+ return;
753
+ }
754
+
755
+ let finalContent = content;
756
+ let finalImages = images ? [...images] : null;
757
+
758
+ // ─── 直接发送的文件附件:保存到 {cwd}/lark-files/ ────────────────────────
759
+ if (files && files.length > 0) {
760
+ const larkFilesDir = path.join(state.cwd, 'lark-files');
761
+ try {
762
+ await fs.mkdir(larkFilesDir, { recursive: true });
763
+ const filePaths = [];
764
+ for (const file of files) {
765
+ const safeName = `${file.fileKey || Date.now()}_${path.basename(file.filename) || 'attachment'}`;
766
+ const filePath = path.join(larkFilesDir, safeName);
767
+ await fs.writeFile(filePath, file.data);
768
+ filePaths.push(filePath);
769
+ console.log(`[Feishu:engine] Saved file: ${filePath}`);
770
+ }
771
+ const refs = filePaths.map(p => `@${p}`).join('\n');
772
+ finalContent = finalContent ? `${finalContent}\n\n${refs}` : refs;
773
+ } catch (err) {
774
+ console.error('[Feishu:engine] Failed to save file attachment:', err.message);
775
+ await this.larkClient.sendText(chatId, `❌ 文件保存失败:${err.message}`);
776
+ return;
777
+ }
778
+ }
779
+
780
+ // ─── 引用消息中的附件:检查是否已在本地,按需下载 ───────────────────────
781
+ if (quotedFiles && quotedFiles.length > 0) {
782
+ const larkFilesDir = path.join(state.cwd, 'lark-files');
783
+ try {
784
+ await fs.mkdir(larkFilesDir, { recursive: true });
785
+ const filePaths = [];
786
+ for (const qf of quotedFiles) {
787
+ if (qf.type === 'image') {
788
+ // 引用的图片:下载后追加到 images 列表,让 Claude 直接"看"到
789
+ const buf = await this.larkClient.downloadFile(qf.messageId, qf.fileKey, 'image');
790
+ finalImages = finalImages || [];
791
+ finalImages.push({ data: buf, mimeType: 'image/jpeg' });
792
+ console.log(`[Feishu:engine] Loaded quoted image: ${qf.fileKey}`);
793
+ } else if (qf.type === 'audio') {
794
+ // 引用的音频:转写后追加到 prompt
795
+ const buf = await this.larkClient.downloadFile(qf.messageId, qf.fileKey);
796
+ const transcribed = await transcribeAudio(buf, 'ogg').catch(err => {
797
+ console.error('[Feishu:engine] transcribeAudio for quoted audio failed:', err.message);
798
+ return null;
799
+ });
800
+ if (transcribed) {
801
+ finalContent = finalContent
802
+ ? `${finalContent}\n\n(引用语音转写:「${transcribed}」)`
803
+ : `(引用语音转写:「${transcribed}」)`;
804
+ }
805
+ } else {
806
+ // 引用的文件/视频:保存到 lark-files/ 并加 @ 引用
807
+ const safeName = `${qf.fileKey}_${path.basename(qf.filename) || 'attachment'}`;
808
+ const filePath = path.join(larkFilesDir, safeName);
809
+ let exists = false;
810
+ try { await fs.access(filePath); exists = true; } catch {}
811
+ if (!exists) {
812
+ const buf = await this.larkClient.downloadFile(qf.messageId, qf.fileKey);
813
+ await fs.writeFile(filePath, buf);
814
+ console.log(`[Feishu:engine] Downloaded quoted file: ${filePath}`);
815
+ } else {
816
+ console.log(`[Feishu:engine] Quoted file already cached: ${filePath}`);
817
+ }
818
+ filePaths.push(filePath);
819
+ }
820
+ }
821
+ if (filePaths.length > 0) {
822
+ const refs = filePaths.map(p => `@${p}`).join('\n');
823
+ finalContent = finalContent ? `${finalContent}\n\n${refs}` : refs;
824
+ }
825
+ } catch (err) {
826
+ console.error('[Feishu:engine] Failed to process quoted attachment:', err.message);
827
+ await this.larkClient.sendText(chatId, `⚠️ 引用附件处理失败:${err.message},将忽略附件继续处理。`);
828
+ // 不中断,继续对话
829
+ }
830
+ }
831
+
832
+ await runQuery({
833
+ feishuOpenId,
834
+ chatId,
835
+ messageId,
836
+ content: finalContent,
837
+ images: finalImages,
838
+ userUuid: binding.user_uuid,
839
+ state,
840
+ larkClient: this.larkClient,
841
+ pendingApprovals: this.pendingApprovals,
842
+ });
843
+ }
844
+
845
+ /**
846
+ * 处理排队消息。调用时 processingLocks 必须已被持有,
847
+ * 方法内部循环处理完所有队列项后统一释放锁。
848
+ */
849
+ async _drainQueue(feishuOpenId) {
850
+ try {
851
+ while (true) {
852
+ const queue = this.messageQueues.get(feishuOpenId);
853
+ if (!queue || queue.length === 0) break;
854
+
855
+ const next = queue.shift();
856
+ if (queue.length === 0) this.messageQueues.delete(feishuOpenId);
857
+
858
+ // 给排队消息添加"处理中"表情
859
+ let reactionId = null;
860
+ if (next.messageId) {
861
+ reactionId = await this.larkClient.addReaction(next.messageId, 'OneSecond').catch(() => null);
862
+ }
863
+
864
+ try {
865
+ await this._runChat(feishuOpenId, next.chatId, next.messageId, next.content, next.images, next.files, next.quotedFiles);
866
+ } catch (err) {
867
+ console.error('[Feishu:engine] drainQueue _runChat error:', err.message);
868
+ } finally {
869
+ if (reactionId && next.messageId) {
870
+ this.larkClient.deleteReaction(next.messageId, reactionId).catch(() => {});
871
+ }
872
+ }
873
+ }
874
+ } finally {
875
+ // 无论正常还是异常,最终释放锁
876
+ this.processingLocks.delete(feishuOpenId);
877
+ }
878
+ }
879
+
880
+ // 辅助:通过 feishuOpenId 获取 chatId(单聊中 open_id 即 chat_id)
881
+ async _getChatIdForUser(feishuOpenId) {
882
+ return feishuOpenId;
883
+ }
884
+ }