@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.
@@ -20,11 +20,12 @@
20
20
  * - 从 Session 自动获取 robotName
21
21
  */
22
22
  import { z } from 'zod';
23
- import { listAllRobots } from '../config-wizard.js';
24
- import { connectRobot, disconnectRobot, getClient, getConnectionState, isRobotOccupied, getRobotOccupiedBy, } from '../connection-manager.js';
25
- import { generateCcId, registerCcId, unregisterCcId, getRobotByCcId, getCCRegistryEntry, } from '../http-server.js';
26
- import { subscribeWecomMessageByRobot } from '../message-bus.js';
27
- import { updateWechatModeConfig, addPermissionHook, removePermissionHook } from '../project-config.js';
23
+ import { listAllRobots, installSkill } from '../config-wizard.js';
24
+ import { connectRobot, disconnectRobot, getClient, getConnectionState, } from '../connection-manager.js';
25
+ import { registerCcId, unregisterCcId, getRobotByCcId, getProjectDirByCcId, generateCcId, } from '../http-server.js';
26
+ import { subscribeWecomMessageByCcId } from '../message-bus.js';
27
+ import { updateWechatModeConfig, loadWechatModeConfig, addPermissionHook, removePermissionHook, addTaskCompletedHook, removeTaskCompletedHook } from '../project-config.js';
28
+ import { logger } from '../logger.js';
28
29
  // 辅助函数:从 ccId 获取客户端
