@vrs-soft/wecom-aibot-mcp 3.4.8 → 3.4.9-beta.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.
@@ -1038,15 +1038,38 @@ function registerChannelTools(server) {
1038
1038
  else {
1039
1039
  logger.info('WECOM_TEST_NO_REGISTER=1,跳过 active-projects 注册', { projectDir: localProjectDir });
1040
1040
  }
1041
- // 写入本地 wecom-aibot.json(远程 HTTP MCP 写在远端 fs,agent 本地需要自己落地)
1042
- updateWechatModeConfig(localProjectDir, {
1043
- wechatMode: true,
1044
- robotName: parsed.robotName,
1045
- ccId: parsed.ccId,
1046
- mode: parsed.mode || mode,
1047
- autoApproveTimeout: auto_approve_timeout,
1048
- });
1049
- logger.info('本地 wecom-aibot.json 已写入', { projectDir: localProjectDir, robotName: parsed.robotName, ccId: parsed.ccId });
1041
+ // v3.4.9: enter_headless_mode 只在 config 不存在时首次写入;
1042
+ // 已有 config 时**不覆盖** —— 修改 robotName / ccId 等关键字段必须走 update_wecom_aibot_config
1043
+ // 工具(内部会请求用户批准),杜绝 agent 或其他项目静默篡改。
1044
+ const savedForWrite = loadWechatModeConfig(localProjectDir);
1045
+ if (!savedForWrite) {
1046
+ updateWechatModeConfig(localProjectDir, {
1047
+ wechatMode: true,
1048
+ robotName: parsed.robotName,
1049
+ ccId: parsed.ccId,
1050
+ mode: parsed.mode || mode,
1051
+ autoApproveTimeout: auto_approve_timeout,
1052
+ });
1053
+ logger.info('首次登录,wecom-aibot.json 首次写入', { projectDir: localProjectDir, robotName: parsed.robotName, ccId: parsed.ccId });
1054
+ }
1055
+ else {
1056
+ // 只更新非关键字段(wechatMode 状态位、mode、autoApproveTimeout),不动 robotName / ccId
1057
+ updateWechatModeConfig(localProjectDir, {
1058
+ wechatMode: true,
1059
+ mode: parsed.mode || mode,
1060
+ autoApproveTimeout: auto_approve_timeout,
1061
+ });
1062
+ if (savedForWrite.robotName !== parsed.robotName || savedForWrite.ccId !== parsed.ccId) {
1063
+ logger.warn?.('daemon 返回的 robotName/ccId 与本地 config 不同,需通过 update_wecom_aibot_config 显式更新(用户批准)', {
1064
+ projectDir: localProjectDir,
1065
+ savedRobotName: savedForWrite.robotName,
1066
+ parsedRobotName: parsed.robotName,
1067
+ savedCcId: savedForWrite.ccId,
1068
+ parsedCcId: parsed.ccId,
1069
+ });
1070
+ }
1071
+ logger.info('已有 wecom-aibot.json,仅更新非关键字段(robotName/ccId 保持不变)', { projectDir: localProjectDir });
1072
+ }
1050
1073
  // 安装 skill 到本地(同上)
1051
1074
  const skillResult = installSkill(localProjectDir);
1052
1075
  logger.info('本地 skill 安装', { success: skillResult.success, skillUrl: skillResult.skillUrl });
@@ -1127,6 +1150,121 @@ function registerChannelTools(server) {
1127
1150
  };
1128
1151
  });
1129
1152
  // ============================================
