@wu529778790/open-im 1.8.1 → 1.8.3-beta.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.
Files changed (36) hide show
  1. package/dist/access/access-control.js +1 -1
  2. package/dist/adapters/claude-sdk-adapter.js +71 -58
  3. package/dist/cli.js +5 -2
  4. package/dist/commands/handler.d.ts +0 -1
  5. package/dist/commands/handler.js +6 -20
  6. package/dist/config-web.js +24 -9
  7. package/dist/config.d.ts +11 -0
  8. package/dist/config.js +27 -2
  9. package/dist/dingtalk/event-handler.js +7 -3
  10. package/dist/dingtalk/message-sender.js +13 -0
  11. package/dist/feishu/event-handler.js +39 -19
  12. package/dist/index.js +9 -0
  13. package/dist/manager-control.js +7 -0
  14. package/dist/qq/client.js +6 -1
  15. package/dist/qq/event-handler.js +5 -2
  16. package/dist/qq/message-sender.js +11 -0
  17. package/dist/service-control.js +4 -0
  18. package/dist/session/session-manager.js +11 -1
  19. package/dist/shared/chat-user-map.js +11 -0
  20. package/dist/shared/media-storage.js +27 -0
  21. package/dist/telegram/client.js +3 -1
  22. package/dist/telegram/event-handler.js +41 -9
  23. package/dist/telegram/message-sender.js +13 -0
  24. package/dist/wechat/auth/qclaw-api.js +1 -1
  25. package/dist/wechat/client.d.ts +10 -3
  26. package/dist/wechat/client.js +70 -214
  27. package/dist/wechat/event-handler.js +7 -4
  28. package/dist/wechat/qclaw-transport.d.ts +66 -0
  29. package/dist/wechat/qclaw-transport.js +303 -0
  30. package/dist/wechat/transport.d.ts +41 -0
  31. package/dist/wechat/transport.js +5 -0
  32. package/dist/wechat/workbuddy-transport.d.ts +33 -0
  33. package/dist/wechat/workbuddy-transport.js +145 -0
  34. package/dist/wework/event-handler.js +28 -4
  35. package/dist/wework/message-sender.js +53 -21
  36. package/package.json +2 -2
@@ -8,7 +8,7 @@ export class AccessControl {
8
8
  }
