dsh-im-union 0.5.1 → 0.5.3

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,46 @@
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
+
30
+ ## 0.5.2(Discord 服务器频道消息失联:Gateway intents 位值修正)
31
+
32
+ 感谢 @lide4144 在 #1 里的报告与双连接对照实验,定位、根因与修正值全部准确。
33
+
34
+ ### Fixed
35
+
36
+ - **Gateway intents 位值写错导致服务器频道消息永远收不到**(#1):`GUILD_MESSAGES` 实写为 `1 << 15`(应为 `1 << 9`)、`MESSAGE_CONTENT` 实写为 `1 << 24`(应为 `1 << 15`,且 `1 << 24` 不是任何已定义 intent)。于是 IDENTIFY 实际请求的是 `MESSAGE_CONTENT | DIRECT_MESSAGES | 未定义位`,**从未订阅 GUILD_MESSAGES**——Discord 因此不推送服务器频道的 `MESSAGE_CREATE`;而握手/READY/心跳全部正常,连接看着是健康的,故极难发现。有有效值 `16814080 → 37377`。**不引入新的门户勾选要求**:`MESSAGE_CONTENT`(特权位)此前已被 `1 << 15` 误命名请求着,行为不变。
37
+ - **IDENTIFY 补齐 `GUILDS`(`1 << 0`)**:此前 `INTENTS.GUILDS` 定了却从未使用,招不到 `GUILD_CREATE`,部分场景(刚加入的服务器、缓存缺失的频道)拿不到上下文。
38
+ - **`createDiscordAdapter` 透传 `logger`**:`ChannelFactory` 契约早已有 `logger` 字段(宿主接线到 `plugin.log`),本工厂始终丢弃它,导致 Discord 适配器的全量生命周期日志(含“Gateway 断开”)不落盘——#1 里“`plugin.log` 没有任何入站记录”的次因。
39
+ - **测试不得拿实现值自证**:`tests/discord.test.mjs` 原本把错误位值 `(1<<15)|(1<<12)|(1<<24)` 钉进了断言,这是本缺陷能存活的原因。现改为按官方文档值逐位断言,并加“`1<<24` 必须为 0”的回归守卫;新增 logger 透传用例。全量 771/771 绿。
40
+
41
+ ### Known(本版本未动,另单跟踪)
42
+
43
+ - Gateway `close` 后无自动重连(仅记日志),op 7 RECONNECT 后靠重建;`AccountManager.ensureConnected` 只在操作时触发——Discord 定期下发 RECONNECT 时会静默失联直到手动重启。属代码内已标注的 **T2.5 挂账**(需退避策略 + RESUME/session 生命周期设计),与本次位值修正无关。
44
+
5
45
  ## 0.5.0(QQ 渠道能力补齐:出站图片 / Markdown / 入站图片文件 / 审批按钮 / 群管理)
6
46
 
7
47
  对照 `_ref-im/dsh-qqbot`(同一套 QQ 官方 api-v2)逐面解锁 `src/channels/qq.ts`,遵守净室 / 零运行时依赖 / D7 纪律。
package/dsh.plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "dsh-im-union",
3
3
  "name": "dsh-im-union",
4
- "version": "0.5.1",
4
+ "version": "0.5.3",
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)。"
@@ -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;
@@ -131,6 +174,8 @@ export declare function createDiscordAdapter(options: {
131
174
  accountId: string;
132
175
  config: Record<string, string>;
133
176
  secrets: Record<string, string | undefined>;
177
+ /** 生命周期日志出口(`ChannelFactory` 契约字段);不接则 Gateway 断开等日志静默。 */
178
+ logger?: (msg: string) => void;
134
179
  }): DiscordAdapter;
