@youdu/openclaw-youdu 2026.6.26 → 2026.8.20

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.
@@ -1,3 +1,9 @@
1
1
  import { type ChannelPlugin } from "openclaw/plugin-sdk/core";
2
2
  import { type ResolvedYouDuAccount } from './utils.js';
3
- export declare const youduPlugin: ChannelPlugin<ResolvedYouDuAccount>;
3
+ import type { WsConnectionSnapshot } from "./sdk/types.js";
4
+ export type YouDuProbeResult = WsConnectionSnapshot & {
5
+ ok: boolean;
6
+ status: number;
7
+ error?: string;
8
+ };
9
+ export declare const youduPlugin: ChannelPlugin<ResolvedYouDuAccount, YouDuProbeResult>;
@@ -1,10 +1,11 @@
1
- import { monitorYouDuProvider } from './monitor.js';
1
+ import { monitorYouDuProvider, YOUDU_CONFIGURATION_ERROR_HEALTH_STATE } from './monitor.js';
2
2
  import { resolveYouDuAccount } from './utils.js';
3
3
  import { TEXT_CHUNK_LIMIT, CHANNEL_ID, DEFAULT_DM_POLICY } from './const.js';
4
4
  import { youduSetupAdapter, youduSetupWizard } from './onboarding.js';
5
5
  import { DEFAULT_ACCOUNT_ID, formatPairingApproveHint } from './openclaw-compat.js';
6
6
  import { getYouDuRuntime } from './runtime.js';
7
7
  import { sendYouDuMediaMessage, sendYouDuTextMessage } from './message-sender.js';
8
+ import { getYouDuWebSocket } from './state-manager.js';
8
9
 
9
10
  /*
10
11
  * Licensed under the MIT License.
@@ -19,6 +20,50 @@ const meta = {
19
20
  blurb: '有度机器人接入插件',
20
21
  systemImage: 'message.fill',
21
22
  };
23
+ function toProbeResult(snapshot) {
24
+ const ok = snapshot.state === "authenticated" && snapshot.authenticated;
25
+ return {
26
+ ok,
27
+ status: ok ? 200 : 503,
28
+ ...snapshot,
29
+ ...(ok
30
+ ? {}
31
+ : { error: snapshot.lastError ?? `WebSocket is ${snapshot.state}` }),
32
+ };
33
+ }
34
+ function connectionStatusFields(value) {
35
+ const source = value && typeof value === "object" ? value : {};
36
+ const state = source.state ?? "stopped";
37
+ const authenticated = state === "authenticated" && source.authenticated === true;
38
+ return {
39
+ state,
40
+ authenticated,
41
+ connected: authenticated,
42
+ healthState: state,
43
+ reconnectAttempts: source.reconnectAttempts ?? 0,
44
+ reconnectPhase: source.reconnectPhase ?? "fast",
45
+ nextRetryAt: source.nextRetryAt ?? null,
46
+ lastConnectedAt: source.lastConnectedAt ?? null,
47
+ lastAuthenticatedAt: source.lastAuthenticatedAt ?? null,
48
+ lastDisconnectedAt: source.lastDisconnectedAt ?? null,
49
+ lastError: source.lastError ?? null,
50
+ };
51
+ }
52
+ function probeFromSnapshot(snapshot) {
53
+ if (!snapshot || typeof snapshot !== "object")
54
+ return undefined;
55
+ return snapshot.probe;
56
+ }
57
+ function hasAuthenticationConfigurationError(...values) {
58
+ return values.some((value) => {
59
+ if (!value || typeof value !== "object")
60
+ return false;
61
+ const record = value;
62
+ if (record.healthState === YOUDU_CONFIGURATION_ERROR_HEALTH_STATE)
63
+ return true;
64
+ return [record.lastError, record.error].some((message) => typeof message === "string" && /\b1101\b/.test(message));
65
+ });
66
+ }
22
67
  const youduPlugin = {
23
68
  id: CHANNEL_ID,
24
69
  meta: {
@@ -198,20 +243,45 @@ const youduPlugin = {
198
243
  return issues;
199
244
  });
200
245
  },
201
- buildChannelSummary: ({ snapshot }) => ({
202
- configured: snapshot.configured ?? false,
203
- running: snapshot.running ?? false,
204
- lastStartAt: snapshot.lastStartAt ?? null,
205
- lastStopAt: snapshot.lastStopAt ?? null,
206
- lastError: snapshot.lastError ?? null,
207
- }),
208
- probeAccount: async () => {
209
- return { ok: true, status: 200 };
246
+ buildChannelSummary: ({ snapshot }) => {
247
+ const probe = probeFromSnapshot(snapshot);
248
+ const configurationError = hasAuthenticationConfigurationError(snapshot, probe);
249
+ return {
250
+ configured: configurationError ? false : snapshot.configured ?? false,
251
+ running: snapshot.running ?? false,
252
+ lastStartAt: snapshot.lastStartAt ?? null,
253
+ lastStopAt: snapshot.lastStopAt ?? null,
254
+ ...connectionStatusFields(probe ?? snapshot),
255
+ ...(configurationError
256
+ ? { healthState: YOUDU_CONFIGURATION_ERROR_HEALTH_STATE }
257
+ : {}),
258
+ };
259
+ },
260
+ probeAccount: async ({ account }) => {
261
+ const wsClient = getYouDuWebSocket(account.accountId);
262
+ if (!wsClient) {
263
+ return toProbeResult({
264
+ state: "stopped",
265
+ authenticated: false,
266
+ reconnectAttempts: 0,
267
+ reconnectPhase: "fast",
268
+ nextRetryAt: null,
269
+ lastConnectedAt: null,
270
+ lastAuthenticatedAt: null,
271
+ lastDisconnectedAt: null,
272
+ lastError: "WebSocket client is not running",
273
+ });
274
+ }
275
+ return toProbeResult(wsClient.getConnectionSnapshot());
210
276
  },
211
- buildAccountSnapshot: ({ account, runtime }) => {
212
- const configured = Boolean(account.botId?.trim() &&
277
+ buildAccountSnapshot: ({ account, runtime, probe }) => {
278
+ const configurationError = hasAuthenticationConfigurationError(runtime, probe);
279
+ const configuredByCredentials = Boolean(account.botId?.trim() &&
213
280
  account.secret?.trim() &&
214
281
  account.url?.trim());
282
+ const configured = configurationError || runtime?.configured === false
283
+ ? false
284
+ : configuredByCredentials;
215
285
  return {
216
286
  accountId: account.accountId,
217
287
  name: account.name,
@@ -220,7 +290,10 @@ const youduPlugin = {
220
290
  running: runtime?.running ?? false,
221
291
  lastStartAt: runtime?.lastStartAt ?? null,
222
292
  lastStopAt: runtime?.lastStopAt ?? null,
223
- lastError: runtime?.lastError ?? null,
293
+ ...connectionStatusFields(probe ?? probeFromSnapshot(runtime) ?? runtime),
294
+ ...(configurationError
295
+ ? { healthState: YOUDU_CONFIGURATION_ERROR_HEALTH_STATE }
296
+ : {}),
224
297
  };
225
298
  },
226
299
  },
@@ -232,6 +305,8 @@ const youduPlugin = {
232
305
  config: ctx.cfg,
233
306
  runtime: ctx.runtime,
234
307
  abortSignal: ctx.abortSignal,
308
+ getStatus: ctx.getStatus,
309
+ setStatus: ctx.setStatus,
235
310
  });
236
311
  },
237
312
  logoutAccount: async ({ cfg }) => {
@@ -11,18 +11,12 @@ export declare const UPLOAD_MEDIA_URL = "/cgi/bot/file/upload";
11
11
  export declare const TEXT_CHUNK_LIMIT = 4000;
12
12
  /** WebSocket 心跳间隔(毫秒) */
