@vrs-soft/wecom-aibot-mcp 1.2.1 → 1.3.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.
package/README.md CHANGED
@@ -44,7 +44,7 @@ PermissionRequest Hook 拦截
44
44
 
45
45
  ┌────────────────────────┐
46
46
  │ 检查 headless 模式状态 │
47
- │ (检查 .claude/wecom-aibot.json)
47
+ │ (检查 .claude/headless.json)
48
48
  └────────────────────────┘
49
49
 
50
50
  ┌───────┴───────┐
package/dist/bin.js CHANGED
@@ -15,9 +15,7 @@ import * as path from 'path';
15
15
  import * as os from 'os';
16
16
  import { runConfigWizard, loadConfig, saveConfig, deleteConfig, deleteMcpConfigInteractive, uninstall, addMcpConfig, detectUserIdFromMessage, ensureHookInstalled, listAllRobots, } from './config-wizard.js';
17
17
  import { initClient } from './client.js';
18
- import { registerTools } from './tools/index.js';
19
18
  import { startHttpServer, stopHttpServer, HTTP_PORT } from './http-server.js';
20
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
19
  import { getAllConnectionStates } from './connection-manager.js';
22
20
  import { loadStats, cleanupOldLogs } from './connection-log.js';
23
21
  import { startKeepaliveMonitor, stopKeepaliveMonitor } from './keepalive-monitor.js';
@@ -86,11 +84,11 @@ function showStatus() {
86
84
  console.log('尚未配置机器人,请运行 npx @vrs-soft/wecom-aibot-mcp 启动配置向导');
87
85
  return;
88
86
  }
89
- // 构建机器人占用信息(v3.0: ccId 由 cc-registry 管理,连接状态不再含 ccId)
87
+ // 构建机器人占用信息
90
88
  const robotUsage = new Map();
