@wu529778790/open-im 1.8.1-beta.11 → 1.8.1-beta.13
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/adapters/claude-sdk-adapter.js +14 -8
- package/dist/dingtalk/client.js +11 -3
- package/dist/qq/client.js +105 -87
- package/dist/wechat/client.js +47 -4
- package/dist/wework/client.js +32 -6
- package/package.json +1 -1
|
@@ -103,6 +103,7 @@ export class ClaudeSDKAdapter {
|
|
|
103
103
|
const abortController = new AbortController();
|
|
104
104
|
let streamClosed = false;
|
|
105
105
|
let actualSessionId;
|
|
106
|
+
let pendingTempId; // 记录临时 ID,用于 abort 时清理
|
|
106
107
|
let runSettled = false;
|
|
107
108
|
let timeoutId = null;
|
|
108
109
|
const timeoutMs = options?.timeoutMs ?? 600_000;
|
|
@@ -139,7 +140,10 @@ export class ClaudeSDKAdapter {
|
|
|
139
140
|
log.info(`[V2] Session: ${sessionId ?? 'new'}, prompt="${prompt.slice(0, 50)}..."`);
|
|
140
141
|
log.info(`[V2] model param=${String(options?.model ?? '')} baseUrl=${process.env.ANTHROPIC_BASE_URL ?? '(default)'}`);
|
|
141
142
|
// 获取或创建会话
|
|
142
|
-
const { session } = await getOrCreateSession(sessionId, workDir, options?.model, permissionMode);
|
|
143
|
+
const { session, sessionId: returnedId } = await getOrCreateSession(sessionId, workDir, options?.model, permissionMode);
|
|
144
|
+
if (returnedId.startsWith('pending-')) {
|
|
145
|
+
pendingTempId = returnedId;
|
|
146
|
+
}
|
|
143
147
|
// 发送用户消息
|
|
144
148
|
await session.send(prompt);
|
|
145
149
|
// 获取响应流
|
|
@@ -263,10 +267,11 @@ export class ClaudeSDKAdapter {
|
|
|
263
267
|
if (abortController.signal.aborted) {
|
|
264
268
|
log.info('Session run aborted');
|
|
265
269
|
clearRunTimeout();
|
|
266
|
-
// 清理 pending tempId
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
// 清理 pending tempId(abort 可能在 init 消息之前发生)
|
|
271
|
+
const idToClean = actualSessionId ?? pendingTempId;
|
|
272
|
+
if (idToClean?.startsWith('pending-')) {
|
|
273
|
+
activeSessions.delete(idToClean);
|
|
274
|
+
log.info(`Cleaned up pending session: ${idToClean}`);
|
|
270
275
|
}
|
|
271
276
|
return;
|
|
272
277
|
}
|
|
@@ -279,9 +284,10 @@ export class ClaudeSDKAdapter {
|
|
|
279
284
|
log.error(`Error stack: ${errorObj.stack}`);
|
|
280
285
|
}
|
|
281
286
|
// 清理 pending tempId(session 在获取真实 ID 前就失败了)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
287
|
+
const errIdToClean = actualSessionId ?? pendingTempId;
|
|
288
|
+
if (errIdToClean?.startsWith('pending-')) {
|
|
289
|
+
activeSessions.delete(errIdToClean);
|
|
290
|
+
log.info(`Cleaned up pending session after error: ${errIdToClean}`);
|
|
285
291
|
}
|
|
286
292
|
callbacks.onError(msg);
|
|
287
293
|
}
|
package/dist/dingtalk/client.js
CHANGED
|
@@ -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
|
|
51
|
-
if (!
|
|
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',
|
package/dist/qq/client.js
CHANGED
|
@@ -17,6 +17,7 @@ let stopped = false;
|
|
|
17
17
|
let seq = null;
|
|
18
18
|
let sessionId = null;
|
|
19
19
|
let reconnectAttempt = 0;
|
|
20
|
+
let connecting = false; // 防止并发 connectWebSocket
|
|
20
21
|
let currentConfig = null;
|
|
21
22
|
let currentHandler = null;
|
|
22
23
|
let tokenState = null;
|
|
@@ -147,99 +148,116 @@ function startHeartbeat(intervalMs) {
|
|
|
147
148
|
}, intervalMs);
|
|
148
149
|
}
|
|
149
150
|
async function connectWebSocket(config, handler) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (typeof payload.s === "number")
|
|
170
|
-
seq = payload.s;
|
|
171
|
-
if (payload.op === 10) {
|
|
172
|
-
const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
173
|
-
startHeartbeat(heartbeatInterval);
|
|
174
|
-
socket.send(JSON.stringify({
|
|
175
|
-
op: sessionId ? 6 : 2,
|
|
176
|
-
d: sessionId
|
|
177
|
-
? {
|
|
178
|
-
token: `QQBot ${token}`,
|
|
179
|
-
session_id: sessionId,
|
|
180
|
-
seq,
|
|
181
|
-
}
|
|
182
|
-
: {
|
|
183
|
-
token: `QQBot ${token}`,
|
|
184
|
-
intents: INTENTS.GROUP_AND_C2C |
|
|
185
|
-
INTENTS.DIRECT_MESSAGE |
|
|
186
|
-
INTENTS.PUBLIC_GUILD_MESSAGES,
|
|
187
|
-
properties: {
|
|
188
|
-
os: process.platform,
|
|
189
|
-
browser: "open-im",
|
|
190
|
-
device: "open-im",
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
}));
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
if (payload.op === 0 && payload.t === "READY") {
|
|
197
|
-
sessionId = String(payload.d?.session_id ?? "");
|
|
198
|
-
settle(resolve);
|
|
151
|
+
// 防止并发连接
|
|
152
|
+
if (connecting) {
|
|
153
|
+
log.warn("QQ gateway connection already in progress");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
connecting = true;
|
|
157
|
+
try {
|
|
158
|
+
const gatewayUrl = await getGatewayUrl(config);
|
|
159
|
+
const token = await fetchAccessToken(config);
|
|
160
|
+
await new Promise((resolve, reject) => {
|
|
161
|
+
const socket = new WebSocket(gatewayUrl);
|
|
162
|
+
ws = socket;
|
|
163
|
+
let settled = false;
|
|
164
|
+
let readyTimeoutId = setTimeout(() => {
|
|
165
|
+
readyTimeoutId = null;
|
|
166
|
+
settle(() => reject(new Error("QQ gateway ready timeout")));
|
|
167
|
+
}, 15000);
|
|
168
|
+
const settle = (fn) => {
|
|
169
|
+
if (settled)
|
|
199
170
|
return;
|
|
171
|
+
settled = true;
|
|
172
|
+
if (readyTimeoutId) {
|
|
173
|
+
clearTimeout(readyTimeoutId);
|
|
174
|
+
readyTimeoutId = null;
|
|
200
175
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
176
|
+
fn();
|
|
177
|
+
};
|
|
178
|
+
socket.on("open", () => {
|
|
179
|
+
log.info("QQ gateway connected");
|
|
180
|
+
reconnectAttempt = 0;
|
|
181
|
+
});
|
|
182
|
+
socket.on("message", async (raw) => {
|
|
183
|
+
try {
|
|
184
|
+
const payload = JSON.parse(raw.toString());
|
|
185
|
+
if (typeof payload.s === "number")
|
|
186
|
+
seq = payload.s;
|
|
187
|
+
if (payload.op === 10) {
|
|
188
|
+
const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
189
|
+
startHeartbeat(heartbeatInterval);
|
|
190
|
+
socket.send(JSON.stringify({
|
|
191
|
+
op: sessionId ? 6 : 2,
|
|
192
|
+
d: sessionId
|
|
193
|
+
? {
|
|
194
|
+
token: `QQBot ${token}`,
|
|
195
|
+
session_id: sessionId,
|
|
196
|
+
seq,
|
|
197
|
+
}
|
|
198
|
+
: {
|
|
199
|
+
token: `QQBot ${token}`,
|
|
200
|
+
intents: INTENTS.GROUP_AND_C2C |
|
|
201
|
+
INTENTS.DIRECT_MESSAGE |
|
|
202
|
+
INTENTS.PUBLIC_GUILD_MESSAGES,
|
|
203
|
+
properties: {
|
|
204
|
+
os: process.platform,
|
|
205
|
+
browser: "open-im",
|
|
206
|
+
device: "open-im",
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (payload.op === 0 && payload.t === "READY") {
|
|
213
|
+
sessionId = String(payload.d?.session_id ?? "");
|
|
214
|
+
settle(resolve);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (payload.op === 0 && payload.t === "RESUMED") {
|
|
218
|
+
settle(resolve);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const event = normalizeInboundEvent(payload);
|
|
222
|
+
if (event && (event.content || (event.attachments?.length ?? 0) > 0)) {
|
|
223
|
+
await handler(event);
|
|
224
|
+
}
|
|
204
225
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
await handler(event);
|
|
226
|
+
catch (error) {
|
|
227
|
+
log.error("Failed to handle QQ gateway payload:", error);
|
|
208
228
|
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
log.error("
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
tokenState = null;
|
|
226
|
-
sessionId = null;
|
|
227
|
-
seq = null;
|
|
228
|
-
}
|
|
229
|
-
const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
230
|
-
reconnectAttempt += 1;
|
|
231
|
-
reconnectTimer = setTimeout(() => {
|
|
232
|
-
if (currentConfig && currentHandler) {
|
|
233
|
-
connectWebSocket(currentConfig, currentHandler).catch((err) => {
|
|
234
|
-
log.error("QQ reconnect failed:", err);
|
|
235
|
-
});
|
|
229
|
+
});
|
|
230
|
+
socket.on("error", (error) => {
|
|
231
|
+
log.error("QQ gateway error:", error);
|
|
232
|
+
settle(() => reject(error));
|
|
233
|
+
});
|
|
234
|
+
socket.on("close", (code, reason) => {
|
|
235
|
+
settle(() => { }); // 清理 ready timeout
|
|
236
|
+
clearTimers();
|
|
237
|
+
ws = null;
|
|
238
|
+
log.info(`QQ gateway closed: ${code} ${reason.toString()}`);
|
|
239
|
+
if (stopped)
|
|
240
|
+
return;
|
|
241
|
+
if (code === 4004 || code === 4006 || code === 4007 || code === 4009) {
|
|
242
|
+
tokenState = null;
|
|
243
|
+
sessionId = null;
|
|
244
|
+
seq = null;
|
|
236
245
|
}
|
|
237
|
-
|
|
246
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
247
|
+
reconnectAttempt += 1;
|
|
248
|
+
reconnectTimer = setTimeout(() => {
|
|
249
|
+
if (currentConfig && currentHandler) {
|
|
250
|
+
connectWebSocket(currentConfig, currentHandler).catch((err) => {
|
|
251
|
+
log.error("QQ reconnect failed:", err);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}, delay);
|
|
255
|
+
});
|
|
238
256
|
});
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
connecting = false;
|
|
260
|
+
}
|
|
243
261
|
}
|
|
244
262
|
export function getQQBot() {
|
|
245
263
|
if (!client || !currentConfig) {
|
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,6 +97,7 @@ 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;
|
|
@@ -108,6 +114,7 @@ async function connectWebSocket(config) {
|
|
|
108
114
|
resolve();
|
|
109
115
|
});
|
|
110
116
|
ws.on('message', async (data) => {
|
|
117
|
+
lastServerResponseTime = Date.now();
|
|
111
118
|
try {
|
|
112
119
|
const envelope = JSON.parse(data.toString());
|
|
113
120
|
log.debug('Received AGP message:', envelope.method);
|
|
@@ -190,11 +197,35 @@ function updateState(state) {
|
|
|
190
197
|
}
|
|
191
198
|
/**
|
|
192
199
|
* Start heartbeat to keep connection alive
|
|
200
|
+
* 同时检测服务端是否响应,超时无响应则主动断开触发重连
|
|
193
201
|
*/
|
|
194
202
|
function startHeartbeat(interval) {
|
|
195
203
|
stopHeartbeat();
|
|
204
|
+
lastServerResponseTime = Date.now();
|
|
196
205
|
heartbeatTimer = setInterval(() => {
|
|
197
206
|
if (channelState === 'connected') {
|
|
207
|
+
// 检测连接是否已死:长时间未收到任何服务端响应
|
|
208
|
+
const elapsed = Date.now() - lastServerResponseTime;
|
|
209
|
+
const pongTimeout = interval * PONG_TIMEOUT_FACTOR;
|
|
210
|
+
if (lastServerResponseTime > 0 && elapsed > pongTimeout) {
|
|
211
|
+
log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
212
|
+
stopHeartbeat();
|
|
213
|
+
if (ws) {
|
|
214
|
+
try {
|
|
215
|
+
ws.removeAllListeners();
|
|
216
|
+
ws.close();
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
/* ignore */
|
|
220
|
+
}
|
|
221
|
+
ws = null;
|
|
222
|
+
}
|
|
223
|
+
updateState('disconnected');
|
|
224
|
+
if (wsConfigRef) {
|
|
225
|
+
scheduleReconnect(wsConfigRef);
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
198
229
|
sendAGPMessage('ping', { timestamp: Date.now() });
|
|
199
230
|
}
|
|
200
231
|
}, interval);
|
|
@@ -210,17 +241,28 @@ function stopHeartbeat() {
|
|
|
210
241
|
}
|
|
211
242
|
/**
|
|
212
243
|
* Schedule reconnection attempt
|
|
244
|
+
* 超过 maxAttempts 后自动重置计数器继续重试,避免永久断连
|
|
213
245
|
*/
|
|
214
246
|
function scheduleReconnect(config) {
|
|
247
|
+
if (isStopping)
|
|
248
|
+
return;
|
|
215
249
|
const maxAttempts = config.maxReconnectAttempts ?? 10;
|
|
216
|
-
if (
|
|
217
|
-
log.error('Max reconnect attempts reached');
|
|
250
|
+
if (reconnectTimer) {
|
|
218
251
|
return;
|
|
219
252
|
}
|
|
220
|
-
|
|
253
|
+
// 超过最大重试次数后重置计数器,降低频率继续重试
|
|
254
|
+
if (reconnectAttempts >= maxAttempts) {
|
|
255
|
+
log.warn(`Max reconnect attempts (${maxAttempts}) reached, resetting counter and retrying at lower frequency`);
|
|
256
|
+
reconnectAttempts = 0;
|
|
257
|
+
}
|
|
258
|
+
const baseInterval = config.reconnectInterval ?? 5000;
|
|
259
|
+
// 超过一半次数后逐渐增加间隔,最大 60 秒
|
|
260
|
+
const backoff = Math.min(baseInterval * Math.pow(1.5, Math.floor(reconnectAttempts / 3)), 60000);
|
|
261
|
+
const interval = Math.round(backoff);
|
|
221
262
|
reconnectTimer = setTimeout(async () => {
|
|
263
|
+
reconnectTimer = null;
|
|
222
264
|
reconnectAttempts++;
|
|
223
|
-
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts}`);
|
|
265
|
+
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${maxAttempts} (interval: ${interval}ms)`);
|
|
224
266
|
try {
|
|
225
267
|
await connectWebSocket(config);
|
|
226
268
|
}
|
|
@@ -274,6 +316,7 @@ function saveToken() {
|
|
|
274
316
|
* Stop WeChat client
|
|
275
317
|
*/
|
|
276
318
|
export function stopWeChat() {
|
|
319
|
+
isStopping = true;
|
|
277
320
|
stopHeartbeat();
|
|
278
321
|
if (reconnectTimer) {
|
|
279
322
|
clearTimeout(reconnectTimer);
|
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
|
}
|