13
13
  export declare const WS_HEARTBEAT_INTERVAL_MS = 30000;
14
- /** WebSocket 最大重连次数 */
14
+ /** WebSocket 快速重连阶段次数;超过后转入低频持续恢复。 */
15
15
  export declare const WS_MAX_RECONNECT_ATTEMPTS = 10;
16
- /** 消息处理总超时时间(毫秒,被两级超时机制取代,保留兼容) */
17
- export declare const MESSAGE_PROCESS_TIMEOUT_MS: number;
18
- /** 第一级超时:警告阈值(毫秒)- 10 分钟,发送提示后任务继续 */
19
- export declare const WARNING_TIMEOUT_MS: number;
20
- /** 第二级超时:中止阈值(毫秒)- 20 分钟,发送提示后强制中断 */
21
- export declare const ABORT_TIMEOUT_MS: number;
22
- /** 超时警告提示 */
23
- export declare const WARNING_MESSAGE = "\u5904\u7406\u65F6\u95F4\u8F83\u957F\uFF0C\u8BF7\u7A0D\u7B49\uFF0C\u6B63\u5728\u751F\u6210\u6700\u7EC8\u7ED3\u679C...";
24
- /** 超时中止提示 */
25
- export declare const ABORT_MESSAGE = "\u5904\u7406\u65F6\u95F4\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
16
+ /** 循环提醒间隔(毫秒)- 每 10 分钟发送一次进度提醒,任务永不中断 */
17
+ export declare const REMINDER_INTERVAL_MS: number;
18
+ /** 生成提醒消息,包含已过时间 */
19
+ export declare function getReminderMessage(elapsedMinutes: number): string;
26
20
  /** "思考中"流式消息占位内容 */
27
21
  export declare const THINKING_MESSAGE = "<think></think>";
28
22
  /** 消息状态清理间隔(毫秒) */
package/dist/src/const.js CHANGED
@@ -14,16 +14,14 @@ const UPLOAD_MEDIA_URL = "/cgi/bot/file/upload";
14
14
  const TEXT_CHUNK_LIMIT = 4000;
15
15
  /** WebSocket 心跳间隔(毫秒) */
16
16
  const WS_HEARTBEAT_INTERVAL_MS = 30000;
17
- /** WebSocket 最大重连次数 */
17
+ /** WebSocket 快速重连阶段次数;超过后转入低频持续恢复。 */
18
18
  const WS_MAX_RECONNECT_ATTEMPTS = 10;
19
- /** 第一级超时:警告阈值(毫秒)- 10 分钟,发送提示后任务继续 */
20
- const WARNING_TIMEOUT_MS = 10 * 60 * 1000;
21
- /** 第二级超时:中止阈值(毫秒)- 20 分钟,发送提示后强制中断 */
22
- const ABORT_TIMEOUT_MS = 20 * 60 * 1000;
23
- /** 超时警告提示 */
24
- const WARNING_MESSAGE = '处理时间较长,请稍等,正在生成最终结果...';
25
- /** 超时中止提示 */
26
- const ABORT_MESSAGE = '处理时间超时,请稍后重试';
19
+ /** 循环提醒间隔(毫秒)- 每 10 分钟发送一次进度提醒,任务永不中断 */
20
+ const REMINDER_INTERVAL_MS = 10 * 60 * 1000;
21
+ /** 生成提醒消息,包含已过时间 */
22
+ function getReminderMessage(elapsedMinutes) {
23
+ return `处理时间较长,已处理${elapsedMinutes}分钟,请稍等,正在生成最终结果.`;
24
+ }
27
25
  /** "思考中"流式消息占位内容 */
28
26
  const THINKING_MESSAGE = '<think></think>';
29
27
  /** 消息状态清理间隔(毫秒) */
@@ -35,4 +33,4 @@ const MEDIA_DOCUMENT_PLACEHOLDER = "<media:document>";
35
33
  /** 最大的媒体传输大小 */
36
34
  const DEFAULT_MEDIA_MAX_SIZE = 12 * 1024 * 1024; // 12MB
37
35
 
38
- export { ABORT_MESSAGE, ABORT_TIMEOUT_MS, CHANNEL_ID, DEFAULT_DM_POLICY, DEFAULT_MEDIA_MAX_SIZE, DOWNLOAD_MEDIA_URL, HTTP_SCHEME, MEDIA_DOCUMENT_PLACEHOLDER, MEDIA_IMAGE_PLACEHOLDER, MESSAGE_STATE_CLEANUP_INTERVAL_MS, PORT, TEXT_CHUNK_LIMIT, THINKING_MESSAGE, UPLOAD_MEDIA_URL, WARNING_MESSAGE, WARNING_TIMEOUT_MS, WEBSOCKET_SCHEME, WS_HEARTBEAT_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_URL };
36
+ export { CHANNEL_ID, DEFAULT_DM_POLICY, DEFAULT_MEDIA_MAX_SIZE, DOWNLOAD_MEDIA_URL, HTTP_SCHEME, MEDIA_DOCUMENT_PLACEHOLDER, MEDIA_IMAGE_PLACEHOLDER, MESSAGE_STATE_CLEANUP_INTERVAL_MS, PORT, REMINDER_INTERVAL_MS, TEXT_CHUNK_LIMIT, THINKING_MESSAGE, UPLOAD_MEDIA_URL, WEBSOCKET_SCHEME, WS_HEARTBEAT_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_URL, getReminderMessage };
@@ -1,5 +1,6 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
2
  import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
3
+ import { WSClient } from './sdk/ws-client.js';
3
4
  import { ResolvedYouDuAccount } from './utils.js';
4
5
  import { type Youdu } from './media.js';
5
6
  import { type YouduDecodedBody } from './message-prepare.js';
@@ -42,7 +43,13 @@ export interface YouDuMonitorOptions {
42
43
  config: OpenClawConfig;
43
44
  runtime: RuntimeEnv;
44
45
  abortSignal?: AbortSignal;
46
+ /** OpenClaw channel runtime status sink. It observes the manager; it never schedules recovery. */
47
+ getStatus?: () => any;
48
+ setStatus?: (next: any) => void;
49
+ /** Test/runtime seam for supplying a client without changing reconnect ownership. */
50
+ createClient?: (options: any) => WSClient;
45
51
  }
52
+ export declare const YOUDU_CONFIGURATION_ERROR_HEALTH_STATE = "configuration-error";
46
53
  /**
47
54
  * 监听 YouDu WebSocket 连接(主函数)
48
55
  */
@@ -1,7 +1,7 @@
1
1
  import { WSClient } from './sdk/ws-client.js';
2
2
  import { getYouDuRuntime } from './runtime.js';
3
3
  import { resolveYouDuAccount } from './utils.js';
4
- import { WS_MAX_RECONNECT_ATTEMPTS, WS_HEARTBEAT_INTERVAL_MS, PORT, THINKING_MESSAGE, ABORT_TIMEOUT_MS, ABORT_MESSAGE, WARNING_TIMEOUT_MS, WARNING_MESSAGE, CHANNEL_ID, MEDIA_IMAGE_PLACEHOLDER, MEDIA_DOCUMENT_PLACEHOLDER } from './const.js';
4
+ import { WS_MAX_RECONNECT_ATTEMPTS, WS_HEARTBEAT_INTERVAL_MS, PORT, THINKING_MESSAGE, getReminderMessage, REMINDER_INTERVAL_MS, CHANNEL_ID, MEDIA_IMAGE_PLACEHOLDER, MEDIA_DOCUMENT_PLACEHOLDER } from './const.js';
5
5
  import { buildYouDuTlsConnectOptions } from './tls.js';
6
6
  import { cleanupAccount, startMessageStateCleanup, setYouDuWebSocket, stopMessageStateCleanup, rememberDirectChatTarget, setMessageState, deleteMessageState } from './state-manager.js';
