@vrs-soft/wecom-aibot-mcp 1.4.0 → 1.5.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 +33 -1
- package/dist/approval-manager.d.ts +38 -0
- package/dist/approval-manager.js +129 -0
- package/dist/bin.js +83 -2
- package/dist/cc-registry.d.ts +62 -0
- package/dist/cc-registry.js +278 -0
- package/dist/config-wizard.d.ts +4 -0
- package/dist/config-wizard.js +134 -16
- package/dist/connection-manager.d.ts +2 -0
- package/dist/connection-manager.js +15 -14
- package/dist/http-server.d.ts +10 -17
- package/dist/http-server.js +61 -106
- package/dist/project-config.d.ts +41 -0
- package/dist/project-config.js +132 -0
- package/dist/tools/headless.d.ts +8 -0
- package/dist/tools/headless.js +248 -0
- package/dist/tools/index.js +59 -72
- package/dist/tools/messaging.d.ts +7 -0
- package/dist/tools/messaging.js +170 -0
- package/dist/tools/utils-tools.d.ts +11 -0
- package/dist/tools/utils-tools.js +249 -0
- package/dist/utils/atomic-write.d.ts +4 -0
- package/dist/utils/atomic-write.js +9 -0
- package/dist/utils/sanitize.d.ts +59 -0
- package/dist/utils/sanitize.js +246 -0
- package/package.json +1 -1
- package/skills/headless-mode/SKILL.md +77 -22
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @vrs-soft/wecom-aibot-mcp
|
|
2
2
|
|
|
3
|
+
中文 | [English](README_EN.md)
|
|
4
|
+
|
|
3
5
|
企业微信智能机器人 MCP 服务 - Claude Code 远程审批通道
|
|
4
6
|
|
|
5
7
|
> 通过企业微信智能机器人实现 Claude Code 的远程审批和消息推送,离开电脑也能处理决策请求。
|
|
@@ -9,7 +11,6 @@
|
|
|
9
11
|
- 🔐 **远程审批**:敏感操作通过微信卡片审批,支持"允许一次/拒绝"
|
|
10
12
|
- 💬 **双向通信**:任务进度、完成通知实时推送到微信
|
|
11
13
|
- 📱 **Headless 模式**:离开电脑时切换到微信交互,长轮询实时接收消息
|
|
12
|
-
- 🔄 **智能代批**:超时自动审批,项目内操作允许,删除操作拒绝
|
|
13
14
|
- 🤖 **多机器人支持**:支持配置多个机器人,团队场景下多人独立使用
|
|
14
15
|
- 🌐 **HTTP Transport**:使用 HTTP 传输,支持多实例共享服务
|
|
15
16
|
|
|
@@ -143,6 +144,7 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
143
144
|
| `npx @vrs-soft/wecom-aibot-mcp --add` | 添加新机器人 |
|
|
144
145
|
| `npx @vrs-soft/wecom-aibot-mcp --delete` | 删除机器人配置 |
|
|
145
146
|
| `npx @vrs-soft/wecom-aibot-mcp --uninstall` | 完全卸载 |
|
|
147
|
+
| `npx @vrs-soft/wecom-aibot-mcp --debug` | 前台启动(输出调试日志) |
|
|
146
148
|
|
|
147
149
|
### 添加新机器人
|
|
148
150
|
|
|
@@ -290,6 +292,36 @@ Claude:执行命令,发送结果到群聊
|
|
|
290
292
|
}
|
|
291
293
|
```
|
|
292
294
|
|
|
295
|
+
### 超时审批配置
|
|
296
|
+
|
|
297
|
+
在 `~/.wecom-aibot-mcp/config.json` 中可配置审批超时时间:
|
|
298
|
+
|
|
299
|
+
```json
|
|
300
|
+
{
|
|
301
|
+
"botId": "bot-xxx",
|
|
302
|
+
"secret": "sec-yyy",
|
|
303
|
+
"targetUserId": "user1",
|
|
304
|
+
"nameTag": "机器人1",
|
|
305
|
+
"autoApproveTimeout": 600
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
- `autoApproveTimeout`: 审批超时时间(秒),默认 600 秒(10 分钟)
|
|
310
|
+
- 超时后,项目目录内的操作会自动允许,项目外的操作会自动拒绝
|
|
311
|
+
|
|
312
|
+
### 调试模式
|
|
313
|
+
|
|
314
|
+
使用 `--debug` 启动可在终端查看 hook 脚本的调试日志:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
npx @vrs-soft/wecom-aibot-mcp --debug
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
调试日志会输出到 stderr,包括:
|
|
321
|
+
- 审批请求拦截信息
|
|
322
|
+
- 超时时间配置
|
|
323
|
+
- 操作类型判断详情
|
|
324
|
+
|
|
293
325
|
## 故障排查
|
|
294
326
|
|
|
295
327
|
### 认证失败(错误码 40058)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 审批管理器
|
|
3
|
+
*
|
|
4
|
+
* 负责:
|
|
5
|
+
* 1. 存储 pendingApprovals Map(http-server 审批记录)
|
|
6
|
+
* 2. 持久化到 approval-state.json,MCP 重启后恢复
|
|
7
|
+
* 3. 恢复时将 approvalRecord 注入对应的 WecomClient
|
|
8
|
+
*
|
|
9
|
+
* 与 WecomClient.approvals 的关系:
|
|
10
|
+
* - WecomClient.approvals 记录企业微信卡片状态(用户点击后更新)
|
|
11
|
+
* - approval-manager 记录 http-server 层的审批条目
|
|
12
|
+
* - MCP 重启后,WecomClient 实例是全新的,需要 injectApprovalRecord 恢复
|
|
13
|
+
*/
|
|
14
|
+
export interface ApprovalEntry {
|
|
15
|
+
taskId: string;
|
|
16
|
+
status: 'pending' | 'allow-once' | 'allow-always' | 'deny';
|
|
17
|
+
timestamp: number;
|
|
18
|
+
tool_name: string;
|
|
19
|
+
tool_input: Record<string, unknown>;
|
|
20
|
+
description: string;
|
|
21
|
+
robotName: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 设置配置目录(仅用于测试)
|
|
25
|
+
*/
|
|
26
|
+
export declare function setConfigDir(dir: string): void;
|
|
27
|
+
export declare function addApproval(entry: ApprovalEntry): void;
|
|
28
|
+
export declare function getApproval(taskId: string): ApprovalEntry | undefined;
|
|
29
|
+
export declare function updateApprovalStatus(taskId: string, status: 'allow-once' | 'allow-always' | 'deny'): void;
|
|
30
|
+
export declare function getPendingApprovals(): Map<string, ApprovalEntry>;
|
|
31
|
+
export declare function saveApprovalState(): void;
|
|
32
|
+
/**
|
|
33
|
+
* 从文件恢复审批状态,并将审批记录注入对应的 WecomClient
|
|
34
|
+
* 需在 connectAllRobots() 完成后调用,确保 client 已存在
|
|
35
|
+
*/
|
|
36
|
+
export declare function loadApprovalState(getClientFn: (robotName: string) => Promise<import('./client.js').WecomClient | null>): Promise<void>;
|
|
37
|
+
export declare function startAutoSave(): void;
|
|
38
|
+
export declare function stopAutoSave(): void;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 审批管理器
|
|
3
|
+
*
|
|
4
|
+
* 负责:
|
|
5
|
+
* 1. 存储 pendingApprovals Map(http-server 审批记录)
|
|
6
|
+
* 2. 持久化到 approval-state.json,MCP 重启后恢复
|
|
7
|
+
* 3. 恢复时将 approvalRecord 注入对应的 WecomClient
|
|
8
|
+
*
|
|
9
|
+
* 与 WecomClient.approvals 的关系:
|
|
10
|
+
* - WecomClient.approvals 记录企业微信卡片状态(用户点击后更新)
|
|
11
|
+
* - approval-manager 记录 http-server 层的审批条目
|
|
12
|
+
* - MCP 重启后,WecomClient 实例是全新的,需要 injectApprovalRecord 恢复
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import * as os from 'os';
|
|
17
|
+
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
18
|
+
const pendingApprovals = new Map();
|
|
19
|
+
// 支持测试环境覆盖
|
|
20
|
+
let CONFIG_DIR = path.join(os.homedir(), '.wecom-aibot-mcp');
|
|
21
|
+
let APPROVAL_STATE_FILE = path.join(CONFIG_DIR, 'approval-state.json');
|
|
22
|
+
/**
|
|
23
|
+
* 设置配置目录(仅用于测试)
|
|
24
|
+
*/
|
|
25
|
+
export function setConfigDir(dir) {
|
|
26
|
+
CONFIG_DIR = dir;
|
|
27
|
+
APPROVAL_STATE_FILE = path.join(CONFIG_DIR, 'approval-state.json');
|
|
28
|
+
}
|
|
29
|
+
let saveInterval = null;
|
|
30
|
+
// ────────────────────────────────────────────
|
|
31
|
+
// 审批 CRUD
|
|
32
|
+
// ────────────────────────────────────────────
|
|
33
|
+
export function addApproval(entry) {
|
|
34
|
+
pendingApprovals.set(entry.taskId, entry);
|
|
35
|
+
}
|
|
36
|
+
export function getApproval(taskId) {
|
|
37
|
+
return pendingApprovals.get(taskId);
|
|
38
|
+
}
|
|
39
|
+
export function updateApprovalStatus(taskId, status) {
|
|
40
|
+
const entry = pendingApprovals.get(taskId);
|
|
41
|
+
if (entry) {
|
|
42
|
+
entry.status = status;
|
|
43
|
+
// 审批完成后从 Map 中移除,避免 pendingApprovals.size 持续增长
|
|
44
|
+
pendingApprovals.delete(taskId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function getPendingApprovals() {
|
|
48
|
+
return pendingApprovals;
|
|
49
|
+
}
|
|
50
|
+
// ────────────────────────────────────────────
|
|
51
|
+
// 持久化
|
|
52
|
+
// ────────────────────────────────────────────
|
|
53
|
+
export function saveApprovalState() {
|
|
54
|
+
const approvals = [];
|
|
55
|
+
for (const [taskId, entry] of pendingApprovals) {
|
|
56
|
+
if (entry.status === 'pending') {
|
|
57
|
+
approvals.push({ taskId, entry });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 无待处理审批时不创建文件
|
|
61
|
+
if (approvals.length === 0)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
65
|
+
atomicWriteFileSync(APPROVAL_STATE_FILE, JSON.stringify({ approvals, savedAt: Date.now() }, null, 2));
|
|
66
|
+
console.log(`[approval-manager] 已保存 ${approvals.length} 个待处理审批`);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error('[approval-manager] 保存审批状态失败:', err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 从文件恢复审批状态,并将审批记录注入对应的 WecomClient
|
|
74
|
+
* 需在 connectAllRobots() 完成后调用,确保 client 已存在
|
|
75
|
+
*/
|
|
76
|
+
export async function loadApprovalState(getClientFn) {
|
|
77
|
+
if (!fs.existsSync(APPROVAL_STATE_FILE))
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
const content = fs.readFileSync(APPROVAL_STATE_FILE, 'utf-8');
|
|
81
|
+
const state = JSON.parse(content);
|
|
82
|
+
// 只恢复 10 分钟内的 pending 审批(超时的不再有效)
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const maxAge = 10 * 60 * 1000;
|
|
85
|
+
let restored = 0;
|
|
86
|
+
for (const { taskId, entry } of state.approvals) {
|
|
87
|
+
if (entry.status === 'pending' && now - entry.timestamp < maxAge) {
|
|
88
|
+
pendingApprovals.set(taskId, entry);
|
|
89
|
+
// 将审批记录注入对应 WecomClient,使用户点击后能正确路由
|
|
90
|
+
const client = await getClientFn(entry.robotName);
|
|
91
|
+
if (client) {
|
|
92
|
+
client.injectApprovalRecord(taskId, {
|
|
93
|
+
toolName: entry.tool_name,
|
|
94
|
+
toolInput: entry.tool_input,
|
|
95
|
+
});
|
|
96
|
+
console.log(`[approval-manager] 恢复审批: ${taskId} → robot=${entry.robotName}`);
|
|
97
|
+
restored++;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.warn(`[approval-manager] 恢复审批 ${taskId} 失败:机器人 ${entry.robotName} 不在线`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 恢复完成,删除持久化文件
|
|
105
|
+
fs.unlinkSync(APPROVAL_STATE_FILE);
|
|
106
|
+
console.log(`[approval-manager] 共恢复 ${restored} 个审批`);
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.warn('[approval-manager] 恢复审批状态失败:', err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// ────────────────────────────────────────────
|
|
113
|
+
// 定时保存
|
|
114
|
+
// ────────────────────────────────────────────
|
|
115
|
+
export function startAutoSave() {
|
|
116
|
+
if (saveInterval)
|
|
117
|
+
return;
|
|
118
|
+
saveInterval = setInterval(() => {
|
|
119
|
+
if (pendingApprovals.size > 0) {
|
|
120
|
+
saveApprovalState();
|
|
121
|
+
}
|
|
122
|
+
}, 30000);
|
|
123
|
+
}
|
|
124
|
+
export function stopAutoSave() {
|
|
125
|
+
if (saveInterval) {
|
|
126
|
+
clearInterval(saveInterval);
|
|
127
|
+
saveInterval = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/bin.js
CHANGED
|
@@ -13,7 +13,7 @@ import { spawn } from 'child_process';
|
|
|
13
13
|
import * as fs from 'fs';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import * as os from 'os';
|
|
16
|
-
import { runConfigWizard, loadConfig, saveConfig, deleteConfig, deleteMcpConfigInteractive, uninstall, addMcpConfig, detectUserIdFromMessage, ensureHookInstalled, listAllRobots, } from './config-wizard.js';
|
|
16
|
+
import { runConfigWizard, loadConfig, saveConfig, deleteConfig, deleteMcpConfigInteractive, uninstall, addMcpConfig, detectUserIdFromMessage, ensureHookInstalled, listAllRobots, ensureGlobalConfigs, } from './config-wizard.js';
|
|
17
17
|
import { initClient } from './client.js';
|
|
18
18
|
import { registerTools } from './tools/index.js';
|
|
19
19
|
import { startHttpServer, stopHttpServer, HTTP_PORT } from './http-server.js';
|
|
@@ -21,7 +21,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
21
21
|
import { getAllConnectionStates } from './connection-manager.js';
|
|
22
22
|
import { loadStats, cleanupOldLogs } from './connection-log.js';
|
|
23
23
|
import { startKeepaliveMonitor, stopKeepaliveMonitor } from './keepalive-monitor.js';
|
|
24
|
-
const VERSION = '1.2
|
|
24
|
+
const VERSION = '1.4.2';
|
|
25
25
|
const PID_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'server.pid');
|
|
26
26
|
function showHelp() {
|
|
27
27
|
console.log(`
|
|
@@ -36,6 +36,8 @@ function showHelp() {
|
|
|
36
36
|
选项:
|
|
37
37
|
--help, -h 显示帮助信息
|
|
38
38
|
--version, -v 显示版本号
|
|
39
|
+
--upgrade 强制升级全局配置(覆盖 MCP 配置、权限、skill)
|
|
40
|
+
--reinstall 重新安装全局配置(删除后重新写入,保留机器人配置)
|
|
39
41
|
--start 启动 MCP Server(后台服务模式)
|
|
40
42
|
--stop 停止 MCP Server
|
|
41
43
|
--debug 前台启动 MCP Server(日志直接输出到终端,用于调试)
|
|
@@ -256,6 +258,11 @@ function startMcpServerBackground() {
|
|
|
256
258
|
}
|
|
257
259
|
async function main() {
|
|
258
260
|
const args = process.argv.slice(2);
|
|
261
|
+
// --reinstall 命令需要先删除再安装,跳过开头的 ensureGlobalConfigs
|
|
262
|
+
if (!args.includes('--reinstall')) {
|
|
263
|
+
// 强制覆盖所有全局配置(不依赖智能体)
|
|
264
|
+
ensureGlobalConfigs();
|
|
265
|
+
}
|
|
259
266
|
// 解析命令行参数
|
|
260
267
|
if (args.includes('--help') || args.includes('-h')) {
|
|
261
268
|
showHelp();
|
|
@@ -265,6 +272,75 @@ async function main() {
|
|
|
265
272
|
showVersion();
|
|
266
273
|
process.exit(0);
|
|
267
274
|
}
|
|
275
|
+
// --upgrade 命令:强制升级全局配置(已在启动时执行,这里显示结果)
|
|
276
|
+
if (args.includes('--upgrade')) {
|
|
277
|
+
console.log('\n[mcp] ✅ 全局配置已更新完成!');
|
|
278
|
+
console.log('[mcp] 配置位置:');
|
|
279
|
+
console.log(' - ~/.claude.json (MCP Server 配置)');
|
|
280
|
+
console.log(' - ~/.claude/settings.local.json (权限和 Hook)');
|
|
281
|
+
console.log(' - ~/.claude/skills/headless-mode/ (Skill)');
|
|
282
|
+
console.log(' - ~/.wecom-aibot-mcp/version.json (版本记录)');
|
|
283
|
+
console.log('\n[mcp] 请重启 Claude Code 以加载最新配置');
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
// --reinstall 命令:删除所有全局配置(保留机器人配置)后重新安装
|
|
287
|
+
if (args.includes('--reinstall')) {
|
|
288
|
+
console.log('\n[mcp] 重新安装全局配置...');
|
|
289
|
+
console.log('[mcp] 保留所有机器人配置: ~/.wecom-aibot-mcp/config.json 和 robot-*.json');
|
|
290
|
+
const CLAUDE_CONFIG_FILE = path.join(os.homedir(), '.claude.json');
|
|
291
|
+
const CLAUDE_SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.local.json');
|
|
292
|
+
const SKILL_DIR = path.join(os.homedir(), '.claude', 'skills', 'headless-mode');
|
|
293
|
+
const VERSION_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'version.json');
|
|
294
|
+
const HOOK_SCRIPT = path.join(os.homedir(), '.wecom-aibot-mcp', 'permission-hook.sh');
|
|
295
|
+
// 1. 删除 ~/.claude.json 中的 wecom-aibot 配置
|
|
296
|
+
if (fs.existsSync(CLAUDE_CONFIG_FILE)) {
|
|
297
|
+
const content = fs.readFileSync(CLAUDE_CONFIG_FILE, 'utf-8');
|
|
298
|
+
const config = JSON.parse(content);
|
|
299
|
+
if (config.mcpServers?.['wecom-aibot']) {
|
|
300
|
+
delete config.mcpServers['wecom-aibot'];
|
|
301
|
+
fs.writeFileSync(CLAUDE_CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
302
|
+
console.log('[mcp] 已删除 ~/.claude.json 中的 wecom-aibot 配置');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// 2. 删除 ~/.claude/settings.local.json 中的权限和 Hook
|
|
306
|
+
if (fs.existsSync(CLAUDE_SETTINGS_FILE)) {
|
|
307
|
+
const content = fs.readFileSync(CLAUDE_SETTINGS_FILE, 'utf-8');
|
|
308
|
+
const config = JSON.parse(content);
|
|
309
|
+
if (config.permissions?.allow) {
|
|
310
|
+
config.permissions.allow = config.permissions.allow.filter((p) => !p.startsWith('mcp__wecom-aibot__'));
|
|
311
|
+
console.log('[mcp] 已删除 wecom-aibot 工具权限');
|
|
312
|
+
}
|
|
313
|
+
if (config.hooks?.PermissionRequest) {
|
|
314
|
+
config.hooks.PermissionRequest = config.hooks.PermissionRequest.filter((h) => !h.hooks?.some?.((hook) => hook.command?.includes?.('wecom-aibot-mcp')));
|
|
315
|
+
if (config.hooks.PermissionRequest.length === 0) {
|
|
316
|
+
delete config.hooks.PermissionRequest;
|
|
317
|
+
}
|
|
318
|
+
console.log('[mcp] 已删除 PermissionRequest hook');
|
|
319
|
+
}
|
|
320
|
+
fs.writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(config, null, 2));
|
|
321
|
+
}
|
|
322
|
+
// 3. 删除 skill 目录
|
|
323
|
+
if (fs.existsSync(SKILL_DIR)) {
|
|
324
|
+
fs.rmSync(SKILL_DIR, { recursive: true });
|
|
325
|
+
console.log('[mcp] 已删除 ~/.claude/skills/headless-mode/');
|
|
326
|
+
}
|
|
327
|
+
// 4. 删除版本文件
|
|
328
|
+
if (fs.existsSync(VERSION_FILE)) {
|
|
329
|
+
fs.unlinkSync(VERSION_FILE);
|
|
330
|
+
console.log('[mcp] 已删除 ~/.wecom-aibot-mcp/version.json');
|
|
331
|
+
}
|
|
332
|
+
// 5. 删除 hook 脚本
|
|
333
|
+
if (fs.existsSync(HOOK_SCRIPT)) {
|
|
334
|
+
fs.unlinkSync(HOOK_SCRIPT);
|
|
335
|
+
console.log('[mcp] 已删除 ~/.wecom-aibot-mcp/permission-hook.sh');
|
|
336
|
+
}
|
|
337
|
+
// 6. 重新安装全局配置
|
|
338
|
+
console.log('\n[mcp] 正在重新安装...');
|
|
339
|
+
ensureGlobalConfigs();
|
|
340
|
+
console.log('\n[mcp] ✅ 重新安装完成!');
|
|
341
|
+
console.log('[mcp] 请重启 Claude Code 以加载最新配置');
|
|
342
|
+
process.exit(0);
|
|
343
|
+
}
|
|
268
344
|
if (args.includes('--status') || args.includes('--list')) {
|
|
269
345
|
showStatus();
|
|
270
346
|
process.exit(0);
|
|
@@ -302,7 +378,12 @@ async function main() {
|
|
|
302
378
|
// --debug:前台启动,日志直接输出到终端
|
|
303
379
|
if (args.includes('--debug')) {
|
|
304
380
|
console.log('[mcp] Debug 模式:前台运行,Ctrl+C 退出');
|
|
381
|
+
// 写入 debug 标记文件,hook 脚本检测后日志输出到 stderr
|
|
382
|
+
const debugFile = path.join(os.homedir(), '.wecom-aibot-mcp', 'debug');
|
|
383
|
+
fs.writeFileSync(debugFile, 'true');
|
|
305
384
|
await startMcpServerForeground();
|
|
385
|
+
// 退出时删除标记文件
|
|
386
|
+
fs.unlinkSync(debugFile);
|
|
306
387
|
return;
|
|
307
388
|
}
|
|
308
389
|
// --start:后台启动
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccId 注册表
|
|
3
|
+
*
|
|
4
|
+
* 管理 ~/.wecom-aibot-mcp/cc-registry.json
|
|
5
|
+
* 维护 ccId → { robotName, lastActive, createdAt } 的映射
|
|
6
|
+
*
|
|
7
|
+
* 文件锁通过 .lock 文件实现(EEXIST 原子性)
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 设置配置目录(仅用于测试)
|
|
11
|
+
*/
|
|
12
|
+
export declare function setConfigDir(dir: string): void;
|
|
13
|
+
export interface CcRegistryEntry {
|
|
14
|
+
robotName: string;
|
|
15
|
+
lastActive: number;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
lastNotified?: number;
|
|
18
|
+
}
|
|
19
|
+
type Registry = Record<string, CcRegistryEntry>;
|
|
20
|
+
/**
|
|
21
|
+
* 公开接口:独立调用时使用(内部会获取锁)
|
|
22
|
+
*/
|
|
23
|
+
export declare function cleanupExpiredEntries(): void;
|
|
24
|
+
export type RegisterResult = 'registered' | 'renewed' | 'occupied';
|
|
25
|
+
/**
|
|
26
|
+
* 注册 ccId
|
|
27
|
+
* - 新 ccId → registered
|
|
28
|
+
* - 已存在且 robotName 相同 → renewed(续期)
|
|
29
|
+
* - 已存在且 robotName 不同 → occupied(被占用)
|
|
30
|
+
*/
|
|
31
|
+
export declare function registerCcId(ccId: string, robotName: string): RegisterResult;
|
|
32
|
+
/**
|
|
33
|
+
* 注销 ccId
|
|
34
|
+
*/
|
|
35
|
+
export declare function unregisterCcId(ccId: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* 检查 ccId 是否已注册
|
|
38
|
+
*/
|
|
39
|
+
export declare function isCcIdRegistered(ccId: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* 更新 ccId 的最后活跃时间
|
|
42
|
+
*/
|
|
43
|
+
export declare function touchCcId(ccId: string): void;
|
|
44
|
+
/**
|
|
45
|
+
* 获取 ccId 绑定的机器人名称
|
|
46
|
+
*/
|
|
47
|
+
export declare function getCcIdBinding(ccId: string): {
|
|
48
|
+
robotName: string;
|
|
49
|
+
} | null;
|
|
50
|
+
/**
|
|
51
|
+
* 获取完整注册表(调试用)
|
|
52
|
+
*/
|
|
53
|
+
export declare function getRegistry(): Registry;
|
|
54
|
+
/**
|
|
55
|
+
* 启动心跳检测(5 分钟扫描一次)
|
|
56
|
+
*/
|
|
57
|
+
export declare function startHeartbeatMonitor(): void;
|
|
58
|
+
/**
|
|
59
|
+
* 停止心跳检测
|
|
60
|
+
*/
|
|
61
|
+
export declare function stopHeartbeatMonitor(): void;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ccId 注册表
|
|
3
|
+
*
|
|
4
|
+
* 管理 ~/.wecom-aibot-mcp/cc-registry.json
|
|
5
|
+
* 维护 ccId → { robotName, lastActive, createdAt } 的映射
|
|
6
|
+
*
|
|
7
|
+
* 文件锁通过 .lock 文件实现(EEXIST 原子性)
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
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 天:清理
|
|
16
|
+
// 心跳检测常量
|
|
17
|
+
const OFFLINE_THRESHOLD = 10 * 60 * 1000; // 10 分钟无心跳视为离线
|
|
18
|
+
const NOTIFICATION_INTERVAL = 30 * 60 * 1000; // 30 分钟最多通知一次
|
|
19
|
+
// 支持测试环境覆盖
|
|
20
|
+
let CONFIG_DIR = path.join(os.homedir(), '.wecom-aibot-mcp');
|
|
21
|
+
let REGISTRY_FILE = path.join(CONFIG_DIR, 'cc-registry.json');
|
|
22
|
+
let LOCK_FILE = path.join(CONFIG_DIR, 'cc-registry.lock');
|
|
23
|
+
/**
|
|
24
|
+
* 设置配置目录(仅用于测试)
|
|
25
|
+
*/
|
|
26
|
+
export function setConfigDir(dir) {
|
|
27
|
+
CONFIG_DIR = dir;
|
|
28
|
+
REGISTRY_FILE = path.join(CONFIG_DIR, 'cc-registry.json');
|
|
29
|
+
LOCK_FILE = path.join(CONFIG_DIR, 'cc-registry.lock');
|
|
30
|
+
}
|
|
31
|
+
// ────────────────────────────────────────────
|
|
32
|
+
// 文件锁(基于 EEXIST 原子性)
|
|
33
|
+
// ────────────────────────────────────────────
|
|
34
|
+
function acquireLock() {
|
|
35
|
+
try {
|
|
36
|
+
fs.openSync(LOCK_FILE, 'wx');
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e.code === 'EEXIST')
|
|
41
|
+
return false;
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function releaseLock() {
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(LOCK_FILE);
|
|
48
|
+
}
|
|
49
|
+
catch { /* ignore */ }
|
|
50
|
+
}
|
|
51
|
+
function withLock(fn) {
|
|
52
|
+
const deadline = Date.now() + 3000;
|
|
53
|
+
while (!acquireLock()) {
|
|
54
|
+
if (Date.now() > deadline)
|
|
55
|
+
throw new Error('cc-registry: 获取锁超时');
|
|
56
|
+
// 自旋等待(锁持有时间极短)
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
while (Date.now() - start < 50) { /* busy wait */ }
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return fn();
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
releaseLock();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ────────────────────────────────────────────
|
|
68
|
+
// 注册表 I/O
|
|
69
|
+
// ────────────────────────────────────────────
|
|
70
|
+
function readRegistry() {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(fs.readFileSync(REGISTRY_FILE, 'utf-8'));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function writeRegistry(registry) {
|
|
79
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
80
|
+
atomicWriteFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2));
|
|
81
|
+
}
|
|
82
|
+
// ────────────────────────────────────────────
|
|
83
|
+
// 过期清理
|
|
84
|
+
// ────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* 内部清理:在已持锁的上下文中直接操作传入的 registry 对象。
|
|
87
|
+
* 不获取锁,调用方负责锁安全。
|
|
88
|
+
*/
|
|
89
|
+
function pruneExpired(registry) {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
let changed = false;
|
|
92
|
+
for (const [ccId, entry] of Object.entries(registry)) {
|
|
93
|
+
const inactive = now - entry.lastActive;
|
|
94
|
+
if (inactive > EXPIRY_MS) {
|
|
95
|
+
logWarn(`[cc-registry] 已清理过期 ccId "${ccId}" (robot: ${entry.robotName}),超过 14 天未活跃`);
|
|
96
|
+
delete registry[ccId];
|
|
97
|
+
changed = true;
|
|
98
|
+
}
|
|
99
|
+
else if (inactive > WARNING_MS) {
|
|
100
|
+
logWarn(`[cc-registry] 警告:ccId "${ccId}" (robot: ${entry.robotName}) 已 10 天未活跃,4 天后将自动清理`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return changed;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 公开接口:独立调用时使用(内部会获取锁)
|
|
107
|
+
*/
|
|
108
|
+
export function cleanupExpiredEntries() {
|
|
109
|
+
withLock(() => {
|
|
110
|
+
const registry = readRegistry();
|
|
111
|
+
if (pruneExpired(registry))
|
|
112
|
+
writeRegistry(registry);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 注册 ccId
|
|
117
|
+
* - 新 ccId → registered
|
|
118
|
+
* - 已存在且 robotName 相同 → renewed(续期)
|
|
119
|
+
* - 已存在且 robotName 不同 → occupied(被占用)
|
|
120
|
+
*/
|
|
121
|
+
export function registerCcId(ccId, robotName) {
|
|
122
|
+
return withLock(() => {
|
|
123
|
+
const registry = readRegistry();
|
|
124
|
+
pruneExpired(registry); // 已在锁内,不二次获取
|
|
125
|
+
const existing = registry[ccId];
|
|
126
|
+
if (existing) {
|
|
127
|
+
if (existing.robotName === robotName) {
|
|
128
|
+
// 同 ccId + 同 robotName → 续期
|
|
129
|
+
existing.lastActive = Date.now();
|
|
130
|
+
writeRegistry(registry);
|
|
131
|
+
return 'renewed';
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
return 'occupied';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
registry[ccId] = {
|
|
138
|
+
robotName,
|
|
139
|
+
lastActive: Date.now(),
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
writeRegistry(registry);
|
|
143
|
+
return 'registered';
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 注销 ccId
|
|
148
|
+
*/
|
|
149
|
+
export function unregisterCcId(ccId) {
|
|
150
|
+
withLock(() => {
|
|
151
|
+
const registry = readRegistry();
|
|
152
|
+
delete registry[ccId];
|
|
153
|
+
writeRegistry(registry);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 检查 ccId 是否已注册
|
|
158
|
+
*/
|
|
159
|
+
export function isCcIdRegistered(ccId) {
|
|
160
|
+
const registry = readRegistry();
|
|
161
|
+
return ccId in registry;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 更新 ccId 的最后活跃时间
|
|
165
|
+
*/
|
|
166
|
+
export function touchCcId(ccId) {
|
|
167
|
+
withLock(() => {
|
|
168
|
+
const registry = readRegistry();
|
|
169
|
+
if (registry[ccId]) {
|
|
170
|
+
registry[ccId].lastActive = Date.now();
|
|
171
|
+
writeRegistry(registry);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 获取 ccId 绑定的机器人名称
|
|
177
|
+
*/
|
|
178
|
+
export function getCcIdBinding(ccId) {
|
|
179
|
+
const registry = readRegistry();
|
|
180
|
+
const entry = registry[ccId];
|
|
181
|
+
if (!entry)
|
|
182
|
+
return null;
|
|
183
|
+
return { robotName: entry.robotName };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 获取完整注册表(调试用)
|
|
187
|
+
*/
|
|
188
|
+
export function getRegistry() {
|
|
189
|
+
return readRegistry();
|
|
190
|
+
}
|
|
191
|
+
// ────────────────────────────────────────────
|
|
192
|
+
// 心跳检测
|
|
193
|
+
// ────────────────────────────────────────────
|
|
194
|
+
let heartbeatInterval = null;
|
|
195
|
+
/**
|
|
196
|
+
* 检查 CC 心跳并发送离线通知
|
|
197
|
+
*/
|
|
198
|
+
async function checkCcHeartbeat() {
|
|
199
|
+
const registry = readRegistry();
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
let changed = false;
|
|
202
|
+
for (const [ccId, entry] of Object.entries(registry)) {
|
|
203
|
+
const inactive = now - entry.lastActive;
|
|
204
|
+
if (inactive > OFFLINE_THRESHOLD) {
|
|
205
|
+
// 检查是否需要通知(避免重复)
|
|
206
|
+
const shouldNotify = !entry.lastNotified ||
|
|
207
|
+
(now - entry.lastNotified > NOTIFICATION_INTERVAL);
|
|
208
|
+
if (shouldNotify) {
|
|
209
|
+
await sendOfflineNotification(ccId, entry.robotName);
|
|
210
|
+
entry.lastNotified = now;
|
|
211
|
+
changed = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (changed) {
|
|
216
|
+
withLock(() => {
|
|
217
|
+
const reg = readRegistry();
|
|
218
|
+
for (const [ccId, entry] of Object.entries(registry)) {
|
|
219
|
+
if (entry.lastNotified && reg[ccId]) {
|
|
220
|
+
reg[ccId].lastNotified = entry.lastNotified;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
writeRegistry(reg);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 发送离线通知到微信
|
|
229
|
+
*/
|
|
230
|
+
async function sendOfflineNotification(ccId, robotName) {
|
|
231
|
+
try {
|
|
232
|
+
// 动态导入避免循环依赖
|
|
233
|
+
const { getClient } = await import('./connection-manager.js');
|
|
234
|
+
const client = await getClient(robotName);
|
|
235
|
+
if (!client) {
|
|
236
|
+
console.log(`[heartbeat] 机器人 ${robotName} 未连接,无法发送离线通知`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const registryEntry = getRegistry()[ccId];
|
|
240
|
+
const inactiveMinutes = registryEntry ? Math.floor((Date.now() - registryEntry.lastActive) / 60000) : 0;
|
|
241
|
+
const message = `【系统警告】CC "${ccId}" 已超过 ${inactiveMinutes} 分钟无心跳,可能已离线。
|
|
242
|
+
|
|
243
|
+
可能原因:
|
|
244
|
+
• CC 进程已退出
|
|
245
|
+
• 网络连接中断
|
|
246
|
+
• CC 正在执行长时间任务
|
|
247
|
+
|
|
248
|
+
建议:请检查终端状态或重新启动 CC。`;
|
|
249
|
+
await client.sendText(message);
|
|
250
|
+
console.log(`[heartbeat] 已发送离线通知: ccId=${ccId}, robot=${robotName}`);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
console.error(`[heartbeat] 发送离线通知失败:`, err);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* 启动心跳检测(5 分钟扫描一次)
|
|
258
|
+
*/
|
|
259
|
+
export function startHeartbeatMonitor() {
|
|
260
|
+
if (heartbeatInterval)
|
|
261
|
+
return;
|
|
262
|
+
heartbeatInterval = setInterval(() => {
|
|
263
|
+
checkCcHeartbeat().catch(err => {
|
|
264
|
+
console.error('[heartbeat] 心跳检测错误:', err);
|
|
265
|
+
});
|
|
266
|
+
}, 5 * 60 * 1000);
|
|
267
|
+
console.log('[heartbeat] 心跳检测已启动(5 分钟周期)');
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 停止心跳检测
|
|
271
|
+
*/
|
|
272
|
+
export function stopHeartbeatMonitor() {
|
|
273
|
+
if (heartbeatInterval) {
|
|
274
|
+
clearInterval(heartbeatInterval);
|
|
275
|
+
heartbeatInterval = null;
|
|
276
|
+
console.log('[heartbeat] 心跳检测已停止');
|
|
277
|
+
}
|
|
278
|
+
}
|