135
180
  interface DiscordMessageLike {
136
181
  channel_id?: string;
@@ -1,10 +1,23 @@
1
1
  import { FILE_BUDGET } from '../engine/files.js';
2
2
  import { mergeQuote } from '../engine/quote.js';
3
+ /**
4
+ * Gateway intents:位值逐条对齐官方 list-of-intents。
5
+ *
6
+ * 写错不会报错也不会断连——只是对应事件永远不推,连接看着完全健康,所以很难发现
7
+ * (历史上 `GUILD_MESSAGES` 被写成 `1 << 15`、`MESSAGE_CONTENT` 写成 `1 << 24`,
8
+ * 于是服务器频道消息永远收不到)。故每个常量带官方位号注释,并由
9
+ * `tests/discord.test.mjs` 按位钉值校验(断言写在测试里的必须是官方文档值,
10
+ * 不得拿实现值自证)。
11
+ */
3
12
  const INTENTS = {
13
+ /** `1 << 0` GUILDS:`GUILD_CREATE` 等(非特权)。 */
4
14
  GUILDS: 1 << 0,
15
+ /** `1 << 9` GUILD_MESSAGES:服务器频道 `MESSAGE_CREATE`(非特权)。 */
16
+ GUILD_MESSAGES: 1 << 9,
17
+ /** `1 << 12` DIRECT_MESSAGES:私聊 `MESSAGE_CREATE`(非特权)。 */
5
18
  DIRECT_MESSAGES: 1 << 12,
6
- GUILD_MESSAGES: 1 << 15,
7
- MESSAGE_CONTENT: 1 << 24,
19
+ /** `1 << 15` MESSAGE_CONTENT:读消息正文(**特权** intent,需在开发者门户勾选)。 */
20
+ MESSAGE_CONTENT: 1 << 15,
8
21
  };
9
22
  const OP = {
10
23
  DISPATCH: 0,
@@ -49,12 +62,29 @@ export class DiscordAdapter {
49
62
  fetchImpl;
50
63
  wsFactory;
51
64
  log;
65
+ reconnectBaseMs;
66
+ reconnectMaxMs;
67
+ random;
52
68
  listeners = [];
53
69
  ws;
54
70
  botUserId = '';
55
71
  lastSeq;
56
72
  heartbeat;
57
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;
58
88
  constructor(options) {
59
89
  if (typeof options.token !== 'string' || options.token === '') {
60
90
  throw new Error('discord: botToken 缺失(凭据库解析失败或未配置账号)');
@@ -62,6 +92,9 @@ export class DiscordAdapter {
62
92
  this.token = options.token;
63
93
  this.apiBase = options.apiBase ?? 'https://discord.com/api/v10';
64
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;
65
98
  const globalWS = globalThis.WebSocket;
66
99
  this.wsFactory =
67
100
  options.WebSocketImpl ??
@@ -105,34 +138,87 @@ export class DiscordAdapter {
105
138
  }
106
139
  // —— Gateway ——————————————————————————————————————————
107
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
+ }
108
158
  const me = (await this.rest('GET', '/users/@me', undefined, signal));
109
159
  this.botUserId = String(me.id ?? '');
110
160
  const gateway = (await this.rest('GET', '/gateway', undefined, signal));
111
161
  const url = String(gateway.url ?? 'wss://gateway.discord.gg/?v=10&encoding=json');
112
- 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;
113
173
  const ws = this.wsFactory(wsUrl);
114
174
  this.ws = ws;
115
- 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
+ });
116
183
  ws.addEventListener('close', () => {
184
+ if (this.ws !== ws)
185
+ return;
117
186
  this.connected = false;
118
187
  this.stopHeartbeat();
119
- if (!this.closeController.signal.aborted)
120
- this.log('discord Gateway 断开(上层重连策略归 T2.5)');
188
+ // 主动拆线(disconnect / 宿主 abort / connect 接管)不重连;其余一律进退避排程。
189
+ if (this.shuttingDown || this.closeController.signal.aborted)
190
+ return;
191
+ this.scheduleReconnect('断开');
121
192
  });
122
193
  ws.addEventListener('error', () => {
194
+ if (this.ws !== ws)
195
+ return;
123
196
  this.connected = false;
124
197
  });
125
198
  }
199
+ /** 取消待执行的重连排程(单一收口,避免各处 clearTimeout 写法走样)。 */
200
+ cancelScheduled() {
201
+ if (this.reconnectTimer !== undefined) {
202
+ clearTimeout(this.reconnectTimer);
203
+ this.reconnectTimer = undefined;
204
+ }
205
+ }
126
206
  async disconnect() {
207
+ this.shuttingDown = true;
127
208
  this.stopHeartbeat();
209
+ this.cancelScheduled();
210
+ this.sessionId = '';
211
+ this.resumeUrl = '';
212
+ this.attempts = 0;
128
213
  this.closeController.abort();
214
+ const ws = this.ws;
215
+ this.ws = undefined;
129
216
  try {
130
- this.ws?.close();
217
+ ws?.close();
131
218
  }
132
219
  catch {
133
220
  // 已断开即目标达成。
134
221
  }
135
- this.ws = undefined;
136
222
  this.connected = false;
137
223
  }
138
224
  handleGatewayFrame(raw) {
@@ -151,32 +237,63 @@ export class DiscordAdapter {
151
237
  const intervalMs = Number(frame.d?.heartbeat_interval ?? 41250);
152
238
  this.stopHeartbeat();
153
239
  this.heartbeat = setInterval(() => this.sendHeartbeat(), intervalMs);
154
- this.sendIdentify();
240
+ // 有 session 就续传(不丢断口期间的事件、不重拉全量);否则完整 IDENTIFY。
241
+ if (this.canResume())
242
+ this.sendResume();
243
+ else
244
+ this.sendIdentify();
155
245
  return;
156
246
  }
157
247
  case OP.HEARTBEAT_ACK:
248
+ this.awaitingAck = false;
158
249
  return;
159
250
  case OP.RECONNECT:
160
- this.log('discord Gateway 要求重连(当前实现退IDENTIFY重建)');
251
+ // 服务端定期要求重连:先落排程,再关连接(close 处理器看到已有排程就不叠加)。
252
+ this.scheduleReconnect('收到 op7 RECONNECT');
161
253
  try {
162
254
  this.ws?.close();
163
255
  }
164
256
  catch {
165
- // ignore
257
+ // ignore:排程已落,关失败也不影响下一轮。
166
258
  }
167
259
  return;
168
260
  case OP.INVALID_SESSION: {
169
261
  this.connected = false;
170
262
  this.stopHeartbeat();
171
- if (frame.d === true) {
172
- this.log('discord 会话可续(RESUME 归后续迭代),当前直接重 IDENTIFY');
173
- }
174
- 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
+ });
175
275
  return;
176
276
  }
177
277
  case OP.DISPATCH: {
178
278
  if (frame.t === 'READY') {
179
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} 次尝试` : ''})`);
180
297
  return;
181
298
  }
