@vrs-soft/wecom-aibot-mcp 1.5.0 → 2.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/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * - robotName 作为连接索引
10
10
  */
11
11
  export { WecomClient, initClient } from './client.js';
12
- export { connectRobot, disconnectRobot, getClient, getConnectionState, getAllConnectionStates, isRobotOccupied, getRobotOccupiedBy, } from './connection-manager.js';
12
+ export { connectRobot, disconnectRobot, getClient, getConnectionState, getAllConnectionStates, } from './connection-manager.js';
13
13
  export { startHttpServer, stopHttpServer, HTTP_PORT, HOOK_SCRIPT_PATH, } from './http-server.js';
14
14
  export type { ApprovalRequest } from './http-server.js';
15
15
  export { registerTools } from './tools/index.js';
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // Client 模块
12
12
  export { WecomClient, initClient } from './client.js';
13
13
  // 连接管理模块
14
- export { connectRobot, disconnectRobot, getClient, getConnectionState, getAllConnectionStates, isRobotOccupied, getRobotOccupiedBy, } from './connection-manager.js';
14
+ export { connectRobot, disconnectRobot, getClient, getConnectionState, getAllConnectionStates, } from './connection-manager.js';
15
15
  // HTTP 服务模块
16
16
  export { startHttpServer, stopHttpServer, HTTP_PORT, HOOK_SCRIPT_PATH, } from './http-server.js';
17
17
  // 工具注册
@@ -8,6 +8,7 @@
8
8
  * - 不再使用 projectDir
9
9
  */
10
10
  import { getClient, getConnectionState } from './connection-manager.js';
11
+ import { logger } from './logger.js';
11
12
  const KEEPALIVE_INTERVAL_MINUTES = 5; // 每 5 分钟
12
13
  const CHECK_INTERVAL_MS = 60000; // 每分钟检查一次
13
14
  const MAX_KEEPALIVE_COUNT = 2; // 保活提醒最多 2 次
@@ -17,7 +18,7 @@ let monitorTimer = null;
17
18
  */
18
19
  export function startKeepaliveMonitor() {
19
20
  monitorTimer = setInterval(checkAndSendKeepalive, CHECK_INTERVAL_MS);
20
- console.log('[keepalive] 保活监控已启动 (每 5 分钟发送审批提醒)');
21
+ logger.log('[keepalive] 保活监控已启动 (每 5 分钟发送审批提醒)');
21
22
  }
