@ywfe/openclaw-plugin-caryscloud-im 0.1.6 → 0.1.7
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/index.js +4921 -19
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/dist/channel.d.ts +0 -15
- package/dist/channel.d.ts.map +0 -1
- package/dist/channel.js +0 -552
- package/dist/channel.js.map +0 -1
- package/dist/index.d.ts +0 -13
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/plugin-sdk.d.ts +0 -226
- package/dist/plugin-sdk.d.ts.map +0 -1
- package/dist/plugin-sdk.js +0 -10
- package/dist/plugin-sdk.js.map +0 -1
- package/dist/shared/constants.d.ts +0 -8
- package/dist/shared/constants.d.ts.map +0 -1
- package/dist/shared/constants.js +0 -8
- package/dist/shared/constants.js.map +0 -1
- package/dist/shared/errors.d.ts +0 -18
- package/dist/shared/errors.d.ts.map +0 -1
- package/dist/shared/errors.js +0 -38
- package/dist/shared/errors.js.map +0 -1
- package/dist/shared/logger.d.ts +0 -15
- package/dist/shared/logger.d.ts.map +0 -1
- package/dist/shared/logger.js +0 -19
- package/dist/shared/logger.js.map +0 -1
- package/dist/shared/utils.d.ts +0 -3
- package/dist/shared/utils.d.ts.map +0 -1
- package/dist/shared/utils.js +0 -28
- package/dist/shared/utils.js.map +0 -1
- package/dist/types.d.ts +0 -43
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/dist/ws-client.d.ts +0 -91
- package/dist/ws-client.d.ts.map +0 -1
- package/dist/ws-client.js +0 -680
- package/dist/ws-client.js.map +0 -1
package/dist/ws-client.js
DELETED
|
@@ -1,680 +0,0 @@
|
|
|
1
|
-
import WebSocket from 'ws';
|
|
2
|
-
import { ConnectionError, MessageError, } from './shared/errors.js';
|
|
3
|
-
import { DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_BASE_DELAY, DEFAULT_RECONNECT_MAX_DELAY, DEFAULT_MESSAGE_QUEUE_MAX_SIZE, DEFAULT_PONG_TIMEOUT, } from './shared/constants.js';
|
|
4
|
-
const DEFAULT_AUTH_TIMEOUT = 10000;
|
|
5
|
-
const DEFAULT_RECONNECT_RESET_WINDOW = 10000;
|
|
6
|
-
const DEFAULT_RATE_LIMIT_RETRY_SECONDS = 60;
|
|
7
|
-
export class WebSocketClient {
|
|
8
|
-
ws = null;
|
|
9
|
-
_state = 'idle';
|
|
10
|
-
messageHandlers = [];
|
|
11
|
-
logger;
|
|
12
|
-
uuid;
|
|
13
|
-
wsUrl;
|
|
14
|
-
authToken;
|
|
15
|
-
isDestroyed = false;
|
|
16
|
-
heartbeatInterval;
|
|
17
|
-
connectionTimeout;
|
|
18
|
-
reconnectBaseDelay;
|
|
19
|
-
reconnectMaxDelay;
|
|
20
|
-
messageQueueMaxSize;
|
|
21
|
-
pongTimeout;
|
|
22
|
-
authTimeout;
|
|
23
|
-
reconnectResetWindow;
|
|
24
|
-
targetUserId;
|
|
25
|
-
sendTestMessage;
|
|
26
|
-
reconnectAttempts = 0;
|
|
27
|
-
reconnectTimer = null;
|
|
28
|
-
connectionTimer = null;
|
|
29
|
-
authTimer = null;
|
|
30
|
-
reconnectResetTimer = null;
|
|
31
|
-
heartbeatTimer = null;
|
|
32
|
-
pongTimer = null;
|
|
33
|
-
messageQueue = [];
|
|
34
|
-
lastPongTime = 0;
|
|
35
|
-
constructor(options, logger) {
|
|
36
|
-
this.wsUrl = options.wsUrl;
|
|
37
|
-
this.uuid = options.uuid;
|
|
38
|
-
this.authToken = options.authToken;
|
|
39
|
-
this.logger = logger;
|
|
40
|
-
this.targetUserId = options.targetUserId;
|
|
41
|
-
this.sendTestMessage = options.sendTestMessage ?? false;
|
|
42
|
-
this.heartbeatInterval = options.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL;
|
|
43
|
-
this.connectionTimeout = options.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT;
|
|
44
|
-
this.reconnectBaseDelay = options.reconnectBaseDelay ?? DEFAULT_RECONNECT_BASE_DELAY;
|
|
45
|
-
this.reconnectMaxDelay = options.reconnectMaxDelay ?? DEFAULT_RECONNECT_MAX_DELAY;
|
|
46
|
-
this.messageQueueMaxSize = options.messageQueueMaxSize ?? DEFAULT_MESSAGE_QUEUE_MAX_SIZE;
|
|
47
|
-
this.pongTimeout = options.pongTimeout ?? DEFAULT_PONG_TIMEOUT;
|
|
48
|
-
this.authTimeout = options.authTimeout ?? DEFAULT_AUTH_TIMEOUT;
|
|
49
|
-
this.reconnectResetWindow = options.reconnectResetWindow ?? DEFAULT_RECONNECT_RESET_WINDOW;
|
|
50
|
-
}
|
|
51
|
-
get state() {
|
|
52
|
-
return this._state;
|
|
53
|
-
}
|
|
54
|
-
getIsReady() {
|
|
55
|
-
return this._state === 'connected' && !this.isDestroyed;
|
|
56
|
-
}
|
|
57
|
-
isInConflict() {
|
|
58
|
-
return this._state === 'conflict';
|
|
59
|
-
}
|
|
60
|
-
getUuid() {
|
|
61
|
-
return this.uuid;
|
|
62
|
-
}
|
|
63
|
-
async initialize() {
|
|
64
|
-
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
65
|
-
this.logger.warn('Already connecting, skipping...');
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (this.isDestroyed) {
|
|
69
|
-
throw new ConnectionError('Client has been destroyed, cannot initialize');
|
|
70
|
-
}
|
|
71
|
-
this._state = 'connecting';
|
|
72
|
-
this.reconnectAttempts = 0;
|
|
73
|
-
try {
|
|
74
|
-
await this.connect();
|
|
75
|
-
}
|
|
76
|
-
catch (error) {
|
|
77
|
-
this._state = 'disconnected';
|
|
78
|
-
this.logger.error(`Initial connection failed: ${error}`);
|
|
79
|
-
this.scheduleReconnect('INITIAL_CONNECT_FAILED');
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
async connect() {
|
|
83
|
-
return new Promise((resolve, reject) => {
|
|
84
|
-
let settled = false;
|
|
85
|
-
const resolveOnce = () => {
|
|
86
|
-
if (settled)
|
|
87
|
-
return;
|
|
88
|
-
settled = true;
|
|
89
|
-
resolve();
|
|
90
|
-
};
|
|
91
|
-
const rejectOnce = (error) => {
|
|
92
|
-
if (settled)
|
|
93
|
-
return;
|
|
94
|
-
settled = true;
|
|
95
|
-
reject(error);
|
|
96
|
-
};
|
|
97
|
-
try {
|
|
98
|
-
// Append auth token as query param for public endpoints (e.g. wss://host/ws?token=xxx)
|
|
99
|
-
const url = this.authToken
|
|
100
|
-
? `${this.wsUrl}${this.wsUrl.includes('?') ? '&' : '?'}token=${this.authToken}`
|
|
101
|
-
: this.wsUrl;
|
|
102
|
-
this.logger.info(`Connecting to ${url}...`);
|
|
103
|
-
this.ws = new WebSocket(url);
|
|
104
|
-
this.connectionTimer = setTimeout(() => {
|
|
105
|
-
this.logger.error(`Connection timeout (${this.connectionTimeout}ms)`);
|
|
106
|
-
this.handleConnectionTimeout();
|
|
107
|
-
rejectOnce(new ConnectionError('Connection timeout'));
|
|
108
|
-
}, this.connectionTimeout);
|
|
109
|
-
this.ws.on('open', () => {
|
|
110
|
-
this.handleOpen();
|
|
111
|
-
resolveOnce();
|
|
112
|
-
});
|
|
113
|
-
this.ws.on('message', (data) => {
|
|
114
|
-
this.handleMessage(data);
|
|
115
|
-
});
|
|
116
|
-
this.ws.on('close', (code, reason) => {
|
|
117
|
-
const reasonText = reason.toString();
|
|
118
|
-
this.handleClose(code, reasonText);
|
|
119
|
-
rejectOnce(new ConnectionError(`Connection closed before ready: code=${code}, reason=${reasonText || 'N/A'}`));
|
|
120
|
-
});
|
|
121
|
-
this.ws.on('error', (error) => {
|
|
122
|
-
this.handleError(error);
|
|
123
|
-
rejectOnce(error);
|
|
124
|
-
});
|
|
125
|
-
this.ws.on('pong', () => {
|
|
126
|
-
this.handlePong();
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
catch (error) {
|
|
130
|
-
this.logger.error(`Connection error: ${error}`);
|
|
131
|
-
rejectOnce(error);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
handleOpen() {
|
|
136
|
-
this.logger.info('WebSocket connected');
|
|
137
|
-
this.clearConnectionTimer();
|
|
138
|
-
if (this.authToken) {
|
|
139
|
-
this._state = 'authenticating';
|
|
140
|
-
this.startAuthTimeout();
|
|
141
|
-
if (!this.sendAuthMessage()) {
|
|
142
|
-
this._state = 'error';
|
|
143
|
-
this.closeSocket(1011, 'Failed to send auth message');
|
|
144
|
-
}
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
this.markConnected('Connected without auth token');
|
|
148
|
-
}
|
|
149
|
-
sendAuthMessage() {
|
|
150
|
-
const authMsg = {
|
|
151
|
-
type: 'auth',
|
|
152
|
-
payload: {
|
|
153
|
-
token: this.authToken,
|
|
154
|
-
uuid: this.uuid,
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
try {
|
|
158
|
-
this.ws?.send(JSON.stringify(authMsg));
|
|
159
|
-
this.logger.info('Auth message sent');
|
|
160
|
-
return true;
|
|
161
|
-
}
|
|
162
|
-
catch (error) {
|
|
163
|
-
this.logger.error(`Failed to send auth message: ${error}`);
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
handleMessage(data) {
|
|
168
|
-
try {
|
|
169
|
-
const message = JSON.parse(data.toString());
|
|
170
|
-
if (message.type === 'auth_response') {
|
|
171
|
-
this.handleAuthResponse(message);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
if (message.type === 'pong') {
|
|
175
|
-
this.handlePong();
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
if (message.type === 'conflict') {
|
|
179
|
-
this.handleConflict(message);
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
if (this.isRateLimitMessage(message)) {
|
|
183
|
-
this.handleRateLimitedMessage(message);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
this.messageHandlers.forEach(handler => {
|
|
187
|
-
try {
|
|
188
|
-
handler(message);
|
|
189
|
-
}
|
|
190
|
-
catch (error) {
|
|
191
|
-
this.logger.error(`Error in message handler: ${error}`);
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
catch (error) {
|
|
196
|
-
this.logger.error(`Failed to parse message: ${error}`);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
handleAuthResponse(message) {
|
|
200
|
-
this.clearAuthTimer();
|
|
201
|
-
const authSuccess = message.success === true || message.payload?.success === true;
|
|
202
|
-
if (authSuccess) {
|
|
203
|
-
this.markConnected('Authentication successful');
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (this.isRateLimitMessage(message)) {
|
|
207
|
-
this.handleRateLimitedMessage(message, 'Auth rejected by rate limit');
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const errorMessage = this.extractErrorMessage(message);
|
|
211
|
-
this.logger.error(`Authentication failed: ${errorMessage}`);
|
|
212
|
-
this._state = 'error';
|
|
213
|
-
this.scheduleReconnect('AUTH_FAILED');
|
|
214
|
-
this.closeSocket(1008, 'Authentication failed');
|
|
215
|
-
}
|
|
216
|
-
handleConflict(message) {
|
|
217
|
-
this.logger.error(`UUID conflict detected: ${message.reason || 'duplicate UUID'}`);
|
|
218
|
-
this._state = 'conflict';
|
|
219
|
-
this.clearAuthTimer();
|
|
220
|
-
this.stopHeartbeat();
|
|
221
|
-
this.clearReconnectResetTimer();
|
|
222
|
-
if (this.reconnectTimer) {
|
|
223
|
-
clearTimeout(this.reconnectTimer);
|
|
224
|
-
this.reconnectTimer = null;
|
|
225
|
-
}
|
|
226
|
-
this.messageHandlers.forEach(handler => {
|
|
227
|
-
try {
|
|
228
|
-
handler({
|
|
229
|
-
type: 'SYSTEM_CONFLICT',
|
|
230
|
-
payload: {
|
|
231
|
-
uuid: this.uuid,
|
|
232
|
-
message: message.reason || 'UUID conflict detected',
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
catch (error) {
|
|
237
|
-
this.logger.error(`Error in conflict handler: ${error}`);
|
|
238
|
-
}
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
handleClose(code, reason) {
|
|
242
|
-
this.logger.warn(`WebSocket closed: code=${code}, reason=${reason}`);
|
|
243
|
-
this.clearConnectionTimer();
|
|
244
|
-
this.clearAuthTimer();
|
|
245
|
-
this.clearReconnectResetTimer();
|
|
246
|
-
this.stopHeartbeat();
|
|
247
|
-
if (this._state === 'conflict' || this.isDestroyed) {
|
|
248
|
-
this.logger.info('Skipping reconnection (conflict or destroyed)');
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
if (this._state !== 'rate_limited') {
|
|
252
|
-
this._state = 'disconnected';
|
|
253
|
-
}
|
|
254
|
-
this.scheduleReconnect('CONNECTION_CLOSED');
|
|
255
|
-
}
|
|
256
|
-
handleError(error) {
|
|
257
|
-
this.logger.error(`WebSocket error: ${error.message}`);
|
|
258
|
-
this._state = 'error';
|
|
259
|
-
}
|
|
260
|
-
handleConnectionTimeout() {
|
|
261
|
-
this.clearConnectionTimer();
|
|
262
|
-
if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
|
|
263
|
-
this.logger.error('Connection timeout, forcing close');
|
|
264
|
-
this._state = 'error';
|
|
265
|
-
this.scheduleReconnect('CONNECTION_TIMEOUT');
|
|
266
|
-
this.closeSocket(1000, 'Connection timeout');
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
scheduleReconnect(reason, minDelayMs) {
|
|
270
|
-
if (this.isDestroyed || this._state === 'conflict') {
|
|
271
|
-
this.logger.info('Skipping reconnection');
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
if (this.reconnectTimer) {
|
|
275
|
-
this.logger.debug(`Reconnect already scheduled, skipping duplicate (reason: ${reason})`);
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
this.reconnectAttempts++;
|
|
279
|
-
const backoffDelay = this.calculateReconnectDelay(this.reconnectAttempts);
|
|
280
|
-
const explicitDelay = typeof minDelayMs === 'number' && Number.isFinite(minDelayMs) && minDelayMs > 0
|
|
281
|
-
? minDelayMs
|
|
282
|
-
: 0;
|
|
283
|
-
const delay = Math.max(backoffDelay, explicitDelay);
|
|
284
|
-
this.logger.info(`Scheduling reconnect attempt #${this.reconnectAttempts} in ${delay}ms (reason: ${reason})`);
|
|
285
|
-
this.reconnectTimer = setTimeout(async () => {
|
|
286
|
-
this.reconnectTimer = null;
|
|
287
|
-
if (this.isDestroyed || this._state === 'conflict') {
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
try {
|
|
291
|
-
this.logger.info(`Reconnecting (attempt #${this.reconnectAttempts})...`);
|
|
292
|
-
this._state = 'connecting';
|
|
293
|
-
await this.connect();
|
|
294
|
-
this.logger.info('Reconnect socket opened, waiting for readiness confirmation');
|
|
295
|
-
}
|
|
296
|
-
catch (error) {
|
|
297
|
-
this.logger.error(`Reconnect attempt #${this.reconnectAttempts} failed: ${error}`);
|
|
298
|
-
this.scheduleReconnect('RECONNECT_FAILED');
|
|
299
|
-
}
|
|
300
|
-
}, delay);
|
|
301
|
-
}
|
|
302
|
-
calculateReconnectDelay(attempt) {
|
|
303
|
-
const jitter = Math.floor(Math.random() * 1000);
|
|
304
|
-
const baseDelay = Math.min(this.reconnectBaseDelay * Math.pow(2, Math.max(attempt - 1, 0)), this.reconnectMaxDelay);
|
|
305
|
-
return baseDelay + jitter;
|
|
306
|
-
}
|
|
307
|
-
startAuthTimeout() {
|
|
308
|
-
this.clearAuthTimer();
|
|
309
|
-
this.authTimer = setTimeout(() => {
|
|
310
|
-
if (this.isDestroyed || this._state !== 'authenticating') {
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
this.logger.warn(`Auth response timeout (${this.authTimeout}ms)`);
|
|
314
|
-
this._state = 'error';
|
|
315
|
-
this.scheduleReconnect('AUTH_TIMEOUT');
|
|
316
|
-
this.closeSocket(1008, 'Auth response timeout');
|
|
317
|
-
}, this.authTimeout);
|
|
318
|
-
}
|
|
319
|
-
clearAuthTimer() {
|
|
320
|
-
if (this.authTimer) {
|
|
321
|
-
clearTimeout(this.authTimer);
|
|
322
|
-
this.authTimer = null;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
clearReconnectResetTimer() {
|
|
326
|
-
if (this.reconnectResetTimer) {
|
|
327
|
-
clearTimeout(this.reconnectResetTimer);
|
|
328
|
-
this.reconnectResetTimer = null;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
clearConnectionTimer() {
|
|
332
|
-
if (this.connectionTimer) {
|
|
333
|
-
clearTimeout(this.connectionTimer);
|
|
334
|
-
this.connectionTimer = null;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
closeSocket(code, reason) {
|
|
338
|
-
if (!this.ws)
|
|
339
|
-
return;
|
|
340
|
-
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
341
|
-
try {
|
|
342
|
-
this.ws.close(code, reason);
|
|
343
|
-
}
|
|
344
|
-
catch (error) {
|
|
345
|
-
this.logger.debug(`Ignored error during socket close: ${error}`);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
markConnected(message) {
|
|
350
|
-
this.clearAuthTimer();
|
|
351
|
-
this._state = 'connected';
|
|
352
|
-
this.startHeartbeat();
|
|
353
|
-
this.flushMessageQueue();
|
|
354
|
-
this.scheduleReconnectAttemptReset();
|
|
355
|
-
this.logger.info(message);
|
|
356
|
-
if (this.sendTestMessage && this.targetUserId) {
|
|
357
|
-
this.sendConnectionTestMessage();
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
scheduleReconnectAttemptReset() {
|
|
361
|
-
this.clearReconnectResetTimer();
|
|
362
|
-
this.reconnectResetTimer = setTimeout(() => {
|
|
363
|
-
this.reconnectResetTimer = null;
|
|
364
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this._state !== 'connected') {
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
if (this.reconnectAttempts > 0) {
|
|
368
|
-
this.logger.info(`Connection stable for ${this.reconnectResetWindow}ms, resetting reconnect attempts`);
|
|
369
|
-
}
|
|
370
|
-
this.reconnectAttempts = 0;
|
|
371
|
-
}, this.reconnectResetWindow);
|
|
372
|
-
}
|
|
373
|
-
extractErrorMessage(message) {
|
|
374
|
-
const candidates = [
|
|
375
|
-
message?.error,
|
|
376
|
-
message?.message,
|
|
377
|
-
message?.reason,
|
|
378
|
-
message?.payload?.error,
|
|
379
|
-
message?.payload?.message,
|
|
380
|
-
message?.payload?.reason,
|
|
381
|
-
];
|
|
382
|
-
for (const candidate of candidates) {
|
|
383
|
-
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
|
384
|
-
return candidate.trim();
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return 'Unknown error';
|
|
388
|
-
}
|
|
389
|
-
extractRetryAfterMs(message) {
|
|
390
|
-
const retryAfterCandidates = [
|
|
391
|
-
message?.retry_after,
|
|
392
|
-
message?.retryAfter,
|
|
393
|
-
message?.payload?.retry_after,
|
|
394
|
-
message?.payload?.retryAfter,
|
|
395
|
-
];
|
|
396
|
-
for (const candidate of retryAfterCandidates) {
|
|
397
|
-
let seconds = null;
|
|
398
|
-
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
|
399
|
-
seconds = candidate;
|
|
400
|
-
}
|
|
401
|
-
else if (typeof candidate === 'string' && candidate.trim() !== '') {
|
|
402
|
-
const parsed = Number(candidate);
|
|
403
|
-
if (Number.isFinite(parsed)) {
|
|
404
|
-
seconds = parsed;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
if (seconds !== null && seconds > 0) {
|
|
408
|
-
return Math.min(seconds * 1000, 60 * 60 * 1000);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
return undefined;
|
|
412
|
-
}
|
|
413
|
-
isRateLimitMessage(message) {
|
|
414
|
-
const type = String(message?.type ?? '').toLowerCase();
|
|
415
|
-
if (type === 'rate_limit' || type === 'rate_limited') {
|
|
416
|
-
return true;
|
|
417
|
-
}
|
|
418
|
-
if (this.extractRetryAfterMs(message) !== undefined) {
|
|
419
|
-
return true;
|
|
420
|
-
}
|
|
421
|
-
const codeCandidates = [
|
|
422
|
-
message?.code,
|
|
423
|
-
message?.errorCode,
|
|
424
|
-
message?.payload?.code,
|
|
425
|
-
message?.payload?.errorCode,
|
|
426
|
-
];
|
|
427
|
-
if (codeCandidates.some(code => typeof code === 'string' && code.toLowerCase().includes('rate'))) {
|
|
428
|
-
return true;
|
|
429
|
-
}
|
|
430
|
-
const errorText = this.extractErrorMessage(message).toLowerCase();
|
|
431
|
-
return (errorText.includes('rate limit') ||
|
|
432
|
-
errorText.includes('too many requests') ||
|
|
433
|
-
errorText.includes('cooldown') ||
|
|
434
|
-
errorText.includes('try again later') ||
|
|
435
|
-
errorText.includes('429'));
|
|
436
|
-
}
|
|
437
|
-
handleRateLimitedMessage(message, logPrefix = 'Rate limited by server') {
|
|
438
|
-
this.clearAuthTimer();
|
|
439
|
-
const retryAfterMs = this.extractRetryAfterMs(message);
|
|
440
|
-
const effectiveRetryDelay = retryAfterMs ?? DEFAULT_RATE_LIMIT_RETRY_SECONDS * 1000;
|
|
441
|
-
const retryAfterSeconds = Math.ceil(effectiveRetryDelay / 1000);
|
|
442
|
-
const errorMessage = this.extractErrorMessage(message);
|
|
443
|
-
this._state = 'rate_limited';
|
|
444
|
-
this.logger.warn(`${logPrefix}: ${errorMessage}. Retrying in ${retryAfterSeconds}s`);
|
|
445
|
-
this.scheduleReconnect('RATE_LIMITED', effectiveRetryDelay);
|
|
446
|
-
this.closeSocket(1013, 'Rate limited');
|
|
447
|
-
}
|
|
448
|
-
startHeartbeat() {
|
|
449
|
-
this.stopHeartbeat();
|
|
450
|
-
this.heartbeatTimer = setInterval(() => {
|
|
451
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
452
|
-
try {
|
|
453
|
-
this.ws.ping();
|
|
454
|
-
const pingMsg = JSON.stringify({ type: 'ping', timestamp: Date.now() });
|
|
455
|
-
this.ws.send(pingMsg);
|
|
456
|
-
this.logger.debug('Sent ping (protocol + application layer)');
|
|
457
|
-
if (this.pongTimer) {
|
|
458
|
-
clearTimeout(this.pongTimer);
|
|
459
|
-
}
|
|
460
|
-
this.pongTimer = setTimeout(() => {
|
|
461
|
-
this.logger.error('Pong timeout, reconnecting...');
|
|
462
|
-
this.ws?.close(1000, 'Pong timeout');
|
|
463
|
-
}, this.pongTimeout);
|
|
464
|
-
}
|
|
465
|
-
catch (error) {
|
|
466
|
-
this.logger.error(`Failed to send ping: ${error}`);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}, this.heartbeatInterval);
|
|
470
|
-
}
|
|
471
|
-
stopHeartbeat() {
|
|
472
|
-
if (this.heartbeatTimer) {
|
|
473
|
-
clearInterval(this.heartbeatTimer);
|
|
474
|
-
this.heartbeatTimer = null;
|
|
475
|
-
}
|
|
476
|
-
if (this.pongTimer) {
|
|
477
|
-
clearTimeout(this.pongTimer);
|
|
478
|
-
this.pongTimer = null;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
handlePong() {
|
|
482
|
-
this.lastPongTime = Date.now();
|
|
483
|
-
if (this.pongTimer) {
|
|
484
|
-
clearTimeout(this.pongTimer);
|
|
485
|
-
this.pongTimer = null;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
flushMessageQueue() {
|
|
489
|
-
if (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
|
|
490
|
-
this.logger.info(`Flushing ${this.messageQueue.length} queued messages`);
|
|
491
|
-
while (this.messageQueue.length > 0) {
|
|
492
|
-
const message = this.messageQueue.shift();
|
|
493
|
-
if (message) {
|
|
494
|
-
try {
|
|
495
|
-
this.ws?.send(JSON.stringify(message));
|
|
496
|
-
}
|
|
497
|
-
catch (error) {
|
|
498
|
-
this.logger.error(`Failed to send queued message: ${error}`);
|
|
499
|
-
this.messageQueue.unshift(message);
|
|
500
|
-
break;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
async sendTextMessage(toUserId, text) {
|
|
507
|
-
if (!this.getIsReady()) {
|
|
508
|
-
throw new MessageError('Client not ready');
|
|
509
|
-
}
|
|
510
|
-
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
511
|
-
const message = {
|
|
512
|
-
type: 'text',
|
|
513
|
-
from: this.uuid,
|
|
514
|
-
to: toUserId,
|
|
515
|
-
text,
|
|
516
|
-
messageId,
|
|
517
|
-
timestamp: Date.now(),
|
|
518
|
-
};
|
|
519
|
-
return this.sendMessage(message);
|
|
520
|
-
}
|
|
521
|
-
sendConnectionTestMessage() {
|
|
522
|
-
if (!this.targetUserId) {
|
|
523
|
-
this.logger.warn('Cannot send test message: targetUserId not configured');
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
const testMessage = {
|
|
527
|
-
type: 'text',
|
|
528
|
-
from: this.uuid,
|
|
529
|
-
to: this.targetUserId,
|
|
530
|
-
text: '🔗 OpenClaw CarysCloud IM 连接成功!Connection established successfully!',
|
|
531
|
-
messageId: `test_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
|
532
|
-
timestamp: Date.now(),
|
|
533
|
-
};
|
|
534
|
-
try {
|
|
535
|
-
this.ws?.send(JSON.stringify(testMessage));
|
|
536
|
-
this.logger.info(`✅ Connection test message sent to ${this.targetUserId}`);
|
|
537
|
-
}
|
|
538
|
-
catch (error) {
|
|
539
|
-
this.logger.error(`Failed to send connection test message: ${error}`);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
async sendImageMessage(toUserId, imageFile) {
|
|
543
|
-
if (!this.getIsReady()) {
|
|
544
|
-
throw new MessageError('Client not ready');
|
|
545
|
-
}
|
|
546
|
-
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
547
|
-
const reader = new FileReader();
|
|
548
|
-
const base64 = await new Promise((resolve, reject) => {
|
|
549
|
-
reader.onload = () => resolve(reader.result);
|
|
550
|
-
reader.onerror = reject;
|
|
551
|
-
reader.readAsDataURL(imageFile);
|
|
552
|
-
});
|
|
553
|
-
const message = {
|
|
554
|
-
type: 'image',
|
|
555
|
-
from: this.uuid,
|
|
556
|
-
to: toUserId,
|
|
557
|
-
payload: {
|
|
558
|
-
base64,
|
|
559
|
-
filename: imageFile.name,
|
|
560
|
-
size: imageFile.size,
|
|
561
|
-
},
|
|
562
|
-
messageId,
|
|
563
|
-
timestamp: Date.now(),
|
|
564
|
-
};
|
|
565
|
-
return this.sendMessage(message);
|
|
566
|
-
}
|
|
567
|
-
async sendFileMessage(toUserId, file) {
|
|
568
|
-
if (!this.getIsReady()) {
|
|
569
|
-
throw new MessageError('Client not ready');
|
|
570
|
-
}
|
|
571
|
-
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
572
|
-
const reader = new FileReader();
|
|
573
|
-
const base64 = await new Promise((resolve, reject) => {
|
|
574
|
-
reader.onload = () => resolve(reader.result);
|
|
575
|
-
reader.onerror = reject;
|
|
576
|
-
reader.readAsDataURL(file);
|
|
577
|
-
});
|
|
578
|
-
const message = {
|
|
579
|
-
type: 'file',
|
|
580
|
-
from: this.uuid,
|
|
581
|
-
to: toUserId,
|
|
582
|
-
payload: {
|
|
583
|
-
base64,
|
|
584
|
-
filename: file.name,
|
|
585
|
-
size: file.size,
|
|
586
|
-
},
|
|
587
|
-
messageId,
|
|
588
|
-
timestamp: Date.now(),
|
|
589
|
-
};
|
|
590
|
-
return this.sendMessage(message);
|
|
591
|
-
}
|
|
592
|
-
sendMessage(message) {
|
|
593
|
-
return new Promise((resolve, reject) => {
|
|
594
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
595
|
-
if (this.messageQueue.length >= this.messageQueueMaxSize) {
|
|
596
|
-
this.messageQueue.shift();
|
|
597
|
-
this.logger.warn('Message queue full, dropping oldest message');
|
|
598
|
-
}
|
|
599
|
-
this.messageQueue.push(message);
|
|
600
|
-
this.logger.info('Message queued (not connected)');
|
|
601
|
-
resolve(message.messageId);
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
try {
|
|
605
|
-
this.ws.send(JSON.stringify(message), (error) => {
|
|
606
|
-
if (error) {
|
|
607
|
-
this.logger.error(`Failed to send message: ${error}`);
|
|
608
|
-
if (this.messageQueue.length < this.messageQueueMaxSize) {
|
|
609
|
-
this.messageQueue.push(message);
|
|
610
|
-
}
|
|
611
|
-
reject(new MessageError(`Failed to send message: ${error.message}`, error));
|
|
612
|
-
}
|
|
613
|
-
else {
|
|
614
|
-
resolve(message.messageId);
|
|
615
|
-
}
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
catch (error) {
|
|
619
|
-
this.logger.error(`Send error: ${error}`);
|
|
620
|
-
reject(new MessageError(`Send error: ${error}`, error));
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
onMessage(handler) {
|
|
625
|
-
this.messageHandlers.push(handler);
|
|
626
|
-
}
|
|
627
|
-
offMessage(handler) {
|
|
628
|
-
const index = this.messageHandlers.indexOf(handler);
|
|
629
|
-
if (index > -1) {
|
|
630
|
-
this.messageHandlers.splice(index, 1);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
async destroy() {
|
|
634
|
-
this.isDestroyed = true;
|
|
635
|
-
if (this.reconnectTimer) {
|
|
636
|
-
clearTimeout(this.reconnectTimer);
|
|
637
|
-
this.reconnectTimer = null;
|
|
638
|
-
}
|
|
639
|
-
this.clearConnectionTimer();
|
|
640
|
-
this.clearAuthTimer();
|
|
641
|
-
this.clearReconnectResetTimer();
|
|
642
|
-
this.stopHeartbeat();
|
|
643
|
-
if (this.ws) {
|
|
644
|
-
const ws = this.ws;
|
|
645
|
-
this.ws = null;
|
|
646
|
-
try {
|
|
647
|
-
// Only close if the connection is fully established
|
|
648
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
649
|
-
ws.close(1000, 'Client destroyed');
|
|
650
|
-
}
|
|
651
|
-
else if (ws.readyState === WebSocket.CONNECTING) {
|
|
652
|
-
// For connecting state, just terminate without close frame
|
|
653
|
-
// to avoid "closed before connection was established" error
|
|
654
|
-
ws.terminate();
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
catch (error) {
|
|
658
|
-
// Ignore errors during close/terminate - connection might already be dead
|
|
659
|
-
this.logger.debug(`Ignored error during WebSocket cleanup: ${error}`);
|
|
660
|
-
}
|
|
661
|
-
try {
|
|
662
|
-
ws.removeAllListeners();
|
|
663
|
-
}
|
|
664
|
-
catch {
|
|
665
|
-
// Ignore errors removing listeners
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
this.messageHandlers = [];
|
|
669
|
-
this.messageQueue = [];
|
|
670
|
-
this._state = 'idle';
|
|
671
|
-
this.logger.info('Client destroyed');
|
|
672
|
-
}
|
|
673
|
-
isUserSignExpired() {
|
|
674
|
-
return false;
|
|
675
|
-
}
|
|
676
|
-
async refreshUserSign() {
|
|
677
|
-
this.logger.info('refreshUserSign called (no-op for WebSocket)');
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
//# sourceMappingURL=ws-client.js.map
|