182
299
  if (frame.t === 'MESSAGE_CREATE') {
@@ -204,12 +321,79 @@ export class DiscordAdapter {
204
321
  op: OP.IDENTIFY,
205
322
  d: {
206
323
  token: this.token,
207
- intents: INTENTS.GUILD_MESSAGES | INTENTS.DIRECT_MESSAGES | INTENTS.MESSAGE_CONTENT,
324
+ // 含 GUILDS:否则无 GUILD_CREATE,部分场景(刚加入的服务器、缓存缺失的频道)拿不到上下文。
325
+ intents: INTENTS.GUILDS | INTENTS.GUILD_MESSAGES | INTENTS.DIRECT_MESSAGES | INTENTS.MESSAGE_CONTENT,
208
326
  properties: { os: 'dsh-plugin', browser: 'dsh-im-union', device: 'dsh-im-union' },
209
327
  },
210
328
  });
211
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
+ }
212
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;
213
397
  this.sendFrame({ op: OP.HEARTBEAT, d: this.lastSeq ?? null });
214
398
  }
215
399
  stopHeartbeat() {
@@ -549,5 +733,9 @@ export function createDiscordAdapter(options) {
549
733
  throw new Error(`discord 账号 ${options.accountId}: botToken 未入库(检查凭据配置)`);
550
734
  }
551
735
  const apiBase = options.config.apiBaseUrl;
552
- return new DiscordAdapter({ token, ...(apiBase !== undefined && apiBase !== '' ? { apiBase } : {}) });
736
+ return new DiscordAdapter({
737
+ token,
738
+ ...(apiBase !== undefined && apiBase !== '' ? { apiBase } : {}),
739
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
740
+ });
553
741
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-im-union",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -39,11 +39,24 @@ import type {
39
39
  SemanticMessage,
40
40
  } from './types.js'
41
41
 
42
+ /**
43
+ * Gateway intents:位值逐条对齐官方 list-of-intents。
44
+ *
45
+ * 写错不会报错也不会断连——只是对应事件永远不推,连接看着完全健康,所以很难发现
46
+ * (历史上 `GUILD_MESSAGES` 被写成 `1 << 15`、`MESSAGE_CONTENT` 写成 `1 << 24`,
47
+ * 于是服务器频道消息永远收不到)。故每个常量带官方位号注释,并由
48
+ * `tests/discord.test.mjs` 按位钉值校验(断言写在测试里的必须是官方文档值,
49
+ * 不得拿实现值自证)。
50
+ */
42
51
  const INTENTS = {
52
+ /** `1 << 0` GUILDS:`GUILD_CREATE` 等(非特权)。 */
43
53
  GUILDS: 1 << 0,
54
+ /** `1 << 9` GUILD_MESSAGES:服务器频道 `MESSAGE_CREATE`(非特权)。 */
55
+ GUILD_MESSAGES: 1 << 9,
56
+ /** `1 << 12` DIRECT_MESSAGES:私聊 `MESSAGE_CREATE`(非特权)。 */
44
57
  DIRECT_MESSAGES: 1 << 12,
45
- GUILD_MESSAGES: 1 << 15,
46
- MESSAGE_CONTENT: 1 << 24,
58
+ /** `1 << 15` MESSAGE_CONTENT:读消息正文(**特权** intent,需在开发者门户勾选)。 */
59
+ MESSAGE_CONTENT: 1 << 15,
47
60
  } as const
