@vrs-soft/wecom-aibot-mcp 3.4.8 → 3.4.9-beta.1
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/channel-server.js +155 -9
- package/package.json +1 -1
- package/skills/headless-mode/SKILL.md +42 -14
package/dist/channel-server.js
CHANGED
|
@@ -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
|
-
//
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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 });
|
|
@@ -1085,6 +1108,14 @@ function registerChannelTools(server) {
|
|
|
1085
1108
|
// 注销本地 active-projects 记录
|
|
1086
1109
|
unregisterActiveProject(localProjectDir);
|
|
1087
1110
|
logChannel('本地 active-projects 已注销', { projectDir: localProjectDir });
|
|
1111
|
+
// v3.4.9-beta.1: 关闭本地 wecom-aibot.json 的 wechatMode 开关(治本)
|
|
1112
|
+
// 否则 stop-hook 读到 wechatMode:true 会阻塞 stop 并发 stderr 提示"恢复轮询",
|
|
1113
|
+
// LLM 看到提示又调 enter_headless_mode → CC 被"自动复活"永远退不掉
|
|
1114
|
+
updateWechatModeConfig(localProjectDir, {
|
|
1115
|
+
wechatMode: false,
|
|
1116
|
+
heartbeatJobId: undefined,
|
|
1117
|
+
});
|
|
1118
|
+
logger.info('本地 wecom-aibot.json wechatMode 已置 false', { projectDir: localProjectDir });
|
|
1088
1119
|
return forwardToHttpMcp('exit_headless_mode', { cc_id, project_dir: localProjectDir });
|
|
1089
1120
|
});
|
|
1090
1121
|
// ============================================
|
|
@@ -1127,6 +1158,121 @@ function registerChannelTools(server) {
|
|
|
1127
1158
|
};
|
|
1128
1159
|
});
|
|
1129
1160
|
// ============================================
|
|
1161
|
+
// 工具 14: 修改 wecom-aibot.json 配置(v3.4.9 引入)
|
|
1162
|
+
// ============================================
|
|
1163
|
+
// 目的:把"修改项目 .claude/wecom-aibot.json"的动作变成受审批的工具调用。
|
|
1164
|
+
// 首次登录(config 不存在)无需审批,直接落地;已存在时,任何字段变化都要
|
|
1165
|
+
// 走 daemon /approve 让用户在企微批准 → 用户拒绝就不改,杜绝静默篡改。
|
|
1166
|
+
server.tool('update_wecom_aibot_config', '修改项目 .claude/wecom-aibot.json 配置。首次登录直接写入;已有配置时任何字段变化都会请求用户在企微批准。用于绑定/切换机器人(robotName)、修改 ccId 等关键字段。', {
|
|
1167
|
+
robot_name: z.string().optional().describe('要设置的 robotName'),
|
|
1168
|
+
cc_id: z.string().optional().describe('要设置的 ccId'),
|
|
1169
|
+
mode: z.enum(['channel', 'http']).optional().describe('运行模式'),
|
|
1170
|
+
wechat_mode: z.boolean().optional().describe('微信模式开关'),
|
|
1171
|
+
heartbeat_job_id: z.string().optional().describe('HTTP 模式心跳 job ID'),
|
|
1172
|
+
auto_approve_timeout: z.number().optional().describe('审批超时秒数'),
|
|
1173
|
+
reason: z.string().optional().describe('修改原因(会展示在企微审批卡片正文)'),
|
|
1174
|
+
}, async ({ robot_name, cc_id, mode, wechat_mode, heartbeat_job_id, auto_approve_timeout, reason }) => {
|
|
1175
|
+
const authProjectDir = process.cwd();
|
|
1176
|
+
const old = loadWechatModeConfig(authProjectDir);
|
|
1177
|
+
const proposed = {
|
|
1178
|
+
...(robot_name !== undefined && { robotName: robot_name }),
|
|
1179
|
+
...(cc_id !== undefined && { ccId: cc_id }),
|
|
1180
|
+
...(mode !== undefined && { mode }),
|
|
1181
|
+
...(wechat_mode !== undefined && { wechatMode: wechat_mode }),
|
|
1182
|
+
...(heartbeat_job_id !== undefined && { heartbeatJobId: heartbeat_job_id }),
|
|
1183
|
+
...(auto_approve_timeout !== undefined && { autoApproveTimeout: auto_approve_timeout }),
|
|
1184
|
+
};
|
|
1185
|
+
if (Object.keys(proposed).length === 0) {
|
|
1186
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'no_op', message: '未指定任何字段' }) }] };
|
|
1187
|
+
}
|
|
1188
|
+
// 首次登录:config 不存在 → 直接写(首次固化,无需审批)
|
|
1189
|
+
if (!old) {
|
|
1190
|
+
const merged = { wechatMode: false, ...proposed };
|
|
1191
|
+
updateWechatModeConfig(authProjectDir, merged);
|
|
1192
|
+
logger.info('update_wecom_aibot_config 首次写入', { projectDir: authProjectDir, proposed });
|
|
1193
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'written_first_time', config: merged }) }] };
|
|
1194
|
+
}
|
|
1195
|
+
// 计算 diff
|
|
1196
|
+
const changed = {};
|
|
1197
|
+
for (const [k, v] of Object.entries(proposed)) {
|
|
1198
|
+
const oldVal = old[k];
|
|
1199
|
+
if (oldVal !== v)
|
|
1200
|
+
changed[k] = { from: oldVal, to: v };
|
|
1201
|
+
}
|
|
1202
|
+
if (Object.keys(changed).length === 0) {
|
|
1203
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'no_change', config: old }) }] };
|
|
1204
|
+
}
|
|
1205
|
+
// 有变化 → 请求用户批准
|
|
1206
|
+
const summaryLines = Object.entries(changed)
|
|
1207
|
+
.map(([k, { from, to }]) => `• ${k}: ${JSON.stringify(from)} → ${JSON.stringify(to)}`)
|
|
1208
|
+
.join('\n');
|
|
1209
|
+
const description = `修改 wecom-aibot.json 配置${reason ? `(${reason})` : ''}:\n${summaryLines}`;
|
|
1210
|
+
// 提交 /approve
|
|
1211
|
+
const approveBody = {
|
|
1212
|
+
tool_name: 'update_wecom_aibot_config',
|
|
1213
|
+
tool_input: {
|
|
1214
|
+
project_dir: authProjectDir,
|
|
1215
|
+
changes: changed,
|
|
1216
|
+
description,
|
|
1217
|
+
},
|
|
1218
|
+
projectDir: authProjectDir,
|
|
1219
|
+
// 不传 robotName:让 daemon 端按 ccId → getRobotByCcId → 第一个已连接 robot fallback
|
|
1220
|
+
// 直接传 old.robotName 的话,如果 old 已过时(daemon 里没有该 robot),/approve 会 503
|
|
1221
|
+
ccId: old.ccId || '',
|
|
1222
|
+
};
|
|
1223
|
+
let taskId = '';
|
|
1224
|
+
try {
|
|
1225
|
+
const approveRes = await fetch(`${MCP_URL}/approve`, {
|
|
1226
|
+
method: 'POST',
|
|
1227
|
+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
1228
|
+
body: JSON.stringify(approveBody),
|
|
1229
|
+
});
|
|
1230
|
+
if (!approveRes.ok) {
|
|
1231
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: `/approve 失败: ${approveRes.status}` }) }] };
|
|
1232
|
+
}
|
|
1233
|
+
const approveJson = await approveRes.json();
|
|
1234
|
+
taskId = approveJson.taskId || '';
|
|
1235
|
+
}
|
|
1236
|
+
catch (e) {
|
|
1237
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: `/approve 网络错误: ${String(e)}` }) }] };
|
|
1238
|
+
}
|
|
1239
|
+
if (!taskId) {
|
|
1240
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'daemon 未返回 taskId' }) }] };
|
|
1241
|
+
}
|
|
1242
|
+
// 轮询 approval_status
|
|
1243
|
+
const timeoutSec = Number(old.autoApproveTimeout) || 300;
|
|
1244
|
+
const maxPoll = Math.max(1, Math.ceil(timeoutSec / 2));
|
|
1245
|
+
let approved = 'timeout';
|
|
1246
|
+
for (let i = 0; i < maxPoll; i++) {
|
|
1247
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1248
|
+
try {
|
|
1249
|
+
const statusRes = await fetch(`${MCP_URL}/approval_status/${taskId}`, { headers: getAuthHeaders() });
|
|
1250
|
+
if (!statusRes.ok)
|
|
1251
|
+
continue;
|
|
1252
|
+
const statusJson = await statusRes.json();
|
|
1253
|
+
const r = statusJson.result;
|
|
1254
|
+
if (r === 'allow-once' || r === 'allow-always') {
|
|
1255
|
+
approved = 'allow';
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
if (r === 'deny') {
|
|
1259
|
+
approved = 'deny';
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
catch { /* ignore, keep polling */ }
|
|
1264
|
+
}
|
|
1265
|
+
if (approved !== 'allow') {
|
|
1266
|
+
logger.info('update_wecom_aibot_config 用户未批准', { taskId, approved, changes: changed });
|
|
1267
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: approved === 'deny' ? 'denied' : 'timeout', taskId, changes: changed }) }] };
|
|
1268
|
+
}
|
|
1269
|
+
// 批准 → 落地
|
|
1270
|
+
updateWechatModeConfig(authProjectDir, proposed);
|
|
1271
|
+
logger.info('update_wecom_aibot_config 已批准并落地', { taskId, projectDir: authProjectDir, changes: changed });
|
|
1272
|
+
const finalCfg = loadWechatModeConfig(authProjectDir);
|
|
1273
|
+
return { content: [{ type: 'text', text: JSON.stringify({ status: 'updated', taskId, changes: changed, config: finalCfg }) }] };
|
|
1274
|
+
});
|
|
1275
|
+
// ============================================
|
|
1130
1276
|
// 文档代理工具(转发到 HTTP MCP)
|
|
1131
1277
|
// ============================================
|
|
1132
1278
|
const docTools = [
|
package/package.json
CHANGED
|
@@ -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
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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) | 无 |
|