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 +40 -0
- package/dsh.plugin.json +1 -1
- package/lib/channels/discord.d.ts +45 -0
- package/lib/channels/discord.js +205 -17
- package/package.json +1 -1
- package/src/channels/discord.ts +206 -16
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.
|
|
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;
|
package/lib/channels/discord.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
MESSAGE_CONTENT: 1 <<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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",
|
package/src/channels/discord.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
MESSAGE_CONTENT: 1 <<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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({
|
|
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 {
|