@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.
@@ -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
- export function generateCcId() {
39
- sessionIndex++;
40
- return `cc-${sessionIndex}`;
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
- // 推送微信消息到 MCP 客户端(通过 SSE)
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
- console.log('[http] 无活跃 session,无法推送消息');
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
- console.log(`[http] 已推送消息到 session ${sessionId}`);
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
- console.error(`[http] 推送消息到 session ${sessionId} 失败:`, err);
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
- export function registerCcId(ccId, robotName, agentName) {
78
- ccIdRegistry.set(ccId, { robotName, agentName });
79
- console.log(`[ccid] 注册: ${ccId} → ${robotName} (${agentName || 'unknown'})`);
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
- console.log(`[ccid] 注销: ${ccId}`);
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 = '1.2.0';
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
- // 处理微信消息(路由给对应的 Session)
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
- // 有引用,SSE 推送给对应的 CC
140
- console.log(`[http] 消息路由给 ${targetCcId}`);
141
- pushMessageToSession(msg.robotName, {
142
- msgid: msg.msgid,
143
- content: msg.content,
144
- from_userid: msg.from_userid,
145
- chatid: msg.chatid,
146
- chattype: msg.chattype,
147
- timestamp: msg.timestamp,
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
- else if (getCCCount() === 1) {
151
- // 只有一个 CC 在线,直接推送(无需引用)
152
- console.log(`[http] CC 模式,直接推送`);
153
- pushMessageToSession(msg.robotName, {
154
- msgid: msg.msgid,
155
- content: msg.content,
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
- else if (getCCCount() > 1) {
163
- // CC 在线但无引用:先尝试按 from_userid 匹配机器人的 targetUserId
164
- const matchedCcId = findCcIdByTargetUserId(msg.from_userid);
165
- if (matchedCcId) {
166
- console.log(`[http] CC 模式,按 from_userid 路由给 ${matchedCcId}`);
167
- pushMessageToSession(msg.robotName, {
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
- console.log(`[http] 多 CC 模式,无引用,发送提示`);
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().map(id => `• ${id}`).join('\n');
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
- 示例:引用【${getOnlineCcIds()[0]}】的消息后回复`;
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
- console.log('[http] GET /mcp: 无 session ID,返回 405');
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
- console.log(`[http] GET /mcp: session ${sessionId} not found`);
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
- console.log(`[http] 建立 SSE 流: session ${sessionId}`);
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
- console.log(`[http] Session 初始化: ${sid}`);
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
- console.log(`[http] Session 关闭: ${sid}`);
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
- console.error('[http] MCP 请求处理失败:', err);
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 = generateCcId();
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
- console.log(`[http] [DEBUG] 进入 headless 模式: ccId: ${ccId}`);
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
- console.log(`[http] [DEBUG] 退出 headless 模式`);
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
- console.log(`[http] [DEBUG] 模拟断开机器人: ${robotName}`);
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
- console.log(`[http] [DEBUG] 触发重连机器人: ${robotName}`);
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
- console.log(`[http] MCP Server 已启动: http://127.0.0.1:${port}`);
465
- console.log(`[http] MCP endpoint: http://127.0.0.1:${port}/mcp (stateless mode)`);
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
- console.log('[http] HTTP Server 已停止');
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
- console.log(`[http] 审批路由: 请求指定 robotName=${robotName}`);
780
+ logger.log(`[http] 审批路由: 请求指定 robotName=${robotName}`);
489
781
  }
490
782
  else if (ccId) {
491
783
  robotName = getRobotByCcId(ccId);
492
784
  if (robotName) {
493
- console.log(`[http] 审批路由: ccId=${ccId} → ${robotName}`);
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
- console.log(`[http] 审批路由: 回退到第一个已连接机器人 ${robotName}`);
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
- console.log(`[http] 审批请求已发送: ${taskId} (机器人: ${robotName})`);
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
- console.error('[http] 审批请求处理失败:', err);
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
- console.error(`[http] 推送到 session ${sessionId} 失败:`, err);
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
- console.error('[http] 推送通知失败:', err);
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
- console.log('[http] 使用固定端口:', HTTP_PORT);
1074
+ logger.log('[http] 使用固定端口:', HTTP_PORT);
742
1075
  }