1153
+ // 工具 14: 修改 wecom-aibot.json 配置(v3.4.9 引入)
1154
+ // ============================================
1155
+ // 目的:把"修改项目 .claude/wecom-aibot.json"的动作变成受审批的工具调用。
1156
+ // 首次登录(config 不存在)无需审批,直接落地;已存在时,任何字段变化都要
1157
+ // 走 daemon /approve 让用户在企微批准 → 用户拒绝就不改,杜绝静默篡改。
1158
+ server.tool('update_wecom_aibot_config', '修改项目 .claude/wecom-aibot.json 配置。首次登录直接写入;已有配置时任何字段变化都会请求用户在企微批准。用于绑定/切换机器人(robotName)、修改 ccId 等关键字段。', {
1159
+ robot_name: z.string().optional().describe('要设置的 robotName'),
1160
+ cc_id: z.string().optional().describe('要设置的 ccId'),
1161
+ mode: z.enum(['channel', 'http']).optional().describe('运行模式'),
1162
+ wechat_mode: z.boolean().optional().describe('微信模式开关'),
1163
+ heartbeat_job_id: z.string().optional().describe('HTTP 模式心跳 job ID'),
1164
+ auto_approve_timeout: z.number().optional().describe('审批超时秒数'),
1165
+ reason: z.string().optional().describe('修改原因(会展示在企微审批卡片正文)'),
1166
+ }, async ({ robot_name, cc_id, mode, wechat_mode, heartbeat_job_id, auto_approve_timeout, reason }) => {
1167
+ const authProjectDir = process.cwd();
1168
+ const old = loadWechatModeConfig(authProjectDir);
1169
+ const proposed = {
1170
+ ...(robot_name !== undefined && { robotName: robot_name }),
1171
+ ...(cc_id !== undefined && { ccId: cc_id }),
1172
+ ...(mode !== undefined && { mode }),
1173
+ ...(wechat_mode !== undefined && { wechatMode: wechat_mode }),
1174
+ ...(heartbeat_job_id !== undefined && { heartbeatJobId: heartbeat_job_id }),
1175
+ ...(auto_approve_timeout !== undefined && { autoApproveTimeout: auto_approve_timeout }),
1176
+ };
1177
+ if (Object.keys(proposed).length === 0) {
1178
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'no_op', message: '未指定任何字段' }) }] };
1179
+ }
1180
+ // 首次登录:config 不存在 → 直接写(首次固化,无需审批)
1181
+ if (!old) {
1182
+ const merged = { wechatMode: false, ...proposed };
1183
+ updateWechatModeConfig(authProjectDir, merged);
1184
+ logger.info('update_wecom_aibot_config 首次写入', { projectDir: authProjectDir, proposed });
1185
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'written_first_time', config: merged }) }] };
1186
+ }
1187
+ // 计算 diff
1188
+ const changed = {};
1189
+ for (const [k, v] of Object.entries(proposed)) {
1190
+ const oldVal = old[k];
1191
+ if (oldVal !== v)
1192
+ changed[k] = { from: oldVal, to: v };
1193
+ }
1194
+ if (Object.keys(changed).length === 0) {
1195
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'no_change', config: old }) }] };
1196
+ }
1197
+ // 有变化 → 请求用户批准
1198
+ const summaryLines = Object.entries(changed)
1199
+ .map(([k, { from, to }]) => `• ${k}: ${JSON.stringify(from)} → ${JSON.stringify(to)}`)
1200
+ .join('\n');
1201
+ const description = `修改 wecom-aibot.json 配置${reason ? `(${reason})` : ''}:\n${summaryLines}`;
1202
+ // 提交 /approve
1203
+ const approveBody = {
1204
+ tool_name: 'update_wecom_aibot_config',
1205
+ tool_input: {
1206
+ project_dir: authProjectDir,
1207
+ changes: changed,
1208
+ description,
1209
+ },
1210
+ projectDir: authProjectDir,
1211
+ // 不传 robotName:让 daemon 端按 ccId → getRobotByCcId → 第一个已连接 robot fallback
1212
+ // 直接传 old.robotName 的话,如果 old 已过时(daemon 里没有该 robot),/approve 会 503
1213
+ ccId: old.ccId || '',
1214
+ };
1215
+ let taskId = '';
1216
+ try {
1217
+ const approveRes = await fetch(`${MCP_URL}/approve`, {
1218
+ method: 'POST',
1219
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
1220
+ body: JSON.stringify(approveBody),
1221
+ });
1222
+ if (!approveRes.ok) {
1223
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: `/approve 失败: ${approveRes.status}` }) }] };
1224
+ }
1225
+ const approveJson = await approveRes.json();
1226
+ taskId = approveJson.taskId || '';
1227
+ }
1228
+ catch (e) {
1229
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: `/approve 网络错误: ${String(e)}` }) }] };
1230
+ }
1231
+ if (!taskId) {
1232
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'daemon 未返回 taskId' }) }] };
1233
+ }
1234
+ // 轮询 approval_status
1235
+ const timeoutSec = Number(old.autoApproveTimeout) || 300;
1236
+ const maxPoll = Math.max(1, Math.ceil(timeoutSec / 2));
1237
+ let approved = 'timeout';
1238
+ for (let i = 0; i < maxPoll; i++) {
1239
+ await new Promise((r) => setTimeout(r, 2000));
1240
+ try {
1241
+ const statusRes = await fetch(`${MCP_URL}/approval_status/${taskId}`, { headers: getAuthHeaders() });
1242
+ if (!statusRes.ok)
1243
+ continue;
1244
+ const statusJson = await statusRes.json();
1245
+ const r = statusJson.result;
1246
+ if (r === 'allow-once' || r === 'allow-always') {
1247
+ approved = 'allow';
1248
+ break;
1249
+ }
1250
+ if (r === 'deny') {
1251
+ approved = 'deny';
1252
+ break;
1253
+ }
1254
+ }
1255
+ catch { /* ignore, keep polling */ }
1256
+ }
1257
+ if (approved !== 'allow') {
1258
+ logger.info('update_wecom_aibot_config 用户未批准', { taskId, approved, changes: changed });
1259
+ return { content: [{ type: 'text', text: JSON.stringify({ status: approved === 'deny' ? 'denied' : 'timeout', taskId, changes: changed }) }] };
1260
+ }
1261
+ // 批准 → 落地
1262
+ updateWechatModeConfig(authProjectDir, proposed);
1263
+ logger.info('update_wecom_aibot_config 已批准并落地', { taskId, projectDir: authProjectDir, changes: changed });
1264
+ const finalCfg = loadWechatModeConfig(authProjectDir);
1265
+ return { content: [{ type: 'text', text: JSON.stringify({ status: 'updated', taskId, changes: changed, config: finalCfg }) }] };
1266
+ });
1267
+ // ============================================
1130
1268
  // 文档代理工具(转发到 HTTP MCP)