29
30
  async function getConnectedClient(ccId) {
30
31
  if (!ccId) {
@@ -47,15 +48,49 @@ export function registerTools(server) {
47
48
  server.tool('send_message', '向企业微信发送消息(用于通知用户)。群聊时传入 chatid 可回复到群里。', {
48
49
  content: z.string().describe('消息内容(支持 Markdown)'),
49
50
  target_user: z.string().optional().describe('目标用户/群 ID(可选)'),
50
- cc_id: z.string().optional().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
51
+ cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
51
52
  }, async ({ content, target_user, cc_id }, extra) => {
52
53
  const { error, client } = await getConnectedClient(cc_id);
53
54
  if (error || !client) {
54
55
  return { content: [{ type: 'text', text: JSON.stringify({ error }) }] };
55
56
  }
56
- const success = await client.sendText(content, target_user);
57
+ // 自动在消息头部添加 ccId 标识(多对一场景区分)
58
+ const prefixedContent = `【${cc_id}】${content}`;
59
+ const success = await client.sendText(prefixedContent, target_user);
60
+ return {
61
+ content: [{
62
+ type: 'text',
63
+ text: success
64
+ ? '✅ 消息已发送,继续轮询,继续等待'
65
+ : '发送失败,请检查连接状态',
66
+ }],
67
+ };
68
+ });
69
+ // ============================================
70
+ // 工具 2: 心跳检查(HTTP 模式)
71
+ // ============================================
72
+ server.tool('heartbeat_check', '心跳检查,提示智能体继续轮询(仅 HTTP 模式使用)', {}, async () => {
57
73
  return {
58
- content: [{ type: 'text', text: success ? '消息已发送' : '发送失败,请检查连接状态' }],
74
+ content: [{
75
+ type: 'text',
76
+ text: '心跳检查:如有执行结果请发送,如果没有应继续轮询 get_pending_messages 接口!',
77
+ }],
78
+ };
79
+ });
80
+ // ============================================
81
+ // 工具 3: 保存心跳 job ID(HTTP 模式)
82
+ // ============================================
83
+ server.tool('update_heartbeat_job_id', '保存心跳定时任务 job ID 到配置文件(HTTP 模式用,/loop 创建后调用)', {
84
+ cc_id: z.string().describe('CC 唯一标识'),
85
+ job_id: z.string().describe('由 /loop 命令返回的 job ID'),
86
+ }, async ({ cc_id, job_id }) => {
87
+ const projectDir = getProjectDirByCcId(cc_id) || process.cwd();
88
+ updateWechatModeConfig(projectDir, { heartbeatJobId: job_id });
89
+ return {
90
+ content: [{
91
+ type: 'text',
92
+ text: JSON.stringify({ success: true, projectDir, job_id }),
93
+ }],
59
94
  };
60
95
  });
61
96
  // ============================================
@@ -77,10 +112,10 @@ export function registerTools(server) {
77
112
  // ============================================
78
113
  // 工具 6: 获取待处理消息
79
114
  // ============================================
80
- server.tool('get_pending_messages', '获取待处理的微信消息。支持长轮询:传入 timeout_ms 后阻塞等待,有消息立即返回,无消息等到超时。', {
115
+ server.tool('get_pending_messages', '获取待处理的微信消息。支持长轮询:传入 timeout_ms 后阻塞等待,有消息立即返回,无消息等到超时。超时后继续轮询,不要停止。', {
81
116
  clear: z.boolean().optional().default(true).describe('是否清除已获取的消息'),
82
- timeout_ms: z.number().optional().default(0).describe('长轮询超时(毫秒),0 表示立即返回,最大 60000'),
83
- cc_id: z.string().optional().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
117
+ timeout_ms: z.number().optional().default(30000).describe('长轮询超时(毫秒),默认 30000,最大 60000'),
118
+ cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
84
119
  }, async ({ clear, timeout_ms = 0, cc_id }) => {
85
120
  const { error, client, robotName } = await getConnectedClient(cc_id);
86
121
  if (error || !client) {
@@ -91,12 +126,21 @@ export function registerTools(server) {
91
126
  }],
92
127
  };
93
128
  }
129
+ // 消息格式化:返回给智能体的数据结构
130
+ // 字段说明:
131
+ // - content: 用户发送的消息内容
132
+ // - from: 发送者用户 ID
133
+ // - chatid: 会话 ID(单聊=用户ID,群聊=群ID)
134
+ // - chattype: 会话类型(single/group)
135
+ // - time: 消息时间(ISO 格式)
136
+ // - quoteContent: 用户引用的消息内容(可选,用户回复时引用的上一条消息)
94
137
  const formatMessages = (msgs) => msgs.map(m => ({
95
138
  content: m.content,
96
139
  from: m.from_userid,
97
140
  chatid: m.chatid,
98
141
  chattype: m.chattype,
99
142
  time: new Date(m.timestamp).toISOString(),
143
+ quoteContent: m.quoteContent,
100
144
  }));
101
145
  // 先检查是否已有积压消息
102
146
  const existing = client.getPendingMessages(false);
@@ -110,20 +154,27 @@ export function registerTools(server) {
110
154
  }],
111
155
  };
112
156
  }
113
- // 无积压且不等待,立即返回空
157
+ // 无积压且不等待,立即返回空(timeout_ms 传 0 时)
114
158
  const waitMs = Math.min(timeout_ms, 60000);
115
159
  if (waitMs <= 0) {
116
160
  return {
117
- content: [{ type: 'text', text: JSON.stringify({ count: 0, messages: [] }) }],
161
+ content: [{
162
+ type: 'text',
163
+ text: JSON.stringify({
164
+ count: 0,
165
+ messages: [],
166
+ hint: 'timeout_ms=0 仅用于检查积压消息,请传入 timeout_ms=30000 开始长轮询'
167
+ }),
168
+ }],
118
169
  };
119
170
  }
120
- // 长轮询:订阅消息总线,等待该机器人的消息
171
+ // 长轮询:订阅消息总线,等待该机器人且匹配 ccId 的消息
121
172
  const arrived = await new Promise(resolve => {
122
173
  const timer = setTimeout(() => {
123
174
  sub.unsubscribe();
124
175
  resolve(null);
125
176
  }, waitMs);
126
- const sub = subscribeWecomMessageByRobot(robotName, (msg) => {
177
+ const sub = subscribeWecomMessageByCcId(robotName, cc_id, (msg) => {
127
178
  clearTimeout(timer);
128
179
  sub.unsubscribe();
129
180
  resolve(msg);
@@ -131,7 +182,15 @@ export function registerTools(server) {
131
182
  });
132
183
  if (arrived === null) {
133
184
  return {
134
- content: [{ type: 'text', text: JSON.stringify({ count: 0, messages: [], timeout: true }) }],
185
+ content: [{
186
+ type: 'text',
187
+ text: JSON.stringify({
188
+ count: 0,
189
+ messages: [],
190
+ waiting: true,
191
+ hint: '继续等待,继续轮询'
192
+ }),
193
+ }],
135
194
  };
136
195
  }
137
196
  // 消息到了,取出所有积压(含刚到的)
@@ -183,6 +242,69 @@ npx @vrs-soft/wecom-aibot-mcp
183
242
  return { content: [{ type: 'text', text: guide }] };
184
243
  });
185
244
  // ============================================
245
+ // 工具: 获取配置需求(供 skill 自动配置)
246
+ // ============================================
247
+ server.tool('get_setup_requirements', '获取 MCP 配置需求,用于 skill 自动配置本地环境(权限、Hook、skill)。启动时调用检查配置是否完整。', {}, async () => {
248
+ return {
249
+ content: [{
250
+ type: 'text',
251
+ text: JSON.stringify({
252
+ version: '2.0.0',
253
+ requirements: {
254
+ // 权限配置需求
255
+ permissions: {
256
+ file: '~/.claude/settings.local.json',
257
+ allow: [
258
+ 'mcp__wecom-aibot__send_message',
259
+ 'mcp__wecom-aibot__heartbeat_check',
260
+ 'mcp__wecom-aibot__get_pending_messages',
261
+ 'mcp__wecom-aibot__check_connection',
262
+ 'mcp__wecom-aibot__list_robots',
263
+ 'mcp__wecom-aibot__enter_headless_mode',
264
+ 'mcp__wecom-aibot__exit_headless_mode',
265
+ 'mcp__wecom-aibot__get_connection_stats',
266
+ 'mcp__wecom-aibot__get_setup_requirements',
267
+ 'mcp__wecom-aibot__get_skill',
268
+ 'mcp__wecom-aibot__update_heartbeat_job_id',
269
+ ],
270
+ },
271
+ // Hook 配置需求
272
+ hooks: {
273
+ file: '~/.claude/settings.local.json',
274
+ PermissionRequest: {
275
+ script: '~/.wecom-aibot-mcp/permission-hook.sh',
276
+ description: '审批请求通过微信发送',
277
+ },
278
+ },
279
+ // Skill 安装需求
280
+ skills: {
281
+ projectDir: '.claude/skills/headless-mode',
282
+ files: ['SKILL.md'],
283
+ skillUrl: `${process.env.MCP_URL || 'http://127.0.0.1:18963'}/skill`, // 远程部署下载 URL
284
+ },
285
+ },
286
+ // 检查命令(供 skill 验证)
287
+ checkCommands: {
288
+ permissions: '检查 ~/.claude/settings.local.json 是否包含 mcp__wecom-aibot__ 权限',
289
+ hook: '检查 ~/.claude/settings.local.json 是否包含 PermissionRequest hook',
290
+ skill: '检查 ~/.claude/skills/headless-mode/SKILL.md 是否存在',
291
+ },
292
+ // 模式说明
293
+ modes: {
294
+ channel: {
295
+ description: 'SSE 推送模式,微信消息自动唤醒 Agent',
296
+ capability: 'claude/channel',
297
+ },
298
+ http: {
299
+ description: '轮询模式,Agent 需调用 get_pending_messages 和 heartbeat_check',
300
+ capability: null,
301
+ },
302
+ },
303
+ }, null, 2),
304
+ }],
305
+ };
306
+ });
307
+ // ============================================
186
308
  // 工具 7: 添加新机器人配置
187
309
  // ============================================
188
310
  server.tool('add_robot_config', '添加新机器人配置', {
@@ -204,43 +326,40 @@ npx @vrs-soft/wecom-aibot-mcp
204
326
  // ============================================
205
327
  // 工具 8: 列出所有机器人
206
328
  // ============================================
207
- server.tool('list_robots', '列出配置中的所有机器人及其状态', {}, async () => {
329
+ server.tool('list_robots', '列出配置中的所有机器人。多 CC 可共享同一机器人,直接选择使用即可。', {}, async () => {
208
330
  const allRobots = listAllRobots();
209
- const state = getConnectionState();
210
- const robots = allRobots.map(robot => {
211
- const occupied = isRobotOccupied(robot.name);
212
- const occupiedBy = occupied ? getRobotOccupiedBy(robot.name) : null;
213
- const connected = state.robotName === robot.name && state.connected;
214
- return {
215
- name: robot.name,
216
- botId: robot.botId,
217
- targetUser: robot.targetUserId,
218
- status: connected ? 'connected' : (occupied ? 'occupied' : 'available'),
219
- occupiedBy,
220
- };
221
- });
331
+ // 返回完整信息,包括 botId 用于区分同名机器人
332
+ const robotList = allRobots.map((robot, index) => ({
333
+ index: index + 1,
334
+ name: robot.name,
335
+ botId: robot.botId?.slice(0, 12) + '...', // 只显示前12位
336
+ targetUser: robot.targetUserId,
337
+ }));
222
338
  return {
223
339
  content: [{
224
340
  type: 'text',
225
- text: JSON.stringify({
226
- robots,
227
- total: robots.length,
228
- connected: robots.filter(r => r.status === 'connected').length,
229
- occupied: robots.filter(r => r.status === 'occupied').length,
230
- }, null, 2),
341
+ text: JSON.stringify({ robots: robotList }, null, 2),
231
342
  }],
232
343
  };
233
344
  });
234
345
  // ============================================
235
- // 工具 10: 进入 headless 模式
346
+ // 工具 9: 进入 headless 模式
236
347
  // ============================================
237
348
  server.tool('enter_headless_mode', '进入微信模式,建立 WebSocket 连接。当用户说「现在开始通过微信联系」时调用。', {
238
- agent_name: z.string().describe('智能体名称'),
349
+ agent_name: z.string().optional().describe('智能体/项目名称(用于生成 ccId,如项目名)'),
350
+ cc_id: z.string().optional().describe('CC 唯一标识(可选,未传入时服务端自动生成)'),
239
351
  robot_id: z.string().optional().describe('指定机器人名称或序号'),
240
352
  project_dir: z.string().optional().describe('项目目录路径(用于写入配置文件)'),
353
+ mode: z.enum(['channel', 'http']).optional().default('http')
354
+ .describe('运行模式:channel=SSE推送(推荐),http=轮询(兼容)'),
241
355
  auto_approve: z.boolean().optional().default(true).describe('超时自动审批(默认 true)'),
242
356
  auto_approve_timeout: z.number().optional().default(600).describe('自动审批超时时间(秒,默认 600 即 10 分钟)'),
243
- }, async ({ agent_name, robot_id, project_dir, auto_approve, auto_approve_timeout }, extra) => {
357
+ }, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve, auto_approve_timeout }, extra) => {
358
+ // 获取项目目录
359
+ const projectDir = project_dir || process.cwd();
360
+ // 智能体名称(用于生成 ccId)
361
+ // 优先级:agent_name > cc_id > 项目目录名 > 'cc'
362
+ const effectiveAgentName = agent_name || cc_id || projectDir.split('/').pop() || 'cc';
244
363
  const allRobots = listAllRobots();
245
364
  if (allRobots.length === 0) {
246
365
  return {
@@ -282,7 +401,7 @@ npx @vrs-soft/wecom-aibot-mcp
282
401
  }
283
402
  }
284
403
  // 连接机器人
285
- const result = await connectRobot(selectedRobot.name, agent_name);
404
+ const result = await connectRobot(selectedRobot.name, effectiveAgentName);
286
405
  if (!result.success || !result.client) {
287
406
  return {
288
407
  content: [{
@@ -294,22 +413,30 @@ npx @vrs-soft/wecom-aibot-mcp
294
413
  }],
295
414
  };
296
415
  }
297
- // 生成 ccId 并注册到 CC 注册表
298
- const ccId = generateCcId();
299
- registerCcId(ccId, selectedRobot.name, agent_name);
416
+ // 如果用户传入 cc_id,直接使用;否则自动生成
417
+ const finalCcId = cc_id || generateCcId(effectiveAgentName);
418
+ // 检查 wecom-aibot.json 是否已有匹配的 ccId(重连场景)
419
+ const existingConfig = loadWechatModeConfig(projectDir);
420
+ const isReconnect = existingConfig?.ccId === finalCcId;
421
+ // 注册 ccId 到 CC 注册表(重连直接覆盖,首次注册清理超时条目)
422
+ registerCcId(finalCcId, selectedRobot.name, effectiveAgentName, mode, projectDir, isReconnect);
300
423
  // 更新项目配置文件中的 wechatMode 为 true
301
- const projectDir = project_dir || process.cwd();
302
424
  updateWechatModeConfig(projectDir, {
303
425
  wechatMode: true,
304
426
  robotName: selectedRobot.name,
305
- ccId,
427
+ ccId: finalCcId,
306
428
  autoApprove: auto_approve,
307
429
  autoApproveTimeout: auto_approve_timeout,
308
430
  });
431
+ // 安装 skill 到项目本地(支持远程部署 MCP)
432
+ const skillResult = installSkill(projectDir);
309
433
  // 添加 PermissionRequest hook 到项目 settings.json
310
434
  const hookResult = addPermissionHook(projectDir);
311
- // 发送确认消息(包含 ccId 标识)
312
- await result.client.sendText(`【${ccId}】已进入微信模式,使用机器人「${selectedRobot.name}」。`);
435
+ // HTTP 模式添加 TaskCompleted hook(Channel 模式不需要,消息自动推送)
436
+ const taskCompletedHookResult = mode === 'http' ? addTaskCompletedHook(projectDir) : { added: false, message: 'Channel 模式不需要 TaskCompleted hook' };
437
+ // 发送确认消息(头部标注来源 ccId 和 mode)
438
+ const modeDesc = mode === 'channel' ? 'Channel模式,消息自动推送' : 'HTTP模式,请定期轮询获取消息';
439
+ await result.client.sendText(`【${finalCcId}】已进入微信模式(${modeDesc}),使用机器人「${selectedRobot.name}」。`);
313
440
  return {
314
441
  content: [{
315
442
  type: 'text',
@@ -317,9 +444,16 @@ npx @vrs-soft/wecom-aibot-mcp
317
444
  status: 'entered',
318
445
  headless: true,
319
446
  robotName: selectedRobot.name,
320
- ccId,
447
+ ccId: finalCcId,
448
+ agentName: effectiveAgentName, // 返回使用的 agentName
449
+ mode,
321
450
  hook: hookResult,
322
- message: '用户消息通过 SSE 实时推送,审批请求通过微信发送',
451
+ taskCompletedHook: taskCompletedHookResult,
452
+ skill: skillResult, // skill 安装结果(如果 success=false,包含 skillUrl)
453
+ sseEndpoint: mode === 'channel' ? `http://127.0.0.1:18963/sse/${finalCcId}` : undefined,
454
+ message: mode === 'channel'
455
+ ? `连接 SSE endpoint: http://127.0.0.1:18963/sse/${finalCcId} 接收推送消息`
456
+ : '已进入微信模式(HTTP)',
323
457
  }),
324
458
  }],
325
459
  };
@@ -328,10 +462,9 @@ npx @vrs-soft/wecom-aibot-mcp
328
462
  // 工具 11: 退出 headless 模式
329
463
  // ============================================
330
464
  server.tool('exit_headless_mode', '退出微信模式,断开连接。当用户说「结束微信模式」或「我回来了」时调用。', {
331
- agent_name: z.string().optional().describe('智能体名称'),
332
- cc_id: z.string().optional().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
465
+ cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
333
466
  project_dir: z.string().optional().describe('项目目录路径(用于更新配置文件)'),
334
- }, async ({ agent_name, cc_id, project_dir }) => {
467
+ }, async ({ cc_id, project_dir }) => {
335
468
  const { error, client, robotName } = await getConnectedClient(cc_id);
336
469
  if (error || !client || !robotName) {
337
470
  return {
@@ -341,10 +474,8 @@ npx @vrs-soft/wecom-aibot-mcp
341
474
  }],
342
475
  };
343
476
  }
344
- // 发送退出通知
345
- const entry = cc_id ? getCCRegistryEntry(cc_id) : null;
346
- const name = agent_name || entry?.agentName || '智能体';
347
- await client.sendText(`【${name}】已退出微信模式,恢复终端交互。`);
477
+ // 发送退出通知(使用 ccId 作为标识)
478
+ await client.sendText(`【${cc_id}】已退出微信模式,恢复终端交互。`);
348
479
  // 注销 ccId
349
480
  if (cc_id) {
350
481
  unregisterCcId(cc_id);
@@ -356,6 +487,8 @@ npx @vrs-soft/wecom-aibot-mcp
356
487
  updateWechatModeConfig(projectDir, { wechatMode: false });
357
488
  // 删除 PermissionRequest hook 从项目 settings.json
358
489
  const hookResult = removePermissionHook(projectDir);
490
+ // 删除 TaskCompleted hook 从项目 settings.json
491
+ const taskCompletedHookResult = removeTaskCompletedHook(projectDir);
359
492
  return {
360
493
  content: [{
361
494
  type: 'text',
@@ -364,6 +497,7 @@ npx @vrs-soft/wecom-aibot-mcp
364
497
  headless: false,
365
498
  robotName,
366
499
  hook: hookResult,
500
+ taskCompletedHook: taskCompletedHookResult,
367
501
  message: '审批将使用默认 UI',
368
502
  }),
369
503
  }],
@@ -429,5 +563,40 @@ npx @vrs-soft/wecom-aibot-mcp
429
563
  }],
430
564
  };
431
565
  });
432
- console.log('[mcp] 已注册 11 个工具');
566
+ // ============================================
567
+ // 工具 14: 获取 skill 文件内容
568
+ // ============================================
569
+ server.tool('get_skill', '获取 headless-mode skill 文件内容,用于写入本地项目目录。远程部署 HTTP MCP 时使用此工具获取 skill 文件。', {}, async () => {
570
+ const { fileURLToPath } = await import('url');
571
+ const fs = await import('fs');
572
+ const path = await import('path');
573
+ const __filename = fileURLToPath(import.meta.url);
574
+ const __dirname = path.dirname(__filename);
575
+ const skillPath = path.join(__dirname, '..', '..', 'skills', 'headless-mode', 'SKILL.md');
576
+ if (!fs.existsSync(skillPath)) {
577
+ return {
578
+ content: [{
579
+ type: 'text',
580
+ text: JSON.stringify({
581
+ success: false,
582
+ error: 'Skill 文件不存在',
583
+ skillUrl: `${process.env.MCP_URL || 'http://127.0.0.1:18963'}/skill`,
584
+ }),
585
+ }],
586
+ };
587
+ }
588
+ const content = fs.readFileSync(skillPath, 'utf-8');
589
+ return {
590
+ content: [{
591
+ type: 'text',
592
+ text: JSON.stringify({
593
+ success: true,
594
+ content,
595
+ filename: 'SKILL.md',
596
+ installPath: '.claude/skills/headless-mode/SKILL.md',
597
+ }),
598
+ }],
599
+ };
600
+ });
601
+ logger.log('[mcp] 已注册 14 个工具');
433
602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "1.5.0",
3
+ "version": "2.3.0",
4
4
  "description": "企业微信智能机器人 MCP 服务 - Claude Code 审批通道",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",