@wu529778790/open-im 1.8.1-beta.2 → 1.8.1-beta.21

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 (55) hide show
  1. package/dist/access/access-control.js +1 -1
  2. package/dist/adapters/claude-sdk-adapter.js +94 -36
  3. package/dist/channels/capabilities.js +5 -0
  4. package/dist/cli.js +5 -2
  5. package/dist/commands/handler.d.ts +1 -2
  6. package/dist/commands/handler.js +6 -18
  7. package/dist/config-web-page-i18n.d.ts +12 -0
  8. package/dist/config-web-page-i18n.js +12 -0
  9. package/dist/config-web-page-script.js +1 -0
  10. package/dist/config-web-page-template.js +48 -1
  11. package/dist/config-web.js +110 -7
  12. package/dist/config.d.ts +25 -1
  13. package/dist/config.js +46 -0
  14. package/dist/constants.d.ts +2 -0
  15. package/dist/constants.js +2 -0
  16. package/dist/dingtalk/client.js +11 -3
  17. package/dist/dingtalk/event-handler.js +18 -3
  18. package/dist/dingtalk/message-sender.js +13 -0
  19. package/dist/feishu/event-handler.js +144 -10
  20. package/dist/index.js +26 -2
  21. package/dist/manager-control.js +7 -0
  22. package/dist/qq/client.js +111 -88
  23. package/dist/qq/event-handler.js +16 -2
  24. package/dist/qq/message-sender.js +11 -0
  25. package/dist/service-control.js +4 -0
  26. package/dist/session/session-manager.js +11 -1
  27. package/dist/setup.js +2 -1
  28. package/dist/shared/active-chats.d.ts +2 -2
  29. package/dist/shared/ai-task.js +13 -1
  30. package/dist/shared/chat-user-map.js +11 -0
  31. package/dist/shared/media-storage.js +27 -0
  32. package/dist/telegram/client.js +25 -3
  33. package/dist/telegram/event-handler.js +44 -8
  34. package/dist/telegram/message-sender.js +13 -0
  35. package/dist/wechat/auth/qclaw-api.js +1 -1
  36. package/dist/wechat/client.js +81 -4
  37. package/dist/wechat/event-handler.js +10 -3
  38. package/dist/wework/client.js +36 -14
  39. package/dist/wework/event-handler.js +39 -4
  40. package/dist/wework/message-sender.js +53 -21
  41. package/dist/workbuddy/centrifuge-client.d.ts +74 -0
  42. package/dist/workbuddy/centrifuge-client.js +272 -0
  43. package/dist/workbuddy/client.d.ts +27 -0
  44. package/dist/workbuddy/client.js +162 -0
  45. package/dist/workbuddy/event-handler.d.ts +11 -0
  46. package/dist/workbuddy/event-handler.js +118 -0
  47. package/dist/workbuddy/index.d.ts +8 -0
  48. package/dist/workbuddy/index.js +8 -0
  49. package/dist/workbuddy/message-sender.d.ts +16 -0
  50. package/dist/workbuddy/message-sender.js +51 -0
  51. package/dist/workbuddy/oauth.d.ts +114 -0
  52. package/dist/workbuddy/oauth.js +310 -0
  53. package/dist/workbuddy/types.d.ts +86 -0
  54. package/dist/workbuddy/types.js +4 -0
  55. package/package.json +4 -2
@@ -131,6 +131,33 @@ export async function saveBase64Media(base64, extension, basenameHint) {
131
131
  return saveBufferMedia(Buffer.from(base64, "base64"), extension, basenameHint);
132
132
  }