9
9
  isAllowed(userId) {
10
10
  if (this.allowedUserIds.size === 0) {
11
- log.debug(`Allowing user ${userId} (no whitelist configured)`);
11
+ log.warn(`Allowing user ${userId} no whitelist configured. Set allowedUserIds to restrict access.`);
12
12
  return true;
13
13
  }
14
14
  const allowed = this.allowedUserIds.has(userId);
@@ -16,6 +16,21 @@ const log = createLogger('ClaudeSDK');
16
16
  const activeSessions = new Map();
17
17
  // 存储正在进行的流式迭代器,用于中断
18
18
  const activeStreams = new Set();
19
+ // Mutex to serialize process.chdir() calls across concurrent users
20
+ let chdirMutex = Promise.resolve();
21
+ function withChdirMutex(fn) {
22
+ const previous = chdirMutex;
23
+ let resolve;
24
+ chdirMutex = new Promise((r) => { resolve = r; });
25
+ return previous.then(() => {
26
+ try {
27
+ return fn();
28
+ }
29
+ finally {
30
+ resolve();
31
+ }
32
+ });
33
+ }
19
34
  function isStreamEvent(msg) {
20
35
  return msg.type === 'stream_event';
21
36
  }
@@ -37,39 +52,51 @@ function isResult(msg) {
37
52
  * @param permissionMode 权限模式
38
53
  * @returns SDKSession 对象和实际的 sessionId
39
54
  */
40
- async function getOrCreateSession(sessionId, _workDir, // 保留参数以备将来使用
41
- model, permissionMode) {
55
+ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
42
56
  const resolvedModel = model?.trim() || 'claude-opus-4-5';
43
57
  const sessionOptions = {
44
58
  model: resolvedModel,
45
59
  permissionMode,
46
- // 可以添加其他选项,如 hooks, allowedTools 等
47
60
  };
48
61
  const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '(default)';
49
- log.info(`[V2] getOrCreateSession model param=${String(model ?? '')} resolved=${resolvedModel} baseUrl=${baseUrl}`);
50
- let session;
51
- if (sessionId) {
52
- // 尝试恢复已有会话
62
+ log.info(`[V2] getOrCreateSession model param=${String(model ?? '')} resolved=${resolvedModel} baseUrl=${baseUrl} workDir=${workDir}`);
63
+ // Use mutex to serialize process.chdir() calls across concurrent users
64
+ return withChdirMutex(() => {
65
+ let session;
66
+ const originalCwd = process.cwd();
53
67
  try {
54
- log.info(`Attempting to resume session: ${sessionId}`);
55
- session = unstable_v2_resumeSession(sessionId, sessionOptions);
56
- activeSessions.set(sessionId, session);
57
- log.info(`Successfully resumed session: ${sessionId}`);
58
- return { session, sessionId };
68
+ if (workDir && workDir !== originalCwd) {
69
+ process.chdir(workDir);
70
+ }
71
+ if (sessionId) {
72
+ // 尝试恢复已有会话
73
+ try {
74
+ log.info(`Attempting to resume session: ${sessionId}`);
75
+ session = unstable_v2_resumeSession(sessionId, sessionOptions);
76
+ activeSessions.set(sessionId, session);
77
+ log.info(`Successfully resumed session: ${sessionId}`);
78
+ return { session, sessionId };
79
+ }
80
+ catch (err) {
81
+ log.warn(`Failed to resume session ${sessionId}, creating new one: ${err}`);
82
+ // 恢复失败,创建新会话
83
+ }
84
+ }
85
+ // 创建新会话
86
+ session = unstable_v2_createSession(sessionOptions);
87
+ // 新会话的 sessionId 需要从第一个消息中获取
88
+ // 暂时返回 undefined,稍后在 init 消息中获取
89
+ const tempId = `pending-${Date.now()}`;
90
+ activeSessions.set(tempId, session);
91
+ log.info(`Created new session (tempId: ${tempId})`);
92
+ return { session, sessionId: tempId };
59
93
  }
60
- catch (err) {
61
- log.warn(`Failed to resume session ${sessionId}, creating new one: ${err}`);
62
- // 恢复失败,创建新会话
94
+ finally {
95
+ if (workDir && workDir !== originalCwd) {
96
+ process.chdir(originalCwd);
97
+ }
63
98
  }
64
- }
65
- // 创建新会话
66
- session = unstable_v2_createSession(sessionOptions);
67
- // 新会话的 sessionId 需要从第一个消息中获取
68
- // 暂时返回 undefined,稍后在 init 消息中获取
69
- const tempId = `pending-${Date.now()}`;
70
- activeSessions.set(tempId, session);
71
- log.info(`Created new session (tempId: ${tempId})`);
72
- return { session, sessionId: tempId };
99
+ });
73
100
  }
74
101
  export class ClaudeSDKAdapter {
75
102
  toolId = 'claude-sdk';
@@ -105,14 +132,6 @@ export class ClaudeSDKAdapter {
105
132
  let actualSessionId;
106
133
  let pendingTempId; // 记录临时 ID,用于 abort 时清理
107
134
  let runSettled = false;
108
- let timeoutId = null;
109
- const timeoutMs = options?.timeoutMs ?? 600_000;
110
- const clearRunTimeout = () => {
111
- if (timeoutId !== null) {
112
- clearTimeout(timeoutId);
113
- timeoutId = null;
114
- }
115
- };
116
135
  const permissionMode = options?.skipPermissions
117
136
  ? 'bypassPermissions'
118
137
  : options?.permissionMode === 'acceptEdits'
@@ -121,15 +140,6 @@ export class ClaudeSDKAdapter {
121
140
  ? 'plan'
122
141
  : 'default';
123
142
  const runSession = async () => {
124
- timeoutId = setTimeout(() => {
125
- if (runSettled)
126
- return;
127
- runSettled = true;
128
- clearRunTimeout();
129
- log.warn(`[ClaudeSDK] Request timeout after ${timeoutMs}ms`);
130
- abortController.abort();
131
- callbacks.onError(`请求超时(${Math.round(timeoutMs / 1000)}s),请重试或缩短问题。`);
132
- }, timeoutMs);
133
143
  try {
134
144
  // 检查环境变量
135
145
  const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
@@ -209,7 +219,6 @@ export class ClaudeSDKAdapter {
209
219
  // 检查会话错误
210
220
  if (!success) {
211
221
  runSettled = true;
212
- clearRunTimeout();
213
222
  const noConvErr = errs.find((e) => e.includes('No conversation found') || e.includes('session not found'));
214
223
  if (noConvErr) {
215
224
  log.warn(`Session ${actualSessionId} not found, may need to create new one`);
@@ -237,25 +246,31 @@ export class ClaudeSDKAdapter {
237
246
  result.result = accumulated;
238
247
  }
239
248
  runSettled = true;
240
- clearRunTimeout();
241
249
  callbacks.onComplete(result);
242
250
  return;
243
251
  }
244
252
  }
245
253
  // 如果流正常结束但没有收到 result 消息
246
- if (!streamClosed && accumulated) {
247
- log.info('Stream ended without result message, using accumulated text');
248
- runSettled = true;
249
- clearRunTimeout();
250
- callbacks.onComplete({
251
- success: true,
252
- result: accumulated,
253
- accumulated,
254
- cost: 0,
255
- durationMs: 0,
256
- numTurns: 1,
257
- toolStats,
258
- });
254
+ if (!streamClosed) {
255
+ if (accumulated) {
256
+ log.info('Stream ended without result message, using accumulated text');
257
+ runSettled = true;
258
+ callbacks.onComplete({
259
+ success: true,
260
+ result: accumulated,
261
+ accumulated,
262
+ cost: 0,
263
+ durationMs: 0,
264
+ numTurns: 1,
265
+ toolStats,
266
+ });
267
+ }
268
+ else {
269
+ // 流结束但无 result 也无 accumulated:必须触发回调,否则 Promise 永远挂起
270
+ log.warn('Stream ended with no result and no accumulated text, calling onError to prevent stuck state');
271
+ runSettled = true;
272
+ callbacks.onError('AI 响应异常结束(无输出),请重试');
273
+ }
259
274
  }
260
275
  }
261
276
  finally {
@@ -266,7 +281,6 @@ export class ClaudeSDKAdapter {
266
281
  catch (err) {
267
282
  if (abortController.signal.aborted) {
268
283
  log.info('Session run aborted');
269
- clearRunTimeout();
270
284
  // 清理 pending tempId(abort 可能在 init 消息之前发生)
271
285
  const idToClean = actualSessionId ?? pendingTempId;
272
286
  if (idToClean?.startsWith('pending-')) {
@@ -276,7 +290,6 @@ export class ClaudeSDKAdapter {
276
290
  return;
277
291
  }
278
292
  runSettled = true;
279
- clearRunTimeout();
280
293
  const errorObj = err;
281
294
  const msg = errorObj.message || String(err);
282
295
  log.error(`Claude SDK V2 error: ${msg}`);
package/dist/cli.js CHANGED
@@ -50,7 +50,7 @@ async function cmdStart() {
50
50
  console.log("\nopen-im is already running in the background.");
51
51
  console.log(` pid: ${status.pid}`);
52
52
  console.log(` config page: ${getWebConfigUrl()}`);
53
- return;
53
+ process.exit(0);
54
54
  }
55
55
  if (!(await ensureConfigured("start"))) {
56
56
  process.exit(1);
@@ -67,17 +67,19 @@ async function cmdStart() {
67
67
  console.log(" A one-time login URL (with login_token) has been printed by the config-web server logger.");
68
68
  console.log(" Please use that URL (replacing 127.0.0.1 with your server IP/hostname) for the first login.");
69
69
  }
70
+ process.exit(0);
70
71
  }
71
72
  async function cmdStop() {
72
73
  const status = getManagerStatus();
73
74
  if (!status.pid) {
74
75
  console.log("open-im is not running in the background.");
75
- return;
76
+ process.exit(0);
76
77
  }
77
78
  await stopBackgroundService();
78
79
  const result = await stopManagerProcess();
79
80
  console.log("\nopen-im stopped.");
80
81
  console.log(` pid: ${result.pid}`);
82
+ process.exit(0);
81
83
  }
82
84
  async function cmdRestart() {
83
85
  const status = getManagerStatus();
@@ -98,6 +100,7 @@ async function cmdRestart() {
98
100
  console.log("\nopen-im restarted in the background.");
99
101
  console.log(` pid: ${child.pid}`);
100
102
  console.log(` config page: ${getWebConfigUrl()}`);
103
+ process.exit(0);
101
104
  }
102
105
  async function cmdInit() {
103
106
  console.log("\nopen-im CLI setup\n");
@@ -19,7 +19,6 @@ export declare class CommandHandler {
19
19
  private deps;
20
20
  constructor(deps: CommandHandlerDeps);
21
21
  dispatch(text: string, chatId: string, userId: string, platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'wechat' | 'wework' | 'workbuddy', handleClaudeRequest: ClaudeRequestHandler): Promise<boolean>;
22
- private getClearHistoryHint;
23
22
  private handleHelp;
24
23
  private handleNew;
25
24
  private handlePwd;
@@ -16,9 +16,9 @@ export class CommandHandler {
16
16
  return true;
17
17
  }
18
18
  if (t === '/help')
19
- return this.handleHelp(chatId, platform);
19
+ return this.handleHelp(chatId);
20
20
  if (t === '/new')
21
- return this.handleNew(chatId, userId, platform);
21
+ return this.handleNew(chatId, userId);
22
22
  if (t === '/pwd')
23
23
  return this.handlePwd(chatId, userId);
24
24
  if (t === '/status')
@@ -33,18 +33,7 @@ export class CommandHandler {
33
33
  }
34
34
  return false;
35
35
  }
36
- getClearHistoryHint(platform) {
37
- return platform === 'feishu'
38
- ? '💡 提示:如需清除本对话的历史消息,请点击飞书聊天右上角「...」→ 清除聊天记录'
39
- : platform === 'wechat'
40
- ? '💡 提示:如需清除本对话的历史消息,请清除聊天记录'
41
- : platform === 'dingtalk'
42
- ? '💡 提示:如需清除本对话的历史消息,请在钉钉中清空聊天记录'
43
- : platform === 'workbuddy'
44
- ? '💡 提示:如需清除本对话的历史消息,请清除聊天记录'
45
- : '💡 提示:如需清除本对话的历史消息,请点击 Telegram 聊天右上角 ⋮ → 清除历史';
46
- }
47
- async handleHelp(chatId, platform) {
36
+ async handleHelp(chatId) {
48
37
  const help = [
49
38
  '📋 可用命令:',
50
39
  '',
@@ -53,16 +42,14 @@ export class CommandHandler {
53
42
  '/status - 显示状态',
54
43
  '/cd <路径> - 切换工作目录',
55
44
  '/pwd - 当前工作目录',
56
- '',
57
- this.getClearHistoryHint(platform),
58
45
  ].join('\n');
59
46
  await this.deps.sender.sendTextReply(chatId, help);
60
47
  return true;
61
48
  }
62
- async handleNew(chatId, userId, platform) {
49
+ async handleNew(chatId, userId) {
63
50
  const ok = this.deps.sessionManager.newSession(userId);
64
51
  await this.deps.sender.sendTextReply(chatId, ok
65
- ? `✅ AI 会话已重置,下一条消息将使用全新上下文。\n\n${this.getClearHistoryHint(platform)}`
52
+ ? '✅ AI 会话已重置,下一条消息将使用全新上下文。'
66
53
  : '当前没有活动会话。');
67
54
  return true;
68
55
  }
@@ -103,8 +90,7 @@ export class CommandHandler {
103
90
  try {
104
91
  const resolved = await this.deps.sessionManager.setWorkDir(userId, dir);
105
92
  await this.deps.sender.sendTextReply(chatId, `📁 工作目录已切换到: ${escapePathForMarkdown(resolved)}\n\n` +
106
- `🔄 AI 会话已重置,下一条消息将使用全新上下文。\n` +
107
- this.getClearHistoryHint(platform));
93
+ `🔄 AI 会话已重置,下一条消息将使用全新上下文。`);
108
94
  }
109
95
  catch (err) {
110
96
  await this.deps.sender.sendTextReply(chatId, err instanceof Error ? err.message : String(err));
@@ -180,10 +180,20 @@ function clean(value) {
180
180
  const trimmed = value.trim();
181
181
  return trimmed ? trimmed : undefined;
182
182
  }
183
+ const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; // 1 MB
183
184
  function readJson(request) {
184
185
  return new Promise((resolve, reject) => {
185
186
  const chunks = [];
186
- request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
187
+ let totalBytes = 0;
188
+ request.on("data", (chunk) => {
189
+ totalBytes += chunk.length;
190
+ if (totalBytes > MAX_REQUEST_BODY_BYTES) {
191
+ reject(new Error("Request body too large (max 1 MB)"));
192
+ request.destroy();
193
+ return;
194
+ }
195
+ chunks.push(Buffer.from(chunk));
196
+ });
187
197
  request.on("end", () => {
188
198
  try {
189
199
  const raw = Buffer.concat(chunks).toString("utf-8");
@@ -200,6 +210,11 @@ function json(response, statusCode, body) {
200
210
  response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
201
211
  response.end(JSON.stringify(body));
202
212
  }
213
+ function maskSecret(value) {
214
+ if (!value || value.length <= 4)
215
+ return value ? "****" : "";
216
+ return value.slice(0, 2) + "****" + value.slice(-2);
217
+ }
203
218
  function buildInitialPayload(file) {
204
219
  // Load Claude settings from ~/.claude/settings.json
205
220
  const claudeEnv = loadClaudeSettingsEnv();
@@ -208,7 +223,7 @@ function buildInitialPayload(file) {
208
223
  telegram: {
209
224
  enabled: file.platforms?.telegram?.enabled ?? Boolean(file.platforms?.telegram?.botToken),
210
225
  aiCommand: file.platforms?.telegram?.aiCommand ?? "",
211
- botToken: file.platforms?.telegram?.botToken ?? "",
226
+ botToken: maskSecret(file.platforms?.telegram?.botToken),
212
227
  proxy: file.platforms?.telegram?.proxy ?? "",
213
228
  allowedUserIds: (file.platforms?.telegram?.allowedUserIds ?? []).join(", "),
214
229
  },
@@ -216,36 +231,36 @@ function buildInitialPayload(file) {
216
231
  enabled: file.platforms?.feishu?.enabled ?? Boolean(file.platforms?.feishu?.appId && file.platforms?.feishu?.appSecret),
217
232
  aiCommand: file.platforms?.feishu?.aiCommand ?? "",
218
233
  appId: file.platforms?.feishu?.appId ?? "",
219
- appSecret: file.platforms?.feishu?.appSecret ?? "",
234
+ appSecret: maskSecret(file.platforms?.feishu?.appSecret),
220
235
  allowedUserIds: (file.platforms?.feishu?.allowedUserIds ?? []).join(", "),
221
236
  },
222
237
  qq: {
223
238
  enabled: file.platforms?.qq?.enabled ?? Boolean(file.platforms?.qq?.appId && file.platforms?.qq?.secret),
224
239
  aiCommand: file.platforms?.qq?.aiCommand ?? "",
225
240
  appId: file.platforms?.qq?.appId ?? "",
226
- secret: file.platforms?.qq?.secret ?? "",
241
+ secret: maskSecret(file.platforms?.qq?.secret),
227
242
  allowedUserIds: (file.platforms?.qq?.allowedUserIds ?? []).join(", "),
228
243
  },
229
244
  wework: {
230
245
  enabled: file.platforms?.wework?.enabled ?? Boolean(file.platforms?.wework?.corpId && file.platforms?.wework?.secret),
231
246
  aiCommand: file.platforms?.wework?.aiCommand ?? "",
232
247
  corpId: file.platforms?.wework?.corpId ?? "",
233
- secret: file.platforms?.wework?.secret ?? "",
248
+ secret: maskSecret(file.platforms?.wework?.secret),
234
249
  allowedUserIds: (file.platforms?.wework?.allowedUserIds ?? []).join(", "),
235
250
  },
236
251
  dingtalk: {
237
252
  enabled: file.platforms?.dingtalk?.enabled ?? Boolean(file.platforms?.dingtalk?.clientId && file.platforms?.dingtalk?.clientSecret),
238
253
  aiCommand: file.platforms?.dingtalk?.aiCommand ?? "",
239
254
  clientId: file.platforms?.dingtalk?.clientId ?? "",
240
- clientSecret: file.platforms?.dingtalk?.clientSecret ?? "",
255
+ clientSecret: maskSecret(file.platforms?.dingtalk?.clientSecret),
241
256
  cardTemplateId: file.platforms?.dingtalk?.cardTemplateId ?? "",
242
257
  allowedUserIds: (file.platforms?.dingtalk?.allowedUserIds ?? []).join(", "),
243
258
  },
244
259
  workbuddy: {
245
260
  enabled: file.platforms?.workbuddy?.enabled ?? Boolean(file.platforms?.workbuddy?.accessToken && file.platforms?.workbuddy?.refreshToken && file.platforms?.workbuddy?.userId),
246
261
  aiCommand: file.platforms?.workbuddy?.aiCommand ?? "",
247
- accessToken: file.platforms?.workbuddy?.accessToken ?? "",
248
- refreshToken: file.platforms?.workbuddy?.refreshToken ?? "",
262
+ accessToken: maskSecret(file.platforms?.workbuddy?.accessToken),
263
+ refreshToken: maskSecret(file.platforms?.workbuddy?.refreshToken),
249
264
  userId: file.platforms?.workbuddy?.userId ?? "",
250
265
  baseUrl: file.platforms?.workbuddy?.baseUrl ?? "",
251
266
  allowedUserIds: (file.platforms?.workbuddy?.allowedUserIds ?? []).join(", "),
@@ -258,7 +273,7 @@ function buildInitialPayload(file) {
258
273
  claudeConfigPath: process.platform === 'win32'
259
274
  ? getClaudeConfigHome() + "\\.claude\\settings.json"
260
275
  : getClaudeConfigHome() + "/.claude/settings.json",
261
- claudeAuthToken: claudeEnv.ANTHROPIC_AUTH_TOKEN ?? "",
276
+ claudeAuthToken: maskSecret(claudeEnv.ANTHROPIC_AUTH_TOKEN),
262
277
  claudeBaseUrl: claudeEnv.ANTHROPIC_BASE_URL ?? "",
263
278
  claudeModel: claudeEnv.ANTHROPIC_MODEL ?? "",
264
279
  claudeProxy: file.tools?.claude?.proxy ?? "",
package/dist/config.d.ts CHANGED
@@ -64,6 +64,7 @@ export interface Config {
64
64
  wechat?: {
65
65
  enabled: boolean;
66
66
  aiCommand?: AiCommand;
67
+ loginMode?: 'qclaw' | 'workbuddy';
67
68
  wsUrl?: string;
68
69
  token?: string;
69
70
  jwtToken?: string;
@@ -71,6 +72,10 @@ export interface Config {
71
72
  guid?: string;
72
73
  userId?: string;
73
74
  allowedUserIds: string[];
75
+ workbuddyAccessToken?: string;
76
+ workbuddyRefreshToken?: string;
77
+ workbuddyBaseUrl?: string;
78
+ workbuddyHostId?: string;
74
79
  };
75
80
  wework?: {
76
81
  enabled: boolean;
@@ -122,6 +127,8 @@ interface FilePlatformWechat {
122
127
  appId?: string;
123
128
  appSecret?: string;
124
129
  aiCommand?: AiCommand;
130
+ /** 连接模式:qclaw(QClaw JPRX 网关)或 workbuddy(Centrifuge) */
131
+ loginMode?: 'qclaw' | 'workbuddy';
125
132
  token?: string;
126
133
  jwtToken?: string;
127
134
  loginKey?: string;
@@ -129,6 +136,10 @@ interface FilePlatformWechat {
129
136
  userId?: string;
130
137
  wsUrl?: string;
131
138
  allowedUserIds?: string[];
139
+ workbuddyAccessToken?: string;
140
+ workbuddyRefreshToken?: string;
141
+ workbuddyBaseUrl?: string;
142
+ workbuddyHostId?: string;
132
143
  }
133
144
  export interface FilePlatformWework {
134
145
  enabled?: boolean;
package/dist/config.js CHANGED
@@ -251,6 +251,7 @@ export function loadConfig() {
251
251
  // 微信支持两种协议:
252
252
  // 1. AGP 协议:token + guid + userId(推荐)
253
253
  // 2. 标准协议:appId + appSecret
254
+ const wechatLoginMode = fileWechat?.loginMode ?? 'qclaw';
254
255
  const wechatToken = process.env.WECHAT_TOKEN ??
255
256
  fileWechat?.token;
256
257
  const wechatJwtToken = fileWechat?.jwtToken;
@@ -265,6 +266,15 @@ export function loadConfig() {
265
266
  fileWechat?.appSecret;
266
267
  const wechatWsUrl = process.env.WECHAT_WS_URL ??
267
268
  fileWechat?.wsUrl;
269
+ // 微信 WorkBuddy 模式凭证(loginMode === 'workbuddy' 时使用)
270
+ const wechatWorkbuddyAccessToken = process.env.WECHAT_WORKBUDDY_ACCESS_TOKEN ??
271
+ fileWechat?.workbuddyAccessToken;
272
+ const wechatWorkbuddyRefreshToken = process.env.WECHAT_WORKBUDDY_REFRESH_TOKEN ??
273
+ fileWechat?.workbuddyRefreshToken;
274
+ const wechatWorkbuddyBaseUrl = process.env.WECHAT_WORKBUDDY_BASE_URL ??
275
+ fileWechat?.workbuddyBaseUrl;
276
+ const wechatWorkbuddyHostId = process.env.WECHAT_WORKBUDDY_HOST_ID ??
277
+ fileWechat?.workbuddyHostId;
268
278
  const weworkCorpId = process.env.WEWORK_CORP_ID ??
269
279
  fileWework?.corpId;
270
280
  const weworkSecret = process.env.WEWORK_SECRET ??
@@ -302,10 +312,15 @@ export function loadConfig() {
302
312
  const telegramEnabled = !!telegramBotToken && (telegramEnabledFlag !== false);
303
313
  const feishuEnabled = !!(feishuAppId && feishuAppSecret) && (feishuEnabledFlag !== false);
304
314
  const qqEnabled = !!(qqAppId && qqSecret) && (qqEnabledFlag !== false);
305
- // 微信启用条件:AGP 协议凭证 或 标准协议凭证
315
+ // 微信启用条件:
316
+ // - qclaw 模式:AGP 协议凭证(token + guid + userId)或 标准协议凭证(appId + appSecret)
317
+ // - workbuddy 模式:workbuddy OAuth 凭证
306
318
  const hasWechatAGPCreds = !!(wechatToken && wechatGuid && wechatUserId);
307
319
  const hasWechatStandardCreds = !!(wechatAppId && wechatAppSecret);
308
- const wechatEnabled = (hasWechatAGPCreds || hasWechatStandardCreds) && (wechatEnabledFlag !== false);
320
+ const hasWechatWorkbuddyCreds = !!(wechatWorkbuddyAccessToken && wechatWorkbuddyRefreshToken);
321
+ const wechatEnabled = (wechatLoginMode === 'workbuddy'
322
+ ? hasWechatWorkbuddyCreds
323
+ : (hasWechatAGPCreds || hasWechatStandardCreds)) && (wechatEnabledFlag !== false);
309
324
  // 企业微信只需要 corpId (botId) 和 secret
310
325
  const weworkEnabled = !!(weworkCorpId && weworkSecret) && (weworkEnabledFlag !== false);
311
326
  const dingtalkEnabled = !!(dingtalkClientId && dingtalkClientSecret) && (dingtalkEnabledFlag !== false);
@@ -558,6 +573,7 @@ export function loadConfig() {
558
573
  ? {
559
574
  enabled: true,
560
575
  aiCommand: normalizeAiCommand(file.platforms?.wechat?.aiCommand, aiCommand),
576
+ loginMode: wechatLoginMode,
561
577
  wsUrl: wechatWsUrl,
562
578
  token: wechatToken,
563
579
  jwtToken: wechatJwtToken,
@@ -565,10 +581,15 @@ export function loadConfig() {
565
581
  guid: wechatGuid,
566
582
  userId: wechatUserId,
567
583
  allowedUserIds: wechatAllowedUserIds,
584
+ workbuddyAccessToken: wechatWorkbuddyAccessToken,
585
+ workbuddyRefreshToken: wechatWorkbuddyRefreshToken,
586
+ workbuddyBaseUrl: wechatWorkbuddyBaseUrl,
587
+ workbuddyHostId: wechatWorkbuddyHostId,
568
588
  }
569
589
  : {
570
590
  enabled: false,
571
591
  aiCommand: normalizeAiCommand(file.platforms?.wechat?.aiCommand, aiCommand),
592
+ loginMode: wechatLoginMode,
572
593
  wsUrl: wechatWsUrl,
573
594
  token: wechatToken,
574
595
  jwtToken: wechatJwtToken,
@@ -576,6 +597,10 @@ export function loadConfig() {
576
597
  guid: wechatGuid,
577
598
  userId: wechatUserId,
578
599
  allowedUserIds: wechatAllowedUserIds,
600
+ workbuddyAccessToken: wechatWorkbuddyAccessToken,
601
+ workbuddyRefreshToken: wechatWorkbuddyRefreshToken,
602
+ workbuddyBaseUrl: wechatWorkbuddyBaseUrl,
603
+ workbuddyHostId: wechatWorkbuddyHostId,
579
604
  },
580
605
  wework: weworkEnabled
581
606
  ? {
@@ -99,7 +99,8 @@ async function buildMediaPrompt(message, kind, robotCodeFallback) {
99
99
  fallbackExtension: kind === 'image' ? 'jpg' : 'bin',
100
100
  });
101
101
  }
102
- catch {
102
+ catch (err) {
103
+ log.warn('Failed to download DingTalk media from URL:', err);
103
104
  localPath = undefined;
104
105
  }
105
106
  }
@@ -118,7 +119,8 @@ async function buildMediaPrompt(message, kind, robotCodeFallback) {
118
119
  localPath = await saveBufferMedia(downloaded.buffer, extension, basenameHint);
119
120
  }
120
121
  }
121
- catch {
122
+ catch (err) {
123
+ log.warn('Failed to download DingTalk media via robotCode:', err);
122
124
  localPath = undefined;
123
125
  }
124
126
  }
@@ -194,7 +196,9 @@ export function setupDingTalkHandlers(config, sessionManager) {
194
196
  try {
195
197
  await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
196
198
  }
197
- catch { /* ignore */ }
199
+ catch (err) {
200
+ log.warn('Failed to send startup error reply:', err);
201
+ }
198
202
  return;
199
203
  }
200
204
  const stopTyping = startTypingLoop(chatId);
@@ -24,6 +24,19 @@ const FLOW_STATUS = {
24
24
  };
25
25
  let senderSettings = {};
26
26
  const streamStates = new Map();
27
+ // Periodic cleanup of orphaned stream states (max 30 minutes)
28
+ const STREAM_MAX_AGE_MS = 30 * 60 * 1000;
29
+ setInterval(() => {
30
+ const now = Date.now();
31
+ for (const [id, state] of streamStates) {
32
+ // streamStates in DingTalk don't have createdAt, clean up by size
33
+ if (streamStates.size > 50) {
34
+ streamStates.delete(id);
35
+ log.info(`Cleaned up old DingTalk stream state: ${id}`);
36
+ break; // Clean one at a time to avoid blocking
37
+ }
38
+ }
39
+ }, STREAM_MAX_AGE_MS);
27
40
  function generateMessageId() {
28
41
  return `${Date.now()}-${randomBytes(6).toString('hex')}`;
29
42
  }