7
7
  import { checkDmPolicy } from './dm-policy.js';
@@ -14,6 +14,7 @@ import { prepareMessageBody } from './message-prepare.js';
14
14
  /*
15
15
  * Licensed under the MIT License.
16
16
  */
17
+ const YOUDU_CONFIGURATION_ERROR_HEALTH_STATE = 'configuration-error';
17
18
  function buildMessageContext(frame, account, config, text, mediaList) {
18
19
  const core = getYouDuRuntime();
19
20
  const body = frame.body;
@@ -165,40 +166,30 @@ async function processYouDuMessage(params) {
165
166
  runtime,
166
167
  onCleanup: cleanupState,
167
168
  }), {
168
- warningMs: WARNING_TIMEOUT_MS,
169
- abortMs: ABORT_TIMEOUT_MS,
170
- onWarning: async () => {
171
- runtime.log?.(`[youdu] Task running for ${WARNING_TIMEOUT_MS / 1000 / 60} min, sending warning`);
172
- await sendYouDuReply({ wsClient, frame, text: WARNING_MESSAGE, runtime });
173
- },
174
- onAbort: async () => {
175
- runtime.log?.(`[youdu] Task running for ${ABORT_TIMEOUT_MS / 1000 / 60} min, aborting`);
176
- state.timedOut = true;
177
- await sendYouDuReply({ wsClient, frame, text: ABORT_MESSAGE, runtime });
169
+ intervalMs: REMINDER_INTERVAL_MS,
170
+ onReminder: async (elapsedMs) => {
171
+ const minutes = Math.round(elapsedMs / 60000);
172
+ const msg = getReminderMessage(minutes);
173
+ runtime.log?.(`[youdu] Task running for ${minutes} min, sending reminder`);
174
+ await sendYouDuReply({ wsClient, frame, text: msg, runtime });
178
175
  },
179
176
  });
180
177
  }
