lightclawbot 1.2.8 → 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;
@@ -210,6 +210,28 @@ export function normalizeMessage(msg) {
210
210
  const allFiles = [...(textFiles.length > 0 ? textFiles : []), ...(contentFiles ?? [])];
211
211
  if (allFiles.length > 0)
212
212
  files = deduplicateFiles(allFiles);
213
+ // 修复:当用户消息有 MediaPath/MediaPaths(OpenClaw 存在附件文件)
214
+ // 但 files 里没有 uri 时,补上 localfile:// 格式的 uri
215
+ if (!files || files.some((f) => !f.uri)) {
216
+ const mediaPaths = Array.isArray(msg.MediaPaths)
217
+ ? msg.MediaPaths
218
+ : (typeof msg.MediaPath === "string" ? [msg.MediaPath] : []);
219
+ if (mediaPaths.length > 0) {
220
+ const existingFiles = files ?? [];
221
+ const needInit = existingFiles.length === 0;
222
+ const updatedFiles = needInit
223
+ ? mediaPaths.map((p, i) => ({
224
+ name: existingFiles[i]?.name ?? p.split("/").pop() ?? `file-${i}`,
225
+ mimeType: existingFiles[i]?.mimeType,
226
+ uri: `localfile://${p}`,
227
+ }))
228
+ : existingFiles.map((f, i) => ({
229
+ ...f,
230
+ uri: f.uri ?? (mediaPaths[i] ? `localfile://${mediaPaths[i]}` : undefined),
231
+ }));
232
+ files = updatedFiles;
233
+ }
234
+ }
213
235
  }
214
236
  else {
215
237
  files = extractContentFiles(msg.content) || undefined;
@@ -84,17 +84,52 @@ function sendViaSocket(accountId, target, text, replyToId) {
84
84
  // 从 socket-registry 中获取当前账户的 socket entry
85
85
  const entry = getSocket(resolvedAccountId);
86
86
  const msgId = generateOutboundMsgId();
87
- // 构造标准消息体,格式与 gateway 入站消息保持一致
87
+ const fromId = entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? '';
88
+ // 构造标准消息体,格式与 gateway 入站消息保持一致。
89
+ //
90
+ // 协议关键字段说明:
91
+ // - kind: 'stream_chunk'
92
+ // outbound 由 LLM 工具调用结果(如 message 工具发图/发文)触发,
93
+ // 归属于"流式回复信号链"的一部分,前端依赖 kind 推导消息状态
94
+ // (stream_chunk → 'updating')。若缺省,前端 statusMap 会兜底到
95
+ // typing_stop=='success',导致内容被独立成一条"已完成回复"气泡,
96
+ // 与上一段 LLM 文本(已 typing_stop)错位拼接。详见 stream-reply-sink。
97
+ // - extra.chatId: ''
98
+ // 协议规定所有 EVENT_MESSAGE_PRIVATE 必须携带 extra.chatId(多会话分桶)。
99
+ // outbound 触发时通常无明确 chatId 上下文(cron / message 工具 / REST 等),
100
+ // 按协议约定填空字符串落到默认会话。
101
+ // - idempotencyKey: 由 ReliableEmitter 在 emitWithAck 内部注入,此处无需显式构造。
88
102
  const message = {
89
103
  msgId,
90
104
  // 优先使用 entry 中缓存的 botClientId,entry 不存在时从 registry 直接查询
91
- from: entry?.botClientId ?? getBotClientId(resolvedAccountId) ?? '',
105
+ from: fromId,
92
106
  to: target,
93
107
  content: text,
94
108
  timestamp: Date.now(),
109
+ kind: 'stream_chunk',
95
110
  // undefined 表示非回复消息,避免发送多余字段
96
111
  replyToMsgId: replyToId ?? undefined,
112
+ extra: { chatId: '' },
97
113
  };
114
+ // 自闭合 typing_stop 帧:与 stream_chunk 共用同一个 msgId / chatId,
115
+ // content 必须为空字符串(前端 addOrUpdateMessage 会按 oldContent + content 拼接,
116
+ // 非空 content 会让 caption 文本被复制一遍)。
117
+ //
118
+ // 设计理由:
119
+ // outbound 通道(如 message 工具调用结果)由 OpenClaw 框架以「独立 messageId」投递,
120
+ // 不会与上一段 LLM 流式回复合并。仅发 stream_chunk 而无 typing_stop,
121
+ // 会导致前端这条独立气泡永远停留在 'updating' 状态(loading 转圈)。
122
+ // 故每条 outbound 消息都自带一帧 typing_stop 收尾,让该气泡正常进入 'success'。
123
+ const buildClosingFrame = () => ({
124
+ msgId,
125
+ from: fromId,
126
+ to: target,
127
+ content: '',
128
+ timestamp: Date.now(),
129
+ kind: 'typing_stop',
130
+ replyToMsgId: replyToId ?? undefined,
131
+ extra: { chatId: '' },
132
+ });
98
133
  if (entry) {
99
134
  // 策略 1:Socket 已连接,优先走可靠发送(emitWithAck + 自动重试)
100
135
  const emitter = getReliableEmitter(resolvedAccountId);
@@ -105,12 +140,24 @@ function sendViaSocket(accountId, target, text, replyToId) {
105
140
  logger.error(`[${CHANNEL_KEY}] outbound delivery failed after retries: msgId=${msgId}`);
106
141
  }
107
142
  });
143
+ // 紧跟一帧 typing_stop 闭合该 msgId 的流式状态。
144
+ // ReliableEmitter 内部按 emit 顺序生成 idempotencyKey(字典序 == emit 顺序),
145
+ // 即便网络层 batch 抖动导致 packet 乱序到达,前端也能按字典序合并出
146
+ // 「stream_chunk 在前 → typing_stop 在后」的稳定终态。
147
+ const closingFrame = buildClosingFrame();
148
+ emitter.emitWithAck(EVENT_MESSAGE_PRIVATE, closingFrame, msgId).then((ok) => {
149
+ if (!ok) {
150
+ logger.warn(`[${CHANNEL_KEY}] outbound closing frame ack timeout: msgId=${msgId}`);
151
+ }
152
+ });
108
153
  logger.info(`[${CHANNEL_KEY}] outbound sent via reliable WS: to=${target} msgId=${msgId}`);
109
154
  return msgId;
110
155
  }