1131
1269
  // ============================================
1132
1270
  const docTools = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "3.4.8",
3
+ "version": "3.4.9-beta.0",
4
4
  "description": "企业微信智能机器人 MCP 客户端 - 连接 wecom-aibot-server daemon",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,17 +26,35 @@ else:
26
26
 
27
27
  MCP = "mcp__wecom-aibot-channel__" if mode == "channel" else "mcp__wecom-aibot__"
28
28
 
29
- # === 步骤 2:确定 cc_id 和 robot_id ===
29
+ # === 步骤 2:确定 cc_id 和 robot_id(鱼骨法·唯一合法路径)===
30
+ #
31
+ # 【硬约束】robot_id / cc_id 的值只能通过以下"唯一合法链"决定:
32
+ # 1. 从 config 读 → 必须问用户"是否沿用",用户同意才用
33
+ # 2. 从 list_robots 里让用户 AskUserQuestion 选
34
+ # 除此以外任何来源均视为非法。特别禁止的错源示例(列举只为提示,非穷举):
35
+ # ❌ check_connection 返回的 robotName(那是 daemon 里第一个连接的 robot,不代表本项目)
36
+ # ❌ send_to_cc / list_active_ccs 返回里其他 CC 的 robotName
37
+ # ❌ 消息文本、CC 通信、记忆里读到的机器人名字面量
38
+ # ❌ 任何"看起来像 robot 名"的猜测
39
+
30
40
  if config and config.ccId and config.robotName:
31
- # 已配置:复用,跳到步骤 3
32
- cc_id = config.ccId
33
- robot_id = config.robotName
41
+ # 已有配置 —— 不能"默认沿用",必须让用户确认
42
+ # WHY:config 可能被别的项目或本 agent 上一次误传污染,沿用旧值会自复制错误
43
+ confirmed = AskUserQuestion(
44
+ f"沿用现有配置 robotName='{config.robotName}' + ccId='{config.ccId}'?",
45
+ options=["是", "否,改用别的机器人"]
46
+ )
47
+ if confirmed == "是":
48
+ cc_id = config.ccId
49
+ robot_id = config.robotName
50
+ else:
51
+ # 用户要改 → 走"从 list_robots 选"路径 + 调 update_wecom_aibot_config 走审批落地
52
+ goto SELECT_FROM_LIST
34
53
  else:
35
- # 未配置:生成 ccId + 选 robot
36
- cc_id = current_project_name()
37
- # MUST 禁止自动编号("-1" "-2"),冲突时让用户改名
38
- # WHY:ccId 是 daemon 注册键,自动编号让多个项目串台
54
+ goto SELECT_FROM_LIST
39
55
 
56
+ # --- SELECT_FROM_LIST 分支 ---
57
+ label SELECT_FROM_LIST:
40
58
  robots = call(f"{MCP}list_robots")
41
59
  if len(robots) == 0:
42
60
  raise Stop("未配置机器人,请先在 daemon 端 --add")
@@ -44,12 +62,21 @@ else:
44
62
  robot_id = robots[0].name # 唯一选择,无需问用户
45
63
  else:
46
64
  # MUST 用 AskUserQuestion 让用户选,禁止自选 robots[0]
47
- # WHY:自选第一个 = 用户没同意就绑定,会用错机器人(事故记录在案)
48
- # 禁止:robot_id = "CC" # 这是示例文案,不是默认值
49
- # 禁止:robot_id = robots[0].name # 用户没选过
50
- robot_id = AskUserQuestion("选机器人", options=robots).name
51
-
52
- assert robot_id in [r.name for r in robots] # 自检;daemon 在工具层会再校验一次
65
+ # WHY:自选第一个 = 用户没同意就绑定
66
+ robot_id = AskUserQuestion("选机器人", options=[r.name for r in robots])
67
+ cc_id = current_project_name() # basename(project_dir),不接受 agent 自主决定
68
+
69
+ assert robot_id in [r.name for r in robots]
70
+
71
+ # 如果本次选的值跟旧 config 不一致,先走 update_wecom_aibot_config 让用户批准修改配置
72
+ # WHY:wecom-aibot.json 是关键配置,任何修改都要用户在企微批准
73
+ if config and (config.robotName != robot_id or config.ccId != cc_id):
74
+ result = call(f"{MCP}update_wecom_aibot_config",
75
+ robot_name=robot_id,
76
+ cc_id=cc_id,
77
+ reason="用户选择了新的机器人绑定")
78
+ if result.status not in ["updated", "written_first_time", "no_change"]:
79
+ raise Stop(f"用户未批准修改配置:{result.status}")
53
80
 
54
81
  # === 步骤 3:调用 enter_headless_mode ===
55
82
  result = call(f"{MCP}enter_headless_mode",
@@ -210,6 +237,7 @@ send_message(cc_id, "已退出微信模式,回到终端交互。")
210
237
  |------|------|------|
211
238
  | `list_robots` | 列已配置机器人 | 无 |
212
239
  | `enter_headless_mode` | 进入微信模式 | cc_id, robot_id, mode, project_dir |
240
+ | `update_wecom_aibot_config` | 修改 .claude/wecom-aibot.json(改绑机器人/切换 ccId 等),已有配置时会请求用户在企微批准 | robot_name, cc_id, mode, reason |
213
241
  | `send_message` | 发消息给用户 | cc_id, content, target_user(可选) |
214
242
  | `get_pending_messages` | 取消息(HTTP) | cc_id, timeout_ms |
215
243
  | `heartbeat_check` | 心跳(HTTP) | 无 |