@vrs-soft/wecom-aibot-mcp 2.4.22 → 2.4.24

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 CHANGED
@@ -94,13 +94,31 @@ npx @vrs-soft/wecom-aibot-mcp --http-only --start
94
94
  | `--add / --delete` | Add/delete bot |
95
95
  | `--set-token [token]` | Set Auth Token (for remote deployment) |
96
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) |
97
+ | `--debug` | Start in foreground with debug-level logging (writes to `server.log` + stdout) |
98
+ | `--http-only` | Deprecated; identical to `--start` |
99
99
  | `--channel-only` | Configure Channel MCP only (requires `MCP_URL`) |
100
100
  | `--clean-cache` | Clear CC registry cache |
101
101
  | `--upgrade` | Force upgrade global configs |
102
102
  | `--uninstall` | Complete uninstall |
103
103
 
104
+ ## Logs
105
+
106
+ All logs are written to `~/.wecom-aibot-mcp/` as JSON Lines (one JSON object per line):
107
+
108
+ | File | Producer | Levels | Rotation |
109
+ |------|----------|--------|----------|
110
+ | `server.log` | `--start` / `--debug` daemon | `info` always, `debug` when `--debug` | 10MB × 5 |
111
+ | `channel.log` | `--channel` MCP proxy (per agent) | `info` always, `debug` when debug marker exists | 10MB × 5 |
112
+ | `connection.log` | WebSocket layer (connect/disconnect/auth) | n/a | append-only |
113
+ | `debug` (marker file) | `--debug` creates it; channel-server reads it | — | — |
114
+
115
+ Quick inspection:
116
+
117
+ ```bash
118
+ tail -f ~/.wecom-aibot-mcp/server.log | jq .
119
+ grep -h '"level":"error"' ~/.wecom-aibot-mcp/server.log*
120
+ ```
121
+
104
122
  ## Run Modes
105
123
 
106
124
  | | Channel Mode | HTTP Mode |
