@vrs-soft/wecom-aibot-mcp 2.4.10 → 2.4.12
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/dist/bin.js +69 -28
- package/dist/channel-server.js +12 -7
- package/dist/client.d.ts +3 -1
- package/dist/client.js +30 -33
- package/dist/config-wizard.js +5 -40
- package/dist/http-server.js +91 -3
- package/dist/project-config.d.ts +0 -1
- package/dist/project-config.js +18 -0
- package/dist/tools/index.d.ts +0 -21
- package/dist/tools/index.js +3 -25
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - robotName 作为连接索引
|
|
10
10
|
* - 不再使用 projectDir
|
|
11
11
|
*/
|
|
12
|
-
import { spawn } from 'child_process';
|
|
12
|
+
import { spawn, execSync } from 'child_process';
|
|
13
13
|
import * as fs from 'fs';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import * as os from 'os';
|
|
@@ -164,43 +164,81 @@ function isServerRunning() {
|
|
|
164
164
|
return false;
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
// 通过端口查找进程 PID(fallback,当 PID 文件不存在时)
|
|
168
|
+
function findPidByPort(port) {
|
|
169
|
+
try {
|
|
170
|
+
// Linux: ss -tlnp | grep :18963
|
|
171
|
+
const output = execSync(`ss -tlnp 2>/dev/null | grep ':${port}'`, { encoding: 'utf-8' });
|
|
172
|
+
const match = output.match(/pid=(\d+)/);
|
|
173
|
+
if (match)
|
|
174
|
+
return parseInt(match[1]);
|
|
175
|
+
}
|
|
176
|
+
catch { /* ignore */ }
|
|
177
|
+
try {
|
|
178
|
+
// macOS: lsof -ti :18963
|
|
179
|
+
const output = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
180
|
+
if (output)
|
|
181
|
+
return parseInt(output.split('\n')[0]);
|
|
182
|
+
}
|
|
183
|
+
catch { /* ignore */ }
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
167
186
|
// 停止服务
|
|
168
187
|
function stopServer() {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
let pid = null;
|
|
189
|
+
// 优先从 PID 文件获取
|
|
190
|
+
if (fs.existsSync(PID_FILE)) {
|
|
191
|
+
pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
|
|
192
|
+
// 检查进程是否存在
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// PID 文件残留但进程已死,清理 PID 文件
|
|
198
|
+
console.log('[mcp] PID 文件残留,进程已退出,清理中...');
|
|
199
|
+
fs.unlinkSync(PID_FILE);
|
|
200
|
+
pid = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// PID 文件不存在或残留:通过端口查找
|
|
204
|
+
if (pid === null) {
|
|
205
|
+
pid = findPidByPort(HTTP_PORT);
|
|
206
|
+
if (pid === null) {
|
|
207
|
+
console.log('[mcp] 服务未运行');
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
console.log(`[mcp] 通过端口 ${HTTP_PORT} 找到进程 PID: ${pid}`);
|
|
172
211
|
}
|
|
212
|
+
// 发送 SIGTERM
|
|
173
213
|
try {
|
|
174
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
|
|
175
214
|
process.kill(pid, 'SIGTERM');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
process.kill(pid, 0);
|
|
181
|
-
// 进程还存在,等待
|
|
182
|
-
setTimeout(() => { }, 500);
|
|
183
|
-
attempts++;
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
// 进程已退出
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// 进程退出后删除 PID 文件(如果还存在)
|
|
191
|
-
if (fs.existsSync(PID_FILE)) {
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// ESRCH: 进程不存在,清理即可
|
|
218
|
+
if (fs.existsSync(PID_FILE))
|
|
192
219
|
fs.unlinkSync(PID_FILE);
|
|
193
|
-
}
|
|
194
220
|
console.log('[mcp] 服务已停止');
|
|
195
221
|
return true;
|
|
196
222
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
// 等待进程退出(最多 5 秒)
|
|
224
|
+
const deadline = Date.now() + 5000;
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, 0);
|
|
228
|
+
// 进程还在,同步等待 100ms
|
|
229
|
+
const waitUntil = Date.now() + 100;
|
|
230
|
+
while (Date.now() < waitUntil) { /* busy wait */ }
|
|
201
231
|
}
|
|
202
|
-
|
|
232
|
+
catch {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// 清理 PID 文件
|
|
237
|
+
if (fs.existsSync(PID_FILE)) {
|
|
238
|
+
fs.unlinkSync(PID_FILE);
|
|
203
239
|
}
|
|
240
|
+
console.log('[mcp] 服务已停止');
|
|
241
|
+
return true;
|
|
204
242
|
}
|
|
205
243
|
// 等待连接验证(用于配置向导验证凭证)
|
|
206
244
|
async function waitForConnection(client, timeoutMs = 10000) {
|
|
@@ -318,10 +356,13 @@ async function main() {
|
|
|
318
356
|
// --channel: 作为 Channel MCP 代理运行,不应改写全局配置
|
|
319
357
|
// --reinstall / --http-only: 有自己的处理逻辑
|
|
320
358
|
// --version / -v: 只查版本,不写配置
|
|
359
|
+
// --stop / --status / --list / --clean-cache / --set-token / --config: 管理命令,不应改写配置
|
|
321
360
|
const skipEnsure = args.includes('--reinstall') || args.includes('--http-only') ||
|
|
322
361
|
args.includes('--setup') || args.includes('--channel') ||
|
|
323
362
|
args.includes('--version') || args.includes('-v') ||
|
|
324
|
-
args.includes('--start') || args.includes('--debug')
|
|
363
|
+
args.includes('--start') || args.includes('--debug') ||
|
|
364
|
+
args.includes('--stop') || args.includes('--status') || args.includes('--list') ||
|
|
365
|
+
args.includes('--clean-cache') || args.includes('--set-token') || args.includes('--config');
|
|
325
366
|
if (!skipEnsure) {
|
|
326
367
|
// 强制覆盖所有全局配置(不依赖智能体)
|
|
327
368
|
ensureGlobalConfigs(installMode);
|
package/dist/channel-server.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as fs from 'fs';
|
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import * as os from 'os';
|
|
18
18
|
import { VERSION } from './config-wizard.js';
|
|
19
|
-
import { addPermissionHook } from './project-config.js';
|
|
19
|
+
import { addPermissionHook, registerActiveProject, unregisterActiveProject } from './project-config.js';
|
|
20
20
|
const MCP_URL = process.env.MCP_URL || 'http://127.0.0.1:18963';
|
|
21
21
|
const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
|
|
22
22
|
// 构建带 auth 的 fetch headers
|
|
@@ -399,9 +399,8 @@ function registerChannelTools(server) {
|
|
|
399
399
|
project_dir: z.string().optional().describe('项目目录路径(用于写入配置文件)'),
|
|
400
400
|
mode: z.enum(['channel', 'http']).optional().default('http')
|
|
401
401
|
.describe('运行模式:channel=SSE推送(推荐),http=轮询(兼容)'),
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve, auto_approve_timeout }) => {
|
|
402
|
+
auto_approve_timeout: z.number().optional().default(600).describe('超时自动决策等待时间(秒,默认 600 即 10 分钟)'),
|
|
403
|
+
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve_timeout }) => {
|
|
405
404
|
// 转发请求
|
|
406
405
|
const result = await forwardToHttpMcp('enter_headless_mode', {
|
|
407
406
|
agent_name,
|
|
@@ -409,7 +408,6 @@ function registerChannelTools(server) {
|
|
|
409
408
|
robot_id,
|
|
410
409
|
project_dir: project_dir || process.cwd(),
|
|
411
410
|
mode,
|
|
412
|
-
auto_approve,
|
|
413
411
|
auto_approve_timeout,
|
|
414
412
|
});
|
|
415
413
|
// 拦截响应,提取 ccId,建立 SSE 连接
|
|
@@ -425,6 +423,9 @@ function registerChannelTools(server) {
|
|
|
425
423
|
const localProjectDir = project_dir || process.cwd();
|
|
426
424
|
const hookResult = addPermissionHook(localProjectDir);
|
|
427
425
|
logChannel('本地 PermissionRequest hook 已写入', { path: hookResult.path, success: hookResult.success });
|
|
426
|
+
// 注册本地 PID → projectDir(供本地 permission-hook.sh 通过进程树匹配项目)
|
|
427
|
+
registerActiveProject(process.ppid ?? process.pid, localProjectDir);
|
|
428
|
+
logChannel('本地 active-projects 已注册', { pid: process.ppid ?? process.pid, projectDir: localProjectDir });
|
|
428
429
|
// Channel 模式:过滤 heartbeat 信息,简化消息
|
|
429
430
|
if (mode === 'channel' || parsed.mode === 'channel') {
|
|
430
431
|
delete parsed.heartbeat; // Channel 模式不需要 heartbeat loop
|
|
@@ -448,6 +449,7 @@ function registerChannelTools(server) {
|
|
|
448
449
|
cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
|
|
449
450
|
project_dir: z.string().optional().describe('项目目录路径(用于更新配置文件)'),
|
|
450
451
|
}, async ({ cc_id, project_dir }) => {
|
|
452
|
+
const localProjectDir = project_dir || process.cwd();
|
|
451
453
|
// 断开 SSE 连接(abort 后重连逻辑不会触发)
|
|
452
454
|
if (sseAbortController) {
|
|
453
455
|
sseAbortController.abort();
|
|
@@ -456,7 +458,10 @@ function registerChannelTools(server) {
|
|
|
456
458
|
sseCurrentCcId = undefined;
|
|
457
459
|
logChannel('SSE disconnected', { cc_id });
|
|
458
460
|
}
|
|
459
|
-
|
|
461
|
+
// 注销本地 active-projects 记录
|
|
462
|
+
unregisterActiveProject(localProjectDir);
|
|
463
|
+
logChannel('本地 active-projects 已注销', { projectDir: localProjectDir });
|
|
464
|
+
return forwardToHttpMcp('exit_headless_mode', { cc_id, project_dir: localProjectDir });
|
|
460
465
|
});
|
|
461
466
|
// ============================================
|
|
462
467
|
// 工具 11: 从消息识别用户
|
|
@@ -605,7 +610,7 @@ export async function startChannelServer() {
|
|
|
605
610
|
tools: {},
|
|
606
611
|
},
|
|
607
612
|
// 告知 Claude 如何处理 channel 事件
|
|
608
|
-
instructions: '企业微信消息通过 <channel> 标签推送。属性说明:from=发送者userid, chatid=会话ID(单聊=用户ID,群聊=群ID), chattype=single|group, cc_id
|
|
613
|
+
instructions: '企业微信消息通过 <channel> 标签推送。属性说明:from=发送者userid, chatid=会话ID(单聊=用户ID,群聊=群ID), chattype=single|group, cc_id=当前会话标识。【强制规则】收到任何用户消息后,必须先执行步骤1再执行步骤2,禁止跳过:1) 立即发送确认 send_message(cc_id, "收到,正在处理...", target_user=chatid);2) 处理任务;3) 发送结果 send_message(cc_id, "【完成】...", target_user=chatid)。',
|
|
609
614
|
});
|
|
610
615
|
// 注册工具
|
|
611
616
|
registerChannelTools(mcpServer);
|
package/dist/client.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface ApprovalRecord {
|
|
|
14
14
|
operationHash?: string;
|
|
15
15
|
consumed?: boolean;
|
|
16
16
|
ccId?: string;
|
|
17
|
+
detailUrl?: string;
|
|
17
18
|
}
|
|
18
19
|
interface MessageRecord {
|
|
19
20
|
seq: number;
|
|
@@ -61,7 +62,8 @@ declare class WecomClient extends EventEmitter {
|
|
|
61
62
|
}>;
|
|
62
63
|
sendText(content: string, targetUser?: string): Promise<boolean>;
|
|
63
64
|
sendApprovalRequest(title: string, description: string, requestId: string, targetUser?: string, toolInput?: Record<string, unknown>, // v3.0: 用于去重
|
|
64
|
-
ccId?: string
|
|
65
|
+
ccId?: string, // v3.0: 参与哈希,防止跨 CC 复用审批
|
|
66
|
+
detailUrlBase?: string): Promise<string>;
|
|
65
67
|
sendQueuedApproval(taskId: string, title: string, description: string, targetUser?: string): Promise<boolean>;
|
|
66
68
|
getApprovalResult(taskId: string): 'pending' | 'allow-once' | 'allow-always' | 'deny';
|
|
67
69
|
setApprovalResult(taskId: string, result: 'allow-once' | 'deny', reason?: string): boolean;
|
package/dist/client.js
CHANGED
|
@@ -19,6 +19,30 @@ import { logger } from './logger.js';
|
|
|
19
19
|
const MAX_PENDING_MESSAGES = 100;
|
|
20
20
|
// 全局消息序号计数器
|
|
21
21
|
let globalMessageSeq = 0;
|
|
22
|
+
// 审批卡片正文长度上限;超过则截断,余下由详情链接承接
|
|
23
|
+
const APPROVAL_DESC_MAX = 200;
|
|
24
|
+
// 构建审批卡片 payload(发首次审批 + 排队重发共用)
|
|
25
|
+
function buildApprovalCard(title, description, taskId, detailUrl) {
|
|
26
|
+
const truncated = description.length > APPROVAL_DESC_MAX
|
|
27
|
+
? description.slice(0, APPROVAL_DESC_MAX) + '…(已截断,点击「详情」查看完整内容)'
|
|
28
|
+
: description;
|
|
29
|
+
const subTitle = truncated + `\n\n📋 TaskID: ${taskId}`;
|
|
30
|
+
const card = {
|
|
31
|
+
card_type: 'button_interaction',
|
|
32
|
+
main_title: { title },
|
|
33
|
+
sub_title_text: subTitle,
|
|
34
|
+
button_list: [
|
|
35
|
+
{ text: '允许', key: 'allow-once', style: 1 },
|
|
36
|
+
{ text: '默认', key: 'allow-always', style: 1 },
|
|
37
|
+
{ text: '拒绝', key: 'deny', style: 2 },
|
|
38
|
+
],
|
|
39
|
+
task_id: taskId,
|
|
40
|
+
...(detailUrl
|
|
41
|
+
? { horizontal_content_list: [{ keyname: '详情', value: '查看完整命令', type: 1, url: detailUrl }] }
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
44
|
+
return { msgtype: 'template_card', template_card: card };
|
|
45
|
+
}
|
|
22
46
|
class WecomClient extends EventEmitter {
|
|
23
47
|
wsClient;
|
|
24
48
|
approvals = new Map();
|
|
@@ -344,7 +368,8 @@ class WecomClient extends EventEmitter {
|
|
|
344
368
|
}
|
|
345
369
|
// 发送审批请求(带按钮的模板卡片)
|
|
346
370
|
async sendApprovalRequest(title, description, requestId, targetUser, toolInput, // v3.0: 用于去重
|
|
347
|
-
ccId // v3.0: 参与哈希,防止跨 CC 复用审批
|
|
371
|
+
ccId, // v3.0: 参与哈希,防止跨 CC 复用审批
|
|
372
|
+
detailUrlBase // 详情页 h5 链接的 base(最终 URL = base/taskId)
|
|
348
373
|
) {
|
|
349
374
|
const userId = targetUser || this.targetUserId;
|
|
350
375
|
// 从 title 中提取工具名称(格式: 【待审批】Bash)
|
|
@@ -360,6 +385,7 @@ class WecomClient extends EventEmitter {
|
|
|
360
385
|
}
|
|
361
386
|
const taskId = `approval_${requestId}_${Date.now()}`;
|
|
362
387
|
const operationHash = toolInput && toolName ? hashOperation(ccId ?? '', toolName, toolInput) : undefined;
|
|
388
|
+
const detailUrl = detailUrlBase ? `${detailUrlBase}/${taskId}` : undefined;
|
|
363
389
|
// 始终存储审批记录(断线时也需要,让 Hook 能轮询到)
|
|
364
390
|
this.approvals.set(taskId, {
|
|
365
391
|
taskId,
|
|
@@ -370,6 +396,7 @@ class WecomClient extends EventEmitter {
|
|
|
370
396
|
description, // 保存审批请求原文
|
|
371
397
|
operationHash,
|
|
372
398
|
ccId, // 保存 ccId,用于 SSE 推送审批结果
|
|
399
|
+
detailUrl, // 供排队重发时复用
|
|
373
400
|
});
|
|
374
401
|
// 断线时将审批请求加入队列,等待重连后发送
|
|
375
402
|
if (!this.connected) {
|
|
@@ -383,22 +410,7 @@ class WecomClient extends EventEmitter {
|
|
|
383
410
|
// 返回 taskId,审批记录已创建,等待重连后发送
|
|
384
411
|
return taskId;
|
|
385
412
|
}
|
|
386
|
-
|
|
387
|
-
const displayDesc = description + `\n\n📋 TaskID: ${taskId}`;
|
|
388
|
-
await this.wsClient.sendMessage(userId, {
|
|
389
|
-
msgtype: 'template_card',
|
|
390
|
-
template_card: {
|
|
391
|
-
card_type: 'button_interaction',
|
|
392
|
-
main_title: { title },
|
|
393
|
-
sub_title_text: displayDesc,
|
|
394
|
-
button_list: [
|
|
395
|
-
{ text: '允许', key: 'allow-once', style: 1 },
|
|
396
|
-
{ text: '默认', key: 'allow-always', style: 1 },
|
|
397
|
-
{ text: '拒绝', key: 'deny', style: 2 },
|
|
398
|
-
],
|
|
399
|
-
task_id: taskId,
|
|
400
|
-
},
|
|
401
|
-
});
|
|
413
|
+
await this.wsClient.sendMessage(userId, buildApprovalCard(title, description, taskId, detailUrl));
|
|
402
414
|
logger.log(`[wecom] 已发送审批请求到 ${userId}: ${taskId}`);
|
|
403
415
|
return taskId;
|
|
404
416
|
}
|
|
@@ -415,22 +427,7 @@ class WecomClient extends EventEmitter {
|
|
|
415
427
|
return false;
|
|
416
428
|
}
|
|
417
429
|
const userId = targetUser || this.targetUserId;
|
|
418
|
-
|
|
419
|
-
const displayDesc = description + `\n\n📋 TaskID: ${taskId}`;
|
|
420
|
-
await this.wsClient.sendMessage(userId, {
|
|
421
|
-
msgtype: 'template_card',
|
|
422
|
-
template_card: {
|
|
423
|
-
card_type: 'button_interaction',
|
|
424
|
-
main_title: { title },
|
|
425
|
-
sub_title_text: displayDesc,
|
|
426
|
-
button_list: [
|
|
427
|
-
{ text: '允许', key: 'allow-once', style: 1 },
|
|
428
|
-
{ text: '默认', key: 'allow-always', style: 1 },
|
|
429
|
-
{ text: '拒绝', key: 'deny', style: 2 },
|
|
430
|
-
],
|
|
431
|
-
task_id: taskId,
|
|
432
|
-
},
|
|
433
|
-
});
|
|
430
|
+
await this.wsClient.sendMessage(userId, buildApprovalCard(title, description, taskId, approval.detailUrl));
|
|
434
431
|
logger.log(`[wecom] 已发送排队审批请求到 ${userId}: ${taskId}`);
|
|
435
432
|
return true;
|
|
436
433
|
}
|
package/dist/config-wizard.js
CHANGED
|
@@ -413,7 +413,7 @@ function writeHookScript() {
|
|
|
413
413
|
# HTTP Transport 版本
|
|
414
414
|
#
|
|
415
415
|
# 固定端口: 18963
|
|
416
|
-
#
|
|
416
|
+
# 通过 PID 树查 ~/.wecom-aibot-mcp/active-projects.json 匹配项目,读 wechatMode 开关
|
|
417
417
|
|
|
418
418
|
MCP_PORT=18963
|
|
419
419
|
|
|
@@ -594,37 +594,10 @@ while [[ $POLL_COUNT -lt $MAX_POLL ]]; do
|
|
|
594
594
|
fi
|
|
595
595
|
done
|
|
596
596
|
|
|
597
|
-
log_debug "[$(date)] Timeout reached,
|
|
597
|
+
log_debug "[$(date)] Timeout reached, executing smart auto-approval"
|
|
598
598
|
|
|
599
|
-
#
|
|
600
|
-
# autoApprove: false → 继续等待(无限轮询)
|
|
601
|
-
# autoApprove: true → 智能代批
|
|
602
|
-
|
|
603
|
-
AUTO_APPROVE=$(jq -r '.autoApprove // false' "$CONFIG_FILE" 2>/dev/null)
|
|
604
|
-
log_debug "[$(date)] autoApprove: $AUTO_APPROVE"
|
|
605
|
-
if [[ "$AUTO_APPROVE" != "true" ]]; then
|
|
606
|
-
log_debug "[$(date)] autoApprove off, entering infinite wait"
|
|
607
|
-
# autoApprove 关闭,继续无限等待用户响应
|
|
608
|
-
while true; do
|
|
609
|
-
sleep 2
|
|
610
|
-
STATUS=$(curl -s -m 3 "\${AUTH_ARGS[@]}" "$MCP_BASE_URL/approval_status/$TASK_ID" 2>/dev/null)
|
|
611
|
-
RESULT=$(echo "$STATUS" | jq -r '.result // empty')
|
|
612
|
-
|
|
613
|
-
if [[ "$RESULT" == "allow-once" || "$RESULT" == "allow-always" ]]; then
|
|
614
|
-
log_debug "[$(date)] Approved by user (infinite wait)"
|
|
615
|
-
printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
|
|
616
|
-
exit 0
|
|
617
|
-
elif [[ "$RESULT" == "deny" ]]; then
|
|
618
|
-
log_debug "[$(date)] Denied by user (infinite wait)"
|
|
619
|
-
printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"用户拒绝"}}}'
|
|
620
|
-
exit 0
|
|
621
|
-
fi
|
|
622
|
-
done
|
|
623
|
-
fi
|
|
624
|
-
|
|
625
|
-
# autoApprove: true,执行智能代批
|
|
599
|
+
# 超时处理:必须立即决策,Claude Code 的 hook timeout 会杀掉阻塞进程。
|
|
626
600
|
# 规则:删除命令→拒绝,项目内操作→允许,项目外操作→拒绝
|
|
627
|
-
log_debug "[$(date)] Executing smart auto-approval"
|
|
628
601
|
|
|
629
602
|
# 检查是否是删除命令(仅匹配命令行本身,不匹配 heredoc 内容)
|
|
630
603
|
IS_DELETE=0
|
|
@@ -726,7 +699,7 @@ function writeStopHookScript() {
|
|
|
726
699
|
# HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
|
|
727
700
|
#
|
|
728
701
|
# 固定端口: 18963
|
|
729
|
-
#
|
|
702
|
+
# 只检查 $(pwd)/.claude/wecom-aibot.json 的 wechatMode 字段
|
|
730
703
|
|
|
731
704
|
MCP_PORT=18963
|
|
732
705
|
|
|
@@ -763,14 +736,6 @@ if [[ "$WECHAT_MODE" != "true" ]]; then
|
|
|
763
736
|
exit 0
|
|
764
737
|
fi
|
|
765
738
|
|
|
766
|
-
# 检查 autoApprove 是否为 true(需要恢复轮询的模式)
|
|
767
|
-
AUTO_APPROVE=$(jq -r '.autoApprove // false' "$CONFIG_FILE" 2>/dev/null)
|
|
768
|
-
log_debug "[$(date)] autoApprove: $AUTO_APPROVE"
|
|
769
|
-
if [[ "$AUTO_APPROVE" != "true" ]]; then
|
|
770
|
-
log_debug "[$(date)] autoApprove not true, exit 0 (allow stop)"
|
|
771
|
-
exit 0
|
|
772
|
-
fi
|
|
773
|
-
|
|
774
739
|
# 确定 MCP Server 地址(本地优先,失败则尝试远程 channel 配置)
|
|
775
740
|
MCP_BASE_URL="http://127.0.0.1:$MCP_PORT"
|
|
776
741
|
AUTH_ARGS=()
|
|
@@ -810,7 +775,7 @@ if [[ -z "$CC_ID" ]]; then
|
|
|
810
775
|
exit 0
|
|
811
776
|
fi
|
|
812
777
|
|
|
813
|
-
#
|
|
778
|
+
# 处于微信模式,需要恢复轮询
|
|
814
779
|
# 使用 exit code 2 阻止停止,并提示 Claude 调用 MCP 工具
|
|
815
780
|
log_debug "[$(date)] ✅ WeChat mode active, blocking stop to resume polling"
|
|
816
781
|
log_debug "[$(date)] ccId=$CC_ID, will prompt Claude to call get_pending_messages"
|
package/dist/http-server.js
CHANGED
|
@@ -622,6 +622,10 @@ export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
|
|
|
622
622
|
handleApprovalStatus(req, res, url);
|
|
623
623
|
return;
|
|
624
624
|
}
|
|
625
|
+
if (req.method === 'GET' && url.startsWith('/approval/')) {
|
|
626
|
+
handleApprovalDetail(req, res, url);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
625
629
|
if (req.method === 'POST' && url.startsWith('/approval_timeout/')) {
|
|
626
630
|
await handleApprovalTimeout(req, res, url);
|
|
627
631
|
return;
|
|
@@ -877,7 +881,7 @@ async function handleApprovalRequest(req, res) {
|
|
|
877
881
|
res.end(JSON.stringify({ error: '未连接机器人,请先进入微信模式' }));
|
|
878
882
|
return;
|
|
879
883
|
}
|
|
880
|
-
const { tool_name, tool_input } = request;
|
|
884
|
+
const { tool_name, tool_input, projectDir } = request;
|
|
881
885
|
let description = '';
|
|
882
886
|
if (tool_name === 'Bash') {
|
|
883
887
|
description = `执行命令: ${tool_input?.command || '(unknown)'}`;
|
|
@@ -890,8 +894,12 @@ async function handleApprovalRequest(req, res) {
|
|
|
890
894
|
}
|
|
891
895
|
const title = `【待审批】${tool_name}`;
|
|
892
896
|
const requestId = `hook_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
893
|
-
|
|
894
|
-
|
|
897
|
+
// 构建卡片"详情"链接的 base(同源,从本请求的 Host/scheme 推断)
|
|
898
|
+
const scheme = req.socket.encrypted ? 'https' : 'http';
|
|
899
|
+
const host = req.headers.host || `127.0.0.1:${HTTP_PORT}`;
|
|
900
|
+
const detailUrlBase = `${scheme}://${host}/approval`;
|
|
901
|
+
const taskId = await client.sendApprovalRequest(title, description, requestId, undefined, tool_input, ccId, detailUrlBase);
|
|
902
|
+
logger.log(`[http] 审批请求已发送: ${taskId} (机器人: ${robotName}) 详情页: ${detailUrlBase}/${taskId}`);
|
|
895
903
|
// 存储审批并启动超时计时器
|
|
896
904
|
const entry = {
|
|
897
905
|
taskId,
|
|
@@ -902,6 +910,8 @@ async function handleApprovalRequest(req, res) {
|
|
|
902
910
|
tool_input,
|
|
903
911
|
description,
|
|
904
912
|
robotName,
|
|
913
|
+
ccId,
|
|
914
|
+
projectDir,
|
|
905
915
|
};
|
|
906
916
|
pendingApprovals.set(taskId, entry);
|
|
907
917
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -913,6 +923,84 @@ async function handleApprovalRequest(req, res) {
|
|
|
913
923
|
res.end(JSON.stringify({ error: err.message }));
|
|
914
924
|
}
|
|
915
925
|
}
|
|
926
|
+
function escapeHtml(raw) {
|
|
927
|
+
return raw
|
|
928
|
+
.replace(/&/g, '&')
|
|
929
|
+
.replace(/</g, '<')
|
|
930
|
+
.replace(/>/g, '>')
|
|
931
|
+
.replace(/"/g, '"')
|
|
932
|
+
.replace(/'/g, ''');
|
|
933
|
+
}
|
|
934
|
+
function handleApprovalDetail(_req, res, url) {
|
|
935
|
+
const taskId = url.replace('/approval/', '');
|
|
936
|
+
const entry = pendingApprovals.get(taskId);
|
|
937
|
+
const respondHtml = (status, body) => {
|
|
938
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
939
|
+
res.end(body);
|
|
940
|
+
};
|
|
941
|
+
if (!entry) {
|
|
942
|
+
respondHtml(404, `<!doctype html><meta charset="utf-8"><title>审批不存在</title>
|
|
943
|
+
<body style="font-family:-apple-system,system-ui,sans-serif;padding:24px;color:#333">
|
|
944
|
+
<h2>审批已过期或不存在</h2>
|
|
945
|
+
<p>TaskID: <code>${escapeHtml(taskId)}</code></p>
|
|
946
|
+
<p>此条记录可能已被清理(用户已决策或超时)。</p>
|
|
947
|
+
</body>`);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const inputPretty = (() => {
|
|
951
|
+
try {
|
|
952
|
+
return JSON.stringify(entry.tool_input ?? {}, null, 2);
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
return String(entry.tool_input);
|
|
956
|
+
}
|
|
957
|
+
})();
|
|
958
|
+
const statusLabel = {
|
|
959
|
+
'pending': '⏳ 待审批',
|
|
960
|
+
'allow-once': '✅ 已允许',
|
|
961
|
+
'deny': '❌ 已拒绝',
|
|
962
|
+
}[entry.status] ?? entry.status;
|
|
963
|
+
const html = `<!doctype html>
|
|
964
|
+
<html>
|
|
965
|
+
<head>
|
|
966
|
+
<meta charset="utf-8">
|
|
967
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
968
|
+
<title>审批详情 · ${escapeHtml(entry.tool_name)}</title>
|
|
969
|
+
<style>
|
|
970
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
971
|
+
max-width: 780px; margin: 0 auto; padding: 16px; color: #222; background: #f7f7f9; }
|
|
972
|
+
h1 { font-size: 20px; margin: 8px 0 16px; }
|
|
973
|
+
.meta { background: #fff; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px;
|
|
974
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.04); }
|
|
975
|
+
.meta .row { display: flex; padding: 4px 0; border-bottom: 1px dashed #eee; }
|
|
976
|
+
.meta .row:last-child { border-bottom: none; }
|
|
977
|
+
.meta .k { width: 96px; color: #888; flex-shrink: 0; }
|
|
978
|
+
.meta .v { flex: 1; word-break: break-all; }
|
|
979
|
+
pre { background: #fff; border-radius: 8px; padding: 12px 16px;
|
|
980
|
+
overflow-x: auto; font-size: 13px; line-height: 1.5;
|
|
981
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.04); white-space: pre-wrap; word-break: break-all; }
|
|
982
|
+
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px;
|
|
983
|
+
background: #eef; color: #446; }
|
|
984
|
+
footer { color: #aaa; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
985
|
+
</style>
|
|
986
|
+
</head>
|
|
987
|
+
<body>
|
|
988
|
+
<h1>审批详情</h1>
|
|
989
|
+
<div class="meta">
|
|
990
|
+
<div class="row"><div class="k">状态</div><div class="v">${statusLabel}</div></div>
|
|
991
|
+
<div class="row"><div class="k">工具</div><div class="v"><span class="tag">${escapeHtml(entry.tool_name)}</span></div></div>
|
|
992
|
+
<div class="row"><div class="k">概要</div><div class="v">${escapeHtml(entry.description)}</div></div>
|
|
993
|
+
${entry.projectDir ? `<div class="row"><div class="k">项目目录</div><div class="v">${escapeHtml(entry.projectDir)}</div></div>` : ''}
|
|
994
|
+
${entry.ccId ? `<div class="row"><div class="k">CC</div><div class="v">${escapeHtml(entry.ccId)}</div></div>` : ''}
|
|
995
|
+
<div class="row"><div class="k">TaskID</div><div class="v"><code>${escapeHtml(taskId)}</code></div></div>
|
|
996
|
+
</div>
|
|
997
|
+
<h3>完整参数</h3>
|
|
998
|
+
<pre>${escapeHtml(inputPretty)}</pre>
|
|
999
|
+
<footer>此页面随审批记录自动过期清理 · 请回到企业微信卡片点击审批按钮</footer>
|
|
1000
|
+
</body>
|
|
1001
|
+
</html>`;
|
|
1002
|
+
respondHtml(200, html);
|
|
1003
|
+
}
|
|
916
1004
|
function handleApprovalStatus(_req, res, url) {
|
|
917
1005
|
const taskId = url.replace('/approval_status/', '');
|
|
918
1006
|
const entry = pendingApprovals.get(taskId);
|
package/dist/project-config.d.ts
CHANGED
package/dist/project-config.js
CHANGED
|
@@ -270,6 +270,14 @@ const STOP_HOOK = {
|
|
|
270
270
|
matcher: '',
|
|
271
271
|
hooks: [{ type: 'command', command: STOP_HOOK_SCRIPT_PATH }],
|
|
272
272
|
};
|
|
273
|
+
/**
|
|
274
|
+
* 进入微信模式时默认预批的 MCP 工具通配(避免每次都走 hook 增加延迟)
|
|
275
|
+
* hook 本身对 mcp__* 也会放行,加入 allow 只是让 Claude Code 跳过 hook。
|
|
276
|
+
*/
|
|
277
|
+
const DEFAULT_MCP_ALLOW = [
|
|
278
|
+
'mcp__wecom-aibot__*',
|
|
279
|
+
'mcp__wecom-aibot-channel__*',
|
|
280
|
+
];
|
|
273
281
|
/**
|
|
274
282
|
* 添加 PermissionRequest hook 到项目 settings.json
|
|
275
283
|
*/
|
|
@@ -296,6 +304,16 @@ export function addPermissionHook(projectDir) {
|
|
|
296
304
|
settings.hooks = {};
|
|
297
305
|
}
|
|
298
306
|
settings.hooks.PermissionRequest = [PERMISSION_HOOK];
|
|
307
|
+
// 合并默认 MCP 通配到 permissions.allow(去重保序)
|
|
308
|
+
const perms = settings.permissions ?? {};
|
|
309
|
+
const existingAllow = Array.isArray(perms.allow) ? perms.allow : [];
|
|
310
|
+
const merged = [...existingAllow];
|
|
311
|
+
for (const entry of DEFAULT_MCP_ALLOW) {
|
|
312
|
+
if (!merged.includes(entry))
|
|
313
|
+
merged.push(entry);
|
|
314
|
+
}
|
|
315
|
+
perms.allow = merged;
|
|
316
|
+
settings.permissions = perms;
|
|
299
317
|
// 写入配置
|
|
300
318
|
try {
|
|
301
319
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -1,23 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP 工具注册入口
|
|
3
|
-
*
|
|
4
|
-
* 注册以下工具:
|
|
5
|
-
* - send_message: 发送消息
|
|
6
|
-
* - send_approval_request: 发送审批请求
|
|
7
|
-
* - get_approval_result: 获取审批结果
|
|
8
|
-
* - check_connection: 检查连接状态
|
|
9
|
-
* - get_pending_messages: 获取待处理消息
|
|
10
|
-
* - get_setup_guide: 获取安装指南
|
|
11
|
-
* - add_robot_config: 添加机器人配置
|
|
12
|
-
* - list_robots: 列出所有机器人
|
|
13
|
-
* - get_robot_status: 获取机器人状态
|
|
14
|
-
* - enter_headless_mode: 进入微信模式
|
|
15
|
-
* - exit_headless_mode: 退出微信模式
|
|
16
|
-
* - detect_user_from_message: 从消息识别用户
|
|
17
|
-
*
|
|
18
|
-
* v2.0 架构变更:
|
|
19
|
-
* - 不再使用 projectDir 参数
|
|
20
|
-
* - 从 Session 自动获取 robotName
|
|
21
|
-
*/
|
|
22
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
2
|
export declare function registerTools(server: McpServer): void;
|
package/dist/tools/index.js
CHANGED
|
@@ -1,24 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* MCP 工具注册入口
|
|
3
|
-
*
|
|
4
|
-
* 注册以下工具:
|
|
5
|
-
* - send_message: 发送消息
|
|
6
|
-
* - send_approval_request: 发送审批请求
|
|
7
|
-
* - get_approval_result: 获取审批结果
|
|
8
|
-
* - check_connection: 检查连接状态
|
|
9
|
-
* - get_pending_messages: 获取待处理消息
|
|
10
|
-
* - get_setup_guide: 获取安装指南
|
|
11
|
-
* - add_robot_config: 添加机器人配置
|
|
12
|
-
* - list_robots: 列出所有机器人
|
|
13
|
-
* - get_robot_status: 获取机器人状态
|
|
14
|
-
* - enter_headless_mode: 进入微信模式
|
|
15
|
-
* - exit_headless_mode: 退出微信模式
|
|
16
|
-
* - detect_user_from_message: 从消息识别用户
|
|
17
|
-
*
|
|
18
|
-
* v2.0 架构变更:
|
|
19
|
-
* - 不再使用 projectDir 参数
|
|
20
|
-
* - 从 Session 自动获取 robotName
|
|
21
|
-
*/
|
|
1
|
+
// MCP 工具注册入口。完整工具清单见 design/tools-api.md。
|
|
22
2
|
import { z } from 'zod';
|
|
23
3
|
import { listAllRobots, getDocMcpUrl, installSkill, VERSION } from '../config-wizard.js';
|
|
24
4
|
import { callDocTool } from '../doc-proxy.js';
|
|
@@ -356,9 +336,8 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
356
336
|
project_dir: z.string().optional().describe('项目目录路径(用于写入配置文件)'),
|
|
357
337
|
mode: z.enum(['channel', 'http']).optional().default('http')
|
|
358
338
|
.describe('运行模式:channel=SSE推送(推荐),http=轮询(兼容)'),
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve, auto_approve_timeout }, extra) => {
|
|
339
|
+
auto_approve_timeout: z.number().optional().default(300).describe('超时自动决策等待时间(秒,默认 300 即 5 分钟)'),
|
|
340
|
+
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve_timeout }, extra) => {
|
|
362
341
|
// 获取项目目录
|
|
363
342
|
const projectDir = project_dir || process.cwd();
|
|
364
343
|
// 智能体名称(用于生成 ccId)
|
|
@@ -429,7 +408,6 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
429
408
|
wechatMode: true,
|
|
430
409
|
robotName: selectedRobot.name,
|
|
431
410
|
ccId: finalCcId,
|
|
432
|
-
autoApprove: auto_approve,
|
|
433
411
|
autoApproveTimeout: auto_approve_timeout,
|
|
434
412
|
mode,
|
|
435
413
|
});
|