dsh-im-union 0.5.2 → 0.5.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  本项目版本号遵循语义化版本;发布面与宿主兼容矩阵见 `scripts/hosts.mjs`(README 管理块同源)。
4
4
 
5
+ ## 0.5.3(Discord Gateway 自动重连与 RESUME 续传:#2 收口)
6
+
7
+ 原挂账 T2.5 落地:连接一断就只记日志、不重建,而 Discord 会**定期**主动下发 `op 7 RECONNECT`,所以这条路径必然被走到——账号静默失联直到手动重启。
8
+
9
+ ### Added
10
+
11
+ - **退避自动重连**:`close` / `op 7` / 僵尸判定统一走 `scheduleAttempt()`——指数退避 `base·2^(n-1)`(默认 1s 起)+ 正向抖动≤30%(免全量客户端同刻共击),封顶 60s;**不主动放弃**(IM 账号静默失联比慢重连更糟)。单一 `reconnectTimer` 位即并发守卫:close 与 op7 同时触发只保留一个排程。
12
+ - **RESUME 续传**:捕获 READY 的 `session_id` 与 `resume_gateway_url`,断线重连优先打 resume 域并发 `op 6`(带最后 `seq`),不丢断口期间的事件也不重拉全量;拿不到 resume 信息时回退完整 IDENTIFY。`RESUMED` 帧才置 `connected`,并重连成功留痕(含第几次尝试)。
13
+ - **僵尸连接判定**:发出 `HEARTBEAT` 后下一拍仍未收 ACK → 主动断开并进重连(官方推荐做法);收到 ACK 即清位,不误判。
14
+ - **`op 9` 两档分流**:`d=false` 会**清 session**(不清则后续 HELLO 误走 RESUME 反复碰壁);两档均经退避排程而非零等待,消除「op9 d=true → 同连接立即 RESUME → 再被拒」的热循环。
15
+
16
+ ### Fixed
17
+
18
+ - **重连与上层 `connect()` 叠出双 Gateway 连接**:`AccountManager.ensureConnected` 在任何操作前都会调 `connect()`,而它在 `connected=false` 时不撤内部排程就另建一条——Discord 视为两个 session,入站可能只投其中一条。现在 `connect()` 语义改为**接管**:先 `cancelScheduled()` + 主动拆旧 socket + 归零 `attempts`,全仓始终只有一条在途连接。
19
+ - **被替换的旧 socket 迟到事件污染当前状态**:message/close/error 三个处理器均先比 `this.ws !== ws`,旧连接的迟到帧与 close 一律丢弃(不改 `connected`、不触发新排程、不向上层投递消息)。
20
+
21
+ ### 面板可见性
22
+
23
+ 无需新增 API:重连等待期内 `connected` 保持 false,而 `account-manager` 已按 `live?.adapter?.connected === true` 透出账号状态,故降级对用户可见(不再是“看着已连接但其实哑了”)。
24
+
25
+ ### 备注
26
+
27
+ - `reconnectBaseMs` / `reconnectMaxMs` / `random` 仅作为适配器构造选项(供测试确定性断言),**不进用户设置面**:默认值已适用于生产,多一个开关就多一类误配。
28
+ - 新增 9 例契约测试(共 40 例 discord / **780 例全量**):退避递增封顶、op7 不叠加排程、RESUME 域名与 seq、IDENTIFY 回退、僵尸判定与 ACK 不误判、disconnect 不重连、connect 接管不叠连、旧 socket 事件丢弃。
29
+
5
30
  ## 0.5.2(Discord 服务器频道消息失联:Gateway intents 位值修正)
6
31
 
7
32
  感谢 @lide4144 在 #1 里的报告与双连接对照实验,定位、根因与修正值全部准确。
package/README.en.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A union IM bridge for DeepSeek Harness — a clean-room reimplementation merging the capability sets of xmanrui/dsh-im and MichengAI/dsh-im-connect, with no source copied from either.
4
4
 
5
5
  <!-- host-compat:begin — generated by scripts/sync-hosts.mjs from scripts/hosts.mjs; DO NOT EDIT -->
6
- - **Supported DSH hosts:** `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2` `0.2.1-alpha.2` `0.2.1-alpha.3` `0.2.1-rc.1` `0.2.1-rc.2` `0.2.1-rc.3` `0.2.1-rc.4` (14 rc/alpha releases across lines 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 / 0.2.1)
6
+ - **Supported DSH hosts:** `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2` `0.2.1-alpha.2` `0.2.1-alpha.3` `0.2.1-rc.1` `0.2.1-rc.2` `0.2.1-rc.3` `0.2.1-rc.4` `0.2.1-rc.5` `0.2.2-rc.1` (16 rc/alpha releases across lines 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 / 0.2.1)
7
7
  <!-- host-compat:end -->
8
8
 
9
9
  ## Status
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  DeepSeek Harness 的 IM 联合桥——净室重写,能力合集来自社区两仓(xmanrui/dsh-im 与 MichengAI/dsh-im-connect),不复制其源码。
4
4
 
5
5
  <!-- host-compat:begin — generated by scripts/sync-hosts.mjs from scripts/hosts.mjs; DO NOT EDIT -->