111
156
  // 策略 2:可靠发送器不可用,直接 emit(兜底,正常情况不应走到此分支)
112
157
  try {
113
158
  entry.socket.emit(EVENT_MESSAGE_PRIVATE, message);
159
+ // 同样补一帧 typing_stop;裸 emit 无 ack,无法保证送达,best-effort 即可。
160
+ entry.socket.emit(EVENT_MESSAGE_PRIVATE, buildClosingFrame());
114
161
  logger.info(`[${CHANNEL_KEY}] outbound sent via WS (fallback): to=${target} msgId=${msgId}`);
115
162
  return msgId;
116
163
  }
@@ -125,6 +172,10 @@ function sendViaSocket(accountId, target, text, replyToId) {
125
172
  if (hasEntry(resolvedAccountId)) {
126
173
  const buffered = bufferMessage(resolvedAccountId, message);
127
174
  if (buffered) {
175
+ // 顺序 push 一帧 typing_stop,flush 时随业务消息一起按 FIFO 发出。
176
+ // bufferMessage 失败(队列满)则跳过闭合帧 —— 主消息都没缓冲住,
177
+ // 闭合帧也没意义;前端会因永远收不到 stream_chunk 而不展示这条气泡。
178
+ bufferMessage(resolvedAccountId, buildClosingFrame());
128
179
  logger.info(`[${CHANNEL_KEY}] outbound buffered (WS reconnecting): to=${target} msgId=${msgId}`);
129
180
  return msgId;
130
181
  }
@@ -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
  }
@@ -33,6 +33,56 @@ const LOCALIZED_ABORT_REPLY = "已停止当前回复。";
33
33
  function localizeAbortReplyText(text) {
34
34
  return OPENCLAW_ABORT_REPLY_RE.test(text.trim()) ? LOCALIZED_ABORT_REPLY : text;
35
35
  }
36
+ /** 已知的图片扩展名集合,用于判断是否走 files 渲染(而非 markdown 文本链接)。 */
37
+ const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico"]);
38
+ /**
39
+ * 判断 URL 指向的资源是否为图片(基于文件扩展名)。
40
+ *
41
+ * 优先匹配 COS 公网 URL 中的 `filePath=xxx` query 参数;
42
+ * 回退到 URL 末段。判断失败一律按"非图片"处理(保留文本链接,前端兼容性更好)。
43
+ */
44
+ function isImageUrl(url) {
45
+ let candidate = url;
46
+ const filePathMatch = url.match(/filePath=([^&]+)/);
47
+ if (filePathMatch) {
48
+ try {
49
+ candidate = decodeURIComponent(filePathMatch[1]);
50
+ }
51
+ catch {
52
+ candidate = filePathMatch[1];
53
+ }
54
+ }
55
+ const lastDot = candidate.lastIndexOf(".");
56
+ if (lastDot < 0)
57
+ return false;
58
+ const ext = candidate.slice(lastDot + 1).split(/[?#]/)[0].toLowerCase();
59
+ return IMAGE_EXTS.has(ext);
60
+ }
61
+ /**
62
+ * 对 mediaUrls 入口去重:同一文件常以裸路径 `/path/to/x.png` 和带前缀
63
+ * `file:///path/to/x.png` 两种形式同时出现(如 browser screenshot toolResult),
64
+ * 不归一就会让前端同时渲染两份"全部文件"卡片(即用户看到的"两个全部文件按钮")。
65
+ *
66
+ * 归一规则:
67
+ * - 去掉 `file://` 前缀做"路径键"比较;
68
+ * - 保留首次出现的原始字符串(保留 `file://` 与否取决于 mediaList 顺序),
69
+ * 避免破坏后续 `existsSync` / `mediaUrlsToFiles` 对路径形态的处理。
70
+ *
71
+ * 不做内容级去重(buffer hash),因为 data URL 与本地路径同图的归一代价较高,
72
+ * 且配合"图片不再重复拼 markdown"已能消除可见的重复渲染。
73
+ */
74
+ function dedupMediaUrls(urls) {
75
+ const seen = new Set();
76
+ const result = [];
77
+ for (const url of urls) {
78
+ const key = url.startsWith("file://") ? url.slice(7) : url;
79
+ if (seen.has(key))
80
+ continue;
81
+ seen.add(key);
82
+ result.push(url);
83
+ }
84
+ return result;
85
+ }
36
86
  export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
37
87
  const { emitter, targetId, originalMsgId, log, effectiveApiKey, typingAlreadyStarted, sessionKey, agentId } = opts;
38
88
  // ── 群聊扩展字段 ──
@@ -237,25 +287,44 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
237
287
  }
238
288
  };
239
289
  /**
240
- * 处理 payload 中的 mediaUrls:COS 上传 Markdown 链接 推送给用户。
290
+ * 处理 payload 中的 mediaUrls:去重COS 上传与文本同帧推送给用户。
241
291
  * 仅在 mediaList 非空时调用。
242
292
  *
243
- * 群聊模式:通过 stream_chunk 信号帧推送 Markdown 图片/文件链接(带群聊 extra)。
244
- * 私聊模式:通过 emitter.sendFiles / emitter.sendReply 推送。
293
+ * 私聊 / 群聊统一通过 `emitSignal('stream_chunk', ...)` 走信号链,
294
+ * 复用本轮 replyMsgId(与 stream_chunk 文本同 msgId 合并到同一气泡),
295
+ * 同时携带 kind / agentId / extra.chatId / chatKind 等协议字段,
296
+ * 避免前端因 kind 缺失而把媒体帧兜底为 typing_stop 完成态独立气泡。
297
+ *
298
+ * 协议约定:
299
+ * - 图片由顶层 `files` 字段独家承载(前端文件卡片渲染),
300
+ * 文本里**不再**额外拼 `![](url)` markdown,避免"两张图"重复渲染。
301
+ * - 非图片文件(pdf / 压缩包等)保留 `📎 [filename](url)` 文本链接,
302
+ * 方便用户复制公网 URL,同时仍透传 files 给前端文件卡片。
245
303
  */
246
304
  const handleMediaFinal = async (replyText, mediaList) => {
247
- const files = await mediaUrlsToFiles(mediaList, log);
305
+ // 去重:mediaUrls 常包含同一文件的多种引用(裸路径 / file:// 前缀),
306
+ // 归一后再交给下游处理,避免 files 数组与 publicUrls 数组各自重复一次。
307
+ const dedupedMediaList = dedupMediaUrls(mediaList);
308
+ const files = await mediaUrlsToFiles(dedupedMediaList, log);
248
309
  const storageConfig = { apiKey: effectiveApiKey };
249
- const publicUrls = [];
250
- for (const mediaUrl of mediaList) {
310
+ /** 仅非图片文件需要在文本里给出公网链接,图片由 files 渲染。 */
311
+ const nonImagePublicLinks = [];
312
+ for (const mediaUrl of dedupedMediaList) {
251
313
  try {
252
314
  const localPath = mediaUrl.startsWith("file://") ? mediaUrl.slice(7) : mediaUrl;
253
315
  if (localPath.startsWith("/") || localPath.match(/^[A-Za-z]:\\/)) {
254
316
  const { existsSync } = await import("node:fs");
255
317
  if (existsSync(localPath)) {
256
318
  const result = await uploadFileToServer(localPath, storageConfig);
257
- publicUrls.push(result.url || "");
258
- log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${result.url}`);
319
+ const publicUrl = result.url || "";
320
+ log?.info(`[${CHANNEL_KEY}] [stream] Uploaded to COS: ${localPath} → ${publicUrl}`);
321
+ // 仅非图片文件保留文本链接(图片由 files 渲染,避免重复)
322
+ if (publicUrl && !isImageUrl(publicUrl)) {
323
+ const match = publicUrl.match(/filePath=([^&]+)/);
324
+ const filePath = match ? decodeURIComponent(match[1]) : "";
325
+ const fileName = filePath.split("/").pop() || "file";
326
+ nonImagePublicLinks.push(`📎 [${fileName}](${publicUrl})`);
327
+ }
259
328
  }
260
329
  }
261
330
  }
@@ -264,41 +333,33 @@ export function createStreamReplyConfig(opts, prefixOptions, signalCtx) {
264
333
  }
265
334
  }
266
335
  let enrichedText = replyText;
267
- if (publicUrls.length > 0) {
268
- // 判断是否为图片类型(根据 URL 中的文件扩展名)
269
- const urlSection = publicUrls
270
- .map((url, i) => {
271
- const match = url.match(/filePath=([^&]+)/);
272
- const filePath = match ? decodeURIComponent(match[1]) : "";
273
- const fileName = filePath.split("/").pop() || `file${publicUrls.length > 1 ? ` (${i + 1})` : ""}`;
274
- const ext = fileName.split('.').pop()?.toLowerCase() || '';
275
- const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'].includes(ext);
276
- // 图片使用 Markdown 图片语法,非图片使用链接语法
277
- return isImage ? `![${fileName}](${url})` : `📎 [${fileName}](${url})`;
278
- })
279
- .join("\n");
336
+ if (nonImagePublicLinks.length > 0) {
337
+ const urlSection = nonImagePublicLinks.join("\n");
280
338
  enrichedText = enrichedText ? `${enrichedText}\n\n${urlSection}` : urlSection;
281
339
  }
282
- // ── 群聊模式:通过 stream_chunk 推送(带群聊 extra,前端能正确识别) ──
283
- if (isGroupMode) {
284
- if (enrichedText.trim()) {
285
- const delta = `\n\n${enrichedText}`;
286
- streamedText += delta;
287
- emitSignal(signalCtx, "stream_chunk", delta, groupSignalExtra, buildGroupDataExtra('delta'));
288
- emittedUserVisible = true;
289
- log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${publicUrls.length} media as stream_chunk`);
290
- }
340
+ // 没有任何可见内容(既无文本又无文件)→ 直接返回,避免发空帧
341
+ if (!enrichedText.trim() && files.length === 0) {
291
342
  return;
292
343
  }
293
- // ── 私聊模式:通过 sendFiles / sendReply 推送 ──
294
- if (files.length > 0) {
295
- emitter.sendFiles(targetId, enrichedText, files, originalMsgId, signalCtx.chatId);
296
- emittedUserVisible = true;
297
- }
298
- else if (enrichedText.trim()) {
299
- emitter.sendReply(targetId, enrichedText, originalMsgId, signalCtx.chatId);
344
+ // ── 群聊模式:通过 stream_chunk 推送(带群聊 extra) ──
345
+ // 群聊额外把文本累加到 streamedText,让 markComplete 的 aggregatedText 能算上这段。
346
+ if (isGroupMode) {
347
+ const delta = enrichedText.trim() ? `\n\n${enrichedText}` : "";
348
+ if (delta)
349
+ streamedText += delta;
350
+ emitSignal(signalCtx, "stream_chunk", delta, { ...(files.length > 0 ? { files } : {}), ...groupSignalExtra }, buildGroupDataExtra('delta'));
300
351
  emittedUserVisible = true;
352
+ log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal(group): pushed ${files.length} file(s), ` +
353
+ `nonImageLinks=${nonImagePublicLinks.length}`);
354
+ return;
301
355
  }
356
+ // ── 私聊模式:同样通过 stream_chunk 走信号链 ──
357
+ // 关键:复用 signalCtx.replyMsgId → 与本轮 stream_chunk 文本同 msgId 合并,
358
+ // 且自动携带 kind/agentId/extra.chatId,前端不再兜底为 typing_stop。
359
+ emitSignal(signalCtx, "stream_chunk", enrichedText, files.length > 0 ? { files } : undefined);
360
+ emittedUserVisible = true;
361
+ log?.info(`[${CHANNEL_KEY}] [stream] handleMediaFinal: pushed ${files.length} file(s) via stream_chunk, ` +
362
+ `textLen=${enrichedText.length} nonImageLinks=${nonImagePublicLinks.length}`);
302
363
  };
303
364
  // ────────────────────────────────────────
304
365
  // dispatcher
@@ -45,7 +45,7 @@ export function registerUploadTool(api) {
45
45
  }
46
46
  return {
47
47
  name: UPLOAD_TOOL_NAME,
48
- description: " AI 生成的本机文件注册为可下载的引用,返回 `localfile://<绝对路径>` 格式的 Markdown 下载链接。" +
48
+ description: "重要:将 AI 生成的本机文件注册为可下载的引用,要求必须立马返回 `[<文件名称>]localfile://<绝对路径>` 格式的 Markdown 下载链接;用户可以下载。" +
49
49
  "当需要把生成的文件(图片、文档、代码包等)交付给用户时,必须使用此工具。" +
50
50
  "文件必须先写入磁盘,然后传入绝对路径。单次最多 5 个文件,超出请分批调用。" +
51
51
  "重要:工具返回的 Markdown 链接已是最终格式,请原样使用,不要改写为 https:// 或本机路径字符串。",
@@ -53,7 +53,11 @@ export function buildAuthHeaders(apiKey) {
53
53
  * 统一的信号发送出口,收敛 typing / stream / tool / usage 控制帧的构造逻辑。
54
54
  *
55
55
  * - typing_start 不带 replyToMsgId(协议要求);其余帧都携带。
56
- * - topLevelExtra 用于透传顶层 kind 相关字段(如 tool_start 的 toolName/toolPhase、tool_end 的 toolIsError)。
56
+ * - topLevelExtra 用于透传顶层 kind 相关字段:
57
+ * · tool_start 的 toolName / toolPhase
58
+ * · tool_end 的 toolIsError
59
+ * · 群聊场景的 chatKind
60
+ * · 媒体附件的 files(与文本同帧投递,确保 kind/agentId/extra 齐全)
57
61
  * - innerExtra 用于在 PrivateMessageData.extra 内追加自定义字段(如 usage 帧的 usage 对象)。
58
62
  * chatId 始终由本函数管理,调用方无需也不能在 innerExtra 中传 chatId(会被覆盖)。
59
63
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightclawbot",
3
- "version": "1.2.8",
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": [