cc-im 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.
@@ -0,0 +1,439 @@
1
+ import { saveRuntimeConfig } from '../config.js';
2
+ import { resolveLatestPermission, getPendingCount, listPending } from '../hook/permission-server.js';
3
+ import { TERMINAL_ONLY_COMMANDS } from '../constants.js';
4
+ import { readFileSync } from 'node:fs';
5
+ import { execFile } from 'node:child_process';
6
+ import { join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ /**
9
+ * 共享的命令处理器
10
+ */
11
+ export class CommandHandler {
12
+ deps;
13
+ constructor(deps) {
14
+ this.deps = deps;
15
+ }
16
+ /**
17
+ * 更新运行中的任务数量
18
+ */
19
+ updateRunningTasksSize(size) {
20
+ this.deps.runningTasksSize = size;
21
+ }
22
+ /**
23
+ * 统一命令分发:识别并处理所有命令,返回 true 表示已处理
24
+ */
25
+ async dispatch(text, chatId, userId, platform, handleClaudeRequest, threadCtx) {
26
+ const trimmed = text.trim();
27
+ // 平台特有命令
28
+ if (platform === 'telegram' && trimmed === '/start') {
29
+ await this.deps.sender.sendTextReply(chatId, '欢迎使用 Claude Code Bot!\n\n发送消息与 Claude Code 交互,输入 /help 查看帮助。');
30
+ return true;
31
+ }
32
+ if (platform === 'feishu' && trimmed === '/threads') {
33
+ return this.handleThreads(chatId, userId, threadCtx);
34
+ }
35
+ // 无参数命令
36
+ if (trimmed === '/help')
37
+ return this.handleHelp(chatId, platform, threadCtx);
38
+ if (trimmed === '/new')
39
+ return this.handleNew(chatId, userId, threadCtx);
40
+ if (trimmed === '/pwd')
41
+ return this.handlePwd(chatId, userId, threadCtx);
42
+ if (trimmed === '/list')
43
+ return this.handleList(chatId, userId, threadCtx);
44
+ if (trimmed === '/cost')
45
+ return this.handleCost(chatId, userId, threadCtx);
46
+ if (trimmed === '/status')
47
+ return this.handleStatus(chatId, userId, threadCtx);
48
+ if (trimmed === '/doctor')
49
+ return this.handleDoctor(chatId, userId, threadCtx);
50
+ if (trimmed === '/todos')
51
+ return this.handleTodos(chatId, userId, handleClaudeRequest, threadCtx);
52
+ if (trimmed === '/allow' || trimmed === '/y')
53
+ return this.handleAllow(chatId, threadCtx);
54
+ if (trimmed === '/deny' || trimmed === '/n')
55
+ return this.handleDeny(chatId, threadCtx);
56
+ if (trimmed === '/allowall')
57
+ return this.handleAllowAll(chatId, threadCtx);
58
+ if (trimmed === '/pending')
59
+ return this.handlePending(chatId, threadCtx);
60
+ // 带可选参数的命令
61
+ if (trimmed === '/cd' || trimmed.startsWith('/cd ')) {
62
+ return this.handleCd(chatId, userId, trimmed.slice(3).trim(), threadCtx);
63
+ }
64
+ if (trimmed === '/model' || trimmed.startsWith('/model ')) {
65
+ return this.handleModel(chatId, trimmed.slice(6).trim(), threadCtx);
66
+ }
67
+ if (trimmed === '/compact' || trimmed.startsWith('/compact ')) {
68
+ return this.handleCompact(chatId, userId, trimmed.slice(8).trim(), handleClaudeRequest, threadCtx);
69
+ }
70
+ // 仅终端可用的命令
71
+ const cmdName = trimmed.split(/\s+/)[0];
72
+ if (TERMINAL_ONLY_COMMANDS.has(cmdName)) {
73
+ await this.deps.sender.sendTextReply(chatId, `${cmdName} 命令仅在终端交互模式下可用。\n\n输入 /help 查看可用命令。`, threadCtx);
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * 处理 /help 命令
80
+ */
81
+ async handleHelp(chatId, platform, threadCtx) {
82
+ const startCmd = platform === 'telegram' ? '/start - 显示欢迎信息\n' : '';
83
+ const threadsCmd = platform === 'feishu' ? '/threads - 列出所有话题会话\n' : '';
84
+ const helpText = [
85
+ '📋 可用命令:',
86
+ '',
87
+ startCmd,
88
+ '/help - 显示此帮助信息',
89
+ '/new - 开始新会话',
90
+ '/compact [说明] - 压缩对话上下文(节省 token)',
91
+ '/cost - 显示本次会话费用统计',
92
+ '/status - 显示 Claude Code 状态信息',
93
+ '/model [模型名] - 查看或切换模型',
94
+ '/doctor - 检查 Claude Code 健康状态',
95
+ '/cd <路径> - 切换工作目录',
96
+ '/pwd - 查看当前工作目录',
97
+ '/list - 列出所有工作区',
98
+ '/todos - 列出当前 TODO 项',
99
+ threadsCmd,
100
+ '/allow (/y) - 允许权限请求',
101
+ '/deny (/n) - 拒绝权限请求',
102
+ '/allowall - 批量允许所有待确认权限',
103
+ '/pending - 查看待确认权限列表',
104
+ ].filter(Boolean).join('\n');
105
+ await this.deps.sender.sendTextReply(chatId, helpText, threadCtx);
106
+ return true;
107
+ }
108
+ /**
109
+ * 处理 /new 命令 - 开始新会话
110
+ */
111
+ async handleNew(chatId, userId, threadCtx) {
112
+ if (threadCtx) {
113
+ const success = this.deps.sessionManager.newThreadSession(userId, threadCtx.threadId);
114
+ if (success) {
115
+ await this.deps.sender.sendTextReply(chatId, '✅ 已开始新会话,之前的上下文不会延续。', threadCtx);
116
+ }
117
+ else {
118
+ await this.deps.sender.sendTextReply(chatId, '当前话题没有活动会话。', threadCtx);
119
+ }
120
+ }
121
+ else {
122
+ const created = this.deps.sessionManager.newSession(userId);
123
+ if (created) {
124
+ await this.deps.sender.sendTextReply(chatId, '✅ 已开始新会话,之前的上下文不会延续。', threadCtx);
125
+ }
126
+ else {
127
+ await this.deps.sender.sendTextReply(chatId, '当前没有活动会话。', threadCtx);
128
+ }
129
+ }
130
+ return true;
131
+ }
132
+ /**
133
+ * 处理 /cd 命令
134
+ */
135
+ async handleCd(chatId, userId, args, threadCtx) {
136
+ const dir = args.trim();
137
+ if (!dir) {
138
+ const workDir = threadCtx
139
+ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
140
+ : this.deps.sessionManager.getWorkDir(userId);
141
+ await this.deps.sender.sendTextReply(chatId, `当前工作目录: ${workDir}`, threadCtx);
142
+ return true;
143
+ }
144
+ try {
145
+ const resolved = threadCtx
146
+ ? await this.deps.sessionManager.setWorkDirForThread(userId, threadCtx.threadId, dir, threadCtx.rootMessageId)
147
+ : await this.deps.sessionManager.setWorkDir(userId, dir);
148
+ await this.deps.sender.sendTextReply(chatId, `工作目录已切换到: ${resolved}\n会话已重置。`, threadCtx);
149
+ }
150
+ catch (err) {
151
+ const message = err instanceof Error ? err.message : String(err);
152
+ await this.deps.sender.sendTextReply(chatId, message, threadCtx);
153
+ }
154
+ return true;
155
+ }
156
+ /**
157
+ * 处理 /pwd 命令
158
+ */
159
+ async handlePwd(chatId, userId, threadCtx) {
160
+ const workDir = threadCtx
161
+ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
162
+ : this.deps.sessionManager.getWorkDir(userId);
163
+ await this.deps.sender.sendTextReply(chatId, `当前工作目录: ${workDir}`, threadCtx);
164
+ return true;
165
+ }
166
+ /**
167
+ * 处理 /list 命令
168
+ */
169
+ async handleList(chatId, userId, threadCtx) {
170
+ const dirs = this.listClaudeProjects();
171
+ if (dirs.length === 0) {
172
+ await this.deps.sender.sendTextReply(chatId, '未找到 Claude Code 工作区记录。', threadCtx);
173
+ }
174
+ else {
175
+ const current = threadCtx
176
+ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
177
+ : this.deps.sessionManager.getWorkDir(userId);
178
+ const lines = dirs.map((d) => (d === current ? `▶ ${d}` : ` ${d}`));
179
+ await this.deps.sender.sendTextReply(chatId, `Claude Code 工作区列表:\n${lines.join('\n')}\n\n使用 /cd <路径> 切换`, threadCtx);
180
+ }
181
+ return true;
182
+ }
183
+ /**
184
+ * 处理 /cost 命令
185
+ */
186
+ async handleCost(chatId, userId, threadCtx) {
187
+ const record = this.deps.userCosts.get(userId);
188
+ if (!record || record.requestCount === 0) {
189
+ await this.deps.sender.sendTextReply(chatId, '暂无费用记录(本次服务启动后)。', threadCtx);
190
+ }
191
+ else {
192
+ const lines = [
193
+ '💰 费用统计(本次服务启动后):',
194
+ '',
195
+ `请求次数: ${record.requestCount}`,
196
+ `总费用: $${record.totalCost.toFixed(4)}`,
197
+ `总耗时: ${(record.totalDurationMs / 1000).toFixed(1)}s`,
198
+ `平均每次: $${(record.totalCost / record.requestCount).toFixed(4)}`,
199
+ ];
200
+ await this.deps.sender.sendTextReply(chatId, lines.join('\n'), threadCtx);
201
+ }
202
+ return true;
203
+ }
204
+ /**
205
+ * 处理 /status 命令
206
+ */
207
+ async handleStatus(chatId, userId, threadCtx) {
208
+ const version = await this.getClaudeVersion();
209
+ const workDir = threadCtx
210
+ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
211
+ : this.deps.sessionManager.getWorkDir(userId);
212
+ const sessionId = threadCtx
213
+ ? this.deps.sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
214
+ : this.deps.sessionManager.getSessionIdForConv(userId, this.deps.sessionManager.getConvId(userId));
215
+ const record = this.deps.userCosts.get(userId);
216
+ const lines = [
217
+ '📊 Claude Code 状态:',
218
+ '',
219
+ `版本: ${version}`,
220
+ `工作目录: ${workDir}`,
221
+ `会话 ID: ${sessionId ?? '(无)'}`,
222
+ `跳过权限: ${this.deps.config.claudeSkipPermissions ? '是' : '否'}`,
223
+ `超时设置: ${this.deps.config.claudeTimeoutMs / 1000}s`,
224
+ `累计费用: $${record?.totalCost.toFixed(4) ?? '0.0000'}`,
225
+ ];
226
+ await this.deps.sender.sendTextReply(chatId, lines.join('\n'), threadCtx);
227
+ return true;
228
+ }
229
+ /**
230
+ * 验证模型名称是否合法
231
+ */
232
+ isValidModelName(name) {
233
+ // 允许字母、数字、点、连字符、斜杠,最长 100 字符
234
+ // 不允许连续斜杠、开头/结尾斜杠
235
+ if (!/^[a-zA-Z0-9.\-/]{1,100}$/.test(name))
236
+ return false;
237
+ if (name.includes('//'))
238
+ return false;
239
+ if (name.startsWith('/') || name.endsWith('/'))
240
+ return false;
241
+ return true;
242
+ }
243
+ /**
244
+ * 处理 /model 命令
245
+ */
246
+ async handleModel(chatId, args, threadCtx) {
247
+ const modelArg = args.trim();
248
+ if (!modelArg) {
249
+ await this.deps.sender.sendTextReply(chatId, `当前模型: ${this.deps.config.claudeModel ?? '默认 (由 Claude Code 决定)'}\n\n可选模型: sonnet, opus, haiku 或完整模型名\n用法: /model <模型名>`, threadCtx);
250
+ }
251
+ else {
252
+ if (!this.isValidModelName(modelArg)) {
253
+ await this.deps.sender.sendTextReply(chatId, '❌ 无效的模型名称。模型名只能包含字母、数字、点、连字符和斜杠,且长度不超过 100 字符。', threadCtx);
254
+ return true;
255
+ }
256
+ this.deps.config.claudeModel = modelArg;
257
+ saveRuntimeConfig(this.deps.config);
258
+ await this.deps.sender.sendTextReply(chatId, `模型已切换为: ${modelArg}\n后续对话将使用此模型。`, threadCtx);
259
+ }
260
+ return true;
261
+ }
262
+ /**
263
+ * 处理 /doctor 命令
264
+ */
265
+ async handleDoctor(chatId, userId, threadCtx) {
266
+ const version = await this.getClaudeVersion();
267
+ const workDir = threadCtx
268
+ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
269
+ : this.deps.sessionManager.getWorkDir(userId);
270
+ const lines = [
271
+ '🏥 Claude Code 健康检查:',
272
+ '',
273
+ `CLI 路径: ${this.deps.config.claudeCliPath}`,
274
+ `版本: ${version}`,
275
+ `工作目录: ${workDir}`,
276
+ `允许的基础目录: ${this.deps.config.allowedBaseDirs.join(', ')}`,
277
+ `活跃任务数: ${this.deps.runningTasksSize}`,
278
+ ];
279
+ await this.deps.sender.sendTextReply(chatId, lines.join('\n'), threadCtx);
280
+ return true;
281
+ }
282
+ /**
283
+ * 处理 /compact 命令
284
+ */
285
+ async handleCompact(chatId, userId, args, handleClaudeRequest, threadCtx) {
286
+ const sessionId = threadCtx
287
+ ? this.deps.sessionManager.getSessionIdForThread(userId, threadCtx.threadId)
288
+ : this.deps.sessionManager.getSessionIdForConv(userId, this.deps.sessionManager.getConvId(userId));
289
+ if (!sessionId) {
290
+ await this.deps.sender.sendTextReply(chatId, '当前没有活动会话,无需压缩。', threadCtx);
291
+ return true;
292
+ }
293
+ const instructions = args.trim();
294
+ const compactPrompt = instructions
295
+ ? `请压缩并总结之前的对话上下文,聚焦于: ${instructions}`
296
+ : '请压缩并总结之前的对话上下文,保留关键信息。';
297
+ const workDir = threadCtx
298
+ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
299
+ : this.deps.sessionManager.getWorkDir(userId);
300
+ const queueKey = threadCtx ? threadCtx.threadId : this.deps.sessionManager.getConvId(userId);
301
+ const enqueueResult = this.deps.requestQueue.enqueue(userId, queueKey, compactPrompt, async (prompt) => {
302
+ await handleClaudeRequest(this.deps.config, this.deps.sessionManager, userId, chatId, prompt, workDir, undefined, threadCtx);
303
+ });
304
+ if (enqueueResult === 'rejected') {
305
+ await this.deps.sender.sendTextReply(chatId, '请求队列已满,请等待当前任务完成后再试。', threadCtx);
306
+ }
307
+ else if (enqueueResult === 'queued') {
308
+ await this.deps.sender.sendTextReply(chatId, '前面还有任务在处理中,压缩请求已排队等待。', threadCtx);
309
+ }
310
+ return true;
311
+ }
312
+ /**
313
+ * 处理 /todos 命令
314
+ */
315
+ async handleTodos(chatId, userId, handleClaudeRequest, threadCtx) {
316
+ const workDir = threadCtx
317
+ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId)
318
+ : this.deps.sessionManager.getWorkDir(userId);
319
+ const queueKey = threadCtx ? threadCtx.threadId : this.deps.sessionManager.getConvId(userId);
320
+ const todosPrompt = '请列出当前项目中所有的 TODO 项(检查代码中的 TODO、FIXME、HACK 注释)。';
321
+ const enqueueResult = this.deps.requestQueue.enqueue(userId, queueKey, todosPrompt, async (prompt) => {
322
+ await handleClaudeRequest(this.deps.config, this.deps.sessionManager, userId, chatId, prompt, workDir, undefined, threadCtx);
323
+ });
324
+ if (enqueueResult === 'rejected') {
325
+ await this.deps.sender.sendTextReply(chatId, '请求队列已满,请等待当前任务完成后再试。', threadCtx);
326
+ }
327
+ else if (enqueueResult === 'queued') {
328
+ await this.deps.sender.sendTextReply(chatId, '前面还有任务在处理中,请求已排队等待。', threadCtx);
329
+ }
330
+ return true;
331
+ }
332
+ /**
333
+ * 处理 /allow 或 /y 命令
334
+ */
335
+ async handleAllow(chatId, threadCtx) {
336
+ const reqId = resolveLatestPermission(chatId, 'allow');
337
+ if (reqId) {
338
+ const remaining = getPendingCount(chatId);
339
+ await this.deps.sender.sendTextReply(chatId, `✅ 权限已允许${remaining > 0 ? `(还有 ${remaining} 个待确认)` : ''}`, threadCtx);
340
+ }
341
+ else {
342
+ await this.deps.sender.sendTextReply(chatId, 'ℹ️ 没有待确认的权限请求', threadCtx);
343
+ }
344
+ return true;
345
+ }
346
+ /**
347
+ * 处理 /deny 或 /n 命令
348
+ */
349
+ async handleDeny(chatId, threadCtx) {
350
+ const reqId = resolveLatestPermission(chatId, 'deny');
351
+ if (reqId) {
352
+ const remaining = getPendingCount(chatId);
353
+ await this.deps.sender.sendTextReply(chatId, `❌ 权限已拒绝${remaining > 0 ? `(还有 ${remaining} 个待确认)` : ''}`, threadCtx);
354
+ }
355
+ else {
356
+ await this.deps.sender.sendTextReply(chatId, 'ℹ️ 没有待确认的权限请求', threadCtx);
357
+ }
358
+ return true;
359
+ }
360
+ /**
361
+ * 处理 /allowall 命令
362
+ */
363
+ async handleAllowAll(chatId, threadCtx) {
364
+ let count = 0;
365
+ while (resolveLatestPermission(chatId, 'allow')) {
366
+ count++;
367
+ }
368
+ if (count > 0) {
369
+ await this.deps.sender.sendTextReply(chatId, `✅ 已批量允许 ${count} 个权限请求`, threadCtx);
370
+ }
371
+ else {
372
+ await this.deps.sender.sendTextReply(chatId, 'ℹ️ 没有待确认的权限请求', threadCtx);
373
+ }
374
+ return true;
375
+ }
376
+ /**
377
+ * 处理 /pending 命令
378
+ */
379
+ async handlePending(chatId, threadCtx) {
380
+ const pending = listPending(chatId);
381
+ if (pending.length === 0) {
382
+ await this.deps.sender.sendTextReply(chatId, 'ℹ️ 没有待确认的权限请求', threadCtx);
383
+ }
384
+ else {
385
+ const list = pending.map((p, i) => `${i + 1}. ${p.toolName} (ID: ${p.id})`).join('\n');
386
+ await this.deps.sender.sendTextReply(chatId, `🔐 待确认权限列表:\n\n${list}\n\n使用 /allow 允许最早的请求`, threadCtx);
387
+ }
388
+ return true;
389
+ }
390
+ /**
391
+ * 处理 /threads 命令 - 列出所有话题会话
392
+ */
393
+ async handleThreads(chatId, userId, threadCtx) {
394
+ const threads = this.deps.sessionManager.listThreads(userId);
395
+ if (threads.length === 0) {
396
+ await this.deps.sender.sendTextReply(chatId, '暂无话题会话记录。', threadCtx);
397
+ }
398
+ else {
399
+ const lines = threads.map((t, i) => {
400
+ const sessionStatus = t.sessionId ? '✓' : '✗';
401
+ const displayName = t.displayName || t.threadId.slice(-8);
402
+ return `${i + 1}. ${displayName} [${sessionStatus}] - ${t.workDir}`;
403
+ });
404
+ await this.deps.sender.sendTextReply(chatId, `📋 话题会话列表 (${threads.length}):\n\n${lines.join('\n')}\n\n✓ = 有活跃会话 | ✗ = 无会话`, threadCtx);
405
+ }
406
+ return true;
407
+ }
408
+ /**
409
+ * 列出 Claude 项目
410
+ */
411
+ listClaudeProjects() {
412
+ const configPath = join(homedir(), '.claude.json');
413
+ try {
414
+ const data = JSON.parse(readFileSync(configPath, 'utf-8'));
415
+ const projects = data.projects ?? {};
416
+ return Object.keys(projects)
417
+ .filter((dir) => this.deps.config.allowedBaseDirs.some((base) => dir === base || dir.startsWith(base + '/')))
418
+ .sort();
419
+ }
420
+ catch {
421
+ return [];
422
+ }
423
+ }
424
+ /**
425
+ * 获取 Claude 版本
426
+ */
427
+ getClaudeVersion() {
428
+ return new Promise((resolve) => {
429
+ execFile(this.deps.config.claudeCliPath, ['--version'], { timeout: 5000 }, (err, stdout) => {
430
+ if (err) {
431
+ resolve('未知');
432
+ }
433
+ else {
434
+ resolve(stdout.trim() || '未知');
435
+ }
436
+ });
437
+ });
438
+ }
439
+ }
package/dist/config.js ADDED
@@ -0,0 +1,151 @@
1
+ import 'dotenv/config';
2
+ import { readFileSync, writeFileSync, mkdirSync, accessSync, constants } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { join, isAbsolute } from 'node:path';
5
+ import { createLogger } from './logger.js';
6
+ import { APP_HOME } from './constants.js';
7
+ const logger = createLogger('Config');
8
+ function loadFileConfig() {
9
+ const configPath = join(APP_HOME, 'config.json');
10
+ try {
11
+ const content = readFileSync(configPath, 'utf-8');
12
+ logger.debug(`Loaded configuration from ${configPath}`);
13
+ return JSON.parse(content);
14
+ }
15
+ catch (err) {
16
+ if (err instanceof SyntaxError) {
17
+ logger.warn(`警告: 配置文件 ${configPath} 格式错误,将使用环境变量`);
18
+ logger.warn(`错误详情: ${err.message}`);
19
+ }
20
+ else {
21
+ const error = err;
22
+ if (error.code !== 'ENOENT') {
23
+ logger.warn(`警告: 无法读取配置文件 ${configPath}: ${error.message}`);
24
+ }
25
+ }
26
+ return {};
27
+ }
28
+ }
29
+ function parseCommaSeparated(value) {
30
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
31
+ }
32
+ function detectPlatforms(file) {
33
+ const platforms = [];
34
+ // 检测 Telegram
35
+ const telegramToken = process.env.TELEGRAM_BOT_TOKEN ?? file.telegramBotToken;
36
+ if (telegramToken) {
37
+ platforms.push('telegram');
38
+ }
39
+ // 检测飞书
40
+ const feishuAppId = process.env.FEISHU_APP_ID ?? file.feishuAppId;
41
+ const feishuAppSecret = process.env.FEISHU_APP_SECRET ?? file.feishuAppSecret;
42
+ if (feishuAppId && feishuAppSecret) {
43
+ platforms.push('feishu');
44
+ }
45
+ // 如果都没配置,抛出错误
46
+ if (platforms.length === 0) {
47
+ throw new Error('至少需要配置一个平台:\n' +
48
+ ' Telegram: 设置 TELEGRAM_BOT_TOKEN\n' +
49
+ ' 飞书: 设置 FEISHU_APP_ID 和 FEISHU_APP_SECRET');
50
+ }
51
+ return platforms;
52
+ }
53
+ export function loadConfig() {
54
+ const file = loadFileConfig();
55
+ const enabledPlatforms = detectPlatforms(file);
56
+ // 飞书配置
57
+ const appId = process.env.FEISHU_APP_ID ?? file.feishuAppId ?? '';
58
+ const appSecret = process.env.FEISHU_APP_SECRET ?? file.feishuAppSecret ?? '';
59
+ // Telegram 配置
60
+ const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN ?? file.telegramBotToken ?? '';
61
+ const allowedUserIds = process.env.ALLOWED_USER_IDS !== undefined
62
+ ? parseCommaSeparated(process.env.ALLOWED_USER_IDS)
63
+ : file.allowedUserIds ?? [];
64
+ const claudeCliPath = process.env.CLAUDE_CLI_PATH ?? file.claudeCliPath ?? 'claude';
65
+ const claudeWorkDir = process.env.CLAUDE_WORK_DIR ?? file.claudeWorkDir ?? process.cwd();
66
+ const allowedBaseDirs = process.env.ALLOWED_BASE_DIRS !== undefined
67
+ ? parseCommaSeparated(process.env.ALLOWED_BASE_DIRS)
68
+ : file.allowedBaseDirs ?? [];
69
+ if (allowedBaseDirs.length === 0) {
70
+ allowedBaseDirs.push(claudeWorkDir);
71
+ }
72
+ const claudeSkipPermissions = process.env.CLAUDE_SKIP_PERMISSIONS !== undefined
73
+ ? process.env.CLAUDE_SKIP_PERMISSIONS === 'true'
74
+ : file.claudeSkipPermissions ?? false;
75
+ const claudeTimeoutMs = process.env.CLAUDE_TIMEOUT_MS !== undefined
76
+ ? parseInt(process.env.CLAUDE_TIMEOUT_MS, 10) || 300000
77
+ : file.claudeTimeoutMs ?? 300000;
78
+ // 验证 Claude CLI 路径
79
+ if (isAbsolute(claudeCliPath) || claudeCliPath.includes('/')) {
80
+ // 绝对路径或包含目录分隔符:直接用 accessSync 验证
81
+ try {
82
+ accessSync(claudeCliPath, constants.F_OK | constants.X_OK);
83
+ }
84
+ catch (err) {
85
+ throw new Error(`Claude CLI 不可访问或不可执行: ${claudeCliPath}\n` +
86
+ `请检查:\n` +
87
+ ` 1. 文件是否存在\n` +
88
+ ` 2. 是否有执行权限\n` +
89
+ ` 3. CLAUDE_CLI_PATH 环境变量或 ${APP_HOME} 配置是否正确`);
90
+ }
91
+ }
92
+ else {
93
+ // 裸命令名(如 "claude"):在 PATH 中查找
94
+ try {
95
+ execFileSync('which', [claudeCliPath], { stdio: 'pipe' });
96
+ }
97
+ catch (err) {
98
+ throw new Error(`Claude CLI 在 PATH 中未找到: ${claudeCliPath}\n` +
99
+ `请检查:\n` +
100
+ ` 1. 是否已安装 Claude CLI\n` +
101
+ ` 2. 命令是否在 PATH 环境变量中\n` +
102
+ ` 3. 或通过 CLAUDE_CLI_PATH 指定完整路径`);
103
+ }
104
+ }
105
+ const hookPort = process.env.HOOK_SERVER_PORT !== undefined
106
+ ? parseInt(process.env.HOOK_SERVER_PORT, 10) || 18900
107
+ : 18900;
108
+ const logDir = process.env.LOG_DIR ?? file.logDir ?? join(APP_HOME, 'logs');
109
+ return {
110
+ enabledPlatforms,
111
+ feishuAppId: appId,
112
+ feishuAppSecret: appSecret,
113
+ telegramBotToken,
114
+ allowedUserIds,
115
+ claudeCliPath,
116
+ claudeWorkDir,
117
+ allowedBaseDirs,
118
+ claudeSkipPermissions,
119
+ claudeTimeoutMs,
120
+ claudeModel: file.claudeModel,
121
+ hookPort,
122
+ logDir,
123
+ };
124
+ }
125
+ /**
126
+ * 将运行时可变配置(如 claudeModel)持久化到配置文件
127
+ */
128
+ export function saveRuntimeConfig(config) {
129
+ const configPath = join(APP_HOME, 'config.json');
130
+ try {
131
+ let existing = {};
132
+ try {
133
+ existing = JSON.parse(readFileSync(configPath, 'utf-8'));
134
+ }
135
+ catch {
136
+ // 文件不存在或格式错误,从空对象开始
137
+ }
138
+ if (config.claudeModel) {
139
+ existing.claudeModel = config.claudeModel;
140
+ }
141
+ else {
142
+ delete existing.claudeModel;
143
+ }
144
+ mkdirSync(APP_HOME, { recursive: true });
145
+ writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
146
+ logger.debug('Runtime config saved');
147
+ }
148
+ catch (err) {
149
+ logger.warn('Failed to save runtime config:', err);
150
+ }
151
+ }
@@ -0,0 +1,94 @@
1
+ import { join } from 'node:path';
2
+ import { homedir, tmpdir } from 'node:os';
3
+ /**
4
+ * 系统级常量定义
5
+ */
6
+ /**
7
+ * 应用数据根目录 ~/.cc-im
8
+ */
9
+ export const APP_HOME = join(homedir(), '.cc-im');
10
+ export const IMAGE_DIR = join(tmpdir(), 'cc-im-images');
11
+ /**
12
+ * 只读工具列表 - 这些工具不需要权限确认
13
+ * 用于 Hook Script 中判断是否需要请求用户授权
14
+ */
15
+ export const READ_ONLY_TOOLS = [
16
+ 'Read',
17
+ 'Glob',
18
+ 'Grep',
19
+ 'WebFetch',
20
+ 'WebSearch',
21
+ 'Task',
22
+ 'TodoRead',
23
+ ];
24
+ /**
25
+ * 仅终端可用的命令集合
26
+ * 这些命令只能在 Claude Code CLI 终端交互模式下使用
27
+ * 在飞书/Telegram 等消息平台中不可用
28
+ */
29
+ export const TERMINAL_ONLY_COMMANDS = new Set([
30
+ '/context',
31
+ '/rewind',
32
+ '/resume',
33
+ '/copy',
34
+ '/export',
35
+ '/config',
36
+ '/init',
37
+ '/memory',
38
+ '/permissions',
39
+ '/theme',
40
+ '/vim',
41
+ '/statusline',
42
+ '/terminal-setup',
43
+ '/debug',
44
+ '/tasks',
45
+ '/mcp',
46
+ '/teleport',
47
+ '/add-dir',
48
+ ]);
49
+ /**
50
+ * 消息去重 TTL(毫秒)
51
+ * 用于防止重复处理同一消息
52
+ */
53
+ export const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes
54
+ /**
55
+ * 消息更新节流时间(毫秒)
56
+ * Telegram 使用 editMessageText,限频较严
57
+ */
58
+ export const THROTTLE_MS = 200;
59
+ /**
60
+ * CardKit 流式更新节流时间(毫秒)
61
+ * cardElement.content 专为流式设计,支持更高频率
62
+ */
63
+ export const CARDKIT_THROTTLE_MS = 80;
64
+ /**
65
+ * 飞书卡片最大内容长度(JSON 1.0 / im.v1.message.patch)
66
+ */
67
+ export const MAX_CARD_CONTENT_LENGTH = 3800;
68
+ /**
69
+ * CardKit 流式内容最大长度(CardKit 卡片上限 30KB,留余量)
70
+ */
71
+ export const MAX_STREAMING_CONTENT_LENGTH = 25000;
72
+ /**
73
+ * Telegram 消息最大长度
74
+ */
75
+ export const MAX_TELEGRAM_MESSAGE_LENGTH = 4000; // Telegram 限制 4096,留一些余地
76
+ /**
77
+ * 权限请求超时时间(毫秒)
78
+ */
79
+ export const PERMISSION_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
80
+ /**
81
+ * 权限请求体最大大小(字节)
82
+ */
83
+ export const MAX_BODY_SIZE = 1024 * 1024; // 1MB
84
+ /**
85
+ * Hook Script 退出码
86
+ */
87
+ export const HOOK_EXIT_CODES = {
88
+ /** 成功(允许或自动放行) */
89
+ SUCCESS: 0,
90
+ /** 一般错误 */
91
+ ERROR: 1,
92
+ /** 权限服务器不可达 */
93
+ PERMISSION_SERVER_ERROR: 2,
94
+ };