48
61
 
49
62
  const OP = {
@@ -86,6 +99,12 @@ export interface DiscordAdapterOptions {
86
99
  fetchImpl?: FetchLike
87
100
  WebSocketImpl?: WSFactory
88
101
  logger?: (msg: string) => void
102
+ /** 重连退避基准(默认 1000ms),按 2^n 递增到 `reconnectMaxMs`;测试可压小。 */
103
+ reconnectBaseMs?: number
104
+ /** 重连退避上限(默认 60000ms):不放弃重试,只封顶等待。 */
105
+ reconnectMaxMs?: number
106
+ /** 抖动源(默认 `Math.random`):测试注定值以确定性断言等待时长。 */
107
+ random?: () => number
89
108
  }
90
109
 
91
110
  export class DiscordApiError extends Error {
@@ -121,12 +140,29 @@ export class DiscordAdapter implements ChannelAdapter {
121
140
  private readonly fetchImpl: FetchLike
122
141
  private readonly wsFactory: WSFactory
123
142
  private readonly log: (msg: string) => void
143
+ private readonly reconnectBaseMs: number
144
+ private readonly reconnectMaxMs: number
145
+ private readonly random: () => number
124
146
  private listeners: MessageListener[] = []
125
147
  private ws: WSLike | undefined
126
148
  private botUserId = ''
127
149
  private lastSeq: number | undefined
128
150
  private heartbeat: ReturnType<typeof setInterval> | undefined
129
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
130
166
 
131
167
  constructor(options: DiscordAdapterOptions) {
132
168
  if (typeof options.token !== 'string' || options.token === '') {
@@ -135,6 +171,9 @@ export class DiscordAdapter implements ChannelAdapter {
135
171
  this.token = options.token
136
172
  this.apiBase = options.apiBase ?? 'https://discord.com/api/v10'
137
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
138
177
  const globalWS = (globalThis as { WebSocket?: { new (url: string): WSLike } }).WebSocket
139
178
  this.wsFactory =
140
179
  options.WebSocketImpl ??
@@ -179,33 +218,83 @@ export class DiscordAdapter implements ChannelAdapter {
179
218
  // —— Gateway ——————————————————————————————————————————
180
219
 
181
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
+ }
182
237
  const me = (await this.rest('GET', '/users/@me', undefined, signal)) as { id?: string }
183
238
  this.botUserId = String(me.id ?? '')
184
239
  const gateway = (await this.rest('GET', '/gateway', undefined, signal)) as { url?: string }
185
240
  const url = String(gateway.url ?? 'wss://gateway.discord.gg/?v=10&encoding=json')
186
- 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
187
252
  const ws = this.wsFactory(wsUrl)
188
253
  this.ws = ws
189
- 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
+ })
190
261
  ws.addEventListener('close', () => {
262
+ if (this.ws !== ws) return
191
263
  this.connected = false
192
264
  this.stopHeartbeat()
193
- 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('断开')
194
268
  })
195
269
  ws.addEventListener('error', () => {
270
+ if (this.ws !== ws) return
196
271
  this.connected = false
197
272
  })
198
273
  }
199
274
 
275
+ /** 取消待执行的重连排程(单一收口,避免各处 clearTimeout 写法走样)。 */
276
+ private cancelScheduled(): void {
277
+ if (this.reconnectTimer !== undefined) {
278
+ clearTimeout(this.reconnectTimer)
279
+ this.reconnectTimer = undefined
280
+ }
281
+ }
282
+
200
283
  async disconnect(): Promise<void> {
284
+ this.shuttingDown = true
201
285
  this.stopHeartbeat()
286
+ this.cancelScheduled()
287
+ this.sessionId = ''
288
+ this.resumeUrl = ''
289
+ this.attempts = 0
202
290
  this.closeController.abort()
291
+ const ws = this.ws
292
+ this.ws = undefined
203
293
  try {
204
- this.ws?.close()
294
+ ws?.close()
205
295
  } catch {
206
296
  // 已断开即目标达成。
207
297
  }
208
- this.ws = undefined
209
298
  this.connected = false
210
299
  }
211
300
 
@@ -223,31 +312,58 @@ export class DiscordAdapter implements ChannelAdapter {
223
312
  const intervalMs = Number(frame.d?.heartbeat_interval ?? 41250)
224
313
  this.stopHeartbeat()
225
314
  this.heartbeat = setInterval(() => this.sendHeartbeat(), intervalMs)
226
- this.sendIdentify()
315
+ // 有 session 就续传(不丢断口期间的事件、不重拉全量);否则完整 IDENTIFY。
316
+ if (this.canResume()) this.sendResume()
317
+ else this.sendIdentify()
227
318
  return
228
319
  }
229
320
  case OP.HEARTBEAT_ACK:
321
+ this.awaitingAck = false
230
322
  return
231
323
  case OP.RECONNECT:
232
- this.log('discord Gateway 要求重连(当前实现退IDENTIFY重建)')
324
+ // 服务端定期要求重连:先落排程,再关连接(close 处理器看到已有排程就不叠加)。
325
+ this.scheduleReconnect('收到 op7 RECONNECT')
233
326
  try {
234
327
  this.ws?.close()
235
328
  } catch {
236
- // ignore
329
+ // ignore:排程已落,关失败也不影响下一轮。
237
330
  }
238
331
  return
239
332
  case OP.INVALID_SESSION: {
240
333
  this.connected = false
241
334
  this.stopHeartbeat()
242
- if (frame.d === true) {
243
- this.log('discord 会话可续(RESUME 归后续迭代),当前直接重 IDENTIFY')
244
- }
245
- 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
+ })
246
344
  return
247
345
  }
248
346
  case OP.DISPATCH: {
249
347
  if (frame.t === 'READY') {
250
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} 次尝试` : ''})`)
251
367
  return
252
368
  }
