@wu529778790/open-im 1.8.1-beta.9 → 1.8.2

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.
@@ -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';
@@ -103,15 +130,8 @@ export class ClaudeSDKAdapter {
103
130
  const abortController = new AbortController();
104
131
  let streamClosed = false;
105
132
  let actualSessionId;
133
+ let pendingTempId; // 记录临时 ID,用于 abort 时清理
106
134
  let runSettled = false;
107
- let timeoutId = null;
108
- const timeoutMs = options?.timeoutMs ?? 600_000;
109
- const clearRunTimeout = () => {
110
- if (timeoutId !== null) {
111
- clearTimeout(timeoutId);
112
- timeoutId = null;
113
- }
114
- };
115
135
  const permissionMode = options?.skipPermissions
116
136
  ? 'bypassPermissions'
117
137
  : options?.permissionMode === 'acceptEdits'
@@ -120,15 +140,6 @@ export class ClaudeSDKAdapter {
120
140
  ? 'plan'
121
141
  : 'default';
122
142
  const runSession = async () => {
123
- timeoutId = setTimeout(() => {
124
- if (runSettled)
125
- return;
126
- runSettled = true;
127
- clearRunTimeout();
128
- log.warn(`[ClaudeSDK] Request timeout after ${timeoutMs}ms`);
129
- abortController.abort();
130
- callbacks.onError(`请求超时(${Math.round(timeoutMs / 1000)}s),请重试或缩短问题。`);
131
- }, timeoutMs);
132
143
  try {
133
144
  // 检查环境变量
134
145
  const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
@@ -139,7 +150,10 @@ export class ClaudeSDKAdapter {
139
150
  log.info(`[V2] Session: ${sessionId ?? 'new'}, prompt="${prompt.slice(0, 50)}..."`);
140
151
  log.info(`[V2] model param=${String(options?.model ?? '')} baseUrl=${process.env.ANTHROPIC_BASE_URL ?? '(default)'}`);
141
152
  // 获取或创建会话
142
- const { session } = await getOrCreateSession(sessionId, workDir, options?.model, permissionMode);
153
+ const { session, sessionId: returnedId } = await getOrCreateSession(sessionId, workDir, options?.model, permissionMode);
154
+ if (returnedId.startsWith('pending-')) {
155
+ pendingTempId = returnedId;
156
+ }
143
157
  // 发送用户消息
144
158
  await session.send(prompt);
145
159
  // 获取响应流
@@ -205,7 +219,6 @@ export class ClaudeSDKAdapter {
205
219
  // 检查会话错误
206
220
  if (!success) {
207
221
  runSettled = true;
208
- clearRunTimeout();
209
222
  const noConvErr = errs.find((e) => e.includes('No conversation found') || e.includes('session not found'));
210
223
  if (noConvErr) {
211
224
  log.warn(`Session ${actualSessionId} not found, may need to create new one`);
@@ -233,25 +246,31 @@ export class ClaudeSDKAdapter {
233
246
  result.result = accumulated;
234
247
  }
235
248
  runSettled = true;
236
- clearRunTimeout();
237
249
  callbacks.onComplete(result);
238
250
  return;
239
251
  }
240
252
  }
241
253
  // 如果流正常结束但没有收到 result 消息
242
- if (!streamClosed && accumulated) {
243
- log.info('Stream ended without result message, using accumulated text');
244
- runSettled = true;
245
- clearRunTimeout();
246
- callbacks.onComplete({
247
- success: true,
248
- result: accumulated,
249
- accumulated,
250
- cost: 0,
251
- durationMs: 0,
252
- numTurns: 1,
253
- toolStats,
254
- });
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
+ }
255
274
  }
256
275
  }
257
276
  finally {
@@ -262,16 +281,15 @@ export class ClaudeSDKAdapter {
262
281
  catch (err) {
263
282
  if (abortController.signal.aborted) {
264
283
  log.info('Session run aborted');
265
- clearRunTimeout();
266
- // 清理 pending tempId
267
- if (actualSessionId?.startsWith('pending-')) {
268
- activeSessions.delete(actualSessionId);
269
- log.info(`Cleaned up pending session: ${actualSessionId}`);
284
+ // 清理 pending tempId(abort 可能在 init 消息之前发生)
285
+ const idToClean = actualSessionId ?? pendingTempId;
286
+ if (idToClean?.startsWith('pending-')) {
287
+ activeSessions.delete(idToClean);
288
+ log.info(`Cleaned up pending session: ${idToClean}`);
270
289
  }
271
290
  return;
272
291
  }
273
292
  runSettled = true;
274
- clearRunTimeout();
275
293
  const errorObj = err;
276
294
  const msg = errorObj.message || String(err);
277
295
  log.error(`Claude SDK V2 error: ${msg}`);
@@ -279,9 +297,10 @@ export class ClaudeSDKAdapter {
279
297
  log.error(`Error stack: ${errorObj.stack}`);
280
298
  }
281
299
  // 清理 pending tempId(session 在获取真实 ID 前就失败了)
282
- if (actualSessionId?.startsWith('pending-')) {
283
- activeSessions.delete(actualSessionId);
284
- log.info(`Cleaned up pending session after error: ${actualSessionId}`);
300
+ const errIdToClean = actualSessionId ?? pendingTempId;
301
+ if (errIdToClean?.startsWith('pending-')) {
302
+ activeSessions.delete(errIdToClean);
303
+ log.info(`Cleaned up pending session after error: ${errIdToClean}`);
285
304
  }
286
305
  callbacks.onError(msg);
287
306
  }
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));
@@ -117,6 +117,7 @@ export function getHealthPlatformSnapshot(file, env = process.env) {
117
117
  const fileQQ = file.platforms?.qq;
118
118
  const fileWework = file.platforms?.wework;
119
119
  const fileDingtalk = file.platforms?.dingtalk;
120
+ const fileWorkbuddy = file.platforms?.workbuddy;
120
121
  const telegramBotToken = env.TELEGRAM_BOT_TOKEN ?? fileTelegram?.botToken ?? file.telegramBotToken;
121
122
  const feishuAppId = env.FEISHU_APP_ID ?? fileFeishu?.appId ?? file.feishuAppId;
122
123
  const feishuAppSecret = env.FEISHU_APP_SECRET ?? fileFeishu?.appSecret ?? file.feishuAppSecret;
@@ -126,6 +127,9 @@ export function getHealthPlatformSnapshot(file, env = process.env) {
126
127
  const weworkSecret = env.WEWORK_SECRET ?? fileWework?.secret;
127
128
  const dingtalkClientId = env.DINGTALK_CLIENT_ID ?? fileDingtalk?.clientId;
128
129
  const dingtalkClientSecret = env.DINGTALK_CLIENT_SECRET ?? fileDingtalk?.clientSecret;
130
+ const workbuddyAccessToken = fileWorkbuddy?.accessToken;
131
+ const workbuddyRefreshToken = fileWorkbuddy?.refreshToken;
132
+ const workbuddyUserId = fileWorkbuddy?.userId;
129
133
  return {
130
134
  telegram: {
131
135
  configured: !!telegramBotToken,
@@ -157,6 +161,12 @@ export function getHealthPlatformSnapshot(file, env = process.env) {
157
161
  healthy: !!(dingtalkClientId && dingtalkClientSecret),
158
162
  message: dingtalkClientId && dingtalkClientSecret ? "Client ID and Secret configured" : "Missing credentials",
159
163
  },
164
+ workbuddy: {
165
+ configured: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
166
+ enabled: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) && fileWorkbuddy?.enabled !== false,
167
+ healthy: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
168
+ message: workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId ? "OAuth credentials configured" : "Missing credentials",
169
+ },
160
170
  };
161
171
  }
162
172
  function splitCsv(value) {
@@ -170,10 +180,20 @@ function clean(value) {
170
180
  const trimmed = value.trim();
171
181
  return trimmed ? trimmed : undefined;
172
182
  }
183
+ const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; // 1 MB
173
184
  function readJson(request) {
174
185
  return new Promise((resolve, reject) => {
175
186
  const chunks = [];
176
- 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
+ });
177
197
  request.on("end", () => {
178
198
  try {
179
199
  const raw = Buffer.concat(chunks).toString("utf-8");
@@ -190,6 +210,11 @@ function json(response, statusCode, body) {
190
210
  response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
191
211
  response.end(JSON.stringify(body));
192
212
  }
213
+ function maskSecret(value) {
214
+ if (!value || value.length <= 4)
215
+ return value ? "****" : "";
216
+ return value.slice(0, 2) + "****" + value.slice(-2);
217
+ }
193
218
  function buildInitialPayload(file) {
194
219
  // Load Claude settings from ~/.claude/settings.json
195
220
  const claudeEnv = loadClaudeSettingsEnv();
@@ -198,7 +223,7 @@ function buildInitialPayload(file) {
198
223
  telegram: {
199
224
  enabled: file.platforms?.telegram?.enabled ?? Boolean(file.platforms?.telegram?.botToken),
200
225
  aiCommand: file.platforms?.telegram?.aiCommand ?? "",
201
- botToken: file.platforms?.telegram?.botToken ?? "",
226
+ botToken: maskSecret(file.platforms?.telegram?.botToken),
202
227
  proxy: file.platforms?.telegram?.proxy ?? "",
203
228
  allowedUserIds: (file.platforms?.telegram?.allowedUserIds ?? []).join(", "),
204
229
  },
@@ -206,31 +231,40 @@ function buildInitialPayload(file) {
206
231
  enabled: file.platforms?.feishu?.enabled ?? Boolean(file.platforms?.feishu?.appId && file.platforms?.feishu?.appSecret),
207
232
  aiCommand: file.platforms?.feishu?.aiCommand ?? "",
208
233
  appId: file.platforms?.feishu?.appId ?? "",
209
- appSecret: file.platforms?.feishu?.appSecret ?? "",
234
+ appSecret: maskSecret(file.platforms?.feishu?.appSecret),
210
235
  allowedUserIds: (file.platforms?.feishu?.allowedUserIds ?? []).join(", "),
211
236
  },
212
237
  qq: {
213
238
  enabled: file.platforms?.qq?.enabled ?? Boolean(file.platforms?.qq?.appId && file.platforms?.qq?.secret),
214
239
  aiCommand: file.platforms?.qq?.aiCommand ?? "",
215
240
  appId: file.platforms?.qq?.appId ?? "",
216
- secret: file.platforms?.qq?.secret ?? "",
241
+ secret: maskSecret(file.platforms?.qq?.secret),
217
242
  allowedUserIds: (file.platforms?.qq?.allowedUserIds ?? []).join(", "),
218
243
  },
219
244
  wework: {
220
245
  enabled: file.platforms?.wework?.enabled ?? Boolean(file.platforms?.wework?.corpId && file.platforms?.wework?.secret),
221
246
  aiCommand: file.platforms?.wework?.aiCommand ?? "",
222
247
  corpId: file.platforms?.wework?.corpId ?? "",
223
- secret: file.platforms?.wework?.secret ?? "",
248
+ secret: maskSecret(file.platforms?.wework?.secret),
224
249
  allowedUserIds: (file.platforms?.wework?.allowedUserIds ?? []).join(", "),
225
250
  },
226
251
  dingtalk: {
227
252
  enabled: file.platforms?.dingtalk?.enabled ?? Boolean(file.platforms?.dingtalk?.clientId && file.platforms?.dingtalk?.clientSecret),
228
253
  aiCommand: file.platforms?.dingtalk?.aiCommand ?? "",
229
254
  clientId: file.platforms?.dingtalk?.clientId ?? "",
230
- clientSecret: file.platforms?.dingtalk?.clientSecret ?? "",
255
+ clientSecret: maskSecret(file.platforms?.dingtalk?.clientSecret),
231
256
  cardTemplateId: file.platforms?.dingtalk?.cardTemplateId ?? "",
232
257
  allowedUserIds: (file.platforms?.dingtalk?.allowedUserIds ?? []).join(", "),
233
258
  },
259
+ workbuddy: {
260
+ enabled: file.platforms?.workbuddy?.enabled ?? Boolean(file.platforms?.workbuddy?.accessToken && file.platforms?.workbuddy?.refreshToken && file.platforms?.workbuddy?.userId),
261
+ aiCommand: file.platforms?.workbuddy?.aiCommand ?? "",
262
+ accessToken: maskSecret(file.platforms?.workbuddy?.accessToken),
263
+ refreshToken: maskSecret(file.platforms?.workbuddy?.refreshToken),
264
+ userId: file.platforms?.workbuddy?.userId ?? "",
265
+ baseUrl: file.platforms?.workbuddy?.baseUrl ?? "",
266
+ allowedUserIds: (file.platforms?.workbuddy?.allowedUserIds ?? []).join(", "),
267
+ },
234
268
  },
235
269
  ai: {
236
270
  aiCommand: file.aiCommand ?? "claude",
@@ -239,7 +273,7 @@ function buildInitialPayload(file) {
239
273
  claudeConfigPath: process.platform === 'win32'
240
274
  ? getClaudeConfigHome() + "\\.claude\\settings.json"
241
275
  : getClaudeConfigHome() + "/.claude/settings.json",
242
- claudeAuthToken: claudeEnv.ANTHROPIC_AUTH_TOKEN ?? "",
276
+ claudeAuthToken: maskSecret(claudeEnv.ANTHROPIC_AUTH_TOKEN),
243
277
  claudeBaseUrl: claudeEnv.ANTHROPIC_BASE_URL ?? "",
244
278
  claudeModel: claudeEnv.ANTHROPIC_MODEL ?? "",
245
279
  claudeProxy: file.tools?.claude?.proxy ?? "",
@@ -276,6 +310,12 @@ function validatePayload(payload) {
276
310
  errors.push("DingTalk client ID is required.");
277
311
  if (payload.platforms.dingtalk.enabled && !clean(payload.platforms.dingtalk.clientSecret))
278
312
  errors.push("DingTalk client secret is required.");
313
+ if (payload.platforms.workbuddy.enabled && !clean(payload.platforms.workbuddy.accessToken))
314
+ errors.push("WorkBuddy access token is required.");
315
+ if (payload.platforms.workbuddy.enabled && !clean(payload.platforms.workbuddy.refreshToken))
316
+ errors.push("WorkBuddy refresh token is required.");
317
+ if (payload.platforms.workbuddy.enabled && !clean(payload.platforms.workbuddy.userId))
318
+ errors.push("WorkBuddy user ID is required.");
279
319
  if (!clean(payload.ai.claudeWorkDir))
280
320
  errors.push("Default work directory is required.");
281
321
  if (!Number.isFinite(payload.ai.claudeTimeoutMs) || payload.ai.claudeTimeoutMs <= 0)
@@ -330,6 +370,17 @@ function validateConfigForPlatform(platform, config) {
330
370
  errors.push("DingTalk client secret is required and must be a non-empty string.");
331
371
  }
332
372
  break;
373
+ case "workbuddy":
374
+ if (!c.accessToken || typeof c.accessToken !== "string" || !clean(c.accessToken)) {
375
+ errors.push("WorkBuddy access token is required and must be a non-empty string.");
376
+ }
377
+ if (!c.refreshToken || typeof c.refreshToken !== "string" || !clean(c.refreshToken)) {
378
+ errors.push("WorkBuddy refresh token is required and must be a non-empty string.");
379
+ }
380
+ if (!c.userId || typeof c.userId !== "string" || !clean(c.userId)) {
381
+ errors.push("WorkBuddy user ID is required and must be a non-empty string.");
382
+ }
383
+ break;
333
384
  default:
334
385
  errors.push(`Unknown platform: ${platform}`);
335
386
  }
@@ -455,6 +506,34 @@ async function probeDingTalk(config) {
455
506
  }
456
507
  return "DingTalk credentials are valid.";
457
508
  }
509
+ async function probeWorkBuddy(config) {
510
+ const accessToken = clean(String(config.accessToken ?? ""));
511
+ const refreshToken = clean(String(config.refreshToken ?? ""));
512
+ const userId = clean(String(config.userId ?? ""));
513
+ if (!accessToken || !refreshToken || !userId)
514
+ throw new Error("WorkBuddy access token, refresh token, and user ID are required.");
515
+ const baseUrl = clean(String(config.baseUrl ?? "")) || "https://copilot.tencent.com";
516
+ // Validate credentials by attempting to register workspace
517
+ const response = await fetch(`${baseUrl}/api/copilot/workspace/register`, {
518
+ method: "POST",
519
+ headers: {
520
+ "content-type": "application/json",
521
+ "authorization": `Bearer ${accessToken}`,
522
+ },
523
+ body: JSON.stringify({
524
+ userId,
525
+ hostId: "open-im-test",
526
+ workspaceId: "open-im-test-workspace",
527
+ workspaceName: "OpenIM Test Workspace",
528
+ }),
529
+ signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
530
+ });
531
+ if (!response.ok) {
532
+ const body = await response.text();
533
+ throw new Error(`WorkBuddy authentication failed: ${body.slice(0, 200) || `HTTP ${response.status}`}`);
534
+ }
535
+ return "WorkBuddy credentials are valid.";
536
+ }
458
537
  export async function testPlatformConfig(platform, config) {
459
538
  const errors = validateConfigForPlatform(platform, config);
460
539
  if (errors.length > 0) {
@@ -471,6 +550,8 @@ export async function testPlatformConfig(platform, config) {
471
550
  return probeWeWork(config);
472
551
  case "dingtalk":
473
552
  return probeDingTalk(config);
553
+ case "workbuddy":
554
+ return probeWorkBuddy(config);
474
555
  default:
475
556
  throw new Error(`Unknown platform: ${platform}`);
476
557
  }
@@ -557,6 +638,16 @@ function toFileConfig(payload, existing) {
557
638
  cardTemplateId: clean(payload.platforms.dingtalk.cardTemplateId),
558
639
  allowedUserIds: splitCsv(payload.platforms.dingtalk.allowedUserIds),
559
640
  },
641
+ workbuddy: {
642
+ ...existing.platforms?.workbuddy,
643
+ enabled: payload.platforms.workbuddy.enabled,
644
+ aiCommand: clean(payload.platforms.workbuddy.aiCommand),
645
+ accessToken: clean(payload.platforms.workbuddy.accessToken),
646
+ refreshToken: clean(payload.platforms.workbuddy.refreshToken),
647
+ userId: clean(payload.platforms.workbuddy.userId),
648
+ baseUrl: clean(payload.platforms.workbuddy.baseUrl),
649
+ allowedUserIds: splitCsv(payload.platforms.workbuddy.allowedUserIds),
650
+ },
560
651
  },
561
652
  };
562
653
  }
@@ -812,6 +903,7 @@ export async function startWebConfigServer(options) {
812
903
  const fileQQ = file.platforms?.qq;
813
904
  const fileWework = file.platforms?.wework;
814
905
  const fileDingtalk = file.platforms?.dingtalk;
906
+ const fileWorkbuddy = file.platforms?.workbuddy;
815
907
  const telegramBotToken = process.env.TELEGRAM_BOT_TOKEN ?? fileTelegram?.botToken ?? file.telegramBotToken;
816
908
  const feishuAppId = process.env.FEISHU_APP_ID ?? fileFeishu?.appId ?? file.feishuAppId;
817
909
  const feishuAppSecret = process.env.FEISHU_APP_SECRET ?? fileFeishu?.appSecret ?? file.feishuAppSecret;
@@ -821,6 +913,9 @@ export async function startWebConfigServer(options) {
821
913
  const weworkSecret = process.env.WEWORK_SECRET ?? fileWework?.secret;
822
914
  const dingtalkClientId = process.env.DINGTALK_CLIENT_ID ?? fileDingtalk?.clientId;
823
915
  const dingtalkClientSecret = process.env.DINGTALK_CLIENT_SECRET ?? fileDingtalk?.clientSecret;
916
+ const workbuddyAccessToken = fileWorkbuddy?.accessToken;
917
+ const workbuddyRefreshToken = fileWorkbuddy?.refreshToken;
918
+ const workbuddyUserId = fileWorkbuddy?.userId;
824
919
  const platforms = {};
825
920
  // 检查 Telegram
826
921
  platforms.telegram = {
@@ -857,6 +952,13 @@ export async function startWebConfigServer(options) {
857
952
  healthy: !!(dingtalkClientId && dingtalkClientSecret),
858
953
  message: (dingtalkClientId && dingtalkClientSecret) ? "Client ID and Secret configured" : "Missing credentials"
859
954
  };
955
+ // 检查 WorkBuddy
956
+ platforms.workbuddy = {
957
+ configured: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
958
+ enabled: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) && fileWorkbuddy?.enabled !== false,
959
+ healthy: !!(workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId),
960
+ message: (workbuddyAccessToken && workbuddyRefreshToken && workbuddyUserId) ? "OAuth credentials configured" : "Missing credentials"
961
+ };
860
962
  json(response, 200, { platforms, serviceStatus: getServiceStatus() });
861
963
  return;
862
964
  }
@@ -7,7 +7,9 @@ const TEXT_MSG_KEY = 'sampleText';
7
7
  const DINGTALK_STREAM_HOST = 'wss-open-connection.dingtalk.com';
8
8
  let client = null;
9
9
  let messageHandler = null;
10
+ // sessionWebhook 有过期时间(约 2 小时),需要记录时间戳
10
11
  const sessionWebhookByChat = new Map();
12
+ const WEBHOOK_TTL_MS = 90 * 60 * 1000; // 90 分钟后视为过期
11
13
  const unionIdByUserId = new Map();
12
14
  let dingtalkWarnFilterInstalled = false;
13
15
  export function shouldSuppressDingTalkSocketWarn(args) {
@@ -44,13 +46,19 @@ function getClient() {
44
46
  export function registerSessionWebhook(chatId, sessionWebhook) {
45
47
  if (!chatId || !sessionWebhook)
46
48
  return;
47
- sessionWebhookByChat.set(chatId, sessionWebhook);
49
+ sessionWebhookByChat.set(chatId, { webhook: sessionWebhook, registeredAt: Date.now() });
48
50
  }
49
51
  async function sendByWebhook(chatId, body) {
50
- const sessionWebhook = sessionWebhookByChat.get(chatId);
51
- if (!sessionWebhook) {
52
+ const entry = sessionWebhookByChat.get(chatId);
53
+ if (!entry) {
52
54
  throw new Error(`DingTalk sessionWebhook unavailable for chat ${chatId}`);
53
55
  }
56
+ // 检查 webhook 是否过期
57
+ if (Date.now() - entry.registeredAt > WEBHOOK_TTL_MS) {
58
+ sessionWebhookByChat.delete(chatId);
59
+ throw new Error(`DingTalk sessionWebhook expired for chat ${chatId}`);
60
+ }
61
+ const sessionWebhook = entry.webhook;
54
62
  const accessToken = await getClient().getAccessToken();
55
63
  const res = await fetch(sessionWebhook, {
56
64
  method: 'POST',
@@ -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);