@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 +1 -1
- package/dist/bin.js +10 -20
- package/dist/cc-registry.js +13 -7
- package/dist/client.d.ts +4 -1
- package/dist/client.js +57 -21
- package/dist/config-wizard.js +98 -120
- package/dist/connection-log.d.ts +11 -26
- package/dist/connection-log.js +46 -129
- package/dist/connection-manager.d.ts +27 -18
- package/dist/connection-manager.js +129 -57
- package/dist/headless-state.d.ts +62 -19
- package/dist/headless-state.js +260 -66
- package/dist/http-server.d.ts +27 -20
- package/dist/http-server.js +580 -264
- package/dist/index.d.ts +12 -6
- package/dist/index.js +12 -8
- package/dist/keepalive-monitor.js +2 -1
- package/dist/tools/headless.js +22 -20
- package/dist/tools/index.d.ts +18 -5
- package/dist/tools/index.js +445 -11
- package/dist/tools/messaging.js +19 -8
- package/package.json +1 -1
- package/skills/headless-mode/SKILL.md +62 -24
package/README.md
CHANGED
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
|
-
//
|
|
87
|
+
// 构建机器人占用信息
|
|
90
88
|
const robotUsage = new Map();
|
|
91
89
|
for (const conn of connections) {
|
|
92
|
-
if (conn.
|
|
93
|
-
robotUsage.set(conn.robotName, {
|
|
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.
|
|
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
|
-
|
|
178
|
-
|
|
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(
|
|
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
|
|
229
|
-
if (
|
|
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
|
}
|
package/dist/cc-registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
},
|
|
261
|
-
console.log('[heartbeat]
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
85
|
+
logReconnecting(attempt);
|
|
86
86
|
});
|
|
87
87
|
this.wsClient.on('error', (err) => {
|
|
88
|
-
logError(
|
|
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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
+
// 多实例支持(按 robotName 索引)
|
|
564
|
+
const instances = new Map();
|
|
555
565
|
export function initClient(botId, secret, targetUserId, robotName) {
|
|
556
|
-
|
|
557
|
-
|
|
566
|
+
// 如果该机器人已存在,先断开旧连接
|
|
567
|
+
if (instances.has(robotName)) {
|
|
568
|
+
instances.get(robotName).disconnect();
|
|
558
569
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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 (
|
|
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
|
-
|
|
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 };
|
package/dist/config-wizard.js
CHANGED
|
@@ -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 版本
|
|
252
|
+
# HTTP Transport 版本
|
|
253
253
|
#
|
|
254
|
-
#
|
|
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
|
-
#
|
|
276
|
+
# 直接检查项目目录的 headless 状态文件
|
|
293
277
|
PROJECT_DIR=$(pwd)
|
|
294
|
-
|
|
278
|
+
HEADLESS_FILE="$PROJECT_DIR/.claude/headless.json"
|
|
295
279
|
|
|
296
|
-
#
|
|
297
|
-
if [[ ! -f "$
|
|
280
|
+
# 不在 headless 模式
|
|
281
|
+
if [[ ! -f "$HEADLESS_FILE" ]]; then
|
|
298
282
|
exit 0
|
|
299
283
|
fi
|
|
300
284
|
|
|
301
|
-
#
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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"
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
330
|
+
done
|
|
392
331
|
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
# ============================================
|
|
396
|
-
echo "[hook] 开始无限轮询审批结果" >&2
|
|
332
|
+
# 超时处理:智能代批
|
|
333
|
+
# 规则:删除命令→拒绝,项目内操作→允许,项目外操作→拒绝
|
|
397
334
|
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
fi
|
|
352
|
+
# 检查操作路径是否在项目内
|
|
353
|
+
IS_IN_PROJECT=0
|
|
407
354
|
|
|
408
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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/
|
|
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
|
-
|
|
437
|
-
|
|
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/
|
|
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.
|
|
550
|
-
|
|
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();
|
package/dist/connection-log.d.ts
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
* 连接状态日志模块
|
|
3
3
|
*
|
|
4
4
|
* 记录 WebSocket 连接状态变化,便于分析连接问题
|
|
5
|
-
* 支持多机器人,每个机器人独立记录
|
|
6
5
|
*/
|
|
7
6
|
interface ConnectionRecord {
|
|
8
|
-
|
|
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
|
|
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():
|
|
31
|
-
export declare function
|
|
32
|
-
export declare function
|
|
33
|
-
export declare function
|
|
34
|
-
export declare function
|
|
35
|
-
export declare function
|
|
36
|
-
export declare function
|
|
37
|
-
export declare function
|
|
38
|
-
export declare function
|
|
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;
|