lightclawbot 1.2.9 → 1.2.10

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.
@@ -42,6 +42,18 @@ export const SOCKET_PATH = '/ws/agent';
42
42
  export const SOCKET_RECONNECTION_DELAY = 1000;
43
43
  export const SOCKET_RECONNECTION_DELAY_MAX = 30000;
44
44
  export const SOCKET_RECONNECTION_ATTEMPTS = Infinity;
45
+ /** 首次重连随机抖动上限(ms),用于打散服务发布后的集中重连 */
46
+ export const SOCKET_RECONNECTION_INITIAL_JITTER_MAX = 10_000;
47
+ /** 后续指数退避随机抖动比例,0.5 表示在 50%~150% 区间内随机 */
48
+ export const SOCKET_RECONNECTION_JITTER_RATIO = 0.5;
49
+ /** WebSocket 协议级 ping 间隔(ms),需小于 ai-server 60s 在线 TTL */
50
+ export const WS_PING_INTERVAL_MS = 20_000;
51
+ /** WebSocket heartbeat 首次 ping 随机抖动上限(ms),用于避免重连后 ping 周期对齐 */
52
+ export const WS_HEARTBEAT_INITIAL_JITTER_MAX = WS_PING_INTERVAL_MS;
53
+ /** WebSocket pong 等待超时时间(ms) */
54
+ export const WS_PONG_TIMEOUT_MS = 10_000;
55
+ /** 连续 pong 超时次数阈值,达到后判定连接半开并主动重连 */
56
+ export const WS_MAX_MISSED_PONG = 2;
45
57
  // ============================================================
46
58
  // HTTP Header 配置
47
59
  // ============================================================
@@ -77,6 +77,30 @@ async function resolveBotClientId(apiKey, log) {
77
77
  ticket: result?.data?.ticket || '',
78
78
  };
79
79
  }
80
+ /**
81
+ * 获取 Bot clientId 与 ticket,失败或 ticket 为空时额外重试一次。
82
+ * @param apiKey - 当前账户的 apiKey
83
+ * @param log - 可选日志对象
84
+ */
85
+ export async function resolveBotClientIdWithRetry(apiKey, log) {
86
+ let lastError;
87
+ for (let attempt = 0; attempt < 2; attempt++) {
88
+ try {
89
+ const result = await resolveBotClientId(apiKey, log);
90
+ if (result.ticket || attempt === 1)
91
+ return result;
92
+ lastError = new Error(`[${CHANNEL_KEY}] Missing ticket in ${API_PATH_USER_CURRENT} response`);
93
+ }
94
+ catch (error) {
95
+ lastError = error;
96
+ }
97
+ if (attempt === 0) {
98
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
99
+ log?.warn(`[${CHANNEL_KEY}] Failed to resolve ticket, retrying once: ${message}`);
100
+ }
101
+ }
102
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
103
+ }
80
104
  // ============================================================
81
105
  // 核心:启动 Gateway
82
106
  // ============================================================