133
133
  export async function downloadMediaFromUrl(url, options) {
134
+ // SSRF protection: validate URL before fetching
135
+ const BLOCKED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0', '[::1]', '169.254.169.254'];
136
+ let parsedUrl;
137
+ try {
138
+ parsedUrl = new URL(url);
139
+ }
140
+ catch {
141
+ throw new Error(`Invalid URL: ${url}`);
142
+ }
143
+ const protocol = parsedUrl.protocol.toLowerCase();
144
+ if (protocol !== 'https:' && protocol !== 'http:') {
145
+ throw new Error(`Unsupported URL protocol: ${protocol}`);
146
+ }
147
+ const hostname = parsedUrl.hostname.toLowerCase();
148
+ for (const blocked of BLOCKED_HOSTS) {
149
+ if (hostname === blocked) {
150
+ throw new Error(`Blocked URL host: ${hostname}`);
151
+ }
152
+ }
153
+ // Block link-local and private IPs (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x)
154
+ const ipMatch = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
155
+ if (ipMatch) {
156
+ const [, a, b] = ipMatch.map(Number);
157
+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 0) {
158
+ throw new Error(`Blocked private/internal IP: ${hostname}`);
159
+ }
160
+ }
134
161
  await mkdir(IMAGE_DIR, { recursive: true });
135
162
  const response = await fetch(url, { signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS) });
136
163
  if (!response.ok) {
@@ -20,9 +20,31 @@ export async function initTelegram(config, setupHandlers) {
20
20
  setupHandlers(bot);
21
21
  const me = (await bot.telegram.getMe());
22
22
  botUsername = me.username;
23
- bot.launch().catch((err) => {
24
- log.error("Telegram polling error:", err);
25
- process.exit(1);
23
+ const launchWithRetry = async (attempt = 1) => {
24
+ try {
25
+ await bot.launch();
26
+ }
27
+ catch (err) {
28
+ log.error("Telegram polling error:", err);
29
+ try {
30
+ bot.stop("Telegram polling error");
31
+ }
32
+ catch {
33
+ /* ignore */
34
+ }
35
+ const maxAttempts = 10;
36
+ const delayMs = Math.min(5000 * attempt, 60000);
37
+ if (attempt < maxAttempts) {
38
+ log.info(`Telegram reconnect in ${Math.round(delayMs / 1000)}s (attempt ${attempt}/${maxAttempts})`);
39
+ await new Promise((r) => setTimeout(r, delayMs));
40
+ return launchWithRetry(attempt + 1);
41
+ }
42
+ log.error("Telegram gave up reconnecting, skipping");
43
+ // 不再 exit(1),让其他通道继续运行
44
+ }
45
+ };
46
+ void launchWithRetry().catch((err) => {
47
+ log.error("Telegram launchWithRetry failed fatally:", err);
26
48
  });
27
49
  log.info("Telegram bot launched");
28
50
  }
@@ -107,6 +107,12 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
107
107
  }
108
108
  catch (err) {
109
109
  log.error("Failed to send thinking message:", err);
110
+ try {
111
+ await sendTextReply(chatId, "启动 AI 处理失败,请重试。");
112
+ }
113
+ catch (err) {
114
+ log.warn('Failed to send startup error reply:', err);
115
+ }
110
116
  return;
111
117
  }
112
118
  const stopTyping = startTypingLoop(chatId);
@@ -176,7 +182,8 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
176
182
  throttle.recordSuccess();
177
183
  lastUpdateTime = Date.now();
178
184
  }
179
- catch {
185
+ catch (err) {
186
+ log.debug('Stream update failed:', err);
180
187
  throttle.recordError();
181
188
  }
182
189
  finally {
@@ -190,7 +197,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
190
197
  }
191
198
  }
192
199
  };
193
- return (content, toolNote) => {
200
+ const wrapper = (content, toolNote) => {
194
201
  if (content.startsWith("💭 **思考中...**")) {
195
202
  return;
196
203
  }
@@ -211,6 +218,17 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
211
218
  performUpdate(content, toolNote);
212
219
  }, Math.max(DEBOUNCE_MS, baseDelay));
213
220
  };
221
+ // flush 排队的 debounce 更新,防止 sendComplete 时仍有 streaming 更新在排队
222
+ wrapper.flush = async () => {
223
+ if (debounceTimer) {
224
+ clearTimeout(debounceTimer);
225
+ debounceTimer = null;
226
+ }
227
+ while (updateInProgress) {
228
+ await new Promise((resolve) => setTimeout(resolve, 50));
229
+ }
230
+ };
231
+ return wrapper;
214
232
  };
215
233
  const streamUpdateWrapper = createStreamUpdateWrapper();