253
369
  if (frame.t === 'MESSAGE_CREATE') {
@@ -277,13 +393,81 @@ export class DiscordAdapter implements ChannelAdapter {
277
393
  op: OP.IDENTIFY,
278
394
  d: {
279
395
  token: this.token,
280
- intents: INTENTS.GUILD_MESSAGES | INTENTS.DIRECT_MESSAGES | INTENTS.MESSAGE_CONTENT,
396
+ // 含 GUILDS:否则无 GUILD_CREATE,部分场景(刚加入的服务器、缓存缺失的频道)拿不到上下文。
397
+ intents: INTENTS.GUILDS | INTENTS.GUILD_MESSAGES | INTENTS.DIRECT_MESSAGES | INTENTS.MESSAGE_CONTENT,
281
398
  properties: { os: 'dsh-plugin', browser: 'dsh-im-union', device: 'dsh-im-union' },
282
399
  },
283
400
  })
284
401
  }
285
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
+
286
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
287
471
  this.sendFrame({ op: OP.HEARTBEAT, d: this.lastSeq ?? null })
288
472
  }
289
473
 
@@ -647,13 +831,19 @@ export function createDiscordAdapter(options: {
647
831
  accountId: string
648
832
  config: Record<string, string>
649
833
  secrets: Record<string, string | undefined>
834
+ /** 生命周期日志出口(`ChannelFactory` 契约字段);不接则 Gateway 断开等日志静默。 */
835
+ logger?: (msg: string) => void
650
836
  }): DiscordAdapter {
651
837
  const token = options.secrets.botToken
652
838
  if (typeof token !== 'string' || token === '') {
653
839
  throw new Error(`discord 账号 ${options.accountId}: botToken 未入库(检查凭据配置)`)
654
840
  }
655
841
  const apiBase = options.config.apiBaseUrl
656
- return new DiscordAdapter({ token, ...(apiBase !== undefined && apiBase !== '' ? { apiBase } : {}) })
842
+ return new DiscordAdapter({
843
+ token,
844
+ ...(apiBase !== undefined && apiBase !== '' ? { apiBase } : {}),
845
+ ...(options.logger !== undefined ? { logger: options.logger } : {}),
846
+ })
657
847
  }
658
848
 
659
849
  interface DiscordMessageLike {