22
23
  /**
23
24
  * 停止保活监控
@@ -27,7 +28,7 @@ export function stopKeepaliveMonitor() {
27
28
  clearInterval(monitorTimer);
28
29
  monitorTimer = null;
29
30
  }
30
- console.log('[keepalive] 保活监控已停止');
31
+ logger.log('[keepalive] 保活监控已停止');
31
32
  }
32
33
  /**
33
34
  * 检查并发送保活消息
@@ -75,12 +76,12 @@ async function sendKeepaliveMessage(approval, minutes, robotName) {
75
76
  if (client) {
76
77
  const sent = await client.sendText(message);
77
78
  if (sent) {
78
- console.log(`[keepalive] 已发送审批提醒: ${approval.taskId}, 等待 ${minutes} 分钟`);
79
+ logger.log(`[keepalive] 已发送审批提醒: ${approval.taskId}, 等待 ${minutes} 分钟`);
79
80
  }
80
81
  }
81
82
  }
82
83
  catch (err) {
83
- console.error(`[keepalive] 发送审批提醒失败:`, err);
84
+ logger.error(`[keepalive] 发送审批提醒失败:`, err);
84
85
  }
85
86
  }
86
87
  /**
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 统一日志模块
3
+ *
4
+ * 通过 --debug 参数控制日志输出:
5
+ * - debug 模式:所有日志输出到 stderr
6
+ * - 正常模式:只输出必要信息(如错误、启动消息)
7
+ *
8
+ * Debug 标记文件:~/.wecom-aibot-mcp/debug
9
+ *
10
+ * 用法与 console 完全一致:
11
+ * - logger.log() - debug 模式才输出
12
+ * - logger.error() - 始终输出到 stderr
13
+ * - logger.warn() - 始终输出到 stderr
14
+ * - logger.info() - 始终输出到 stdout
15
+ */
16
+ /**
17
+ * 检查是否处于 debug 模式
18
+ */
19
+ export declare function isDebugMode(): boolean;
20
+ /**
21
+ * 设置 debug 模式
22
+ */
23
+ export declare function setDebugMode(enabled: boolean): void;
24
+ /**
25
+ * 日志输出(仅 debug 模式)
26
+ * 与 console.log 用法完全一致
27
+ */
28
+ export declare function log(...args: unknown[]): void;
29
+ /**
30
+ * 错误日志(始终输出到 stderr)
31
+ * 与 console.error 用法完全一致
32
+ */
33
+ export declare function error(...args: unknown[]): void;
34
+ /**
35
+ * 信息日志(始终输出到 stdout)
36
+ * 与 console.info 用法完全一致
37
+ */
38
+ export declare function info(...args: unknown[]): void;
39
+ /**
40
+ * 警告日志(始终输出到 stderr)
41
+ * 与 console.warn 用法完全一致
42
+ */
43
+ export declare function warn(...args: unknown[]): void;
44
+ export declare const logger: {
45
+ log: typeof log;
46
+ error: typeof error;
47
+ info: typeof info;
48
+ warn: typeof warn;
49
+ isDebug: typeof isDebugMode;
50
+ setDebug: typeof setDebugMode;
51
+ };
package/dist/logger.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 统一日志模块
3
+ *
4
+ * 通过 --debug 参数控制日志输出:
5
+ * - debug 模式:所有日志输出到 stderr
6
+ * - 正常模式:只输出必要信息(如错误、启动消息)
7
+ *
8
+ * Debug 标记文件:~/.wecom-aibot-mcp/debug
9
+ *
10
+ * 用法与 console 完全一致:
11
+ * - logger.log() - debug 模式才输出
12
+ * - logger.error() - 始终输出到 stderr
13
+ * - logger.warn() - 始终输出到 stderr
14
+ * - logger.info() - 始终输出到 stdout
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import * as os from 'os';
19
+ const DEBUG_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'debug');
20
+ let debugMode = false;
21
+ /**
22
+ * 检查是否处于 debug 模式
23
+ */
24
+ export function isDebugMode() {
25
+ if (debugMode)
26
+ return true;
27
+ // 检查标记文件
28
+ if (fs.existsSync(DEBUG_FILE)) {
29
+ debugMode = true;
30
+ return true;
31
+ }
32
+ return false;
33
+ }
34
+ /**
35
+ * 设置 debug 模式
36
+ */
37
+ export function setDebugMode(enabled) {
38
+ debugMode = enabled;
39
+ if (enabled && !fs.existsSync(DEBUG_FILE)) {
40
+ fs.writeFileSync(DEBUG_FILE, 'true');
41
+ }
42
+ else if (!enabled && fs.existsSync(DEBUG_FILE)) {
43
+ fs.unlinkSync(DEBUG_FILE);
44
+ }
45
+ }
46
+ /**
47
+ * 日志输出(仅 debug 模式)
48
+ * 与 console.log 用法完全一致
49
+ */
50
+ export function log(...args) {
51
+ if (isDebugMode()) {
52
+ console.log(...args);
53
+ }
54
+ }
55
+ /**
56
+ * 错误日志(始终输出到 stderr)
57
+ * 与 console.error 用法完全一致
58
+ */
59
+ export function error(...args) {
60
+ console.error(...args);
61
+ }
62
+ /**
63
+ * 信息日志(始终输出到 stdout)
64
+ * 与 console.info 用法完全一致
65
+ */
66
+ export function info(...args) {
67
+ console.info(...args);
68
+ }
69
+ /**
70
+ * 警告日志(始终输出到 stderr)
71
+ * 与 console.warn 用法完全一致
72
+ */
73
+ export function warn(...args) {
74
+ console.warn(...args);
75
+ }
76
+ // 导出 logger 对象,用法与 console 完全一致
77
+ export const logger = {
78
+ log,
79
+ error,
80
+ info,
81
+ warn,
82
+ isDebug: isDebugMode,
83
+ setDebug: setDebugMode,
84
+ };
@@ -5,6 +5,12 @@
5
5
  * - WecomClient 收到微信消息 → 发布到 bus
