@wu529778790/open-im 1.11.8-beta.8 → 1.11.8-beta.9
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/clawbot/client.js +44 -78
- package/dist/qq/client.js +45 -53
- package/dist/shared/connection-manager.d.ts +111 -0
- package/dist/shared/connection-manager.js +157 -0
- package/dist/wework/client.js +79 -127
- package/dist/workbuddy/client.js +40 -74
- package/package.json +1 -1
package/dist/clawbot/client.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { createLogger } from '../logger.js';
|
|
12
|
-
import {
|
|
12
|
+
import { isFatalReconnectError } from '../shared/reconnect.js';
|
|
13
|
+
import { ReconnectManager, HeartbeatMonitor, StateManager } from '../shared/connection-manager.js';
|
|
13
14
|
import { cacheContextToken } from './message-sender.js';
|
|
14
15
|
import { setClawbotContextToken, clearClawbotContextToken } from '../shared/active-chats.js';
|
|
15
16
|
import { createMediaTargetPath } from '../shared/media-storage.js';
|
|
@@ -19,27 +20,30 @@ const log = createLogger('ClawBot');
|
|
|
19
20
|
const RECONNECT_DELAYS_MS = [3000, 5000, 10000, 20000, 30000];
|
|
20
21
|
const BASE_INFO = { channel_version: '0.1.0' };
|
|
21
22
|
let pollController = null;
|
|
22
|
-
let channelState = 'disconnected';
|
|
23
23
|
let messageHandler = null;
|
|
24
|
-
let stateChangeHandler = null;
|
|
25
|
-
let reconnectTimer = null;
|
|
26
|
-
let watchdogTimer = null;
|
|
27
|
-
let reconnectAttempt = 0;
|
|
28
|
-
let fatal = false;
|
|
29
24
|
let stopped = false;
|
|
30
25
|
let apiUrl = 'https://ilinkai.weixin.qq.com';
|
|
31
26
|
let apiToken = '';
|
|
32
27
|
/** Opaque cursor for getupdates pagination (replaces numeric offset) */
|
|
33
28
|
let getUpdatesBuf = '';
|
|
34
|
-
/** Timestamp of last successful poll response (for watchdog) */
|
|
35
|
-
let lastResponseAt = 0;
|
|
36
29
|
/** Per-request timeout for long-polling (ms) */
|
|
37
30
|
const POLL_REQUEST_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
|
|
38
31
|
/** Watchdog interval: force reconnect if no response for this long (ms) */
|
|
39
32
|
const WATCHDOG_INTERVAL_MS = 60_000; // check every 60s
|
|
40
33
|
const WATCHDOG_STALE_MS = 5 * 60 * 1000; // 5 minutes without response = stale
|
|
34
|
+
// Connection infrastructure (shared managers replace raw timers/counters)
|
|
35
|
+
const stateManager = new StateManager('disconnected');
|
|
36
|
+
const heartbeatMonitor = new HeartbeatMonitor();
|
|
37
|
+
const reconnectManager = new ReconnectManager({
|
|
38
|
+
name: 'ClawBot',
|
|
39
|
+
backoff: { mode: 'stepped', delays: RECONNECT_DELAYS_MS },
|
|
40
|
+
onReconnect: () => {
|
|
41
|
+
stateManager.set('connected');
|
|
42
|
+
startPolling();
|
|
43
|
+
},
|
|
44
|
+
});
|
|
41
45
|
export function getChannelState() {
|
|
42
|
-
return
|
|
46
|
+
return stateManager.current;
|
|
43
47
|
}
|
|
44
48
|
export async function initClawbot(config, eventHandler, onStateChange) {
|
|
45
49
|
const pc = config.platforms?.clawbot;
|
|
@@ -52,14 +56,14 @@ export async function initClawbot(config, eventHandler, onStateChange) {
|
|
|
52
56
|
apiUrl = pc.apiUrl ?? 'https://ilinkai.weixin.qq.com';
|
|
53
57
|
apiToken = pc.apiToken;
|
|
54
58
|
messageHandler = eventHandler;
|
|
55
|
-
|
|
59
|
+
stateManager.setOnChange(onStateChange);
|
|
56
60
|
stopped = false;
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
reconnectManager.reset();
|
|
62
|
+
reconnectManager.resume();
|
|
59
63
|
getUpdatesBuf = '';
|
|
60
64
|
// Start polling directly — no blocking connectivity check.
|
|
61
65
|
// The polling loop handles errors and reconnection internally.
|
|
62
|
-
|
|
66
|
+
stateManager.set('connected');
|
|
63
67
|
startPolling();
|
|
64
68
|
log.info('ClawBot client initialized');
|
|
65
69
|
}
|
|
@@ -82,18 +86,18 @@ function startPolling() {
|
|
|
82
86
|
base_info: BASE_INFO,
|
|
83
87
|
}, combinedSignal);
|
|
84
88
|
// Record successful response time for watchdog
|
|
85
|
-
|
|
89
|
+
heartbeatMonitor.recordResponse();
|
|
86
90
|
if (signal.aborted)
|
|
87
91
|
break;
|
|
88
92
|
if (!res.ok) {
|
|
89
93
|
// Detect fatal errors (e.g. errcode -14 "session timeout") — retrying won't help
|
|
90
94
|
if (res.errcode === -14 || isFatalReconnectError(res.error)) {
|
|
91
95
|
log.warn(`ClawBot fatal error (errcode=${res.errcode}), entering slow-probe mode`);
|
|
92
|
-
|
|
96
|
+
reconnectManager.setFatal(true);
|
|
93
97
|
getUpdatesBuf = ''; // session expired, cursor is stale
|
|
94
98
|
clearClawbotContextToken(); // session expired, token is stale
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
stateManager.set('error');
|
|
100
|
+
reconnectManager.schedule();
|
|
97
101
|
return;
|
|
98
102
|
}
|
|
99
103
|
log.warn(`ClawBot getupdates error: ${res.error ?? 'unknown'}`);
|
|
@@ -101,10 +105,9 @@ function startPolling() {
|
|
|
101
105
|
continue;
|
|
102
106
|
}
|
|
103
107
|
// Successful response — clear fatal mode and reset backoff
|
|
104
|
-
if (fatal ||
|
|
108
|
+
if (reconnectManager.fatal || reconnectManager.attemptCount > 0) {
|
|
105
109
|
log.info('ClawBot connection recovered');
|
|
106
|
-
|
|
107
|
-
reconnectAttempt = 0;
|
|
110
|
+
reconnectManager.reset();
|
|
108
111
|
}
|
|
109
112
|
// Update cursor for next poll
|
|
110
113
|
if (res.updatesBuf) {
|
|
@@ -189,69 +192,35 @@ function startPolling() {
|
|
|
189
192
|
if (err instanceof Error && err.name === 'AbortError')
|
|
190
193
|
break;
|
|
191
194
|
log.error('ClawBot polling error:', err);
|
|
192
|
-
|
|
193
|
-
|
|
195
|
+
stateManager.set('error');
|
|
196
|
+
reconnectManager.schedule();
|
|
194
197
|
return;
|
|
195
198
|
}
|
|
196
199
|
}
|
|
197
200
|
})();
|
|
198
201
|
}
|
|
199
|
-
function scheduleReconnect() {
|
|
200
|
-
if (stopped)
|
|
201
|
-
return;
|
|
202
|
-
if (reconnectTimer) {
|
|
203
|
-
clearTimeout(reconnectTimer);
|
|
204
|
-
reconnectTimer = null;
|
|
205
|
-
}
|
|
206
|
-
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
207
|
-
const delay = fatal ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
208
|
-
reconnectAttempt++;
|
|
209
|
-
if (fatal) {
|
|
210
|
-
log.warn(`ClawBot fatal error, slow-probe in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempt})...`);
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
log.info(`ClawBot reconnecting in ${delay}ms (attempt ${reconnectAttempt})...`);
|
|
214
|
-
}
|
|
215
|
-
reconnectTimer = setTimeout(() => {
|
|
216
|
-
reconnectTimer = null;
|
|
217
|
-
if (stopped)
|
|
218
|
-
return;
|
|
219
|
-
updateState('connected');
|
|
220
|
-
startPolling();
|
|
221
|
-
}, delay);
|
|
222
|
-
}
|
|
223
|
-
function updateState(state) {
|
|
224
|
-
channelState = state;
|
|
225
|
-
stateChangeHandler?.(state);
|
|
226
|
-
log.debug(`ClawBot state: ${state}`);
|
|
227
|
-
}
|
|
228
202
|
/**
|
|
229
203
|
* Watchdog: periodically check if the poll loop is alive.
|
|
230
204
|
* After Mac sleep or network drop, the fetch may hang without throwing.
|
|
231
205
|
* If no successful response for WATCHDOG_STALE_MS, force a reconnect.
|
|
232
206
|
*/
|
|
233
207
|
function startWatchdog() {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
watchdogTimer = setInterval(() => {
|
|
237
|
-
if (stopped)
|
|
238
|
-
return;
|
|
239
|
-
const elapsed = Date.now() - lastResponseAt;
|
|
240
|
-
if (elapsed > WATCHDOG_STALE_MS) {
|
|
241
|
-
log.warn(`ClawBot watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
242
|
-
if (pollController) {
|
|
243
|
-
pollController.abort();
|
|
244
|
-
pollController = null;
|
|
245
|
-
}
|
|
246
|
-
updateState('error');
|
|
247
|
-
scheduleReconnect();
|
|
248
|
-
}
|
|
249
|
-
}, WATCHDOG_INTERVAL_MS);
|
|
208
|
+
heartbeatMonitor.recordResponse();
|
|
209
|
+
heartbeatMonitor.start(WATCHDOG_INTERVAL_MS, watchdogTick);
|
|
250
210
|
}
|
|
251
|
-
function
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
211
|
+
function watchdogTick() {
|
|
212
|
+
if (stopped)
|
|
213
|
+
return;
|
|
214
|
+
if (heartbeatMonitor.isStale(WATCHDOG_STALE_MS)) {
|
|
215
|
+
const elapsed = Date.now() - heartbeatMonitor.lastResponseTime;
|
|
216
|
+
log.warn(`ClawBot watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
217
|
+
heartbeatMonitor.stop();
|
|
218
|
+
if (pollController) {
|
|
219
|
+
pollController.abort();
|
|
220
|
+
pollController = null;
|
|
221
|
+
}
|
|
222
|
+
stateManager.set('error');
|
|
223
|
+
reconnectManager.schedule();
|
|
255
224
|
}
|
|
256
225
|
}
|
|
257
226
|
/**
|
|
@@ -441,13 +410,10 @@ export function stopClawbot() {
|
|
|
441
410
|
pollController.abort();
|
|
442
411
|
pollController = null;
|
|
443
412
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
reconnectTimer = null;
|
|
447
|
-
}
|
|
448
|
-
stopWatchdog();
|
|
413
|
+
reconnectManager.stop();
|
|
414
|
+
heartbeatMonitor.stop();
|
|
449
415
|
messageHandler = null;
|
|
450
416
|
// Don't clear context_token here — it's persisted for startup notifications
|
|
451
|
-
|
|
417
|
+
stateManager.set('disconnected');
|
|
452
418
|
log.info('ClawBot client stopped');
|
|
453
419
|
}
|
package/dist/qq/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { createLogger } from "../logger.js";
|
|
3
|
-
import {
|
|
3
|
+
import { ReconnectManager, HeartbeatMonitor } from "../shared/connection-manager.js";
|
|
4
4
|
const log = createLogger("QQ");
|
|
5
5
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
6
6
|
const API_BASE = "https://api.sgroup.qq.com";
|
|
@@ -12,25 +12,23 @@ const INTENTS = {
|
|
|
12
12
|
};
|
|
13
13
|
let client = null;
|
|
14
14
|
let ws = null;
|
|
15
|
-
let heartbeatTimer = null;
|
|
16
|
-
let reconnectTimer = null;
|
|
17
15
|
let stopped = false;
|
|
18
16
|
let seq = null;
|
|
19
17
|
let sessionId = null;
|
|
20
|
-
let reconnectAttempt = 0;
|
|
21
18
|
let connecting = false; // 防止并发 connectWebSocket
|
|
22
19
|
let currentConfig = null;
|
|
23
20
|
let currentHandler = null;
|
|
24
21
|
let tokenState = null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
// Connection infrastructure (shared managers replace raw timers/counters)
|
|
23
|
+
const heartbeatMonitor = new HeartbeatMonitor();
|
|
24
|
+
const reconnectManager = new ReconnectManager({
|
|
25
|
+
name: 'QQ',
|
|
26
|
+
backoff: { mode: 'stepped', delays: RECONNECT_DELAYS_MS },
|
|
27
|
+
onReconnect: () => reconnectFn(),
|
|
28
|
+
});
|
|
29
|
+
async function reconnectFn() {
|
|
30
|
+
if (currentConfig && currentHandler) {
|
|
31
|
+
await connectWebSocket(currentConfig, currentHandler);
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
function buildAuthHeaders(token) {
|
|
@@ -143,28 +141,28 @@ function normalizeInboundEvent(payload) {
|
|
|
143
141
|
}
|
|
144
142
|
return null;
|
|
145
143
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
144
|
+
/**
|
|
145
|
+
* Heartbeat tick: send ping and detect stale connections.
|
|
146
|
+
* The heartbeat interval is dynamic (from QQ gateway HELLO message).
|
|
147
|
+
*/
|
|
148
|
+
let heartbeatIntervalMs = 30000;
|
|
149
|
+
function heartbeatTick() {
|
|
150
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
151
|
+
return;
|
|
152
|
+
if (heartbeatMonitor.isStale(heartbeatIntervalMs * 3)) {
|
|
153
|
+
const elapsed = Date.now() - heartbeatMonitor.lastResponseTime;
|
|
154
|
+
log.warn(`QQ dead connection: no response for ${Math.round(elapsed / 1000)}s, reconnecting`);
|
|
155
|
+
heartbeatMonitor.stop();
|
|
156
|
+
ws?.terminate();
|
|
157
|
+
reconnectFn().catch((err) => log.error('QQ reconnect from heartbeat failed:', err));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
ws.send(JSON.stringify({ op: 1, d: seq }));
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
log.warn('QQ heartbeat send failed:', err);
|
|
165
|
+
}
|
|
168
166
|
}
|
|
169
167
|
async function connectWebSocket(config, handler) {
|
|
170
168
|
// 防止并发连接
|
|
@@ -196,17 +194,17 @@ async function connectWebSocket(config, handler) {
|
|
|
196
194
|
};
|
|
197
195
|
socket.on("open", () => {
|
|
198
196
|
log.info("QQ gateway connected");
|
|
199
|
-
|
|
197
|
+
reconnectManager.reset();
|
|
200
198
|
});
|
|
201
199
|
socket.on("message", async (raw) => {
|
|
202
|
-
|
|
200
|
+
heartbeatMonitor.recordResponse();
|
|
203
201
|
try {
|
|
204
202
|
const payload = JSON.parse(raw.toString());
|
|
205
203
|
if (typeof payload.s === "number")
|
|
206
204
|
seq = payload.s;
|
|
207
205
|
if (payload.op === 10) {
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
heartbeatIntervalMs = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
207
|
+
heartbeatMonitor.start(heartbeatIntervalMs, heartbeatTick);
|
|
210
208
|
socket.send(JSON.stringify({
|
|
211
209
|
op: sessionId ? 6 : 2,
|
|
212
210
|
d: sessionId
|
|
@@ -253,7 +251,7 @@ async function connectWebSocket(config, handler) {
|
|
|
253
251
|
});
|
|
254
252
|
socket.on("close", (code, reason) => {
|
|
255
253
|
settle(() => { }); // 清理 ready timeout
|
|
256
|
-
|
|
254
|
+
heartbeatMonitor.stop();
|
|
257
255
|
ws = null;
|
|
258
256
|
const reasonStr = reason.toString();
|
|
259
257
|
if (code === 4009) {
|
|
@@ -273,19 +271,11 @@ async function connectWebSocket(config, handler) {
|
|
|
273
271
|
seq = null;
|
|
274
272
|
}
|
|
275
273
|
const isFatalClose = code === 4004 || code === 4006 || code === 4007;
|
|
276
|
-
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
277
|
-
const delay = isFatalClose ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
278
274
|
if (isFatalClose) {
|
|
279
|
-
|
|
275
|
+
reconnectManager.setFatal(true);
|
|
276
|
+
log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(5 * 60)}s 一次)`);
|
|
280
277
|
}
|
|
281
|
-
|
|
282
|
-
reconnectTimer = setTimeout(() => {
|
|
283
|
-
if (currentConfig && currentHandler) {
|
|
284
|
-
connectWebSocket(currentConfig, currentHandler).catch((err) => {
|
|
285
|
-
log.error("QQ reconnect failed:", err);
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}, delay);
|
|
278
|
+
reconnectManager.schedule();
|
|
289
279
|
});
|
|
290
280
|
});
|
|
291
281
|
}
|
|
@@ -306,6 +296,8 @@ export async function initQQ(config, eventHandler) {
|
|
|
306
296
|
stopped = false;
|
|
307
297
|
currentConfig = config;
|
|
308
298
|
currentHandler = eventHandler;
|
|
299
|
+
reconnectManager.reset();
|
|
300
|
+
reconnectManager.resume();
|
|
309
301
|
client = {
|
|
310
302
|
sendPrivateMessage: async (openid, content, replyToMessageId) => {
|
|
311
303
|
const res = await apiRequest(config, "POST", `/v2/users/${openid}/messages`, buildMessageBody(content, replyToMessageId));
|
|
@@ -328,7 +320,8 @@ export async function initQQ(config, eventHandler) {
|
|
|
328
320
|
}
|
|
329
321
|
export async function stopQQ() {
|
|
330
322
|
stopped = true;
|
|
331
|
-
|
|
323
|
+
heartbeatMonitor.stop();
|
|
324
|
+
reconnectManager.stop();
|
|
332
325
|
if (ws) {
|
|
333
326
|
ws.close(1000);
|
|
334
327
|
ws = null;
|
|
@@ -339,5 +332,4 @@ export async function stopQQ() {
|
|
|
339
332
|
tokenState = null;
|
|
340
333
|
sessionId = null;
|
|
341
334
|
seq = null;
|
|
342
|
-
lastServerResponseTime = 0;
|
|
343
335
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connection management infrastructure for self-managing platform clients.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates three cross-cutting concerns duplicated across wework, clawbot,
|
|
5
|
+
* qq, and workbuddy clients:
|
|
6
|
+
*
|
|
7
|
+
* - **ReconnectManager**: jittered backoff scheduling with fatal→slow-probe mode
|
|
8
|
+
* - **HeartbeatMonitor**: interval-based heartbeat / watchdog timer
|
|
9
|
+
* - **StateManager**: state variable with change-notification callback
|
|
10
|
+
*/
|
|
11
|
+
export interface ReconnectManagerConfig {
|
|
12
|
+
/** Platform name used in log messages. */
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* Delay strategy:
|
|
16
|
+
* - `{ mode: 'exponential', baseMs, maxMs, cap }` — exponential backoff
|
|
17
|
+
* `min(baseMs * 1.5^floor(attempt / cap), maxMs)`.
|
|
18
|
+
* - `{ mode: 'stepped', delays }` — stepped array; last entry repeats.
|
|
19
|
+
*/
|
|
20
|
+
backoff: {
|
|
21
|
+
mode: 'exponential';
|
|
22
|
+
baseMs: number;
|
|
23
|
+
maxMs: number;
|
|
24
|
+
cap?: number;
|
|
25
|
+
} | {
|
|
26
|
+
mode: 'stepped';
|
|
27
|
+
delays: number[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* After this many attempts the counter resets and retries continue
|
|
31
|
+
* (avoids permanent disconnection). 0 = unlimited (no reset).
|
|
32
|
+
*/
|
|
33
|
+
maxAttempts?: number;
|
|
34
|
+
/** Callback invoked after the computed delay. */
|
|
35
|
+
onReconnect: () => void | Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Manages reconnection scheduling with jittered backoff and fatal→slow-probe.
|
|
39
|
+
*
|
|
40
|
+
* Replaces the duplicated `scheduleReconnect()` pattern across 4 client files:
|
|
41
|
+
* timer cleanup, attempt counting, backoff computation, jitter, logging.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ReconnectManager {
|
|
44
|
+
private timer;
|
|
45
|
+
private attempt;
|
|
46
|
+
private _fatal;
|
|
47
|
+
private _stopped;
|
|
48
|
+
private readonly log;
|
|
49
|
+
private readonly cfg;
|
|
50
|
+
constructor(cfg: ReconnectManagerConfig);
|
|
51
|
+
get attemptCount(): number;
|
|
52
|
+
get fatal(): boolean;
|
|
53
|
+
get stopped(): boolean;
|
|
54
|
+
/** Mark a fatal (auth) error — next scheduleReconnect uses slow-probe delay. */
|
|
55
|
+
setFatal(value: boolean): void;
|
|
56
|
+
/** Reset attempt counter and fatal flag after a successful connection. */
|
|
57
|
+
reset(): void;
|
|
58
|
+
/**
|
|
59
|
+
* Schedule a reconnect attempt. Clears any pending timer, computes the
|
|
60
|
+
* backoff delay (with jitter), and invokes `onReconnect` after the delay.
|
|
61
|
+
*/
|
|
62
|
+
schedule(): void;
|
|
63
|
+
/** Cancel any pending reconnect timer. */
|
|
64
|
+
stop(): void;
|
|
65
|
+
/** Allow scheduling again (undo `stop`). */
|
|
66
|
+
resume(): void;
|
|
67
|
+
private clearTimer;
|
|
68
|
+
private computeBaseDelay;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Manages a setInterval-based heartbeat or watchdog timer.
|
|
72
|
+
*
|
|
73
|
+
* Replaces duplicated `heartbeatTimer` / `watchdogTimer` patterns:
|
|
74
|
+
* timer cleanup on re-start, clearInterval on stop.
|
|
75
|
+
*/
|
|
76
|
+
export declare class HeartbeatMonitor {
|
|
77
|
+
private timer;
|
|
78
|
+
private _lastResponseTime;
|
|
79
|
+
get lastResponseTime(): number;
|
|
80
|
+
/** Record that a response was received (for watchdog stale detection). */
|
|
81
|
+
recordResponse(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Start a periodic callback. Clears any existing timer first.
|
|
84
|
+
* @param intervalMs Interval in milliseconds.
|
|
85
|
+
* @param onTick Callback invoked on each tick.
|
|
86
|
+
*/
|
|
87
|
+
start(intervalMs: number, onTick: () => void | Promise<void>): void;
|
|
88
|
+
/** Clear the heartbeat timer. */
|
|
89
|
+
stop(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Watchdog convenience: returns true if more than `staleMs` have elapsed
|
|
92
|
+
* since the last `recordResponse()` call.
|
|
93
|
+
*/
|
|
94
|
+
isStale(staleMs: number): boolean;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Manages a connection state variable with change-notification callback.
|
|
98
|
+
*
|
|
99
|
+
* Replaces duplicated `connectionState` / `channelState` + `stateChangeHandler`
|
|
100
|
+
* patterns across wework, clawbot, and workbuddy clients.
|
|
101
|
+
*/
|
|
102
|
+
export declare class StateManager<T extends string> {
|
|
103
|
+
private _state;
|
|
104
|
+
private onChange?;
|
|
105
|
+
constructor(initialState: T);
|
|
106
|
+
get current(): T;
|
|
107
|
+
/** Register a callback invoked whenever the state changes. */
|
|
108
|
+
setOnChange(handler: ((state: T) => void) | undefined): void;
|
|
109
|
+
/** Transition to a new state and notify the callback if it changed. */
|
|
110
|
+
set(newState: T): void;
|
|
111
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connection management infrastructure for self-managing platform clients.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates three cross-cutting concerns duplicated across wework, clawbot,
|
|
5
|
+
* qq, and workbuddy clients:
|
|
6
|
+
*
|
|
7
|
+
* - **ReconnectManager**: jittered backoff scheduling with fatal→slow-probe mode
|
|
8
|
+
* - **HeartbeatMonitor**: interval-based heartbeat / watchdog timer
|
|
9
|
+
* - **StateManager**: state variable with change-notification callback
|
|
10
|
+
*/
|
|
11
|
+
import { createLogger } from '../logger.js';
|
|
12
|
+
import { jitteredDelay, SLOW_PROBE_MS } from './reconnect.js';
|
|
13
|
+
/**
|
|
14
|
+
* Manages reconnection scheduling with jittered backoff and fatal→slow-probe.
|
|
15
|
+
*
|
|
16
|
+
* Replaces the duplicated `scheduleReconnect()` pattern across 4 client files:
|
|
17
|
+
* timer cleanup, attempt counting, backoff computation, jitter, logging.
|
|
18
|
+
*/
|
|
19
|
+
export class ReconnectManager {
|
|
20
|
+
timer = null;
|
|
21
|
+
attempt = 0;
|
|
22
|
+
_fatal = false;
|
|
23
|
+
_stopped = false;
|
|
24
|
+
log;
|
|
25
|
+
cfg;
|
|
26
|
+
constructor(cfg) {
|
|
27
|
+
this.cfg = cfg;
|
|
28
|
+
this.log = createLogger(`${cfg.name}/Reconnect`);
|
|
29
|
+
}
|
|
30
|
+
get attemptCount() { return this.attempt; }
|
|
31
|
+
get fatal() { return this._fatal; }
|
|
32
|
+
get stopped() { return this._stopped; }
|
|
33
|
+
/** Mark a fatal (auth) error — next scheduleReconnect uses slow-probe delay. */
|
|
34
|
+
setFatal(value) { this._fatal = value; }
|
|
35
|
+
/** Reset attempt counter and fatal flag after a successful connection. */
|
|
36
|
+
reset() {
|
|
37
|
+
this.attempt = 0;
|
|
38
|
+
this._fatal = false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Schedule a reconnect attempt. Clears any pending timer, computes the
|
|
42
|
+
* backoff delay (with jitter), and invokes `onReconnect` after the delay.
|
|
43
|
+
*/
|
|
44
|
+
schedule() {
|
|
45
|
+
this.clearTimer();
|
|
46
|
+
if (this._stopped)
|
|
47
|
+
return;
|
|
48
|
+
const max = this.cfg.maxAttempts;
|
|
49
|
+
if (max && max > 0 && this.attempt >= max) {
|
|
50
|
+
this.log.warn(`Max reconnect attempts (${max}) reached, resetting counter`);
|
|
51
|
+
this.attempt = 0;
|
|
52
|
+
}
|
|
53
|
+
const baseDelay = this.computeBaseDelay();
|
|
54
|
+
const delay = this._fatal ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
55
|
+
this.attempt++;
|
|
56
|
+
const fatalTag = this._fatal ? ' [slow-probe]' : '';
|
|
57
|
+
this.log.info(`Reconnecting in ${delay}ms (attempt ${this.attempt})${fatalTag}...`);
|
|
58
|
+
this.timer = setTimeout(async () => {
|
|
59
|
+
this.timer = null;
|
|
60
|
+
try {
|
|
61
|
+
await this.cfg.onReconnect();
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
this.log.warn(`Reconnect callback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
65
|
+
}
|
|
66
|
+
}, delay);
|
|
67
|
+
}
|
|
68
|
+
/** Cancel any pending reconnect timer. */
|
|
69
|
+
stop() {
|
|
70
|
+
this._stopped = true;
|
|
71
|
+
this.clearTimer();
|
|
72
|
+
}
|
|
73
|
+
/** Allow scheduling again (undo `stop`). */
|
|
74
|
+
resume() {
|
|
75
|
+
this._stopped = false;
|
|
76
|
+
}
|
|
77
|
+
clearTimer() {
|
|
78
|
+
if (this.timer) {
|
|
79
|
+
clearTimeout(this.timer);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
computeBaseDelay() {
|
|
84
|
+
const { backoff } = this.cfg;
|
|
85
|
+
if (backoff.mode === 'exponential') {
|
|
86
|
+
const cap = backoff.cap ?? 5;
|
|
87
|
+
return Math.min(backoff.baseMs * Math.pow(1.5, Math.floor(this.attempt / cap)), backoff.maxMs);
|
|
88
|
+
}
|
|
89
|
+
const { delays } = backoff;
|
|
90
|
+
return delays[Math.min(this.attempt, delays.length - 1)];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// ── HeartbeatMonitor ─────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Manages a setInterval-based heartbeat or watchdog timer.
|
|
96
|
+
*
|
|
97
|
+
* Replaces duplicated `heartbeatTimer` / `watchdogTimer` patterns:
|
|
98
|
+
* timer cleanup on re-start, clearInterval on stop.
|
|
99
|
+
*/
|
|
100
|
+
export class HeartbeatMonitor {
|
|
101
|
+
timer = null;
|
|
102
|
+
_lastResponseTime = 0;
|
|
103
|
+
get lastResponseTime() { return this._lastResponseTime; }
|
|
104
|
+
/** Record that a response was received (for watchdog stale detection). */
|
|
105
|
+
recordResponse() { this._lastResponseTime = Date.now(); }
|
|
106
|
+
/**
|
|
107
|
+
* Start a periodic callback. Clears any existing timer first.
|
|
108
|
+
* @param intervalMs Interval in milliseconds.
|
|
109
|
+
* @param onTick Callback invoked on each tick.
|
|
110
|
+
*/
|
|
111
|
+
start(intervalMs, onTick) {
|
|
112
|
+
this.stop();
|
|
113
|
+
this.timer = setInterval(() => {
|
|
114
|
+
Promise.resolve(onTick()).catch(() => { });
|
|
115
|
+
}, intervalMs);
|
|
116
|
+
}
|
|
117
|
+
/** Clear the heartbeat timer. */
|
|
118
|
+
stop() {
|
|
119
|
+
if (this.timer) {
|
|
120
|
+
clearInterval(this.timer);
|
|
121
|
+
this.timer = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Watchdog convenience: returns true if more than `staleMs` have elapsed
|
|
126
|
+
* since the last `recordResponse()` call.
|
|
127
|
+
*/
|
|
128
|
+
isStale(staleMs) {
|
|
129
|
+
return this._lastResponseTime > 0 && Date.now() - this._lastResponseTime > staleMs;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ── StateManager ─────────────────────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Manages a connection state variable with change-notification callback.
|
|
135
|
+
*
|
|
136
|
+
* Replaces duplicated `connectionState` / `channelState` + `stateChangeHandler`
|
|
137
|
+
* patterns across wework, clawbot, and workbuddy clients.
|
|
138
|
+
*/
|
|
139
|
+
export class StateManager {
|
|
140
|
+
_state;
|
|
141
|
+
onChange;
|
|
142
|
+
constructor(initialState) {
|
|
143
|
+
this._state = initialState;
|
|
144
|
+
}
|
|
145
|
+
get current() { return this._state; }
|
|
146
|
+
/** Register a callback invoked whenever the state changes. */
|
|
147
|
+
setOnChange(handler) {
|
|
148
|
+
this.onChange = handler;
|
|
149
|
+
}
|
|
150
|
+
/** Transition to a new state and notify the callback if it changed. */
|
|
151
|
+
set(newState) {
|
|
152
|
+
if (this._state === newState)
|
|
153
|
+
return;
|
|
154
|
+
this._state = newState;
|
|
155
|
+
this.onChange?.(newState);
|
|
156
|
+
}
|
|
157
|
+
}
|
package/dist/wework/client.js
CHANGED
|
@@ -10,26 +10,44 @@
|
|
|
10
10
|
import { WebSocket } from 'ws';
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import { createLogger } from '../logger.js';
|
|
13
|
-
import {
|
|
13
|
+
import { isFatalReconnectError } from '../shared/reconnect.js';
|
|
14
|
+
import { ReconnectManager, HeartbeatMonitor, StateManager } from '../shared/connection-manager.js';
|
|
14
15
|
const log = createLogger('WeWork');
|
|
15
16
|
const DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com';
|
|
16
17
|
const HEARTBEAT_INTERVAL = 30000; // 30秒
|
|
17
18
|
const PONG_TIMEOUT = HEARTBEAT_INTERVAL * 3; // 90秒无任何服务端响应则判定连接死亡
|
|
18
|
-
|
|
19
|
-
// Global state
|
|
19
|
+
// Connection infrastructure (shared managers replace raw timers/counters)
|
|
20
20
|
let ws = null;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
let heartbeatTimer = null;
|
|
24
|
-
let reconnectAttempts = 0;
|
|
21
|
+
const stateManager = new StateManager('disconnected');
|
|
22
|
+
const heartbeatMonitor = new HeartbeatMonitor();
|
|
25
23
|
let shouldReconnect = false;
|
|
26
24
|
let isStopping = false;
|
|
27
|
-
let lastServerResponseTime = 0; // 上次收到服务端消息的时间
|
|
28
25
|
// Event handlers
|
|
29
26
|
let messageHandler = null;
|
|
30
|
-
let stateChangeHandler = null;
|
|
31
27
|
// Configuration
|
|
32
28
|
let config = null;
|
|
29
|
+
// Reconnect manager: exponential backoff 5s → 7.5s → 11s → ... max 60s
|
|
30
|
+
const reconnectManager = new ReconnectManager({
|
|
31
|
+
name: 'WeWork',
|
|
32
|
+
backoff: { mode: 'exponential', baseMs: 5000, maxMs: 60000, cap: 5 },
|
|
33
|
+
maxAttempts: 100,
|
|
34
|
+
onReconnect: async () => {
|
|
35
|
+
log.info('Reconnecting...');
|
|
36
|
+
try {
|
|
37
|
+
await connectWebSocket();
|
|
38
|
+
reconnectManager.setFatal(false); // 连上后恢复正常退避
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (isFatalReconnectError(err)) {
|
|
42
|
+
reconnectManager.setFatal(true);
|
|
43
|
+
log.warn('WeWork 致命错误(鉴权失败),转慢探测:', err);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
log.error('Reconnection failed:', err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
33
51
|
/**
|
|
34
52
|
* Generate unique request ID
|
|
35
53
|
*/
|
|
@@ -40,7 +58,7 @@ function generateReqId() {
|
|
|
40
58
|
* Get current connection state
|
|
41
59
|
*/
|
|
42
60
|
export function getConnectionState() {
|
|
43
|
-
return
|
|
61
|
+
return stateManager.current;
|
|
44
62
|
}
|
|
45
63
|
/**
|
|
46
64
|
* 主动推送消息 (aibot_send_msg)
|
|
@@ -48,7 +66,7 @@ export function getConnectionState() {
|
|
|
48
66
|
* 注意:需用户曾与机器人对话后,才能向该会话主动推送
|
|
49
67
|
*/
|
|
50
68
|
export function sendProactiveMessage(chatId, content) {
|
|
51
|
-
if (!ws ||
|
|
69
|
+
if (!ws || stateManager.current !== 'connected') {
|
|
52
70
|
throw new Error('Cannot send proactive message: WebSocket not connected');
|
|
53
71
|
}
|
|
54
72
|
if (!chatId) {
|
|
@@ -77,7 +95,7 @@ export function sendProactiveMessage(chatId, content) {
|
|
|
77
95
|
* 长连接模式下必须用此方式回复,透传 req_id
|
|
78
96
|
*/
|
|
79
97
|
export function sendWebSocketReply(reqId, body) {
|
|
80
|
-
if (!ws ||
|
|
98
|
+
if (!ws || stateManager.current !== 'connected') {
|
|
81
99
|
throw new Error('Cannot send reply: WebSocket not connected');
|
|
82
100
|
}
|
|
83
101
|
if (!reqId) {
|
|
@@ -109,11 +127,13 @@ export async function initWeWork(cfg, eventHandler, onStateChange) {
|
|
|
109
127
|
websocketUrl: cfg.weworkWsUrl || DEFAULT_WS_URL,
|
|
110
128
|
};
|
|
111
129
|
messageHandler = eventHandler;
|
|
112
|
-
|
|
130
|
+
stateManager.setOnChange(onStateChange);
|
|
113
131
|
isStopping = false;
|
|
114
132
|
shouldReconnect = false;
|
|
133
|
+
reconnectManager.reset();
|
|
134
|
+
reconnectManager.resume();
|
|
115
135
|
log.info('Initializing WeWork client');
|
|
116
|
-
// 首次连接支持重试:单独启用企微时偶发 TLS
|
|
136
|
+
// 首次连接支持重试:单独启用企微时偶发 TLS 连接失败,加飞书后因初始化顺序有"预热"效果则稳定
|
|
117
137
|
const maxAttempts = 3;
|
|
118
138
|
const retryDelayMs = 1500;
|
|
119
139
|
let lastErr = null;
|
|
@@ -136,7 +156,7 @@ export async function initWeWork(cfg, eventHandler, onStateChange) {
|
|
|
136
156
|
* Connect to WeWork WebSocket server
|
|
137
157
|
*/
|
|
138
158
|
async function connectWebSocket() {
|
|
139
|
-
if (
|
|
159
|
+
if (stateManager.current === 'connecting') {
|
|
140
160
|
log.warn('WebSocket connection already in progress');
|
|
141
161
|
return;
|
|
142
162
|
}
|
|
@@ -154,16 +174,16 @@ async function connectWebSocket() {
|
|
|
154
174
|
}
|
|
155
175
|
ws = null;
|
|
156
176
|
}
|
|
157
|
-
|
|
177
|
+
stateManager.set('connecting');
|
|
158
178
|
const websocketUrl = config.websocketUrl || DEFAULT_WS_URL;
|
|
159
179
|
return new Promise((resolve, reject) => {
|
|
160
180
|
try {
|
|
161
181
|
ws = new WebSocket(websocketUrl);
|
|
162
182
|
ws.on('open', async () => {
|
|
163
183
|
log.info('WeWork WebSocket connected');
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
184
|
+
reconnectManager.reset();
|
|
185
|
+
stateManager.set('connected');
|
|
186
|
+
heartbeatMonitor.start(HEARTBEAT_INTERVAL, heartbeatTick);
|
|
167
187
|
// 发送认证订阅消息,并等待服务端确认(否则 aibot_send_msg 会报 846609 not subscribed)
|
|
168
188
|
try {
|
|
169
189
|
await sendSubscribeAndWaitAck(resolve, reject);
|
|
@@ -176,7 +196,7 @@ async function connectWebSocket() {
|
|
|
176
196
|
}
|
|
177
197
|
});
|
|
178
198
|
ws.on('message', async (data) => {
|
|
179
|
-
|
|
199
|
+
heartbeatMonitor.recordResponse();
|
|
180
200
|
try {
|
|
181
201
|
const message = JSON.parse(data.toString());
|
|
182
202
|
await handleMessage(message);
|
|
@@ -187,21 +207,21 @@ async function connectWebSocket() {
|
|
|
187
207
|
});
|
|
188
208
|
ws.on('error', (err) => {
|
|
189
209
|
log.error('WeWork WebSocket error:', err);
|
|
190
|
-
|
|
210
|
+
stateManager.set('error');
|
|
191
211
|
reject(err);
|
|
192
212
|
});
|
|
193
213
|
ws.on('close', () => {
|
|
194
214
|
log.info('WeWork WebSocket closed');
|
|
195
|
-
|
|
196
|
-
|
|
215
|
+
heartbeatMonitor.stop();
|
|
216
|
+
stateManager.set('disconnected');
|
|
197
217
|
if (!isStopping && shouldReconnect) {
|
|
198
|
-
|
|
218
|
+
reconnectManager.schedule();
|
|
199
219
|
}
|
|
200
220
|
});
|
|
201
221
|
}
|
|
202
222
|
catch (err) {
|
|
203
223
|
log.error('Error creating WebSocket connection:', err);
|
|
204
|
-
|
|
224
|
+
stateManager.set('error');
|
|
205
225
|
reject(err);
|
|
206
226
|
}
|
|
207
227
|
});
|
|
@@ -279,7 +299,7 @@ async function handleMessage(message) {
|
|
|
279
299
|
* Send message to WeWork
|
|
280
300
|
*/
|
|
281
301
|
export function sendMessage(message) {
|
|
282
|
-
if (!ws ||
|
|
302
|
+
if (!ws || stateManager.current !== 'connected') {
|
|
283
303
|
log.warn('Cannot send message: WebSocket not connected');
|
|
284
304
|
return;
|
|
285
305
|
}
|
|
@@ -319,108 +339,43 @@ export function sendStreamWithItems(reqId, streamId, content, finish, msgItem) {
|
|
|
319
339
|
});
|
|
320
340
|
}
|
|
321
341
|
/**
|
|
322
|
-
*
|
|
323
|
-
*/
|
|
324
|
-
function updateState(state) {
|
|
325
|
-
connectionState = state;
|
|
326
|
-
if (stateChangeHandler) {
|
|
327
|
-
stateChangeHandler(state);
|
|
328
|
-
}
|
|
329
|
-
log.debug('Connection state:', state);
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* Start heartbeat to keep connection alive
|
|
342
|
+
* Heartbeat tick: send ping and detect stale connections.
|
|
333
343
|
* 同时检测服务端是否响应,超时无响应则主动断开触发重连
|
|
334
344
|
*/
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
ws.removeAllListeners();
|
|
347
|
-
ws.close();
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
/* ignore */
|
|
351
|
-
}
|
|
352
|
-
ws = null;
|
|
353
|
-
updateState('disconnected');
|
|
354
|
-
scheduleReconnect();
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const pingMessage = {
|
|
358
|
-
cmd: "ping" /* WeWorkCommand.PING */,
|
|
359
|
-
headers: {
|
|
360
|
-
req_id: generateReqId(),
|
|
361
|
-
},
|
|
362
|
-
body: {},
|
|
363
|
-
};
|
|
364
|
-
try {
|
|
365
|
-
ws.send(JSON.stringify(pingMessage));
|
|
366
|
-
log.debug('Sent ping');
|
|
367
|
-
}
|
|
368
|
-
catch (err) {
|
|
369
|
-
log.error('Error sending ping:', err);
|
|
370
|
-
}
|
|
345
|
+
function heartbeatTick() {
|
|
346
|
+
if (stateManager.current !== 'connected' || !ws)
|
|
347
|
+
return;
|
|
348
|
+
// 检测连接是否已死:长时间未收到任何服务端响应
|
|
349
|
+
if (heartbeatMonitor.isStale(PONG_TIMEOUT)) {
|
|
350
|
+
const elapsed = Date.now() - heartbeatMonitor.lastResponseTime;
|
|
351
|
+
log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
352
|
+
heartbeatMonitor.stop();
|
|
353
|
+
try {
|
|
354
|
+
ws.removeAllListeners();
|
|
355
|
+
ws.close();
|
|
371
356
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (heartbeatTimer) {
|
|
379
|
-
clearInterval(heartbeatTimer);
|
|
380
|
-
heartbeatTimer = null;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
/**
|
|
384
|
-
* Schedule reconnection attempt
|
|
385
|
-
* 超过 MAX_RECONNECT_ATTEMPTS 后自动重置计数器继续重试,避免永久断连
|
|
386
|
-
*/
|
|
387
|
-
/** 致命(鉴权)错误后转慢探测,避免紧密 hammer 网关 */
|
|
388
|
-
let fatalSlowProbe = false;
|
|
389
|
-
function scheduleReconnect() {
|
|
390
|
-
if (isStopping || !shouldReconnect) {
|
|
357
|
+
catch {
|
|
358
|
+
/* ignore */
|
|
359
|
+
}
|
|
360
|
+
ws = null;
|
|
361
|
+
stateManager.set('disconnected');
|
|
362
|
+
reconnectManager.schedule();
|
|
391
363
|
return;
|
|
392
364
|
}
|
|
393
|
-
|
|
394
|
-
|
|
365
|
+
const pingMessage = {
|
|
366
|
+
cmd: "ping" /* WeWorkCommand.PING */,
|
|
367
|
+
headers: {
|
|
368
|
+
req_id: generateReqId(),
|
|
369
|
+
},
|
|
370
|
+
body: {},
|
|
371
|
+
};
|
|
372
|
+
try {
|
|
373
|
+
ws.send(JSON.stringify(pingMessage));
|
|
374
|
+
log.debug('Sent ping');
|
|
395
375
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
log.warn(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, resetting counter and retrying at lower frequency`);
|
|
399
|
-
reconnectAttempts = 0;
|
|
376
|
+
catch (err) {
|
|
377
|
+
log.error('Error sending ping:', err);
|
|
400
378
|
}
|
|
401
|
-
// 逐步增加间隔,5s → 7.5s → 11s → ... 最大 60s;致命(鉴权)错误转慢探测
|
|
402
|
-
const backoff = fatalSlowProbe
|
|
403
|
-
? SLOW_PROBE_MS
|
|
404
|
-
: Math.min(5000 * Math.pow(1.5, Math.floor(reconnectAttempts / 5)), 60000);
|
|
405
|
-
const interval = jitteredDelay(Math.round(backoff));
|
|
406
|
-
reconnectTimer = setTimeout(async () => {
|
|
407
|
-
reconnectTimer = null;
|
|
408
|
-
reconnectAttempts++;
|
|
409
|
-
log.info(`Reconnecting... Attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} (interval: ${interval}ms${fatalSlowProbe ? ', slow-probe' : ''})`);
|
|
410
|
-
try {
|
|
411
|
-
await connectWebSocket();
|
|
412
|
-
fatalSlowProbe = false; // 连上后恢复正常退避
|
|
413
|
-
}
|
|
414
|
-
catch (err) {
|
|
415
|
-
if (isFatalReconnectError(err)) {
|
|
416
|
-
fatalSlowProbe = true;
|
|
417
|
-
log.warn('WeWork 致命错误(鉴权失败),转慢探测:', err);
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
log.error('Reconnection failed:', err);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}, interval);
|
|
424
379
|
}
|
|
425
380
|
/**
|
|
426
381
|
* Stop WeWork client
|
|
@@ -428,15 +383,12 @@ function scheduleReconnect() {
|
|
|
428
383
|
export function stopWeWork() {
|
|
429
384
|
isStopping = true;
|
|
430
385
|
shouldReconnect = false;
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
clearTimeout(reconnectTimer);
|
|
434
|
-
reconnectTimer = null;
|
|
435
|
-
}
|
|
386
|
+
heartbeatMonitor.stop();
|
|
387
|
+
reconnectManager.stop();
|
|
436
388
|
if (ws) {
|
|
437
389
|
ws.close();
|
|
438
390
|
ws = null;
|
|
439
391
|
}
|
|
440
|
-
|
|
392
|
+
stateManager.set('disconnected');
|
|
441
393
|
log.info('WeWork client stopped');
|
|
442
394
|
}
|
package/dist/workbuddy/client.js
CHANGED
|
@@ -9,7 +9,8 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { hostname, homedir } from 'node:os';
|
|
11
11
|
import { createLogger } from '../logger.js';
|
|
12
|
-
import {
|
|
12
|
+
import { isFatalReconnectError } from '../shared/reconnect.js';
|
|
13
|
+
import { ReconnectManager, HeartbeatMonitor, StateManager } from '../shared/connection-manager.js';
|
|
13
14
|
import { WorkBuddyOAuth } from './oauth.js';
|
|
14
15
|
import { WorkBuddyCentrifugeClient } from './centrifuge-client.js';
|
|
15
16
|
const log = createLogger('WorkBuddy');
|
|
@@ -18,19 +19,29 @@ const CHANNEL_HEARTBEAT_MS = 30_000;
|
|
|
18
19
|
// Global state
|
|
19
20
|
let oauthClient = null;
|
|
20
21
|
let centrifugeClient = null;
|
|
21
|
-
let channelState = 'disconnected';
|
|
22
22
|
let messageHandler = null;
|
|
23
|
-
let stateChangeHandler = null;
|
|
24
|
-
let heartbeatTimer = null;
|
|
25
|
-
let reconnectTimer = null;
|
|
26
|
-
let reconnectAttempt = 0;
|
|
27
|
-
let fatalSlowProbe = false;
|
|
28
23
|
let stopped = false;
|
|
29
24
|
let platformConfig = null;
|
|
30
25
|
/** 单飞刷新 token 的在途 Promise,避免并发 401 重复刷新 */
|
|
31
26
|
let refreshInFlight = null;
|
|
27
|
+
// Connection infrastructure (shared managers replace raw timers/counters)
|
|
28
|
+
const stateManager = new StateManager('disconnected');
|
|
29
|
+
const heartbeatMonitor = new HeartbeatMonitor();
|
|
30
|
+
const reconnectManager = new ReconnectManager({
|
|
31
|
+
name: 'WorkBuddy',
|
|
32
|
+
backoff: { mode: 'stepped', delays: RECONNECT_DELAYS_MS },
|
|
33
|
+
onReconnect: async () => {
|
|
34
|
+
try {
|
|
35
|
+
await connect();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
log.error('Reconnect attempt failed:', err);
|
|
39
|
+
reconnectManager.schedule();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
32
43
|
function getChannelState() {
|
|
33
|
-
return
|
|
44
|
+
return stateManager.current;
|
|
34
45
|
}
|
|
35
46
|
export async function initWorkBuddy(config, eventHandler, onStateChange) {
|
|
36
47
|
const pc = config.platforms?.workbuddy;
|
|
@@ -42,9 +53,10 @@ export async function initWorkBuddy(config, eventHandler, onStateChange) {
|
|
|
42
53
|
}
|
|
43
54
|
platformConfig = pc;
|
|
44
55
|
messageHandler = eventHandler;
|
|
45
|
-
|
|
56
|
+
stateManager.setOnChange(onStateChange);
|
|
46
57
|
stopped = false;
|
|
47
|
-
|
|
58
|
+
reconnectManager.reset();
|
|
59
|
+
reconnectManager.resume();
|
|
48
60
|
const baseDir = config.logDir ?? join(process.env.HOME ?? '', '.open-im');
|
|
49
61
|
const dataDir = join(baseDir, 'data');
|
|
50
62
|
if (!existsSync(dataDir))
|
|
@@ -83,10 +95,10 @@ async function connect() {
|
|
|
83
95
|
catch (err) {
|
|
84
96
|
log.error('Host workspace registration failed:', err);
|
|
85
97
|
if (isFatalReconnectError(err)) {
|
|
86
|
-
|
|
98
|
+
reconnectManager.setFatal(true);
|
|
87
99
|
log.warn('WorkBuddy 致命错误(鉴权失败),转慢探测(token 可能已过期):', err);
|
|
88
100
|
}
|
|
89
|
-
|
|
101
|
+
reconnectManager.schedule();
|
|
90
102
|
return;
|
|
91
103
|
}
|
|
92
104
|
// sessionId = userId_hostId_clawPath (matches plugin's buildSessionId format)
|
|
@@ -137,9 +149,8 @@ async function connect() {
|
|
|
137
149
|
onConnected: () => {
|
|
138
150
|
log.info('WorkBuddy Centrifuge connected');
|
|
139
151
|
log.info(`WeChat KF sessionId: ${workspaceSessionId}`);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
updateState('connected');
|
|
152
|
+
reconnectManager.reset();
|
|
153
|
+
stateManager.set('connected');
|
|
143
154
|
// Step 2: Register Claw workspace to get WeChat KF routing channel + sessionId
|
|
144
155
|
oauth.registerWorkspace({
|
|
145
156
|
userId: pc.userId ?? '',
|
|
@@ -152,7 +163,7 @@ async function connect() {
|
|
|
152
163
|
// Subscribe to Claw channel — WeChat KF messages are published here
|
|
153
164
|
centrifugeClient?.subscribeChannel(clawParams.channel, clawParams.subscriptionToken);
|
|
154
165
|
const doRegister = () => {
|
|
155
|
-
if (stopped ||
|
|
166
|
+
if (stopped || stateManager.current !== 'connected')
|
|
156
167
|
return;
|
|
157
168
|
if (replyLock) {
|
|
158
169
|
log.debug('Heartbeat skipped (reply in progress)');
|
|
@@ -168,14 +179,12 @@ async function connect() {
|
|
|
168
179
|
.catch((err) => log.warn(`registerChannel failed: ${String(err)}`));
|
|
169
180
|
};
|
|
170
181
|
doRegister();
|
|
171
|
-
|
|
172
|
-
clearInterval(heartbeatTimer);
|
|
173
|
-
heartbeatTimer = setInterval(doRegister, CHANNEL_HEARTBEAT_MS);
|
|
182
|
+
heartbeatMonitor.start(CHANNEL_HEARTBEAT_MS, doRegister);
|
|
174
183
|
}).catch((err) => {
|
|
175
184
|
log.error('Claw workspace registration failed:', err);
|
|
176
185
|
// Fallback: register with host sessionId
|
|
177
186
|
const doRegister = () => {
|
|
178
|
-
if (stopped ||
|
|
187
|
+
if (stopped || stateManager.current !== 'connected')
|
|
179
188
|
return;
|
|
180
189
|
if (replyLock) {
|
|
181
190
|
log.debug('Heartbeat skipped (reply in progress, fallback path)');
|
|
@@ -191,19 +200,14 @@ async function connect() {
|
|
|
191
200
|
.catch((e) => log.warn(`registerChannel failed: ${String(e)}`));
|
|
192
201
|
};
|
|
193
202
|
doRegister();
|
|
194
|
-
|
|
195
|
-
clearInterval(heartbeatTimer);
|
|
196
|
-
heartbeatTimer = setInterval(doRegister, CHANNEL_HEARTBEAT_MS);
|
|
203
|
+
heartbeatMonitor.start(CHANNEL_HEARTBEAT_MS, doRegister);
|
|
197
204
|
});
|
|
198
205
|
},
|
|
199
206
|
onDisconnected: (reason) => {
|
|
200
207
|
log.info(`WorkBuddy Centrifuge disconnected: ${reason}`);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
updateState('disconnected');
|
|
206
|
-
scheduleReconnect();
|
|
208
|
+
heartbeatMonitor.stop();
|
|
209
|
+
stateManager.set('disconnected');
|
|
210
|
+
reconnectManager.schedule();
|
|
207
211
|
},
|
|
208
212
|
onError: (error) => {
|
|
209
213
|
const msg = error instanceof Error ? error.message : JSON.stringify(error);
|
|
@@ -214,16 +218,13 @@ async function connect() {
|
|
|
214
218
|
else {
|
|
215
219
|
log.error(`WorkBuddy Centrifuge error: ${msg}`);
|
|
216
220
|
}
|
|
217
|
-
|
|
221
|
+
stateManager.set('error');
|
|
218
222
|
},
|
|
219
223
|
onPersistentFailure: () => {
|
|
220
224
|
log.warn('WorkBuddy Centrifuge persistent failure detected — doing full re-registration');
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
updateState('disconnected');
|
|
226
|
-
scheduleReconnect();
|
|
225
|
+
heartbeatMonitor.stop();
|
|
226
|
+
stateManager.set('disconnected');
|
|
227
|
+
reconnectManager.schedule();
|
|
227
228
|
},
|
|
228
229
|
onMessage: async (chatId, msgId, content) => {
|
|
229
230
|
if (messageHandler) {
|
|
@@ -238,35 +239,6 @@ async function connect() {
|
|
|
238
239
|
});
|
|
239
240
|
centrifugeClient.start();
|
|
240
241
|
}
|
|
241
|
-
function scheduleReconnect() {
|
|
242
|
-
if (stopped)
|
|
243
|
-
return;
|
|
244
|
-
if (reconnectTimer) {
|
|
245
|
-
clearTimeout(reconnectTimer);
|
|
246
|
-
reconnectTimer = null;
|
|
247
|
-
}
|
|
248
|
-
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
249
|
-
const delay = fatalSlowProbe ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
250
|
-
reconnectAttempt++;
|
|
251
|
-
log.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt})${fatalSlowProbe ? ' [slow-probe]' : ''}...`);
|
|
252
|
-
reconnectTimer = setTimeout(async () => {
|
|
253
|
-
reconnectTimer = null;
|
|
254
|
-
if (stopped)
|
|
255
|
-
return;
|
|
256
|
-
try {
|
|
257
|
-
await connect();
|
|
258
|
-
}
|
|
259
|
-
catch (err) {
|
|
260
|
-
log.error('Reconnect attempt failed:', err);
|
|
261
|
-
scheduleReconnect();
|
|
262
|
-
}
|
|
263
|
-
}, delay);
|
|
264
|
-
}
|
|
265
|
-
function updateState(state) {
|
|
266
|
-
channelState = state;
|
|
267
|
-
stateChangeHandler?.(state);
|
|
268
|
-
log.debug(`WorkBuddy state: ${state}`);
|
|
269
|
-
}
|
|
270
242
|
export function getCentrifugeClient() {
|
|
271
243
|
return centrifugeClient;
|
|
272
244
|
}
|
|
@@ -301,20 +273,14 @@ async function refreshWorkBuddyToken() {
|
|
|
301
273
|
export function stopWorkBuddy() {
|
|
302
274
|
log.info('Stopping WorkBuddy client...');
|
|
303
275
|
stopped = true;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
heartbeatTimer = null;
|
|
307
|
-
}
|
|
308
|
-
if (reconnectTimer) {
|
|
309
|
-
clearTimeout(reconnectTimer);
|
|
310
|
-
reconnectTimer = null;
|
|
311
|
-
}
|
|
276
|
+
heartbeatMonitor.stop();
|
|
277
|
+
reconnectManager.stop();
|
|
312
278
|
if (centrifugeClient) {
|
|
313
279
|
centrifugeClient.stop();
|
|
314
280
|
centrifugeClient = null;
|
|
315
281
|
}
|
|
316
282
|
oauthClient = null;
|
|
317
283
|
platformConfig = null;
|
|
318
|
-
|
|
284
|
+
stateManager.set('disconnected');
|
|
319
285
|
log.info('WorkBuddy client stopped');
|
|
320
286
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wu529778790/open-im",
|
|
3
|
-
"version": "1.11.8-beta.
|
|
3
|
+
"version": "1.11.8-beta.9",
|
|
4
4
|
"description": "Your AI coding assistant, in every chat app. Multi-platform IM bridge for Claude Code, Codex, and CodeBuddy.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|