91
89
  for (const conn of connections) {
92
- if (conn.connected) {
93
- robotUsage.set(conn.robotName, { ccId: '' });
90
+ if (conn.agentName) {
91
+ robotUsage.set(conn.robotName, { agentName: conn.agentName });
94
92
  }
95
93
  }
96
94
  console.log(`已配置 ${allRobots.length} 个机器人:\n`);
@@ -101,7 +99,7 @@ function showStatus() {
101
99
  console.log(` Bot ID: ${robot.botId}`);
102
100
  console.log(` 目标用户: ${robot.targetUserId}`);
103
101
  if (usage) {
104
- console.log(` 使用者: ${usage.ccId}`);
102
+ console.log(` 使用者: ${usage.agentName}`);
105
103
  }
106
104
  console.log('');
107
105
  }
@@ -174,13 +172,11 @@ async function waitForConnection(client, timeoutMs = 10000) {
174
172
  }
175
173
  // 启动 MCP Server(前台运行,供 --start 使用)
176
174
  async function startMcpServerForeground() {
177
- // 检查是否有任何机器人配置(支持 config.json 和 robot-*.json)
178
- const allRobots = listAllRobots();
179
- if (allRobots.length === 0) {
175
+ const savedConfig = loadConfig();
176
+ if (!savedConfig || !savedConfig.botId || !savedConfig.secret || !savedConfig.targetUserId) {
180
177
  console.error('[mcp] 未找到配置,请先运行: npx @vrs-soft/wecom-aibot-mcp');
181
178
  process.exit(1);
182
179
  }
183
- console.log(`[mcp] 发现 ${allRobots.length} 个机器人配置: ${allRobots.map(r => r.name).join(', ')}`);
184
180
  // 写入 PID 文件
185
181
  fs.writeFileSync(PID_FILE, String(process.pid));
186
182
  // 确保 hook 已安装
@@ -188,12 +184,6 @@ async function startMcpServerForeground() {
188
184
  // 加载统计并清理旧日志
189
185
  loadStats();
190
186
  cleanupOldLogs(1 / 24);
191
- // 创建 MCP Server
192
- const server = new McpServer({
193
- name: 'wecom-aibot-mcp',
194
- version: VERSION,
195
- });
196
- registerTools(server);
197
187
  // 启动 HTTP 服务
198
188
  console.log('');
199
189
  console.log(' ╔════════════════════════════════════════════════════════╗');
@@ -202,7 +192,7 @@ async function startMcpServerForeground() {
202
192
  console.log(' ╚════════════════════════════════════════════════════════╝');
203
193
  console.log('');
204
194
  console.log(`[mcp] 启动 MCP HTTP Server (端口: ${HTTP_PORT})...`);
205
- await startHttpServer(server);
195
+ await startHttpServer();
206
196
  startKeepaliveMonitor();
207
197
  console.log(`[mcp] MCP Server 已就绪`);
208
198
  console.log(`[mcp] HTTP endpoint: http://127.0.0.1:${HTTP_PORT}/mcp`);
@@ -224,9 +214,9 @@ async function startMcpServerForeground() {
224
214
  }
225
215
  // 后台启动 MCP Server(使用 spawn)
226
216
  function startMcpServerBackground() {
227
- // 检查是否有任何机器人配置
228
- const allRobots = listAllRobots();
229
- if (allRobots.length === 0) {
217
+ // 检查配置是否存在
218
+ const savedConfig = loadConfig();
219
+ if (!savedConfig || !savedConfig.botId || !savedConfig.secret || !savedConfig.targetUserId) {
230
220
  console.error('[mcp] 未找到配置,请先运行: npx @vrs-soft/wecom-aibot-mcp');
231
221
  process.exit(1);
232
222
  }
@@ -10,7 +10,9 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import { atomicWriteFileSync } from './utils/atomic-write.js';
13
- const EXPIRY_MS = 14 * 24 * 60 * 60 * 1000;
13
+ import { logWarn } from './connection-log.js';
14
+ const WARNING_MS = 10 * 24 * 60 * 60 * 1000; // 10 天:过期前警告
15
+ const EXPIRY_MS = 14 * 24 * 60 * 60 * 1000; // 14 天:清理
14
16
  // 心跳检测常量
15
17
  const OFFLINE_THRESHOLD = 10 * 60 * 1000; // 10 分钟无心跳视为离线
16
18
  const NOTIFICATION_INTERVAL = 30 * 60 * 1000; // 30 分钟最多通知一次
@@ -88,11 +90,15 @@ function pruneExpired(registry) {
88
90
  const now = Date.now();
89
91
  let changed = false;
90
92
  for (const [ccId, entry] of Object.entries(registry)) {
91
- if (now - entry.lastActive > EXPIRY_MS) {
92
- console.log(`[cc-registry] 清理过期条目: ccId=${ccId}, robot=${entry.robotName}`);
93
+ const inactive = now - entry.lastActive;
94
+ if (inactive > EXPIRY_MS) {
95
+ logWarn(`[cc-registry] 已清理过期 ccId "${ccId}" (robot: ${entry.robotName}),超过 14 天未活跃`);
93
96
  delete registry[ccId];
94
97
  changed = true;
95
98
  }
99
+ else if (inactive > WARNING_MS) {
100
+ logWarn(`[cc-registry] 警告:ccId "${ccId}" (robot: ${entry.robotName}) 已 10 天未活跃,4 天后将自动清理`);
101
+ }
96
102
  }
97
103
  return changed;
98
104
  }
@@ -230,7 +236,8 @@ async function sendOfflineNotification(ccId, robotName) {
230
236
  console.log(`[heartbeat] 机器人 ${robotName} 未连接,无法发送离线通知`);
231
237
  return;
232
238
  }
233
- const inactiveMinutes = Math.floor((Date.now() - getRegistry()[ccId]?.lastActive || 0) / 60000);
239
+ const registryEntry = getRegistry()[ccId];
240
+ const inactiveMinutes = registryEntry ? Math.floor((Date.now() - registryEntry.lastActive) / 60000) : 0;
234
241
  const message = `【系统警告】CC "${ccId}" 已超过 ${inactiveMinutes} 分钟无心跳,可能已离线。
235
242
 
236
243
  可能原因:
@@ -252,13 +259,12 @@ async function sendOfflineNotification(ccId, robotName) {
252
259
  export function startHeartbeatMonitor() {
253
260
  if (heartbeatInterval)
254
261
  return;
255
- // 测试期间改为 1 分钟,正式环境改为 5 * 60 * 1000
256
262
  heartbeatInterval = setInterval(() => {
257
263
  checkCcHeartbeat().catch(err => {
258
264
  console.error('[heartbeat] 心跳检测错误:', err);
259
265
  });
260
- }, 1 * 60 * 1000);
261
- console.log('[heartbeat] 心跳检测已启动(测试模式:1 分钟周期)');
266
+ }, 5 * 60 * 1000);
267
+ console.log('[heartbeat] 心跳检测已启动(5 分钟周期)');
262
268
  }
263
269
  /**
264
270
  * 停止心跳检测
package/dist/client.d.ts CHANGED
@@ -22,6 +22,7 @@ interface MessageRecord {
22
22
  from_userid: string;
23
23
  chatid: string;
24
24
  chattype: 'single' | 'group';
25
+ quoteContent?: string;
25
26
  }
26
27
  declare class WecomClient extends EventEmitter {
27
28
  private wsClient;
@@ -90,5 +91,7 @@ declare class WecomClient extends EventEmitter {
90
91
  };
91
92
  }
92
93
  export declare function initClient(botId: string, secret: string, targetUserId: string, robotName: string): WecomClient;
93
- export declare function getClient(): WecomClient;
94
+ export declare function getClient(robotName?: string): WecomClient;
95
+ export declare function getAllClients(): Map<string, WecomClient>;
96
+ export declare function disconnectClient(robotName: string): boolean;
94
97
  export { WecomClient, ApprovalRecord, MessageRecord, MAX_PENDING_MESSAGES };
package/dist/client.js CHANGED
@@ -53,14 +53,14 @@ class WecomClient extends EventEmitter {
53
53
  }
54
54
  setupEventHandlers() {
55
55
  this.wsClient.on('connected', () => {
56
- logConnected(this.robotName);
56
+ logConnected();
57
57
  });
58
58
  this.wsClient.on('authenticated', () => {
59
59
  const wasReconnecting = this.wasReconnecting;
60
60
  this.connected = true;
61
61
  this.wasReconnecting = false;
62
62
  this.reconnectAttempt = 0;
63
- logAuthenticated(this.robotName);
63
+ logAuthenticated();
64
64
  // 重连成功后发送通知
65
65
  if (wasReconnecting) {
66
66
  this.sendText('【系统】连接已恢复').catch(err => {
@@ -74,7 +74,7 @@ class WecomClient extends EventEmitter {
74
74
  this.connected = false;
75
75
  this.wasReconnecting = true;
76
76
  this.lastDisconnectTime = Date.now();
77
- logDisconnected(this.robotName, reason);
77
+ logDisconnected(reason);
78
78
  // 发送断线通知
79
79
  this.sendText('【系统】连接中断,正在重连...').catch(err => {
80
80
  console.error('[wecom] 发送断线通知失败:', err);
@@ -82,10 +82,10 @@ class WecomClient extends EventEmitter {
82
82
  });
83
83
  this.wsClient.on('reconnecting', (attempt) => {
84
84
  this.reconnectAttempt = attempt;
85
- logReconnecting(this.robotName, attempt);
85
+ logReconnecting(attempt);
86
86
  });
87
87
  this.wsClient.on('error', (err) => {
88
- logError(this.robotName, err.message);
88
+ logError(err.message);
89
89
  // 检测授权相关错误(40058: invalid Request Parameter)
90
90
  if (err.message.includes('40058') || err.message.includes('invalid Request Parameter')) {
91
91
  console.log('');
@@ -159,6 +159,7 @@ class WecomClient extends EventEmitter {
159
159
  from_userid,
160
160
  chatid,
161
161
  chattype,
162
+ quoteContent,
162
163
  };
163
164
  // v3.0: 检查队列上限
164
165
  if (this.messages.length >= MAX_PENDING_MESSAGES) {
@@ -185,12 +186,21 @@ class WecomClient extends EventEmitter {
185
186
  const event = frame.body?.event;
186
187
  if (!event)
187
188
  return;
188
- // template_card_event 结构在 event.template_card_event 中
189
- const cardEvent = event.template_card_event;
190
- if (!cardEvent)
189
+ // 调试:打印完整事件结构
190
+ console.log('[wecom] 审批事件原始结构:', JSON.stringify(event).substring(0, 500));
191
+ // task_id 和 event_key 可能在 event 层级(SDK 扁平化)
192
+ // 也可能嵌套在 template_card_event 对象内(WeChat 原始结构)
193
+ let taskId = event.task_id;
194
+ let eventKey = event.event_key;
195
+ // 如果直接找不到,尝试嵌套结构
196
+ if (!taskId && event.template_card_event) {
197
+ taskId = event.template_card_event.task_id;
198
+ eventKey = event.template_card_event.event_key;
199
+ }
200
+ if (!taskId) {
201
+ console.log('[wecom] 审批事件未找到 task_id,事件 keys:', Object.keys(event));
191
202
  return;
192
- const taskId = cardEvent.task_id;
193
- const eventKey = cardEvent.event_key; // 用户点击的按钮 key
203
+ }
194
204
  console.log(`[wecom] 收到审批响应: taskId=${taskId}, key=${eventKey}`);
195
205
  const approval = this.approvals.get(taskId);
196
206
  if (approval && !approval.resolved) {
@@ -303,7 +313,7 @@ class WecomClient extends EventEmitter {
303
313
  if (toolInput && toolName) {
304
314
  const operationHash = hashOperation(ccId ?? '', toolName, toolInput);
305
315
  const existing = this.findApprovalByHash(operationHash);
306
- if (existing) {
316
+ if (existing && !existing.resolved) {
307
317
  console.log(`[wecom] 复用已有审批: ${existing.taskId} (hash: ${operationHash.slice(0, 8)}...)`);
308
318
  return existing.taskId;
309
319
  }
@@ -550,20 +560,46 @@ class WecomClient extends EventEmitter {
550
560
  };
551
561
  }
552
562
  }
553
- // 单例实例
554
- let instance = null;
563
+ // 多实例支持(按 robotName 索引)
564
+ const instances = new Map();
555
565
  export function initClient(botId, secret, targetUserId, robotName) {
556
- if (instance) {
557
- instance.disconnect();
566
+ // 如果该机器人已存在,先断开旧连接
567
+ if (instances.has(robotName)) {
568
+ instances.get(robotName).disconnect();
558
569
  }
559
- instance = new WecomClient(botId, secret, targetUserId, robotName);
560
- instance.connect();
561
- return instance;
570
+ const client = new WecomClient(botId, secret, targetUserId, robotName);
571
+ client.connect();
572
+ instances.set(robotName, client);
573
+ return client;
562
574
  }
563
- export function getClient() {
564
- if (!instance) {
575
+ export function getClient(robotName) {
576
+ if (robotName) {
577
+ const client = instances.get(robotName);
578
+ if (!client) {
579
+ throw new Error(`WecomClient 未初始化:机器人 "${robotName}" 不存在`);
580
+ }
581
+ return client;
582
+ }
583
+ // 无参数时返回第一个实例(向后兼容)
584
+ if (instances.size === 0) {
565
585
  throw new Error('WecomClient 未初始化,请先调用 initClient');
566
586
  }
567
- return instance;
587
+ const first = instances.values().next().value;
588
+ if (!first) {
589
+ throw new Error('WecomClient 未初始化');
590
+ }
591
+ return first;
592
+ }
593
+ export function getAllClients() {
594
+ return instances;
595
+ }
596
+ export function disconnectClient(robotName) {
597
+ const client = instances.get(robotName);
598
+ if (client) {
599
+ client.disconnect();
600
+ instances.delete(robotName);
601
+ return true;
602
+ }
603
+ return false;
568
604
  }
569
605
  export { WecomClient, MAX_PENDING_MESSAGES };
@@ -249,32 +249,16 @@ export function uninstall() {
249
249
  function writeHookScript() {
250
250
  const script = `#!/bin/bash
251
251
  # wecom-aibot-mcp PermissionRequest hook
252
- # HTTP Transport 版本 - v3.0
252
+ # HTTP Transport 版本
253
253
  #
254
- # v3.0 增强:
255
- # - 工具名验证(防止注入攻击)
256
- # - curl 错误处理(检查退出码)
257
- # - 重试机制(3 次重试)
254
+ # 固定端口: 18963
255
+ # 直接检查 $(pwd)/.claude/headless.json
258
256
 
259
257
  MCP_PORT=18963
260
- MAX_RETRIES=3
261
- RETRY_DELAY=2
262
258
 
263
259
  INPUT=$(cat)
264
260
  TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
265
261
 
266
- # ============================================
267
- # 工具名验证(防止注入攻击)
268
- # ============================================
269
- # 只允许字母、数字、下划线、连字符
270
- if [[ ! "$TOOL_NAME" =~ ^[a-zA-Z0-9_-]+$ ]]; then
271
- # MCP 工具名可能包含特殊字符(如 mcp__server__tool)
272
- if [[ ! "$TOOL_NAME" =~ ^[a-zA-Z0-9_-]+$ ]] && [[ ! "$TOOL_NAME" == mcp__* ]]; then
273
- echo "[hook] 无效的工具名: $TOOL_NAME" >&2
274
- exit 0
275
- fi
276
- fi
277
-
278
262
  # MCP 工具本身不需要拦截
279
263
  if [[ "$TOOL_NAME" == mcp__* ]]; then
280
264
  printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
@@ -289,97 +273,52 @@ case "$TOOL_NAME" in
289
273
  ;;
290
274
  esac
291
275
 
292
- # 检查项目目录的 settings.json 是否有 hooks 配置
276
+ # 直接检查项目目录的 headless 状态文件
293
277
  PROJECT_DIR=$(pwd)
294
- SETTINGS_FILE="$PROJECT_DIR/.claude/settings.json"
278
+ HEADLESS_FILE="$PROJECT_DIR/.claude/headless.json"
295
279
 
296
- # 没有 hooks 配置,说明不在微信模式
297
- if [[ ! -f "$SETTINGS_FILE" ]]; then
280
+ # 不在 headless 模式
281
+ if [[ ! -f "$HEADLESS_FILE" ]]; then
298
282
  exit 0
299
283
  fi
300
284
 
301
- # 检查是否有 PermissionRequest hook
302
- if ! jq -e '.hooks.PermissionRequest' "$SETTINGS_FILE" > /dev/null 2>&1; then
285
+ # 检查 MCP Server 是否在线
286
+ HEALTH=$(curl -s -m 2 "http://127.0.0.1:$MCP_PORT/health" 2>/dev/null)
287
+ if ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
303
288
  exit 0
304
289
  fi
305
290
 
306
- # ============================================
307
- # 带重试的 curl 函数
308
- # ============================================
309
- retry_curl() {
310
- local url="$1"
311
- local max_retries="$2"
312
- local attempt=1
313
- local response
314
-
315
- while [[ $attempt -le $max_retries ]]; do
316
- response=$(curl -s -m 10 "$url" 2>/dev/null)
317
- local exit_code=$?
318
-
319
- if [[ $exit_code -eq 0 ]]; then
320
- echo "$response"
321
- return 0
322
- fi
323
-
324
- echo "[hook] curl 失败 (尝试 $attempt/$max_retries, 退出码: $exit_code)" >&2
291
+ # 从 headless.json 提取 ccId(优先 agentName,回退 ccId)
292
+ CC_ID=$(jq -r '.agentName // .ccId // empty' "$HEADLESS_FILE" 2>/dev/null)
325
293
 
326
- if [[ $attempt -lt $max_retries ]]; then
327
- sleep $RETRY_DELAY
328
- fi
329
-
330
- ((attempt++))
331
- done
332
-
333
- return 1
334
- }
335
-
336
- # ============================================
337
- # 检查 MCP Server 健康状态
338
- # ============================================
339
- HEALTH=$(retry_curl "http://127.0.0.1:$MCP_PORT/health" "$MAX_RETRIES")
340
- if [[ $? -ne 0 ]] || ! echo "$HEALTH" | jq -e '.status == "ok"' > /dev/null 2>&1; then
341
- echo "[hook] MCP Server 不在线,跳过审批" >&2
342
- exit 0
343
- fi
344
-
345
- # ============================================
346
- # 发送审批请求
347
- # ============================================
294
+ # 发送审批请求(包含 ccId 用于路由)
348
295
  TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
349
-
350
- # 验证 tool_input 是有效的 JSON
351
- if ! echo "$TOOL_INPUT" | jq -e . > /dev/null 2>&1; then
352
- echo "[hook] 无效的 tool_input JSON" >&2
353
- exit 0
354
- fi
355
-
356
- BODY=$(jq -n --arg tool_name "$TOOL_NAME" --argjson tool_input "$TOOL_INPUT" --arg project_dir "$PROJECT_DIR" \\
357
- '{"tool_name":$tool_name,"tool_input":$tool_input,"projectDir":$project_dir}')
296
+ BODY=$(jq -n \\
297
+ --arg tool_name "$TOOL_NAME" \\
298
+ --argjson tool_input "$TOOL_INPUT" \\
299
+ --arg project_dir "$PROJECT_DIR" \\
300
+ --arg cc_id "$CC_ID" \\
301
+ '{"tool_name":$tool_name,"tool_input":$tool_input,"projectDir":$project_dir,"ccId":$cc_id}')
358
302
 
359
303
  RESPONSE=$(curl -s -m 10 -X POST "http://127.0.0.1:$MCP_PORT/approve" \\
360
304
  -H "Content-Type: application/json" \\
361
- -d "$BODY" 2>/dev/null)
362
- CURL_EXIT=$?
363
-
364
- if [[ $CURL_EXIT -ne 0 ]]; then
365
- echo "[hook] 发送审批请求失败 (退出码: $CURL_EXIT)" >&2
366
- exit 0
367
- fi
305
+ -d "$BODY")
368
306
 
369
307
  TASK_ID=$(echo "$RESPONSE" | jq -r '.taskId // empty')
370
308
  if [[ -z "$TASK_ID" ]]; then
371
- echo "[hook] 未获取到 taskId" >&2
372
309
  exit 0
373
310
  fi
374
311
 
375
- # ============================================
376
- # 阻塞等待审批结果
377
- # ============================================
378
- WAIT_RESULT=$(curl -s -m 600 -X GET "http://127.0.0.1:$MCP_PORT/approval_wait/$TASK_ID" 2>/dev/null)
379
- CURL_EXIT=$?
312
+ # 轮询审批结果(带超时:10 分钟)
313
+ POLL_COUNT=0
314
+ MAX_POLL=300 # 300 * 2秒 = 600秒 = 10分钟
380
315
 
381
- if [[ $CURL_EXIT -eq 0 ]] && [[ -n "$WAIT_RESULT" ]]; then
382
- RESULT=$(echo "$WAIT_RESULT" | jq -r '.result // empty')
316
+ while [[ $POLL_COUNT -lt $MAX_POLL ]]; do
317
+ sleep 2
318
+ POLL_COUNT=$((POLL_COUNT + 1))
319
+
320
+ STATUS=$(curl -s -m 3 "http://127.0.0.1:$MCP_PORT/approval_status/$TASK_ID" 2>/dev/null)
321
+ RESULT=$(echo "$STATUS" | jq -r '.result // empty')
383
322
 
384
323
  if [[ "$RESULT" == "allow-once" || "$RESULT" == "allow-always" ]]; then
385
324
  printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
@@ -388,33 +327,65 @@ if [[ $CURL_EXIT -eq 0 ]] && [[ -n "$WAIT_RESULT" ]]; then
388
327
  printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"用户拒绝"}}}'
389
328
  exit 0
390
329
  fi
391
- fi
330
+ done
392
331
 
393
- # ============================================
394
- # 无限轮询审批结果
395
- # ============================================
396
- echo "[hook] 开始无限轮询审批结果" >&2
332
+ # 超时处理:智能代批
333
+ # 规则:删除命令→拒绝,项目内操作→允许,项目外操作→拒绝
397
334
 
398
- while true; do
399
- sleep 2
335
+ # 检查是否是删除命令
336
+ IS_DELETE=0
337
+ if [[ "$TOOL_NAME" == "Bash" ]]; then
338
+ CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
339
+ if [[ "$CMD" =~ ^rm\\ ]] || [[ "$CMD" =~ \\ rm\\ ]] || \
340
+ [[ "$CMD" =~ ^rmdir\\ ]] || [[ "$CMD" =~ \\ rmdir\\ ]] || \
341
+ [[ "$CMD" =~ ^unlink\\ ]] || [[ "$CMD" =~ rm\\ -rf ]]; then
342
+ IS_DELETE=1
343
+ fi
344
+ fi
400
345
 
401
- STATUS=$(curl -s -m 3 "http://127.0.0.1:$MCP_PORT/approval_status/$TASK_ID" 2>/dev/null)
402
- CURL_EXIT=$?
346
+ # 删除操作 永远拒绝
347
+ if [[ $IS_DELETE -eq 1 ]]; then
348
+ printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"超时自动拒绝:删除操作需人工确认"}}}'
349
+ exit 0
350
+ fi
403
351
 
404
- if [[ $CURL_EXIT -ne 0 ]]; then
405
- continue
406
- fi
352
+ # 检查操作路径是否在项目内
353
+ IS_IN_PROJECT=0
407
354
 
408
- RESULT=$(echo "$STATUS" | jq -r '.result // empty')
355
+ case "$TOOL_NAME" in
356
+ Bash)
357
+ CMD=$(echo "$TOOL_INPUT" | jq -r '.command // empty')
358
+ if [[ "$CMD" == *"$PROJECT_DIR"* ]] || \
359
+ [[ "$CMD" =~ ^\\./ ]] || \
360
+ [[ "$CMD" =~ ^npm\\ ]] || \
361
+ [[ "$CMD" =~ ^npx\\ ]] || \
362
+ [[ "$CMD" =~ ^git\\ ]] || \
363
+ [[ "$CMD" =~ ^node\\ ]]; then
364
+ IS_IN_PROJECT=1
365
+ fi
366
+ ;;
367
+ Write|Edit)
368
+ FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty')
369
+ if [[ "$FILE_PATH" == "$PROJECT_DIR"* ]] || [[ "$FILE_PATH" != /* ]]; then
370
+ IS_IN_PROJECT=1
371
+ fi
372
+ ;;
373
+ *)
374
+ FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .path // .directory // empty')
375
+ if [[ -n "$FILE_PATH" ]]; then
376
+ if [[ "$FILE_PATH" == "$PROJECT_DIR"* ]] || [[ "$FILE_PATH" != /* ]]; then
377
+ IS_IN_PROJECT=1
378
+ fi
379
+ fi
380
+ ;;
381
+ esac
409
382
 
410
- if [[ "$RESULT" == "allow-once" || "$RESULT" == "allow-always" ]]; then
411
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
412
- exit 0
413
- elif [[ "$RESULT" == "deny" ]]; then
414
- printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"用户拒绝"}}}'
415
- exit 0
416
- fi
417
- done
383
+ # 根据项目内/外决策
384
+ if [[ $IS_IN_PROJECT -eq 1 ]]; then
385
+ printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","message":"超时自动允许:项目内操作"}}}'
386
+ else
387
+ printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"超时自动拒绝:项目外操作需人工确认"}}}'
388
+ fi
418
389
  `;
419
390
  ensureConfigDir();
420
391
  fs.writeFileSync(HOOK_SCRIPT_PATH, script, { mode: 0o755 });
@@ -423,7 +394,7 @@ done
423
394
  // 写入 MCP Server 配置到 ~/.claude.json
424
395
  function writeMcpServerConfig(config, instanceName) {
425
396
  try {
426
- // 1. 写入机器人配置到 ~/.wecom-aibot-mcp/robot-${timestamp}.json
397
+ // 1. 写入机器人配置到 ~/.wecom-aibot-mcp/config.json
427
398
  ensureConfigDir();
428
399
  const botConfig = {
429
400
  botId: config.botId,
@@ -433,10 +404,8 @@ function writeMcpServerConfig(config, instanceName) {
433
404
  if (config.nameTag) {
434
405
  botConfig.nameTag = config.nameTag;
435
406
  }
436
- // 所有机器人都使用独立文件,不再有默认 config.json
437
- const robotConfigPath = path.join(CONFIG_DIR, `robot-${Date.now()}.json`);
438
- fs.writeFileSync(robotConfigPath, JSON.stringify(botConfig, null, 2));
439
- console.log(`[config] 机器人配置已写入: ${path.basename(robotConfigPath)}`);
407
+ fs.writeFileSync(BOT_CONFIG_FILE, JSON.stringify(botConfig, null, 2));
408
+ console.log('[config] 机器人配置已写入 ~/.wecom-aibot-mcp/config.json');
440
409
  // 2. 写入 MCP 配置到 ~/.claude.json(仅 URL)
441
410
  let claudeConfig = {};
442
411
  if (fs.existsSync(CLAUDE_CONFIG_FILE)) {
@@ -459,7 +428,7 @@ function writeMcpServerConfig(config, instanceName) {
459
428
  console.error('[config] 写入配置失败:', err);
460
429
  console.log('[config] ⚠️ 请手动配置:');
461
430
  console.log('');
462
- console.log('~/.wecom-aibot-mcp/robot-<timestamp>.json:');
431
+ console.log('~/.wecom-aibot-mcp/config.json:');
463
432
  console.log(JSON.stringify({
464
433
  botId: config.botId,
465
434
  secret: config.secret,
@@ -544,10 +513,19 @@ export async function addMcpConfig() {
544
513
  };
545
514
  // 确保配置目录存在
546
515
  ensureConfigDir();
547
- // 所有机器人都保存为独立文件(不再区分第一个)
516
+ // 如果是第一个机器人,保存为默认配置
517
+ const defaultConfigPath = BOT_CONFIG_FILE;
548
518
  const robotConfigPath = path.join(CONFIG_DIR, `robot-${Date.now()}.json`);
549
- fs.writeFileSync(robotConfigPath, JSON.stringify(robotConfig, null, 2));
550
- console.log(`\n[config] ✅ 已添加机器人: ${robotName}`);
519
+ if (!fs.existsSync(defaultConfigPath)) {
520
+ // 第一个机器人作为默认
521
+ fs.writeFileSync(defaultConfigPath, JSON.stringify(robotConfig, null, 2));
522
+ console.log(`\n[config] ✅ 已设为默认机器人: ${robotName}`);
523
+ }
524
+ else {
525
+ // 后续机器人保存为独立文件
526
+ fs.writeFileSync(robotConfigPath, JSON.stringify(robotConfig, null, 2));
527
+ console.log(`\n[config] ✅ 已添加新机器人: ${robotName}`);
528
+ }
551
529
  console.log(`[config] 用户 ID: ${targetUserId}`);
552
530
  // 列出所有机器人
553
531
  const robots = listAllRobots();
@@ -2,11 +2,9 @@
2
2
  * 连接状态日志模块
3
3
  *
4
4
  * 记录 WebSocket 连接状态变化,便于分析连接问题
5
- * 支持多机器人,每个机器人独立记录
6
5
  */
7
6
  interface ConnectionRecord {
8
- robotName: string;
9
- event: 'connected' | 'authenticated' | 'disconnected' | 'reconnecting' | 'error';
7
+ event: 'connected' | 'authenticated' | 'disconnected' | 'reconnecting' | 'error' | 'warn';
10
8
  timestamp: string;
11
9
  isoTime: string;
12
10
  reason?: string;
@@ -14,7 +12,7 @@ interface ConnectionRecord {
14
12
  errorMessage?: string;
15
13
  connectionDuration?: number;
16
14
  }
17
- interface RobotConnectionStats {
15
+ interface ConnectionStats {
18
16
  totalConnections: number;
19
17
  totalDisconnections: number;
20
18
  totalReconnects: number;
@@ -23,29 +21,16 @@ interface RobotConnectionStats {
23
21
  lastDisconnectTime?: string;
24
22
  longestConnection?: number;
25
23
  totalConnectionTime?: number;
26
- currentConnectionStart?: number;
27
- lastHeartbeat?: number;
28
- lastAuthenticated?: boolean;
29
24
  }
30
- export declare function loadStats(): Record<string, RobotConnectionStats>;
31
- export declare function getAllStats(): Record<string, RobotConnectionStats>;
32
- export declare function getRobotConnectionStats(robotName: string): RobotConnectionStats | undefined;
33
- export declare function logConnected(robotName: string): void;
34
- export declare function logAuthenticated(robotName: string): void;
35
- export declare function logDisconnected(robotName: string, reason: string): void;
36
- export declare function logReconnecting(robotName: string, attempt: number): void;
37
- export declare function logError(robotName: string, errorMessage: string): void;
38
- export declare function logHeartbeat(robotName: string): void;
39
- export declare function isConnectionHealthy(robotName: string, maxAgeMs?: number): boolean;
40
- export declare function getStats(): {
41
- totalConnections: number;
42
- totalDisconnections: number;
43
- totalReconnects: number;
44
- totalErrors: number;
45
- lastConnectTime?: string;
46
- lastDisconnectTime?: string;
47
- };
48
- export declare function getRecentLogs(count?: number, robotName?: string): ConnectionRecord[];
25
+ export declare function loadStats(): ConnectionStats;
26
+ export declare function logConnected(): void;
27
+ export declare function logAuthenticated(): void;
28
+ export declare function logDisconnected(reason: string): void;
29
+ export declare function logReconnecting(attempt: number): void;
30
+ export declare function logError(errorMessage: string): void;
31
+ export declare function logWarn(message: string): void;
32
+ export declare function getStats(): ConnectionStats;
33
+ export declare function getRecentLogs(count?: number): ConnectionRecord[];
49
34
  export declare function cleanupOldLogs(daysToKeep?: number): void;
50
35
  export declare function getLogFilePath(): string;
51
36
  export declare function getStatsFilePath(): string;