@@ -112,7 +136,7 @@ export async function startGateway(ctx) {
112
136
  // 通过 HTTP 接口获取 Bot 的 clientId(botClientId)
113
137
  // 新配置格式:accountId 即为 uin,apiKey 与 uin 一一对应,直接构建映射表
114
138
  log?.info(`${prefix} Resolving botClientId for account ${account.accountId}...`);
115
- const { botClientId, ticket } = await resolveBotClientId(account.apiKey, log);
139
+ const { botClientId, ticket: initialTicket } = await resolveBotClientIdWithRetry(account.apiKey, log);
116
140
  // 一次性构建全局 uin→apiKey 映射(遍历所有 accounts,幂等操作)
117
141
  // 解决多账号场景下每个 gateway 启动时覆盖全局 Map 的问题
118
142
  buildGlobalApiKeyMap(cfg);
@@ -306,14 +330,26 @@ export async function startGateway(ctx) {
306
330
  // 将 cleanup 注册到全局 Map,供防重入检查和外部销毁使用
307
331
  activeGateways.set(account.accountId, cleanup);
308
332
  // ---- WebSocket 连接 ----
309
- // 采用 ticket 作为 query 参数进行认证,不再使用 header 方式
310
- // 为兼容已有的 path 选项,仍通过 options.path 传入 /claw-socket
311
- const ticketQuery = ticket ? `?ticket=${encodeURIComponent(ticket)}` : '';
312
- log?.info(`[${CHANNEL_KEY}] Connecting to ${WS_URL}${SOCKET_PATH}${ticketQuery}`);
333
+ // 采用 ticket 作为 query 参数进行认证,不再使用 header 方式。
334
+ // ai-server ticket 是一次性凭证,因此每次建连/重连前都必须重新获取 fresh ticket。
335
+ // 首次连接复用启动阶段已获取的 ticket;后续重连再重新请求,避免浪费未消费 ticket
336
+ let nextTicket = initialTicket;
337
+ const buildSocketQuery = async () => {
338
+ const freshTicket = nextTicket || (await resolveBotClientIdWithRetry(account.apiKey, log)).ticket;
339
+ nextTicket = '';
340
+ if (!freshTicket) {
341
+ throw new Error(`[${CHANNEL_KEY}] Missing ticket in ${API_PATH_USER_CURRENT} response`);
342
+ }
343
+ return {
344
+ ticket: freshTicket,
345
+ enableMultiLogin: false,
346
+ };
347
+ };
313
348
  // 使用原生 WebSocket 封装层建立连接
314
349
  const socket = new NativeSocketClient(WS_URL, {
315
- // 认证:ticket 作为 URL query 参数,替代 Authorization header
316
- path: `${SOCKET_PATH}${ticketQuery}&enableMultiLogin=false`,
350
+ // 认证:每次连接前由动态 query 获取 fresh ticket,避免自动重连复用旧 ticket 触发 401。
351
+ path: SOCKET_PATH,
352
+ query: buildSocketQuery,
317
353
  logPrefix: `[${CHANNEL_KEY}:${account.accountId}:NativeSocket]`,
318
354
  });
319
355
  currentSocket = socket;
@@ -13,7 +13,7 @@
13
13
  * ACK :{ event: 'message:ack'; data: { msgId: string; relatedMsgId: string; ... } }
14
14
  */
15
15
  import WebSocket from 'ws';
16
- import { SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_DELAY_MAX, SOCKET_RECONNECTION_ATTEMPTS } from '../config.js';
16
+ import { SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_DELAY_MAX, SOCKET_RECONNECTION_ATTEMPTS, SOCKET_RECONNECTION_INITIAL_JITTER_MAX, SOCKET_RECONNECTION_JITTER_RATIO, WS_PING_INTERVAL_MS, WS_HEARTBEAT_INITIAL_JITTER_MAX, WS_PONG_TIMEOUT_MS, WS_MAX_MISSED_PONG, } from '../config.js';
17
17
  import { getModuleLogger } from '../utils/logger.js';
18
18
  /** 模块级日志器 */
19
19
  const moduleLog = getModuleLogger('socket.native-socket');
@@ -25,8 +25,11 @@ const moduleLog = getModuleLogger('socket.native-socket');
25
25
  *
26
26
  * 使用方式:
27
27
  * ```ts
28
- * // 认证信息请直接拼接到 URL 上(例如 ?ticket=xxx),本类不再处理 auth headers
29
- * const socket = new NativeSocketClient(`${WS_URL}?ticket=xxx`, { path: '/claw-socket' });
28
+ * // path 仅表达路径;ticket 等鉴权参数通过 query 传入,可用异步函数在重连前刷新
29
+ * const socket = new NativeSocketClient(WS_URL, {
30
+ * path: '/ws/agent',
31
+ * query: async () => ({ ticket: await fetchTicket() }),
32
+ * });
30
33
  * socket.on('connect', () => { ... });
31
34
  * socket.on('message:private', (data) => { ... });
32
35
  * socket.timeout(5000).emit('message:private', data, (err) => { ... });
@@ -42,6 +45,8 @@ export class NativeSocketClient {
42
45
  _connected = false;
43
46
  _id = undefined;
44
47
  _ws = null;
48
+ /** 是否正在构建或发起连接,避免异步 query 获取期间重复建连 */
49
+ _connecting = false;
45
50
  /** 是否为主动断开(主动断开时不触发自动重连) */
46
51
  _manualDisconnect = false;
47
52
  // ----------------------------------------------------------
@@ -50,6 +55,14 @@ export class NativeSocketClient {
50
55
  _reconnectAttempts = 0;
51
56
  _reconnectTimer = null;
52
57
  // ----------------------------------------------------------
58
+ // WebSocket 协议级心跳状态
59
+ // ----------------------------------------------------------
60
+ _heartbeatInitialTimer = null;
61
+ _pingTimer = null;
62
+ _pongTimeoutTimer = null;
63
+ _lastPingAt = 0;
64
+ _missedPongCount = 0;
65
+ // ----------------------------------------------------------
53
66
  // 事件监听器映射表
54
67
  // ----------------------------------------------------------
55
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -67,16 +80,17 @@ export class NativeSocketClient {
67
80
  /** 日志前缀,便于在多实例场景下区分日志来源。 */
68
81
  logPrefix;
69
82
  /**
70
- * @param url WebSocket 服务端地址(ws:// 或 wss://),认证所需的 ticket 等参数请直接拼到 URL query 上
83
+ * @param url WebSocket 服务端地址(ws:// 或 wss://)
71
84
  * @param options 连接选项
72
- * - path: 连接路径(如 /claw-socket),会附加到 URL
85
+ * - path: 连接路径(如 /ws/agent),仅表达路径语义
86
+ * - query: 静态或动态查询参数;ticket 等鉴权参数应放在这里
73
87
  * - logPrefix: 日志前缀(可选),默认 `[NativeSocket]`
74
88
  */
75
89
  constructor(url, options = {}) {
76
90
  this.url = url;
77
91
  this.options = options;
78
92
  this.logPrefix = options.logPrefix ?? '[NativeSocket]';
79
- this._connect();
93
+ void this._connect();
80
94
  }
81
95
  // ----------------------------------------------------------
82
96
  // NativeSocket 接口实现
@@ -184,38 +198,63 @@ export class NativeSocketClient {
184
198
  connect() {
185
199
  // 手动触发重连(用于服务端主动断开后的手动重连场景)
186
200
  // 若当前已有活跃连接(CONNECTING 或 OPEN),直接返回,避免连接泄漏
187
- if (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN)) {
188
- this.log.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws.readyState === WebSocket.OPEN ? 'OPEN' : 'CONNECTING'}`);
201
+ if (this._connecting || (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN))) {
202
+ this.log.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws?.readyState === WebSocket.OPEN ? 'OPEN' : 'CONNECTING'}`);
189
203
  return this;
190
204
  }
191
205
  this.log.info(`${this.logPrefix} connect() called, manually reconnecting`);
192
206
  this._manualDisconnect = false;
193
207
  this._reconnectAttempts = 0;
194
208
  this._clearReconnectTimer();
195
- this._connect();
209
+ void this._connect();
196
210
  return this;
197
211
  }
198
212
  // ----------------------------------------------------------
199
213
  // 内部:建立 WebSocket 连接
200
214
  // ----------------------------------------------------------
201
- _connect() {
202
- // 构建完整 URL(仅拼接 path,认证通过 URL query 参数完成,无需 headers)
203
- const fullUrl = this._buildUrl();
204
- this.log.info(`${this.logPrefix} Connecting to ${fullUrl}`);
215
+ async _connect() {
216
+ if (this._connecting || (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN))) {
217
+ this.log.debug?.(`${this.logPrefix} _connect() skipped, connection already in progress or open`);
218
+ return;
219
+ }
220
+ this._connecting = true;
221
+ let fullUrl;
222
+ try {
223
+ // 构建完整 URL(仅拼接 path,认证通过 URL query 参数完成,无需 headers)
224
+ fullUrl = await this._buildUrl();
225
+ }
226
+ catch (err) {
227
+ this._connecting = false;
228
+ const message = err instanceof Error ? err.message : String(err);
229
+ this.log.error(`${this.logPrefix} Failed to build WebSocket URL: ${message}`);
230
+ this._dispatch('connect_error', err instanceof Error ? err : new Error(message));
231
+ if (!this._manualDisconnect) {
232
+ this._scheduleReconnect();
233
+ }
234
+ return;
235
+ }
236
+ this.log.info(`${this.logPrefix} Connecting to ${this._maskUrl(fullUrl)}`);
205
237
  try {
206
238
  const ws = new WebSocket(fullUrl);
207
239
  this._ws = ws;
208
240
  ws.onopen = () => {
241
+ this._connecting = false;
209
242
  this._reconnectAttempts = 0;
210
243
  this._connected = true;
211
244
  this.log.info(`${this.logPrefix} WebSocket opened`);
245
+ this._startHeartbeat();
212
246
  this._dispatch('connect');
213
247
  };
214
248
  ws.onmessage = (event) => {
215
249
  this._handleMessage(event.data);
216
250
  };
251
+ ws.on('pong', () => {
252
+ this._handleHeartbeatPong();
253
+ });
217
254
  ws.onclose = (event) => {
218
255
  const wasConnected = this._connected;
256
+ this._stopHeartbeat();
257
+ this._connecting = false;
219
258
  this._connected = false;
220
259
  this._id = undefined;
221
260
  this._ws = null;
@@ -238,6 +277,7 @@ export class NativeSocketClient {
238
277
  };
239
278
  }
240
279
  catch (err) {
280
+ this._connecting = false;
241
281
  const message = err instanceof Error ? err.message : String(err);
242
282
  this.log.error(`${this.logPrefix} Failed to construct WebSocket: ${message}`);
243
283
  this._dispatch('connect_error', err instanceof Error ? err : new Error(String(err)));
@@ -314,8 +354,103 @@ export class NativeSocketClient {
314
354
  }
315
355
  }
316
356
  // ----------------------------------------------------------
357
+ // 内部:WebSocket 协议级心跳
358
+ // ----------------------------------------------------------
359
+ _startHeartbeat() {
360
+ this._stopHeartbeat();
361
+ this._missedPongCount = 0;
362
+ const initialDelay = this._randomDelay(0, WS_HEARTBEAT_INITIAL_JITTER_MAX);
363
+ this.log.info(`${this.logPrefix} WebSocket heartbeat started (initialDelay=${initialDelay}ms, interval=${WS_PING_INTERVAL_MS}ms, timeout=${WS_PONG_TIMEOUT_MS}ms, maxMissed=${WS_MAX_MISSED_PONG})`);
364
+ this._heartbeatInitialTimer = setTimeout(() => {
365
+ this._heartbeatInitialTimer = null;
366
+ this._sendHeartbeatPing();
367
+ this._pingTimer = setInterval(() => {
368
+ this._sendHeartbeatPing();
369
+ }, WS_PING_INTERVAL_MS);
370
+ }, initialDelay);
371
+ }
372
+ _stopHeartbeat() {
373
+ if (this._heartbeatInitialTimer !== null) {
374
+ clearTimeout(this._heartbeatInitialTimer);
375
+ this._heartbeatInitialTimer = null;
376
+ }
377
+ if (this._pingTimer !== null) {
378
+ clearInterval(this._pingTimer);
379
+ this._pingTimer = null;
380
+ }
381
+ if (this._pongTimeoutTimer !== null) {
382
+ clearTimeout(this._pongTimeoutTimer);
383
+ this._pongTimeoutTimer = null;
384
+ }
385
+ this._lastPingAt = 0;
386
+ this._missedPongCount = 0;
387
+ }
388
+ _sendHeartbeatPing() {
389
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN)
390
+ return;
391
+ if (this._pongTimeoutTimer !== null) {
392
+ clearTimeout(this._pongTimeoutTimer);
393
+ this._pongTimeoutTimer = null;
394
+ }
395
+ this._lastPingAt = Date.now();
396
+ try {
397
+ this._ws.ping();
398
+ this.log.debug?.(`${this.logPrefix} WebSocket ping sent`);
399
+ }
400
+ catch (err) {
401
+ const message = err instanceof Error ? err.message : String(err);
402
+ this._terminateStaleConnection(`ping failed: ${message}`);
403
+ return;
404
+ }
405
+ this._pongTimeoutTimer = setTimeout(() => {
406
+ this._pongTimeoutTimer = null;
407
+ this._missedPongCount++;
408
+ this.log.warn(`${this.logPrefix} WebSocket pong timeout (missed=${this._missedPongCount}/${WS_MAX_MISSED_PONG})`);
409
+ if (this._missedPongCount >= WS_MAX_MISSED_PONG) {
410
+ this._terminateStaleConnection('pong timeout');
411
+ }
412
+ }, WS_PONG_TIMEOUT_MS);
413
+ }
414
+ _handleHeartbeatPong() {
415
+ const rtt = this._lastPingAt > 0 ? Date.now() - this._lastPingAt : 0;
416
+ if (this._pongTimeoutTimer !== null) {
417
+ clearTimeout(this._pongTimeoutTimer);
418
+ this._pongTimeoutTimer = null;
419
+ }
420
+ this._missedPongCount = 0;
421
+ this.log.debug?.(`${this.logPrefix} WebSocket pong received (rtt=${rtt}ms)`);
422
+ }
423
+ _terminateStaleConnection(reason) {
424
+ if (!this._ws)
425
+ return;
426
+ this.log.warn(`${this.logPrefix} WebSocket stale detected: ${reason}, terminating connection`);
427
+ this._stopHeartbeat();
428
+ try {
429
+ this._ws.terminate();
430
+ }
431
+ catch (err) {
432
+ const message = err instanceof Error ? err.message : String(err);
433
+ this.log.error(`${this.logPrefix} WebSocket terminate failed: ${message}`);
434
+ }
435
+ }
436
+ // ----------------------------------------------------------
317
437
  // 内部:自动重连
318
438
  // ----------------------------------------------------------
439
+ _randomDelay(min, max) {
440
+ const normalizedMin = Math.max(0, Math.floor(min));
441
+ const normalizedMax = Math.max(normalizedMin, Math.floor(max));
442
+ return normalizedMin + Math.floor(Math.random() * (normalizedMax - normalizedMin + 1));
443
+ }
444
+ _getReconnectDelay() {
445
+ const baseDelay = Math.min(SOCKET_RECONNECTION_DELAY * Math.pow(2, this._reconnectAttempts), SOCKET_RECONNECTION_DELAY_MAX);
446
+ if (this._reconnectAttempts === 0) {
447
+ return this._randomDelay(SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_INITIAL_JITTER_MAX);
448
+ }
449
+ const jitterRange = Math.max(0, SOCKET_RECONNECTION_JITTER_RATIO);
450
+ const minDelay = Math.floor(baseDelay * Math.max(0, 1 - jitterRange));
451
+ const maxDelay = Math.min(SOCKET_RECONNECTION_DELAY_MAX, Math.floor(baseDelay * (1 + jitterRange)));
452
+ return this._randomDelay(minDelay, maxDelay);
453
+ }
319
454
  _scheduleReconnect() {
320
455
  // 超过最大重连次数时停止重连
321
456
  if (SOCKET_RECONNECTION_ATTEMPTS !== Infinity && this._reconnectAttempts >= SOCKET_RECONNECTION_ATTEMPTS) {
@@ -323,14 +458,13 @@ export class NativeSocketClient {
323
458
  this._dispatch('connect_error', new Error(`Max reconnection attempts (${SOCKET_RECONNECTION_ATTEMPTS}) reached`));
324
459
  return;
325
460
  }
326
- // 指数退避延迟:delay = min(base * 2^attempts, max)
327
- const delay = Math.min(SOCKET_RECONNECTION_DELAY * Math.pow(2, this._reconnectAttempts), SOCKET_RECONNECTION_DELAY_MAX);
461
+ const delay = this._getReconnectDelay();
328
462
  this._reconnectAttempts++;
329
- this.log.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms`);
463
+ this.log.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms (jittered)`);
330
464
  this._reconnectTimer = setTimeout(() => {
331
465
  this._reconnectTimer = null;
332
466
  if (!this._manualDisconnect) {
333
- this._connect();
467
+ void this._connect();
334
468
  }
335
469
  }, delay);
336
470
  }
@@ -352,6 +486,7 @@ export class NativeSocketClient {
352
486
  // 内部:关闭 WebSocket
353
487
  // ----------------------------------------------------------
354
488
  _closeWs() {
489
+ this._stopHeartbeat();
355
490
  if (this._ws) {
356
491
  // 移除所有回调,防止 onclose 触发重连
357
492
  this._ws.onopen = null;
@@ -366,6 +501,7 @@ export class NativeSocketClient {
366
501
  }
367
502
  this._ws = null;
368
503
  }
504
+ this._connecting = false;
369
505
  this._connected = false;
370
506
  this._id = undefined;
371
507
  // onclose 被置 null 后不会自动触发,需手动 flush 挂起的 ACK
@@ -374,13 +510,40 @@ export class NativeSocketClient {
374
510
  // ----------------------------------------------------------
375
511
  // 内部:构建完整 URL
376
512
  // ----------------------------------------------------------
377
- _buildUrl() {
378
- let base = this.url;
379
- // 拼接 path(如 /claw-socket)
513
+ async _buildUrl() {
514
+ let base = this.url.replace(/\/$/, '');
380
515
  if (this.options.path) {
381
- // 确保 base 末尾无斜杠,path 开头有斜杠
382
- base = base.replace(/\/$/, '') + this.options.path;
516
+ const path = this.options.path.startsWith('/')
517
+ ? this.options.path
518
+ : `/${this.options.path}`;
519
+ base += path;
520
+ }
521
+ const query = typeof this.options.query === 'function'
522
+ ? await this.options.query()
523
+ : this.options.query;
524
+ const searchParams = new URLSearchParams();
525
+ Object.entries(query ?? {}).forEach(([key, value]) => {
526
+ if (value === undefined || value === null || value === '')
527
+ return;
528
+ searchParams.set(key, String(value));
529
+ });
530
+ const queryString = searchParams.toString();
531
+ if (!queryString)
532
+ return base;
533
+ return `${base}${base.includes('?') ? '&' : '?'}${queryString}`;
534
+ }
535
+ /** 对连接 URL 中的敏感 query 做日志脱敏 */
536
+ _maskUrl(url) {
537
+ try {
538
+ const parsed = new URL(url);
539
+ const ticket = parsed.searchParams.get('ticket');
540
+ if (ticket) {
541
+ parsed.searchParams.set('ticket', `${ticket.slice(0, 6)}...${ticket.slice(-4)}`);
542
+ }
543
+ return parsed.toString();
544
+ }
545
+ catch {
546
+ return url.replace(/([?&]ticket=)([^&]+)/, (_match, prefix, value) => (`${prefix}${value.slice(0, 6)}...${value.slice(-4)}`));
383
547
  }
384
- return base;
385
548
  }
386
549
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightclawbot",
3
- "version": "1.2.9",
3
+ "version": "1.2.10",
4
4
  "description": "LightClawBot channel plugin with message support, cron jobs, and proactive messaging",
5
5
  "type": "module",
6
6
  "files": [