6
6
  * - MCP Server 订阅消息 → SSE 推送给 CC
7
7
  * - 按 CCID 过滤消息(基于引用内容)
8
+ *
9
+ * v2.1 使用订阅计数替代 tap 计数:
10
+ * - subscribe 时计数 +1
11
+ * - unsubscribe 时计数 -1(自动处理掉线)
12
+ * - 订阅数 = 1 时直接发给唯一订阅者
13
+ * - 订阅数 > 1 时检查 ccId
8
14
  */
9
15
  import { Subject } from 'rxjs';
10
16
  export interface WecomMessage {
@@ -17,6 +23,7 @@ export interface WecomMessage {
17
23
  timestamp: number;
18
24
  quoteContent?: string;
19
25
  }
26
+ export declare function getSubscriberCount(robotName: string): number;
20
27
  declare const wecomMessage$: Subject<WecomMessage>;
21
28
  /**
22
29
  * 发布微信消息(由 WecomClient 调用)
@@ -27,7 +34,12 @@ export declare function publishWecomMessage(msg: WecomMessage): void;
27
34
  */
28
35
  export declare function subscribeWecomMessage(callback: (msg: WecomMessage) => void): import("rxjs").Subscription;
29
36
  /**
30
- * 订阅特定机器人的微信消息
37
+ * 订阅特定机器人的微信消息(带订阅计数)
31
38
  */
32
39
  export declare function subscribeWecomMessageByRobot(robotName: string, callback: (msg: WecomMessage) => void): import("rxjs").Subscription;
40
+ /**
41
+ * 订阅特定机器人且匹配 ccId 的消息
42
+ * 用于多 CC 共用一个机器人场景的过滤
43
+ */
44
+ export declare function subscribeWecomMessageByCcId(robotName: string, ccId: string, callback: (msg: WecomMessage) => void): import("rxjs").Subscription;
33
45
  export { wecomMessage$ };
@@ -5,9 +5,32 @@
5
5
  * - WecomClient 收到微信消息 → 发布到 bus
6
6
  * - MCP Server 订阅消息 → SSE 推送给 CC
7
7
  * - 按 CCID 过滤消息(基于引用内容)
8
+ *
9
+ * v2.1 使用订阅计数替代 tap 计数:
10
+ * - subscribe 时计数 +1
11
+ * - unsubscribe 时计数 -1(自动处理掉线)
12
+ * - 订阅数 = 1 时直接发给唯一订阅者
13
+ * - 订阅数 > 1 时检查 ccId
8
14
  */
9
15
  import { Subject } from 'rxjs';
10
- import { filter } from 'rxjs/operators';
16
+ import { filter, finalize } from 'rxjs/operators';
17
+ // ============================================
18
+ // 订阅计数器(替代 tap 计数)
19
+ // ============================================
20
+ const subscriberCount = new Map(); // robotName → 订阅数
21
+ export function getSubscriberCount(robotName) {
22
+ return subscriberCount.get(robotName) || 0;
23
+ }
24
+ function incrementSubscriberCount(robotName) {
25
+ const current = subscriberCount.get(robotName) || 0;
26
+ subscriberCount.set(robotName, current + 1);
27
+ }
28
+ function decrementSubscriberCount(robotName) {
29
+ const current = subscriberCount.get(robotName) || 0;
30
+ if (current > 0) {
31
+ subscriberCount.set(robotName, current - 1);
32
+ }
33
+ }
11
34
  // ============================================
12
35
  // 消息总线(RxJS Subject)
13
36
  // ============================================
@@ -28,10 +51,40 @@ export function subscribeWecomMessage(callback) {
28
51
  return wecomMessage$.subscribe(callback);
29
52
  }
30
53
  /**
31
- * 订阅特定机器人的微信消息
54
+ * 订阅特定机器人的微信消息(带订阅计数)
32
55
  */
33
56
  export function subscribeWecomMessageByRobot(robotName, callback) {
34
- return wecomMessage$.pipe(filter(msg => msg.robotName === robotName)).subscribe(callback);
57
+ incrementSubscriberCount(robotName);
58
+ return wecomMessage$.pipe(filter(msg => msg.robotName === robotName), finalize(() => decrementSubscriberCount(robotName)) // 无论何时结束都减计数
59
+ ).subscribe(callback);
60
+ }
61
+ /**
62
+ * 订阅特定机器人且匹配 ccId 的消息
63
+ * 用于多 CC 共用一个机器人场景的过滤
64
+ */
65
+ export function subscribeWecomMessageByCcId(robotName, ccId, callback) {
66
+ incrementSubscriberCount(robotName);
67
+ return wecomMessage$.pipe(filter(msg => msg.robotName === robotName), filter(msg => isMessageForCcId(msg, ccId)), finalize(() => decrementSubscriberCount(robotName)) // 无论何时结束都减计数
68
+ ).subscribe(callback);
69
+ }
70
+ /**
71
+ * 检查消息是否属于指定的 ccId
72
+ */
73
+ function isMessageForCcId(msg, ccId) {
74
+ // 有引用内容,检查是否引用该 ccId
75
+ if (msg.quoteContent) {
76
+ const match = msg.quoteContent.match(/【([^】]+)】/);
77
+ if (match && match[1] === ccId) {
78
+ return true; // 引用了该 ccId,消息属于该 CC
79
+ }
80
+ // 引用了其他 ccId,不属于当前 ccId
81
+ if (match) {
82
+ return false;
83
+ }
84
+ }
85
+ // 无引用内容,消息不属于任何特定 CC
86
+ // 在多 CC 场景下,需要用户指定
87
+ return false;
35
88
  }
36
89
  // 导出 Observable(供高级用法)
37
90
  export { wecomMessage$ };
@@ -19,6 +19,7 @@ export interface WechatModeConfig {
19
19
  ccId?: string;
20
20
  autoApprove?: boolean;
21
21
  autoApproveTimeout?: number;
22
+ heartbeatJobId?: string;
22
23
  }
23
24
  /**
24
25
  * 获取项目配置文件路径
@@ -128,3 +129,18 @@ export declare function removePermissionHook(projectDir: string): {
128
129
  path: string;
129
130
  existed: boolean;
130
131
  };
132
+ /**
133
+ * 添加 TaskCompleted hook 到项目 settings.json
134
+ */
135
+ export declare function addTaskCompletedHook(projectDir: string): {
136
+ success: boolean;
137
+ path: string;
138
+ };
139
+ /**
140
+ * 删除 TaskCompleted hook 从项目 settings.json
141
+ */
142
+ export declare function removeTaskCompletedHook(projectDir: string): {
143
+ success: boolean;
144
+ path: string;
145
+ existed: boolean;
146
+ };
@@ -10,6 +10,7 @@
10
10
  import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
+ import { logger } from './logger.js';
13
14
  // 配置文件路径
14
15
  const CLAUDE_CONFIG_FILE = path.join(os.homedir(), '.claude.json');
15
16
  const PROJECT_CONFIG_SUBDIR = '.claude/wecom-aibot';
@@ -37,7 +38,7 @@ export function loadProjectConfig(projectDir) {
37
38
  const config = JSON.parse(content);
38
39
  // 验证必需字段
39
40
  if (!config.botId || !config.secret || !config.defaultUser) {
40
- console.error(`[project-config] 项目配置缺少必需字段: ${configPath}`);
41
+ logger.error(`[project-config] 项目配置缺少必需字段: ${configPath}`);
41
42
  return null;
42
43
  }
43
44
  return {
@@ -48,7 +49,7 @@ export function loadProjectConfig(projectDir) {
48
49
  };
49
50
  }
50
51
  catch (err) {
51
- console.error(`[project-config] 解析项目配置失败: ${configPath}`, err);
52
+ logger.error(`[project-config] 解析项目配置失败: ${configPath}`, err);
52
53
  return null;
53
54
  }
54
55
  }
@@ -66,7 +67,7 @@ export function saveProjectConfig(projectDir, config) {
66
67
  fs.mkdirSync(configDir, { recursive: true });
67
68
  }
68
69
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
69
- console.log(`[project-config] 已保存项目配置: ${configPath}`);
70
+ logger.log(`[project-config] 已保存项目配置: ${configPath}`);
70
71
  }
