@xgjktech/xg_cwork_im 1.0.7 → 1.0.9

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/src/connection.ts CHANGED
@@ -1,351 +1,362 @@
1
- /**
2
- * XG-IM WebSocket 连接管理模块
3
- *
4
- * 功能:
5
- * - 建立 wss://<baseUrl>/ws-notify/websocket?accessToken=<token> 长连接
6
- * - 接收 robotMention 消息并回调
7
- * - 支持基于 WebSocket 的 AI 回复流式推送(START/CHUNK/END 协议)
8
- * - 指数退避 + 抖动的自动重连机制
9
- * - 主动发送 WebSocket ping 保活,超时自动重连
10
- */
11
-
12
- import { randomUUID } from "node:crypto";
13
- import WebSocket from "ws";
14
- import type { Log } from "./auth.js";
15
- import type { WsMessage, XgImConfig } from "./types.js";
16
-
17
- export type OnMessageCallback = (msg: WsMessage) => void;
18
-
19
- /**
20
- * CHUNK 载荷:发送端带 seq,便于集群/跨服务按序重排。
21
- * - isThinking=true 为思考阶段,seq 从 0 递增(thinkSeq);
22
- * - isThinking=false 为回答阶段,seq 从 0 递增(answerSeq)。
23
- */
24
- export interface ImStreamChunkData {
25
- isThinking: boolean;
26
- content: string;
27
- /** 当前阶段内的序号,think 与 answer 各自从 0 递增 */
28
- seq: number;
29
- }
30
-
31
- /**
32
- * 基于 WebSocket 的流式消息客户端。
33
- *
34
- * - start(): 发送 START,等待 ack,返回 msgId。
35
- * - chunk(): 基于 msgId 发送增量文本,data 中带 seq(think/answer 阶段分开)。
36
- * - end(): 发送 END,通知服务端结束本次流式消息。
37
- */
38
- export interface ImStreamClient {
39
- start: (opts: { groupId?: string; toUserId?: string }) => Promise<{ msgId: string }>;
40
- chunk: (msgId: string, data: ImStreamChunkData) => Promise<void>;
41
- end: (msgId: string, reason: string) => Promise<void>;
42
- }
43
-
44
- /** 心跳间隔(ms):每 20 秒发一次 ping,防止连接被后台剔除 */
45
- const PING_INTERVAL_MS = 20_000;
46
-
47
- /** 计算指数退避延迟(带随机抖动) */
48
- function calcDelay(
49
- attempt: number,
50
- initial: number,
51
- max: number,
52
- jitter: number,
53
- ): number {
54
- const base = Math.min(initial * 2 ** attempt, max);
55
- const jitterMs = base * jitter * Math.random();
56
- return Math.round(base + jitterMs);
57
- }
58
-
59
- /**
60
- * 启动 WebSocket 连接并监听消息。
61
- *
62
- * @returns 一个 `stop()` 函数,调用后永久断开连接,不再重连。
63
- */
64
- export function startWebSocket(
65
- config: XgImConfig,
66
- token: string,
67
- onMessage: OnMessageCallback,
68
- log: Log,
69
- ): { stop: () => void; streamClient: ImStreamClient } {
70
- const maxAttempts = config.maxConnectionAttempts ?? 200;
71
- const initialDelay = config.initialReconnectDelay ?? 1_000;
72
- const maxDelay = config.maxReconnectDelay ?? 60_000;
73
- const jitter = config.reconnectJitter ?? 0.3;
74
- const logPrefix = `[cwork_im:${config.agentId || "main"}]`;
75
-
76
- // baseUrl http/https 协议替换为 ws/wss,并根据业务规则处理独立域名
77
- let wsBase = config.wsBaseUrl;
78
- if (!wsBase) {
79
- // 自动映射逻辑:
80
- // https://test.xgjktech.com.cn -> wss://websocket.xgjktech.com.cn
81
- // https://xg.mediportal.com.cn -> wss://websocket.mediportal.com.cn
82
- wsBase = config.baseUrl
83
- .replace(/^http/, "ws");
84
- }
85
-
86
- const wsUrl = `${wsBase}/ws-notify/websocket?accessToken=${encodeURIComponent(token)}`;
87
-
88
- let stopped = false;
89
- let ws: WebSocket | null = null;
90
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
91
- let pingTimer: ReturnType<typeof setInterval> | null = null;
92
- let attempt = 0;
93
-
94
- /**
95
- * clientMsgId -> resolver(msgId)
96
- *
97
- * - 当发送 START 时,会生成一个 clientMsgId,并在此处挂一个 resolver。
98
- * - 收到 im_stream_msg_ack 后,根据 clientMsgId 触发 resolver,返回服务端生成的 msgId
99
- */
100
- const pendingAcks = new Map<string, (msgId: string) => void>();
101
-
102
- /** 清除心跳定时器 */
103
- function clearHeartbeat(): void {
104
- if (pingTimer !== null) {
105
- clearInterval(pingTimer);
106
- pingTimer = null;
107
- }
108
- }
109
-
110
- /** 启动心跳:每 30s 发一次 ping */
111
- function startHeartbeat(socket: WebSocket): void {
112
- clearHeartbeat();
113
-
114
- pingTimer = setInterval(() => {
115
- if (socket.readyState !== WebSocket.OPEN) {
116
- clearHeartbeat();
117
- return;
118
- }
119
-
120
- const now = new Date().toISOString();
121
- log.info(`${logPrefix} ♥ Ping sent at ${now}`);
122
-
123
- // 发送标准 WebSocket ping 帧
124
- socket.ping((err: Error | null) => {
125
- if (err) {
126
- log.error(`${logPrefix} Ping frame error: ${err.message}`);
127
- }
128
- });
129
-
130
- // 发送业务层文本 ping(更新服务端活跃时间)
131
- socket.send("ping");
132
- }, PING_INTERVAL_MS);
133
- }
134
-
135
- /**
136
- * 确保当前存在可用的 WebSocket 连接。
137
- * 若不存在或未处于 OPEN 状态,则抛出错误,由上层决定是否回退为非流式模式。
138
- */
139
- function ensureSocketReady(): WebSocket {
140
- if (!ws || ws.readyState !== WebSocket.OPEN) {
141
- throw new Error(`${logPrefix} WebSocket is not ready for sending stream message`);
142
- }
143
- return ws;
144
- }
145
-
146
- function connect(): void {
147
- if (stopped) return;
148
-
149
- log.info(`${logPrefix} Connecting to WebSocket (attempt ${attempt + 1})...`);
150
-
151
- ws = new WebSocket(wsUrl);
152
-
153
- ws.on("open", () => {
154
- attempt = 0; // 连接成功后重置重试计数
155
- log.info(`${logPrefix} WebSocket connected successfully.`);
156
- startHeartbeat(ws!);
157
- });
158
-
159
- // 处理消息
160
- ws.on("message", (raw: WebSocket.RawData) => {
161
- const rawString = raw.toString();
162
-
163
- if (rawString === "pong" || rawString === "ping") {
164
- if (config.debug) log.debug?.(`${logPrefix} Ignored plain text ${rawString}`);
165
- return;
166
- }
167
-
168
- let parsed: any;
169
- try {
170
- parsed = JSON.parse(rawString) as unknown;
171
- } catch {
172
- // 如果不是 JSON,说明是心跳或者其他内容,忽略即可
173
- // 如果开启 debug 还是可以打印日志
174
- if (config.debug) {
175
- log.debug?.(`${logPrefix} Received non-JSON message: ${rawString.slice(0, 50)}`);
176
- }
177
- return;
178
- }
179
-
180
- log.info(`${logPrefix} WS message received: cmd=${parsed.cmd}`);
181
-
182
- // 处理流式消息 ack:im_stream_msg_ack
183
- if (parsed.cmd === "im_stream_msg_ack" && parsed.params) {
184
- const clientMsgId: string | undefined = parsed.params.clientMsgId;
185
- const msgId: string | undefined = parsed.params.msgId;
186
- if (clientMsgId && msgId) {
187
- const resolver = pendingAcks.get(clientMsgId);
188
- if (resolver) {
189
- pendingAcks.delete(clientMsgId);
190
- resolver(msgId);
191
- log.info(`${logPrefix} im_stream_msg_ack received: clientMsgId=${clientMsgId} msgId=${msgId}`);
192
- } else if (config.debug) {
193
- log.debug?.(`${logPrefix} im_stream_msg_ack with unknown clientMsgId=${clientMsgId}`);
194
- }
195
- } else if (config.debug) {
196
- log.debug?.(`${logPrefix} im_stream_msg_ack missing clientMsgId or msgId`);
197
- }
198
- return;
199
- }
200
-
201
- // 处理包含 params 的事件(robotMention / groupMessage)
202
- if (parsed.params && (parsed.cmd === "robotMention" || parsed.cmd === "groupMessage")) {
203
- if (parsed.cmd === "robotMention") {
204
- log.info(`${logPrefix} 📨 robotMention received: groupId=${parsed.params.groupId} msgId=${parsed.params.msgId}`);
205
- }
206
- // onMessage 是 async 函数,必须用 .catch() 捕获异步错误
207
- // 否则 await 之后的错误会变成未处理的 Promise rejection 被静默丢弃
208
- Promise.resolve(onMessage(parsed as WsMessage)).catch((err: unknown) => {
209
- log.error(`${logPrefix} onMessage async error: ${String(err)}`);
210
- });
211
- }
212
- });
213
-
214
- ws.on("close", (code: number, reason: Buffer) => {
215
- clearHeartbeat();
216
- if (stopped) {
217
- log.info(`${logPrefix} WebSocket closed (intentional stop).`);
218
- return;
219
- }
220
- const reasonStr = (reason && reason.length > 0) ? reason.toString() : "(none)";
221
-
222
- // 使用 log.info 输出警告,避免 log.warn 不存在导致漏打日志
223
- log.info(`${logPrefix} [WARN] WebSocket closed unexpectedly: code=${code}, reason=${reasonStr}. Scheduling reconnect...`);
224
- scheduleReconnect();
225
- });
226
-
227
- ws.on("error", (err: Error) => {
228
- if (stopped) return;
229
- log.error(`${logPrefix} WebSocket error: ${err.message}`);
230
- // 关闭后会触发 close 事件,由 close 处理重连
231
- });
232
- }
233
-
234
- function scheduleReconnect(): void {
235
- if (stopped) return;
236
-
237
- if (attempt >= maxAttempts) {
238
- log.error(`${logPrefix} Max reconnect attempts (${maxAttempts}) reached. Giving up.`);
239
- return;
240
- }
241
-
242
- const delay = calcDelay(attempt, initialDelay, maxDelay, jitter);
243
- attempt += 1;
244
- log.info(`${logPrefix} Reconnecting in ${delay}ms (attempt ${attempt}/${maxAttempts})...`);
245
-
246
- reconnectTimer = setTimeout(() => {
247
- reconnectTimer = null;
248
- connect();
249
- }, delay);
250
- }
251
-
252
- // 首次连接
253
- connect();
254
-
255
- const streamClient: ImStreamClient = {
256
- async start(opts: { groupId?: string; toUserId?: string }): Promise<{ msgId: string }> {
257
- const socket = ensureSocketReady();
258
- const clientMsgId = randomUUID();
259
-
260
- const payload = {
261
- cmd: "im_stream_msg",
262
- params: {
263
- event: "START",
264
- clientMsgId,
265
- data: {
266
- groupId: opts.groupId,
267
- toUserId: opts.toUserId,
268
- },
269
- },
270
- };
271
-
272
- log.info(`${logPrefix} ⇒ WS START stream: clientMsgId=${clientMsgId} groupId=${opts.groupId ?? ""} toUserId=${opts.toUserId ?? ""}`);
273
-
274
- const msgId = await new Promise<string>((resolve, reject) => {
275
- pendingAcks.set(clientMsgId, resolve);
276
-
277
- socket.send(JSON.stringify(payload), (err) => {
278
- if (err) {
279
- pendingAcks.delete(clientMsgId);
280
- reject(err);
281
- }
282
- });
283
-
284
- // 简单超时保护,避免 ack 永远不到导致 Promise 悬挂
285
- setTimeout(() => {
286
- if (pendingAcks.has(clientMsgId)) {
287
- pendingAcks.delete(clientMsgId);
288
- reject(new Error(`${logPrefix} START stream ack timeout for clientMsgId=${clientMsgId}`));
289
- }
290
- }, 10_000);
291
- });
292
-
293
- return { msgId };
294
- },
295
-
296
- async chunk(msgId: string, data: ImStreamChunkData): Promise<void> {
297
- const socket = ensureSocketReady();
298
- const payload = {
299
- cmd: "im_stream_msg",
300
- params: {
301
- event: "CHUNK",
302
- msgId,
303
- data: {
304
- isThinking: data.isThinking,
305
- content: data.content,
306
- seq: data.seq,
307
- },
308
- },
309
- };
310
- if (config.debug) {
311
- log.debug?.(`${logPrefix} ⇒ WS CHUNK stream: msgId=${msgId} isThinking=${data.isThinking} seq=${data.seq} length=${data.content.length}`);
312
- }
313
- socket.send(JSON.stringify(payload));
314
- },
315
-
316
- async end(msgId: string, reason: string): Promise<void> {
317
- const socket = ensureSocketReady();
318
- const payload = {
319
- cmd: "im_stream_msg",
320
- params: {
321
- event: "END",
322
- msgId,
323
- data: { reason },
324
- },
325
- };
326
- log.info(`${logPrefix} ⇒ WS END stream: msgId=${msgId} reason=${reason}`);
327
- socket.send(JSON.stringify(payload));
328
- },
329
- };
330
-
331
- return {
332
- stop(): void {
333
- if (stopped) return;
334
- stopped = true;
335
- log.info(`${logPrefix} Stopping WebSocket connection...`);
336
-
337
- clearHeartbeat();
338
-
339
- if (reconnectTimer !== null) {
340
- clearTimeout(reconnectTimer);
341
- reconnectTimer = null;
342
- }
343
-
344
- if (ws && ws.readyState !== WebSocket.CLOSED) {
345
- ws.close(1000, "Plugin stopped");
346
- }
347
- ws = null;
348
- },
349
- streamClient,
350
- };
351
- }
1
+ /**
2
+ * XG-IM WebSocket 连接管理模块
3
+ *
4
+ * 功能:
5
+ * - 建立 wss://<baseUrl>/ws-notify/websocket?accessToken=<token> 长连接
6
+ * - 接收 robotMention 消息并回调
7
+ * - 支持基于 WebSocket 的 AI 回复流式推送(START/CHUNK/END 协议)
8
+ * - 指数退避 + 抖动的自动重连机制
9
+ * - 主动发送 WebSocket ping 保活,超时自动重连
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import WebSocket from "ws";
14
+ import type { Log } from "./auth.js";
15
+ import type { WsMessage, XgImConfig } from "./types.js";
16
+
17
+ export type OnMessageCallback = (msg: WsMessage) => void;
18
+
19
+ /**
20
+ * CHUNK 载荷:发送端带 seq,便于集群/跨服务按序重排。
21
+ * - isThinking=true 为思考阶段,seq 从 0 递增(thinkSeq);
22
+ * - isThinking=false 为回答阶段,seq 从 0 递增(answerSeq)。
23
+ */
24
+ export interface ImStreamChunkData {
25
+ isThinking: boolean;
26
+ content: string;
27
+ /** 当前阶段内的序号,think 与 answer 各自从 0 递增 */
28
+ seq: number;
29
+ }
30
+
31
+ /**
32
+ * 基于 WebSocket 的流式消息客户端。
33
+ *
34
+ * - start(): 发送 START,等待 ack,返回 msgId。
35
+ * - chunk(): 基于 msgId 发送增量文本,data 中带 seq(think/answer 阶段分开)。
36
+ * - end(): 发送 END,通知服务端结束本次流式消息。
37
+ */
38
+ export interface ImStreamClient {
39
+ start: (opts: { groupId?: string; toUserId?: string }) => Promise<{ msgId: string }>;
40
+ chunk: (msgId: string, data: ImStreamChunkData) => Promise<void>;
41
+ end: (msgId: string, reason: string) => Promise<void>;
42
+ }
43
+
44
+ /** 心跳间隔(ms):每 20 秒发一次 ping,防止连接被后台剔除 */
45
+ const PING_INTERVAL_MS = 20_000;
46
+
47
+ /** 心跳日志间隔(ms):每 10 分钟输出一次 info 日志,避免刷屏 */
48
+ const PING_LOG_INTERVAL_MS = 600_000;
49
+
50
+ /** 计算指数退避延迟(带随机抖动) */
51
+ function calcDelay(
52
+ attempt: number,
53
+ initial: number,
54
+ max: number,
55
+ jitter: number,
56
+ ): number {
57
+ const base = Math.min(initial * 2 ** attempt, max);
58
+ const jitterMs = base * jitter * Math.random();
59
+ return Math.round(base + jitterMs);
60
+ }
61
+
62
+ /**
63
+ * 启动 WebSocket 连接并监听消息。
64
+ *
65
+ * @returns 一个 `stop()` 函数,调用后永久断开连接,不再重连。
66
+ */
67
+ export function startWebSocket(
68
+ config: XgImConfig,
69
+ token: string,
70
+ onMessage: OnMessageCallback,
71
+ log: Log,
72
+ ): { stop: () => void; streamClient: ImStreamClient } {
73
+ const maxAttempts = config.maxConnectionAttempts ?? 200;
74
+ const initialDelay = config.initialReconnectDelay ?? 1_000;
75
+ const maxDelay = config.maxReconnectDelay ?? 60_000;
76
+ const jitter = config.reconnectJitter ?? 0.3;
77
+ const logPrefix = `[cwork_im:${config.agentId || "main"}]`;
78
+
79
+ // 将 baseUrl 的 http/https 协议替换为 ws/wss,并根据业务规则处理独立域名
80
+ let wsBase = config.wsBaseUrl;
81
+ if (!wsBase) {
82
+ // 自动映射逻辑:
83
+ // https://test.xgjktech.com.cn -> wss://websocket.xgjktech.com.cn
84
+ // https://xg.mediportal.com.cn -> wss://websocket.mediportal.com.cn
85
+ wsBase = config.baseUrl
86
+ .replace(/^http/, "ws");
87
+ }
88
+
89
+ const wsUrl = `${wsBase}/ws-notify/websocket?accessToken=${encodeURIComponent(token)}`;
90
+
91
+ let stopped = false;
92
+ let ws: WebSocket | null = null;
93
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
94
+ let pingTimer: ReturnType<typeof setInterval> | null = null;
95
+ let attempt = 0;
96
+
97
+ /**
98
+ * clientMsgId -> resolver(msgId)
99
+ *
100
+ * - 当发送 START 时,会生成一个 clientMsgId,并在此处挂一个 resolver。
101
+ * - 收到 im_stream_msg_ack 后,根据 clientMsgId 触发 resolver,返回服务端生成的 msgId。
102
+ */
103
+ const pendingAcks = new Map<string, (msgId: string) => void>();
104
+
105
+ /** 清除心跳定时器 */
106
+ function clearHeartbeat(): void {
107
+ if (pingTimer !== null) {
108
+ clearInterval(pingTimer);
109
+ pingTimer = null;
110
+ }
111
+ }
112
+
113
+ /** 启动心跳:每 20s 发一次 ping,info 日志每 10 分钟输出一次 */
114
+ function startHeartbeat(socket: WebSocket): void {
115
+ clearHeartbeat();
116
+ let lastLogTime = 0;
117
+
118
+ pingTimer = setInterval(() => {
119
+ if (socket.readyState !== WebSocket.OPEN) {
120
+ clearHeartbeat();
121
+ return;
122
+ }
123
+
124
+ const now = Date.now();
125
+ const shouldLog = now - lastLogTime >= PING_LOG_INTERVAL_MS;
126
+ if (shouldLog) {
127
+ lastLogTime = now;
128
+ log.info(`${logPrefix} ♥ Ping sent at ${new Date().toISOString()}`);
129
+ }
130
+
131
+ // 发送标准 WebSocket ping
132
+ socket.ping((err: Error | null) => {
133
+ if (err) {
134
+ log.error(`${logPrefix} Ping frame error: ${err.message}`);
135
+ }
136
+ });
137
+
138
+ // 发送业务层文本 ping(更新服务端活跃时间)
139
+ socket.send("ping");
140
+ }, PING_INTERVAL_MS);
141
+ }
142
+
143
+ /**
144
+ * 确保当前存在可用的 WebSocket 连接。
145
+ * 若不存在或未处于 OPEN 状态,则抛出错误,由上层决定是否回退为非流式模式。
146
+ */
147
+ function ensureSocketReady(): WebSocket {
148
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
149
+ throw new Error(`${logPrefix} WebSocket is not ready for sending stream message`);
150
+ }
151
+ return ws;
152
+ }
153
+
154
+ function connect(): void {
155
+ if (stopped) return;
156
+
157
+ log.info(`${logPrefix} Connecting to WebSocket (attempt ${attempt + 1})...`);
158
+
159
+ ws = new WebSocket(wsUrl);
160
+
161
+ ws.on("open", () => {
162
+ attempt = 0; // 连接成功后重置重试计数
163
+ log.info(`${logPrefix} WebSocket connected successfully.`);
164
+ startHeartbeat(ws!);
165
+ });
166
+
167
+ // 处理消息
168
+ ws.on("message", (raw: WebSocket.RawData) => {
169
+ const rawString = raw.toString();
170
+
171
+ if (rawString === "pong" || rawString === "ping") {
172
+ if (config.debug) log.debug?.(`${logPrefix} Ignored plain text ${rawString}`);
173
+ return;
174
+ }
175
+
176
+ let parsed: any;
177
+ try {
178
+ parsed = JSON.parse(rawString) as unknown;
179
+ } catch {
180
+ // 如果不是 JSON,说明是心跳或者其他内容,忽略即可
181
+ // 如果开启 debug 还是可以打印日志
182
+ if (config.debug) {
183
+ log.debug?.(`${logPrefix} Received non-JSON message: ${rawString.slice(0, 50)}`);
184
+ }
185
+ return;
186
+ }
187
+
188
+ log.info(`${logPrefix} ⇐ WS message received: cmd=${parsed.cmd}`);
189
+
190
+ // 处理流式消息 ack:im_stream_msg_ack
191
+ if (parsed.cmd === "im_stream_msg_ack" && parsed.params) {
192
+ const clientMsgId: string | undefined = parsed.params.clientMsgId;
193
+ const msgId: string | undefined = parsed.params.msgId;
194
+ if (clientMsgId && msgId) {
195
+ const resolver = pendingAcks.get(clientMsgId);
196
+ if (resolver) {
197
+ pendingAcks.delete(clientMsgId);
198
+ resolver(msgId);
199
+ log.info(`${logPrefix} ⇐ im_stream_msg_ack received: clientMsgId=${clientMsgId} msgId=${msgId}`);
200
+ } else if (config.debug) {
201
+ log.debug?.(`${logPrefix} im_stream_msg_ack with unknown clientMsgId=${clientMsgId}`);
202
+ }
203
+ } else if (config.debug) {
204
+ log.debug?.(`${logPrefix} im_stream_msg_ack missing clientMsgId or msgId`);
205
+ }
206
+ return;
207
+ }
208
+
209
+ // 处理包含 params 的事件(robotMention / groupMessage)
210
+ if (parsed.params && (parsed.cmd === "robotMention" || parsed.cmd === "groupMessage")) {
211
+ if (parsed.cmd === "robotMention") {
212
+ log.info(`${logPrefix} 📨 robotMention received: groupId=${parsed.params.groupId} msgId=${parsed.params.msgId}`);
213
+ }
214
+ const msg: WsMessage = {
215
+ cmd: parsed.cmd,
216
+ params: parsed.params,
217
+ ts: typeof parsed.ts === "number" ? parsed.ts : Date.now(),
218
+ };
219
+ Promise.resolve(onMessage(msg)).catch((err: unknown) => {
220
+ log.error(`${logPrefix} onMessage async error: ${String(err)}`);
221
+ });
222
+ }
223
+ });
224
+
225
+ ws.on("close", (code: number, reason: Buffer) => {
226
+ clearHeartbeat();
227
+ if (stopped) {
228
+ log.info(`${logPrefix} WebSocket closed (intentional stop).`);
229
+ return;
230
+ }
231
+ const reasonStr = (reason && reason.length > 0) ? reason.toString() : "(none)";
232
+
233
+ // 使用 log.info 输出警告,避免 log.warn 不存在导致漏打日志
234
+ log.info(`${logPrefix} [WARN] WebSocket closed unexpectedly: code=${code}, reason=${reasonStr}. Scheduling reconnect...`);
235
+ scheduleReconnect();
236
+ });
237
+
238
+ ws.on("error", (err: Error) => {
239
+ if (stopped) return;
240
+ log.error(`${logPrefix} WebSocket error: ${err.message}`);
241
+ // 关闭后会触发 close 事件,由 close 处理重连
242
+ });
243
+ }
244
+
245
+ function scheduleReconnect(): void {
246
+ if (stopped) return;
247
+
248
+ if (attempt >= maxAttempts) {
249
+ log.error(`${logPrefix} Max reconnect attempts (${maxAttempts}) reached. Giving up.`);
250
+ return;
251
+ }
252
+
253
+ const delay = calcDelay(attempt, initialDelay, maxDelay, jitter);
254
+ attempt += 1;
255
+ log.info(`${logPrefix} Reconnecting in ${delay}ms (attempt ${attempt}/${maxAttempts})...`);
256
+
257
+ reconnectTimer = setTimeout(() => {
258
+ reconnectTimer = null;
259
+ connect();
260
+ }, delay);
261
+ }
262
+
263
+ // 首次连接
264
+ connect();
265
+
266
+ const streamClient: ImStreamClient = {
267
+ async start(opts: { groupId?: string; toUserId?: string }): Promise<{ msgId: string }> {
268
+ const socket = ensureSocketReady();
269
+ const clientMsgId = randomUUID();
270
+
271
+ const payload = {
272
+ cmd: "im_stream_msg",
273
+ params: {
274
+ event: "START",
275
+ clientMsgId,
276
+ data: {
277
+ groupId: opts.groupId,
278
+ toUserId: opts.toUserId,
279
+ },
280
+ },
281
+ };
282
+
283
+ log.info(`${logPrefix} ⇒ WS START stream: clientMsgId=${clientMsgId} groupId=${opts.groupId ?? ""} toUserId=${opts.toUserId ?? ""}`);
284
+
285
+ const msgId = await new Promise<string>((resolve, reject) => {
286
+ pendingAcks.set(clientMsgId, resolve);
287
+
288
+ socket.send(JSON.stringify(payload), (err) => {
289
+ if (err) {
290
+ pendingAcks.delete(clientMsgId);
291
+ reject(err);
292
+ }
293
+ });
294
+
295
+ // 简单超时保护,避免 ack 永远不到导致 Promise 悬挂
296
+ setTimeout(() => {
297
+ if (pendingAcks.has(clientMsgId)) {
298
+ pendingAcks.delete(clientMsgId);
299
+ reject(new Error(`${logPrefix} START stream ack timeout for clientMsgId=${clientMsgId}`));
300
+ }
301
+ }, 10_000);
302
+ });
303
+
304
+ return { msgId };
305
+ },
306
+
307
+ async chunk(msgId: string, data: ImStreamChunkData): Promise<void> {
308
+ const socket = ensureSocketReady();
309
+ const payload = {
310
+ cmd: "im_stream_msg",
311
+ params: {
312
+ event: "CHUNK",
313
+ msgId,
314
+ data: {
315
+ isThinking: data.isThinking,
316
+ content: data.content,
317
+ seq: data.seq,
318
+ },
319
+ },
320
+ };
321
+ if (config.debug) {
322
+ log.debug?.(`${logPrefix} ⇒ WS CHUNK stream: msgId=${msgId} isThinking=${data.isThinking} seq=${data.seq} length=${data.content.length}`);
323
+ }
324
+ socket.send(JSON.stringify(payload));
325
+ },
326
+
327
+ async end(msgId: string, reason: string): Promise<void> {
328
+ const socket = ensureSocketReady();
329
+ const payload = {
330
+ cmd: "im_stream_msg",
331
+ params: {
332
+ event: "END",
333
+ msgId,
334
+ data: { reason },
335
+ },
336
+ };
337
+ log.info(`${logPrefix} ⇒ WS END stream: msgId=${msgId} reason=${reason}`);
338
+ socket.send(JSON.stringify(payload));
339
+ },
340
+ };
341
+
342
+ return {
343
+ stop(): void {
344
+ if (stopped) return;
345
+ stopped = true;
346
+ log.info(`${logPrefix} Stopping WebSocket connection...`);
347
+
348
+ clearHeartbeat();
349
+
350
+ if (reconnectTimer !== null) {
351
+ clearTimeout(reconnectTimer);
352
+ reconnectTimer = null;
353
+ }
354
+
355
+ if (ws && ws.readyState !== WebSocket.CLOSED) {
356
+ ws.close(1000, "Plugin stopped");
357
+ }
358
+ ws = null;
359
+ },
360
+ streamClient,
361
+ };
362
+ }