@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.
- package/dist/access/access-control.js +1 -1
- package/dist/adapters/claude-sdk-adapter.js +85 -66
- package/dist/cli.js +5 -2
- package/dist/commands/handler.d.ts +0 -1
- package/dist/commands/handler.js +6 -20
- package/dist/config-web.js +109 -7
- package/dist/dingtalk/client.js +11 -3
- package/dist/dingtalk/event-handler.js +7 -3
- package/dist/dingtalk/message-sender.js +13 -0
- package/dist/feishu/event-handler.js +127 -11
- package/dist/index.js +9 -0
- package/dist/manager-control.js +7 -0
- package/dist/qq/client.js +111 -88
- package/dist/qq/event-handler.js +5 -2
- package/dist/qq/message-sender.js +11 -0
- package/dist/service-control.js +4 -0
- package/dist/session/session-manager.js +11 -1
- package/dist/shared/chat-user-map.js +11 -0
- package/dist/shared/media-storage.js +27 -0
- package/dist/telegram/client.js +3 -1
- package/dist/telegram/event-handler.js +41 -9
- package/dist/telegram/message-sender.js +13 -0
- package/dist/wechat/auth/qclaw-api.js +1 -1
- package/dist/wechat/client.js +81 -4
- package/dist/wechat/event-handler.js +7 -4
- package/dist/wework/client.js +32 -6
- package/dist/wework/event-handler.js +28 -4
- package/dist/wework/message-sender.js +53 -21
- package/package.json +2 -2
|
@@ -110,7 +110,9 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
110
110
|
try {
|
|
111
111
|
await sendTextReply(chatId, "启动 AI 处理失败,请重试。");
|
|
112
112
|
}
|
|
113
|
-
catch {
|
|
113
|
+
catch (err) {
|
|
114
|
+
log.warn('Failed to send startup error reply:', err);
|
|
115
|
+
}
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
118
|
const stopTyping = startTypingLoop(chatId);
|
|
@@ -180,7 +182,8 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
180
182
|
throttle.recordSuccess();
|
|
181
183
|
lastUpdateTime = Date.now();
|
|
182
184
|
}
|
|
183
|
-
catch {
|
|
185
|
+
catch (err) {
|
|
186
|
+
log.debug('Stream update failed:', err);
|
|
184
187
|
throttle.recordError();
|
|
185
188
|
}
|
|
186
189
|
finally {
|
|
@@ -194,7 +197,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
199
|
};
|
|
197
|
-
|
|
200
|
+
const wrapper = (content, toolNote) => {
|
|
198
201
|
if (content.startsWith("💭 **思考中...**")) {
|
|
199
202
|
return;
|
|
200
203
|
}
|
|
@@ -215,6 +218,17 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
215
218
|
performUpdate(content, toolNote);
|
|
216
219
|
}, Math.max(DEBOUNCE_MS, baseDelay));
|
|
217
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;
|
|
218
232
|
};
|
|
219
233
|
const streamUpdateWrapper = createStreamUpdateWrapper();
|
|
220
234
|
await runAITask({ config, sessionManager }, {
|
|
@@ -232,12 +246,30 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
232
246
|
},
|
|
233
247
|
sendComplete: async (content, note) => {
|
|
234
248
|
throttle.reset();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
}
|
|
241
273
|
}
|
|
242
274
|
},
|
|
243
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") {
|
package/dist/wechat/client.js
CHANGED
|
@@ -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 (
|
|
217
|
-
log.error('Max reconnect attempts reached');
|
|
284
|
+
if (reconnectTimer) {
|
|
218
285
|
return;
|
|
219
286
|
}
|
|
220
|
-
|
|
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
|
-
|
|
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({
|
|
@@ -133,7 +134,9 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
133
134
|
try {
|
|
134
135
|
await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
|
|
135
136
|
}
|
|
136
|
-
catch {
|
|
137
|
+
catch (err) {
|
|
138
|
+
log.warn('Failed to send startup error reply:', err);
|
|
139
|
+
}
|
|
137
140
|
return;
|
|
138
141
|
}
|
|
139
142
|
const stopTyping = startTypingLoop(chatId);
|
package/dist/wework/client.js
CHANGED
|
@@ -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;
|
|
@@ -173,6 +175,7 @@ async function connectWebSocket() {
|
|
|
173
175
|
}
|
|
174
176
|
});
|
|
175
177
|
ws.on('message', async (data) => {
|
|
178
|
+
lastServerResponseTime = Date.now();
|
|
176
179
|
try {
|
|
177
180
|
const message = JSON.parse(data.toString());
|
|
178
181
|
await handleMessage(message);
|
|
@@ -326,11 +329,30 @@ function updateState(state) {
|
|
|
326
329
|
}
|
|
327
330
|
/**
|
|
328
331
|
* Start heartbeat to keep connection alive
|
|
332
|
+
* 同时检测服务端是否响应,超时无响应则主动断开触发重连
|
|
329
333
|
*/
|
|
330
334
|
function startHeartbeat() {
|
|
331
335
|
stopHeartbeat();
|
|
336
|
+
lastServerResponseTime = Date.now();
|
|
332
337
|
heartbeatTimer = setInterval(() => {
|
|
333
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
|
+
}
|
|
334
356
|
const pingMessage = {
|
|
335
357
|
cmd: "ping" /* WeWorkCommand.PING */,
|
|
336
358
|
headers: {
|
|
@@ -359,23 +381,27 @@ function stopHeartbeat() {
|
|
|
359
381
|
}
|
|
360
382
|
/**
|
|
361
383
|
* Schedule reconnection attempt
|
|
384
|
+
* 超过 MAX_RECONNECT_ATTEMPTS 后自动重置计数器继续重试,避免永久断连
|
|
362
385
|
*/
|
|
363
386
|
function scheduleReconnect() {
|
|
364
387
|
if (isStopping || !shouldReconnect) {
|
|
365
388
|
return;
|
|
366
389
|
}
|
|
367
|
-
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
368
|
-
log.error('Max reconnect attempts reached');
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const interval = 5000; // 5秒后重连
|
|
372
390
|
if (reconnectTimer) {
|
|
373
391
|
return;
|
|
374
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);
|
|
375
401
|
reconnectTimer = setTimeout(async () => {
|
|
376
402
|
reconnectTimer = null;
|
|
377
403
|
reconnectAttempts++;
|
|
378
|
-
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
|
404
|
+
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} (interval: ${interval}ms)`);
|
|
379
405
|
try {
|
|
380
406
|
await connectWebSocket();
|
|
381
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
|
-
|
|
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({
|
|
@@ -194,11 +197,31 @@ export function setupWeWorkHandlers(config, sessionManager) {
|
|
|
194
197
|
try {
|
|
195
198
|
await sendTextReply(chatId, '启动 AI 处理失败,请重试。', reqId);
|
|
196
199
|
}
|
|
197
|
-
catch {
|
|
200
|
+
catch (err) {
|
|
201
|
+
log.warn('Failed to send startup error reply:', err);
|
|
202
|
+
}
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
200
205
|
const stopTyping = startTypingLoop(chatId);
|
|
201
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
|
+
};
|
|
202
225
|
await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
|
|
203
226
|
throttleMs: WEWORK_THROTTLE_MS,
|
|
204
227
|
streamUpdate: async (content, toolNote) => {
|
|
@@ -217,6 +240,7 @@ export function setupWeWorkHandlers(config, sessionManager) {
|
|
|
217
240
|
await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId, reqId);
|
|
218
241
|
},
|
|
219
242
|
extraCleanup: () => {
|
|
243
|
+
clearSafetyTimer();
|
|
220
244
|
stopTyping();
|
|
221
245
|
runningTasks.delete(taskKey);
|
|
222
246
|
},
|
|
@@ -101,6 +101,18 @@ function formatWeWorkMessage(title, content, status, note) {
|
|
|
101
101
|
return message;
|
|
102
102
|
}
|
|
103
103
|
const streamStates = new Map();
|
|
104
|
+
// Periodic cleanup of expired/orphaned stream states
|
|
105
|
+
const STREAM_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
106
|
+
setInterval(() => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
for (const [id, state] of streamStates) {
|
|
109
|
+
if (now - state.createdAt >= STREAM_SAFE_TTL_MS) {
|
|
110
|
+
state.closed = true;
|
|
111
|
+
streamStates.delete(id);
|
|
112
|
+
log.info(`Cleaned up expired stream state: ${id}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}, STREAM_CLEANUP_INTERVAL_MS);
|
|
104
116
|
function sleep(ms) {
|
|
105
117
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
118
|
}
|
|
@@ -187,6 +199,12 @@ export async function updateMessage(chatId, streamId, content, status, note, too
|
|
|
187
199
|
return;
|
|
188
200
|
if (Date.now() - state.createdAt >= STREAM_SAFE_TTL_MS) {
|
|
189
201
|
markExpired(state, streamId);
|
|
202
|
+
// Stream expired - fall back to text delivery for errors and final states
|
|
203
|
+
if (status === 'error' || status === 'done') {
|
|
204
|
+
const reqIdUsed = getReqId(reqId);
|
|
205
|
+
sendText(reqIdUsed, message);
|
|
206
|
+
log.info(`Stream expired, sent ${status} via text fallback: streamId=${streamId}`);
|
|
207
|
+
}
|
|
190
208
|
return;
|
|
191
209
|
}
|
|
192
210
|
state.pendingUpdate = { message, status, reqId };
|
|
@@ -207,17 +225,24 @@ export async function sendFinalMessages(chatId, streamId, fullContent, note, too
|
|
|
207
225
|
const title = getToolTitle(toolId, 'done');
|
|
208
226
|
const parts = splitLongContent(contentToSend, MAX_WEWORK_MESSAGE_LENGTH);
|
|
209
227
|
const finalMessage = formatWeWorkMessage(title, parts[0], 'done', parts.length > 1 ? `内容较长,已分段发送 (1/${parts.length})` : note);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
const state = streamStates.get(streamId);
|
|
229
|
+
const shouldFallbackToText = !!state && (state.expired || Date.now() - state.createdAt >= STREAM_SAFE_TTL_MS);
|
|
230
|
+
// 先发一条「输出中」带正文,再发 finish 的最终条,避免企微端一直停在「思考中」不刷新
|
|
231
|
+
// 独立 try-catch:即使此步失败,仍须发送 finish=true,否则企微永远卡在「正在思考」
|
|
232
|
+
if (!shouldFallbackToText && state && contentToSend.length > 0) {
|
|
233
|
+
try {
|
|
215
234
|
await updateMessage(chatId, streamId, contentToSend, 'streaming', note, toolId, reqId);
|
|
216
235
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
state.pendingUpdate = undefined;
|
|
236
|
+
catch (err) {
|
|
237
|
+
log.warn('Pre-finish streaming update failed, will still send finish=true:', err);
|
|
220
238
|
}
|
|
239
|
+
}
|
|
240
|
+
if (state) {
|
|
241
|
+
state.closed = true;
|
|
242
|
+
state.pendingUpdate = undefined;
|
|
243
|
+
}
|
|
244
|
+
// finish=true 是关键:必须保证发出,否则企微 UI 永远停留在「正在思考」
|
|
245
|
+
try {
|
|
221
246
|
if (!shouldFallbackToText) {
|
|
222
247
|
if (state) {
|
|
223
248
|
const elapsed = Date.now() - state.lastSentAt;
|
|
@@ -232,21 +257,28 @@ export async function sendFinalMessages(chatId, streamId, fullContent, note, too
|
|
|
232
257
|
sendText(getReqId(reqId), finalMessage);
|
|
233
258
|
log.info(`Final stream expired, sent text fallback instead: streamId=${streamId}`);
|
|
234
259
|
}
|
|
235
|
-
streamStates.delete(streamId);
|
|
236
|
-
for (let i = 1; i < parts.length; i++) {
|
|
237
|
-
try {
|
|
238
|
-
const partContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
|
|
239
|
-
const partMessage = formatWeWorkMessage(title, partContent, 'done', i === parts.length - 1 ? note : undefined);
|
|
240
|
-
sendText(getReqId(reqId), partMessage);
|
|
241
|
-
log.info(`Final message part ${i + 1}/${parts.length} sent`);
|
|
242
|
-
}
|
|
243
|
-
catch (err) {
|
|
244
|
-
log.error(`Failed to send part ${i + 1}:`, err);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
260
|
}
|
|
248
261
|
catch (err) {
|
|
249
|
-
log.
|
|
262
|
+
log.warn('Primary finish send failed, trying text fallback:', err);
|
|
263
|
+
try {
|
|
264
|
+
sendText(getReqId(reqId), finalMessage);
|
|
265
|
+
log.info(`Fallback text sent after primary finish failure, streamId=${streamId}`);
|
|
266
|
+
}
|
|
267
|
+
catch (fallbackErr) {
|
|
268
|
+
log.error('Both primary and fallback finish sends failed:', fallbackErr);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
streamStates.delete(streamId);
|
|
272
|
+
for (let i = 1; i < parts.length; i++) {
|
|
273
|
+
try {
|
|
274
|
+
const partContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
|
|
275
|
+
const partMessage = formatWeWorkMessage(title, partContent, 'done', i === parts.length - 1 ? note : undefined);
|
|
276
|
+
sendText(getReqId(reqId), partMessage);
|
|
277
|
+
log.info(`Final message part ${i + 1}/${parts.length} sent`);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
log.error(`Failed to send part ${i + 1}:`, err);
|
|
281
|
+
}
|
|
250
282
|
}
|
|
251
283
|
}
|
|
252
284
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wu529778790/open-im",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"url": "https://github.com/wu529778790/open-im/issues"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
49
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.86",
|
|
50
50
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
51
51
|
"@wu529778790/open-im": "^1.8.1-beta.8",
|
|
52
52
|
"centrifuge": "^5.3.0",
|