@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,492 @@
1
+ /**
2
+ * command-handler.js — 飞书斜杠命令处理
3
+ *
4
+ * 支持命令:
5
+ * /auth <token> 绑定 claudecodeui 账号
6
+ * /unbind 解除绑定
7
+ * /new 新建 Claude 会话
8
+ * /list 列出当前项目的会话
9
+ * /switch <id前缀> 切换到指定会话
10
+ * /project 查看当前状态
11
+ * /project list 列出项目
12
+ * /project create 创建新项目
13
+ * /project use <路径> 切换项目
14
+ * /mode 查看 / 选择权限模式
15
+ * /mode <name> 直接切换模式
16
+ * /stop 中止当前运行的会话
17
+ * /help 显示帮助
18
+ *
19
+ * 注意:messageId 可为 null(菜单点击事件没有原始消息),
20
+ * 此时 _reply/_replyCard 自动降级为向 chatId 发新消息。
21
+ */
22
+
23
+ import jwt from 'jsonwebtoken';
24
+ import { feishuDb } from './feishu-db.js';
25
+ import { userDb } from '../../database/db.js';
26
+ import { abortClaudeSDKSession } from '../../claude-sdk.js';
27
+ import { getProjects, getSessions } from '../../projects.js';
28
+ import { JWT_SECRET } from '../../middleware/auth.js';
29
+ import {
30
+ MODE_LABELS,
31
+ stripProjectPrefix,
32
+ buildModeSelectCardDirect,
33
+ buildStatusCardDirect,
34
+ buildListCardDirect,
35
+ buildDeleteListCardDirect,
36
+ buildHelpCardDirect,
37
+ buildCreateProjectCardDirect,
38
+ } from './card-builder.js';
39
+
40
+ const VALID_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions']);
41
+ const MODE_ALIASES = {
42
+ edit: 'acceptEdits',
43
+ yolo: 'bypassPermissions',
44
+ bypass: 'bypassPermissions',
45
+ };
46
+
47
+ export class CommandHandler {
48
+ constructor(larkClient) {
49
+ this.larkClient = larkClient;
50
+ }
51
+
52
+ /**
53
+ * 命令分发入口
54
+ * @param {string} feishuOpenId
55
+ * @param {string} chatId
56
+ * @param {string|null} messageId 为 null 时(菜单事件)降级为发新消息
57
+ * @param {string} rawText 原始消息文本(以 / 开头)
58
+ * @returns {Promise<boolean>} true = 命令已处理,false = 未识别
59
+ */
60
+ async dispatch(feishuOpenId, chatId, messageId, rawText) {
61
+ const parts = rawText.trim().split(/\s+/);
62
+ const cmd = parts[0].toLowerCase();
63
+ const args = parts.slice(1);
64
+
65
+ switch (cmd) {
66
+ case '/auth': return this._handleAuth(feishuOpenId, chatId, messageId, args[0]);
67
+ case '/unbind': return this._handleUnbind(feishuOpenId, chatId, messageId);
68
+ case '/new': return this._handleNew(feishuOpenId, chatId, messageId);
69
+ case '/list': return this._handleList(feishuOpenId, chatId, messageId);
70
+ case '/switch': return this._handleSwitch(feishuOpenId, chatId, messageId, args[0]);
71
+ case '/project': return this._handleProject(feishuOpenId, chatId, messageId, args);
72
+ case '/delete': return this._handleDeleteSession(feishuOpenId, chatId, messageId);
73
+ case '/mode': return this._handleMode(feishuOpenId, chatId, messageId, args[0]);
74
+ case '/stop': return this._handleStop(feishuOpenId, chatId, messageId);
75
+ case '/help': return this._handleHelp(feishuOpenId, chatId, messageId);
76
+ default:
77
+ await this._reply(chatId, messageId, `未知命令: \`${cmd}\`\n发送 /help 查看帮助`);
78
+ return true;
79
+ }
80
+ }
81
+
82
+ // ─── 命令实现 ────────────────────────────────────────────────────────────────
83
+
84
+ async _handleAuth(feishuOpenId, chatId, messageId, token) {
85
+ if (!token) {
86
+ await this._reply(chatId, messageId,
87
+ '请先完成账号绑定:\n\n1. 登录 Claude Code UI 网页\n2. 进入 **设置 → 账户**,点击「生成绑定 Token」\n3. 复制命令后在此发送:`/auth <token>`'
88
+ );
89
+ return true;
90
+ }
91
+
92
+ let payload;
93
+ try {
94
+ payload = jwt.verify(token, JWT_SECRET);
95
+ } catch {
96
+ await this._reply(chatId, messageId, '❌ Token 无效或已过期,请重新生成。');
97
+ return true;
98
+ }
99
+
100
+ const user = userDb.getUserByUuid?.(payload.uuid) || userDb.getUserById?.(payload.userId);
101
+ if (!user || user.status === 'disabled') {
102
+ await this._reply(chatId, messageId, '❌ 账号不存在或已被禁用。');
103
+ return true;
104
+ }
105
+
106
+ feishuDb.createBinding(feishuOpenId, user.uuid);
107
+ feishuDb.updateSessionState(feishuOpenId, {
108
+ claude_session_id: null,
109
+ cwd: '',
110
+ permission_mode: 'default',
111
+ });
112
+
113
+ await this._reply(chatId, messageId,
114
+ `✅ 绑定成功!欢迎 **${user.email || user.username}**\n\n发送 /help 查看可用命令。`
115
+ );
116
+ return true;
117
+ }
118
+
119
+ async _handleUnbind(feishuOpenId, chatId, messageId) {
120
+ feishuDb.removeBinding(feishuOpenId);
121
+ await this._reply(chatId, messageId, '✅ 已解除绑定。再次使用请重新发送 /auth <token>。');
122
+ return true;
123
+ }
124
+
125
+ async _handleNew(feishuOpenId, chatId, messageId) {
126
+ feishuDb.clearSession(feishuOpenId);
127
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
128
+
129
+ const card = buildStatusCardDirect({ ...state, claude_session_id: null });
130
+ await this._replyCard(chatId, messageId, card, 'interactive');
131
+ return true;
132
+ }
133
+
134
+ async _handleList(feishuOpenId, chatId, messageId) {
135
+ const binding = feishuDb.getBinding(feishuOpenId);
136
+ if (!binding) {
137
+ await this._reply(chatId, messageId, '请先发送 /auth <token> 完成账号绑定。');
138
+ return true;
139
+ }
140
+
141
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
142
+ const cwd = state.cwd;
143
+
144
+ if (!cwd) {
145
+ await this._reply(chatId, messageId, '请先选择项目,发送 /project list 查看可用项目。');
146
+ return true;
147
+ }
148
+
149
+ // 从项目路径提取项目名
150
+ const projectName = _cwdToProjectName(cwd);
151
+
152
+ let sessions = [];
153
+ try {
154
+ const result = await getSessions(projectName, 20, 0, binding.user_uuid);
155
+ sessions = Array.isArray(result) ? result : (result?.sessions || []);
156
+ } catch (err) {
157
+ console.error('[Feishu:command] getSessions error:', err.message);
158
+ }
159
+
160
+ if (sessions.length === 0) {
161
+ await this._reply(chatId, messageId, '当前项目暂无历史会话,直接发消息开始新会话。');
162
+ return true;
163
+ }
164
+
165
+ const currentId = state.claude_session_id;
166
+ const sliced = sessions.slice(0, 20);
167
+ const items = sliced.map(s => {
168
+ const isCurrent = s.id === currentId;
169
+ const preview = (s.summary || s.lastUserMessage || '').slice(0, 40);
170
+ return {
171
+ label: `[${s.id?.slice(0, 8)}] ${preview}`,
172
+ sub: isCurrent ? '◀ 当前会话' : '',
173
+ isCurrent,
174
+ };
175
+ });
176
+
177
+ this._storeSessionList(feishuOpenId, sliced);
178
+ const card = buildListCardDirect({
179
+ title: '💬 会话列表',
180
+ items,
181
+ actionType: 'session',
182
+ });
183
+ await this._replyCard(chatId, messageId, card, 'interactive');
184
+ return true;
185
+ }
186
+
187
+ // 存储最近一次列表(供卡片回调使用)
188
+ _listCache = new Map();
189
+
190
+ _storeSessionList(feishuOpenId, sessions) {
191
+ this._listCache.set(`${feishuOpenId}:sessions`, sessions);
192
+ setTimeout(() => this._listCache.delete(`${feishuOpenId}:sessions`), 5 * 60 * 1000);
193
+ }
194
+
195
+ _storeProjectList(feishuOpenId, projects) {
196
+ this._listCache.set(`${feishuOpenId}:projects`, projects);
197
+ setTimeout(() => this._listCache.delete(`${feishuOpenId}:projects`), 5 * 60 * 1000);
198
+ }
199
+
200
+ getListCache(feishuOpenId, type) {
201
+ return this._listCache.get(`${feishuOpenId}:${type}`) || [];
202
+ }
203
+
204
+ async _handleSwitch(feishuOpenId, chatId, messageId, arg) {
205
+ if (!arg) {
206
+ await this._reply(chatId, messageId, '用法:`/switch <序号或会话ID前缀>`');
207
+ return true;
208
+ }
209
+
210
+ const binding = feishuDb.getBinding(feishuOpenId);
211
+ if (!binding) { return this._requireAuth(chatId, messageId); }
212
+
213
+ // 尝试按序号
214
+ const index = parseInt(arg, 10);
215
+ const sessions = this.getListCache(feishuOpenId, 'sessions');
216
+
217
+ let targetSession = null;
218
+ if (!isNaN(index) && index >= 1 && index <= sessions.length) {
219
+ targetSession = sessions[index - 1];
220
+ } else {
221
+ // 按 ID 前缀匹配
222
+ const prefix = arg.toLowerCase();
223
+ targetSession = sessions.find(s => s.id?.toLowerCase().startsWith(prefix));
224
+ }
225
+
226
+ if (!targetSession) {
227
+ await this._reply(chatId, messageId,
228
+ `未找到会话 \`${arg}\`,请先发送 /list 查看列表。`
229
+ );
230
+ return true;
231
+ }
232
+
233
+ feishuDb.updateSessionState(feishuOpenId, { claude_session_id: targetSession.id });
234
+ await this._reply(chatId, messageId,
235
+ `✅ 已切换到会话 \`${targetSession.id?.slice(0, 8)}\``
236
+ );
237
+ return true;
238
+ }
239
+
240
+ async _handleProject(feishuOpenId, chatId, messageId, args) {
241
+ const binding = feishuDb.getBinding(feishuOpenId);
242
+ if (!binding) { return this._requireAuth(chatId, messageId); }
243
+
244
+ const sub = args[0]?.toLowerCase();
245
+
246
+ if (sub === 'delete') {
247
+ let projects = [];
248
+ try {
249
+ projects = await getProjects(binding.user_uuid);
250
+ } catch (err) {
251
+ console.error('[Feishu:command] getProjects error:', err.message);
252
+ }
253
+
254
+ if (projects.length === 0) {
255
+ await this._reply(chatId, messageId, '暂无项目可删除。');
256
+ return true;
257
+ }
258
+
259
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
260
+ const currentCwd = state.cwd;
261
+ const slicedProjects = projects.slice(0, 20);
262
+ const items = slicedProjects.map(p => {
263
+ const name = p.displayName || p.name;
264
+ const sessions = p.sessionCount || p.sessions?.length || 0;
265
+ const isCurrent = p.path === currentCwd || p.name === currentCwd;
266
+ return {
267
+ label: `${name} (${sessions} 个会话)`,
268
+ sub: stripProjectPrefix(p.path || p.name),
269
+ isCurrent,
270
+ };
271
+ });
272
+
273
+ this._storeProjectList(feishuOpenId, slicedProjects);
274
+ const card = buildDeleteListCardDirect({
275
+ title: '🗑️ 删除项目',
276
+ items,
277
+ actionType: 'project',
278
+ });
279
+ await this._replyCard(chatId, messageId, card, 'interactive');
280
+ return true;
281
+ }
282
+
283
+ if (sub === 'create') {
284
+ const card = buildCreateProjectCardDirect();
285
+ await this._replyCard(chatId, messageId, card, 'interactive');
286
+ return true;
287
+ }
288
+
289
+ if (sub === 'list') {
290
+ let projects = [];
291
+ try {
292
+ projects = await getProjects(binding.user_uuid);
293
+ } catch (err) {
294
+ console.error('[Feishu:command] getProjects error:', err.message);
295
+ }
296
+
297
+ if (projects.length === 0) {
298
+ await this._reply(chatId, messageId, '暂无项目,请在 Claude Code UI 中创建项目。');
299
+ return true;
300
+ }
301
+
302
+ const slicedProjects = projects.slice(0, 20);
303
+ const items = slicedProjects.map(p => {
304
+ const name = p.displayName || p.name;
305
+ const sessions = p.sessionCount || p.sessions?.length || 0;
306
+ return {
307
+ label: `${name} (${sessions} 个会话)`,
308
+ sub: stripProjectPrefix(p.path || p.name),
309
+ };
310
+ });
311
+
312
+ this._storeProjectList(feishuOpenId, slicedProjects);
313
+ const card = buildListCardDirect({
314
+ title: '📁 项目列表',
315
+ items,
316
+ actionType: 'project',
317
+ });
318
+ await this._replyCard(chatId, messageId, card, 'interactive');
319
+ return true;
320
+ }
321
+
322
+ if (sub === 'use') {
323
+ const arg = args.slice(1).join(' ').trim();
324
+ if (!arg) {
325
+ await this._reply(chatId, messageId, '用法:`/project use <序号或项目路径>`');
326
+ return true;
327
+ }
328
+
329
+ let projectPath = arg;
330
+ const index = parseInt(arg, 10);
331
+ const projects = this.getListCache(feishuOpenId, 'projects');
332
+
333
+ if (!isNaN(index) && index >= 1 && index <= projects.length) {
334
+ projectPath = projects[index - 1].path || projects[index - 1].name;
335
+ }
336
+
337
+ feishuDb.updateSessionState(feishuOpenId, { cwd: projectPath, claude_session_id: null });
338
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
339
+
340
+ const card = buildStatusCardDirect({ ...state, cwd: projectPath });
341
+ await this._replyCard(chatId, messageId, card, 'interactive');
342
+ return true;
343
+ }
344
+
345
+ // /project(无参数)→ 显示状态卡片
346
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
347
+ const card = buildStatusCardDirect(state);
348
+ await this._replyCard(chatId, messageId, card, 'interactive');
349
+ return true;
350
+ }
351
+
352
+ async _handleMode(feishuOpenId, chatId, messageId, arg) {
353
+ const binding = feishuDb.getBinding(feishuOpenId);
354
+ if (!binding) { return this._requireAuth(chatId, messageId); }
355
+
356
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
357
+
358
+ if (!arg) {
359
+ // 无参数 → 显示模式选择卡片
360
+ const card = buildModeSelectCardDirect(state.permission_mode || 'default');
361
+ await this._replyCard(chatId, messageId, card, 'interactive');
362
+ return true;
363
+ }
364
+
365
+ // 有参数 → 直接切换
366
+ const normalizedMode = MODE_ALIASES[arg.toLowerCase()] || arg;
367
+ if (!VALID_MODES.has(normalizedMode)) {
368
+ await this._reply(chatId, messageId,
369
+ `❌ 不支持的模式 \`${arg}\`\n可用:${[...VALID_MODES].join(', ')}`
370
+ );
371
+ return true;
372
+ }
373
+
374
+ feishuDb.updateSessionState(feishuOpenId, { permission_mode: normalizedMode });
375
+ await this._reply(chatId, messageId,
376
+ `✅ 已切换到 **${MODE_LABELS[normalizedMode]}** (\`${normalizedMode}\`)`
377
+ );
378
+ return true;
379
+ }
380
+
381
+ async _handleDeleteSession(feishuOpenId, chatId, messageId) {
382
+ const binding = feishuDb.getBinding(feishuOpenId);
383
+ if (!binding) { return this._requireAuth(chatId, messageId); }
384
+
385
+ const state = feishuDb.getSessionState(feishuOpenId) || {};
386
+ const cwd = state.cwd;
387
+
388
+ if (!cwd) {
389
+ await this._reply(chatId, messageId, '请先选择项目,发送 /project list 查看可用项目。');
390
+ return true;
391
+ }
392
+
393
+ const projectName = _cwdToProjectName(cwd);
394
+
395
+ let sessions = [];
396
+ try {
397
+ const result = await getSessions(projectName, 20, 0, binding.user_uuid);
398
+ sessions = Array.isArray(result) ? result : (result?.sessions || []);
399
+ } catch (err) {
400
+ console.error('[Feishu:command] getSessions error:', err.message);
401
+ }
402
+
403
+ if (sessions.length === 0) {
404
+ await this._reply(chatId, messageId, '当前项目暂无历史会话。');
405
+ return true;
406
+ }
407
+
408
+ const currentId = state.claude_session_id;
409
+ const sliced = sessions.slice(0, 20);
410
+ const items = sliced.map(s => {
411
+ const isCurrent = s.id === currentId;
412
+ const preview = (s.summary || s.lastUserMessage || '').slice(0, 40);
413
+ return {
414
+ label: `[${s.id?.slice(0, 8)}] ${preview}`,
415
+ sub: isCurrent ? '◀ 当前会话' : '',
416
+ isCurrent,
417
+ };
418
+ });
419
+
420
+ this._storeSessionList(feishuOpenId, sliced);
421
+ const card = buildDeleteListCardDirect({
422
+ title: '🗑️ 删除会话',
423
+ items,
424
+ actionType: 'session',
425
+ });
426
+ await this._replyCard(chatId, messageId, card, 'interactive');
427
+ return true;
428
+ }
429
+
430
+ async _handleStop(feishuOpenId, chatId, messageId) {
431
+ const state = feishuDb.getSessionState(feishuOpenId);
432
+ const sessionId = state?.claude_session_id;
433
+ if (sessionId) {
434
+ const ok = await abortClaudeSDKSession(sessionId);
435
+ await this._reply(chatId, messageId, ok ? '✅ 已中止当前会话' : '当前无正在运行的会话');
436
+ } else {
437
+ await this._reply(chatId, messageId, '当前无活跃会话。');
438
+ }
439
+ return true;
440
+ }
441
+
442
+ async _handleHelp(feishuOpenId, chatId, messageId) {
443
+ const binding = feishuDb.getBinding(feishuOpenId);
444
+ const user = binding ? (userDb.getUserByUuid?.(binding.user_uuid) || null) : null;
445
+ const email = user?.email || user?.username || '未绑定';
446
+ const state = feishuDb.getSessionState(feishuOpenId);
447
+
448
+ await this._replyCard(chatId, messageId, buildHelpCardDirect(email, state), 'interactive');
449
+ return true;
450
+ }
451
+
452
+ // ─── 工具方法 ─────────────────────────────────────────────────────────────────
453
+
454
+ /**
455
+ * 有 messageId 则回复原消息(线程化),否则向 chatId 发新消息。
456
+ * 自动检测 chatId 类型:
457
+ * - 以 "oc_" 开头 → p2p chat_id,用 sendText(receive_id_type: chat_id)
458
+ * - 以 "ou_" 开头 → open_id,用 sendTextToUser(receive_id_type: open_id)
459
+ */
460
+ async _reply(chatId, messageId, text) {
461
+ if (messageId) return this.larkClient.replyText(messageId, text);
462
+ if (!chatId) { console.warn('[Feishu:command] _reply: no messageId and no chatId'); return; }
463
+ if (chatId.startsWith('ou_')) return this.larkClient.sendTextToUser(chatId, text);
464
+ return this.larkClient.sendText(chatId, text);
465
+ }
466
+
467
+ /**
468
+ * 有 messageId 则回复原消息(线程化),否则向 chatId 发新卡片消息。
469
+ * 同样自动检测 chatId 类型(oc_ vs ou_)。
470
+ */
471
+ async _replyCard(chatId, messageId, card, msgType = 'interactive') {
472
+ if (messageId) return this.larkClient.replyMessage(messageId, card, msgType);
473
+ if (!chatId) { console.warn('[Feishu:command] _replyCard: no messageId and no chatId'); return; }
474
+ if (chatId.startsWith('ou_')) return this.larkClient.sendMessageToUser(chatId, card, msgType);
475
+ return this.larkClient.sendMessage(chatId, card, msgType);
476
+ }
477
+
478
+ async _requireAuth(chatId, messageId) {
479
+ await this._reply(chatId, messageId, '请先发送 /auth <token> 完成账号绑定。');
480
+ return true;
481
+ }
482
+ }
483
+
484
+ // 从 cwd 路径推断项目名(Claude projects 目录中的 hash 编码路径名)
485
+ // projects 目录结构: ~/.agenthub/users/<UUID>/.claude/projects/<projectName>/
486
+ // projectName 是对实际路径的编码:将 '/' 替换为 '-'
487
+ function _cwdToProjectName(cwd) {
488
+ if (!cwd) return '';
489
+ // Claude CLI 的编码规则:将路径中所有 '/' 替换为 '-'
490
+ // 例如 /Users/user/myproject → -Users-user-myproject
491
+ return cwd.replace(/\//g, '-');
492
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Feishu DB operations — thin wrapper around feishuDb from the main db module.
3
+ * Re-exported here so feishu service files import from one place.
4
+ */
5
+ import { feishuDb } from '../../database/db.js';
6
+
7
+ export { feishuDb };