6
- - **支持的 DSH 宿主:** `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2` `0.2.1-alpha.2` `0.2.1-alpha.3` `0.2.1-rc.1` `0.2.1-rc.2` `0.2.1-rc.3` `0.2.1-rc.4`(覆盖 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 / 0.2.1 共 14 个 rc/alpha)
6
+ - **支持的 DSH 宿主:** `0.1.2-rc.1` `0.1.5-rc.1` `0.1.5-rc.2` `0.1.5-rc.3` `0.1.7-rc.1` `0.1.7-rc.2` `0.2.0-rc.1` `0.2.0-rc.2` `0.2.1-alpha.2` `0.2.1-alpha.3` `0.2.1-rc.1` `0.2.1-rc.2` `0.2.1-rc.3` `0.2.1-rc.4` `0.2.1-rc.5` `0.2.2-rc.1`(覆盖 0.1.2 / 0.1.5 / 0.1.7 / 0.2.0 / 0.2.1 共 16 个 rc/alpha)
7
7
  <!-- host-compat:end -->
8
8
 
9
9
  ## 状态
package/dsh.plugin.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "id": "dsh-im-union",
3
3
  "name": "dsh-im-union",
4
- "version": "0.5.2",
4
+ "version": "0.5.4",
5
5
  "description": {
6
6
  "en": "Union IM bridge for DeepSeek Harness: multi-account bots across messaging platforms, each chat bound to its own session, with cross-host-version compatibility (0.1.2-rc.1 .. 0.2.1-rc.3).",
7
7
  "zh": "DeepSeek Harness 的 IM 联合桥:多平台多账号机器人、每个聊天绑定独立会话,跨宿主版本兼容(0.1.2-rc.1 至 0.2.1-rc.3)。"
8
8
  },
9
9
  "license": "MIT",
10
10
  "engines": {
11
- "dsh": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4"
11
+ "dsh": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1"
12
12
  },
13
13
  "main": "./lib/index.js",