216
234
  await runAITask({ config, sessionManager }, {
@@ -228,12 +246,30 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
228
246
  },
229
247
  sendComplete: async (content, note) => {
230
248
  throttle.reset();
231
- try {
232
- await sendFinalMessages(chatId, msgId, content, note, toolId);
233
- }
234
- catch (err) {
235
- log.error("Failed to send complete message:", err);
236
- await updateMessage(chatId, msgId, content, "done", note, toolId);
249
+ // 先 flush 排队的 streaming 更新,防止它覆盖后续的 done 消息
250
+ await streamUpdateWrapper.flush?.();
251
+ const maxAttempts = 3;
252
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
253
+ try {
254
+ await sendFinalMessages(chatId, msgId, content, note, toolId);
255
+ return;
256
+ }
257
+ catch (err) {
258
+ log.error(`Failed to send complete message (attempt ${attempt}/${maxAttempts}):`, err);
259
+ if (attempt < maxAttempts) {
260
+ await new Promise((r) => setTimeout(r, 2000 * attempt));
261
+ }
262
+ else {
263
+ // 最终失败:尝试发送纯文本作为最后手段
264
+ try {
265
+ await sendTextReply(chatId, `⚠️ 消息更新失败(网络异常),以下是 AI 回复:\n\n${content.slice(0, 4000)}`);
266
+ }
267
+ catch (fallbackErr) {
268
+ log.error("All send attempts failed:", fallbackErr);
269
+ throw err;
270
+ }
271
+ }
272
+ }
237
273
  }
238
274
  },
239
275
  sendError: async (error) => {
@@ -9,6 +9,15 @@ import { MAX_TELEGRAM_MESSAGE_LENGTH } from "../constants.js";
9
9
  import { listDirectories, buildDirectoryKeyboard, } from "../commands/handler.js";
10
10
  const log = createLogger("TgSender");
11
11
  const lastSentByMsg = new Map();
12
+ // Periodic cleanup of orphaned entries (entries not cleaned by done/error)
13
+ const LAST_SENT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
14
+ setInterval(() => {
15
+ // lastSentByMsg doesn't store timestamps, so clear all entries periodically
16
+ // since they are just dedup cache entries, clearing is safe
17
+ if (lastSentByMsg.size > 0) {
18
+ lastSentByMsg.clear();
19
+ }
20
+ }, LAST_SENT_MAX_AGE_MS);
12
21
  const STATUS_ICONS = {
13
22
  thinking: "🔵",
14
23
  streaming: "🔵",
@@ -85,6 +94,10 @@ export async function updateMessage(chatId, messageId, content, status, note, to
85
94
  }
86
95
  else {
87
96
  log.error("Failed to update message:", err);
97
+ // 对 done/error 状态的更新失败必须 throw,否则消息永远卡在 streaming
98
+ if (status === "done" || status === "error") {
99
+ throw err;
100
+ }
88
101
  }
89
102
  }
90
103
  if (status === "done" || status === "error") {
@@ -14,7 +14,7 @@ function nested(obj, ...keys) {
14
14
  export class QClawAPI {
15
15
  env;
16
16
  guid;
17
- loginKey = 'm83qdao0AmE5';
17
+ loginKey = process.env.QCLAW_LOGIN_KEY || 'm83qdao0AmE5';
18
18
  jwtToken = '';
19
19
  userId = '';
20
20
  constructor(env, guid, jwtToken = '') {
@@ -8,6 +8,7 @@ import { createLogger } from '../logger.js';
8
8
  const log = createLogger('WeChat');
9
9
  const TOKEN_FILE = 'wechat-token.json';
10
10
  const DEFAULT_WECHAT_WS_URL = 'wss://openclau-wechat.henryxiaoyang.workers.dev';
11
+ const PONG_TIMEOUT_FACTOR = 3; // 3倍心跳间隔无响应则判定连接死亡
11
12
  // Global state
12
13
  let ws = null;
13
14
  let channelState = 'disconnected';
@@ -16,6 +17,9 @@ let heartbeatTimer = null;
16
17
  let reconnectAttempts = 0;
17
18
  let currentToken = null;
18
19
  let tokenStoragePath = null;
20
+ let lastServerResponseTime = 0; // 上次收到服务端消息的时间
21
+ let wsConfigRef = null; // 保存配置供心跳重连使用
22
+ let isStopping = false; // 防止 stop 后重连定时器继续触发
19
23
  // Event handlers
20
24
  let messageHandler = null;
21
25
  let stateChangeHandler = null;
@@ -70,6 +74,7 @@ export async function initWeChat(config, eventHandler, onStateChange) {
70
74
  }
71
75
  messageHandler = eventHandler;
72
76
  stateChangeHandler = onStateChange ?? null;
77
+ isStopping = false;
73
78
  // Set up token storage path
74
79
  const baseDir = config.logDir ?? join(process.env.HOME ?? '', '.open-im');
75
80
  tokenStoragePath = join(baseDir, 'data');
@@ -92,15 +97,35 @@ export async function initWeChat(config, eventHandler, onStateChange) {
92
97
  * Connect to AGP WebSocket server
93
98
  */
94
99
  async function connectWebSocket(config) {
100
+ wsConfigRef = config;
95
101
  if (channelState === 'connecting') {
96
102
  log.warn('WebSocket connection already in progress');
97
103
  return;
98
104
  }
99
105
  updateState('connecting');
100
106
  return new Promise((resolve, reject) => {
107
+ let settled = false;
108
+ // Connection timeout to prevent promise from hanging forever
109
+ const connectionTimeout = setTimeout(() => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ const err = new Error('WeChat WebSocket connection timeout');
114
+ log.error(err.message);
115
+ updateState('error');
116
+ try {
117
+ ws?.close();
118
+ }
119
+ catch { /* ignore */ }
120
+ reject(err);
121
+ }, 30000);
101
122
  try {
102
123
  ws = new WebSocket(config.url);
103
124
  ws.on('open', () => {
125
+ if (settled)
126
+ return;
127
+ settled = true;
128
+ clearTimeout(connectionTimeout);
104
129
  log.info('WeChat WebSocket connected');
105
130
  reconnectAttempts = 0;
106
131
  updateState('connected');
@@ -108,6 +133,7 @@ async function connectWebSocket(config) {
108
133
  resolve();
109
134
  });
110
135
  ws.on('message', async (data) => {
136
+ lastServerResponseTime = Date.now();
111
137
  try {
112
138
  const envelope = JSON.parse(data.toString());
113
139
  log.debug('Received AGP message:', envelope.method);
@@ -118,18 +144,33 @@ async function connectWebSocket(config) {
118
144
  }
119
145
  });
120
146
  ws.on('error', (err) => {
147
+ if (settled) {
148
+ // Late error after connection was established — just log it
149
+ log.error('WeChat WebSocket error (after open):', err);
150
+ return;
151
+ }
152
+ settled = true;
153
+ clearTimeout(connectionTimeout);
121
154
  log.error('WeChat WebSocket error:', err);
122
155
  updateState('error');
123
156
  reject(err);
124
157
  });
125
158
  ws.on('close', () => {
159
+ clearTimeout(connectionTimeout);
126
160
  log.info('WeChat WebSocket closed');
127
161
  stopHeartbeat();
128
162
  updateState('disconnected');
163
+ if (!settled) {
164
+ settled = true;
165
+ reject(new Error('WeChat WebSocket closed before open'));
166
+ return;
167
+ }
129
168
  scheduleReconnect(config);
130
169
  });
131
170
  }
132
171
  catch (err) {
172
+ settled = true;
173
+ clearTimeout(connectionTimeout);
133
174
  log.error('Error creating WebSocket connection:', err);
134
175
  updateState('error');
135
176
  reject(err);
@@ -190,11 +231,35 @@ function updateState(state) {
190
231
  }
191
232
  /**
192
233
  * Start heartbeat to keep connection alive
234
+ * 同时检测服务端是否响应,超时无响应则主动断开触发重连
193
235
  */
194
236
  function startHeartbeat(interval) {
195
237
  stopHeartbeat();
238
+ lastServerResponseTime = Date.now();
196
239
  heartbeatTimer = setInterval(() => {
197
240
  if (channelState === 'connected') {
241
+ // 检测连接是否已死:长时间未收到任何服务端响应
242
+ const elapsed = Date.now() - lastServerResponseTime;
243
+ const pongTimeout = interval * PONG_TIMEOUT_FACTOR;
244
+ if (lastServerResponseTime > 0 && elapsed > pongTimeout) {
245
+ log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
246
+ stopHeartbeat();
247
+ if (ws) {
248
+ try {
249
+ ws.removeAllListeners();
250
+ ws.close();
251
+ }
252
+ catch {
253
+ /* ignore */
254
+ }
255
+ ws = null;
256
+ }
257
+ updateState('disconnected');
258
+ if (wsConfigRef) {
259
+ scheduleReconnect(wsConfigRef);
260
+ }
261
+ return;
262
+ }
198
263
  sendAGPMessage('ping', { timestamp: Date.now() });
199
264
  }
200
265
  }, interval);
@@ -210,17 +275,28 @@ function stopHeartbeat() {
210
275
  }
211
276
  /**
212
277
  * Schedule reconnection attempt
278
+ * 超过 maxAttempts 后自动重置计数器继续重试,避免永久断连
213
279
  */
214
280
  function scheduleReconnect(config) {
281
+ if (isStopping)
282
+ return;
215
283
  const maxAttempts = config.maxReconnectAttempts ?? 10;
216
- if (reconnectAttempts >= maxAttempts) {
217
- log.error('Max reconnect attempts reached');
284
+ if (reconnectTimer) {
218
285
  return;
219
286
  }
220
- const interval = config.reconnectInterval ?? 5000;
287
+ // 超过最大重试次数后重置计数器,降低频率继续重试
288
+ if (reconnectAttempts >= maxAttempts) {
289
+ log.warn(`Max reconnect attempts (${maxAttempts}) reached, resetting counter and retrying at lower frequency`);
290
+ reconnectAttempts = 0;
291
+ }
292
+ const baseInterval = config.reconnectInterval ?? 5000;
293
+ // 超过一半次数后逐渐增加间隔,最大 60 秒
294
+ const backoff = Math.min(baseInterval * Math.pow(1.5, Math.floor(reconnectAttempts / 3)), 60000);
295
+ const interval = Math.round(backoff);
221
296
  reconnectTimer = setTimeout(async () => {
297
+ reconnectTimer = null;
222
298
  reconnectAttempts++;
223
- log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts}`);
299
+ log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts} (interval: ${interval}ms)`);
224
300
  try {
225
301
  await connectWebSocket(config);
226
302
  }
@@ -274,6 +350,7 @@ function saveToken() {
274
350
  * Stop WeChat client
275
351
  */
276
352
  export function stopWeChat() {
353
+ isStopping = true;
277
354
  stopHeartbeat();
278
355
  if (reconnectTimer) {
279
356
  clearTimeout(reconnectTimer);
@@ -53,7 +53,8 @@ export function setupWeChatHandlers(config, sessionManager) {
53
53
  }
54
54
  return parsed;
55
55
  }
56
- catch {
56
+ catch (err) {
57
+ log.debug('Failed to parse WeChat incoming message JSON:', err);
57
58
  return null;
58
59
  }
59
60
  }
@@ -90,8 +91,8 @@ export function setupWeChatHandlers(config, sessionManager) {
90
91
  text: contextText,
91
92
  });
92
93
  }
93
- catch {
94
- // Fall through to metadata-only prompt.
94
+ catch (err) {
95
+ log.warn('Failed to download WeChat media, falling back to metadata-only prompt:', err);
95
96
  }
96
97
  }
97
98
  return buildMediaMetadataPrompt({
@@ -130,6 +131,12 @@ export function setupWeChatHandlers(config, sessionManager) {
130
131
  }
131
132
  catch (err) {
132
133
  log.error('Failed to send thinking message:', err);
134
+ try {
135
+ await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
136
+ }
137
+ catch (err) {
138
+ log.warn('Failed to send startup error reply:', err);
139
+ }
133
140
  return;
134
141
  }
135
142
  const stopTyping = startTypingLoop(chatId);
@@ -13,6 +13,7 @@ import { createLogger } from '../logger.js';
13
13
  const log = createLogger('WeWork');
14
14
  const DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com';
15
15
  const HEARTBEAT_INTERVAL = 30000; // 30秒
16
+ const PONG_TIMEOUT = HEARTBEAT_INTERVAL * 3; // 90秒无任何服务端响应则判定连接死亡
16
17
  const MAX_RECONNECT_ATTEMPTS = 100;
17
18
  // Global state
18
19
  let ws = null;
@@ -22,6 +23,7 @@ let heartbeatTimer = null;
22
23
  let reconnectAttempts = 0;
23
24
  let shouldReconnect = false;
24
25
  let isStopping = false;
26
+ let lastServerResponseTime = 0; // 上次收到服务端消息的时间
25
27
  // Event handlers
26
28
  let messageHandler = null;
27
29
  let stateChangeHandler = null;
@@ -46,12 +48,10 @@ export function getConnectionState() {
46
48
  */
47
49
  export function sendProactiveMessage(chatId, content) {
48
50
  if (!ws || connectionState !== 'connected') {
49
- log.error('Cannot send proactive message: WebSocket not connected');
50
- return;
51
+ throw new Error('Cannot send proactive message: WebSocket not connected');
51
52
  }
52
53
  if (!chatId) {
53
- log.error('Cannot send proactive message: chatId is required');
54
- return;
54
+ throw new Error('Cannot send proactive message: chatId is required');
55
55
  }
56
56
  const message = {
57
57
  cmd: "aibot_send_msg" /* WeWorkCommand.AIBOT_SEND_MSG */,
@@ -77,12 +77,10 @@ export function sendProactiveMessage(chatId, content) {
77
77
  */
78
78
  export function sendWebSocketReply(reqId, body) {
79
79
  if (!ws || connectionState !== 'connected') {
80
- log.error('Cannot send reply: WebSocket not connected');
81
- return;
80
+ throw new Error('Cannot send reply: WebSocket not connected');
82
81
  }
83
82
  if (!reqId) {
84
- log.error('Cannot send reply: req_id is required');
85
- return;
83
+ throw new Error('Cannot send reply: req_id is required');
86
84
  }
87
85
  const message = {
88
86
  cmd: "aibot_respond_msg" /* WeWorkCommand.AIBOT_RESPOND_MSG */,
@@ -177,6 +175,7 @@ async function connectWebSocket() {
177
175
  }
178
176
  });
179
177
  ws.on('message', async (data) => {
178
+ lastServerResponseTime = Date.now();
180
179
  try {
181
180
  const message = JSON.parse(data.toString());
182
181
  await handleMessage(message);
@@ -330,11 +329,30 @@ function updateState(state) {
330
329
  }
331
330
  /**
332
331
  * Start heartbeat to keep connection alive
332
+ * 同时检测服务端是否响应,超时无响应则主动断开触发重连
333
333
  */
334
334
  function startHeartbeat() {
335
335
  stopHeartbeat();
336
+ lastServerResponseTime = Date.now();
336
337
  heartbeatTimer = setInterval(() => {
337
338
  if (connectionState === 'connected' && ws) {
339
+ // 检测连接是否已死:长时间未收到任何服务端响应
340
+ const elapsed = Date.now() - lastServerResponseTime;
341
+ if (lastServerResponseTime > 0 && elapsed > PONG_TIMEOUT) {
342
+ log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
343
+ stopHeartbeat();
344
+ try {
345
+ ws.removeAllListeners();
346
+ ws.close();
347
+ }
348
+ catch {
349
+ /* ignore */
350
+ }
351
+ ws = null;
352
+ updateState('disconnected');
353
+ scheduleReconnect();
354
+ return;
355
+ }
338
356
  const pingMessage = {
339
357
  cmd: "ping" /* WeWorkCommand.PING */,
340
358
  headers: {
@@ -363,23 +381,27 @@ function stopHeartbeat() {
363
381
  }
364
382
  /**
365
383
  * Schedule reconnection attempt
384
+ * 超过 MAX_RECONNECT_ATTEMPTS 后自动重置计数器继续重试,避免永久断连
366
385
  */
367
386
  function scheduleReconnect() {
368
387
  if (isStopping || !shouldReconnect) {
369
388
  return;
370
389
  }
371
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
372
- log.error('Max reconnect attempts reached');
373
- return;
374
- }
375
- const interval = 5000; // 5秒后重连
376
390
  if (reconnectTimer) {
377
391
  return;
378
392
  }
393
+ // 超过最大重试次数后重置计数器,降低频率继续重试
394
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
395
+ log.warn(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, resetting counter and retrying at lower frequency`);
396
+ reconnectAttempts = 0;
397
+ }
398
+ // 逐步增加间隔,5s → 7.5s → 11s → ... 最大 60s
399
+ const backoff = Math.min(5000 * Math.pow(1.5, Math.floor(reconnectAttempts / 5)), 60000);
400
+ const interval = Math.round(backoff);
379
401
  reconnectTimer = setTimeout(async () => {
380
402
  reconnectTimer = null;
381
403
  reconnectAttempts++;
382
- log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
404
+ log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} (interval: ${interval}ms)`);
383
405
  try {
384
406
  await connectWebSocket();
385
407
  }
@@ -21,6 +21,8 @@ import { buildMediaContext } from '../shared/media-context.js';
21
21
  import { buildErrorNote, buildProgressNote } from '../shared/message-note.js';
22
22
  const log = createLogger('WeWorkHandler');
23
23
  const WEWORK_MEDIA_TIMEOUT_MS = 60_000;
24
+ // Safety timeout: abort hung tasks before stream expires (5 min TTL → 4.5 min safety)
25
+ const WEWORK_TASK_SAFETY_TIMEOUT_MS = 4.5 * 60 * 1000;
24
26
  async function saveWeWorkUrlMedia(payload, fallbackExtension) {
25
27
  if (!payload.url) {
26
28
  throw new Error("Missing WeWork media URL");
@@ -119,7 +121,8 @@ export async function buildMediaPrompt(data, kind) {
119
121
  text: contextText,
120
122
  });
121
123
  }
122
- catch {
124
+ catch (err) {
125
+ log.warn('Failed to download WeWork image, falling back to URL reference:', err);
123
126
  imageReference = `Remote image URL: ${imagePayload.url}`;
124
127
  }
125
128
  }
@@ -144,8 +147,8 @@ export async function buildMediaPrompt(data, kind) {
144
147
  text: contextText,
145
148
  });
146
149
  }
147
- catch {
148
- // Fall back to metadata-only prompt.
150
+ catch (err) {
151
+ log.warn('Failed to download WeWork media, falling back to metadata-only prompt:', err);
149
152
  }
150
153
  }
151
154
  return buildMediaMetadataPrompt({
@@ -185,9 +188,40 @@ export function setupWeWorkHandlers(config, sessionManager) {
185
188
  : undefined;
186
189
  log.info(`[handleAIRequest] Running ${aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
187
190
  const toolId = aiCommand;
188
- const msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, reqId);
191
+ let msgId;
192
+ try {
193
+ msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId, reqId);
194
+ }
195
+ 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;
204
+ }
189
205
  const stopTyping = startTypingLoop(chatId);
190
206
  const taskKey = `${userId}:${msgId}`;
207
+ // Safety timeout: abort hung tasks before stream expires, unblocking the queue
208
+ let safetyTimer = setTimeout(() => {
209
+ 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(() => { });
217
+ }
218
+ }, WEWORK_TASK_SAFETY_TIMEOUT_MS);
219
+ const clearSafetyTimer = () => {
220
+ if (safetyTimer) {
221
+ clearTimeout(safetyTimer);
222
+ safetyTimer = null;
223
+ }
224
+ };
191
225
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
192
226
  throttleMs: WEWORK_THROTTLE_MS,
193
227
  streamUpdate: async (content, toolNote) => {
@@ -206,6 +240,7 @@ export function setupWeWorkHandlers(config, sessionManager) {
206
240
  await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId, reqId);
207
241
  },
208
242
  extraCleanup: () => {
243
+ clearSafetyTimer();
209
244
  stopTyping();
210
245
  runningTasks.delete(taskKey);
211
246
  },