@vrs-soft/wecom-aibot-mcp 1.2.0 → 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/dist/approval-manager.d.ts +38 -0
- package/dist/approval-manager.js +129 -0
- package/dist/bin.js +1 -9
- package/dist/cc-registry.d.ts +62 -0
- package/dist/cc-registry.js +278 -0
- package/dist/client.d.ts +34 -3
- package/dist/client.js +150 -19
- package/dist/config-wizard.js +22 -6
- package/dist/connection-log.d.ts +2 -1
- package/dist/connection-log.js +10 -0
- package/dist/connection-manager.js +21 -1
- package/dist/http-server.d.ts +12 -24
- package/dist/http-server.js +132 -174
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/tools/headless.d.ts +8 -0
- package/dist/tools/headless.js +248 -0
- package/dist/tools/index.js +12 -2
- 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/hash.d.ts +5 -0
- package/dist/utils/hash.js +12 -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 +129 -24
|
@@ -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
|
@@ -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';
|
|
@@ -186,12 +184,6 @@ async function startMcpServerForeground() {
|
|
|
186
184
|
// 加载统计并清理旧日志
|
|
187
185
|
loadStats();
|
|
188
186
|
cleanupOldLogs(1 / 24);
|
|
189
|
-
// 创建 MCP Server
|
|
190
|
-
const server = new McpServer({
|
|
191
|
-
name: 'wecom-aibot-mcp',
|
|
192
|
-
version: VERSION,
|
|
193
|
-
});
|
|
194
|
-
registerTools(server);
|
|
195
187
|
// 启动 HTTP 服务
|
|
196
188
|
console.log('');
|
|
197
189
|
console.log(' ╔════════════════════════════════════════════════════════╗');
|
|
@@ -200,7 +192,7 @@ async function startMcpServerForeground() {
|
|
|
200
192
|
console.log(' ╚════════════════════════════════════════════════════════╝');
|
|
201
193
|
console.log('');
|
|
202
194
|
console.log(`[mcp] 启动 MCP HTTP Server (端口: ${HTTP_PORT})...`);
|
|
203
|
-
await startHttpServer(
|
|
195
|
+
await startHttpServer();
|
|
204
196
|
startKeepaliveMonitor();
|
|
205
197
|
console.log(`[mcp] MCP Server 已就绪`);
|
|
206
198
|
console.log(`[mcp] HTTP endpoint: http://127.0.0.1:${HTTP_PORT}/mcp`);
|
|
@@ -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
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
|
+
declare const MAX_PENDING_MESSAGES = 100;
|
|
2
3
|
interface ApprovalRecord {
|
|
3
4
|
taskId: string;
|
|
4
5
|
resolved: boolean;
|
|
@@ -9,14 +10,19 @@ interface ApprovalRecord {
|
|
|
9
10
|
projectDir?: string;
|
|
10
11
|
lastKeepaliveMinute?: number;
|
|
11
12
|
keepaliveCount?: number;
|
|
13
|
+
operationHash?: string;
|
|
14
|
+
consumed?: boolean;
|
|
12
15
|
}
|
|
13
16
|
interface MessageRecord {
|
|
17
|
+
seq: number;
|
|
14
18
|
msgid: string;
|
|
15
19
|
content: string;
|
|
16
20
|
timestamp: number;
|
|
21
|
+
serverTime: number;
|
|
17
22
|
from_userid: string;
|
|
18
23
|
chatid: string;
|
|
19
24
|
chattype: 'single' | 'group';
|
|
25
|
+
quoteContent?: string;
|
|
20
26
|
}
|
|
21
27
|
declare class WecomClient extends EventEmitter {
|
|
22
28
|
private wsClient;
|
|
@@ -44,7 +50,8 @@ declare class WecomClient extends EventEmitter {
|
|
|
44
50
|
error?: string;
|
|
45
51
|
}>;
|
|
46
52
|
sendText(content: string, targetUser?: string): Promise<boolean>;
|
|
47
|
-
sendApprovalRequest(title: string, description: string, requestId: string, targetUser?: string
|
|
53
|
+
sendApprovalRequest(title: string, description: string, requestId: string, targetUser?: string, toolInput?: Record<string, unknown>, // v3.0: 用于去重
|
|
54
|
+
ccId?: string): Promise<string>;
|
|
48
55
|
sendQueuedApproval(taskId: string, title: string, description: string, targetUser?: string): Promise<boolean>;
|
|
49
56
|
getApprovalResult(taskId: string): 'pending' | 'allow-once' | 'allow-always' | 'deny';
|
|
50
57
|
getPendingApprovals(): string[];
|
|
@@ -52,6 +59,28 @@ declare class WecomClient extends EventEmitter {
|
|
|
52
59
|
getApprovalRecord(taskId: string): ApprovalRecord | undefined;
|
|
53
60
|
getLatestMessage(afterTimestamp: number): MessageRecord | undefined;
|
|
54
61
|
getPendingMessages(clear?: boolean): MessageRecord[];
|
|
62
|
+
/**
|
|
63
|
+
* 根据操作哈希查找已有审批
|
|
64
|
+
*/
|
|
65
|
+
findApprovalByHash(operationHash: string): ApprovalRecord | null;
|
|
66
|
+
/**
|
|
67
|
+
* 检查审批是否可以消费
|
|
68
|
+
* allow-once 只能消费一次
|
|
69
|
+
*/
|
|
70
|
+
canConsumeApproval(taskId: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* 消费审批结果
|
|
73
|
+
* allow-once 消费后标记为已消费
|
|
74
|
+
*/
|
|
75
|
+
consumeApproval(taskId: string): 'allow-once' | 'allow-always' | 'deny' | null;
|
|
76
|
+
/**
|
|
77
|
+
* 注入审批记录(MCP 重启恢复用)
|
|
78
|
+
* 如果 taskId 已存在则跳过,避免覆盖用户已点击的结果
|
|
79
|
+
*/
|
|
80
|
+
injectApprovalRecord(taskId: string, partial: {
|
|
81
|
+
toolName?: string;
|
|
82
|
+
toolInput?: Record<string, unknown>;
|
|
83
|
+
}): void;
|
|
55
84
|
cleanupMessages(maxAgeMs?: number): void;
|
|
56
85
|
private flushPendingMessages;
|
|
57
86
|
getPendingMessageCount(): number;
|
|
@@ -62,5 +91,7 @@ declare class WecomClient extends EventEmitter {
|
|
|
62
91
|
};
|
|
63
92
|
}
|
|
64
93
|
export declare function initClient(botId: string, secret: string, targetUserId: string, robotName: string): WecomClient;
|
|
65
|
-
export declare function getClient(): WecomClient;
|
|
66
|
-
export
|
|
94
|
+
export declare function getClient(robotName?: string): WecomClient;
|
|
95
|
+
export declare function getAllClients(): Map<string, WecomClient>;
|
|
96
|
+
export declare function disconnectClient(robotName: string): boolean;
|
|
97
|
+
export { WecomClient, ApprovalRecord, MessageRecord, MAX_PENDING_MESSAGES };
|