package/dist/bin.js CHANGED
@@ -42,11 +42,11 @@ function showHelp() {
42
42
  --setup --server --channel 本地完整安装(HTTP + Channel)
43
43
  --upgrade 强制升级全局配置(覆盖 MCP 配置、权限、skill)
44
44
  --reinstall 重新安装全局配置(删除后重新写入,保留机器人配置)
45
- --start 启动 MCP Server(后台服务模式)
45
+ --start 启动 HTTP MCP Server(后台守护进程,日志写 server.log)
46
46
  --stop 停止 MCP Server
47
- --debug 前台启动 MCP Server(日志直接输出到终端,用于调试)
48
- --channel 启动 Channel MCP Proxy(stdio 代理 + SSE 唤醒)
49
- --http-only 仅启动 HTTP Server(远程部署场景,不安装 Channel MCP 配置)
47
+ --debug 前台启动 + debug 级日志(日志同时落 server.log,stdout 实时打印)
48
+ --channel 启动 Channel MCP Proxy(stdio 代理 + SSE 唤醒,日志写 channel.log)
49
+ --http-only 仅写 HTTP-only 配置(已废弃;--start 默认就是 daemon-only 行为)
50
50
  --channel-only 仅配置 Channel MCP(本地连接远程 HTTP Server)
51
51
  --status 显示服务状态和机器人配置
52
52
  --config 重新配置默认机器人(修改 Bot ID / Secret / 目标用户)
@@ -96,6 +96,24 @@ MCP 配置(默认安装同时配置两种模式):
96
96
  启动 Channel 模式(研究预览):
97
97
  claude --dangerously-load-development-channels server:wecom-aibot-channel
98
98
 
99
+ 日志:
100
+
101
+ Daemon (HTTP MCP):
102
+ ~/.wecom-aibot-mcp/server.log info 级(永久)+ debug 级(仅 --debug 时写)
103
+ ~/.wecom-aibot-mcp/server.log.1..5 自动滚动备份(每份 ≤10MB,共保留 5 份)
104
+
105
+ Channel MCP:
106
+ ~/.wecom-aibot-mcp/channel.log info 级(永久)+ debug 级(仅 debug 模式时写)
107
+ ~/.wecom-aibot-mcp/channel.log.1..5 自动滚动备份
108
+
109
+ WebSocket 连接事件:
110
+ ~/.wecom-aibot-mcp/connection.log 专用文件(connect/disconnect/auth 事件)
111
+
112
+ Debug 标记:
113
+ ~/.wecom-aibot-mcp/debug 存在则启用 debug 级输出(--debug 时自动创建)
114
+
115
+ 日志格式: JSON Lines,每行一个 {ts, level, msg, data?},方便 jq/grep 检索
116
+
99
117
  更多信息: https://github.com/eric2877/wecom-aibot-mcp
100
118
  `);
101
119
  }
@@ -271,6 +289,10 @@ async function startMcpServerForeground(isDebug = false) {
271
289
  fs.writeFileSync(debugFile, 'true');
272
290
  console.log('[mcp] Debug 标记文件已创建');
273
291
  }
292
+ // 配置统一日志输出到文件(JSON Lines + 自动滚动)
293
+ logger.setLogFile(path.join(os.homedir(), '.wecom-aibot-mcp', 'server.log'));
294
+ if (isDebug)
295
+ logger.setDebug(true);
274
296
  // 确保 hook 已安装
275
297
  ensureHookInstalled();
276
298
  // 加载统计并清理旧日志
@@ -299,9 +321,12 @@ async function startMcpServerForeground(isDebug = false) {
299
321
  logger.log(`[mcp] 健康检查: http://127.0.0.1:${HTTP_PORT}/health`);
300
322
  logger.log(`[mcp] 微信模式:enter_headless_mode 时建立连接`);
301
323
  logger.log(`[mcp] PID: ${process.pid}`);
324
+ // 写入启动事件到 server.log(永久记录)
325
+ logger.info('daemon started', { version: VERSION, port: HTTP_PORT, protocol, pid: process.pid, debug: isDebug });
302
326
  // 退出处理
303
327
  const gracefulShutdown = () => {
304
328
  console.log('[mcp] 正在关闭...');
329
+ logger.info('daemon shutdown', { pid: process.pid });
305
330
  stopKeepaliveMonitor();
306
331
  stopHttpServer();
307
332
  if (fs.existsSync(PID_FILE)) {
@@ -675,6 +700,10 @@ async function main() {
675
700
  fs.writeFileSync(debugFile, 'true');
676
701
  }
677
702
  }
703
+ // 配置统一日志输出到文件(每个 channel-server 进程都写同一份 channel.log,多进程并发追加)
704
+ logger.setLogFile(path.join(os.homedir(), '.wecom-aibot-mcp', 'channel.log'));
705
+ if (isDebug)
706
+ logger.setDebug(true);
678
707
  console.log('[channel] Starting Channel MCP Proxy...');
679
708
  const { startChannelServer } = await import('./channel-server.js');
680
709
  await startChannelServer();
@@ -12,11 +12,50 @@
12
12
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
- import * as fs from 'fs';
16
- import * as path from 'path';
17
- import * as os from 'os';
15
+ import { execSync } from 'child_process';
18
16
  import { VERSION, installSkill } from './config-wizard.js';
19
17
  import { addPermissionHook, registerActiveProject, unregisterActiveProject, updateWechatModeConfig } from './project-config.js';
18
+ import { logger } from './logger.js';
19
+ /**
20
+ * 沿进程树向上查找 Claude Code TUI 的 PID。
21
+ *
22
+ * 背景:本地 dev (`command: "node"`) 时 channel-server 是 Claude TUI 的直接子进程,
23
+ * process.ppid = Claude TUI ✓
24
+ * 但 npx 部署 (`command: "npx"`) 时多了一层 npx:
25
+ * Claude TUI → npx → node bin.js (channel-server)
26
+ * process.ppid = npx ❌
27
+ * permission-hook.sh 从 hook 自身向上查 active-projects.json 时只能命中 Claude TUI
28
+ * 这条祖先链。如果注册的是 npx 的 PID,hook 永远找不到 → 静默 exit 0 → 跳过审批。
29
+ *
30
+ * 此函数从 startPid 起向上遍历,找到第一个命令名为 "claude" 的进程,返回其 PID。
31
+ * 找不到时回退到 startPid(保持旧行为,至少 dev 场景不退化)。
32
+ */
33
+ function findClaudePid(startPid) {
34
+ let pid = startPid;
35
+ for (let i = 0; i < 8; i++) {
36
+ if (!pid || pid <= 1)
37
+ break;
38
+ try {
39
+ const comm = execSync(`ps -p ${pid} -o comm=`, { stdio: ['ignore', 'pipe', 'ignore'] })
40
+ .toString()
41
+ .trim();
42
+ // ps comm= 返回执行文件 basename。Claude Code TUI 安装名就是 "claude"
43
+ if (comm === 'claude' || comm.endsWith('/claude'))
44
+ return pid;
45
+ const ppidStr = execSync(`ps -p ${pid} -o ppid=`, { stdio: ['ignore', 'pipe', 'ignore'] })
46
+ .toString()
47
+ .trim();
48
+ const ppid = parseInt(ppidStr, 10);
49
+ if (!ppid || ppid === pid)
50
+ break;
51
+ pid = ppid;
52
+ }
53
+ catch {
54
+ break;
55
+ }
56
+ }
57
+ return startPid;
58
+ }
20
59
  const MCP_URL = process.env.MCP_URL || 'http://127.0.0.1:18963';
21
60
  const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
22
61
  // 构建带 auth 的 fetch headers
@@ -28,22 +67,14 @@ function getAuthHeaders() {
28
67
  return headers;
29
68
  }
30
69
  // Channel 日志文件
31
- const CHANNEL_LOG_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'channel.log');
32
70
  /**
33
71
  * 写入 Channel 日志
72
+ *
73
+ * 默认走 logger.debug(仅 --debug 时落盘)。关键事件请直接调用 logger.info(),
74
+ * 它们会永久落盘到 ~/.wecom-aibot-mcp/channel.log(自动滚动)。
34
75
  */
35
76
  function logChannel(message, data) {
36
- const timestamp = new Date().toISOString();
37
- const logLine = `[${timestamp}] ${message}${data ? ` | ${JSON.stringify(data)}` : ''}\n`;
38
- // 写入日志文件
39
- try {
40
- fs.appendFileSync(CHANNEL_LOG_FILE, logLine);
41
- }
42
- catch (err) {
43
- console.error(`[channel] 日志写入失败: ${err}`);
44
- }
45
- // 同时输出到 stderr
46
- console.error(`[channel] ${message}${data ? ` | ${JSON.stringify(data).slice(0, 200)}` : ''}`);
77
+ logger.debug(message, data);
47
78
  }
48
79
  // SSE 连接状态
49
80
  let sseConnected = false;
@@ -192,7 +223,7 @@ function connectSSE(ccId) {
192
223
  sseCurrentCcId = ccId;
193
224
  // SSE URL 添加 ccId 查询参数用于授权验证
194
225
  const sseUrl = ccId ? `${MCP_URL}/sse/${ccId}?ccId=${ccId}` : `${MCP_URL}/sse`;
195
- logChannel('Connecting to SSE', { url: sseUrl, ccId, mcpServerReady: mcpServer ? 'yes' : 'no' });
226
+ logger.info('Connecting to SSE', { url: sseUrl, ccId, mcpServerReady: mcpServer ? 'yes' : 'no' });
196
227
  sseAbortController = new AbortController();
197
228
  // SSE fetch 配置:添加 keep-alive headers 确保连接稳定
198
229
  fetch(sseUrl, {
@@ -228,7 +259,7 @@ function connectSSE(ccId) {
228
259
  }
229
260
  return;
230
261
  }
231
- logChannel('SSE connected, waiting for messages', { status: res.status });
262
+ logger.info('SSE connected', { ccId, status: res.status });
232
263
  const reader = res.body?.getReader();
233
264
  if (!reader) {
234
265
  logChannel('No response body');
@@ -250,7 +281,7 @@ function connectSSE(ccId) {
250
281
  sseConnected = false;
251
282
  // 非主动断开时自动重连
252
283
  if (!sseAbortController?.signal.aborted) {
253
- logChannel('SSE 断线,3 秒后重连', { ccId });
284
+ logger.info('SSE 断线,3 秒后重连', { ccId });
254
285
  setTimeout(() => { httpSessionId = null; connectSSE(ccId); }, 3000);
255
286
  }
256
287
  break;
@@ -321,11 +352,11 @@ function connectSSE(ccId) {
321
352
  }
322
353
  clearInterval(heartbeatInterval);
323
354
  }).catch((err) => {
324
- logChannel('SSE error', { error: String(err) });
355
+ logger.error('SSE error', { error: String(err) });
325
356
  sseConnected = false;
326
357
  // 非主动断开时自动重连
327
358
  if (!sseAbortController?.signal.aborted) {
328
- logChannel('SSE 出错,3 秒后重连', { ccId });
359
+ logger.info('SSE 出错,3 秒后重连', { ccId });
329
360
  setTimeout(() => { httpSessionId = null; connectSSE(ccId); }, 3000);
330
361
  }
331
362
  });
@@ -438,7 +469,7 @@ function registerChannelTools(server) {
438
469
  try {
439
470
  const parsed = JSON.parse(content[0].text);
440
471
  if (parsed.ccId) {
441
- logChannel('Got ccId, connecting SSE', { ccId: parsed.ccId, mode });
472
+ logger.info('Got ccId, connecting SSE', { ccId: parsed.ccId, mode });
442
473
  // 保存连接参数供重连复用
443
474
  sseRobotId = robot_id || parsed.robotName;
444
475
  sseProjectDir = project_dir || process.cwd();
@@ -446,10 +477,17 @@ function registerChannelTools(server) {
446
477
  // Channel 模式:在本地项目写入 PermissionRequest hook
447
478
  const localProjectDir = project_dir || process.cwd();
448
479
  const hookResult = addPermissionHook(localProjectDir);
449
- logChannel('本地 PermissionRequest hook 已写入', { path: hookResult.path, success: hookResult.success });
450
- // 注册本地 PID projectDir(供本地 permission-hook.sh 通过进程树匹配项目)
451
- registerActiveProject(process.ppid ?? process.pid, localProjectDir);
452
- logChannel('本地 active-projects 已注册', { pid: process.ppid ?? process.pid, projectDir: localProjectDir });
480
+ logger.info('本地 PermissionRequest hook 已写入', { path: hookResult.path, success: hookResult.success });
481
+ // 注册 Claude TUI PID(不能用 process.ppid,npx 部署时 ppid 是 npx 不是 Claude)
482
+ const startPid = process.ppid ?? process.pid;
483
+ const claudePid = findClaudePid(startPid);
484
+ registerActiveProject(claudePid, localProjectDir);
485
+ logger.info('本地 active-projects 已注册', {
486
+ pid: claudePid,
487
+ rawPpid: startPid,
488
+ resolvedClaudePid: claudePid !== startPid,
489
+ projectDir: localProjectDir,
490
+ });
453
491
  // 写入本地 wecom-aibot.json(远程 HTTP MCP 写在远端 fs,agent 本地需要自己落地)
454
492
  updateWechatModeConfig(localProjectDir, {
455
493
  wechatMode: true,
@@ -458,10 +496,10 @@ function registerChannelTools(server) {
458
496
  mode: parsed.mode || mode,
459
497
  autoApproveTimeout: auto_approve_timeout,
460
498
  });
461
- logChannel('本地 wecom-aibot.json 已写入', { projectDir: localProjectDir, robotName: parsed.robotName, ccId: parsed.ccId });
499
+ logger.info('本地 wecom-aibot.json 已写入', { projectDir: localProjectDir, robotName: parsed.robotName, ccId: parsed.ccId });
462
500
  // 安装 skill 到本地(同上)
463
501
  const skillResult = installSkill(localProjectDir);
464
- logChannel('本地 skill 安装', { success: skillResult.success, skillUrl: skillResult.skillUrl });
502
+ logger.info('本地 skill 安装', { success: skillResult.success, skillUrl: skillResult.skillUrl });
465
503
  // Channel 模式:过滤 heartbeat 信息,简化消息
466
504
  if (mode === 'channel' || parsed.mode === 'channel') {
467
505
  delete parsed.heartbeat; // Channel 模式不需要 heartbeat loop
@@ -492,7 +530,7 @@ function registerChannelTools(server) {
492
530
  sseAbortController = null;
493
531
  sseConnected = false;
494
532
  sseCurrentCcId = undefined;
495
- logChannel('SSE disconnected', { cc_id });
533
+ logger.info('SSE disconnected', { cc_id });
496
534
  }
497
535
  // 注销本地 active-projects 记录
498
536
  unregisterActiveProject(localProjectDir);
@@ -113,7 +113,7 @@ export async function connectRobot(robotName, agentName) {
113
113
  agentName,
114
114
  };
115
115
  connectionPool.set(robot.name, state);
116
- logger.log(`[connection] 已连接机器人: ${robot.name}`);
116
+ logger.info('robot connected', { robotName: robot.name });
117
117
  return {
118
118
  success: true,
119
119
  client,
@@ -127,7 +127,7 @@ export function disconnectRobot(robotName) {
127
127
  if (state) {
128
128
  state.client.disconnect();
129
129
  connectionPool.delete(robotName);
130
- logger.log(`[connection] 已断开机器人: ${robotName}`);
130
+ logger.info('robot disconnected', { robotName });
131
131
  }
132
132
  }
133
133
  /**
@@ -145,7 +145,7 @@ export async function getClient(robotName) {
145
145
  // 断开了,尝试重连
146
146
  const robot = await findRobotConfig(state.robotName);
147
147
  if (robot) {
148
- logger.log(`[connection] 重连机器人: ${robot.name}`);
148
+ logger.info('robot reconnecting', { robotName: robot.name });
149
149
  const oldClient = state.client;
150
150
  // 迁移旧 client 的未解决审批记录和待发送审批消息
151
151
  const pendingApprovals = oldClient.getUnresolvedApprovalMap();
@@ -165,11 +165,11 @@ export async function getClient(robotName) {
165
165
  state.client.connect();
166
166
  const connected = await waitForConnection(state.client, 5000);
167
167
  if (connected) {
168
- logger.log(`[connection] 重连成功: ${robot.name}`);
168
+ logger.info('robot reconnected', { robotName: robot.name });
169
169
  return state.client;
170
170
  }
171
171
  else {
172
- logger.log(`[connection] 重连失败: ${robot.name}`);
172
+ logger.error('robot reconnect failed', { robotName: robot.name });
173
173
  return null;
174
174
  }
175
175
  }
@@ -141,7 +141,7 @@ function cleanStaleCcIds() {
141
141
  for (const [id, entry] of ccIdRegistry) {
142
142
  if (now - entry.lastOnline > CCID_STALE_TIMEOUT) {
143
143
  ccIdRegistry.delete(id);
144
- logger.log(`[ccid] 清理超时条目: ${id} (离线 ${Math.round((now - entry.lastOnline) / 60000)} 分钟)`);
144
+ logger.info('ccId stale cleanup', { ccId: id, offlineMins: Math.round((now - entry.lastOnline) / 60000) });
145
145
  }
146
146
  }
147
147
  }
@@ -150,24 +150,24 @@ function cleanStaleCcIds() {
150
150
  export function registerCcId(ccId, robotName, agentName, mode, projectDir, isReconnect) {
151
151
  if (isReconnect || ccIdRegistry.has(ccId)) {
152
152
  // 重连:直接覆盖,更新 lastOnline
153
- logger.log(`[ccid] 重连: ${ccId} ${robotName}`);
153
+ logger.info('ccId reconnect', { ccId, robotName });
154
154
  }
155
155
  else {
156
156
  // 首次注册:先清理超时条目
157
157
  cleanStaleCcIds();
158
- logger.log(`[ccid] 注册: ${ccId} → ${robotName} (${agentName || 'unknown'}, mode: ${mode || 'http'})`);
158
+ logger.info('ccId registered', { ccId, robotName, agentName: agentName || null, mode: mode || 'http' });
159
159
  }
160
160
  ccIdRegistry.set(ccId, { robotName, agentName, mode, projectDir, lastOnline: Date.now() });
161
161
  return { success: true, ccId };
162
162
  }
163
163
  export function unregisterCcId(ccId) {
164
164
  ccIdRegistry.delete(ccId);
165
- logger.log(`[ccid] 注销: ${ccId}`);
165
+ logger.info('ccId unregistered', { ccId });
166
166
  }
167
167
  export function clearCcIdRegistry() {
168
168
  const entries = Array.from(ccIdRegistry.keys());
169
169
  ccIdRegistry.clear();
170
- logger.log(`[ccid] 清空注册表: 共清理 ${entries.length} (${entries.join(', ')})`);
170
+ logger.info('ccId registry cleared', { count: entries.length, entries });
171
171
  return { cleared: entries.length, entries };
172
172
  }
173
173
  export function getRobotByCcId(ccId) {
package/dist/logger.d.ts CHANGED
@@ -1,51 +1,33 @@
1
1
  /**
2
2
  * 统一日志模块
3
3
  *
4
- * 通过 --debug 参数控制日志输出:
5
- * - debug 模式:所有日志输出到 stderr
6
- * - 正常模式:只输出必要信息(如错误、启动消息)
4
+ * 级别:error / info / debug
5
+ * - error:永远写文件 + stderr
6
+ * - info:永远写文件,--debug 时也写 stdout
7
+ * - debug:仅 --debug 模式写文件 + stdout
7
8
  *
8
- * Debug 标记文件:~/.wecom-aibot-mcp/debug
9
+ * Debug 标记:~/.wecom-aibot-mcp/debug 文件存在 → debug 模式
9
10
  *
10
- * 用法与 console 完全一致:
11
- * - logger.log() - debug 模式才输出
12
- * - logger.error() - 始终输出到 stderr
13
- * - logger.warn() - 始终输出到 stderr
14
- * - logger.info() - 始终输出到 stdout
15
- */
16
- /**
17
- * 检查是否处于 debug 模式
11
+ * 文件输出:JSON Lines 格式,自动按 10MB 滚动,保留最近 5 份
12
+ * <file> 当前
13
+ * <file>.1 上一份
14
+ * ...
15
+ * <file>.5 最旧
16
+ *
17
+ * 调用方在入口处通过 setLogFile() 指定写入文件:
18
+ * - daemon (`--start` / `--debug`): server.log
19
+ * - channel-server (`--channel`): channel.log
18
20
  */
19
21
  export declare function isDebugMode(): boolean;
20
- /**
21
- * 设置 debug 模式
22
- */
23
22
  export declare function setDebugMode(enabled: boolean): void;
24
- /**
25
- * 日志输出(仅 debug 模式)
26
- * 与 console.log 用法完全一致
27
- */
28
- export declare function log(...args: unknown[]): void;
29
- /**
30
- * 错误日志(始终输出到 stderr)
31
- * 与 console.error 用法完全一致
32
- */
33
- export declare function error(...args: unknown[]): void;
34
- /**
35
- * 信息日志(始终输出到 stdout)
36
- * 与 console.info 用法完全一致
37
- */
38
- export declare function info(...args: unknown[]): void;
39
- /**
40
- * 警告日志(始终输出到 stderr)
41
- * 与 console.warn 用法完全一致
42
- */
43
- export declare function warn(...args: unknown[]): void;
23
+ export declare function setLogFile(file: string): void;
44
24
  export declare const logger: {
45
- log: typeof log;
46
- error: typeof error;
47
- info: typeof info;
48
- warn: typeof warn;
25
+ info: (msg: string, data?: unknown) => void;
26
+ debug: (msg: string, data?: unknown) => void;
27
+ error: (msg: string, data?: unknown) => void;
28
+ log: (...args: unknown[]) => void;
29
+ warn: (...args: unknown[]) => void;
49
30
  isDebug: typeof isDebugMode;
50
31
  setDebug: typeof setDebugMode;
32
+ setLogFile: typeof setLogFile;
51
33
  };
package/dist/logger.js CHANGED
@@ -1,84 +1,114 @@
1
1
  /**
2
2
  * 统一日志模块
3
3
  *
4
- * 通过 --debug 参数控制日志输出:
5
- * - debug 模式:所有日志输出到 stderr
6
- * - 正常模式:只输出必要信息(如错误、启动消息)
4
+ * 级别:error / info / debug
5
+ * - error:永远写文件 + stderr
6
+ * - info:永远写文件,--debug 时也写 stdout
7
+ * - debug:仅 --debug 模式写文件 + stdout
7
8
  *
8
- * Debug 标记文件:~/.wecom-aibot-mcp/debug
9
+ * Debug 标记:~/.wecom-aibot-mcp/debug 文件存在 → debug 模式
9
10
  *
10
- * 用法与 console 完全一致:
11
- * - logger.log() - debug 模式才输出
12
- * - logger.error() - 始终输出到 stderr
13
- * - logger.warn() - 始终输出到 stderr
14
- * - logger.info() - 始终输出到 stdout
11
+ * 文件输出:JSON Lines 格式,自动按 10MB 滚动,保留最近 5 份
12
+ * <file> 当前
13
+ * <file>.1 上一份
14
+ * ...
15
+ * <file>.5 最旧
16
+ *
17
+ * 调用方在入口处通过 setLogFile() 指定写入文件:
18
+ * - daemon (`--start` / `--debug`): server.log
19
+ * - channel-server (`--channel`): channel.log
15
20
  */
16
21
  import * as fs from 'fs';
17
22
  import * as path from 'path';
18
23
  import * as os from 'os';
19
- const DEBUG_FILE = path.join(os.homedir(), '.wecom-aibot-mcp', 'debug');
24
+ const CONFIG_DIR = path.join(os.homedir(), '.wecom-aibot-mcp');
25
+ const DEBUG_FILE = path.join(CONFIG_DIR, 'debug');
26
+ const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
27
+ const KEEP_FILES = 5;
28
+ let logFile = null;
20
29
  let debugMode = false;
21
- /**
22
- * 检查是否处于 debug 模式
23
- */
24
30
  export function isDebugMode() {
25
31
  if (debugMode)
26
32
  return true;
27
- // 检查标记文件
28
33
  if (fs.existsSync(DEBUG_FILE)) {
29
34
  debugMode = true;
30
35
  return true;
31
36
  }
32
37
  return false;
33
38
  }
34
- /**
35
- * 设置 debug 模式
36
- */
37
39
  export function setDebugMode(enabled) {
38
40
  debugMode = enabled;
39
41
  if (enabled && !fs.existsSync(DEBUG_FILE)) {
42
+ if (!fs.existsSync(CONFIG_DIR))
43
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
40
44
  fs.writeFileSync(DEBUG_FILE, 'true');
41
45
  }
42
46
  else if (!enabled && fs.existsSync(DEBUG_FILE)) {
43
47
  fs.unlinkSync(DEBUG_FILE);
44
48
  }
45
49
  }
46
- /**
47
- * 日志输出(仅 debug 模式)
48
- * console.log 用法完全一致
49
- */
50
- export function log(...args) {
51
- if (isDebugMode()) {
52
- console.log(...args);
53
- }
54
- }
55
- /**
56
- * 错误日志(始终输出到 stderr)
57
- * 与 console.error 用法完全一致
58
- */
59
- export function error(...args) {
60
- console.error(...args);
50
+ export function setLogFile(file) {
51
+ logFile = file;
52
+ const dir = path.dirname(file);
53
+ if (!fs.existsSync(dir))
54
+ fs.mkdirSync(dir, { recursive: true });
61
55
  }
62
- /**
63
- * 信息日志(始终输出到 stdout)
64
- * console.info 用法完全一致
65
- */
66
- export function info(...args) {
67
- console.info(...args);
56
+ function rotate(file) {
57
+ try {
58
+ for (let i = KEEP_FILES - 1; i >= 1; i--) {
59
+ const src = `${file}.${i}`;
60
+ const dst = `${file}.${i + 1}`;
61
+ if (fs.existsSync(src))
62
+ fs.renameSync(src, dst);
63
+ }
64
+ if (fs.existsSync(file))
65
+ fs.renameSync(file, `${file}.1`);
66
+ }
67
+ catch {
68
+ // 滚动失败忽略,避免阻塞日志写入
69
+ }
68
70
  }
69
- /**
70
- * 警告日志(始终输出到 stderr)
71
- * 与 console.warn 用法完全一致
72
- */
73
- export function warn(...args) {
74
- console.warn(...args);
71
+ function write(level, msg, data) {
72
+ if (level === 'debug' && !isDebugMode())
73
+ return;
74
+ const entry = JSON.stringify({
75
+ ts: new Date().toISOString(),
76
+ level,
77
+ msg,
78
+ ...(data !== undefined ? { data } : {}),
79
+ }) + '\n';
80
+ // 文件
81
+ if (logFile) {
82
+ try {
83
+ if (fs.existsSync(logFile) && fs.statSync(logFile).size >= MAX_SIZE) {
84
+ rotate(logFile);
85
+ }
86
+ fs.appendFileSync(logFile, entry);
87
+ }
88
+ catch {
89
+ // 写文件失败不抛
90
+ }
91
+ }
92
+ // 控制台
93
+ if (level === 'error') {
94
+ process.stderr.write(entry);
95
+ }
96
+ else if (debugMode) {
97
+ process.stdout.write(entry);
98
+ }
75
99
  }
76
- // 导出 logger 对象,用法与 console 完全一致
100
+ // 公共 API
77
101
  export const logger = {
78
- log,
79
- error,
80
- info,
81
- warn,
102
+ info: (msg, data) => write('info', msg, data),
103
+ debug: (msg, data) => write('debug', msg, data),
104
+ error: (msg, data) => write('error', msg, data),
105
+ // 兼容旧 API(散落在各处的调用)
106
+ log: (...args) => {
107
+ if (debugMode)
108
+ console.log(...args);
109
+ },
110
+ warn: (...args) => console.warn(...args),
82
111
  isDebug: isDebugMode,
83
112
  setDebug: setDebugMode,
113
+ setLogFile,
84
114
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vrs-soft/wecom-aibot-mcp",
3
- "version": "2.4.22",
3
+ "version": "2.4.24",
4
4
  "description": "企业微信智能机器人 MCP 服务 - Claude Code 审批通道",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",