@wu529778790/open-im 1.9.3-beta.4 → 1.9.3-beta.6

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,5 +15,10 @@ export declare class ClaudeSDKAdapter implements ToolAdapter {
15
15
  * 清理所有活跃的 SDK 会话和流
16
16
  */
17
17
  static destroy(): void;
18
+ /**
19
+ * Remove a specific session from the in-memory cache and close it.
20
+ * Useful when the caller knows a session is corrupted.
21
+ */
22
+ static removeSession(sessionId: string): void;
18
23
  run(prompt: string, sessionId: string | undefined, workDir: string, callbacks: RunCallbacks, options?: RunOptions): RunHandle;
19
24
  }
@@ -38,6 +38,30 @@ const cleanupInterval = setInterval(() => {
38
38
  }
39
39
  }, CLEANUP_INTERVAL_MS);
40
40
  cleanupInterval.unref(); // 不阻止进程退出
41
+ // Lazy cleanup: check idle sessions periodically during getOrCreateSession calls
42
+ let lazyCleanupCounter = 0;
43
+ const LAZY_CLEANUP_INTERVAL = 10;
44
+ let sessionSeq = 0;
45
+ function lazyCleanupIdleSessions() {
46
+ lazyCleanupCounter++;
47
+ if (lazyCleanupCounter % LAZY_CLEANUP_INTERVAL !== 0)
48
+ return;
49
+ const now = Date.now();
50
+ for (const [id, lastUsed] of sessionLastUsed) {
51
+ if (now - lastUsed > SESSION_IDLE_TTL_MS) {
52
+ const s = activeSessions.get(id);
53
+ if (s) {
54
+ try {
55
+ s.close();
56
+ }
57
+ catch { /* ignore */ }
58
+ activeSessions.delete(id);
59
+ }
60
+ sessionLastUsed.delete(id);
61
+ log.info(`Lazy cleanup: idle session ${id} (unused ${Math.round((now - lastUsed) / 60000)}min)`);
62
+ }
63
+ }
64
+ }
41
65
  // Mutex to serialize process.chdir() calls across concurrent users
42
66
  let chdirMutex = Promise.resolve();