181
178
  catch (err) {
182
- // 超时中止错误已在 onAbort 中发送提示,不再重复发送
183
- if (state.timedOut) {
184
- runtime.error?.(`[youdu][plugin] Message processing timed out after ${ABORT_TIMEOUT_MS / 1000 / 60} min`);
185
- }
186
- else {
187
- runtime.error?.(`[youdu][plugin] Message processing failed: ${String(err)}`);
188
- try {
189
- if (shouldSendThinking) {
190
- await sendYouDuReply({
191
- wsClient,
192
- frame,
193
- text: '处理消息时出现异常,请稍后重试。',
194
- runtime,
195
- });
196
- }
197
- }
198
- catch (finishErr) {
199
- runtime.error?.(`[youdu] Failed to finish thinking stream on error: ${String(finishErr)}`);
179
+ runtime.error?.(`[youdu][plugin] Message processing failed: ${String(err)}`);
180
+ try {
181
+ if (shouldSendThinking) {
182
+ await sendYouDuReply({
183
+ wsClient,
184
+ frame,
185
+ text: '处理消息时出现异常,请稍后重试。',
186
+ runtime,
187
+ });
200
188
  }
201
189
  }
190
+ catch (finishErr) {
191
+ runtime.error?.(`[youdu] Failed to finish thinking stream on error: ${String(finishErr)}`);
192
+ }
202
193
  cleanupState();
203
194
  }
204
195
  }
@@ -222,9 +213,6 @@ async function routeAndDispatchMessage(params) {
222
213
  cfg: config,
223
214
  dispatcherOptions: {
224
215
  deliver: async (payload, info) => {
225
- // 超时中止后不再处理后续交付
226
- if (state.timedOut)
227
- return;
228
216
  state.deliverCalled = true;
229
217
  // 累积文本
230
218
  if (payload.text) {
@@ -297,9 +285,6 @@ function buildMediaErrorSummary(mediaUrl, errMsg) {
297
285
  }
298
286
  async function finishThinkingStream(ctx) {
299
287
  const { wsClient, frame, state, runtime } = ctx;
300
- // 超时中止后不再发送最终回复
301
- if (state.timedOut)
302
- return;
303
288
  let finishText = state.accumulatedText;
304
289
  if (finishText) {
305
290
  await sendYouDuReply({ wsClient, frame, text: finishText, runtime });
@@ -332,50 +317,37 @@ async function finishThinkingStream(ctx) {
332
317
  }
333
318
  }
334
319
  /**
335
- * 两级超时保护
320
+ * 循环提醒保护
336
321
  *
337
- * 第一级(warningMs):发送提示消息,任务继续执行
338
- * 第二级(abortMs):发送中止消息,强制中断任务
322
+ * 每隔 intervalMs 发送一次进度提醒,任务永不中断。
323
+ * 任务完成后自动停止提醒。
339
324
  */
340
325
  async function processWithTimeoutGuard(task, options) {
341
326
  return new Promise((resolve, reject) => {
342
327
  let finished = false;
343
- const warningTimer = setTimeout(async () => {
344
- if (finished)
345
- return;
346
- try {
347
- await options.onWarning();
348
- }
349
- catch {
350
- // 警告发送失败不影响主流程
351
- }
352
- }, options.warningMs);
353
- const abortTimer = setTimeout(async () => {
328
+ const startTime = Date.now();
329
+ const reminderTimer = setInterval(async () => {
354
330
  if (finished)
355
331
  return;
356
- finished = true;
357
- clearTimeout(warningTimer);
332
+ const elapsed = Date.now() - startTime;
358
333
  try {
359
- await options.onAbort();
334
+ await options.onReminder(elapsed);
360
335
  }
361
336
  catch {
362
- // 中止消息发送失败仍要 reject
337
+ // 提醒发送失败不影响主流程
363
338
  }
364
- reject(new Error('Task aborted after timeout'));
365
- }, options.abortMs);
339
+ }, options.intervalMs);
366
340
  task().then((result) => {
367
341
  if (finished)
368
342
  return;
369
343
  finished = true;
370
- clearTimeout(warningTimer);
371
- clearTimeout(abortTimer);
344
+ clearInterval(reminderTimer);
372
345
  resolve(result);
373
346
  }, (error) => {
374
347
  if (finished)
375
348
  return;
376
349
  finished = true;
377
- clearTimeout(warningTimer);
378
- clearTimeout(abortTimer);
350
+ clearInterval(reminderTimer);
379
351
  reject(error);
380
352
  });
381
353
  });
@@ -406,18 +378,18 @@ async function getExtendedMediaLocalRoots(config) {
406
378
  * 监听 YouDu WebSocket 连接(主函数)
407
379
  */
408
380
  async function monitorYouDuProvider(options) {
409
- const { account, config, runtime, abortSignal } = options;
381
+ const { account, config, runtime, abortSignal, getStatus, setStatus, createClient } = options;
410
382
  runtime.log?.(`[youdu][${account.accountId}] ========== monitorYouDuProvider STARTED ==========`);
411
383
  runtime.log?.(`[youdu][${account.accountId}] Initializing WSClient...`);
412
384
  runtime.log?.(`[youdu][${account.accountId}] Config: botId=${account.botId}, websocketUrl=${account.url || '(default)'}`);
413
385
  // 启动消息状态定期清理
414
386
  await cleanupAccount(account.accountId);
415
387
  startMessageStateCleanup();
416
- return new Promise((resolve, reject) => {
388
+ return new Promise((resolve) => {
417
389
  const logger = createSdkLogger(runtime, account.accountId);
418
390
  runtime.log?.(`[youdu][${account.accountId}] Creating WSClient instance...`);
419
391
  const tlsOptions = buildYouDuTlsConnectOptions(account);
420
- const wsClient = new WSClient({
392
+ const wsClient = (createClient ?? ((clientOptions) => new WSClient(clientOptions)))({
421
393
  botId: account.botId,
422
394
  secret: account.secret,
423
395
  wsUrl: `${account.url}:${PORT}`,
@@ -426,37 +398,94 @@ async function monitorYouDuProvider(options) {
426
398
  heartbeatInterval: WS_HEARTBEAT_INTERVAL_MS,
427
399
  maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
428
400
  });
401
+ const syncStatus = (mode = 'active') => {
402
+ if (!setStatus)
403
+ return;
404
+ const snapshot = wsClient.getConnectionSnapshot();
405
+ const healthy = snapshot.state === 'authenticated' && snapshot.authenticated;
406
+ const sdkOwnsRecovery = snapshot.state === 'connecting' ||
407
+ snapshot.state === 'reconnecting' ||
408
+ snapshot.state === 'offline';
409
+ const configurationError = mode === 'configuration-error';
410
+ const current = getStatus?.() ?? { accountId: account.accountId };
411
+ setStatus({
412
+ ...current,
413
+ accountId: account.accountId,
414
+ configured: configurationError ? false : current.configured,
415
+ // OpenClaw treats connected=false as permission to restart the channel.
416
+ // Leave it undecided while this manager owns recovery; probe/status still
417
+ // expose the actual unauthenticated state and retry metadata.
418
+ running: mode === 'stopped' ? false : true,
419
+ connected: healthy
420
+ ? true
421
+ : configurationError || sdkOwnsRecovery
422
+ ? undefined
423
+ : false,
424
+ healthState: configurationError
425
+ ? YOUDU_CONFIGURATION_ERROR_HEALTH_STATE
426
+ : snapshot.state,
427
+ state: snapshot.state,
428
+ authenticated: snapshot.authenticated,
429
+ reconnectAttempts: snapshot.reconnectAttempts,
430
+ reconnectPhase: snapshot.reconnectPhase,
431
+ nextRetryAt: snapshot.nextRetryAt,
432
+ lastConnectedAt: snapshot.lastConnectedAt,
433
+ lastAuthenticatedAt: snapshot.lastAuthenticatedAt,
434
+ lastDisconnectedAt: snapshot.lastDisconnectedAt,
435
+ lastError: snapshot.lastError,
436
+ });
437
+ };
429
438
  runtime.log?.(`[youdu][${account.accountId}] WSClient created, setting up event listeners...`);
430
439
  setYouDuWebSocket(account.accountId, wsClient);
431
- const cleanup = async () => {
440
+ const cleanup = async (removeClient = true) => {
432
441
  stopMessageStateCleanup();
433
- await cleanupAccount(account.accountId, wsClient);
442
+ if (removeClient) {
443
+ await cleanupAccount(account.accountId, wsClient);
444
+ }
445
+ else {
446
+ wsClient.disconnect();
447
+ }
434
448
  };
449
+ let authenticationFailureHandled = false;
435
450
  if (abortSignal) {
436
451
  abortSignal.addEventListener('abort', async () => {
437
452
  runtime.log?.(`[${account.accountId}] Connection aborted`);
438
453
  await cleanup();
454
+ syncStatus('stopped');
439
455
  resolve();
440
456
  });
441
457
  }
442
458
  wsClient.on('connected', () => {
459
+ syncStatus();
443
460
  // runtime.log?.(`[youdu][${account.accountId}] >>> WebSocket CONNECTED event received`);
444
461
  });
445
462
  wsClient.on('authenticated', () => {
446
463
  runtime.log?.(`[youdu][${account.accountId}] >>> Authentication SUCCESSFUL`);
447
464
  setYouDuWebSocket(account.accountId, wsClient);
465
+ syncStatus();
448
466
  });
449
467
  wsClient.on('disconnected', (reason) => {
450
468
  runtime.log?.(`[youdu][${account.accountId}] <<< WebSocket DISCONNECTED: ${reason}`);
469
+ syncStatus();
451
470
  });
452
471
  wsClient.on('reconnecting', (attempt) => {
453
472
  runtime.log?.(`[youdu][${account.accountId}] ↻ Reconnecting attempt #${attempt}...`);
473
+ syncStatus();
454
474
  });
455
475
  wsClient.on('error', (error) => {
456
476
  runtime.error?.(`[youdu][${account.accountId}] !!! WebSocket ERROR: ${error.message}`);
457
477
  if (error.message.includes('Authentication failed')) {
458
- runtime.error?.(`[youdu][${account.accountId}] Authentication failed, rejecting promise`);
459
- cleanup().finally(() => reject(error));
478
+ if (authenticationFailureHandled)
479
+ return;
480
+ authenticationFailureHandled = true;
481
+ // 1101 is a configuration fault. Keep this monitor alive so OpenClaw's
482
+ // account supervisor does not turn it into an automatic restart loop.
483
+ runtime.error?.(`[youdu][${account.accountId}] Authentication failed; waiting for configuration reload`);
484
+ syncStatus('configuration-error');
485
+ void cleanup(false).then(() => syncStatus('configuration-error'));
486
+ }
487
+ else {
488
+ syncStatus();
460
489
  }
461
490
  });
462
491
  runtime.log?.(`[youdu][${account.accountId}] Event listeners registered, calling wsClient.connect()...`);
@@ -477,7 +506,8 @@ async function monitorYouDuProvider(options) {
477
506
  });
478
507
  // 建立连接
479
508
  wsClient.connect();
509
+ syncStatus();
480
510
  });
481
511
  }
482
512
 
483
- export { monitorYouDuProvider };
513
+ export { YOUDU_CONFIGURATION_ERROR_HEALTH_STATE, monitorYouDuProvider };
@@ -1,4 +1,5 @@
1
1
  import type { YouDuTlsConnectOptions } from "../tls.js";
2
+ import type WebSocket from "ws";
2
3
  /**
3
4
  * 日志接口
4
5
  */
@@ -8,6 +9,29 @@ export interface Logger {
8
9
  warn(message: string, ...args: any[]): void;
9
10
  error(message: string, ...args: any[]): void;
10
11
  }
12
+ export type WsConnectionState = "connecting" | "authenticated" | "reconnecting" | "offline" | "stopped";
13
+ export type WsReconnectPhase = "fast" | "slow";
14
+ export interface WsConnectionSnapshot {
15
+ readonly state: WsConnectionState;
16
+ readonly authenticated: boolean;
17
+ readonly reconnectAttempts: number;
18
+ readonly reconnectPhase: WsReconnectPhase;
19
+ readonly nextRetryAt: number | null;
20
+ readonly lastConnectedAt: number | null;
21
+ readonly lastAuthenticatedAt: number | null;
22
+ readonly lastDisconnectedAt: number | null;
23
+ readonly lastError: string | null;
24
+ }
25
+ export interface WsConnectionManagerHooks {
26
+ readonly now?: () => number;
27
+ readonly random?: () => number;
28
+ readonly setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
29
+ readonly clearTimeout?: (timer: ReturnType<typeof setTimeout>) => void;
30
+ /** 可测试的 socket 创建入口;生产默认使用 ws.WebSocket。 */
31
+ readonly createWebSocket?: (url: string, options?: ConstructorParameters<typeof WebSocket>[1]) => WebSocket;
32
+ readonly connectTimeoutMs?: number;
33
+ readonly authTimeoutMs?: number;
34
+ }
11
35
  /**
12
36
  * WebSocket 帧结构
13
37
  */
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from "events";
2
2
  import { WsCmd, WsErrCode, DefaultLogger } from "./ws-utils";
3
- import type { WsFrame, WSClientOptions } from "./types";
3
+ import type { WsConnectionSnapshot, WsFrame, WSClientOptions } from "./types";
4
4
  /**
5
5
  * YouDu WebSocket 客户端
6
6
  */
@@ -16,6 +16,7 @@ export declare class WSClient extends EventEmitter {
16
16
  reply(frame: WsFrame, body: any, cmd?: string): Promise<WsFrame>;
17
17
  sendMessage(chatId: string, body: any): Promise<WsFrame>;
18
18
  getToken(): string;
19
+ getConnectionSnapshot(): WsConnectionSnapshot;
19
20
  }
20
- export type { Logger, WsFrame, WSClientOptions, WSClientEventMap, } from "./types";
21
+ export type { Logger, WsFrame, WSClientOptions, WSClientEventMap, WsConnectionSnapshot, WsConnectionState, WsReconnectPhase, } from "./types";
21
22
  export { DefaultLogger, WsCmd, WsErrCode };
@@ -79,6 +79,9 @@ class WSClient extends EventEmitter {
79
79
  getToken() {
80
80
  return this.wsManager.getToken();
81
81
  }
82
+ getConnectionSnapshot() {
83
+ return this.wsManager.getConnectionSnapshot();
84
+ }
82
85
  }
83
86
 
84
87
  export { DefaultLogger, WSClient, WsCmd };
@@ -1,6 +1,11 @@
1
1
  import { EventEmitter } from "events";
2
- import type { Logger, WsFrame } from "./types";
2
+ import type { Logger, WsConnectionManagerHooks, WsConnectionSnapshot, WsFrame, WsReconnectPhase } from "./types";
3
3
  import type { YouDuTlsConnectOptions } from "../tls.js";
4
+ export interface ReconnectDelay {
5
+ readonly delay: number;
6
+ readonly phase: WsReconnectPhase;
7
+ }
8
+ export declare function calculateReconnectDelay(reconnectAttempt: number, maxFastAttempts?: number, reconnectBaseDelay?: number, reconnectMaxDelay?: number, random?: () => number): ReconnectDelay;
4
9
  /**
5
10
  * WebSocket 长连接管理器
6
11
  */
@@ -13,12 +18,33 @@ export declare class WsConnectionManager extends EventEmitter {
13
18
  private heartbeatTimer;
14
19
  private maxReconnectAttempts;
15
20
  private reconnectAttempts;
21
+ private reconnectTimer;
22
+ private connectWatchdogTimer;
23
+ private authWatchdogTimer;
24
+ private reconnectPhase;
16
25
  private isManualClose;
26
+ private state;
27
+ private authenticated;
28
+ private nextRetryAt;
29
+ private lastConnectedAt;
30
+ private lastAuthenticatedAt;
31
+ private lastDisconnectedAt;
32
+ private lastError;
33
+ /** 保留本次连接尝试中更具体的 socket/auth 错误,避免 close(1006) 覆盖根因。 */
34
+ private lastErrorIsSpecific;
35
+ private readonly now;
36
+ private readonly random;
37
+ private readonly scheduleTimeout;
38
+ private readonly cancelTimeout;
39
+ private readonly createWebSocket;
40
+ private readonly connectTimeoutMs;
41
+ private readonly authTimeoutMs;
17
42
  /** 认证凭证 */
18
43
  private botId;
19
44
  private botSecret;
20
45
  private token;
21
46
  private hasAuthenticated;
47
+ private isReauthenticating;
22
48
  /** 连续未收到心跳响应的次数 */
23
49
  private missedPongCount;
24
50
  private readonly maxMissedPong;
@@ -29,9 +55,10 @@ export declare class WsConnectionManager extends EventEmitter {
29
55
  private pendingAcks;
30
56
  private readonly replyAckTimeout;
31
57
  private readonly maxReplyQueueSize;
32
- constructor(logger: Logger, heartbeatInterval?: number, reconnectBaseDelay?: number, maxReconnectAttempts?: number, wsUrl?: string, tlsOptions?: YouDuTlsConnectOptions);
58
+ constructor(logger: Logger, heartbeatInterval?: number, reconnectBaseDelay?: number, maxReconnectAttempts?: number, wsUrl?: string, tlsOptions?: YouDuTlsConnectOptions, hooks?: WsConnectionManagerHooks);
33
59
  setCredentials(botId: string, botSecret: string): void;
34
60
  getToken(): string;
61
+ getConnectionSnapshot(): WsConnectionSnapshot;
35
62
  connect(): void;
36
63
  private setupEventHandlers;
37
64
  private sendAuth;
@@ -41,6 +68,12 @@ export declare class WsConnectionManager extends EventEmitter {
41
68
  private reconnectWithFreshAuth;
42
69
  private sendHeartbeat;
43
70
  private scheduleReconnect;
71
+ private clearReconnectTimer;
72
+ private startConnectWatchdog;
73
+ private startAuthWatchdog;
74
+ private clearConnectWatchdog;
75
+ private clearAuthWatchdog;
76
+ private clearWatchdogs;
44
77
  send(frame: WsFrame): void;
45
78
  sendReply(reqId: string, body: any, cmd?: string): Promise<WsFrame>;
46
79
  private processReplyQueue;
@@ -6,21 +6,57 @@ import { WsCmd, generateReqId, WsErrCode } from './ws-utils.js';
6
6
  /*
7
7
  * Licensed under the MIT License.
8
8
  */
9
+ const SLOW_RECONNECT_DELAY_MS = 5 * 60 * 1000;
10
+ const SLOW_RECONNECT_JITTER_RATIO = 0.2;
11
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30000;
12
+ const DEFAULT_AUTH_TIMEOUT_MS = 30000;
13
+ function calculateReconnectDelay(reconnectAttempt, maxFastAttempts = 10, reconnectBaseDelay = 1000, reconnectMaxDelay = 30000, random = Math.random) {
14
+ const attempt = Math.max(1, Math.floor(reconnectAttempt));
15
+ const fastAttemptLimit = maxFastAttempts > 0 ? Math.floor(maxFastAttempts) : 10;
16
+ // Fast recovery: the configured window (10 attempts in production) caps at 30 seconds.
17
+ if (attempt <= fastAttemptLimit) {
18
+ return {
19
+ delay: Math.min(reconnectBaseDelay * Math.pow(2, attempt - 1), reconnectMaxDelay),
20
+ phase: "fast",
21
+ };
22
+ }
23
+ // Slow persistent recovery: retry forever every five minutes with +/-20% jitter.
24
+ const randomValue = Math.min(1, Math.max(0, random()));
25
+ const jitterMultiplier = 1 - SLOW_RECONNECT_JITTER_RATIO + randomValue * SLOW_RECONNECT_JITTER_RATIO * 2;
26
+ return {
27
+ delay: Math.round(SLOW_RECONNECT_DELAY_MS * jitterMultiplier),
28
+ phase: "slow",
29
+ };
30
+ }
9
31
  /**
10
32
  * WebSocket 长连接管理器
11
33
  */
12
34
  class WsConnectionManager extends EventEmitter {
13
- constructor(logger, heartbeatInterval = 30000, reconnectBaseDelay = 1000, maxReconnectAttempts = 10, wsUrl, tlsOptions) {
35
+ constructor(logger, heartbeatInterval = 30000, reconnectBaseDelay = 1000, maxReconnectAttempts = 10, wsUrl, tlsOptions, hooks = {}) {
14
36
  super();
15
37
  this.ws = null;
16
38
  this.heartbeatTimer = null;
17
39
  this.reconnectAttempts = 0;
40
+ this.reconnectTimer = null;
41
+ this.connectWatchdogTimer = null;
42
+ this.authWatchdogTimer = null;
43
+ this.reconnectPhase = "fast";
18
44
  this.isManualClose = false;
45
+ this.state = "stopped";
46
+ this.authenticated = false;
47
+ this.nextRetryAt = null;
48
+ this.lastConnectedAt = null;
49
+ this.lastAuthenticatedAt = null;
50
+ this.lastDisconnectedAt = null;
51
+ this.lastError = null;
52
+ /** 保留本次连接尝试中更具体的 socket/auth 错误,避免 close(1006) 覆盖根因。 */
53
+ this.lastErrorIsSpecific = false;
19
54
  /** 认证凭证 */
20
55
  this.botId = "";
21
56
  this.botSecret = "";
22
57
  this.token = "";
23
58
  this.hasAuthenticated = false; // 是否曾经成功认证过
59
+ this.isReauthenticating = false;
24
60
  /** 连续未收到心跳响应的次数 */
25
61
  this.missedPongCount = 0;
26
62
  this.maxMissedPong = 2;
@@ -36,6 +72,13 @@ class WsConnectionManager extends EventEmitter {
36
72
  this.reconnectBaseDelay = reconnectBaseDelay;
37
73
  this.maxReconnectAttempts = maxReconnectAttempts;
38
74
  this.tlsOptions = tlsOptions;
75
+ this.now = hooks.now ?? Date.now;
76
+ this.random = hooks.random ?? Math.random;
77
+ this.scheduleTimeout = hooks.setTimeout ?? setTimeout;
78
+ this.cancelTimeout = hooks.clearTimeout ?? clearTimeout;
79
+ this.createWebSocket = hooks.createWebSocket ?? ((url, options) => new WebSocket(url, options));
80
+ this.connectTimeoutMs = hooks.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
81
+ this.authTimeoutMs = hooks.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
39
82
  if (wsUrl && wsUrl.trim()) {
40
83
  this.wsUrl = `${WEBSOCKET_SCHEME}${wsUrl.trim()}${WS_URL}`;
41
84
  }
@@ -44,14 +87,42 @@ class WsConnectionManager extends EventEmitter {
44
87
  }
45
88
  }
46
89
  setCredentials(botId, botSecret) {
90
+ const changed = this.botId !== botId || this.botSecret !== botSecret;
47
91
  this.botId = botId;
48
92
  this.botSecret = botSecret;
93
+ if (changed) {
94
+ // 配置更新后允许重新认证,即使上一次 1101 曾主动停止了管理器。
95
+ this.isManualClose = false;
96
+ this.clearReconnectTimer();
97
+ this.clearAuth();
98
+ this.state = "stopped";
99
+ this.lastError = null;
100
+ this.lastErrorIsSpecific = false;
101
+ }
49
102
  }
50
103
  getToken() {
51
104
  return this.token;
52
105
  }
106
+ getConnectionSnapshot() {
107
+ return {
108
+ state: this.state,
109
+ authenticated: this.authenticated,
110
+ reconnectAttempts: this.reconnectAttempts,
111
+ reconnectPhase: this.reconnectPhase,
112
+ nextRetryAt: this.nextRetryAt,
113
+ lastConnectedAt: this.lastConnectedAt,
114
+ lastAuthenticatedAt: this.lastAuthenticatedAt,
115
+ lastDisconnectedAt: this.lastDisconnectedAt,
116
+ lastError: this.lastError,
117
+ };
118
+ }
53
119
  connect() {
120
+ this.clearReconnectTimer();
121
+ this.clearWatchdogs();
54
122
  this.isManualClose = false;
123
+ this.lastErrorIsSpecific = false;
124
+ this.authenticated = false;
125
+ this.state = "connecting";
55
126
  if (this.ws) {
56
127
  this.logger.info("[WS] Closing existing connection before reconnect...");
57
128
  this.ws.removeAllListeners();
@@ -70,11 +141,15 @@ class WsConnectionManager extends EventEmitter {
70
141
  else {
71
142
  this.logger.info("[WS] Using WS protocol, no TLS config required");
72
143
  }
73
- this.ws = new WebSocket(this.wsUrl, options);
144
+ this.ws = this.createWebSocket(this.wsUrl, options);
74
145
  this.logger.info(`[WS] WebSocket instance created, readyState=${this.ws.readyState}`);
75
146
  this.setupEventHandlers();
147
+ this.startConnectWatchdog(this.ws);
76
148
  }
77
149
  catch (error) {
150
+ this.state = "offline";
151
+ this.lastError = error.message;
152
+ this.lastErrorIsSpecific = true;
78
153
  this.logger.error(`[WS] !!! Failed to create WebSocket connection: ${error.message}`);
79
154
  this.logger.error(`[WS] Error stack: ${error.stack}`);
80
155
  this.emit("error", error);
@@ -84,22 +159,25 @@ class WsConnectionManager extends EventEmitter {
84
159
  setupEventHandlers() {
85
160
  if (!this.ws)
86
161
  return;
87
- this.logger.info(`[WS] Setting up event handlers for WebSocket (readyState=${this.ws.readyState})`);
88
- this.ws.on("open", () => {
162
+ const socket = this.ws;
163
+ this.logger.info(`[WS] Setting up event handlers for WebSocket (readyState=${socket.readyState})`);
164
+ socket.on("open", () => {
165
+ if (this.ws !== socket || this.isManualClose)
166
+ return;
167
+ this.clearConnectWatchdog();
89
168
  this.missedPongCount = 0;
169
+ this.lastConnectedAt = this.now();
90
170
  const authMethod = (this.hasAuthenticated && this.token) ? "reconnect (with token)" : "subscribe (with bot_id + secret)";
91
171
  this.logger.info(`[WS] WebSocket opened, will use ${authMethod} for authentication`);
92
172
  this.sendAuth();
93
173
  this.emit("connected");
94
174
  });
95
- this.ws.on("message", (data) => {
175
+ socket.on("message", (data) => {
96
176
  const raw = data.toString();
97
177
  let frame;
98
178
  try {
99
179
  frame = JSON.parse(raw);
100
- if (!frame.headers?.reqId.startsWith("ping")) {
101
- this.logger.info(`[WS] <<< Received message (${raw.length} chars): ${raw}`);
102
- }
180
+ this.logger.info(`[WS] <<< Received message (${raw.length} chars): ${raw}`);
103
181
  }
104
182
  catch {
105
183
  frame = {
@@ -117,25 +195,54 @@ class WsConnectionManager extends EventEmitter {
117
195
  }
118
196
  this.handleFrame(frame);
119
197
  });
120
- this.ws.on("close", (code, reason) => {
198
+ socket.on("close", (code, reason) => {
121
199
  const reasonStr = reason.toString() || `code: ${code}`;
122
200
  const logMsg = `[WS] WebSocket DISCONNECTED: ${reasonStr}`;
123
201
  this.logger.warn(logMsg);
124
202
  this.logger.warn(`[WS] isManualClose=${this.isManualClose}`);
203
+ if (this.ws !== socket)
204
+ return;
125
205
  this.stopHeartbeat();
206
+ this.clearWatchdogs();
126
207
  this.clearPendingMessages(`WebSocket connection closed (${reasonStr})`);
208
+ if (this.ws === socket) {
209
+ this.ws = null;
210
+ }
211
+ this.authenticated = false;
212
+ if (this.state !== "stopped") {
213
+ this.state = "offline";
214
+ }
215
+ this.lastDisconnectedAt = this.now();
216
+ if (!this.isManualClose && !this.lastErrorIsSpecific) {
217
+ this.lastError = reasonStr;
218
+ }
127
219
  this.emit("disconnected", reasonStr);
128
220
  if (!this.isManualClose) {
129
221
  this.scheduleReconnect();
130
222
  }
131
223
  });
132
- this.ws.on("error", (error) => {
224
+ socket.on("error", (error) => {
133
225
  const errMsg = `[WS] !!! WebSocket ERROR: ${error.message}`;
226
+ if (this.ws !== socket)
227
+ return;
228
+ this.clearWatchdogs();
229
+ this.authenticated = false;
230
+ if (!this.isManualClose || !this.lastErrorIsSpecific) {
231
+ this.state = this.isManualClose ? "stopped" : "offline";
232
+ this.lastError = error.message;
233
+ this.lastErrorIsSpecific = true;
234
+ }
134
235
  this.logger.error(errMsg);
135
236
  this.emit("error", error);
237
+ if (!this.isManualClose) {
238
+ this.scheduleReconnect();
239
+ }
240
+ });
241
+ socket.on("ping", () => {
242
+ socket.pong();
136
243
  });
137
- this.ws.on("ping", () => {
138
- this.ws?.pong();
244
+ socket.on("pong", () => {
245
+ this.missedPongCount = 0;
139
246
  });
140
247
  this.logger.info("[WS] Event handlers registered");
141
248
  }
@@ -166,6 +273,8 @@ class WsConnectionManager extends EventEmitter {
166
273
  this.logger.info("[WS] Sending subscribe frame with bot_id + secret");
167
274
  }
168
275
  this.send(frame);
276
+ if (this.ws)
277
+ this.startAuthWatchdog(this.ws);
169
278
  this.logger.info("[WS] Auth frame sent successfully");
170
279
  }
171
280
  catch (error) {
@@ -186,24 +295,31 @@ class WsConnectionManager extends EventEmitter {
186
295
  return;
187
296
  }
188
297
  if (actualReqId.startsWith(WsCmd.SUBSCRIBE) || actualReqId.startsWith(WsCmd.RECONNECT)) {
298
+ this.clearAuthWatchdog();
189
299
  const isReconnect = actualReqId.startsWith(WsCmd.RECONNECT);
190
300
  const authType = isReconnect ? "Reconnect" : "Subscribe";
191
301
  if (frame.errCode !== WsErrCode.SUCCESS) {
192
- this.logger.error(`${authType} failed: errcode=${frame.errCode}, errmsg=${frame.errMsg}`);
302
+ const authError = `${authType} failed: errcode=${frame.errCode}, errmsg=${frame.errMsg}`;
303
+ this.authenticated = false;
304
+ this.state = "offline";
305
+ this.lastError = authError;
306
+ this.lastErrorIsSpecific = true;
307
+ this.logger.error(authError);
308
+ if (frame.errCode === WsErrCode.AUTH_FAILED) {
309
+ this.isManualClose = true;
310
+ this.clearReconnectTimer();
311
+ this.stopHeartbeat();
312
+ this.ws?.close(1000, "Authentication failed");
313
+ this.emit("error", new Error(`Authentication failed: ${frame.errMsg} (code: ${frame.errCode})`));
314
+ return;
315
+ }
193
316
  // Token rejection means the current session is no longer trusted.
194
317
  if (isReconnect) {
195
318
  this.reconnectWithFreshAuth("[WS] Token invalid, will reconnect to re-authenticate");
196
319
  }
197
320
  else {
198
- if (frame.errCode === WsErrCode.AUTH_FAILED) {
199
- this.isManualClose = true;
200
- this.ws?.close(1000, "Authentication failed");
201
- this.emit("error", new Error(`${authType} failed: ${frame.errMsg} (code: ${frame.errCode})`));
202
- }
203
- else {
204
- this.logger.warn(`[WS] Subscribe failed with temporary error, reconnect will retry: errcode=${frame.errCode}`);
205
- this.ws?.terminate();
206
- }
321
+ this.logger.warn(`[WS] Subscribe failed with temporary error, reconnect will retry: errcode=${frame.errCode}`);
322
+ this.ws?.terminate();
207
323
  }
208
324
  return;
209
325
  }
@@ -214,7 +330,16 @@ class WsConnectionManager extends EventEmitter {
214
330
  this.logger.info("token already stored");
215
331
  }
216
332
  this.logger.info("Authentication successful");
333
+ this.clearReconnectTimer();
334
+ this.authenticated = true;
335
+ this.state = "authenticated";
336
+ this.isReauthenticating = false;
217
337
  this.reconnectAttempts = 0;
338
+ this.reconnectPhase = "fast";
339
+ this.nextRetryAt = null;
340
+ this.lastAuthenticatedAt = this.now();
341
+ this.lastError = null;
342
+ this.lastErrorIsSpecific = false;
218
343
  this.startHeartbeat();
219
344
  this.emit("authenticated");
220
345
  return;
@@ -230,6 +355,11 @@ class WsConnectionManager extends EventEmitter {
230
355
  this.missedPongCount = 0;
231
356
  return;
232
357
  }
358
+ // A business ACK can arrive after its local timeout removed pendingAcks.
359
+ // Token invalidation still applies to the connection even though its promise is gone.
360
+ if (frame.errCode === WsErrCode.TOKEN_INVALID) {
361
+ this.reconnectWithFreshAuth("[WS] Token invalid in late reply, will reconnect to re-authenticate");
362
+ }
233
363
  }
234
364
  startHeartbeat() {
235
365
  this.stopHeartbeat();
@@ -246,47 +376,131 @@ class WsConnectionManager extends EventEmitter {
246
376
  }
247
377
  }
248
378
  reconnectWithFreshAuth(reason) {
379
+ if (this.isReauthenticating) {
380
+ return;
381
+ }
382
+ this.isReauthenticating = true;
249
383
  this.logger.warn(reason);
384
+ this.clearWatchdogs();
250
385
  this.stopHeartbeat();
251
386
  this.token = "";
252
387
  this.hasAuthenticated = false;
388
+ this.authenticated = false;
389
+ this.state = "offline";
390
+ this.lastError = reason;
391
+ this.lastErrorIsSpecific = true;
253
392
  this.missedPongCount = 0;
393
+ this.clearPendingMessages("Authentication expired; message was not retried");
254
394
  this.ws?.terminate();
255
395
  }
256
396
  sendHeartbeat() {
257
397
  if (this.missedPongCount >= this.maxMissedPong) {
258
- this.logger.warn(`No heartbeat ack received for ${this.missedPongCount} consecutive pings`);
398
+ this.logger.warn(`No heartbeat pong received for ${this.missedPongCount} consecutive pings`);
259
399
  this.stopHeartbeat();
260
400
  this.ws?.terminate();
261
401
  return;
262
402
  }
263
403
  this.missedPongCount++;
264
404
  try {
265
- this.send({
266
- cmd: WsCmd.HEARTBEAT,
267
- headers: { reqId: generateReqId(WsCmd.HEARTBEAT), token: this.token },
268
- });
405
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
406
+ this.ws.ping();
407
+ }
269
408
  }
270
409
  catch (error) {
271
410
  this.logger.error("Failed to send heartbeat:", error.message);
272
411
  }
273
412
  }
274
413
  scheduleReconnect() {
275
- if (this.maxReconnectAttempts !== -1 && this.reconnectAttempts >= this.maxReconnectAttempts) {
276
- this.logger.error(`Max reconnect attempts reached (${this.maxReconnectAttempts}), giving up`);
277
- this.emit("error", new Error("Max reconnect attempts exceeded"));
414
+ if (this.isManualClose) {
278
415
  return;
279
416
  }
280
- this.reconnectAttempts++;
281
- const delay = Math.min(this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1), this.reconnectMaxDelay);
282
- this.logger.info(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`);
417
+ if (this.reconnectTimer !== null) {
418
+ this.state = "reconnecting";
419
+ return;
420
+ }
421
+ const nextAttempt = this.reconnectAttempts + 1;
422
+ const { delay, phase } = calculateReconnectDelay(nextAttempt, this.maxReconnectAttempts, this.reconnectBaseDelay, this.reconnectMaxDelay, this.random);
423
+ this.reconnectAttempts = nextAttempt;
424
+ this.reconnectPhase = phase;
425
+ this.authenticated = false;
426
+ this.state = "reconnecting";
427
+ this.nextRetryAt = this.now() + delay;
428
+ this.logger.info(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}, phase ${phase})...`);
283
429
  this.emit("reconnecting", this.reconnectAttempts);
284
- setTimeout(() => {
285
- if (this.isManualClose)
430
+ this.reconnectTimer = this.scheduleTimeout(() => {
431
+ this.reconnectTimer = null;
432
+ this.nextRetryAt = null;
433
+ if (this.isManualClose) {
434
+ this.state = "stopped";
286
435
  return;
436
+ }
287
437
  this.connect();
288
438
  }, delay);
289
439
  }
440
+ clearReconnectTimer() {
441
+ if (this.reconnectTimer !== null) {
442
+ this.cancelTimeout(this.reconnectTimer);
443
+ this.reconnectTimer = null;
444
+ }
445
+ this.nextRetryAt = null;
446
+ }
447
+ startConnectWatchdog(socket) {
448
+ this.clearConnectWatchdog();
449
+ let timer;
450
+ timer = this.scheduleTimeout(() => {
451
+ if (this.connectWatchdogTimer !== timer)
452
+ return;
453
+ this.connectWatchdogTimer = null;
454
+ if (this.isManualClose || this.ws !== socket)
455
+ return;
456
+ const error = new Error(`WebSocket connection timed out after ${this.connectTimeoutMs}ms`);
457
+ this.state = "offline";
458
+ this.lastError = error.message;
459
+ this.lastErrorIsSpecific = true;
460
+ this.logger.error(`[WS] !!! ${error.message}`);
461
+ this.emit("error", error);
462
+ socket.terminate();
463
+ this.scheduleReconnect();
464
+ }, this.connectTimeoutMs);
465
+ this.connectWatchdogTimer = timer;
466
+ }
467
+ startAuthWatchdog(socket) {
468
+ this.clearAuthWatchdog();
469
+ let timer;
470
+ timer = this.scheduleTimeout(() => {
471
+ if (this.authWatchdogTimer !== timer)
472
+ return;
473
+ this.authWatchdogTimer = null;
474
+ if (this.isManualClose || this.ws !== socket)
475
+ return;
476
+ const error = new Error(`Authentication ACK timed out after ${this.authTimeoutMs}ms`);
477
+ this.authenticated = false;
478
+ this.state = "offline";
479
+ this.lastError = error.message;
480
+ this.lastErrorIsSpecific = true;
481
+ this.logger.error(`[WS] !!! ${error.message}`);
482
+ this.emit("error", error);
483
+ socket.terminate();
484
+ this.scheduleReconnect();
485
+ }, this.authTimeoutMs);
486
+ this.authWatchdogTimer = timer;
487
+ }
488
+ clearConnectWatchdog() {
489
+ if (this.connectWatchdogTimer !== null) {
490
+ this.cancelTimeout(this.connectWatchdogTimer);
491
+ this.connectWatchdogTimer = null;
492
+ }
493
+ }
494
+ clearAuthWatchdog() {
495
+ if (this.authWatchdogTimer !== null) {
496
+ this.cancelTimeout(this.authWatchdogTimer);
497
+ this.authWatchdogTimer = null;
498
+ }
499
+ }
500
+ clearWatchdogs() {
501
+ this.clearConnectWatchdog();
502
+ this.clearAuthWatchdog();
503
+ }
290
504
  send(frame) {
291
505
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
292
506
  const data = JSON.stringify(frame);
@@ -297,8 +511,11 @@ class WsConnectionManager extends EventEmitter {
297
511
  }
298
512
  }
299
513
  sendReply(reqId, body, cmd = WsCmd.RESPONSE) {
514
+ if (!this.hasAuthenticated || this.isReauthenticating || !this.token) {
515
+ return Promise.reject(new Error("WebSocket authentication is not ready; message was not sent"));
516
+ }
300
517
  return new Promise((resolve, reject) => {
301
- const frame = { cmd, headers: { reqId: reqId, token: this.token }, body };
518
+ const frame = { cmd, headers: { reqId: reqId }, body };
302
519
  const item = { frame, resolve, reject };
303
520
  if (!this.replyQueues.has(reqId)) {
304
521
  this.replyQueues.set(reqId, []);
@@ -322,6 +539,13 @@ class WsConnectionManager extends EventEmitter {
322
539
  return;
323
540
  }
324
541
  const item = queue[0];
542
+ if (!this.hasAuthenticated || this.isReauthenticating || !this.token) {
543
+ queue.shift();
544
+ item.reject(new Error("WebSocket authentication is not ready; message was not sent"));
545
+ this.processReplyQueue(reqId);
546
+ return;
547
+ }
548
+ item.frame.headers.token = this.token;
325
549
  try {
326
550
  this.send(item.frame);
327
551
  }
@@ -351,6 +575,10 @@ class WsConnectionManager extends EventEmitter {
351
575
  this.logger.error(`Reply ack error: reqId=${reqId}, errcode=${frame.errCode}`);
352
576
  queue?.shift();
353
577
  pending.reject(frame);
578
+ if (frame.errCode === WsErrCode.TOKEN_INVALID) {
579
+ this.reconnectWithFreshAuth("[WS] Token invalid in reply, will reconnect to re-authenticate");
580
+ return;
581
+ }
354
582
  }
355
583
  else {
356
584
  queue?.shift();
@@ -386,8 +614,13 @@ class WsConnectionManager extends EventEmitter {
386
614
  */
387
615
  disconnect() {
388
616
  this.isManualClose = true;
617
+ this.clearReconnectTimer();
618
+ this.clearWatchdogs();
389
619
  this.stopHeartbeat();
390
620
  this.clearPendingMessages("Connection manually closed");
621
+ this.authenticated = false;
622
+ this.state = "stopped";
623
+ this.lastDisconnectedAt = this.now();
391
624
  // 主动断开时清除 token,下次连接使用 subscribe + bot_id + secret
392
625
  // 被动断线不会调用此方法,会保留 token 并使用 reconnect
393
626
  this.clearAuth();
@@ -399,10 +632,13 @@ class WsConnectionManager extends EventEmitter {
399
632
  }
400
633
  /** 清除认证状态,强制下次连接使用 bot_id + secret 重新认证 */
401
634
  clearAuth() {
635
+ this.clearWatchdogs();
402
636
  this.token = "";
403
637
  this.hasAuthenticated = false;
638
+ this.authenticated = false;
639
+ this.isReauthenticating = false;
404
640
  this.logger.info("[WS] Auth state cleared, will use bot_id + secret for next authentication");
405
641
  }
406
642
  }
407
643
 
408
- export { WsConnectionManager };
644
+ export { WsConnectionManager, calculateReconnectDelay };
@@ -13,8 +13,6 @@ export interface MessageState {
13
13
  hasMediaFailed?: boolean;
14
14
  deliverCalled?: boolean;
15
15
  mediaErrorSummary?: string;
16
- /** 标记是否已超时中止,true 时不再发送流式更新和最终回复 */
17
- timedOut?: boolean;
18
16
  }
19
17
  /**
20
18
  * 设置账户的 WebSocket 客户端
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdu/openclaw-youdu",
3
- "version": "2026.6.26",
3
+ "version": "2026.8.20",
4
4
  "type": "module",
5
5
  "main": "dist/index.esm.js",
6
6
  "module": "dist/index.esm.js",
@@ -13,6 +13,7 @@
13
13
  "skills"
14
14
  ],
15
15
  "scripts": {
16
+ "test": "rollup -c rollup.test.config.mjs && node --test .test-dist/network-reconnect.test.mjs .test-dist/ws-manager.test.mjs",
16
17
  "build": "rollup -c",
17
18
  "dev": "rollup -c -w",
18
19
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
@@ -25,10 +26,6 @@
25
26
  ],
26
27
  "author": "",
27
28
  "license": "MIT",
28
- "repository": {
29
- "type": "git",
30
- "url": "https://github.com/youdu/openclaw-youdu.git"
31
- },
32
29
  "openclaw": {
33
30
  "extensions": [
34
31
  "./dist/index.esm.js"