@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/README.md +50 -337
- package/dist/bin.js +172 -85
- package/dist/channel-server.d.ts +15 -0
- package/dist/channel-server.js +492 -0
- package/dist/channel-server.test.d.ts +5 -0
- package/dist/channel-server.test.js +324 -0
- package/dist/client-pool.js +4 -3
- package/dist/client.js +49 -49
- package/dist/config-wizard.d.ts +13 -3
- package/dist/config-wizard.js +424 -141
- package/dist/connection-log.js +7 -6
- package/dist/connection-manager.d.ts +0 -8
- package/dist/connection-manager.js +11 -23
- package/dist/daemon.js +7 -6
- package/dist/headless-state.d.ts +0 -12
- package/dist/headless-state.js +11 -35
- package/dist/http-server.d.ts +21 -2
- package/dist/http-server.js +421 -88
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/keepalive-monitor.js +5 -4
- package/dist/logger.d.ts +51 -0
- package/dist/logger.js +84 -0
- package/dist/message-bus.d.ts +13 -1
- package/dist/message-bus.js +56 -3
- package/dist/project-config.d.ts +16 -0
- package/dist/project-config.js +91 -12
- package/dist/tools/index.js +226 -57
- package/package.json +1 -1
- package/skills/headless-mode/SKILL.md +136 -181
package/dist/http-server.js
CHANGED
|
@@ -15,35 +15,44 @@
|
|
|
15
15
|
import * as http from 'http';
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import * as os from 'os';
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
18
20
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
19
21
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
20
22
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
21
23
|
import { registerTools } from './tools/index.js';
|
|
22
24
|
import { getClient, getConnectionState, getAllConnectionStates, connectAllRobots } from './connection-manager.js';
|
|
23
|
-
import { subscribeWecomMessage } from './message-bus.js';
|
|
25
|
+
import { subscribeWecomMessage, getSubscriberCount } from './message-bus.js';
|
|
24
26
|
import { listAllRobots } from './config-wizard.js';
|
|
27
|
+
import { logger } from './logger.js';
|
|
28
|
+
// ESM 兼容的 __dirname
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = path.dirname(__filename);
|
|
25
31
|
// 固定端口
|
|
26
32
|
export const HTTP_PORT = 18963;
|
|
27
33
|
// Hook 脚本路径
|
|
28
34
|
export const HOOK_SCRIPT_PATH = path.join(os.homedir(), '.wecom-aibot-mcp', 'permission-hook.sh');
|
|
29
35
|
let httpServer = null;
|
|
30
36
|
let startTime = 0;
|
|
31
|
-
// ccId 序号计数器
|
|
32
|
-
let sessionIndex = 0;
|
|
33
37
|
// Session ID 生成器(MCP SSE 使用)
|
|
34
38
|
function generateSessionId() {
|
|
35
39
|
return `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
36
40
|
}
|
|
37
|
-
// ccId
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
41
|
+
// 从 agentName 生成简化的 ccId 名称
|
|
42
|
+
function sanitizeAgentName(agentName) {
|
|
43
|
+
// 简化名称:移除特殊字符,限制长度
|
|
44
|
+
return agentName.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '').slice(0, 20);
|
|
41
45
|
}
|
|
42
|
-
//
|
|
46
|
+
// ccId 生成器(基于 agentName)- 不自动编号
|
|
47
|
+
export function generateCcId(agentName) {
|
|
48
|
+
const name = agentName ? sanitizeAgentName(agentName) : 'cc';
|
|
49
|
+
return name; // 直接返回名称,不添加编号
|
|
50
|
+
}
|
|
51
|
+
// 推送微信消息到 MCP 客户端(通过 SSE notification)
|
|
43
52
|
export async function pushMessageToSession(robotName, message) {
|
|
44
53
|
// 推送给所有活跃的 session
|
|
45
54
|
if (transports.size === 0) {
|
|
46
|
-
|
|
55
|
+
logger.log('[http] 无活跃 session,无法推送消息');
|
|
47
56
|
return;
|
|
48
57
|
}
|
|
49
58
|
// 广播给所有连接的客户端
|
|
@@ -66,25 +75,95 @@ export async function pushMessageToSession(robotName, message) {
|
|
|
66
75
|
}),
|
|
67
76
|
},
|
|
68
77
|
});
|
|
69
|
-
|
|
78
|
+
logger.log(`[http] 已推送消息到 session ${sessionId}`);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
logger.error(`[http] 推送消息到 session ${sessionId} 失败:`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 推送微信消息到 SSE 客户端(Channel 模式,按 ccId 精准推送)
|
|
86
|
+
export async function pushMessageToSSEClient(robotName, message, targetCcId) {
|
|
87
|
+
// 推送给匹配的 SSE 客户端
|
|
88
|
+
if (sseClients.size === 0) {
|
|
89
|
+
logger.log('[http] 无 SSE 客户端连接,无法推送消息');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// 找到匹配的 SSE 客户端
|
|
93
|
+
for (const [clientId, client] of sseClients) {
|
|
94
|
+
// 按 ccId 匹配或广播给所有同机器人客户端
|
|
95
|
+
if (targetCcId && client.ccId !== targetCcId) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (client.robotName !== robotName) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const data = JSON.stringify({
|
|
103
|
+
type: 'wecom_message',
|
|
104
|
+
robotName,
|
|
105
|
+
ccId: targetCcId || client.ccId,
|
|
106
|
+
message: {
|
|
107
|
+
content: message.content,
|
|
108
|
+
from: message.from_userid,
|
|
109
|
+
chatid: message.chatid,
|
|
110
|
+
chattype: message.chattype,
|
|
111
|
+
time: new Date(message.timestamp).toISOString(),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
client.res.write(`event: message\ndata: ${data}\n\n`);
|
|
115
|
+
logger.log(`[http] SSE 推送成功: clientId=${clientId}, ccId=${targetCcId || client.ccId}`);
|
|
70
116
|
}
|
|
71
117
|
catch (err) {
|
|
72
|
-
|
|
118
|
+
logger.error(`[http] SSE 推送失败: clientId=${clientId}`, err);
|
|
119
|
+
sseClients.delete(clientId);
|
|
73
120
|
}
|
|
74
121
|
}
|
|
75
122
|
}
|
|
76
123
|
const ccIdRegistry = new Map();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
124
|
+
// 超时阈值:30 分钟未活跃的 ccId 视为离线
|
|
125
|
+
const CCID_STALE_TIMEOUT = 30 * 60 * 1000;
|
|
126
|
+
// 清理超时的 ccId 条目
|
|
127
|
+
function cleanStaleCcIds() {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
for (const [id, entry] of ccIdRegistry) {
|
|
130
|
+
if (now - entry.lastOnline > CCID_STALE_TIMEOUT) {
|
|
131
|
+
ccIdRegistry.delete(id);
|
|
132
|
+
logger.log(`[ccid] 清理超时条目: ${id} (离线 ${Math.round((now - entry.lastOnline) / 60000)} 分钟)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// 注册 ccId。重连场景(config 文件已存在)直接覆盖更新;
|
|
137
|
+
// 首次注册时先清理超时条目。始终返回 success=true,不做冲突拦截。
|
|
138
|
+
export function registerCcId(ccId, robotName, agentName, mode, projectDir, isReconnect) {
|
|
139
|
+
if (isReconnect || ccIdRegistry.has(ccId)) {
|
|
140
|
+
// 重连:直接覆盖,更新 lastOnline
|
|
141
|
+
logger.log(`[ccid] 重连: ${ccId} → ${robotName}`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// 首次注册:先清理超时条目
|
|
145
|
+
cleanStaleCcIds();
|
|
146
|
+
logger.log(`[ccid] 注册: ${ccId} → ${robotName} (${agentName || 'unknown'}, mode: ${mode || 'http'})`);
|
|
147
|
+
}
|
|
148
|
+
ccIdRegistry.set(ccId, { robotName, agentName, mode, projectDir, lastOnline: Date.now() });
|
|
149
|
+
return { success: true, ccId };
|
|
80
150
|
}
|
|
81
151
|
export function unregisterCcId(ccId) {
|
|
82
152
|
ccIdRegistry.delete(ccId);
|
|
83
|
-
|
|
153
|
+
logger.log(`[ccid] 注销: ${ccId}`);
|
|
154
|
+
}
|
|
155
|
+
export function clearCcIdRegistry() {
|
|
156
|
+
const entries = Array.from(ccIdRegistry.keys());
|
|
157
|
+
ccIdRegistry.clear();
|
|
158
|
+
logger.log(`[ccid] 清空注册表: 共清理 ${entries.length} 条 (${entries.join(', ')})`);
|
|
159
|
+
return { cleared: entries.length, entries };
|
|
84
160
|
}
|
|
85
161
|
export function getRobotByCcId(ccId) {
|
|
86
162
|
return ccIdRegistry.get(ccId)?.robotName || null;
|
|
87
163
|
}
|
|
164
|
+
export function getProjectDirByCcId(ccId) {
|
|
165
|
+
return ccIdRegistry.get(ccId)?.projectDir || null;
|
|
166
|
+
}
|
|
88
167
|
export function getCCRegistryEntry(ccId) {
|
|
89
168
|
return ccIdRegistry.get(ccId) || null;
|
|
90
169
|
}
|
|
@@ -105,8 +184,9 @@ export function getOnlineCcIds() {
|
|
|
105
184
|
}
|
|
106
185
|
// 使用 Map 存储多个待处理审批(按 taskId 索引)
|
|
107
186
|
const pendingApprovals = new Map();
|
|
108
|
-
const VERSION = '
|
|
187
|
+
const VERSION = '2.0.0';
|
|
109
188
|
const transports = new Map();
|
|
189
|
+
const sseClients = new Map(); // clientId -> SSEClient
|
|
110
190
|
// 初始化 MCP Server(不再全局连接)
|
|
111
191
|
function initMcpServer() {
|
|
112
192
|
// 订阅消息总线,实现 SSE 推送
|
|
@@ -122,75 +202,226 @@ function createMcpServerInstance() {
|
|
|
122
202
|
}, {
|
|
123
203
|
capabilities: {
|
|
124
204
|
logging: {}, // 支持服务端主动推送日志消息
|
|
205
|
+
experimental: {
|
|
206
|
+
'claude/channel': {}, // 支持 Channel 模式 SSE 推送
|
|
207
|
+
},
|
|
125
208
|
}
|
|
126
209
|
});
|
|
127
210
|
registerTools(server);
|
|
128
211
|
return server;
|
|
129
212
|
}
|
|
130
|
-
//
|
|
131
|
-
function handleWecomMessage(msg) {
|
|
132
|
-
if (transports.size === 0) {
|
|
133
|
-
console.log('[http] 无活跃 MCP session,跳过消息处理');
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
213
|
+
// 处理微信消息(根据 mode 选择推送方式)
|
|
214
|
+
async function handleWecomMessage(msg) {
|
|
136
215
|
// 查找匹配的 CC(基于引用内容中的 ccId)
|
|
137
216
|
const targetCcId = extractCcIdFromQuote(msg.quoteContent);
|
|
217
|
+
// 优先检查 SSE 客户端(Channel 模式)
|
|
218
|
+
// 先尝试精准匹配(有 targetCcId)
|
|
138
219
|
if (targetCcId) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
220
|
+
const entry = getCCRegistryEntry(targetCcId);
|
|
221
|
+
if (entry?.mode === 'channel') {
|
|
222
|
+
logger.log(`[http] Channel 模式精准匹配,SSE 推送给 ${targetCcId}`);
|
|
223
|
+
await pushMessageToSSEClient(msg.robotName, {
|
|
224
|
+
msgid: msg.msgid,
|
|
225
|
+
content: msg.content,
|
|
226
|
+
from_userid: msg.from_userid,
|
|
227
|
+
chatid: msg.chatid,
|
|
228
|
+
chattype: msg.chattype,
|
|
229
|
+
timestamp: msg.timestamp,
|
|
230
|
+
}, targetCcId);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
149
233
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
from_userid: msg.from_userid,
|
|
157
|
-
chatid: msg.chatid,
|
|
158
|
-
chattype: msg.chattype,
|
|
159
|
-
timestamp: msg.timestamp,
|
|
160
|
-
});
|
|
234
|
+
// 检查 SSE 客户端数量(按 robotName)
|
|
235
|
+
const matchingSseClients = [];
|
|
236
|
+
for (const [clientId, client] of sseClients) {
|
|
237
|
+
if (client.robotName === msg.robotName) {
|
|
238
|
+
matchingSseClients.push({ clientId, ccId: client.ccId });
|
|
239
|
+
}
|
|
161
240
|
}
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
241
|
+
if (matchingSseClients.length > 0) {
|
|
242
|
+
// 有 SSE 客户端连接
|
|
243
|
+
if (matchingSseClients.length === 1) {
|
|
244
|
+
// 单个 SSE 客户端,直接推送
|
|
245
|
+
const { clientId, ccId } = matchingSseClients[0];
|
|
246
|
+
logger.log(`[http] 单 SSE 客户端 ${clientId},直接推送`);
|
|
247
|
+
await pushMessageToSSEClient(msg.robotName, {
|
|
168
248
|
msgid: msg.msgid,
|
|
169
249
|
content: msg.content,
|
|
170
250
|
from_userid: msg.from_userid,
|
|
171
251
|
chatid: msg.chatid,
|
|
172
252
|
chattype: msg.chattype,
|
|
173
253
|
timestamp: msg.timestamp,
|
|
174
|
-
});
|
|
254
|
+
}, ccId);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// 多个 SSE 客户端,需要引用路由
|
|
258
|
+
if (targetCcId) {
|
|
259
|
+
// 有引用,精准推送
|
|
260
|
+
const matched = matchingSseClients.find(c => c.ccId === targetCcId);
|
|
261
|
+
if (matched) {
|
|
262
|
+
logger.log(`[http] 多 SSE 客户端,引用匹配 ${targetCcId},精准推送`);
|
|
263
|
+
await pushMessageToSSEClient(msg.robotName, {
|
|
264
|
+
msgid: msg.msgid,
|
|
265
|
+
content: msg.content,
|
|
266
|
+
from_userid: msg.from_userid,
|
|
267
|
+
chatid: msg.chatid,
|
|
268
|
+
chattype: msg.chattype,
|
|
269
|
+
timestamp: msg.timestamp,
|
|
270
|
+
}, targetCcId);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// 无引用,尝试按 from_userid 匹配(2v2 场景)
|
|
275
|
+
const matchedCcId = findCcIdByTargetUserId(msg.from_userid);
|
|
276
|
+
if (matchedCcId) {
|
|
277
|
+
const matched = matchingSseClients.find(c => c.ccId === matchedCcId);
|
|
278
|
+
if (matched) {
|
|
279
|
+
logger.log(`[http] 多 SSE 客户端,按 from_userid 路由给 ${matchedCcId}`);
|
|
280
|
+
await pushMessageToSSEClient(msg.robotName, {
|
|
281
|
+
msgid: msg.msgid,
|
|
282
|
+
content: msg.content,
|
|
283
|
+
from_userid: msg.from_userid,
|
|
284
|
+
chatid: msg.chatid,
|
|
285
|
+
chattype: msg.chattype,
|
|
286
|
+
timestamp: msg.timestamp,
|
|
287
|
+
}, matchedCcId);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// 无法确定目标 CC,发送引用提示
|
|
292
|
+
logger.log('[http] 多 SSE 客户端,无引用匹配,发送提示');
|
|
293
|
+
await sendNoReferencePrompt(msg);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// 无 SSE 客户端,走 HTTP 模式 notification 推送
|
|
297
|
+
if (transports.size === 0) {
|
|
298
|
+
logger.log('[http] 无活跃 MCP session,跳过消息处理');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const subscriberCount = getSubscriberCount(msg.robotName);
|
|
302
|
+
logger.log(`[http] 机器人 ${msg.robotName} 订阅数: ${subscriberCount}`);
|
|
303
|
+
if (subscriberCount === 0) {
|
|
304
|
+
logger.log('[http] 无订阅者,跳过消息处理');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (subscriberCount === 1) {
|
|
308
|
+
// 只有一个订阅者,直接广播(SSE 检查已在前面完成)
|
|
309
|
+
logger.log('[http] 单订阅者 HTTP 模式,直接广播');
|
|
310
|
+
for (const [sessionId, sessEntry] of transports) {
|
|
311
|
+
try {
|
|
312
|
+
await sessEntry.server.server.notification({
|
|
313
|
+
method: 'notifications/message',
|
|
314
|
+
params: {
|
|
315
|
+
level: 'info',
|
|
316
|
+
data: JSON.stringify({
|
|
317
|
+
type: 'wecom_message',
|
|
318
|
+
robotName: msg.robotName,
|
|
319
|
+
message: {
|
|
320
|
+
content: msg.content,
|
|
321
|
+
from: msg.from_userid,
|
|
322
|
+
chatid: msg.chatid,
|
|
323
|
+
chattype: msg.chattype,
|
|
324
|
+
time: new Date(msg.timestamp).toISOString(),
|
|
325
|
+
quoteContent: msg.quoteContent,
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
logger.log(`[http] 已推送消息到 session ${sessionId}`);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
logger.error(`[http] 推送失败 session ${sessionId}:`, err);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// 多订阅者模式:检查 ccId 引用
|
|
339
|
+
logger.log(`[http] 多订阅者模式,目标 ccId: ${targetCcId || '无'}`);
|
|
340
|
+
if (targetCcId) {
|
|
341
|
+
// 有明确的 ccId 引用,广播给所有 session(订阅者会自己过滤)
|
|
342
|
+
logger.log(`[http] 引用匹配 ${targetCcId},广播消息`);
|
|
343
|
+
for (const [sessionId, sessEntry] of transports) {
|
|
344
|
+
try {
|
|
345
|
+
await sessEntry.server.server.notification({
|
|
346
|
+
method: 'notifications/message',
|
|
347
|
+
params: {
|
|
348
|
+
level: 'info',
|
|
349
|
+
data: JSON.stringify({
|
|
350
|
+
type: 'wecom_message',
|
|
351
|
+
robotName: msg.robotName,
|
|
352
|
+
targetCcId, // 标记目标 ccId
|
|
353
|
+
message: {
|
|
354
|
+
content: msg.content,
|
|
355
|
+
from: msg.from_userid,
|
|
356
|
+
chatid: msg.chatid,
|
|
357
|
+
chattype: msg.chattype,
|
|
358
|
+
time: new Date(msg.timestamp).toISOString(),
|
|
359
|
+
quoteContent: msg.quoteContent,
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
logger.log(`[http] 已推送消息到 session ${sessionId} (目标: ${targetCcId})`);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
logger.error(`[http] 推送失败 session ${sessionId}:`, err);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
// 无 ccId 引用,尝试按 from_userid 匹配(2v2 场景)
|
|
373
|
+
const matchedCcId = findCcIdByTargetUserId(msg.from_userid);
|
|
374
|
+
if (matchedCcId) {
|
|
375
|
+
logger.log(`[http] 多订阅者模式,按 from_userid 路由给 ${matchedCcId}`);
|
|
376
|
+
const matchedEntry = getCCRegistryEntry(matchedCcId);
|
|
377
|
+
if (matchedEntry?.mode === 'channel') {
|
|
378
|
+
// Channel 模式:SSE 推送
|
|
379
|
+
await pushMessageToSSEClient(msg.robotName, {
|
|
380
|
+
msgid: msg.msgid,
|
|
381
|
+
content: msg.content,
|
|
382
|
+
from_userid: msg.from_userid,
|
|
383
|
+
chatid: msg.chatid,
|
|
384
|
+
chattype: msg.chattype,
|
|
385
|
+
timestamp: msg.timestamp,
|
|
386
|
+
}, matchedCcId);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
// HTTP 模式:notification 推送
|
|
390
|
+
for (const [sessionId, sessEntry] of transports) {
|
|
391
|
+
try {
|
|
392
|
+
await sessEntry.server.server.notification({
|
|
393
|
+
method: 'notifications/message',
|
|
394
|
+
params: {
|
|
395
|
+
level: 'info',
|
|
396
|
+
data: JSON.stringify({
|
|
397
|
+
type: 'wecom_message',
|
|
398
|
+
robotName: msg.robotName,
|
|
399
|
+
targetCcId: matchedCcId,
|
|
400
|
+
message: {
|
|
401
|
+
content: msg.content,
|
|
402
|
+
from: msg.from_userid,
|
|
403
|
+
chatid: msg.chatid,
|
|
404
|
+
chattype: msg.chattype,
|
|
405
|
+
time: new Date(msg.timestamp).toISOString(),
|
|
406
|
+
quoteContent: msg.quoteContent,
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
logger.error(`[http] 推送失败 session ${sessionId}:`, err);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
175
417
|
}
|
|
176
418
|
else {
|
|
177
419
|
// 无法确定目标 CC,发送引用提示
|
|
178
|
-
|
|
179
|
-
sendNoReferencePrompt(msg);
|
|
420
|
+
logger.log('[http] 无引用匹配,发送提示');
|
|
421
|
+
await sendNoReferencePrompt(msg);
|
|
180
422
|
}
|
|
181
423
|
}
|
|
182
424
|
}
|
|
183
|
-
// 从引用内容提取 ccId
|
|
184
|
-
function extractCcIdFromQuote(quoteContent) {
|
|
185
|
-
if (!quoteContent)
|
|
186
|
-
return null;
|
|
187
|
-
// 匹配格式:【cc-1】或【cc-2】等
|
|
188
|
-
const match = quoteContent.match(/【(cc-\d+)】/);
|
|
189
|
-
if (match) {
|
|
190
|
-
return match[1]; // 返回 ccId(如 cc-1)
|
|
191
|
-
}
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
425
|
// 根据 from_userid 匹配 ccId(2v2 场景:每个用户对应一个 CC)
|
|
195
426
|
function findCcIdByTargetUserId(fromUserId) {
|
|
196
427
|
const allRobots = listAllRobots();
|
|
@@ -202,18 +433,27 @@ function findCcIdByTargetUserId(fromUserId) {
|
|
|
202
433
|
}
|
|
203
434
|
return null;
|
|
204
435
|
}
|
|
436
|
+
// 从引用内容提取 ccId(匹配任意格式)
|
|
437
|
+
function extractCcIdFromQuote(quoteContent) {
|
|
438
|
+
if (!quoteContent)
|
|
439
|
+
return null;
|
|
440
|
+
const match = quoteContent.match(/【([^】]+)】/);
|
|
441
|
+
return match ? match[1] : null;
|
|
442
|
+
}
|
|
205
443
|
// 无引用消息提示
|
|
206
444
|
async function sendNoReferencePrompt(msg) {
|
|
207
445
|
const client = await getClient(msg.robotName);
|
|
208
446
|
if (!client)
|
|
209
447
|
return;
|
|
210
|
-
const onlineList = getOnlineCcIds()
|
|
448
|
+
const onlineList = getOnlineCcIds();
|
|
449
|
+
if (onlineList.length === 0)
|
|
450
|
+
return; // 没有 CC 在线,不发提示
|
|
211
451
|
const reply = `检测到多个 Claude Code 会话在线,请引用回复指明接收者。
|
|
212
452
|
|
|
213
453
|
当前在线:
|
|
214
|
-
${onlineList}
|
|
454
|
+
${onlineList.map(id => `• 【${id}】`).join('\n')}
|
|
215
455
|
|
|
216
|
-
示例:引用【${
|
|
456
|
+
示例:引用【${onlineList[0]}】的消息后回复`;
|
|
217
457
|
await client.sendText(reply);
|
|
218
458
|
}
|
|
219
459
|
export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
@@ -244,19 +484,19 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
244
484
|
// 没有 session ID,返回 405 表示不支持匿名 SSE
|
|
245
485
|
// 客户端会先发送 POST 初始化,然后带着 session ID 来 GET
|
|
246
486
|
if (!sessionId) {
|
|
247
|
-
|
|
487
|
+
logger.log('[http] GET /mcp: 无 session ID,返回 405');
|
|
248
488
|
res.writeHead(405, { 'Content-Type': 'text/plain' });
|
|
249
489
|
res.end('Method Not Allowed: Session ID required for SSE stream');
|
|
250
490
|
return;
|
|
251
491
|
}
|
|
252
492
|
const entry = transports.get(sessionId);
|
|
253
493
|
if (!entry) {
|
|
254
|
-
|
|
494
|
+
logger.log(`[http] GET /mcp: session ${sessionId} not found`);
|
|
255
495
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
256
496
|
res.end('Session not found');
|
|
257
497
|
return;
|
|
258
498
|
}
|
|
259
|
-
|
|
499
|
+
logger.log(`[http] 建立 SSE 流: session ${sessionId}`);
|
|
260
500
|
await entry.transport.handleRequest(req, res);
|
|
261
501
|
return;
|
|
262
502
|
}
|
|
@@ -286,7 +526,7 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
286
526
|
const newTransport = new StreamableHTTPServerTransport({
|
|
287
527
|
sessionIdGenerator: generateSessionId,
|
|
288
528
|
onsessioninitialized: (sid) => {
|
|
289
|
-
|
|
529
|
+
logger.log(`[http] Session 初始化: ${sid}`);
|
|
290
530
|
transports.set(sid, { transport: newTransport, server: newServer });
|
|
291
531
|
},
|
|
292
532
|
});
|
|
@@ -294,7 +534,7 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
294
534
|
newTransport.onclose = () => {
|
|
295
535
|
const sid = newTransport.sessionId;
|
|
296
536
|
if (sid) {
|
|
297
|
-
|
|
537
|
+
logger.log(`[http] Session 关闭: ${sid}`);
|
|
298
538
|
transports.delete(sid);
|
|
299
539
|
}
|
|
300
540
|
};
|
|
@@ -323,7 +563,7 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
323
563
|
}
|
|
324
564
|
}
|
|
325
565
|
catch (err) {
|
|
326
|
-
|
|
566
|
+
logger.error('[http] MCP 请求处理失败:', err);
|
|
327
567
|
if (!res.headersSent) {
|
|
328
568
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
329
569
|
res.end(JSON.stringify({ error: err.message }));
|
|
@@ -351,6 +591,11 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
351
591
|
handleStateQuery(req, res);
|
|
352
592
|
return;
|
|
353
593
|
}
|
|
594
|
+
// SSE endpoint for Channel 模式
|
|
595
|
+
if (req.method === 'GET' && url.startsWith('/sse/')) {
|
|
596
|
+
handleSSEConnect(req, res, url);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
354
599
|
if (req.method === 'POST' && url === '/notify') {
|
|
355
600
|
await handleNotify(req, res);
|
|
356
601
|
return;
|
|
@@ -363,9 +608,15 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
363
608
|
await handlePushNotification(req, res);
|
|
364
609
|
return;
|
|
365
610
|
}
|
|
611
|
+
if (req.method === 'POST' && url === '/admin/clean-cache') {
|
|
612
|
+
const result = clearCcIdRegistry();
|
|
613
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
614
|
+
res.end(JSON.stringify({ ok: true, ...result }));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
366
617
|
// 临时调试端点:手动进入 headless 模式
|
|
367
618
|
if (req.method === 'POST' && url === '/debug/enter_headless') {
|
|
368
|
-
const ccId =
|
|
619
|
+
const ccId = `debug-${Date.now()}`;
|
|
369
620
|
registerCcId(ccId, 'ClaudeCode', '调试用户');
|
|
370
621
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
371
622
|
res.end(JSON.stringify({
|
|
@@ -373,7 +624,23 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
373
624
|
ccId,
|
|
374
625
|
message: '已进入 headless 模式(调试)'
|
|
375
626
|
}));
|
|
376
|
-
|
|
627
|
+
logger.log(`[http] [DEBUG] 进入 headless 模式: ccId: ${ccId}`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
// Skill 文件下载 endpoint(支持远程部署)
|
|
631
|
+
if (req.method === 'GET' && url === '/skill') {
|
|
632
|
+
const skillPath = path.join(__dirname, '..', 'skills', 'headless-mode', 'SKILL.md');
|
|
633
|
+
if (fs.existsSync(skillPath)) {
|
|
634
|
+
const content = fs.readFileSync(skillPath, 'utf-8');
|
|
635
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
|
|
636
|
+
res.end(content);
|
|
637
|
+
logger.log(`[http] Skill 文件已下载`);
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
641
|
+
res.end(JSON.stringify({ error: 'Skill 文件不存在' }));
|
|
642
|
+
logger.error(`[http] Skill 文件不存在: ${skillPath}`);
|
|
643
|
+
}
|
|
377
644
|
return;
|
|
378
645
|
}
|
|
379
646
|
// 临时调试端点:退出 headless 模式
|
|
@@ -381,13 +648,38 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
381
648
|
ccIdRegistry.clear();
|
|
382
649
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
383
650
|
res.end(JSON.stringify({ status: 'exited', message: '已退出 headless 模式(调试)' }));
|
|
384
|
-
|
|
651
|
+
logger.log(`[http] [DEBUG] 退出 headless 模式`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
// 调试端点:模拟发送微信消息(测试 SSE 推送)
|
|
655
|
+
if (req.method === 'POST' && url === '/debug/test_message') {
|
|
656
|
+
const body = await readRequestBody(req);
|
|
657
|
+
const params = JSON.parse(body);
|
|
658
|
+
const robotName = params.robotName || 'CC';
|
|
659
|
+
const content = params.content || '测试消息';
|
|
660
|
+
const ccId = params.ccId;
|
|
661
|
+
// 模拟微信消息
|
|
662
|
+
const testMsg = {
|
|
663
|
+
robotName,
|
|
664
|
+
msgid: `test_${Date.now()}`,
|
|
665
|
+
content,
|
|
666
|
+
from_userid: 'TestUser',
|
|
667
|
+
chatid: 'TestUser',
|
|
668
|
+
chattype: 'single',
|
|
669
|
+
timestamp: Date.now(),
|
|
670
|
+
quoteContent: ccId ? `【${ccId}】` : undefined, // 模拟引用指定 ccId
|
|
671
|
+
};
|
|
672
|
+
// 发布到消息总线
|
|
673
|
+
logger.log(`[http] [DEBUG] 模拟发送微信消息: robotName=${robotName}, content=${content}, ccId=${ccId}`);
|
|
674
|
+
handleWecomMessage(testMsg);
|
|
675
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
676
|
+
res.end(JSON.stringify({ status: 'sent', message: '测试消息已发送', ccId }));
|
|
385
677
|
return;
|
|
386
678
|
}
|
|
387
679
|
// 调试端点:模拟断开指定机器人的连接(不删除状态,保留待发送队列)
|
|
388
680
|
if (req.method === 'POST' && url.startsWith('/debug/disconnect/')) {
|
|
389
681
|
const robotName = decodeURIComponent(url.replace('/debug/disconnect/', ''));
|
|
390
|
-
|
|
682
|
+
logger.log(`[http] [DEBUG] 模拟断开机器人: ${robotName}`);
|
|
391
683
|
const client = await getClient(robotName);
|
|
392
684
|
if (client) {
|
|
393
685
|
client.disconnect();
|
|
@@ -403,7 +695,7 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
403
695
|
// 调试端点:触发重连(通过 getClient 自动重连)
|
|
404
696
|
if (req.method === 'POST' && url.startsWith('/debug/reconnect/')) {
|
|
405
697
|
const robotName = decodeURIComponent(url.replace('/debug/reconnect/', ''));
|
|
406
|
-
|
|
698
|
+
logger.log(`[http] [DEBUG] 触发重连机器人: ${robotName}`);
|
|
407
699
|
const client = await getClient(robotName);
|
|
408
700
|
if (client && client.isConnected()) {
|
|
409
701
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -461,8 +753,8 @@ export async function startHttpServer(_server, port = HTTP_PORT) {
|
|
|
461
753
|
}
|
|
462
754
|
});
|
|
463
755
|
httpServer.listen(port, '127.0.0.1', async () => {
|
|
464
|
-
|
|
465
|
-
|
|
756
|
+
logger.log(`[http] MCP Server 已启动: http://127.0.0.1:${port}`);
|
|
757
|
+
logger.log(`[http] MCP endpoint: http://127.0.0.1:${port}/mcp (stateless mode)`);
|
|
466
758
|
// 自动连接所有配置的机器人
|
|
467
759
|
await connectAllRobots();
|
|
468
760
|
resolve();
|
|
@@ -473,7 +765,7 @@ export function stopHttpServer() {
|
|
|
473
765
|
if (httpServer) {
|
|
474
766
|
httpServer.close();
|
|
475
767
|
httpServer = null;
|
|
476
|
-
|
|
768
|
+
logger.log('[http] HTTP Server 已停止');
|
|
477
769
|
}
|
|
478
770
|
}
|
|
479
771
|
async function handleApprovalRequest(req, res) {
|
|
@@ -485,12 +777,12 @@ async function handleApprovalRequest(req, res) {
|
|
|
485
777
|
const { ccId, robotName: requestedRobotName } = request;
|
|
486
778
|
if (requestedRobotName) {
|
|
487
779
|
robotName = requestedRobotName;
|
|
488
|
-
|
|
780
|
+
logger.log(`[http] 审批路由: 请求指定 robotName=${robotName}`);
|
|
489
781
|
}
|
|
490
782
|
else if (ccId) {
|
|
491
783
|
robotName = getRobotByCcId(ccId);
|
|
492
784
|
if (robotName) {
|
|
493
|
-
|
|
785
|
+
logger.log(`[http] 审批路由: ccId=${ccId} → ${robotName}`);
|
|
494
786
|
}
|
|
495
787
|
}
|
|
496
788
|
if (!robotName) {
|
|
@@ -498,7 +790,7 @@ async function handleApprovalRequest(req, res) {
|
|
|
498
790
|
const connectedRobot = states.find(s => s.connected);
|
|
499
791
|
if (connectedRobot) {
|
|
500
792
|
robotName = connectedRobot.robotName;
|
|
501
|
-
|
|
793
|
+
logger.log(`[http] 审批路由: 回退到第一个已连接机器人 ${robotName}`);
|
|
502
794
|
}
|
|
503
795
|
}
|
|
504
796
|
if (!robotName) {
|
|
@@ -527,7 +819,7 @@ async function handleApprovalRequest(req, res) {
|
|
|
527
819
|
const title = `【待审批】${tool_name}`;
|
|
528
820
|
const requestId = `hook_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
529
821
|
const taskId = await client.sendApprovalRequest(title, description, requestId);
|
|
530
|
-
|
|
822
|
+
logger.log(`[http] 审批请求已发送: ${taskId} (机器人: ${robotName})`);
|
|
531
823
|
// 存储审批并启动超时计时器
|
|
532
824
|
const entry = {
|
|
533
825
|
taskId,
|
|
@@ -543,7 +835,7 @@ async function handleApprovalRequest(req, res) {
|
|
|
543
835
|
res.end(JSON.stringify({ taskId, status: 'pending' }));
|
|
544
836
|
}
|
|
545
837
|
catch (err) {
|
|
546
|
-
|
|
838
|
+
logger.error('[http] 审批请求处理失败:', err);
|
|
547
839
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
548
840
|
res.end(JSON.stringify({ error: err.message }));
|
|
549
841
|
}
|
|
@@ -616,8 +908,49 @@ function handleHealthCheck(_req, res) {
|
|
|
616
908
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
617
909
|
websocket: { connected: state.connected, robotName: state.robotName },
|
|
618
910
|
headless: hasActiveSession ? { mode: 'HEADLESS' } : { mode: 'NORMAL' },
|
|
911
|
+
sseClients: sseClients.size, // Channel 模式客户端数
|
|
912
|
+
ccIds: getOnlineCcIds(), // 当前注册的 ccId
|
|
619
913
|
}, null, 2));
|
|
620
914
|
}
|
|
915
|
+
// SSE 连接处理(Channel 模式)
|
|
916
|
+
function handleSSEConnect(req, res, url) {
|
|
917
|
+
const ccId = decodeURIComponent(url.replace('/sse/', ''));
|
|
918
|
+
const entry = getCCRegistryEntry(ccId);
|
|
919
|
+
if (!entry) {
|
|
920
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
921
|
+
res.end(`CC ${ccId} not found`);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const clientId = `${ccId}_${Date.now()}`;
|
|
925
|
+
// 设置 SSE headers
|
|
926
|
+
res.writeHead(200, {
|
|
927
|
+
'Content-Type': 'text/event-stream',
|
|
928
|
+
'Cache-Control': 'no-cache',
|
|
929
|
+
'Connection': 'keep-alive',
|
|
930
|
+
'Access-Control-Allow-Origin': '*',
|
|
931
|
+
});
|
|
932
|
+
// 注册 SSE 客户端
|
|
933
|
+
sseClients.set(clientId, {
|
|
934
|
+
res,
|
|
935
|
+
ccId,
|
|
936
|
+
robotName: entry.robotName,
|
|
937
|
+
});
|
|
938
|
+
logger.log(`[http] SSE 客户端连接: clientId=${clientId}, ccId=${ccId}, robotName=${entry.robotName}`);
|
|
939
|
+
// 发送连接确认
|
|
940
|
+
res.write(`event: connected\ndata: {"clientId":"${clientId}","ccId":"${ccId}"}\n\n`);
|
|
941
|
+
// 心跳机制:每 15 秒发送注释行保持连接活跃
|
|
942
|
+
const heartbeatInterval = setInterval(() => {
|
|
943
|
+
// SSE 注释行(以冒号开头)会被客户端忽略,但保持连接
|
|
944
|
+
res.write(': heartbeat\n\n');
|
|
945
|
+
logger.log(`[http] SSE 心跳发送: clientId=${clientId}`);
|
|
946
|
+
}, 15000);
|
|
947
|
+
// 处理客户端断开
|
|
948
|
+
req.on('close', () => {
|
|
949
|
+
clearInterval(heartbeatInterval);
|
|
950
|
+
sseClients.delete(clientId);
|
|
951
|
+
logger.log(`[http] SSE 客户端断开: clientId=${clientId}`);
|
|
952
|
+
});
|
|
953
|
+
}
|
|
621
954
|
function handleStateQuery(_req, res) {
|
|
622
955
|
const state = getConnectionState();
|
|
623
956
|
const connections = getAllConnectionStates();
|
|
@@ -677,14 +1010,14 @@ async function handlePushNotification(req, res) {
|
|
|
677
1010
|
sent++;
|
|
678
1011
|
}
|
|
679
1012
|
catch (err) {
|
|
680
|
-
|
|
1013
|
+
logger.error(`[http] 推送到 session ${sessionId} 失败:`, err);
|
|
681
1014
|
}
|
|
682
1015
|
}
|
|
683
1016
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
684
1017
|
res.end(JSON.stringify({ success: true, method: method || 'notifications/message', sessions: sent }));
|
|
685
1018
|
}
|
|
686
1019
|
catch (err) {
|
|
687
|
-
|
|
1020
|
+
logger.error('[http] 推送通知失败:', err);
|
|
688
1021
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
689
1022
|
res.end(JSON.stringify({ error: err.message }));
|
|
690
1023
|
}
|
|
@@ -738,5 +1071,5 @@ function readRequestBody(req) {
|
|
|
738
1071
|
});
|
|
739
1072
|
}
|
|
740
1073
|
export function cleanupPortFile() {
|
|
741
|
-
|
|
1074
|
+
logger.log('[http] 使用固定端口:', HTTP_PORT);
|
|
742
1075
|
}
|