14
14
  "client": {
@@ -53,6 +53,12 @@ export interface DiscordAdapterOptions {
53
53
  fetchImpl?: FetchLike;
54
54
  WebSocketImpl?: WSFactory;
55
55
  logger?: (msg: string) => void;
56
+ /** 重连退避基准(默认 1000ms),按 2^n 递增到 `reconnectMaxMs`;测试可压小。 */
57
+ reconnectBaseMs?: number;
58
+ /** 重连退避上限(默认 60000ms):不放弃重试,只封顶等待。 */
59
+ reconnectMaxMs?: number;
60
+ /** 抖动源(默认 `Math.random`):测试注定值以确定性断言等待时长。 */
61
+ random?: () => number;
56
62
  }
57
63
  export declare class DiscordApiError extends Error {
58
64
  readonly status: number;
@@ -70,19 +76,56 @@ export declare class DiscordAdapter implements ChannelAdapter {
70
76
  private readonly fetchImpl;
71
77
  private readonly wsFactory;
72
78
  private readonly log;
79
+ private readonly reconnectBaseMs;
80
+ private readonly reconnectMaxMs;
81
+ private readonly random;
73
82
  private listeners;
74
83
  private ws;
75
84
  private botUserId;
76
85
  private lastSeq;
77
86
  private heartbeat;
78
87
  private readonly closeController;
88
+ /** 首次 IDENTIFY 用的 Gateway 地址(`connect()` 取定);重连优先走 `resumeUrl`。 */
89
+ private gatewayUrl;
90
+ /** READY 下发的 session_id:有值且 `resumeUrl` 在位才能 RESUME(不丢事件)。 */
91
+ private sessionId;
92
+ /** READY 下发的 resume_gateway_url(与初次 `/gateway` 不同域,必须用它续传)。 */
93
+ private resumeUrl;
94
+ /** 连续重连次数;READY/RESUMED 归零,驱动指数退避。 */
95
+ private attempts;
96
+ /** 已发 HEARTBEAT 等 ACK:下一拍仍未清即判定僵尸连接。 */
97
+ private awaitingAck;
98
+ /** 待执行的重连排程(单一排程位即并发守卫:重复 close/RECONNECT 不叠加)。 */
99
+ private reconnectTimer;
100
+ /** 主动拆线位:`disconnect()` 后不得再自动重连。 */
101
+ private shuttingDown;
79
102
  constructor(options: DiscordAdapterOptions);
80
103
  onMessage(listener: MessageListener): () => void;
81
104
  private rest;
82
105
  connect(signal?: AbortSignal): Promise<void>;
106
+ /**
107
+ * 建一条 Gateway 连接。重连不重跑 `connect()` 的 REST 面:
108
+ * 能续传时用 READY 给的 `resume_gateway_url`,否则回退初次取定的 `gatewayUrl`。
109
+ */
110
+ private openGateway;
111
+ /** 取消待执行的重连排程(单一收口,避免各处 clearTimeout 写法走样)。 */
112
+ private cancelScheduled;
83
113
  disconnect(): Promise<void>;
84
114
  private handleGatewayFrame;
85
115
  private sendIdentify;
116
+ /** 可续传:READY 同时给了 session_id 与 resume_gateway_url 才成立(缺一必须重 IDENTIFY)。 */
117
+ private canResume;
118
+ private sendResume;
119
+ /** 指数退避 + 正向抖动(最多 +30%,避免全量客户端同刻共击):base·2^(n-1) 封顶 maxMs。 */
120
+ private backoffMs;
121
+ /**
122
+ * 唯一的退避排程原语。单一 `reconnectTimer` 位就是并发守卫:close、op7、op9 多源
123
+ * 同时触发时只保留第一个排程(不叠加、不互相覆盖、不泄漏旧定时器)。
124
+ * 不主动放弃:只把等待封顶在 `reconnectMaxMs`,IM 账号静默失联比慢重连更糟。
125
+ */
126
+ private scheduleAttempt;
127
+ /** 重连一条新连接:不重跑 `connect()` 的 REST 面,地址用已取定的 resume/gateway 位。 */
128
+ private scheduleReconnect;
86
129
  private sendHeartbeat;
87
130
  private stopHeartbeat;
88
131
  private sendFrame;
@@ -62,12 +62,29 @@ export class DiscordAdapter {
62
62
  fetchImpl;
63
63
  wsFactory;
64
64
  log;
65
+ reconnectBaseMs;
66
+ reconnectMaxMs;
67
+ random;
65
68
  listeners = [];
66
69
  ws;
67
70
  botUserId = '';
68
71
  lastSeq;
69
72
  heartbeat;
70
73
  closeController = new AbortController();
74
+ /** 首次 IDENTIFY 用的 Gateway 地址(`connect()` 取定);重连优先走 `resumeUrl`。 */
75
+ gatewayUrl = '';
76
+ /** READY 下发的 session_id:有值且 `resumeUrl` 在位才能 RESUME(不丢事件)。 */
77
+ sessionId = '';
78
+ /** READY 下发的 resume_gateway_url(与初次 `/gateway` 不同域,必须用它续传)。 */
79
+ resumeUrl = '';
80
+ /** 连续重连次数;READY/RESUMED 归零,驱动指数退避。 */
81
+ attempts = 0;
82
+ /** 已发 HEARTBEAT 等 ACK:下一拍仍未清即判定僵尸连接。 */
83
+ awaitingAck = false;
84
+ /** 待执行的重连排程(单一排程位即并发守卫:重复 close/RECONNECT 不叠加)。 */
85
+ reconnectTimer;
86
+ /** 主动拆线位:`disconnect()` 后不得再自动重连。 */
87
+ shuttingDown = false;
71
88
  constructor(options) {
72
89
  if (typeof options.token !== 'string' || options.token === '') {
73
90
  throw new Error('discord: botToken 缺失(凭据库解析失败或未配置账号)');
@@ -75,6 +92,9 @@ export class DiscordAdapter {
75
92
  this.token = options.token;
76
93
  this.apiBase = options.apiBase ?? 'https://discord.com/api/v10';
77
94
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
95
+ this.reconnectBaseMs = options.reconnectBaseMs ?? 1000;
96
+ this.reconnectMaxMs = options.reconnectMaxMs ?? 60_000;
97
+ this.random = options.random ?? Math.random;
78
98
  const globalWS = globalThis.WebSocket;
79
99
  this.wsFactory =
80
100
  options.WebSocketImpl ??
@@ -118,34 +138,87 @@ export class DiscordAdapter {
118
138
  }
119
139
  // —— Gateway ——————————————————————————————————————————
120
140
  async connect(signal) {
141
+ this.shuttingDown = false;
142
+ // 上层显式 connect(`AccountManager.ensureConnected` 在任何操作前都会调):
143
+ // 由它接管重连。若不先撤内部排程并拆旧 socket,会同时存在两条 Gateway 连接
144
+ // (Discord 视为两个 session,入站可能只投其中一条)。
145
+ this.cancelScheduled();
146
+ this.attempts = 0;
147
+ const prev = this.ws;
148
+ if (prev !== undefined) {
149
+ this.ws = undefined; // 先摘引用:旧 socket 迟到的 close/message 不再参与状态判定
150
+ this.stopHeartbeat();
151
+ try {
152
+ prev.close();
153
+ }
154
+ catch {
155
+ // ignore:旧连接关不上也不能阻塞新连接建立。
156
+ }
157
+ }
121
158
  const me = (await this.rest('GET', '/users/@me', undefined, signal));
122
159
  this.botUserId = String(me.id ?? '');
123
160
  const gateway = (await this.rest('GET', '/gateway', undefined, signal));
124
161
  const url = String(gateway.url ?? 'wss://gateway.discord.gg/?v=10&encoding=json');
125
- const wsUrl = url.includes('?') ? `${url}&encoding=json` : `${url}/?v=10&encoding=json`;
162
+ this.gatewayUrl = url.includes('?') ? `${url}&encoding=json` : `${url}/?v=10&encoding=json`;
163
+ this.openGateway();
164
+ }
165
+ /**
166
+ * 建一条 Gateway 连接。重连不重跑 `connect()` 的 REST 面:
167
+ * 能续传时用 READY 给的 `resume_gateway_url`,否则回退初次取定的 `gatewayUrl`。
168
+ */
169
+ openGateway() {
170
+ if (this.shuttingDown)
171
+ return;
172
+ const wsUrl = this.resumeUrl !== '' ? this.resumeUrl : this.gatewayUrl;
126
173
  const ws = this.wsFactory(wsUrl);
127
174
  this.ws = ws;
128
- ws.addEventListener('message', (event) => this.handleGatewayFrame(String(event.data)));
175
+ this.awaitingAck = false;
176
+ // 两个处理器都先比 `this.ws !== ws`:被 connect()/重连替换过的旧 socket,
177
+ // 其迟到帧与 close 一律不得再改当前状态(否则会误判断开、叠排重连)。
178
+ ws.addEventListener('message', (event) => {
179
+ if (this.ws !== ws)
180
+ return;
181
+ this.handleGatewayFrame(String(event.data));
182
+ });
129
183
  ws.addEventListener('close', () => {
184
+ if (this.ws !== ws)
185
+ return;
130
186
  this.connected = false;
131
187
  this.stopHeartbeat();
132
- if (!this.closeController.signal.aborted)
133
- this.log('discord Gateway 断开(上层重连策略归 T2.5)');
188
+ // 主动拆线(disconnect / 宿主 abort / connect 接管)不重连;其余一律进退避排程。
189
+ if (this.shuttingDown || this.closeController.signal.aborted)
190
+ return;
191
+ this.scheduleReconnect('断开');
134
192
  });
135
193
  ws.addEventListener('error', () => {
194
+ if (this.ws !== ws)
195
+ return;
136
196
  this.connected = false;
137
197
  });
138
198
  }
199
+ /** 取消待执行的重连排程(单一收口,避免各处 clearTimeout 写法走样)。 */
200
+ cancelScheduled() {
201
+ if (this.reconnectTimer !== undefined) {
202
+ clearTimeout(this.reconnectTimer);
203
+ this.reconnectTimer = undefined;
204
+ }
205
+ }
139
206
  async disconnect() {
207
+ this.shuttingDown = true;
140
208
  this.stopHeartbeat();
209
+ this.cancelScheduled();
210
+ this.sessionId = '';
211
+ this.resumeUrl = '';
212
+ this.attempts = 0;
141
213
  this.closeController.abort();
214
+ const ws = this.ws;
215
+ this.ws = undefined;
142
216
  try {
143
- this.ws?.close();
217
+ ws?.close();
144
218
  }
145
219
  catch {
146
220
  // 已断开即目标达成。
147
221
  }
148
- this.ws = undefined;
149
222
  this.connected = false;
150
223
  }
151
224
  handleGatewayFrame(raw) {
@@ -164,32 +237,63 @@ export class DiscordAdapter {
164
237
  const intervalMs = Number(frame.d?.heartbeat_interval ?? 41250);
165
238
  this.stopHeartbeat();
166
239
  this.heartbeat = setInterval(() => this.sendHeartbeat(), intervalMs);
167
- this.sendIdentify();
240
+ // 有 session 就续传(不丢断口期间的事件、不重拉全量);否则完整 IDENTIFY。
241
+ if (this.canResume())
242
+ this.sendResume();
243
+ else
244
+ this.sendIdentify();
168
245
  return;
169
246
  }
170
247
  case OP.HEARTBEAT_ACK:
248
+ this.awaitingAck = false;
171
249
  return;
172
250
  case OP.RECONNECT:
173
- this.log('discord Gateway 要求重连(当前实现退IDENTIFY重建)');
251
+ // 服务端定期要求重连:先落排程,再关连接(close 处理器看到已有排程就不叠加)。
252
+ this.scheduleReconnect('收到 op7 RECONNECT');
174
253
  try {
175
254
  this.ws?.close();
176
255
  }
177
256
  catch {
178
- // ignore
257
+ // ignore:排程已落,关失败也不影响下一轮。
179
258
  }
180
259
  return;
181
260
  case OP.INVALID_SESSION: {
182
261
  this.connected = false;
183
262
  this.stopHeartbeat();
184
- if (frame.d === true) {
185
- this.log('discord 会话可续(RESUME 归后续迭代),当前直接重 IDENTIFY');
186
- }
187
- this.sendIdentify();
263
+ // d=false 意味会话已确定没了:不清 session 的话,下一条 HELLO 会误走 RESUME 反复碰壁。
264
+ if (frame.d !== true)
265
+ this.sessionId = '';
266
+ // 官方要求收到 op9 后等 1–5s 再动作;走同一退避位,不另并一套定时器,
267
+ // 也避免「op9 d=true → 同连接立即 RESUME → 再被拒」的热循环。
268
+ const useResume = this.canResume();
269
+ this.scheduleAttempt(`会话失效(op9 d=${String(frame.d)})`, () => {
270
+ if (useResume)
271
+ this.sendResume();
272
+ else
273
+ this.sendIdentify();
274
+ });
188
275
  return;
189
276
  }
190
277
  case OP.DISPATCH: {
191
278
  if (frame.t === 'READY') {
192
279
  this.connected = true;
280
+ this.attempts = 0;
281
+ this.awaitingAck = false;
282
+ this.sessionId = String(frame.d?.session_id ?? '');
283
+ const resume = frame.d?.resume_gateway_url;
284
+ if (typeof resume === 'string' && resume !== '')
285
+ this.resumeUrl = resume;
286
+ this.log(this.sessionId !== '' && this.resumeUrl !== ''
287
+ ? 'discord Gateway 就绪(session 已建立,后续断开可 RESUME 续传)'
288
+ : 'discord Gateway 就绪(未拿到 resume 信息,后续断开将重 IDENTIFY)');
289
+ return;
290
+ }
291
+ if (frame.t === 'RESUMED') {
292
+ const recovered = this.attempts;
293
+ this.connected = true;
294
+ this.attempts = 0;
295
+ this.awaitingAck = false;
296
+ this.log(`discord Gateway RESUME 成功(断口期间事件已续传${recovered > 1 ? `,第 ${recovered} 次尝试` : ''})`);
193
297
  return;
194
298
  }
195
299
  if (frame.t === 'MESSAGE_CREATE') {
@@ -223,7 +327,73 @@ export class DiscordAdapter {
223
327
  },
224
328
  });
225
329
  }
330
+ /** 可续传:READY 同时给了 session_id 与 resume_gateway_url 才成立(缺一必须重 IDENTIFY)。 */
331
+ canResume() {
332
+ return this.sessionId !== '' && this.resumeUrl !== '';
333
+ }
334
+ sendResume() {
335
+ this.sendFrame({
336
+ op: OP.RESUME,
337
+ d: { token: this.token, session_id: this.sessionId, seq: this.lastSeq ?? 0 },
338
+ });
339
+ }
340
+ /** 指数退避 + 正向抖动(最多 +30%,避免全量客户端同刻共击):base·2^(n-1) 封顶 maxMs。 */
341
+ backoffMs(attempt) {
342
+ const base = Math.min(this.reconnectBaseMs * 2 ** Math.max(0, attempt - 1), this.reconnectMaxMs);
343
+ return Math.round(base * (1 + this.random() * 0.3));
344
+ }
345
+ /**
346
+ * 唯一的退避排程原语。单一 `reconnectTimer` 位就是并发守卫:close、op7、op9 多源
347
+ * 同时触发时只保留第一个排程(不叠加、不互相覆盖、不泄漏旧定时器)。
348
+ * 不主动放弃:只把等待封顶在 `reconnectMaxMs`,IM 账号静默失联比慢重连更糟。
349
+ */
350
+ scheduleAttempt(reason, action) {
351
+ if (this.shuttingDown)
352
+ return;
353
+ if (this.reconnectTimer !== undefined) {
354
+ this.log(`discord ${reason}:已有重连排程在队,不叠加`);
355
+ return;
356
+ }
357
+ const attempt = ++this.attempts;
358
+ const wait = this.backoffMs(attempt);
359
+ this.log(`discord ${reason},${wait}ms 后第 ${attempt} 次尝试`);
360
+ this.reconnectTimer = setTimeout(() => {
361
+ this.reconnectTimer = undefined;
362
+ if (this.shuttingDown)
363
+ return;
364
+ try {
365
+ action();
366
+ }
367
+ catch (error) {
368
+ // 建连/发帧本身抛(如宿主缺 WebSocket):转下一轮退避,不把异常冒到事件循环外。
369
+ this.log(`discord 重连尝试失败: ${error.message}`);
370
+ this.scheduleAttempt('重连尝试失败', action);
371
+ }
372
+ }, wait);
373
+ }
374
+ /** 重连一条新连接:不重跑 `connect()` 的 REST 面,地址用已取定的 resume/gateway 位。 */
375
+ scheduleReconnect(reason) {
376
+ this.scheduleAttempt(`Gateway ${reason}`, () => {
377
+ this.openGateway();
378
+ });
379
+ }
226
380
  sendHeartbeat() {
381
+ // 上一拍还没 ACK 就到了下一拍 = 僵尸连接(TCP 活着但服务端已不响应),
382
+ // 官方推荐做法是主动断开重建;close 处理器会接手排重连。
383
+ if (this.awaitingAck) {
384
+ this.awaitingAck = false;
385
+ this.stopHeartbeat();
386
+ this.log('discord 心跳无 ACK,判定僵尸连接并主动断开重连');
387
+ try {
388
+ this.ws?.close();
389
+ }
390
+ catch {
391
+ // close 处理器不可依赖时,手动补一排程。
392
+ this.scheduleReconnect('僵尸连接断开失败');
393
+ }
394
+ return;
395
+ }
396
+ this.awaitingAck = true;
227
397
  this.sendFrame({ op: OP.HEARTBEAT, d: this.lastSeq ?? null });
228
398
  }
229
399
  stopHeartbeat() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-im-union",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Union IM bridge for DeepSeek Harness 鈥?clean-room merge of dsh-im and dsh-im-connect capabilities, compatible across 0.1.2-rc.1 .. 0.2.1-rc.2 hosts.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,16 +41,16 @@
41
41
  },
42
42
  "engines": {
43
43
  "node": "^22.19.0 || >=24.0.0",
44
- "dsh": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4"
44
+ "dsh": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@deepseek-ai/cordis": "^4.0.2",
48
- "@deepseek-ai/dsh-agent": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
49
- "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
50
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
51
- "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
52
- "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
53
- "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4",
48
+ "@deepseek-ai/dsh-agent": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1",
49
+ "@deepseek-ai/dsh-client-locale": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1",
50
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1",
51
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1",
52
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1",
53
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-rc.1",
54
54
  "@deepseek-ai/schemastery": "^3.18.2",
55
55
  "react": "^18.2.0"
56
56
  },
@@ -99,6 +99,12 @@ export interface DiscordAdapterOptions {
99
99
  fetchImpl?: FetchLike
100
100
  WebSocketImpl?: WSFactory
101
101
  logger?: (msg: string) => void
102
+ /** 重连退避基准(默认 1000ms),按 2^n 递增到 `reconnectMaxMs`;测试可压小。 */
103
+ reconnectBaseMs?: number
104
+ /** 重连退避上限(默认 60000ms):不放弃重试,只封顶等待。 */
105
+ reconnectMaxMs?: number
106
+ /** 抖动源(默认 `Math.random`):测试注定值以确定性断言等待时长。 */
107
+ random?: () => number
102
108
  }
103
109
 
104
110
  export class DiscordApiError extends Error {
@@ -134,12 +140,29 @@ export class DiscordAdapter implements ChannelAdapter {
134
140
  private readonly fetchImpl: FetchLike
135
141
  private readonly wsFactory: WSFactory
136
142
  private readonly log: (msg: string) => void
143
+ private readonly reconnectBaseMs: number
144
+ private readonly reconnectMaxMs: number
145
+ private readonly random: () => number
137
146
  private listeners: MessageListener[] = []
138
147
  private ws: WSLike | undefined
139
148
  private botUserId = ''
140
149
  private lastSeq: number | undefined
141
150
  private heartbeat: ReturnType<typeof setInterval> | undefined
142
151
  private readonly closeController = new AbortController()
152
+ /** 首次 IDENTIFY 用的 Gateway 地址(`connect()` 取定);重连优先走 `resumeUrl`。 */
153
+ private gatewayUrl = ''
154
+ /** READY 下发的 session_id:有值且 `resumeUrl` 在位才能 RESUME(不丢事件)。 */
155
+ private sessionId = ''
156
+ /** READY 下发的 resume_gateway_url(与初次 `/gateway` 不同域,必须用它续传)。 */
157
+ private resumeUrl = ''
158
+ /** 连续重连次数;READY/RESUMED 归零,驱动指数退避。 */
159
+ private attempts = 0
160
+ /** 已发 HEARTBEAT 等 ACK:下一拍仍未清即判定僵尸连接。 */
161
+ private awaitingAck = false
162
+ /** 待执行的重连排程(单一排程位即并发守卫:重复 close/RECONNECT 不叠加)。 */
163
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined
164
+ /** 主动拆线位:`disconnect()` 后不得再自动重连。 */
165
+ private shuttingDown = false
143
166
 
144
167
  constructor(options: DiscordAdapterOptions) {
145
168
  if (typeof options.token !== 'string' || options.token === '') {
@@ -148,6 +171,9 @@ export class DiscordAdapter implements ChannelAdapter {
148
171
  this.token = options.token
149
172
  this.apiBase = options.apiBase ?? 'https://discord.com/api/v10'
150
173
  this.fetchImpl = options.fetchImpl ?? ((globalThis as { fetch?: FetchLike }).fetch as FetchLike)
174
+ this.reconnectBaseMs = options.reconnectBaseMs ?? 1000
175
+ this.reconnectMaxMs = options.reconnectMaxMs ?? 60_000
176
+ this.random = options.random ?? Math.random
151
177
  const globalWS = (globalThis as { WebSocket?: { new (url: string): WSLike } }).WebSocket
152
178
  this.wsFactory =
153
179
  options.WebSocketImpl ??
@@ -192,33 +218,83 @@ export class DiscordAdapter implements ChannelAdapter {
192
218
  // —— Gateway ——————————————————————————————————————————
193
219
 
194
220
  async connect(signal?: AbortSignal): Promise<void> {
221
+ this.shuttingDown = false
222
+ // 上层显式 connect(`AccountManager.ensureConnected` 在任何操作前都会调):
223
+ // 由它接管重连。若不先撤内部排程并拆旧 socket,会同时存在两条 Gateway 连接
224
+ // (Discord 视为两个 session,入站可能只投其中一条)。
225
+ this.cancelScheduled()
226
+ this.attempts = 0
227
+ const prev = this.ws
228
+ if (prev !== undefined) {
229
+ this.ws = undefined // 先摘引用:旧 socket 迟到的 close/message 不再参与状态判定
230
+ this.stopHeartbeat()
231
+ try {
232
+ prev.close()
233
+ } catch {
234
+ // ignore:旧连接关不上也不能阻塞新连接建立。
235
+ }
236
+ }
195
237
  const me = (await this.rest('GET', '/users/@me', undefined, signal)) as { id?: string }
196
238
  this.botUserId = String(me.id ?? '')
197
239
  const gateway = (await this.rest('GET', '/gateway', undefined, signal)) as { url?: string }
198
240
  const url = String(gateway.url ?? 'wss://gateway.discord.gg/?v=10&encoding=json')
199
- const wsUrl = url.includes('?') ? `${url}&encoding=json` : `${url}/?v=10&encoding=json`
241
+ this.gatewayUrl = url.includes('?') ? `${url}&encoding=json` : `${url}/?v=10&encoding=json`
242
+ this.openGateway()
243
+ }
244
+
245
+ /**
246
+ * 建一条 Gateway 连接。重连不重跑 `connect()` 的 REST 面:
247
+ * 能续传时用 READY 给的 `resume_gateway_url`,否则回退初次取定的 `gatewayUrl`。
248
+ */
249
+ private openGateway(): void {
250
+ if (this.shuttingDown) return
251
+ const wsUrl = this.resumeUrl !== '' ? this.resumeUrl : this.gatewayUrl
200
252
  const ws = this.wsFactory(wsUrl)
201
253
  this.ws = ws
202
- ws.addEventListener('message', (event: { data: string }) => this.handleGatewayFrame(String(event.data)))
254
+ this.awaitingAck = false
255
+ // 两个处理器都先比 `this.ws !== ws`:被 connect()/重连替换过的旧 socket,
256
+ // 其迟到帧与 close 一律不得再改当前状态(否则会误判断开、叠排重连)。
257
+ ws.addEventListener('message', (event: { data: string }) => {
258
+ if (this.ws !== ws) return
259
+ this.handleGatewayFrame(String(event.data))
260
+ })
203
261
  ws.addEventListener('close', () => {
262
+ if (this.ws !== ws) return
204
263
  this.connected = false
205
264
  this.stopHeartbeat()
206
- if (!this.closeController.signal.aborted) this.log('discord Gateway 断开(上层重连策略归 T2.5)')
265
+ // 主动拆线(disconnect / 宿主 abort / connect 接管)不重连;其余一律进退避排程。
266
+ if (this.shuttingDown || this.closeController.signal.aborted) return
267
+ this.scheduleReconnect('断开')
207
268
  })
208
269
  ws.addEventListener('error', () => {
270
+ if (this.ws !== ws) return
209
271
  this.connected = false
210
272
  })
211
273
  }
212
274
 
275
+ /** 取消待执行的重连排程(单一收口,避免各处 clearTimeout 写法走样)。 */
276
+ private cancelScheduled(): void {
277
+ if (this.reconnectTimer !== undefined) {
278
+ clearTimeout(this.reconnectTimer)
279
+ this.reconnectTimer = undefined
280
+ }
281
+ }
282
+
213
283
  async disconnect(): Promise<void> {
284
+ this.shuttingDown = true
214
285
  this.stopHeartbeat()
286
+ this.cancelScheduled()
287
+ this.sessionId = ''
288
+ this.resumeUrl = ''
289
+ this.attempts = 0
215
290
  this.closeController.abort()
291
+ const ws = this.ws
292
+ this.ws = undefined
216
293
  try {
217
- this.ws?.close()
294
+ ws?.close()
218
295
  } catch {
219
296
  // 已断开即目标达成。
220
297
  }
221
- this.ws = undefined
222
298
  this.connected = false
223
299
  }
224
300
 
@@ -236,31 +312,58 @@ export class DiscordAdapter implements ChannelAdapter {
236
312
  const intervalMs = Number(frame.d?.heartbeat_interval ?? 41250)
237
313
  this.stopHeartbeat()
238
314
  this.heartbeat = setInterval(() => this.sendHeartbeat(), intervalMs)
239
- this.sendIdentify()
315
+ // 有 session 就续传(不丢断口期间的事件、不重拉全量);否则完整 IDENTIFY。
316
+ if (this.canResume()) this.sendResume()
317
+ else this.sendIdentify()
240
318
  return
241
319
  }
242
320
  case OP.HEARTBEAT_ACK:
321
+ this.awaitingAck = false
243
322
  return
244
323
  case OP.RECONNECT:
245
- this.log('discord Gateway 要求重连(当前实现退IDENTIFY重建)')
324
+ // 服务端定期要求重连:先落排程,再关连接(close 处理器看到已有排程就不叠加)。
325
+ this.scheduleReconnect('收到 op7 RECONNECT')
246
326
  try {
247
327
  this.ws?.close()
248
328
  } catch {
249
- // ignore
329
+ // ignore:排程已落,关失败也不影响下一轮。
250
330
  }
251
331
  return
252
332
  case OP.INVALID_SESSION: {
253
333
  this.connected = false
254
334
  this.stopHeartbeat()
255
- if (frame.d === true) {
256
- this.log('discord 会话可续(RESUME 归后续迭代),当前直接重 IDENTIFY')
257
- }
258
- this.sendIdentify()
335
+ // d=false 意味会话已确定没了:不清 session 的话,下一条 HELLO 会误走 RESUME 反复碰壁。
336
+ if (frame.d !== true) this.sessionId = ''
337
+ // 官方要求收到 op9 后等 1–5s 再动作;走同一退避位,不另并一套定时器,
338
+ // 也避免「op9 d=true → 同连接立即 RESUME → 再被拒」的热循环。
339
+ const useResume = this.canResume()
340
+ this.scheduleAttempt(`会话失效(op9 d=${String(frame.d)})`, () => {
341
+ if (useResume) this.sendResume()
342
+ else this.sendIdentify()
343
+ })
259
344
  return
260
345
  }
261
346
  case OP.DISPATCH: {
262
347
  if (frame.t === 'READY') {
263
348
  this.connected = true
349
+ this.attempts = 0
350
+ this.awaitingAck = false
351
+ this.sessionId = String(frame.d?.session_id ?? '')
352
+ const resume = frame.d?.resume_gateway_url
353
+ if (typeof resume === 'string' && resume !== '') this.resumeUrl = resume
354
+ this.log(
355
+ this.sessionId !== '' && this.resumeUrl !== ''
356
+ ? 'discord Gateway 就绪(session 已建立,后续断开可 RESUME 续传)'
357
+ : 'discord Gateway 就绪(未拿到 resume 信息,后续断开将重 IDENTIFY)',
358
+ )
359
+ return
360
+ }
361
+ if (frame.t === 'RESUMED') {
362
+ const recovered = this.attempts
363
+ this.connected = true
364
+ this.attempts = 0
365
+ this.awaitingAck = false
366
+ this.log(`discord Gateway RESUME 成功(断口期间事件已续传${recovered > 1 ? `,第 ${recovered} 次尝试` : ''})`)
264
367
  return
265
368
  }
266
369
  if (frame.t === 'MESSAGE_CREATE') {
@@ -297,7 +400,74 @@ export class DiscordAdapter implements ChannelAdapter {
297
400
  })
298
401
  }
299
402
 
403
+ /** 可续传:READY 同时给了 session_id 与 resume_gateway_url 才成立(缺一必须重 IDENTIFY)。 */
404
+ private canResume(): boolean {
405
+ return this.sessionId !== '' && this.resumeUrl !== ''
406
+ }
407
+
408
+ private sendResume(): void {
409
+ this.sendFrame({
410
+ op: OP.RESUME,
411
+ d: { token: this.token, session_id: this.sessionId, seq: this.lastSeq ?? 0 },
412
+ })
413
+ }
414
+
415
+ /** 指数退避 + 正向抖动(最多 +30%,避免全量客户端同刻共击):base·2^(n-1) 封顶 maxMs。 */
416
+ private backoffMs(attempt: number): number {
417
+ const base = Math.min(this.reconnectBaseMs * 2 ** Math.max(0, attempt - 1), this.reconnectMaxMs)
418
+ return Math.round(base * (1 + this.random() * 0.3))
419
+ }
420
+
421
+ /**
422
+ * 唯一的退避排程原语。单一 `reconnectTimer` 位就是并发守卫:close、op7、op9 多源
423
+ * 同时触发时只保留第一个排程(不叠加、不互相覆盖、不泄漏旧定时器)。
424
+ * 不主动放弃:只把等待封顶在 `reconnectMaxMs`,IM 账号静默失联比慢重连更糟。
425
+ */
426
+ private scheduleAttempt(reason: string, action: () => void): void {
427
+ if (this.shuttingDown) return
428
+ if (this.reconnectTimer !== undefined) {
429
+ this.log(`discord ${reason}:已有重连排程在队,不叠加`)
430
+ return
431
+ }
432
+ const attempt = ++this.attempts
433
+ const wait = this.backoffMs(attempt)
434
+ this.log(`discord ${reason},${wait}ms 后第 ${attempt} 次尝试`)
435
+ this.reconnectTimer = setTimeout(() => {
436
+ this.reconnectTimer = undefined
437
+ if (this.shuttingDown) return
438
+ try {
439
+ action()
440
+ } catch (error) {
441
+ // 建连/发帧本身抛(如宿主缺 WebSocket):转下一轮退避,不把异常冒到事件循环外。
442
+ this.log(`discord 重连尝试失败: ${(error as Error).message}`)
443
+ this.scheduleAttempt('重连尝试失败', action)
444
+ }
445
+ }, wait)
446
+ }
447
+
448
+ /** 重连一条新连接:不重跑 `connect()` 的 REST 面,地址用已取定的 resume/gateway 位。 */
449
+ private scheduleReconnect(reason: string): void {
450
+ this.scheduleAttempt(`Gateway ${reason}`, () => {
451
+ this.openGateway()
452
+ })
453
+ }
454
+
300
455
  private sendHeartbeat(): void {
456
+ // 上一拍还没 ACK 就到了下一拍 = 僵尸连接(TCP 活着但服务端已不响应),
457
+ // 官方推荐做法是主动断开重建;close 处理器会接手排重连。
458
+ if (this.awaitingAck) {
459
+ this.awaitingAck = false
460
+ this.stopHeartbeat()
461
+ this.log('discord 心跳无 ACK,判定僵尸连接并主动断开重连')
462
+ try {
463
+ this.ws?.close()
464
+ } catch {
465
+ // close 处理器不可依赖时,手动补一排程。
466
+ this.scheduleReconnect('僵尸连接断开失败')
467
+ }
468
+ return
469
+ }
470
+ this.awaitingAck = true
301
471
  this.sendFrame({ op: OP.HEARTBEAT, d: this.lastSeq ?? null })
302
472
  }
303
473