@henryxiaoyang/wechat-access-unqclawed 1.0.0

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.
@@ -0,0 +1,739 @@
1
+ /**
2
+ * `randomUUID` 来自 Node.js 内置的 `node:crypto` 模块。
3
+ * 用于生成符合 RFC 4122 标准的 UUID v4 字符串,格式如:
4
+ * "550e8400-e29b-41d4-a716-446655440000"
5
+ * 每次调用都会生成一个全局唯一的随机字符串,用作消息的 msg_id。
6
+ * 注意:这是 Node.js 原生 API,不需要安装任何第三方库。
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import WebSocket from "ws";
10
+ import type {
11
+ AGPEnvelope,
12
+ AGPMethod,
13
+ WebSocketClientConfig,
14
+ ConnectionState,
15
+ WebSocketClientCallbacks,
16
+ PromptMessage,
17
+ CancelMessage,
18
+ UpdatePayload,
19
+ PromptResponsePayload,
20
+ ContentBlock,
21
+ ToolCall,
22
+ } from "./types.js";
23
+
24
+ // ============================================
25
+ // WebSocket 客户端核心
26
+ // ============================================
27
+ // 负责 WebSocket 连接管理、消息收发、自动重连、心跳保活
28
+
29
+ /**
30
+ * WebSocket 客户端
31
+ * @description
32
+ * 连接到 AGP WebSocket 服务端,处理双向通信:
33
+ * - 接收下行消息:session.prompt / session.cancel
34
+ * - 发送上行消息:session.update / session.promptResponse
35
+ * - 自动重连:连接断开后自动尝试重连(指数退避策略)
36
+ * - 心跳保活:定期发送 WebSocket 原生 ping 帧,防止服务端因空闲超时断开连接
37
+ * - 消息去重:通过 msg_id 实现幂等处理,避免重复消息被处理两次
38
+ */
39
+ export class WechatAccessWebSocketClient {
40
+ private config: Required<Omit<WebSocketClientConfig, "token" | "gatewayPort">> & { token?: string; gatewayPort?: string };
41
+ private callbacks: WebSocketClientCallbacks;
42
+
43
+ /**
44
+ * ws 库的 WebSocket 实例。
45
+ * 类型写作 `WebSocket.WebSocket` 是因为 ws 库的默认导出是类本身,
46
+ * 而 `WebSocket.WebSocket` 是其实例类型(TypeScript 类型系统的要求)。
47
+ * 未连接时为 null。
48
+ */
49
+ private ws: WebSocket | null = null;
50
+
51
+ /** 当前连接状态 */
52
+ private state: ConnectionState = "disconnected";
53
+
54
+ /**
55
+ * 重连定时器句柄。
56
+ * `ReturnType<typeof setTimeout>` 是 TypeScript 推荐的写法,
57
+ * 可以同时兼容 Node.js(返回 Timeout 对象)和浏览器(返回 number)环境。
58
+ */
59
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
60
+
61
+ /**
62
+ * 心跳定时器句柄。
63
+ * `ReturnType<typeof setInterval>` 同上,兼容 Node.js 和浏览器。
64
+ */
65
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
66
+
67
+ /** 当前已尝试的重连次数 */
68
+ private reconnectAttempts = 0;
69
+
70
+ /**
71
+ * 已处理的消息 ID 集合(用于去重)。
72
+ * 使用 Set 而非数组,查找时间复杂度为 O(1)。
73
+ * 当消息因网络问题被重发时,通过检查 msg_id 是否已存在来避免重复处理。
74
+ */
75
+ private processedMsgIds = new Set<string>();
76
+
77
+ /** 消息 ID 缓存定期清理定时器(防止 Set 无限增长导致内存泄漏) */
78
+ private msgIdCleanupTimer: ReturnType<typeof setInterval> | null = null;
79
+
80
+ /** 上次收到 pong 的时间戳(用于检测连接假死) */
81
+ private lastPongTime = Date.now();
82
+
83
+ /** 系统唤醒检测定时器 */
84
+ private wakeupCheckTimer: ReturnType<typeof setInterval> | null = null;
85
+
86
+ /** 唤醒检测:上次 tick 的时间戳 */
87
+ private lastTickTime = Date.now();
88
+
89
+ /** 消息 ID 缓存的最大容量,超过此值时触发清理 */
90
+ private static readonly MAX_MSG_ID_CACHE = 1000;
91
+
92
+ /** 从 config.url 中解析出端口号,用于日志前缀 */
93
+ private get port(): string {
94
+ return this.config.gatewayPort ?? 'unknown';
95
+ }
96
+
97
+ /** 带端口号的日志前缀 */
98
+ private get logPrefix(): string {
99
+ return `[wechat-access-ws:${this.port}]`;
100
+ }
101
+
102
+ constructor(config: WebSocketClientConfig, callbacks: WebSocketClientCallbacks = {}) {
103
+ this.config = {
104
+ url: config.url,
105
+ guid: config.guid ?? '',
106
+ userId: config.userId ?? '',
107
+ token: config.token,
108
+ gatewayPort: config.gatewayPort,
109
+ reconnectInterval: config.reconnectInterval ?? 3000,
110
+ maxReconnectAttempts: config.maxReconnectAttempts ?? 0,
111
+ // 默认 20s发一次心跳,小于服务端 1 分钟的空闲超时时间
112
+ heartbeatInterval: config.heartbeatInterval ?? 20000,
113
+ };
114
+ this.callbacks = callbacks;
115
+ }
116
+
117
+ /**
118
+ * 启动 WebSocket 连接
119
+ * @description
120
+ * 如果当前已连接或正在连接中,则直接返回,避免重复建立连接。
121
+ * 同时启动消息 ID 缓存的定期清理任务。
122
+ */
123
+ start = (): void => {
124
+ if (this.state === "connected" || this.state === "connecting") {
125
+ console.log(`${this.logPrefix} 已连接或正在连接,跳过`);
126
+ return;
127
+ }
128
+ this.connect();
129
+ this.startMsgIdCleanup();
130
+ };
131
+
132
+ /**
133
+ * 停止 WebSocket 连接
134
+ * @description
135
+ * 主动断开连接时调用。会:
136
+ * 1. 将状态设为 "disconnected"(阻止断开后触发自动重连)
137
+ * 2. 清理所有定时器(重连、心跳、消息 ID 清理)
138
+ * 3. 清空消息 ID 缓存
139
+ * 4. 关闭 WebSocket 连接
140
+ */
141
+ stop = (): void => {
142
+ console.log(`${this.logPrefix} 正在停止...`);
143
+ this.state = "disconnected";
144
+ this.clearReconnectTimer();
145
+ this.clearHeartbeat();
146
+ this.clearWakeupDetection();
147
+ this.clearMsgIdCleanup();
148
+ this.processedMsgIds.clear();
149
+
150
+ if (this.ws) {
151
+ this.ws.close();
152
+ this.ws = null;
153
+ }
154
+ console.log(`${this.logPrefix} 已停止`);
155
+ };
156
+
157
+ /**
158
+ * 获取当前连接状态
159
+ * @returns "disconnected" | "connecting" | "connected" | "reconnecting"
160
+ */
161
+ getState = (): ConnectionState => this.state;
162
+
163
+ /**
164
+ * 更新事件回调
165
+ * @description 使用对象展开合并,只更新传入的回调,保留未传入的原有回调
166
+ */
167
+ setCallbacks = (callbacks: Partial<WebSocketClientCallbacks>): void => {
168
+ this.callbacks = { ...this.callbacks, ...callbacks };
169
+ };
170
+
171
+ /**
172
+ * 发送 session.update 消息 — 流式中间更新(文本块)
173
+ * @param sessionId - 所属 Session ID
174
+ * @param promptId - 所属 Turn ID
175
+ * @param content - 文本内容块(type: "text")
176
+ * @description
177
+ * 在 Agent 生成回复的过程中,将增量文本实时推送给服务端,
178
+ * 服务端再转发给用户端展示流式输出效果。
179
+ */
180
+ sendMessageChunk = (sessionId: string, promptId: string, content: ContentBlock, guid?: string, userId?: string): void => {
181
+ console.log(`${this.logPrefix} [sendMessageChunk] sessionId=${sessionId}, promptId=${promptId}, guid=${guid}, userId=${userId}, content=${JSON.stringify(content).substring(0, 200)}`);
182
+ const payload: UpdatePayload = {
183
+ session_id: sessionId,
184
+ prompt_id: promptId,
185
+ update_type: "message_chunk",
186
+ content,
187
+ };
188
+ this.sendEnvelope("session.update", payload, guid, userId);
189
+ };
190
+
191
+ /**
192
+ * 发送 session.update 消息 — 工具调用开始
193
+ * @param sessionId - 所属 Session ID
194
+ * @param promptId - 所属 Turn ID
195
+ * @param toolCall - 工具调用信息(包含 tool_call_id、title、kind、status)
196
+ * @description
197
+ * 当 Agent 开始调用某个工具时发送,通知服务端展示工具调用状态。
198
+ */
199
+ sendToolCall = (sessionId: string, promptId: string, toolCall: ToolCall, guid?: string, userId?: string): void => {
200
+ console.log(`${this.logPrefix} [sendToolCall] sessionId=${sessionId}, promptId=${promptId}, guid=${guid}, userId=${userId}, toolCall=${JSON.stringify(toolCall)}`);
201
+ const payload: UpdatePayload = {
202
+ session_id: sessionId,
203
+ prompt_id: promptId,
204
+ update_type: "tool_call",
205
+ tool_call: toolCall,
206
+ };
207
+ this.sendEnvelope("session.update", payload, guid, userId);
208
+ };
209
+
210
+ /**
211
+ * 发送 session.update 消息 — 工具调用状态变更
212
+ * @param sessionId - 所属 Session ID
213
+ * @param promptId - 所属 Turn ID
214
+ * @param toolCall - 更新后的工具调用信息(status 变为 completed/failed)
215
+ * @description
216
+ * 当工具执行完成或失败时发送,通知服务端更新工具调用的展示状态。
217
+ */
218
+ sendToolCallUpdate = (sessionId: string, promptId: string, toolCall: ToolCall, guid?: string, userId?: string): void => {
219
+ console.log(`${this.logPrefix} [sendToolCallUpdate] sessionId=${sessionId}, promptId=${promptId}, guid=${guid}, userId=${userId}, toolCall=${JSON.stringify(toolCall)}`);
220
+ const payload: UpdatePayload = {
221
+ session_id: sessionId,
222
+ prompt_id: promptId,
223
+ update_type: "tool_call_update",
224
+ tool_call: toolCall,
225
+ };
226
+ this.sendEnvelope("session.update", payload, guid, userId);
227
+ };
228
+
229
+ /**
230
+ * 发送 session.promptResponse 消息 — 最终结果
231
+ * @param payload - 包含 stop_reason、content、error 等最终结果信息
232
+ * @description
233
+ * Agent 处理完成后发送,告知服务端本次 Turn 已结束。
234
+ * stop_reason 可以是:end_turn(正常完成)、cancelled(被取消)、error(出错)
235
+ */
236
+ sendPromptResponse = (payload: PromptResponsePayload, guid?: string, userId?: string): void => {
237
+ const contentPreview = payload.content ? JSON.stringify(payload.content).substring(0, 200) : '(empty)';
238
+ console.log(`${this.logPrefix} [sendPromptResponse] sessionId=${payload.session_id}, promptId=${payload.prompt_id}, stopReason=${payload.stop_reason}, guid=${guid}, userId=${userId}, content=${contentPreview}`);
239
+ this.sendEnvelope("session.promptResponse", payload, guid, userId);
240
+ };
241
+
242
+
243
+ /**
244
+ * 建立 WebSocket 连接
245
+ * @description
246
+ * 使用 ws 库的 `new WebSocket(url)` 创建连接。
247
+ * ws 库会在内部自动完成 TCP 握手和 WebSocket 升级协议(HTTP Upgrade)。
248
+ * 连接是异步建立的,实际连接成功会触发 "open" 事件。
249
+ */
250
+ private connect = (): void => {
251
+ // url 为空时不进行连接,避免 new URL("") 抛出 TypeError
252
+ if (!this.config.url) {
253
+ console.error(`${this.logPrefix} wsUrl 未配置,跳过连接`);
254
+ this.state = "disconnected";
255
+ return;
256
+ }
257
+ // token 为空时不进行连接,避免无效请求
258
+ if (!this.config.token) {
259
+ console.error(`${this.logPrefix} token 为空,跳过 WebSocket 连接`);
260
+ this.state = "disconnected";
261
+ return;
262
+ }
263
+
264
+ this.state = "connecting";
265
+ console.error(`${this.logPrefix} 连接配置: url=${this.config.url}, token=${this.config.token.substring(0, 6) + '...'}, guid=${this.config.guid}, userId=${this.config.userId}`);
266
+ const wsUrl = this.buildConnectionUrl();
267
+ console.error(`${this.logPrefix} 正在连接: ${wsUrl}`);
268
+
269
+ try {
270
+ // new WebSocket(url) 立即返回,不会阻塞
271
+ // 连接过程在后台异步进行,通过事件通知结果
272
+ this.ws = new WebSocket(wsUrl);
273
+ this.setupEventHandlers();
274
+ } catch (error) {
275
+ // 同步错误(如 URL 格式非法)会在这里捕获
276
+ // 异步连接失败(如服务端拒绝)会触发 "error" 事件
277
+ console.error(`${this.logPrefix} 创建连接失败:`, error);
278
+ this.handleConnectionError(error instanceof Error ? error : new Error(String(error)));
279
+ }
280
+ };
281
+
282
+ /**
283
+ * 构建 WebSocket 连接 URL
284
+ * @description
285
+ * 使用 Node.js 内置的 `URL` 类(全局可用,无需 import)构建带查询参数的 URL。
286
+ * `url.searchParams.set()` 会自动对参数值进行 URL 编码(encodeURIComponent),
287
+ * 避免特殊字符导致的 URL 解析问题。
288
+ *
289
+ * 最终格式:ws://host:port/?token={token}
290
+ */
291
+ private buildConnectionUrl = (): string => {
292
+ const url = new URL(this.config.url);
293
+ if (this.config.token) {
294
+ url.searchParams.set("token", this.config.token);
295
+ }
296
+ return url.toString();
297
+ };
298
+
299
+ /**
300
+ * 注册 ws 库的事件监听器
301
+ * @description
302
+ * ws 库使用 Node.js EventEmitter 风格的 `.on(event, handler)` 注册事件,
303
+ * 而非浏览器的 `.addEventListener(event, handler)`。
304
+ * 两者功能相同,但回调参数类型不同:
305
+ *
306
+ * | 事件 | 浏览器原生参数 | ws 库参数 |
307
+ * |---------|----------------------|----------------------------------|
308
+ * | open | Event | 无参数 |
309
+ * | message | MessageEvent | (data: RawData, isBinary: bool) |
310
+ * | close | CloseEvent | (code: number, reason: Buffer) |
311
+ * | error | Event | (error: Error) |
312
+ * | pong | 不支持 | 无参数(ws 库特有) |
313
+ */
314
+ private setupEventHandlers = (): void => {
315
+ if (!this.ws) return;
316
+
317
+ this.ws.on("open", this.handleOpen);
318
+ this.ws.on("message", this.handleRawMessage);
319
+ this.ws.on("close", this.handleClose);
320
+ this.ws.on("error", this.handleError);
321
+ // "pong" 是 ws 库特有的事件,当收到服务端的 pong 控制帧时触发
322
+ // 浏览器原生 WebSocket API 不暴露此事件
323
+ this.ws.on("pong", this.handlePong);
324
+ };
325
+
326
+ // ============================================
327
+ // 事件处理
328
+ // ============================================
329
+
330
+ /**
331
+ * 处理连接建立事件
332
+ * @description
333
+ * ws 库的 "open" 事件在 WebSocket 握手完成后触发,此时可以开始收发消息。
334
+ * 连接成功后:
335
+ * 1. 更新状态为 "connected"
336
+ * 2. 重置重连计数器
337
+ * 3. 重置 pong 时间戳
338
+ * 4. 启动心跳定时器
339
+ * 5. 启动系统唤醒检测
340
+ * 6. 触发 onConnected 回调
341
+ */
342
+ private handleOpen = (): void => {
343
+ console.log(`${this.logPrefix} 连接成功`);
344
+ this.state = "connected";
345
+ this.reconnectAttempts = 0;
346
+ this.lastPongTime = Date.now();
347
+ this.startHeartbeat();
348
+ this.startWakeupDetection();
349
+ this.callbacks.onConnected?.();
350
+ };
351
+
352
+ /**
353
+ * 处理收到的原始消息
354
+ * @param data - ws 库的原始消息数据,类型为 `WebSocket.RawData`
355
+ * @description
356
+ * `WebSocket.RawData` 是 ws 库定义的联合类型:`Buffer | ArrayBuffer | Buffer[]`
357
+ * - 文本消息(text frame):通常是 Buffer 类型
358
+ * - 二进制消息(binary frame):可能是 Buffer 或 ArrayBuffer
359
+ *
360
+ * 处理步骤:
361
+ * 1. 将 RawData 转为字符串(Buffer.toString() 默认使用 UTF-8 编码)
362
+ * 2. JSON.parse 解析为 AGPEnvelope 对象
363
+ * 3. 检查 msg_id 去重
364
+ * 4. 根据 method 字段分发到对应的回调
365
+ */
366
+ private handleRawMessage = (data: WebSocket.RawData): void => {
367
+ try {
368
+ // Buffer.toString() 默认 UTF-8 编码,等同于 data.toString("utf8")
369
+ // 如果 data 已经是 string 类型(理论上 ws 库不会这样,但做兼容处理)
370
+ const raw = typeof data === "string" ? data : data.toString();
371
+ const envelope = JSON.parse(raw) as AGPEnvelope;
372
+
373
+ // 消息去重:同一个 msg_id 只处理一次
374
+ // 网络不稳定时服务端可能重发消息,通过 msg_id 避免重复处理
375
+ if (this.processedMsgIds.has(envelope.msg_id)) {
376
+ console.log(`${this.logPrefix} 重复消息,跳过: ${envelope.msg_id}`);
377
+ return;
378
+ }
379
+ this.processedMsgIds.add(envelope.msg_id);
380
+
381
+ console.log(`${this.logPrefix} 收到消息: method=${envelope.method}, msg_id=${envelope.msg_id}`);
382
+
383
+ // 根据 method 字段分发消息到对应的业务处理回调
384
+ switch (envelope.method) {
385
+ case "session.prompt":
386
+ // 下行:服务端下发用户指令,需要调用 Agent 处理
387
+ this.callbacks.onPrompt?.(envelope as PromptMessage);
388
+ break;
389
+ case "session.cancel":
390
+ // 下行:服务端要求取消正在处理的 Turn
391
+ this.callbacks.onCancel?.(envelope as CancelMessage);
392
+ break;
393
+ default:
394
+ console.warn(`${this.logPrefix} 未知消息类型: ${envelope.method}`);
395
+ }
396
+ } catch (error) {
397
+ console.error(`${this.logPrefix} 消息解析失败:`, error, '原始数据:', data);
398
+ this.callbacks.onError?.(
399
+ error instanceof Error ? error : new Error(`消息解析失败: ${String(error)}`)
400
+ );
401
+ }
402
+ };
403
+
404
+ /**
405
+ * 处理连接关闭事件
406
+ * @param code - WebSocket 关闭状态码(RFC 6455 定义)
407
+ * 常见值:
408
+ * - 1000: 正常关闭
409
+ * - 1001: 端点离开(如服务端重启)
410
+ * - 1006: 异常关闭(连接被强制断开,无关闭握手)
411
+ * - 1008: 策略违规(如 token 不匹配)
412
+ * @param reason - 关闭原因,ws 库中类型为 `Buffer`,需要调用 `.toString()` 转为字符串
413
+ * @description
414
+ * 注意:ws 库的 close 事件参数与浏览器不同:
415
+ * - 浏览器:`(event: CloseEvent)` → 通过 event.code 和 event.reason 获取
416
+ * - ws 库:`(code: number, reason: Buffer)` → 直接获取,reason 是 Buffer 需要转换
417
+ *
418
+ * 只有在非主动关闭(state !== "disconnected")时才触发重连,
419
+ * 避免调用 stop() 后又自动重连。
420
+ */
421
+ private handleClose = (code: number, reason: Buffer): void => {
422
+ // Buffer.toString() 将 Buffer 转为 UTF-8 字符串
423
+ // 如果 reason 为空 Buffer,toString() 返回空字符串,此时用 code 作为描述
424
+ const reasonStr = reason.toString() || `code=${code}`;
425
+ console.log(`${this.logPrefix} 连接关闭: ${reasonStr}`);
426
+ this.clearHeartbeat();
427
+ this.clearWakeupDetection();
428
+ this.ws = null;
429
+
430
+ // 仅在非主动关闭的情况下尝试重连
431
+ // 主动调用 stop() 时会先将 state 设为 "disconnected",此处就不会触发重连
432
+ if (this.state !== "disconnected") {
433
+ this.callbacks.onDisconnected?.(reasonStr);
434
+ this.scheduleReconnect();
435
+ }
436
+ };
437
+
438
+ /**
439
+ * 处理 pong 控制帧
440
+ * @description
441
+ * 当服务端收到我们发送的 ping 帧后,会自动回复一个 pong 帧。
442
+ * ws 库会触发 "pong" 事件通知我们。
443
+ * 记录收到 pong 的时间戳,供心跳定时器检测连接是否假死。
444
+ * 如果长时间未收到 pong,说明连接已不可用(如电脑休眠导致 TCP 断开)。
445
+ */
446
+ private handlePong = (): void => {
447
+ this.lastPongTime = Date.now();
448
+ };
449
+
450
+ /**
451
+ * 处理连接错误事件
452
+ * @param error - ws 库直接传递 Error 对象(浏览器原生 API 传递的是 Event 对象)
453
+ * @description
454
+ * ws 库的 "error" 事件在以下情况触发:
455
+ * - 连接被拒绝(如服务端不可达)
456
+ * - TLS 握手失败
457
+ * - 消息发送失败
458
+ * 注意:error 事件之后通常会紧跟 close 事件,重连逻辑在 handleClose 中处理。
459
+ */
460
+ private handleError = (error: Error): void => {
461
+ console.error(`${this.logPrefix} 连接错误:`, error);
462
+ this.callbacks.onError?.(error);
463
+ };
464
+
465
+ /**
466
+ * 处理连接创建时的同步错误
467
+ * @description
468
+ * 当 `new WebSocket(url)` 抛出同步异常时调用(如 URL 格式非法)。
469
+ * 此时不会触发 "error" 和 "close" 事件,需要手动触发重连。
470
+ */
471
+ private handleConnectionError = (error: Error): void => {
472
+ this.callbacks.onError?.(error);
473
+ this.scheduleReconnect();
474
+ };
475
+
476
+ /**
477
+ * 安排下一次重连
478
+ * @description
479
+ * 使用指数退避(Exponential Backoff)策略计算重连延迟:
480
+ * delay = min(reconnectInterval × 1.5^(attempts-1), 30000)
481
+ *
482
+ * 例如 reconnectInterval=3000 时:
483
+ * 第 1 次:3000ms
484
+ * 第 2 次:4500ms
485
+ * 第 3 次:6750ms
486
+ * 第 4 次:10125ms
487
+ * 第 5 次:15187ms(之后趋近 30000ms 上限)
488
+ *
489
+ * 指数退避的目的:避免服务端故障时大量客户端同时重连造成雪崩效应。
490
+ *
491
+ * `setTimeout` 是 Node.js 全局函数,在指定延迟后执行一次回调。
492
+ * 返回值是 Timeout 对象(Node.js)或 number(浏览器),
493
+ * 需要保存以便后续调用 clearTimeout 取消。
494
+ */
495
+ private scheduleReconnect = (): void => {
496
+ // 检查是否超过最大重连次数(0 表示无限重连)
497
+ if (
498
+ this.config.maxReconnectAttempts > 0 &&
499
+ this.reconnectAttempts >= this.config.maxReconnectAttempts
500
+ ) {
501
+ console.error(`${this.logPrefix} 已达最大重连次数 (${this.config.maxReconnectAttempts}),停止重连`);
502
+ this.state = "disconnected";
503
+ return;
504
+ }
505
+
506
+ this.state = "reconnecting";
507
+ this.reconnectAttempts++;
508
+
509
+ // 指数退避:每次重连等待时间递增,最大 25 秒
510
+ const delay = Math.min(
511
+ this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
512
+ 25000
513
+ );
514
+
515
+ console.log(`${this.logPrefix} ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连...`);
516
+
517
+ // setTimeout 返回的句柄保存到 reconnectTimer,
518
+ // 以便在 stop() 或成功连接时通过 clearTimeout 取消待执行的重连
519
+ this.reconnectTimer = setTimeout(() => {
520
+ this.reconnectTimer = null;
521
+ this.connect();
522
+ }, delay);
523
+ };
524
+
525
+ /**
526
+ * 清除重连定时器
527
+ * @description
528
+ * `clearTimeout` 是 Node.js 全局函数,取消由 setTimeout 创建的定时器。
529
+ * 如果定时器已执行或已被取消,调用 clearTimeout 不会报错(安全操作)。
530
+ */
531
+ private clearReconnectTimer = (): void => {
532
+ if (this.reconnectTimer) {
533
+ clearTimeout(this.reconnectTimer);
534
+ this.reconnectTimer = null;
535
+ }
536
+ };
537
+
538
+ // ============================================
539
+ // 心跳保活
540
+ // ============================================
541
+
542
+ /**
543
+ * 启动心跳定时器
544
+ * @description
545
+ * 使用 `setInterval` 定期发送 WebSocket ping 控制帧,并检测 pong 超时。
546
+ *
547
+ * `ws.ping()` 发送 WebSocket 协议层的 ping 控制帧(opcode=0x9),
548
+ * 服务端必须自动回复 pong 帧。
549
+ *
550
+ * Pong 超时检测:
551
+ * 如果超过 2 倍心跳间隔仍未收到 pong,判定连接已死(如休眠后 TCP 已断),
552
+ * 主动 terminate 触发 close 事件 → 自动重连。
553
+ *
554
+ * Ping 失败处理:
555
+ * 如果 ping 发送抛异常(底层 socket 已关闭),也主动 terminate 触发重连。
556
+ */
557
+ private startHeartbeat = (): void => {
558
+ this.clearHeartbeat();
559
+ this.heartbeatTimer = setInterval(() => {
560
+ if (this.ws && this.state === "connected") {
561
+ // 检测 pong 超时:超过 2 倍心跳间隔未收到 pong,判定连接已死
562
+ const pongTimeout = this.config.heartbeatInterval * 2;
563
+ if (Date.now() - this.lastPongTime > pongTimeout) {
564
+ console.warn(`${this.logPrefix} pong 超时 (${pongTimeout}ms 未收到),判定连接已死,主动断开`);
565
+ this.ws.terminate();
566
+ return;
567
+ }
568
+
569
+ try {
570
+ // ws.ping() 发送 WebSocket 原生 ping 控制帧
571
+ this.ws.ping();
572
+ } catch {
573
+ console.warn(`${this.logPrefix} 心跳发送失败,主动断开触发重连`);
574
+ this.ws?.terminate();
575
+ }
576
+ }
577
+ }, this.config.heartbeatInterval);
578
+ };
579
+
580
+ /**
581
+ * 清除心跳定时器
582
+ * @description
583
+ * `clearInterval` 是 Node.js 全局函数,停止由 setInterval 创建的定时器。
584
+ * 在连接关闭或主动停止时调用,避免向已断开的连接发送 ping。
585
+ */
586
+ private clearHeartbeat = (): void => {
587
+ if (this.heartbeatTimer) {
588
+ clearInterval(this.heartbeatTimer);
589
+ this.heartbeatTimer = null;
590
+ }
591
+ };
592
+
593
+ // ============================================
594
+ // 系统唤醒检测
595
+ // ============================================
596
+
597
+ /**
598
+ * 启动系统唤醒检测
599
+ * @description
600
+ * 电脑休眠时 setInterval 会被冻结,唤醒后恢复。
601
+ * 利用「两次 tick 之间实际经过的时间」远大于「setInterval 设定的间隔」来检测唤醒事件。
602
+ *
603
+ * 例如:CHECK_INTERVAL = 5s,但实际两次 tick 间隔了 60s → 说明系统休眠了约 55s。
604
+ * 此时 TCP 连接大概率已被服务端超时关闭,需要主动 terminate 触发重连。
605
+ *
606
+ * 同时重置重连计数器,确保唤醒后有足够的重连机会。
607
+ */
608
+ private startWakeupDetection = (): void => {
609
+ this.clearWakeupDetection();
610
+ this.lastTickTime = Date.now();
611
+
612
+ const CHECK_INTERVAL = 5000; // 每 5 秒检查一次
613
+ const WAKEUP_THRESHOLD = 15000; // 实际间隔超过 15 秒视为休眠唤醒
614
+
615
+ this.wakeupCheckTimer = setInterval(() => {
616
+ const now = Date.now();
617
+ const elapsed = now - this.lastTickTime;
618
+ this.lastTickTime = now;
619
+
620
+ if (elapsed > WAKEUP_THRESHOLD) {
621
+ console.warn(`${this.logPrefix} 检测到系统唤醒 (tick 间隔 ${elapsed}ms,阈值 ${WAKEUP_THRESHOLD}ms)`);
622
+ // 重置重连计数器,给予唤醒后充足的重连机会
623
+ this.reconnectAttempts = 0;
624
+ // 如果当前连接还标记为已连接,主动断开触发重连
625
+ if (this.ws && this.state === "connected") {
626
+ console.warn(`${this.logPrefix} 唤醒后主动断开连接,触发重连`);
627
+ this.ws.terminate();
628
+ }
629
+ }
630
+ }, CHECK_INTERVAL);
631
+ };
632
+
633
+ /**
634
+ * 清除系统唤醒检测定时器
635
+ */
636
+ private clearWakeupDetection = (): void => {
637
+ if (this.wakeupCheckTimer) {
638
+ clearInterval(this.wakeupCheckTimer);
639
+ this.wakeupCheckTimer = null;
640
+ }
641
+ };
642
+
643
+ // ============================================
644
+ // 消息发送
645
+ // ============================================
646
+
647
+ /**
648
+ * 发送 AGP 信封消息(内部通用方法)
649
+ * @param method - AGP 消息类型(如 "session.update"、"session.promptResponse")
650
+ * @param payload - 消息载荷,泛型 T 由调用方决定具体类型
651
+ * @description
652
+ * 所有上行消息都通过此方法发送,统一处理:
653
+ * 1. 检查连接状态
654
+ * 2. 构建 AGP 信封(添加 msg_id等公共字段)
655
+ * 3. JSON 序列化
656
+ * 4. 调用 ws.send() 发送文本帧
657
+ *
658
+ * `ws.send(data)` 是 ws 库的发送方法:
659
+ * - 传入 string:发送文本帧(opcode=0x1)
660
+ * - 传入 Buffer/ArrayBuffer:发送二进制帧(opcode=0x2)
661
+ * - 这里传入 JSON 字符串,发送文本帧
662
+ *
663
+ * `randomUUID()` 为每条消息生成唯一 ID,服务端可用于去重和追踪。
664
+ */
665
+ private sendEnvelope = <T>(method: AGPMethod, payload: T, guid?: string, userId?: string): void => {
666
+ if (!this.ws || this.state !== "connected") {
667
+ console.warn(`${this.logPrefix} 无法发送消息,当前状态: ${this.state}`);
668
+ return;
669
+ }
670
+
671
+ const envelope: AGPEnvelope<T> = {
672
+ msg_id: randomUUID(),
673
+ guid: guid ?? this.config.guid,
674
+ user_id: userId ?? this.config.userId,
675
+ method,
676
+ payload,
677
+ };
678
+
679
+ try {
680
+ const data = JSON.stringify(envelope);
681
+ // ws.send() 将字符串作为 WebSocket 文本帧发送
682
+ this.ws.send(data);
683
+ // 截断过长的 JSON 日志,避免日志文件膨胀
684
+ const jsonPreview = data.length > 500 ? data.substring(0, 500) + `...(truncated, total ${data.length} chars)` : data;
685
+ console.log(`${this.logPrefix} 发送消息: method=${method}, msg_id=${envelope.msg_id}, json=${jsonPreview}`);
686
+ } catch (error) {
687
+ console.error(`${this.logPrefix} 消息发送失败:`, error);
688
+ this.callbacks.onError?.(
689
+ error instanceof Error ? error : new Error(`消息发送失败: ${String(error)}`)
690
+ );
691
+ }
692
+ };
693
+
694
+ // ============================================
695
+ // 消息 ID 缓存清理
696
+ // ============================================
697
+
698
+ /**
699
+ * 启动消息 ID 缓存定期清理任务
700
+ * @description
701
+ * `processedMsgIds` 是一个 Set,会随着消息的接收不断增长。
702
+ * 如果不清理,长时间运行后会占用大量内存(内存泄漏)。
703
+ *
704
+ * 清理策略:
705
+ * - 每 5 分钟检查一次
706
+ * - 当 Set 大小超过 MAX_MSG_ID_CACHE(1000)时触发清理
707
+ * - 清理时保留最新的一半(500 条),丢弃最旧的一半
708
+ *
709
+ * 为什么保留最新的一半而不是全部清空?
710
+ * 因为刚处理过的消息 ID 最有可能被重发,保留它们可以继续防重。
711
+ *
712
+ * `[...this.processedMsgIds]` 将 Set 转为数组,
713
+ * Set 的迭代顺序是插入顺序,所以 slice(-500) 取的是最后插入的 500 条(最新的)。
714
+ */
715
+ private startMsgIdCleanup = (): void => {
716
+ this.clearMsgIdCleanup();
717
+ this.msgIdCleanupTimer = setInterval(() => {
718
+ if (this.processedMsgIds.size > WechatAccessWebSocketClient.MAX_MSG_ID_CACHE) {
719
+ console.log(`${this.logPrefix} 清理消息 ID 缓存: ${this.processedMsgIds.size} → ${WechatAccessWebSocketClient.MAX_MSG_ID_CACHE / 2}`);
720
+ // 将 Set 转为数组(保持插入顺序),取后半部分(最新的),重建 Set
721
+ const entries = [...this.processedMsgIds];
722
+ this.processedMsgIds.clear();
723
+ entries.slice(-WechatAccessWebSocketClient.MAX_MSG_ID_CACHE / 2).forEach((id) => {
724
+ this.processedMsgIds.add(id);
725
+ });
726
+ }
727
+ }, 5 * 60 * 1000); // 每 5 分钟执行一次
728
+ };
729
+
730
+ /**
731
+ * 清除消息 ID 缓存清理定时器
732
+ */
733
+ private clearMsgIdCleanup = (): void => {
734
+ if (this.msgIdCleanupTimer) {
735
+ clearInterval(this.msgIdCleanupTimer);
736
+ this.msgIdCleanupTimer = null;
737
+ }
738
+ };
739
+ }