@vrs-soft/wecom-aibot-mcp 2.4.11 → 2.4.16
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/README.md +143 -91
- package/dist/bin.js +69 -28
- package/dist/channel-server.js +30 -8
- package/dist/client.d.ts +3 -1
- package/dist/client.js +36 -37
- package/dist/config-wizard.js +5 -40
- package/dist/http-server.js +100 -8
- package/dist/project-config.d.ts +0 -1
- package/dist/project-config.js +18 -0
- package/dist/tools/index.d.ts +0 -21
- package/dist/tools/index.js +3 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,149 +1,201 @@
|
|
|
1
|
-
#
|
|
1
|
+
# wecom-aibot-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [中文文档](README_ZH.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Enterprise WeChat AI Bot MCP Service - Remote Approval Channel for Claude Code
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- 远程审批敏感操作(Bash/Write/Edit),微信卡片一键通过/拒绝
|
|
9
|
-
- 离开电脑后通过微信下达任务,实时接收进度通知
|
|
10
|
-
- 支持群聊 @机器人,多机器人、多用户并发
|
|
11
|
-
- 代理企业微信文档 MCP,支持文档和智能表格操作
|
|
7
|
+
> Handle Claude Code approval requests via WeChat, even when away from your computer.
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
## Features
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
- 🔐 **Remote Approval**: Approve/deny sensitive operations (Bash/Write/Edit) via WeChat cards
|
|
12
|
+
- 🔍 **Full Command View**: Approval cards include a detail link — open in browser to view the complete command
|
|
13
|
+
- 💬 **Bidirectional Communication**: Real-time task progress notifications
|
|
14
|
+
- 📱 **Headless Mode**: Switch to WeChat interaction when leaving terminal
|
|
15
|
+
- 🤖 **Multi-bot Support**: Multiple bots for team and group chat scenarios
|
|
16
|
+
- 🌐 **Remote Deployment**: MCP server can be deployed on a remote host with Bearer Token auth
|
|
17
|
+
- 🔄 **Auto-reconnect**: Channel mode automatically reconnects after network interruption or server restart
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
## Architecture
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
```
|
|
22
|
+
┌─────────────────┐ MCP (stdio) ┌──────────────────────┐
|
|
23
|
+
│ Claude Code │ ──────────────────▶ │ Channel MCP Proxy │
|
|
24
|
+
│ (MCP Client) │ ◀────────────────── │ (local, SSE client) │
|
|
25
|
+
└─────────────────┘ └──────────────────────┘
|
|
26
|
+
│ SSE
|
|
27
|
+
▼
|
|
28
|
+
┌─────────────────────┐
|
|
29
|
+
│ wecom-aibot-mcp │
|
|
30
|
+
│ HTTP MCP Server │
|
|
31
|
+
│ (local or remote) │
|
|
32
|
+
└─────────────────────┘
|
|
33
|
+
│
|
|
34
|
+
WebSocket Connection
|
|
35
|
+
↓
|
|
36
|
+
┌─────────────────────┐
|
|
37
|
+
│ Enterprise WeChat │
|
|
38
|
+
└─────────────────────┘
|
|
39
|
+
│
|
|
40
|
+
↓
|
|
41
|
+
┌─────────────────────┐
|
|
42
|
+
│ User WeChat Client │
|
|
43
|
+
│ (Mobile/Desktop) │
|
|
44
|
+
└─────────────────────┘
|
|
45
|
+
```
|
|
20
46
|
|
|
21
|
-
|
|
47
|
+
## Installation
|
|
22
48
|
|
|
23
|
-
|
|
49
|
+
### Prerequisites
|
|
24
50
|
|
|
25
|
-
|
|
51
|
+
- **Node.js >= 18**
|
|
52
|
+
- Enterprise WeChat account (with bot creation permission)
|
|
53
|
+
- Claude Code
|
|
26
54
|
|
|
27
|
-
|
|
55
|
+
### Step 1: Create Enterprise WeChat Bot
|
|
28
56
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
57
|
+
1. Login to WeChat Work admin portal: https://work.weixin.qq.com
|
|
58
|
+
2. Go to "Management Tools" → "Smart Bot"
|
|
59
|
+
3. Click "Create Bot" → "Manual Creation"
|
|
60
|
+
4. Fill in bot name (e.g., "Claude Approval Assistant")
|
|
61
|
+
5. In "API Configuration":
|
|
62
|
+
- Select "Use Long Connection"
|
|
63
|
+
- Click "Get Secret"
|
|
64
|
+
6. Record **Bot ID** and **Secret**
|
|
32
65
|
|
|
33
|
-
###
|
|
66
|
+
### Step 2: Run Configuration Wizard
|
|
34
67
|
|
|
35
|
-
|
|
68
|
+
```bash
|
|
69
|
+
npx @vrs-soft/wecom-aibot-mcp --setup
|
|
70
|
+
```
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
- 「把 src/index.ts 里的 TODO 都处理掉」
|
|
39
|
-
- 「最近有什么错误日志?」
|
|
72
|
+
Choose the appropriate role flag:
|
|
40
73
|
|
|
41
|
-
|
|
74
|
+
| Command | Role | Description |
|
|
75
|
+
|---------|------|-------------|
|
|
76
|
+
| `--setup` | Interactive | Guides through local or remote setup |
|
|
77
|
+
| `--setup --server` | Server-side | Configure bot + Token, no local MCP config |
|
|
78
|
+
| `--setup --channel` | Channel client | Connect to remote server, write Channel MCP config |
|
|
79
|
+
| `--setup --server --channel` | Full local | HTTP + Channel full install |
|
|
42
80
|
|
|
43
|
-
|
|
81
|
+
**Start server after setup**:
|
|
44
82
|
|
|
45
|
-
|
|
83
|
+
```bash
|
|
84
|
+
npx @vrs-soft/wecom-aibot-mcp --http-only --start
|
|
85
|
+
```
|
|
46
86
|
|
|
47
|
-
|
|
48
|
-
- 触发 CI 任务
|
|
49
|
-
- 审批自己负责的操作(审批请求精确路由到对应 Claude 窗口)
|
|
87
|
+
## Commands
|
|
50
88
|
|
|
51
|
-
|
|
89
|
+
| Command | Description |
|
|
90
|
+
|---------|-------------|
|
|
91
|
+
| `--start / --stop` | Start/stop background service |
|
|
92
|
+
| `--status` | View service status and bot list |
|
|
93
|
+
| `--config` | Modify default bot configuration |
|
|
94
|
+
| `--add / --delete` | Add/delete bot |
|
|
95
|
+
| `--set-token [token]` | Set Auth Token (for remote deployment) |
|
|
96
|
+
| `--set-token --clear` | Clear Auth Token |
|
|
97
|
+
| `--debug` | Start in foreground with debug output |
|
|
98
|
+
| `--http-only` | Start HTTP MCP Server only (server-side use) |
|
|
99
|
+
| `--channel-only` | Configure Channel MCP only (requires `MCP_URL`) |
|
|
100
|
+
| `--clean-cache` | Clear CC registry cache |
|
|
101
|
+
| `--upgrade` | Force upgrade global configs |
|
|
102
|
+
| `--uninstall` | Complete uninstall |
|
|
52
103
|
|
|
53
|
-
##
|
|
104
|
+
## Run Modes
|
|
54
105
|
|
|
55
|
-
|
|
106
|
+
| | Channel Mode | HTTP Mode |
|
|
107
|
+
|-|-------------|-----------|
|
|
108
|
+
| Message delivery | SSE push (instant) | `/loop` heartbeat polling |
|
|
109
|
+
| Latency | Immediate | ≤1 minute |
|
|
110
|
+
| Claude account | claude.ai direct only | Any (including API relay) |
|
|
111
|
+
| Reconnect | Auto (including server restart) | Auto via heartbeat |
|
|
56
112
|
|
|
57
|
-
|
|
113
|
+
To enter WeChat mode, tell Claude: **"Now contact me via WeChat"** — this triggers the `headless-mode` skill automatically.
|
|
58
114
|
|
|
59
|
-
|
|
115
|
+
**Claude startup command for Channel mode**:
|
|
60
116
|
|
|
61
117
|
```bash
|
|
62
|
-
|
|
118
|
+
claude --dangerously-load-development-channels server:wecom-aibot-channel
|
|
63
119
|
```
|
|
64
120
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
| 命令 | 角色 | 说明 |
|
|
68
|
-
|------|------|------|
|
|
69
|
-
| `--setup` | 交互式 | 询问本地 / 远程,自动引导 |
|
|
70
|
-
| `--setup --server` | 服务器端 | 配置机器人 + Token,不写本机 MCP 配置 |
|
|
71
|
-
| `--setup --channel` | Channel 客户端 | 连接远程 Server,写入 Channel MCP |
|
|
72
|
-
| `--setup --server --channel` | 本地完整 | HTTP + Channel 全安装 |
|
|
121
|
+
## Usage Example
|
|
73
122
|
|
|
74
|
-
|
|
123
|
+
### Headless Mode (Remote Approval)
|
|
75
124
|
|
|
76
|
-
```bash
|
|
77
|
-
npx @vrs-soft/wecom-aibot-mcp --http-only --start
|
|
78
125
|
```
|
|
126
|
+
You: Now contact me via WeChat
|
|
79
127
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
```bash
|
|
83
|
-
npx @vrs-soft/wecom-aibot-mcp --start # 后台启动
|
|
84
|
-
npx @vrs-soft/wecom-aibot-mcp --stop # 停止
|
|
85
|
-
```
|
|
128
|
+
Claude: Entered WeChat mode. All interactions will go through Enterprise WeChat.
|
|
86
129
|
|
|
87
|
-
|
|
130
|
+
[You leave the computer. Claude needs to run a command.]
|
|
88
131
|
|
|
89
|
-
|
|
132
|
+
WeChat receives approval card:
|
|
133
|
+
┌──────────────────────────────┐
|
|
134
|
+
│ 【Pending Approval】Bash │
|
|
135
|
+
│ Command: npm run build... │
|
|
136
|
+
│ 📋 TaskID: approval_xxx │
|
|
137
|
+
│ [Allow Once] [Default] [Deny]│
|
|
138
|
+
│ Details: View full command │
|
|
139
|
+
└──────────────────────────────┘
|
|
90
140
|
|
|
91
|
-
|
|
92
|
-
|-|-------------|----------|
|
|
93
|
-
| 消息接收 | SSE 自动推送唤醒 | `/loop` 心跳轮询 |
|
|
94
|
-
| 响应延迟 | 即时 | ≤1 分钟 |
|
|
95
|
-
| 账号要求 | claude.ai 直连 | 任意(含 API 中转)|
|
|
141
|
+
[Tap "Allow Once" on phone, or open "View full command" to see complete output]
|
|
96
142
|
|
|
97
|
-
|
|
143
|
+
Claude continues execution and sends the result to WeChat.
|
|
98
144
|
|
|
99
|
-
|
|
145
|
+
You: I'm back
|
|
100
146
|
|
|
101
|
-
|
|
102
|
-
claude --dangerously-load-development-channels server:wecom-aibot-channel
|
|
147
|
+
Claude: Exited WeChat mode.
|
|
103
148
|
```
|
|
104
149
|
|
|
105
|
-
|
|
150
|
+
### Timeout Auto-Approval
|
|
106
151
|
|
|
107
|
-
|
|
152
|
+
Configure in the bot config file or via `wecom-aibot.json`:
|
|
108
153
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
| `--add / --delete` | 添加/删除机器人 |
|
|
115
|
-
| `--set-token [token]` | 设置 Auth Token(远程部署用) |
|
|
116
|
-
| `--set-token --clear` | 清除 Auth Token |
|
|
117
|
-
| `--debug` | 前台启动,输出调试日志 |
|
|
118
|
-
| `--http-only` | 仅启动 HTTP MCP Server(服务器端用) |
|
|
119
|
-
| `--channel-only` | 仅配置 Channel MCP(需 `MCP_URL` 环境变量) |
|
|
120
|
-
| `--clean-cache` | 清空 CC 注册表缓存 |
|
|
121
|
-
| `--upgrade` | 强制升级全局配置 |
|
|
122
|
-
| `--uninstall` | 完全卸载 |
|
|
123
|
-
|
|
124
|
-
超时自动审批(默认 10 分钟):在机器人配置中设置 `"autoApproveTimeout": 600`。
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"autoApproveTimeout": 600
|
|
157
|
+
}
|
|
158
|
+
```
|
|
125
159
|
|
|
126
|
-
|
|
160
|
+
- `autoApproveTimeout`: Timeout in seconds (default 600s = 10 minutes)
|
|
161
|
+
- After timeout: operations **within** the project directory are auto-allowed; operations outside or delete commands are auto-denied
|
|
127
162
|
|
|
128
|
-
##
|
|
163
|
+
## Troubleshooting
|
|
129
164
|
|
|
130
165
|
```bash
|
|
131
|
-
#
|
|
166
|
+
# Check if service is running
|
|
132
167
|
curl http://127.0.0.1:18963/health
|
|
133
168
|
|
|
134
|
-
# Channel
|
|
135
|
-
# →
|
|
169
|
+
# Channel unavailable ("Channels are not currently available")
|
|
170
|
+
# → Using API key or relay service? Switch to HTTP mode instead.
|
|
171
|
+
|
|
172
|
+
# Channel fails to reconnect after server restart
|
|
173
|
+
# → Auto-reconnect triggers within 5 seconds; no manual action needed.
|
|
174
|
+
# Requires v2.4.13 or later.
|
|
136
175
|
|
|
137
|
-
#
|
|
176
|
+
# Approval detail page shows "Unauthorized"
|
|
177
|
+
# → Upgrade to v2.4.14 or later; the /approval/ path is now auth-exempt.
|
|
178
|
+
|
|
179
|
+
# Port conflict
|
|
138
180
|
lsof -i :18963 | grep LISTEN
|
|
139
181
|
kill <PID>
|
|
140
182
|
|
|
141
|
-
#
|
|
183
|
+
# Clean up stale ccId registrations after disconnect
|
|
142
184
|
npx @vrs-soft/wecom-aibot-mcp --clean-cache
|
|
143
185
|
```
|
|
144
186
|
|
|
145
|
-
|
|
187
|
+
## MCP Tools
|
|
188
|
+
|
|
189
|
+
| Tool | Description | Key Parameters |
|
|
190
|
+
|------|-------------|----------------|
|
|
191
|
+
| `send_message` | Send message to WeChat | `content`, `cc_id`, `target_user` |
|
|
192
|
+
| `get_pending_messages` | Get pending messages (long poll) | `cc_id`, `timeout_ms` |
|
|
193
|
+
| `enter_headless_mode` | Enter WeChat mode | `cc_id`, `robot_id`, `mode` |
|
|
194
|
+
| `exit_headless_mode` | Exit WeChat mode | `cc_id` |
|
|
195
|
+
| `check_connection` | Check WebSocket connection status | - |
|
|
196
|
+
| `list_robots` | List all configured bots | - |
|
|
197
|
+
| `get_connection_stats` | Get connection stats and logs | `recent_logs` |
|
|
146
198
|
|
|
147
199
|
## License
|
|
148
200
|
|
|
149
|
-
MIT · [
|
|
201
|
+
MIT · [Enterprise WeChat Bot Docs](https://developer.work.weixin.qq.com/document/path/101039) · [Channels Reference](https://code.claude.com/docs/en/channels-reference)
|
package/dist/bin.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - robotName 作为连接索引
|
|
10
10
|
* - 不再使用 projectDir
|
|
11
11
|
*/
|
|
12
|
-
import { spawn } from 'child_process';
|
|
12
|
+
import { spawn, execSync } from 'child_process';
|
|
13
13
|
import * as fs from 'fs';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import * as os from 'os';
|
|
@@ -164,43 +164,81 @@ function isServerRunning() {
|
|
|
164
164
|
return false;
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
// 通过端口查找进程 PID(fallback,当 PID 文件不存在时)
|
|
168
|
+
function findPidByPort(port) {
|
|
169
|
+
try {
|
|
170
|
+
// Linux: ss -tlnp | grep :18963
|
|
171
|
+
const output = execSync(`ss -tlnp 2>/dev/null | grep ':${port}'`, { encoding: 'utf-8' });
|
|
172
|
+
const match = output.match(/pid=(\d+)/);
|
|
173
|
+
if (match)
|
|
174
|
+
return parseInt(match[1]);
|
|
175
|
+
}
|
|
176
|
+
catch { /* ignore */ }
|
|
177
|
+
try {
|
|
178
|
+
// macOS: lsof -ti :18963
|
|
179
|
+
const output = execSync(`lsof -ti :${port} 2>/dev/null`, { encoding: 'utf-8' }).trim();
|
|
180
|
+
if (output)
|
|
181
|
+
return parseInt(output.split('\n')[0]);
|
|
182
|
+
}
|
|
183
|
+
catch { /* ignore */ }
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
167
186
|
// 停止服务
|
|
168
187
|
function stopServer() {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
188
|
+
let pid = null;
|
|
189
|
+
// 优先从 PID 文件获取
|
|
190
|
+
if (fs.existsSync(PID_FILE)) {
|
|
191
|
+
pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
|
|
192
|
+
// 检查进程是否存在
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// PID 文件残留但进程已死,清理 PID 文件
|
|
198
|
+
console.log('[mcp] PID 文件残留,进程已退出,清理中...');
|
|
199
|
+
fs.unlinkSync(PID_FILE);
|
|
200
|
+
pid = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// PID 文件不存在或残留:通过端口查找
|
|
204
|
+
if (pid === null) {
|
|
205
|
+
pid = findPidByPort(HTTP_PORT);
|
|
206
|
+
if (pid === null) {
|
|
207
|
+
console.log('[mcp] 服务未运行');
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
console.log(`[mcp] 通过端口 ${HTTP_PORT} 找到进程 PID: ${pid}`);
|
|
172
211
|
}
|
|
212
|
+
// 发送 SIGTERM
|
|
173
213
|
try {
|
|
174
|
-
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8').trim());
|
|
175
214
|
process.kill(pid, 'SIGTERM');
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
process.kill(pid, 0);
|
|
181
|
-
// 进程还存在,等待
|
|
182
|
-
setTimeout(() => { }, 500);
|
|
183
|
-
attempts++;
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
// 进程已退出
|
|
187
|
-
break;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// 进程退出后删除 PID 文件(如果还存在)
|
|
191
|
-
if (fs.existsSync(PID_FILE)) {
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// ESRCH: 进程不存在,清理即可
|
|
218
|
+
if (fs.existsSync(PID_FILE))
|
|
192
219
|
fs.unlinkSync(PID_FILE);
|
|
193
|
-
}
|
|
194
220
|
console.log('[mcp] 服务已停止');
|
|
195
221
|
return true;
|
|
196
222
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
// 等待进程退出(最多 5 秒)
|
|
224
|
+
const deadline = Date.now() + 5000;
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, 0);
|
|
228
|
+
// 进程还在,同步等待 100ms
|
|
229
|
+
const waitUntil = Date.now() + 100;
|
|
230
|
+
while (Date.now() < waitUntil) { /* busy wait */ }
|
|
201
231
|
}
|
|
202
|
-
|
|
232
|
+
catch {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// 清理 PID 文件
|
|
237
|
+
if (fs.existsSync(PID_FILE)) {
|
|
238
|
+
fs.unlinkSync(PID_FILE);
|
|
203
239
|
}
|
|
240
|
+
console.log('[mcp] 服务已停止');
|
|
241
|
+
return true;
|
|
204
242
|
}
|
|
205
243
|
// 等待连接验证(用于配置向导验证凭证)
|
|
206
244
|
async function waitForConnection(client, timeoutMs = 10000) {
|
|
@@ -318,10 +356,13 @@ async function main() {
|
|
|
318
356
|
// --channel: 作为 Channel MCP 代理运行,不应改写全局配置
|
|
319
357
|
// --reinstall / --http-only: 有自己的处理逻辑
|
|
320
358
|
// --version / -v: 只查版本,不写配置
|
|
359
|
+
// --stop / --status / --list / --clean-cache / --set-token / --config: 管理命令,不应改写配置
|
|
321
360
|
const skipEnsure = args.includes('--reinstall') || args.includes('--http-only') ||
|
|
322
361
|
args.includes('--setup') || args.includes('--channel') ||
|
|
323
362
|
args.includes('--version') || args.includes('-v') ||
|
|
324
|
-
args.includes('--start') || args.includes('--debug')
|
|
363
|
+
args.includes('--start') || args.includes('--debug') ||
|
|
364
|
+
args.includes('--stop') || args.includes('--status') || args.includes('--list') ||
|
|
365
|
+
args.includes('--clean-cache') || args.includes('--set-token') || args.includes('--config');
|
|
325
366
|
if (!skipEnsure) {
|
|
326
367
|
// 强制覆盖所有全局配置(不依赖智能体)
|
|
327
368
|
ensureGlobalConfigs(installMode);
|
package/dist/channel-server.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as fs from 'fs';
|
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import * as os from 'os';
|
|
18
18
|
import { VERSION } from './config-wizard.js';
|
|
19
|
-
import { addPermissionHook } from './project-config.js';
|
|
19
|
+
import { addPermissionHook, registerActiveProject, unregisterActiveProject } from './project-config.js';
|
|
20
20
|
const MCP_URL = process.env.MCP_URL || 'http://127.0.0.1:18963';
|
|
21
21
|
const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
|
|
22
22
|
// 构建带 auth 的 fetch headers
|
|
@@ -205,6 +205,23 @@ function connectSSE(ccId) {
|
|
|
205
205
|
if (!res.ok) {
|
|
206
206
|
logChannel('SSE connect failed', { status: res.status });
|
|
207
207
|
sseConnected = false;
|
|
208
|
+
// server 重启后 ccId 注册丢失(404),需重新注册再重连
|
|
209
|
+
if (!sseAbortController?.signal.aborted) {
|
|
210
|
+
const delay = res.status === 404 ? 5000 : 3000;
|
|
211
|
+
logChannel(`SSE 连接失败(${res.status}),${delay / 1000} 秒后重连`, { ccId });
|
|
212
|
+
setTimeout(async () => {
|
|
213
|
+
httpSessionId = null; // 重置 session,防止使用 server 重启前的旧 session
|
|
214
|
+
if (ccId) {
|
|
215
|
+
// 重新调 enter_headless_mode 恢复 server 端 ccId 注册
|
|
216
|
+
await forwardToHttpMcp('enter_headless_mode', {
|
|
217
|
+
cc_id: ccId,
|
|
218
|
+
mode: 'channel',
|
|
219
|
+
project_dir: process.cwd(),
|
|
220
|
+
}).catch((e) => logChannel('重注册 ccId 失败', { error: String(e) }));
|
|
221
|
+
}
|
|
222
|
+
connectSSE(ccId);
|
|
223
|
+
}, delay);
|
|
224
|
+
}
|
|
208
225
|
return;
|
|
209
226
|
}
|
|
210
227
|
logChannel('SSE connected, waiting for messages', { status: res.status });
|
|
@@ -230,7 +247,7 @@ function connectSSE(ccId) {
|
|
|
230
247
|
// 非主动断开时自动重连
|
|
231
248
|
if (!sseAbortController?.signal.aborted) {
|
|
232
249
|
logChannel('SSE 断线,3 秒后重连', { ccId });
|
|
233
|
-
setTimeout(() => connectSSE(ccId), 3000);
|
|
250
|
+
setTimeout(() => { httpSessionId = null; connectSSE(ccId); }, 3000);
|
|
234
251
|
}
|
|
235
252
|
break;
|
|
236
253
|
}
|
|
@@ -305,7 +322,7 @@ function connectSSE(ccId) {
|
|
|
305
322
|
// 非主动断开时自动重连
|
|
306
323
|
if (!sseAbortController?.signal.aborted) {
|
|
307
324
|
logChannel('SSE 出错,3 秒后重连', { ccId });
|
|
308
|
-
setTimeout(() => connectSSE(ccId), 3000);
|
|
325
|
+
setTimeout(() => { httpSessionId = null; connectSSE(ccId); }, 3000);
|
|
309
326
|
}
|
|
310
327
|
});
|
|
311
328
|
}
|
|
@@ -399,9 +416,8 @@ function registerChannelTools(server) {
|
|
|
399
416
|
project_dir: z.string().optional().describe('项目目录路径(用于写入配置文件)'),
|
|
400
417
|
mode: z.enum(['channel', 'http']).optional().default('http')
|
|
401
418
|
.describe('运行模式:channel=SSE推送(推荐),http=轮询(兼容)'),
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve, auto_approve_timeout }) => {
|
|
419
|
+
auto_approve_timeout: z.number().optional().default(600).describe('超时自动决策等待时间(秒,默认 600 即 10 分钟)'),
|
|
420
|
+
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve_timeout }) => {
|
|
405
421
|
// 转发请求
|
|
406
422
|
const result = await forwardToHttpMcp('enter_headless_mode', {
|
|
407
423
|
agent_name,
|
|
@@ -409,7 +425,6 @@ function registerChannelTools(server) {
|
|
|
409
425
|
robot_id,
|
|
410
426
|
project_dir: project_dir || process.cwd(),
|
|
411
427
|
mode,
|
|
412
|
-
auto_approve,
|
|
413
428
|
auto_approve_timeout,
|
|
414
429
|
});
|
|
415
430
|
// 拦截响应,提取 ccId,建立 SSE 连接
|
|
@@ -425,6 +440,9 @@ function registerChannelTools(server) {
|
|
|
425
440
|
const localProjectDir = project_dir || process.cwd();
|
|
426
441
|
const hookResult = addPermissionHook(localProjectDir);
|
|
427
442
|
logChannel('本地 PermissionRequest hook 已写入', { path: hookResult.path, success: hookResult.success });
|
|
443
|
+
// 注册本地 PID → projectDir(供本地 permission-hook.sh 通过进程树匹配项目)
|
|
444
|
+
registerActiveProject(process.ppid ?? process.pid, localProjectDir);
|
|
445
|
+
logChannel('本地 active-projects 已注册', { pid: process.ppid ?? process.pid, projectDir: localProjectDir });
|
|
428
446
|
// Channel 模式:过滤 heartbeat 信息,简化消息
|
|
429
447
|
if (mode === 'channel' || parsed.mode === 'channel') {
|
|
430
448
|
delete parsed.heartbeat; // Channel 模式不需要 heartbeat loop
|
|
@@ -448,6 +466,7 @@ function registerChannelTools(server) {
|
|
|
448
466
|
cc_id: z.string().describe('CC 唯一标识(enter_headless_mode 返回的 ccId)'),
|
|
449
467
|
project_dir: z.string().optional().describe('项目目录路径(用于更新配置文件)'),
|
|
450
468
|
}, async ({ cc_id, project_dir }) => {
|
|
469
|
+
const localProjectDir = project_dir || process.cwd();
|
|
451
470
|
// 断开 SSE 连接(abort 后重连逻辑不会触发)
|
|
452
471
|
if (sseAbortController) {
|
|
453
472
|
sseAbortController.abort();
|
|
@@ -456,7 +475,10 @@ function registerChannelTools(server) {
|
|
|
456
475
|
sseCurrentCcId = undefined;
|
|
457
476
|
logChannel('SSE disconnected', { cc_id });
|
|
458
477
|
}
|
|
459
|
-
|
|
478
|
+
// 注销本地 active-projects 记录
|
|
479
|
+
unregisterActiveProject(localProjectDir);
|
|
480
|
+
logChannel('本地 active-projects 已注销', { projectDir: localProjectDir });
|
|
481
|
+
return forwardToHttpMcp('exit_headless_mode', { cc_id, project_dir: localProjectDir });
|
|
460
482
|
});
|
|
461
483
|
// ============================================
|
|
462
484
|
// 工具 11: 从消息识别用户
|
package/dist/client.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface ApprovalRecord {
|
|
|
14
14
|
operationHash?: string;
|
|
15
15
|
consumed?: boolean;
|
|
16
16
|
ccId?: string;
|
|
17
|
+
detailUrl?: string;
|
|
17
18
|
}
|
|
18
19
|
interface MessageRecord {
|
|
19
20
|
seq: number;
|
|
@@ -61,7 +62,8 @@ declare class WecomClient extends EventEmitter {
|
|
|
61
62
|
}>;
|
|
62
63
|
sendText(content: string, targetUser?: string): Promise<boolean>;
|
|
63
64
|
sendApprovalRequest(title: string, description: string, requestId: string, targetUser?: string, toolInput?: Record<string, unknown>, // v3.0: 用于去重
|
|
64
|
-
ccId?: string
|
|
65
|
+
ccId?: string, // v3.0: 参与哈希,防止跨 CC 复用审批
|
|
66
|
+
detailUrlBase?: string): Promise<string>;
|
|
65
67
|
sendQueuedApproval(taskId: string, title: string, description: string, targetUser?: string): Promise<boolean>;
|
|
66
68
|
getApprovalResult(taskId: string): 'pending' | 'allow-once' | 'allow-always' | 'deny';
|
|
67
69
|
setApprovalResult(taskId: string, result: 'allow-once' | 'deny', reason?: string): boolean;
|
package/dist/client.js
CHANGED
|
@@ -19,6 +19,30 @@ import { logger } from './logger.js';
|
|
|
19
19
|
const MAX_PENDING_MESSAGES = 100;
|
|
20
20
|
// 全局消息序号计数器
|
|
21
21
|
let globalMessageSeq = 0;
|
|
22
|
+
// 审批卡片正文长度上限;超过则截断,余下由详情链接承接
|
|
23
|
+
const APPROVAL_DESC_MAX = 200;
|
|
24
|
+
// 构建审批卡片 payload(发首次审批 + 排队重发共用)
|
|
25
|
+
function buildApprovalCard(title, description, taskId, detailUrl) {
|
|
26
|
+
const truncated = description.length > APPROVAL_DESC_MAX
|
|
27
|
+
? description.slice(0, APPROVAL_DESC_MAX) + '…(已截断,点击「详情」查看完整内容)'
|
|
28
|
+
: description;
|
|
29
|
+
const subTitle = truncated + `\n\n📋 TaskID: ${taskId}`;
|
|
30
|
+
const card = {
|
|
31
|
+
card_type: 'button_interaction',
|
|
32
|
+
main_title: { title },
|
|
33
|
+
sub_title_text: subTitle,
|
|
34
|
+
button_list: [
|
|
35
|
+
{ text: '允许', key: 'allow-once', style: 1 },
|
|
36
|
+
{ text: '默认', key: 'allow-always', style: 1 },
|
|
37
|
+
{ text: '拒绝', key: 'deny', style: 2 },
|
|
38
|
+
],
|
|
39
|
+
task_id: taskId,
|
|
40
|
+
...(detailUrl
|
|
41
|
+
? { horizontal_content_list: [{ keyname: '详情', value: '查看完整命令', type: 1, url: detailUrl }] }
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
44
|
+
return { msgtype: 'template_card', template_card: card };
|
|
45
|
+
}
|
|
22
46
|
class WecomClient extends EventEmitter {
|
|
23
47
|
wsClient;
|
|
24
48
|
approvals = new Map();
|
|
@@ -228,8 +252,9 @@ class WecomClient extends EventEmitter {
|
|
|
228
252
|
: eventKey === 'allow-always' ? '✅ 已允许(永久)'
|
|
229
253
|
: '❌ 已拒绝';
|
|
230
254
|
const toolInfo = approval.toolName ? `: ${approval.toolName}` : '';
|
|
231
|
-
const
|
|
232
|
-
const
|
|
255
|
+
const desc = approval.description || '';
|
|
256
|
+
const descSnippet = desc ? `\n执行命令: ${desc.slice(0, 100)}${desc.length > 100 ? '…' : ''}` : '';
|
|
257
|
+
const content = `**审批结果**${toolInfo}\n\n${resultText}${descSnippet}`;
|
|
233
258
|
this.sendText(content).catch(err => {
|
|
234
259
|
logger.error('wecom', `发送审批确认失败: ${err}`);
|
|
235
260
|
});
|
|
@@ -344,7 +369,8 @@ class WecomClient extends EventEmitter {
|
|
|
344
369
|
}
|
|
345
370
|
// 发送审批请求(带按钮的模板卡片)
|
|
346
371
|
async sendApprovalRequest(title, description, requestId, targetUser, toolInput, // v3.0: 用于去重
|
|
347
|
-
ccId // v3.0: 参与哈希,防止跨 CC 复用审批
|
|
372
|
+
ccId, // v3.0: 参与哈希,防止跨 CC 复用审批
|
|
373
|
+
detailUrlBase // 详情页 h5 链接的 base(最终 URL = base/taskId)
|
|
348
374
|
) {
|
|
349
375
|
const userId = targetUser || this.targetUserId;
|
|
350
376
|
// 从 title 中提取工具名称(格式: 【待审批】Bash)
|
|
@@ -360,6 +386,7 @@ class WecomClient extends EventEmitter {
|
|
|
360
386
|
}
|
|
361
387
|
const taskId = `approval_${requestId}_${Date.now()}`;
|
|
362
388
|
const operationHash = toolInput && toolName ? hashOperation(ccId ?? '', toolName, toolInput) : undefined;
|
|
389
|
+
const detailUrl = detailUrlBase ? `${detailUrlBase}/${taskId}` : undefined;
|
|
363
390
|
// 始终存储审批记录(断线时也需要,让 Hook 能轮询到)
|
|
364
391
|
this.approvals.set(taskId, {
|
|
365
392
|
taskId,
|
|
@@ -370,6 +397,7 @@ class WecomClient extends EventEmitter {
|
|
|
370
397
|
description, // 保存审批请求原文
|
|
371
398
|
operationHash,
|
|
372
399
|
ccId, // 保存 ccId,用于 SSE 推送审批结果
|
|
400
|
+
detailUrl, // 供排队重发时复用
|
|
373
401
|
});
|
|
374
402
|
// 断线时将审批请求加入队列,等待重连后发送
|
|
375
403
|
if (!this.connected) {
|
|
@@ -383,22 +411,7 @@ class WecomClient extends EventEmitter {
|
|
|
383
411
|
// 返回 taskId,审批记录已创建,等待重连后发送
|
|
384
412
|
return taskId;
|
|
385
413
|
}
|
|
386
|
-
|
|
387
|
-
const displayDesc = description + `\n\n📋 TaskID: ${taskId}`;
|
|
388
|
-
await this.wsClient.sendMessage(userId, {
|
|
389
|
-
msgtype: 'template_card',
|
|
390
|
-
template_card: {
|
|
391
|
-
card_type: 'button_interaction',
|
|
392
|
-
main_title: { title },
|
|
393
|
-
sub_title_text: displayDesc,
|
|
394
|
-
button_list: [
|
|
395
|
-
{ text: '允许', key: 'allow-once', style: 1 },
|
|
396
|
-
{ text: '默认', key: 'allow-always', style: 1 },
|
|
397
|
-
{ text: '拒绝', key: 'deny', style: 2 },
|
|
398
|
-
],
|
|
399
|
-
task_id: taskId,
|
|
400
|
-
},
|
|
401
|
-
});
|
|
414
|
+
await this.wsClient.sendMessage(userId, buildApprovalCard(title, description, taskId, detailUrl));
|
|
402
415
|
logger.log(`[wecom] 已发送审批请求到 ${userId}: ${taskId}`);
|
|
403
416
|
return taskId;
|
|
404
417
|
}
|
|
@@ -415,22 +428,7 @@ class WecomClient extends EventEmitter {
|
|
|
415
428
|
return false;
|
|
416
429
|
}
|
|
417
430
|
const userId = targetUser || this.targetUserId;
|
|
418
|
-
|
|
419
|
-
const displayDesc = description + `\n\n📋 TaskID: ${taskId}`;
|
|
420
|
-
await this.wsClient.sendMessage(userId, {
|
|
421
|
-
msgtype: 'template_card',
|
|
422
|
-
template_card: {
|
|
423
|
-
card_type: 'button_interaction',
|
|
424
|
-
main_title: { title },
|
|
425
|
-
sub_title_text: displayDesc,
|
|
426
|
-
button_list: [
|
|
427
|
-
{ text: '允许', key: 'allow-once', style: 1 },
|
|
428
|
-
{ text: '默认', key: 'allow-always', style: 1 },
|
|
429
|
-
{ text: '拒绝', key: 'deny', style: 2 },
|
|
430
|
-
],
|
|
431
|
-
task_id: taskId,
|
|
432
|
-
},
|
|
433
|
-
});
|
|
431
|
+
await this.wsClient.sendMessage(userId, buildApprovalCard(title, description, taskId, approval.detailUrl));
|
|
434
432
|
logger.log(`[wecom] 已发送排队审批请求到 ${userId}: ${taskId}`);
|
|
435
433
|
return true;
|
|
436
434
|
}
|
|
@@ -464,8 +462,9 @@ class WecomClient extends EventEmitter {
|
|
|
464
462
|
const resultText = result === 'deny' ? '❌ 已拒绝' : '✅ 已允许';
|
|
465
463
|
const reasonText = reason ? `\n\n原因:${reason}` : '';
|
|
466
464
|
const toolInfo = approval.toolName ? `: ${approval.toolName}` : '';
|
|
467
|
-
const
|
|
468
|
-
|
|
465
|
+
const desc = approval.description || '';
|
|
466
|
+
const descBlock = desc ? `\n\n**执行命令**\n\`\`\`\n${desc}\n\`\`\`` : '';
|
|
467
|
+
this.sendText(`**审批结果(超时自动决策)**${toolInfo}\n\n${resultText}${reasonText}${descBlock}`).catch(err => {
|
|
469
468
|
logger.error('[wecom] 发送审批确认失败:', err);
|
|
470
469
|
});
|
|
471
470
|
logger.log(`[wecom] 超时自动决策已设置: ${taskId} → ${result}`);
|
package/dist/config-wizard.js
CHANGED
|
@@ -413,7 +413,7 @@ function writeHookScript() {
|
|
|
413
413
|
# HTTP Transport 版本
|
|
414
414
|
#
|
|
415
415
|
# 固定端口: 18963
|
|
416
|
-
#
|
|
416
|
+
# 通过 PID 树查 ~/.wecom-aibot-mcp/active-projects.json 匹配项目,读 wechatMode 开关
|
|
417
417
|
|
|
418
418
|
MCP_PORT=18963
|
|
419
419
|
|
|
@@ -594,37 +594,10 @@ while [[ $POLL_COUNT -lt $MAX_POLL ]]; do
|
|
|
594
594
|
fi
|
|
595
595
|
done
|
|
596
596
|
|
|
597
|
-
log_debug "[$(date)] Timeout reached,
|
|
597
|
+
log_debug "[$(date)] Timeout reached, executing smart auto-approval"
|
|
598
598
|
|
|
599
|
-
#
|
|
600
|
-
# autoApprove: false → 继续等待(无限轮询)
|
|
601
|
-
# autoApprove: true → 智能代批
|
|
602
|
-
|
|
603
|
-
AUTO_APPROVE=$(jq -r '.autoApprove // false' "$CONFIG_FILE" 2>/dev/null)
|
|
604
|
-
log_debug "[$(date)] autoApprove: $AUTO_APPROVE"
|
|
605
|
-
if [[ "$AUTO_APPROVE" != "true" ]]; then
|
|
606
|
-
log_debug "[$(date)] autoApprove off, entering infinite wait"
|
|
607
|
-
# autoApprove 关闭,继续无限等待用户响应
|
|
608
|
-
while true; do
|
|
609
|
-
sleep 2
|
|
610
|
-
STATUS=$(curl -s -m 3 "\${AUTH_ARGS[@]}" "$MCP_BASE_URL/approval_status/$TASK_ID" 2>/dev/null)
|
|
611
|
-
RESULT=$(echo "$STATUS" | jq -r '.result // empty')
|
|
612
|
-
|
|
613
|
-
if [[ "$RESULT" == "allow-once" || "$RESULT" == "allow-always" ]]; then
|
|
614
|
-
log_debug "[$(date)] Approved by user (infinite wait)"
|
|
615
|
-
printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}'
|
|
616
|
-
exit 0
|
|
617
|
-
elif [[ "$RESULT" == "deny" ]]; then
|
|
618
|
-
log_debug "[$(date)] Denied by user (infinite wait)"
|
|
619
|
-
printf '%s\\n' '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"用户拒绝"}}}'
|
|
620
|
-
exit 0
|
|
621
|
-
fi
|
|
622
|
-
done
|
|
623
|
-
fi
|
|
624
|
-
|
|
625
|
-
# autoApprove: true,执行智能代批
|
|
599
|
+
# 超时处理:必须立即决策,Claude Code 的 hook timeout 会杀掉阻塞进程。
|
|
626
600
|
# 规则:删除命令→拒绝,项目内操作→允许,项目外操作→拒绝
|
|
627
|
-
log_debug "[$(date)] Executing smart auto-approval"
|
|
628
601
|
|
|
629
602
|
# 检查是否是删除命令(仅匹配命令行本身,不匹配 heredoc 内容)
|
|
630
603
|
IS_DELETE=0
|
|
@@ -726,7 +699,7 @@ function writeStopHookScript() {
|
|
|
726
699
|
# HTTP 模式使用:阻止 Claude 停止,提示调用 get_pending_messages 恢复轮询
|
|
727
700
|
#
|
|
728
701
|
# 固定端口: 18963
|
|
729
|
-
#
|
|
702
|
+
# 只检查 $(pwd)/.claude/wecom-aibot.json 的 wechatMode 字段
|
|
730
703
|
|
|
731
704
|
MCP_PORT=18963
|
|
732
705
|
|
|
@@ -763,14 +736,6 @@ if [[ "$WECHAT_MODE" != "true" ]]; then
|
|
|
763
736
|
exit 0
|
|
764
737
|
fi
|
|
765
738
|
|
|
766
|
-
# 检查 autoApprove 是否为 true(需要恢复轮询的模式)
|
|
767
|
-
AUTO_APPROVE=$(jq -r '.autoApprove // false' "$CONFIG_FILE" 2>/dev/null)
|
|
768
|
-
log_debug "[$(date)] autoApprove: $AUTO_APPROVE"
|
|
769
|
-
if [[ "$AUTO_APPROVE" != "true" ]]; then
|
|
770
|
-
log_debug "[$(date)] autoApprove not true, exit 0 (allow stop)"
|
|
771
|
-
exit 0
|
|
772
|
-
fi
|
|
773
|
-
|
|
774
739
|
# 确定 MCP Server 地址(本地优先,失败则尝试远程 channel 配置)
|
|
775
740
|
MCP_BASE_URL="http://127.0.0.1:$MCP_PORT"
|
|
776
741
|
AUTH_ARGS=()
|
|
@@ -810,7 +775,7 @@ if [[ -z "$CC_ID" ]]; then
|
|
|
810
775
|
exit 0
|
|
811
776
|
fi
|
|
812
777
|
|
|
813
|
-
#
|
|
778
|
+
# 处于微信模式,需要恢复轮询
|
|
814
779
|
# 使用 exit code 2 阻止停止,并提示 Claude 调用 MCP 工具
|
|
815
780
|
log_debug "[$(date)] ✅ WeChat mode active, blocking stop to resume polling"
|
|
816
781
|
log_debug "[$(date)] ccId=$CC_ID, will prompt Claude to call get_pending_messages"
|
package/dist/http-server.js
CHANGED
|
@@ -204,9 +204,9 @@ function initMcpServer() {
|
|
|
204
204
|
subscribeWecomMessage((msg) => {
|
|
205
205
|
handleWecomMessage(msg);
|
|
206
206
|
});
|
|
207
|
-
// 定时清理过期审批条目(每 5 分钟清理超过
|
|
207
|
+
// 定时清理过期审批条目(每 5 分钟清理超过 30 分钟的条目)
|
|
208
208
|
setInterval(() => {
|
|
209
|
-
const cutoff = Date.now() -
|
|
209
|
+
const cutoff = Date.now() - 30 * 60 * 1000;
|
|
210
210
|
let cleaned = 0;
|
|
211
211
|
for (const [id, entry] of pendingApprovals) {
|
|
212
212
|
if (entry.createdAt < cutoff) {
|
|
@@ -506,9 +506,9 @@ export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
|
|
|
506
506
|
return;
|
|
507
507
|
}
|
|
508
508
|
const url = req.url || '/';
|
|
509
|
-
// Auth token 校验(排除 /health
|
|
509
|
+
// Auth token 校验(排除 /health 和 /approval/ 详情页,后者由浏览器直接访问)
|
|
510
510
|
const authToken = getAuthToken();
|
|
511
|
-
if (authToken && url !== '/health') {
|
|
511
|
+
if (authToken && url !== '/health' && !url.startsWith('/approval/')) {
|
|
512
512
|
const authHeader = req.headers['authorization'];
|
|
513
513
|
if (authHeader !== `Bearer ${authToken}`) {
|
|
514
514
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
@@ -622,6 +622,10 @@ export async function startHttpServer(_server, port = HTTP_PORT, httpsConfig) {
|
|
|
622
622
|
handleApprovalStatus(req, res, url);
|
|
623
623
|
return;
|
|
624
624
|
}
|
|
625
|
+
if (req.method === 'GET' && url.startsWith('/approval/')) {
|
|
626
|
+
handleApprovalDetail(req, res, url);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
625
629
|
if (req.method === 'POST' && url.startsWith('/approval_timeout/')) {
|
|
626
630
|
await handleApprovalTimeout(req, res, url);
|
|
627
631
|
return;
|
|
@@ -877,7 +881,7 @@ async function handleApprovalRequest(req, res) {
|
|
|
877
881
|
res.end(JSON.stringify({ error: '未连接机器人,请先进入微信模式' }));
|
|
878
882
|
return;
|
|
879
883
|
}
|
|
880
|
-
const { tool_name, tool_input } = request;
|
|
884
|
+
const { tool_name, tool_input, projectDir } = request;
|
|
881
885
|
let description = '';
|
|
882
886
|
if (tool_name === 'Bash') {
|
|
883
887
|
description = `执行命令: ${tool_input?.command || '(unknown)'}`;
|
|
@@ -890,8 +894,12 @@ async function handleApprovalRequest(req, res) {
|
|
|
890
894
|
}
|
|
891
895
|
const title = `【待审批】${tool_name}`;
|
|
892
896
|
const requestId = `hook_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
893
|
-
|
|
894
|
-
|
|
897
|
+
// 构建卡片"详情"链接的 base(同源,从本请求的 Host/scheme 推断)
|
|
898
|
+
const scheme = req.socket.encrypted ? 'https' : 'http';
|
|
899
|
+
const host = req.headers.host || `127.0.0.1:${HTTP_PORT}`;
|
|
900
|
+
const detailUrlBase = `${scheme}://${host}/approval`;
|
|
901
|
+
const taskId = await client.sendApprovalRequest(title, description, requestId, undefined, tool_input, ccId, detailUrlBase);
|
|
902
|
+
logger.log(`[http] 审批请求已发送: ${taskId} (机器人: ${robotName}) 详情页: ${detailUrlBase}/${taskId}`);
|
|
895
903
|
// 存储审批并启动超时计时器
|
|
896
904
|
const entry = {
|
|
897
905
|
taskId,
|
|
@@ -902,6 +910,8 @@ async function handleApprovalRequest(req, res) {
|
|
|
902
910
|
tool_input,
|
|
903
911
|
description,
|
|
904
912
|
robotName,
|
|
913
|
+
ccId,
|
|
914
|
+
projectDir,
|
|
905
915
|
};
|
|
906
916
|
pendingApprovals.set(taskId, entry);
|
|
907
917
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -913,6 +923,84 @@ async function handleApprovalRequest(req, res) {
|
|
|
913
923
|
res.end(JSON.stringify({ error: err.message }));
|
|
914
924
|
}
|
|
915
925
|
}
|
|
926
|
+
function escapeHtml(raw) {
|
|
927
|
+
return raw
|
|
928
|
+
.replace(/&/g, '&')
|
|
929
|
+
.replace(/</g, '<')
|
|
930
|
+
.replace(/>/g, '>')
|
|
931
|
+
.replace(/"/g, '"')
|
|
932
|
+
.replace(/'/g, ''');
|
|
933
|
+
}
|
|
934
|
+
function handleApprovalDetail(_req, res, url) {
|
|
935
|
+
const taskId = url.replace('/approval/', '');
|
|
936
|
+
const entry = pendingApprovals.get(taskId);
|
|
937
|
+
const respondHtml = (status, body) => {
|
|
938
|
+
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
939
|
+
res.end(body);
|
|
940
|
+
};
|
|
941
|
+
if (!entry) {
|
|
942
|
+
respondHtml(404, `<!doctype html><meta charset="utf-8"><title>审批不存在</title>
|
|
943
|
+
<body style="font-family:-apple-system,system-ui,sans-serif;padding:24px;color:#333">
|
|
944
|
+
<h2>审批已过期或不存在</h2>
|
|
945
|
+
<p>TaskID: <code>${escapeHtml(taskId)}</code></p>
|
|
946
|
+
<p>此条记录可能已被清理(用户已决策或超时)。</p>
|
|
947
|
+
</body>`);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const inputPretty = (() => {
|
|
951
|
+
try {
|
|
952
|
+
return JSON.stringify(entry.tool_input ?? {}, null, 2);
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
return String(entry.tool_input);
|
|
956
|
+
}
|
|
957
|
+
})();
|
|
958
|
+
const statusLabel = {
|
|
959
|
+
'pending': '⏳ 待审批',
|
|
960
|
+
'allow-once': '✅ 已允许',
|
|
961
|
+
'deny': '❌ 已拒绝',
|
|
962
|
+
}[entry.status] ?? entry.status;
|
|
963
|
+
const html = `<!doctype html>
|
|
964
|
+
<html>
|
|
965
|
+
<head>
|
|
966
|
+
<meta charset="utf-8">
|
|
967
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
968
|
+
<title>审批详情 · ${escapeHtml(entry.tool_name)}</title>
|
|
969
|
+
<style>
|
|
970
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
971
|
+
max-width: 780px; margin: 0 auto; padding: 16px; color: #222; background: #f7f7f9; }
|
|
972
|
+
h1 { font-size: 20px; margin: 8px 0 16px; }
|
|
973
|
+
.meta { background: #fff; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px;
|
|
974
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.04); }
|
|
975
|
+
.meta .row { display: flex; padding: 4px 0; border-bottom: 1px dashed #eee; }
|
|
976
|
+
.meta .row:last-child { border-bottom: none; }
|
|
977
|
+
.meta .k { width: 96px; color: #888; flex-shrink: 0; }
|
|
978
|
+
.meta .v { flex: 1; word-break: break-all; }
|
|
979
|
+
pre { background: #fff; border-radius: 8px; padding: 12px 16px;
|
|
980
|
+
overflow-x: auto; font-size: 13px; line-height: 1.5;
|
|
981
|
+
box-shadow: 0 1px 2px rgba(0,0,0,.04); white-space: pre-wrap; word-break: break-all; }
|
|
982
|
+
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px;
|
|
983
|
+
background: #eef; color: #446; }
|
|
984
|
+
footer { color: #aaa; font-size: 12px; text-align: center; margin-top: 16px; }
|
|
985
|
+
</style>
|
|
986
|
+
</head>
|
|
987
|
+
<body>
|
|
988
|
+
<h1>审批详情</h1>
|
|
989
|
+
<div class="meta">
|
|
990
|
+
<div class="row"><div class="k">状态</div><div class="v">${statusLabel}</div></div>
|
|
991
|
+
<div class="row"><div class="k">工具</div><div class="v"><span class="tag">${escapeHtml(entry.tool_name)}</span></div></div>
|
|
992
|
+
<div class="row"><div class="k">概要</div><div class="v">${escapeHtml(entry.description)}</div></div>
|
|
993
|
+
${entry.projectDir ? `<div class="row"><div class="k">项目目录</div><div class="v">${escapeHtml(entry.projectDir)}</div></div>` : ''}
|
|
994
|
+
${entry.ccId ? `<div class="row"><div class="k">CC</div><div class="v">${escapeHtml(entry.ccId)}</div></div>` : ''}
|
|
995
|
+
<div class="row"><div class="k">TaskID</div><div class="v"><code>${escapeHtml(taskId)}</code></div></div>
|
|
996
|
+
</div>
|
|
997
|
+
<h3>完整参数</h3>
|
|
998
|
+
<pre>${escapeHtml(inputPretty)}</pre>
|
|
999
|
+
<footer>审批记录保留 30 分钟后自动清理 · 请回到企业微信卡片点击审批按钮</footer>
|
|
1000
|
+
</body>
|
|
1001
|
+
</html>`;
|
|
1002
|
+
respondHtml(200, html);
|
|
1003
|
+
}
|
|
916
1004
|
function handleApprovalStatus(_req, res, url) {
|
|
917
1005
|
const taskId = url.replace('/approval_status/', '');
|
|
918
1006
|
const entry = pendingApprovals.get(taskId);
|
|
@@ -968,7 +1056,11 @@ async function handleApprovalTimeout(req, res, url) {
|
|
|
968
1056
|
const success = client.setApprovalResult(taskId, result, reason);
|
|
969
1057
|
if (success) {
|
|
970
1058
|
entry.status = result;
|
|
971
|
-
|
|
1059
|
+
// 保留 30 分钟供事后查看详情,不立即删除
|
|
1060
|
+
setTimeout(() => {
|
|
1061
|
+
pendingApprovals.delete(taskId);
|
|
1062
|
+
logger.log(`[http] 超时审批条目已清理: taskId=${taskId}`);
|
|
1063
|
+
}, 30 * 60 * 1000);
|
|
972
1064
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
973
1065
|
res.end(JSON.stringify({ success: true, taskId, result }));
|
|
974
1066
|
}
|
package/dist/project-config.d.ts
CHANGED
package/dist/project-config.js
CHANGED
|
@@ -270,6 +270,14 @@ const STOP_HOOK = {
|
|
|
270
270
|
matcher: '',
|
|
271
271
|
hooks: [{ type: 'command', command: STOP_HOOK_SCRIPT_PATH }],
|
|
272
272
|
};
|
|
273
|
+
/**
|
|
274
|
+
* 进入微信模式时默认预批的 MCP 工具通配(避免每次都走 hook 增加延迟)
|
|
275
|
+
* hook 本身对 mcp__* 也会放行,加入 allow 只是让 Claude Code 跳过 hook。
|
|
276
|
+
*/
|
|
277
|
+
const DEFAULT_MCP_ALLOW = [
|
|
278
|
+
'mcp__wecom-aibot__*',
|
|
279
|
+
'mcp__wecom-aibot-channel__*',
|
|
280
|
+
];
|
|
273
281
|
/**
|
|
274
282
|
* 添加 PermissionRequest hook 到项目 settings.json
|
|
275
283
|
*/
|
|
@@ -296,6 +304,16 @@ export function addPermissionHook(projectDir) {
|
|
|
296
304
|
settings.hooks = {};
|
|
297
305
|
}
|
|
298
306
|
settings.hooks.PermissionRequest = [PERMISSION_HOOK];
|
|
307
|
+
// 合并默认 MCP 通配到 permissions.allow(去重保序)
|
|
308
|
+
const perms = settings.permissions ?? {};
|
|
309
|
+
const existingAllow = Array.isArray(perms.allow) ? perms.allow : [];
|
|
310
|
+
const merged = [...existingAllow];
|
|
311
|
+
for (const entry of DEFAULT_MCP_ALLOW) {
|
|
312
|
+
if (!merged.includes(entry))
|
|
313
|
+
merged.push(entry);
|
|
314
|
+
}
|
|
315
|
+
perms.allow = merged;
|
|
316
|
+
settings.permissions = perms;
|
|
299
317
|
// 写入配置
|
|
300
318
|
try {
|
|
301
319
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -1,23 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP 工具注册入口
|
|
3
|
-
*
|
|
4
|
-
* 注册以下工具:
|
|
5
|
-
* - send_message: 发送消息
|
|
6
|
-
* - send_approval_request: 发送审批请求
|
|
7
|
-
* - get_approval_result: 获取审批结果
|
|
8
|
-
* - check_connection: 检查连接状态
|
|
9
|
-
* - get_pending_messages: 获取待处理消息
|
|
10
|
-
* - get_setup_guide: 获取安装指南
|
|
11
|
-
* - add_robot_config: 添加机器人配置
|
|
12
|
-
* - list_robots: 列出所有机器人
|
|
13
|
-
* - get_robot_status: 获取机器人状态
|
|
14
|
-
* - enter_headless_mode: 进入微信模式
|
|
15
|
-
* - exit_headless_mode: 退出微信模式
|
|
16
|
-
* - detect_user_from_message: 从消息识别用户
|
|
17
|
-
*
|
|
18
|
-
* v2.0 架构变更:
|
|
19
|
-
* - 不再使用 projectDir 参数
|
|
20
|
-
* - 从 Session 自动获取 robotName
|
|
21
|
-
*/
|
|
22
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
2
|
export declare function registerTools(server: McpServer): void;
|
package/dist/tools/index.js
CHANGED
|
@@ -1,24 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* MCP 工具注册入口
|
|
3
|
-
*
|
|
4
|
-
* 注册以下工具:
|
|
5
|
-
* - send_message: 发送消息
|
|
6
|
-
* - send_approval_request: 发送审批请求
|
|
7
|
-
* - get_approval_result: 获取审批结果
|
|
8
|
-
* - check_connection: 检查连接状态
|
|
9
|
-
* - get_pending_messages: 获取待处理消息
|
|
10
|
-
* - get_setup_guide: 获取安装指南
|
|
11
|
-
* - add_robot_config: 添加机器人配置
|
|
12
|
-
* - list_robots: 列出所有机器人
|
|
13
|
-
* - get_robot_status: 获取机器人状态
|
|
14
|
-
* - enter_headless_mode: 进入微信模式
|
|
15
|
-
* - exit_headless_mode: 退出微信模式
|
|
16
|
-
* - detect_user_from_message: 从消息识别用户
|
|
17
|
-
*
|
|
18
|
-
* v2.0 架构变更:
|
|
19
|
-
* - 不再使用 projectDir 参数
|
|
20
|
-
* - 从 Session 自动获取 robotName
|
|
21
|
-
*/
|
|
1
|
+
// MCP 工具注册入口。完整工具清单见 design/tools-api.md。
|
|
22
2
|
import { z } from 'zod';
|
|
23
3
|
import { listAllRobots, getDocMcpUrl, installSkill, VERSION } from '../config-wizard.js';
|
|
24
4
|
import { callDocTool } from '../doc-proxy.js';
|
|
@@ -356,9 +336,8 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
356
336
|
project_dir: z.string().optional().describe('项目目录路径(用于写入配置文件)'),
|
|
357
337
|
mode: z.enum(['channel', 'http']).optional().default('http')
|
|
358
338
|
.describe('运行模式:channel=SSE推送(推荐),http=轮询(兼容)'),
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve, auto_approve_timeout }, extra) => {
|
|
339
|
+
auto_approve_timeout: z.number().optional().default(300).describe('超时自动决策等待时间(秒,默认 300 即 5 分钟)'),
|
|
340
|
+
}, async ({ agent_name, cc_id, robot_id, project_dir, mode, auto_approve_timeout }, extra) => {
|
|
362
341
|
// 获取项目目录
|
|
363
342
|
const projectDir = project_dir || process.cwd();
|
|
364
343
|
// 智能体名称(用于生成 ccId)
|
|
@@ -429,7 +408,6 @@ npx @vrs-soft/wecom-aibot-mcp
|
|
|
429
408
|
wechatMode: true,
|
|
430
409
|
robotName: selectedRobot.name,
|
|
431
410
|
ccId: finalCcId,
|
|
432
|
-
autoApprove: auto_approve,
|
|
433
411
|
autoApproveTimeout: auto_approve_timeout,
|
|
434
412
|
mode,
|
|
435
413
|
});
|