@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.
- package/dist/src/channel.d.ts +7 -1
- package/dist/src/channel.js +88 -13
- package/dist/src/const.d.ts +5 -11
- package/dist/src/const.js +8 -10
- package/dist/src/monitor.d.ts +7 -0
- package/dist/src/monitor.js +97 -67
- package/dist/src/sdk/types.d.ts +24 -0
- package/dist/src/sdk/ws-client.d.ts +3 -2
- package/dist/src/sdk/ws-client.js +3 -0
- package/dist/src/sdk/ws-manager.d.ts +35 -2
- package/dist/src/sdk/ws-manager.js +273 -37
- package/dist/src/state-manager.d.ts +0 -2
- package/package.json +2 -5
package/dist/src/channel.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import { type ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import { type ResolvedYouDuAccount } from './utils.js';
|
|
3
|
-
|
|
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>;
|
package/dist/src/channel.js
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
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 }) => {
|
package/dist/src/const.d.ts
CHANGED
|
@@ -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
|
|
18
|
-
/**
|
|
19
|
-
export declare
|
|
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
|
-
/**
|
|
20
|
-
const
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
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 };
|
package/dist/src/monitor.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/src/monitor.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
338
|
-
*
|
|
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
|
|
344
|
-
|
|
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
|
-
|
|
357
|
-
clearTimeout(warningTimer);
|
|
332
|
+
const elapsed = Date.now() - startTime;
|
|
358
333
|
try {
|
|
359
|
-
await options.
|
|
334
|
+
await options.onReminder(elapsed);
|
|
360
335
|
}
|
|
361
336
|
catch {
|
|
362
|
-
//
|
|
337
|
+
// 提醒发送失败不影响主流程
|
|
363
338
|
}
|
|
364
|
-
|
|
365
|
-
}, options.abortMs);
|
|
339
|
+
}, options.intervalMs);
|
|
366
340
|
task().then((result) => {
|
|
367
341
|
if (finished)
|
|
368
342
|
return;
|
|
369
343
|
finished = true;
|
|
370
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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 };
|
package/dist/src/sdk/types.d.ts
CHANGED
|
@@ -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 };
|
|
@@ -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 =
|
|
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
|
-
|
|
88
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
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.
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
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.
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@youdu/openclaw-youdu",
|
|
3
|
-
"version": "2026.
|
|
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"
|