71
72
  /**
72
73
  * 删除项目配置
@@ -77,7 +78,7 @@ export function deleteProjectConfig(projectDir) {
77
78
  const configPath = getProjectConfigPath(projectDir);
78
79
  if (fs.existsSync(configPath)) {
79
80
  fs.unlinkSync(configPath);
80
- console.log(`[project-config] 已删除项目配置: ${configPath}`);
81
+ logger.log(`[project-config] 已删除项目配置: ${configPath}`);
81
82
  }
82
83
  }
83
84
  /**
@@ -129,7 +130,7 @@ export function updateWechatModeConfig(projectDir, updates) {
129
130
  // 合并更新
130
131
  const newConfig = { ...config, ...updates };
131
132
  fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2));
132
- console.log(`[project-config] 已更新微信模式配置: ${configPath}`);
133
+ logger.log(`[project-config] 已更新微信模式配置: ${configPath}`);
133
134
  }
134
135
  /**
135
136
  * 加载全局配置(从 ~/.claude.json)
@@ -158,7 +159,7 @@ export function loadGlobalConfig() {
158
159
  };
159
160
  }
160
161
  catch (err) {
161
- console.error('[project-config] 加载全局配置失败:', err);
162
+ logger.error('[project-config] 加载全局配置失败:', err);
162
163
  return null;
163
164
  }
164
165
  }
@@ -177,14 +178,14 @@ export function getConfig(projectDir) {
177
178
  if (projectDir) {
178
179
  const projectConfig = loadProjectConfig(projectDir);
179
180
  if (projectConfig) {
180
- console.log(`[project-config] 使用项目配置: ${projectDir}`);
181
+ logger.log(`[project-config] 使用项目配置: ${projectDir}`);
181
182
  return projectConfig;
182
183
  }
183
184
  }
184
185
  // 2. 回退全局配置
185
186
  const globalConfig = loadGlobalConfig();
186
187
  if (globalConfig) {
187
- console.log('[project-config] 使用全局配置');
188
+ logger.log('[project-config] 使用全局配置');
188
189
  return globalConfig;
189
190
  }
190
191
  return null;
@@ -255,6 +256,17 @@ const PERMISSION_HOOK = {
255
256
  matcher: '',
256
257
  hooks: [{ type: 'command', command: '~/.wecom-aibot-mcp/permission-hook.sh' }],
257
258
  };
259
+ /**
260
+ * TaskCompleted hook 脚本路径
261
+ */
262
+ const TASK_COMPLETED_HOOK_SCRIPT_PATH = path.join(os.homedir(), '.wecom-aibot-mcp', 'task-completed-hook.sh');
263
+ /**
264
+ * TaskCompleted hook 配置
265
+ */
266
+ const TASK_COMPLETED_HOOK = {
267
+ matcher: '',
268
+ hooks: [{ type: 'command', command: TASK_COMPLETED_HOOK_SCRIPT_PATH }],
269
+ };
258
270
  /**
259
271
  * 添加 PermissionRequest hook 到项目 settings.json
260
272
  */