43
67
  function withChdirMutex(fn) {
@@ -66,6 +90,9 @@ function isAssistant(msg) {
66
90
  function isResult(msg) {
67
91
  return msg.type === 'result';
68
92
  }
93
+ function isSessionCorruptionError(msg) {
94
+ return /session\s*(not found|expired|corrupt)|no\s*conversation\s*found/i.test(msg);
95
+ }
69
96
  /**
70
97
  * 获取或创建 SDKSession
71
98
  * @param sessionId 已有的 sessionId,如果为 undefined 则创建新会话
@@ -75,6 +102,7 @@ function isResult(msg) {
75
102
  * @returns SDKSession 对象和实际的 sessionId
76
103
  */
77
104
  async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
105
+ lazyCleanupIdleSessions();
78
106
  const resolvedModel = model?.trim() || 'claude-opus-4-5';
79
107
  const sessionOptions = {
80
108
  model: resolvedModel,
@@ -118,7 +146,7 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
118
146
  session = unstable_v2_createSession(sessionOptions);
119
147
  // 新会话的 sessionId 需要从第一个消息中获取
120
148
  // 暂时返回 undefined,稍后在 init 消息中获取
121
- const tempId = `pending-${Date.now()}`;
149
+ const tempId = `pending-${++sessionSeq}`;
122
150
  activeSessions.set(tempId, session);
123
151
  sessionLastUsed.set(tempId, Date.now());
124
152
  log.info(`Created new session (tempId: ${tempId})`);
@@ -160,6 +188,22 @@ export class ClaudeSDKAdapter {
160
188
  activeSessions.clear();
161
189
  sessionLastUsed.clear();
162
190
  }
191
+ /**
192
+ * Remove a specific session from the in-memory cache and close it.
193
+ * Useful when the caller knows a session is corrupted.
194
+ */
195
+ static removeSession(sessionId) {
196
+ const session = activeSessions.get(sessionId);
197
+ if (session) {
198
+ try {
199
+ session.close();
200
+ }
201
+ catch { /* ignore */ }
202
+ activeSessions.delete(sessionId);
203
+ sessionLastUsed.delete(sessionId);
204
+ log.info(`Explicitly removed session: ${sessionId}`);
205
+ }
206
+ }
163
207
  run(prompt, sessionId, workDir, callbacks, options) {
164
208
  log.info(`[V2] run() entry model=${String(options?.model ?? '')} baseUrl=${process.env.ANTHROPIC_BASE_URL ?? '(default)'}`);
165
209
  const abortController = new AbortController();
@@ -167,6 +211,8 @@ export class ClaudeSDKAdapter {
167
211
  let actualSessionId;
168
212
  let pendingTempId; // 记录临时 ID,用于 abort 时清理
169
213
  let runSettled = false;
214
+ let currentStream; // 用于 abort 时立即中断 stream
215
+ let timeoutHandle;
170
216
  const permissionMode = options?.skipPermissions
171
217
  ? 'bypassPermissions'
172
218
  : options?.permissionMode === 'acceptEdits'
@@ -193,6 +239,7 @@ export class ClaudeSDKAdapter {
193
239
  await session.send(prompt);
194
240
  // 获取响应流
195
241
  const stream = session.stream();
242
+ currentStream = stream;
196
243
  activeStreams.add(stream);
197
244
  let accumulated = '';
198
245
  let accumulatedThinking = '';
@@ -258,10 +305,20 @@ export class ClaudeSDKAdapter {
258
305
  log.info(`[V2] Result: subtype=${m.subtype}, num_turns=${m.num_turns}, sessionId=${actualSessionId ?? 'unknown'}`);
259
306
  // 检查会话错误
260
307
  if (!success) {
308
+ if (timeoutHandle)
309
+ clearTimeout(timeoutHandle);
261
310
  runSettled = true;
262
311
  const noConvErr = errs.find((e) => e.includes('No conversation found') || e.includes('session not found'));
263
312
  if (noConvErr) {
264
- log.warn(`Session ${actualSessionId} not found, may need to create new one`);
313
+ log.warn(`Session ${actualSessionId} not found, removing from active sessions`);
314
+ if (actualSessionId) {
315
+ activeSessions.delete(actualSessionId);
316
+ sessionLastUsed.delete(actualSessionId);
317
+ try {
318
+ session.close();
319
+ }
320
+ catch { /* ignore */ }
321
+ }
265
322
  callbacks.onSessionInvalid?.();
266
323
  }
267
324
  const errMsg = errs[0] || '未知错误';
@@ -286,6 +343,8 @@ export class ClaudeSDKAdapter {
286
343
  result.result = accumulated;
287
344
  }
288
345
  runSettled = true;
346
+ if (timeoutHandle)
347
+ clearTimeout(timeoutHandle);
289
348
  callbacks.onComplete(result);
290
349
  return;
291
350
  }
@@ -294,6 +353,8 @@ export class ClaudeSDKAdapter {
294
353
  if (!streamClosed) {
295
354
  if (accumulated) {
296
355
  log.info('Stream ended without result message, using accumulated text');
356
+ if (timeoutHandle)
357
+ clearTimeout(timeoutHandle);
297
358
  runSettled = true;
298
359
  callbacks.onComplete({
299
360
  success: true,
@@ -308,6 +369,8 @@ export class ClaudeSDKAdapter {
308
369
  else {
309
370
  // 流结束但无 result 也无 accumulated:必须触发回调,否则 Promise 永远挂起
310
371
  log.warn('Stream ended with no result and no accumulated text, calling onError to prevent stuck state');
372
+ if (timeoutHandle)
373
+ clearTimeout(timeoutHandle);
311
374
  runSettled = true;
312
375
  callbacks.onError('AI 响应异常结束(无输出),请重试');
313
376
  }
@@ -330,6 +393,8 @@ export class ClaudeSDKAdapter {
330
393
  return;
331
394
  }
332
395
  runSettled = true;
396
+ if (timeoutHandle)
397
+ clearTimeout(timeoutHandle);
333
398
  const errorObj = err;
334
399
  const msg = errorObj.message || String(err);
335
400
  log.error(`Claude SDK V2 error: ${msg}`);
@@ -342,6 +407,20 @@ export class ClaudeSDKAdapter {
342
407
  activeSessions.delete(errIdToClean);
343
408
  log.info(`Cleaned up pending session after error: ${errIdToClean}`);
344
409
  }
410
+ // If error suggests a corrupted session, remove it from cache to prevent reuse
411
+ if (actualSessionId && isSessionCorruptionError(msg)) {
412
+ const corrupted = activeSessions.get(actualSessionId);
413
+ activeSessions.delete(actualSessionId);
414
+ sessionLastUsed.delete(actualSessionId);
415
+ if (corrupted) {
416
+ try {
417
+ corrupted.close();
418
+ }
419
+ catch { /* ignore */ }
420
+ }
421
+ log.warn(`Removed corrupted session ${actualSessionId} after error: ${msg}`);
422
+ callbacks.onSessionInvalid?.();
423
+ }
345
424
  callbacks.onError(msg);
346
425
  }
347
426
  };
@@ -349,15 +428,45 @@ export class ClaudeSDKAdapter {
349
428
  runSession().catch((err) => {
350
429
  if (!runSettled) {
351
430
  runSettled = true;
431
+ if (timeoutHandle)
432
+ clearTimeout(timeoutHandle);
352
433
  const msg = err instanceof Error ? err.message : String(err);
353
434
  log.error(`Unhandled runSession error: ${msg}`);
354
435
  callbacks.onError(msg);
355
436
  }
356
437
  });
438
+ // 强制执行超时
439
+ if (options?.timeoutMs && options.timeoutMs > 0) {
440
+ timeoutHandle = setTimeout(() => {
441
+ if (!runSettled) {
442
+ log.warn(`Session timed out after ${options.timeoutMs}ms, aborting`);
443
+ abortController.abort();
444
+ // 立即中断 stream,不等下一条消息
445
+ if (currentStream) {
446
+ try {
447
+ currentStream.return?.();
448
+ }
449
+ catch { /* ignore */ }
450
+ }
451
+ runSettled = true;
452
+ callbacks.onError(`AI 响应超时(${Math.round(options.timeoutMs / 1000)}s),请重试`);
453
+ }
454
+ }, options.timeoutMs);
455
+ timeoutHandle.unref();
456
+ }
357
457
  return {
358
458
  abort: () => {
359
459
  log.info('Aborting session run');
360
460
  abortController.abort();
461
+ if (timeoutHandle)
462
+ clearTimeout(timeoutHandle);
463
+ // 立即中断 stream,不等下一条消息
464
+ if (currentStream) {
465
+ try {
466
+ currentStream.return?.();
467
+ }
468
+ catch { /* ignore */ }
469
+ }
361
470
  },
362
471
  };
363
472
  }
package/dist/config.js CHANGED
@@ -4,7 +4,7 @@ try {
4
4
  catch {
5
5
  /* dotenv optional */
6
6
  }
7
- import { readFileSync, writeFileSync, accessSync, constants, existsSync, mkdirSync } from 'node:fs';
7
+ import { readFileSync, writeFileSync, accessSync, constants, existsSync, mkdirSync, statSync } from 'node:fs';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { join, dirname, isAbsolute } from 'node:path';
10
10
  import { homedir } from 'node:os';
@@ -22,6 +22,9 @@ const OLD_ROOT_KEYS = [
22
22
  'claudeWorkDir',
23
23
  'claudeTimeoutMs', 'claudeModel',
24
24
  ];
25
+ // Config cache with mtime tracking
26
+ let cachedConfig = null;
27
+ let cachedClaudeEnv = null;
25
28
  function hasOldConfigFormat(raw) {
26
29
  const hasOld = OLD_ROOT_KEYS.some((k) => raw[k] !== undefined && raw[k] !== null);
27
30
  const hasNew = raw.tools && typeof raw.tools === 'object' && raw.tools.claude;
@@ -78,6 +81,16 @@ function migrateToNewConfigFormat(raw) {
78
81
  }
79
82
  export function loadFileConfig() {
80
83
  try {
84
+ // Check if file exists and get mtime for cache validation
85
+ if (!existsSync(CONFIG_PATH))
86
+ return {};
87
+ const stats = statSync(CONFIG_PATH);
88
+ const currentMtime = stats.mtimeMs;
89
+ // Return cached config if file hasn't changed
90
+ if (cachedConfig && cachedConfig.mtime === currentMtime) {
91
+ return cachedConfig.config;
92
+ }
93
+ // File changed or no cache, read and parse
81
94
  const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
82
95
  if (!raw || typeof raw !== 'object')
83
96
  return {};
@@ -87,8 +100,12 @@ export function loadFileConfig() {
87
100
  if (!existsSync(dir))
88
101
  mkdirSync(dir, { recursive: true });
89
102
  writeFileSync(CONFIG_PATH, JSON.stringify(migrated, null, 2), 'utf-8');
103
+ // Update cache with migrated config
104
+ cachedConfig = { config: migrated, mtime: currentMtime };
90
105
  return migrated;
91
106
  }
107
+ // Update cache
108
+ cachedConfig = { config: raw, mtime: currentMtime };
92
109
  return raw;
93
110
  }
94
111
  catch {
@@ -100,6 +117,8 @@ export function saveFileConfig(raw) {
100
117
  if (!existsSync(dir))
101
118
  mkdirSync(dir, { recursive: true });
102
119
  writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2), 'utf-8');
120
+ // Invalidate cache after save (next read will get fresh mtime)
121
+ cachedConfig = null;
103
122
  }
104
123
  /** 获取用户主目录(兼容不同运行环境,如 launchd、systemd 等) */
105
124
  export function getClaudeConfigHome() {
@@ -114,16 +133,26 @@ export function loadClaudeSettingsEnv() {
114
133
  ];
115
134
  for (const p of paths) {
116
135
  try {
117
- const raw = JSON.parse(readFileSync(p, 'utf-8'));
118
- const env = raw?.env;
119
- if (env && typeof env === 'object') {
120
- const result = {};
121
- for (const [k, v] of Object.entries(env)) {
122
- if (v != null && typeof k === 'string') {
123
- result[k] = String(v);
136
+ // Check cache first
137
+ if (existsSync(p)) {
138
+ const stats = statSync(p);
139
+ const currentMtime = stats.mtimeMs;
140
+ if (cachedClaudeEnv && cachedClaudeEnv.mtime === currentMtime && cachedClaudeEnv.env) {
141
+ return cachedClaudeEnv.env;
142
+ }
143
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
144
+ const env = raw?.env;
145
+ if (env && typeof env === 'object') {
146
+ const result = {};
147
+ for (const [k, v] of Object.entries(env)) {
148
+ if (v != null && typeof k === 'string') {
149
+ result[k] = String(v);
150
+ }
124
151
  }
152
+ // Update cache
153
+ cachedClaudeEnv = { env: result, mtime: currentMtime };
154
+ return result;
125
155
  }
126
- return result;
127
156
  }
128
157
  }
129
158
  catch {
@@ -156,6 +185,8 @@ export function saveClaudeSettingsEnv(env) {
156
185
  existing.env = { ...existing.env, ...env };
157
186
  // 写入文件
158
187
  writeFileSync(claudeSettingsPath, JSON.stringify(existing, null, 2), 'utf-8');
188
+ // Invalidate cache after save
189
+ cachedClaudeEnv = null;
159
190
  }
160
191
  catch (error) {
161
192
  log.error('Failed to save Claude settings:', error);
@@ -164,9 +164,25 @@ export async function sendErrorCard(cardId, error) {
164
164
  // Track if patch API is working for this session
165
165
  let patchApiWorking = true;
166
166
  let patchFailCount = 0;
167
+ let patchDisabledAt = null; // When patch was disabled
167
168
  const MAX_PATCH_FAILURES_BEFORE_DISABLE = 3;
169
+ const PATCH_RECOVERY_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes before retrying
170
+ // Attempt to recover patch API after cooldown period
171
+ function attemptPatchRecovery() {
172
+ if (!patchApiWorking && patchDisabledAt !== null) {
173
+ const now = Date.now();
174
+ if (now - patchDisabledAt >= PATCH_RECOVERY_COOLDOWN_MS) {
175
+ log.info('Attempting to recover patch API after cooldown period');
176
+ patchApiWorking = true;
177
+ patchFailCount = 0;
178
+ patchDisabledAt = null;
179
+ }
180
+ }
181
+ }
168
182
  export async function updateMessage(chatId, messageId, content, status, note, toolId = 'claude') {
169
183
  const client = getClient();
184
+ // Try to recover patch API if cooldown period has passed
185
+ attemptPatchRecovery();
170
186
  const title = getToolTitle(toolId, status);
171
187
  const cardContent = createFeishuCard(title, content, status, note);
172
188
  // Try to use patch API for in-place update (streaming)
@@ -198,6 +214,7 @@ export async function updateMessage(chatId, messageId, content, status, note, to
198
214
  if (patchFailCount >= MAX_PATCH_FAILURES_BEFORE_DISABLE) {
199
215
  log.warn('Patch API disabled for this session due to repeated failures');
200
216
  patchApiWorking = false;
217
+ patchDisabledAt = Date.now();
201
218
  }
202
219
  // 流式更新时不 fallback 到 delete+create,否则会生成新消息但 caller 仍持旧 msgId,导致后续 patch 全失败并不断创建新消息
203
220
  // 直接返回,下次节流周期会重试
@@ -210,6 +227,7 @@ export async function updateMessage(chatId, messageId, content, status, note, to
210
227
  if (patchFailCount >= MAX_PATCH_FAILURES_BEFORE_DISABLE) {
211
228
  log.warn('Patch API disabled for this session due to repeated errors');
212
229
  patchApiWorking = false;
230
+ patchDisabledAt = Date.now();
213
231
  }
214
232
  }
215
233
  }
@@ -219,6 +237,8 @@ export async function updateMessage(chatId, messageId, content, status, note, to
219
237
  export async function sendFinalMessages(chatId, messageId, fullContent, note, toolId = 'claude') {
220
238
  const client = getClient();
221
239
  const parts = splitLongContent(fullContent, MAX_FEISHU_MESSAGE_LENGTH);
240
+ // Try to recover patch API if cooldown period has passed
241
+ attemptPatchRecovery();
222
242
  // If content fits in one message and patch is working, try patch for smooth transition
223
243
  if (parts.length === 1 && patchApiWorking) {
224
244
  const cardContent = createFeishuCard(getToolTitle(toolId, 'done'), fullContent, 'done');
@@ -319,6 +339,7 @@ export async function sendImageReply(chatId, imagePath) {
319
339
  Authorization: `Bearer ${token}`,
320
340
  },
321
341
  body: form,
342
+ signal: AbortSignal.timeout(60000),
322
343
  });
323
344
  if (!uploadResp.ok) {
324
345
  throw new Error(`Failed to upload image: ${uploadResp.statusText}`);
package/dist/qq/client.js CHANGED
@@ -48,6 +48,7 @@ async function fetchAccessToken(config) {
48
48
  appId: config.qqAppId,
49
49
  clientSecret: config.qqSecret,
50
50
  }),
51
+ signal: AbortSignal.timeout(15000),
51
52
  });
52
53
  const data = (await response.json());
53
54
  if (!response.ok || !data.access_token) {
@@ -65,6 +66,7 @@ async function apiRequest(config, method, path, body) {
65
66
  method,
66
67
  headers: buildAuthHeaders(token),
67
68
  body: body ? JSON.stringify(body) : undefined,
69
+ signal: AbortSignal.timeout(15000),
68
70
  });
69
71
  if (!response.ok) {
70
72
  const text = await response.text().catch(() => "");
@@ -9,15 +9,15 @@ const MAX_QQ_MESSAGE_LENGTH = 1500;
9
9
  const pendingReplies = new Map();
10
10
  // Periodic cleanup of orphaned pending replies
11
11
  const PENDING_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
12
+ const CLEANUP_INTERVAL_MS = 2 * 60 * 1000; // Check every 2 minutes
12
13
  setInterval(() => {
13
14
  const now = Date.now();
14
15
  for (const [id, state] of pendingReplies) {
15
- // pendingReplies don't have timestamps, but we can clear old ones based on size
16
- if (pendingReplies.size > 100) {
16
+ if (now - state.createdAt > PENDING_MAX_AGE_MS) {
17
17
  pendingReplies.delete(id);
18
18
  }
19
19
  }
20
- }, PENDING_MAX_AGE_MS).unref();
20
+ }, CLEANUP_INTERVAL_MS).unref();
21
21
  function parseChatTarget(chatId) {
22
22
  if (chatId.startsWith("group:")) {
23
23
  return { kind: "group", id: chatId.slice("group:".length) };
@@ -57,7 +57,7 @@ export async function sendImageReply(chatId, imagePath) {
57
57
  }
58
58
  export async function sendThinkingMessage(chatId, replyToMessageId, _toolId = "claude") {
59
59
  const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
60
- pendingReplies.set(messageId, { replyToMessageId });
60
+ pendingReplies.set(messageId, { replyToMessageId, createdAt: Date.now() });
61
61
  return messageId;
62
62
  }
63
63
  export async function updateMessage(_chatId, _messageId, _content, _status, _note, _toolId = "claude") {
@@ -68,16 +68,20 @@ export class SessionManager {
68
68
  return undefined;
69
69
  }
70
70
  setSessionIdForThread(userId, threadId, toolId, sessionId) {
71
- const s = this.sessions.get(userId);
72
- if (s && !s.threads)
73
- s.threads = {};
74
- const t = s?.threads?.[threadId];
75
- if (t) {
76
- if (!t.sessionIds)
77
- t.sessionIds = {};
78
- t.sessionIds[toolId] = sessionId;
79
- this.save();
71
+ let s = this.sessions.get(userId);
72
+ if (!s) {
73
+ s = { workDir: this.defaultWorkDir, activeConvId: randomBytes(4).toString('hex') };
74
+ this.sessions.set(userId, s);
80
75
  }
76
+ if (!s.threads)
77
+ s.threads = {};
78
+ if (!s.threads[threadId])
79
+ s.threads[threadId] = {};
80
+ const t = s.threads[threadId];
81
+ if (!t.sessionIds)
82
+ t.sessionIds = {};
83
+ t.sessionIds[toolId] = sessionId;
84
+ this.save();
81
85
  }
82
86
  getWorkDir(userId) {
83
87
  return this.sessions.get(userId)?.workDir ?? this.defaultWorkDir;
@@ -103,8 +107,9 @@ export class SessionManager {
103
107
  const currentDir = this.getWorkDir(userId);
104
108
  const realPath = await this.resolveAndValidate(currentDir, workDir);
105
109
  const s = this.sessions.get(userId);
110
+ let oldConvId;
106
111
  if (s) {
107
- const oldConvId = s.activeConvId;
112
+ oldConvId = s.activeConvId;
108
113
  this.persistActiveConvSessions(userId, s);
109
114
  s.workDir = realPath;
110
115
  s.sessionIds = {};
@@ -120,7 +125,7 @@ export class SessionManager {
120
125
  });
121
126
  }
122
127
  this.flushSync();
123
- log.info(`WorkDir changed for user ${userId}: ${realPath}, oldConvId=${s?.activeConvId}`);
128
+ log.info(`WorkDir changed for user ${userId}: ${realPath}, oldConvId=${oldConvId}`);
124
129
  return realPath;
125
130
  }
126
131
  /**
@@ -294,8 +299,8 @@ export class SessionManager {
294
299
  }
295
300
  }
296
301
  }
297
- catch {
298
- /* ignore */
302
+ catch (err) {
303
+ log.warn('Failed to load sessions file, starting with empty state:', err instanceof Error ? err.message : err);
299
304
  }
300
305
  }
301
306
  save() {
@@ -120,9 +120,7 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
120
120
  export async function sendTextReply(chatId, text) {
121
121
  const bot = getBot();
122
122
  try {
123
- await bot.telegram.sendMessage(Number(chatId), formatMessage(text, "done", undefined, OPEN_IM_SYSTEM_TITLE), {
124
- parse_mode: "Markdown",
125
- });
123
+ await bot.telegram.sendMessage(Number(chatId), formatMessage(text, "done", undefined, OPEN_IM_SYSTEM_TITLE));
126
124
  }
127
125
  catch (err) {
128
126
  log.error("Failed to send text:", err);
@@ -4,7 +4,7 @@
4
4
  import { resolvePlatformAiCommand } from '../config.js';
5
5
  import { AccessControl } from '../access/access-control.js';
6
6
  import { RequestQueue } from '../queue/request-queue.js';
7
- import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendImageReply, startTypingLoop, setCurrentReqId, } from './message-sender.js';
7
+ import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendImageReply, startTypingLoop, } from './message-sender.js';
8
8
  import { CommandHandler } from '../commands/handler.js';
9
9
  import { getAdapter } from '../adapters/registry.js';
10
10
  import { runAITask } from '../shared/ai-task.js';
@@ -173,88 +173,81 @@ export function setupWeWorkHandlers(config, sessionManager) {
173
173
  });
174
174
  async function handleAIRequest(userId, chatId, prompt, workDir, convId, _threadCtx, replyToMessageId, reqId) {
175
175
  log.info(`[AI_REQUEST] userId=${userId}, chatId=${chatId}, promptLength=${prompt.length}`);
176
- if (reqId)
177
- setCurrentReqId(reqId);
176
+ const aiCommand = resolvePlatformAiCommand(config, 'wework');
177
+ const toolAdapter = getAdapter(aiCommand);
178
+ if (!toolAdapter) {
179
+ log.error(`[handleAIRequest] No adapter found for: ${aiCommand}`);
180
+ await sendTextReply(chatId, `AI tool is not configured: ${aiCommand}`, reqId);
181
+ return;
182
+ }
183
+ const sessionId = convId
184
+ ? sessionManager.getSessionIdForConv(userId, convId, aiCommand)
185
+ : undefined;
186
+ log.info(`[handleAIRequest] Running ${aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
187
+ const toolId = aiCommand;
188
+ let msgId;
178
189
  try {
179
- const aiCommand = resolvePlatformAiCommand(config, 'wework');
180
- const toolAdapter = getAdapter(aiCommand);
181
- if (!toolAdapter) {
182
- log.error(`[handleAIRequest] No adapter found for: ${aiCommand}`);
183
- await sendTextReply(chatId, `AI tool is not configured: ${aiCommand}`, reqId);
184
- return;
185
- }
186
- const sessionId = convId
187
- ? sessionManager.getSessionIdForConv(userId, convId, aiCommand)
188
- : undefined;
189
- log.info(`[handleAIRequest] Running ${aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
190
- const toolId = aiCommand;
191
- let msgId;
190
+ msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, reqId);
191
+ }
192
+ catch (err) {
193
+ log.error('Failed to send thinking message:', err);
192
194
  try {
193
- msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, reqId);
195
+ await sendTextReply(chatId, '启动 AI 处理失败,请重试。', reqId);
194
196
  }
195
197
  catch (err) {
196
- log.error('Failed to send thinking message:', err);
197
- try {
198
- await sendTextReply(chatId, '启动 AI 处理失败,请重试。', reqId);
199
- }
200
- catch (err) {
201
- log.warn('Failed to send startup error reply:', err);
202
- }
203
- return;
198
+ log.warn('Failed to send startup error reply:', err);
204
199
  }
205
- const stopTyping = startTypingLoop(chatId);
206
- const taskKey = `${userId}:${msgId}`;
207
- // Safety timeout: abort hung tasks before stream expires, unblocking the queue
208
- let safetyTimer = setTimeout(() => {
200
+ return;
201
+ }
202
+ const stopTyping = startTypingLoop(chatId);
203
+ const taskKey = `${userId}:${msgId}`;
204
+ // Safety timeout: abort hung tasks before stream expires, unblocking the queue
205
+ let safetyTimer = setTimeout(() => {
206
+ safetyTimer = null;
207
+ const state = runningTasks.get(taskKey);
208
+ if (state) {
209
+ log.warn(`[SAFETY_TIMEOUT] Task ${taskKey} exceeded ${WEWORK_TASK_SAFETY_TIMEOUT_MS}ms, aborting`);
210
+ state.handle.abort();
211
+ runningTasks.delete(taskKey);
212
+ stopTyping();
213
+ sendTextReply(chatId, `AI 处理超时(${Math.round(WEWORK_TASK_SAFETY_TIMEOUT_MS / 1000)}s),已自动取消。请重试。`, reqId).catch(() => { });
214
+ }
215
+ }, WEWORK_TASK_SAFETY_TIMEOUT_MS);
216
+ const clearSafetyTimer = () => {
217
+ if (safetyTimer) {
218
+ clearTimeout(safetyTimer);
209
219
  safetyTimer = null;
210
- const state = runningTasks.get(taskKey);
211
- if (state) {
212
- log.warn(`[SAFETY_TIMEOUT] Task ${taskKey} exceeded ${WEWORK_TASK_SAFETY_TIMEOUT_MS}ms, aborting`);
213
- state.handle.abort();
214
- runningTasks.delete(taskKey);
215
- stopTyping();
216
- sendTextReply(chatId, `AI 处理超时(${Math.round(WEWORK_TASK_SAFETY_TIMEOUT_MS / 1000)}s),已自动取消。请重试。`, reqId).catch(() => { });
220
+ }
221
+ };
222
+ await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
223
+ throttleMs: WEWORK_THROTTLE_MS,
224
+ streamUpdate: async (content, toolNote) => {
225
+ const note = buildProgressNote(toolNote);
226
+ try {
227
+ await updateMessage(chatId, msgId, content, 'streaming', note, toolId, reqId);
217
228
  }
218
- }, WEWORK_TASK_SAFETY_TIMEOUT_MS);
219
- const clearSafetyTimer = () => {
220
- if (safetyTimer) {
221
- clearTimeout(safetyTimer);
222
- safetyTimer = null;
229
+ catch (err) {
230
+ log.debug('Stream update failed:', err);
223
231
  }
224
- };
225
- await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
226
- throttleMs: WEWORK_THROTTLE_MS,
227
- streamUpdate: async (content, toolNote) => {
228
- const note = buildProgressNote(toolNote);
229
- try {
230
- await updateMessage(chatId, msgId, content, 'streaming', note, toolId, reqId);
231
- }
232
- catch (err) {
233
- log.debug('Stream update failed:', err);
234
- }
235
- },
236
- sendComplete: async (content, note) => {
237
- await sendFinalMessages(chatId, msgId, content, note ?? '', toolId, reqId);
238
- },
239
- sendError: async (error) => {
240
- await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId, reqId);
241
- },
242
- extraCleanup: () => {
243
- clearSafetyTimer();
244
- stopTyping();
245
- runningTasks.delete(taskKey);
246
- },
247
- onTaskReady: (state) => {
248
- runningTasks.set(taskKey, state);
249
- },
250
- sendImage: async (path) => {
251
- await sendImageReply(chatId, path);
252
- },
253
- });
254
- }
255
- finally {
256
- setCurrentReqId(null);
257
- }
232
+ },
233
+ sendComplete: async (content, note) => {
234
+ await sendFinalMessages(chatId, msgId, content, note ?? '', toolId, reqId);
235
+ },
236
+ sendError: async (error) => {
237
+ await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId, reqId);
238
+ },
239
+ extraCleanup: () => {
240
+ clearSafetyTimer();
241
+ stopTyping();
242
+ runningTasks.delete(taskKey);
243
+ },
244
+ onTaskReady: (state) => {
245
+ runningTasks.set(taskKey, state);
246
+ },
247
+ sendImage: async (path) => {
248
+ await sendImageReply(chatId, path);
249
+ },
250
+ });
258
251
  }
259
252
  async function enqueuePrompt(userId, chatId, prompt, reqId) {
260
253
  const workDir = sessionManager.getWorkDir(userId);
@@ -272,7 +265,6 @@ export function setupWeWorkHandlers(config, sessionManager) {
272
265
  async function handleEvent(data) {
273
266
  log.info('[handleEvent] Called with data:', JSON.stringify(data).slice(0, 800));
274
267
  const reqId = data.headers?.req_id ?? '';
275
- setCurrentReqId(reqId);
276
268
  try {
277
269
  const body = data.body;
278
270
  const msgType = body.msgtype;
@@ -329,9 +321,6 @@ export function setupWeWorkHandlers(config, sessionManager) {
329
321
  catch (err) {
330
322
  log.error('[handleEvent] Error processing event:', err);
331
323
  }
332
- finally {
333
- setCurrentReqId(null);
334
- }
335
324
  }
336
325
  return {
337
326
  stop: () => stopTaskCleanup(),
@@ -2,7 +2,6 @@
2
2
  * WeWork (企业微信/WeCom) Message Sender
3
3
  * 通过 WebSocket `aibot_respond_msg` 发送消息,并透传 `req_id`
4
4
  */
5
- export declare function setCurrentReqId(reqId: string | null): void;
6
5
  type MessageStatus = 'thinking' | 'streaming' | 'done' | 'error';
7
6
  /**
8
7
  * Send thinking message to WeWork.
@@ -14,18 +14,12 @@ import { readFile } from 'node:fs/promises';
14
14
  const log = createLogger('WeWorkSender');
15
15
  const STREAM_SEND_INTERVAL_MS = 900;
16
16
  const STREAM_SAFE_TTL_MS = 5 * 60 * 1000;
17
- /** 当前同步处理中的 req_id,仅用于 commandHandler 等同步调用。 */
18
- let currentReqId = null;
19
- export function setCurrentReqId(reqId) {
20
- currentReqId = reqId;
21
- }
22
17
  function getReqId(explicitReqId) {
23
- const id = explicitReqId ?? currentReqId;
24
- if (!id) {
18
+ if (!explicitReqId) {
25
19
  log.warn('No req_id - cannot send WeWork reply');
26
20
  return '';
27
21
  }
28
- return id;
22
+ return explicitReqId;
29
23
  }
30
24
  const STATUS_CONFIG = {
31
25
  thinking: { icon: '[thinking]', title: '思考中' },
@@ -300,10 +294,9 @@ export async function sendProactiveTextReply(chatId, text) {
300
294
  */
301
295
  export async function sendTextReply(chatId, text, threadCtxOrReqId) {
302
296
  const message = formatWeWorkMessage(OPEN_IM_SYSTEM_TITLE, text, 'done');
303
- const explicitReqId = typeof threadCtxOrReqId === 'string' ? threadCtxOrReqId : undefined;
304
- const effectiveReqId = explicitReqId ?? currentReqId;
297
+ const effectiveReqId = typeof threadCtxOrReqId === 'string' ? threadCtxOrReqId : undefined;
305
298
  try {
306
- sendText(getReqId(effectiveReqId ?? undefined), message);
299
+ sendText(getReqId(effectiveReqId), message);
307
300
  log.info(`Text reply sent to user ${chatId}`);
308
301
  }
309
302
  catch (err) {
@@ -20,18 +20,13 @@ export function setupWorkBuddyHandlers(config, sessionManager) {
20
20
  const runningTasks = new Map();
21
21
  const taskKeyByChatId = new Map();
22
22
  const stopTaskCleanup = startTaskCleanup(runningTasks);
23
- let currentMsgId = '';
24
- const commandHandler = new CommandHandler({
23
+ // Base dependencies for creating per-event CommandHandler
24
+ const baseCommandDeps = {
25
25
  config,
26
26
  sessionManager,
27
27
  requestQueue,
28
- sender: {
29
- sendTextReply: async (chatId, text) => {
30
- await sendTextReply(null, chatId, text, currentMsgId);
31
- },
32
- },
33
28
  getRunningTasksSize: () => runningTasks.size,
34
- });
29
+ };
35
30
  async function handleAIRequest(userId, chatId, msgId, prompt, workDir, convId) {
36
31
  log.info(`[AI_REQUEST] userId=${userId}, chatId=${chatId}, promptLength=${prompt.length}`);
37
32
  const aiCommand = resolvePlatformAiCommand(config, 'workbuddy');
@@ -72,7 +67,6 @@ export function setupWorkBuddyHandlers(config, sessionManager) {
72
67
  });
73
68
  }
74
69
  async function handleEvent(chatId, msgId, content) {
75
- currentMsgId = msgId;
76
70
  log.info(`[handleEvent] chatId=${chatId}, msgId=${msgId}, content="${content.substring(0, 100)}"`);
77
71
  // Use chatId as userId for WorkBuddy (WeChat KF doesn't have separate userId)
78
72
  const userId = chatId;
@@ -86,6 +80,15 @@ export function setupWorkBuddyHandlers(config, sessionManager) {
86
80
  setChatUser(chatId, userId, 'workbuddy');
87
81
  const workDir = sessionManager.getWorkDir(userId);
88
82
  const convId = sessionManager.getConvId(userId);
83
+ // Create a per-event CommandHandler with sender that captures msgId for this event
84
+ const commandHandler = new CommandHandler({
85
+ ...baseCommandDeps,
86
+ sender: {
87
+ sendTextReply: async (c, t) => {
88
+ await sendTextReply(null, c, t, msgId);
89
+ },
90
+ },
91
+ });
89
92
  // Try command handler first
90
93
  try {
91
94
  const handled = await commandHandler.dispatch(text, chatId, userId, 'workbuddy', (u, c, p, w, conv, _r, m) => handleAIRequest(u, c, msgId, p, w, conv));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.9.3-beta.4",
3
+ "version": "1.9.3-beta.6",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",