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 +25 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dsh.plugin.json +2 -2
- package/lib/channels/discord.d.ts +43 -0
- package/lib/channels/discord.js +183 -13
- package/package.json +8 -8
- package/src/channels/discord.ts +182 -12
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` (
|
|
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 共
|
|
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.
|
|
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;
|
package/lib/channels/discord.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
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
|
},
|
package/src/channels/discord.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|