@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.
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { createLogger } from '../logger.js';
12
- import { jitteredDelay, isFatalReconnectError, SLOW_PROBE_MS } from '../shared/reconnect.js';
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 channelState;
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
- stateChangeHandler = onStateChange ?? null;
59
+ stateManager.setOnChange(onStateChange);
56
60
  stopped = false;
57
- reconnectAttempt = 0;
58
- fatal = false;
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
- updateState('connected');
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
- lastResponseAt = Date.now();
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
- fatal = true;
96
+ reconnectManager.setFatal(true);
93
97
  getUpdatesBuf = ''; // session expired, cursor is stale
94
98
  clearClawbotContextToken(); // session expired, token is stale
95
- updateState('error');
96
- scheduleReconnect();
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 || reconnectAttempt > 0) {
108
+ if (reconnectManager.fatal || reconnectManager.attemptCount > 0) {
105
109
  log.info('ClawBot connection recovered');
106
- fatal = false;
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
- updateState('error');
193
- scheduleReconnect();
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
- stopWatchdog();
235
- lastResponseAt = Date.now();
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 stopWatchdog() {
252
- if (watchdogTimer) {
253
- clearInterval(watchdogTimer);
254
- watchdogTimer = null;
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
- if (reconnectTimer) {
445
- clearTimeout(reconnectTimer);
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
- updateState('disconnected');
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 { jitteredDelay, SLOW_PROBE_MS } from "../shared/reconnect.js";
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
- let lastServerResponseTime = 0;
26
- function clearTimers() {
27
- if (heartbeatTimer) {
28
- clearInterval(heartbeatTimer);
29
- heartbeatTimer = null;
30
- }
31
- if (reconnectTimer) {
32
- clearTimeout(reconnectTimer);
33
- reconnectTimer = null;
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
- function startHeartbeat(intervalMs) {
147
- lastServerResponseTime = Date.now();
148
- if (heartbeatTimer)
149
- clearInterval(heartbeatTimer);
150
- heartbeatTimer = setInterval(() => {
151
- if (!ws || ws.readyState !== WebSocket.OPEN)
152
- return;
153
- const elapsed = Date.now() - lastServerResponseTime;
154
- if (lastServerResponseTime > 0 && elapsed > intervalMs * 3) {
155
- log.warn(`QQ dead connection: no response for ${Math.round(elapsed / 1000)}s, reconnecting`);
156
- clearTimers();
157
- ws?.terminate();
158
- connectWebSocket(currentConfig, currentHandler);
159
- return;
160
- }
161
- try {
162
- ws.send(JSON.stringify({ op: 1, d: seq }));
163
- }
164
- catch (err) {
165
- log.warn('QQ heartbeat send failed:', err);
166
- }
167
- }, intervalMs);
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
- reconnectAttempt = 0;
197
+ reconnectManager.reset();
200
198
  });
201
199
  socket.on("message", async (raw) => {
202
- lastServerResponseTime = Date.now();
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
- const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
209
- startHeartbeat(heartbeatInterval);
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
- clearTimers();
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
- log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(SLOW_PROBE_MS / 1000)}s 一次)`);
275
+ reconnectManager.setFatal(true);
276
+ log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(5 * 60)}s 一次)`);
280
277
  }
281
- reconnectAttempt += 1;
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
- clearTimers();
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
+ }
@@ -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 { jitteredDelay, SLOW_PROBE_MS, isFatalReconnectError } from '../shared/reconnect.js';
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
- const MAX_RECONNECT_ATTEMPTS = 100;
19
- // Global state
19
+ // Connection infrastructure (shared managers replace raw timers/counters)
20
20
  let ws = null;
21
- let connectionState = 'disconnected';
22
- let reconnectTimer = null;
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 connectionState;
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 || connectionState !== 'connected') {
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 || connectionState !== 'connected') {
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
- stateChangeHandler = onStateChange ?? null;
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 (connectionState === 'connecting') {
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
- updateState('connecting');
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
- reconnectAttempts = 0;
165
- updateState('connected');
166
- startHeartbeat();
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
- lastServerResponseTime = Date.now();
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
- updateState('error');
210
+ stateManager.set('error');
191
211
  reject(err);
192
212
  });
193
213
  ws.on('close', () => {
194
214
  log.info('WeWork WebSocket closed');
195
- stopHeartbeat();
196
- updateState('disconnected');
215
+ heartbeatMonitor.stop();
216
+ stateManager.set('disconnected');
197
217
  if (!isStopping && shouldReconnect) {
198
- scheduleReconnect();
218
+ reconnectManager.schedule();
199
219
  }
200
220
  });
201
221
  }
202
222
  catch (err) {
203
223
  log.error('Error creating WebSocket connection:', err);
204
- updateState('error');
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 || connectionState !== 'connected') {
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
- * Update connection state and notify listeners
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 startHeartbeat() {
336
- stopHeartbeat();
337
- lastServerResponseTime = Date.now();
338
- heartbeatTimer = setInterval(() => {
339
- if (connectionState === 'connected' && ws) {
340
- // 检测连接是否已死:长时间未收到任何服务端响应
341
- const elapsed = Date.now() - lastServerResponseTime;
342
- if (lastServerResponseTime > 0 && elapsed > PONG_TIMEOUT) {
343
- log.warn(`No server response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
344
- stopHeartbeat();
345
- try {
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
- }, HEARTBEAT_INTERVAL);
373
- }
374
- /**
375
- * Stop heartbeat
376
- */
377
- function stopHeartbeat() {
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
- if (reconnectTimer) {
394
- return;
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
- if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
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
- stopHeartbeat();
432
- if (reconnectTimer) {
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
- updateState('disconnected');
392
+ stateManager.set('disconnected');
441
393
  log.info('WeWork client stopped');
442
394
  }
@@ -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 { jitteredDelay, SLOW_PROBE_MS, isFatalReconnectError } from '../shared/reconnect.js';
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 channelState;
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
- stateChangeHandler = onStateChange ?? null;
56
+ stateManager.setOnChange(onStateChange);
46
57
  stopped = false;
47
- reconnectAttempt = 0;
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
- fatalSlowProbe = true;
98
+ reconnectManager.setFatal(true);
87
99
  log.warn('WorkBuddy 致命错误(鉴权失败),转慢探测(token 可能已过期):', err);
88
100
  }
89
- scheduleReconnect();
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
- reconnectAttempt = 0;
141
- fatalSlowProbe = false;
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 || channelState !== 'connected')
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
- if (heartbeatTimer)
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 || channelState !== 'connected')
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
- if (heartbeatTimer)
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
- if (heartbeatTimer) {
202
- clearInterval(heartbeatTimer);
203
- heartbeatTimer = null;
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
- updateState('error');
221
+ stateManager.set('error');
218
222
  },
219
223
  onPersistentFailure: () => {
220
224
  log.warn('WorkBuddy Centrifuge persistent failure detected — doing full re-registration');
221
- if (heartbeatTimer) {
222
- clearInterval(heartbeatTimer);
223
- heartbeatTimer = null;
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
- if (heartbeatTimer) {
305
- clearInterval(heartbeatTimer);
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
- updateState('disconnected');
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.8",
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",