@@ -284,11 +296,11 @@ export function addPermissionHook(projectDir) {
284
296
  // 写入配置
285
297
  try {
286
298
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
287
- console.log(`[project-config] 已添加 PermissionRequest hook: ${settingsPath}`);
299
+ logger.log(`[project-config] 已添加 PermissionRequest hook: ${settingsPath}`);
288
300
  return { success: true, path: settingsPath };
289
301
  }
290
302
  catch (err) {
291
- console.error(`[project-config] 添加 PermissionRequest hook 失败: ${err}`);
303
+ logger.error(`[project-config] 添加 PermissionRequest hook 失败: ${err}`);
292
304
  return { success: false, path: settingsPath };
293
305
  }
294
306
  }
@@ -312,13 +324,80 @@ export function removePermissionHook(projectDir) {
312
324
  }
313
325
  // 写入配置
314
326
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
315
- console.log(`[project-config] 已删除 PermissionRequest hook: ${settingsPath}`);
327
+ logger.log(`[project-config] 已删除 PermissionRequest hook: ${settingsPath}`);
328
+ return { success: true, path: settingsPath, existed: true };
329
+ }
330
+ return { success: true, path: settingsPath, existed: false };
331
+ }
332
+ catch (err) {
333
+ logger.error(`[project-config] 删除 PermissionRequest hook 失败: ${err}`);
334
+ return { success: false, path: settingsPath, existed: false };
335
+ }
336
+ }
337
+ /**
338
+ * 添加 TaskCompleted hook 到项目 settings.json
339
+ */
340
+ export function addTaskCompletedHook(projectDir) {
341
+ const settingsPath = getProjectSettingsPath(projectDir);
342
+ const settingsDir = path.dirname(settingsPath);
343
+ // 确保目录存在
344
+ if (!fs.existsSync(settingsDir)) {
345
+ fs.mkdirSync(settingsDir, { recursive: true });
346
+ }
347
+ // 读取现有配置
348
+ let settings = {};
349
+ if (fs.existsSync(settingsPath)) {
350
+ try {
351
+ const content = fs.readFileSync(settingsPath, 'utf-8');
352
+ settings = JSON.parse(content);
353
+ }
354
+ catch {
355
+ // ignore
356
+ }
357
+ }
358
+ // 添加 hooks.TaskCompleted
359
+ if (!settings.hooks) {
360
+ settings.hooks = {};
361
+ }
362
+ settings.hooks.TaskCompleted = [TASK_COMPLETED_HOOK];
363
+ // 写入配置
364
+ try {
365
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
366
+ logger.log(`[project-config] 已添加 TaskCompleted hook: ${settingsPath}`);
367
+ return { success: true, path: settingsPath };
368
+ }
369
+ catch (err) {
370
+ logger.error(`[project-config] 添加 TaskCompleted hook 失败: ${err}`);
371
+ return { success: false, path: settingsPath };
372
+ }
373
+ }
374
+ /**
375
+ * 删除 TaskCompleted hook 从项目 settings.json
376
+ */
377
+ export function removeTaskCompletedHook(projectDir) {
378
+ const settingsPath = getProjectSettingsPath(projectDir);
379
+ if (!fs.existsSync(settingsPath)) {
380
+ return { success: true, path: settingsPath, existed: false };
381
+ }
382
+ try {
383
+ const content = fs.readFileSync(settingsPath, 'utf-8');
384
+ const settings = JSON.parse(content);
385
+ // 删除 hooks.TaskCompleted
386
+ if (settings.hooks && settings.hooks.TaskCompleted) {
387
+ delete settings.hooks.TaskCompleted;
388
+ // 如果 hooks 为空,删除整个 hooks 字段
389
+ if (Object.keys(settings.hooks).length === 0) {
390
+ delete settings.hooks;
391
+ }
392
+ // 写入配置
393
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
394
+ logger.log(`[project-config] 已删除 TaskCompleted hook: ${settingsPath}`);
316
395
  return { success: true, path: settingsPath, existed: true };
317
396
  }
318
397
  return { success: true, path: settingsPath, existed: false };
319
398
  }
320
399
  catch (err) {
321
- console.error(`[project-config] 删除 PermissionRequest hook 失败: ${err}`);
400
+ logger.error(`[project-config] 删除 TaskCompleted hook 失败: ${err}`);
322
401
  return { success: false, path: settingsPath, existed: false };
323
402
  }
324
403
  }