@onlyne/im-qq 0.0.0-stage → 3.1.0
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/LICENSE +21 -0
- package/README.md +58 -2
- package/package.json +33 -4
- package/src/gateway.ts +546 -0
- package/src/index.ts +395 -0
- package/src/normalize.ts +207 -0
- package/src/port.ts +242 -0
- package/src/send.ts +121 -0
- package/src/webhook.ts +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dbydd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @onlyne/im-qq
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The QQ connector for the Onlyne IM gateway. It implements translation for the official QQ Bot
|
|
4
|
+
OpenAPI v2: it authenticates with an app id and secret, receives events on the platform's
|
|
5
|
+
WebSocket gateway (or on the signed callback), normalizes inbound messages, and sends text,
|
|
6
|
+
Markdown, and quote replies.
|
|
7
|
+
|
|
8
|
+
## Credential
|
|
9
|
+
|
|
10
|
+
The constructor accepts the credential in this order: an explicit `app_id:app_secret` value, a
|
|
11
|
+
credential file, then the pair `QQ_BOT_APP_ID` and `QQ_BOT_APP_SECRET`.
|
|
12
|
+
|
|
13
|
+
## Transport
|
|
14
|
+
|
|
15
|
+
`transport: 'websocket'` is the default: QQ's own receive path. The connector fetches the access
|
|
16
|
+
token, asks `GET /gateway/bot` for the shard count and the session-start budget, and opens one
|
|
17
|
+
socket per shard. Each socket runs Hello → Identify → Ready, heartbeats on the negotiated interval
|
|
18
|
+
and treats a missing `op 11` acknowledgement as a dead connection, and resumes with `op 6` from the
|
|
19
|
+
last sequence number a listener accepted — a delivery that failed is replayed, never skipped.
|
|
20
|
+
Reconnects back off with jitter, and `session_start_limit` gates how many sessions start at once
|
|
21
|
+
and waits out `reset_after` instead of hammering the gateway.
|
|
22
|
+
|
|
23
|
+
`transport: 'webhook'` serves the signed HTTP callback instead: `handleWebhook` answers the
|
|
24
|
+
platform's challenge, verifies `X-Signature-Ed25519` over the timestamp and raw body, and
|
|
25
|
+
acknowledges accepted event batches. The gateway serves it at `/im/webhook` by default.
|
|
26
|
+
|
|
27
|
+
Outbound calls take the OpenAPI HTTP path under either transport.
|
|
28
|
+
|
|
29
|
+
## Supported
|
|
30
|
+
|
|
31
|
+
| Direction | Capability |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Inbound | Text, quoted text, voice transcript, ARK prompt, attachment metadata |
|
|
34
|
+
| Outbound | Text, Markdown, passive reply, quote reply, retract, typing in direct messages |
|
|
35
|
+
|
|
36
|
+
Group typing is refused by the platform. Media upload, cards, and guild events are not
|
|
37
|
+
implemented. The connector declares only `recall` and `typing` beyond `send`, so another
|
|
38
|
+
operation is refused before an operation id is claimed.
|
|
39
|
+
|
|
40
|
+
## Example
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createQqConnector } from '@onlyne/im-qq';
|
|
44
|
+
|
|
45
|
+
const connector = createQqConnector({ credentialFile: './credentials/qq' });
|
|
46
|
+
await connector.start();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The credential file may reference `$NAME` variables. A missing credential leaves the connector
|
|
50
|
+
in onboarding state and reports the variables it needs.
|
|
51
|
+
|
|
52
|
+
## Sources
|
|
53
|
+
|
|
54
|
+
- QQ Bot OpenAPI: <https://bot.q.qq.com/wiki/develop/api-v2/>
|
|
55
|
+
- Webhook signatures: <https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/sign.html>
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlyne/im-qq",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "QQ official Bot OpenAPI v2 connector for the Onlyne IM gateway: gateway WebSocket or webhook events, text and markdown messages, recall and typing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/dbydd/onlyne.git",
|
|
12
|
+
"directory": "packages/im-qq"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/dbydd/onlyne#readme",
|
|
15
|
+
"bugs": "https://github.com/dbydd/onlyne/issues",
|
|
16
|
+
"keywords": ["onlyne", "qq", "bot", "im", "connector"],
|
|
17
|
+
"publishConfig": { "access": "public" },
|
|
18
|
+
"engines": { "bun": ">=1.4.3" },
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"files": ["src", "LICENSE"],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"check": "tsc --noEmit",
|
|
25
|
+
"test": "bun tests/verify.ts && bun tests/send.ts && bun tests/webhook.ts && bun tests/gateway.ts"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@onlyne/im-core": "^3.1.0",
|
|
29
|
+
"@onlyne/lib": "^3.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "^1.3.0",
|
|
33
|
+
"typescript": "^5.9.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The QQ Bot gateway: QQ's own receive path, one WebSocket per shard. Events arrive as `op 0`
|
|
3
|
+
* dispatches; the access token, `/gateway/bot` and every outbound call stay on the HTTP port.
|
|
4
|
+
*
|
|
5
|
+
* The sequence number advances only after the listener accepted an event, so a delivery that
|
|
6
|
+
* failed is replayed by the platform on the next resume instead of being skipped.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { QqPort } from './port.js';
|
|
10
|
+
|
|
11
|
+
/** `idle` before `start`, `connecting` while a socket is being established. */
|
|
12
|
+
export type QqGatewayState = 'idle' | 'connecting' | 'online' | 'resuming' | 'paused' | 'stopped';
|
|
13
|
+
|
|
14
|
+
/** Group and c2c messages (bit 25) plus interaction callbacks (bit 26): what this connector translates. */
|
|
15
|
+
export const QQ_INTENTS = (1 << 25) | (1 << 26);
|
|
16
|
+
|
|
17
|
+
/** QQ's `session_start_limit`: how many sessions may be opened, and how fast. */
|
|
18
|
+
export interface QqSessionStartLimit {
|
|
19
|
+
/** Sessions that may be started at the same time. */
|
|
20
|
+
max_concurrency: number;
|
|
21
|
+
/** Sessions left in the current window. */
|
|
22
|
+
remaining: number;
|
|
23
|
+
/** Milliseconds until `remaining` resets. */
|
|
24
|
+
reset_after: number;
|
|
25
|
+
/** Sessions a full window allows. */
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface QqGatewayStatus {
|
|
30
|
+
state: QqGatewayState;
|
|
31
|
+
/** Connections opened after the first one. */
|
|
32
|
+
reconnects: number;
|
|
33
|
+
/** Heartbeats that went unanswered; each one closed its socket. */
|
|
34
|
+
heartbeat_timeouts: number;
|
|
35
|
+
/** Sessions continued by `op 6` after a drop. */
|
|
36
|
+
resumes: number;
|
|
37
|
+
/** Resume attempts the platform rejected, each followed by a fresh Identify. */
|
|
38
|
+
resume_failures: number;
|
|
39
|
+
shards: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface QqGatewayHandlers {
|
|
43
|
+
/**
|
|
44
|
+
* One `op 0` dispatch. The sequence number advances only after this resolves, so a rejection
|
|
45
|
+
* is redelivered after the next resume.
|
|
46
|
+
*/
|
|
47
|
+
dispatch(payload: Record<string, unknown>): void | Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface QqGatewayOptions {
|
|
51
|
+
/** The HTTP boundary: the access token and `/gateway/bot`. */
|
|
52
|
+
port: QqPort;
|
|
53
|
+
/** The intents bitmask; defaults to `QQ_INTENTS`. */
|
|
54
|
+
intents?: number;
|
|
55
|
+
log?: (message: string, fields?: Record<string, unknown>) => void;
|
|
56
|
+
/**
|
|
57
|
+
* One call per state transition, with the shard, session and attempt the transition knew.
|
|
58
|
+
* A gateway whose host owns the log reports here instead of writing its own line.
|
|
59
|
+
*/
|
|
60
|
+
onState?: (to: QqGatewayState, fields: Record<string, unknown>) => void;
|
|
61
|
+
now?: () => number;
|
|
62
|
+
/** First reconnect waits this long before the exponential starts; 0 reconnects at once. */
|
|
63
|
+
reconnectBaseMs?: number;
|
|
64
|
+
/** The backoff ceiling. */
|
|
65
|
+
reconnectCapMs?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface QqGateway {
|
|
69
|
+
start(handlers: QqGatewayHandlers): Promise<void>;
|
|
70
|
+
stop(): Promise<void>;
|
|
71
|
+
status(): QqGatewayStatus;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DEFAULT_RECONNECT_BASE_MS = 1_000;
|
|
75
|
+
const DEFAULT_RECONNECT_CAP_MS = 30_000;
|
|
76
|
+
/** A socket that has not opened by then is dead; QQ's own clients give up at a similar point. */
|
|
77
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
78
|
+
/** How often a paused session start re-checks its budget. */
|
|
79
|
+
const PAUSED_POLL_MS = 250;
|
|
80
|
+
/** The Hello interval when the platform omits it; QQ documents 41 250 ms. */
|
|
81
|
+
const DEFAULT_HEARTBEAT_MS = 41_250;
|
|
82
|
+
/** `session_start_limit.reset_after` of 0 would pause forever, so a missing window is a second. */
|
|
83
|
+
const DEFAULT_LIMIT_WINDOW_MS = 1_000;
|
|
84
|
+
|
|
85
|
+
interface GatewayInfo {
|
|
86
|
+
url: string;
|
|
87
|
+
shards: number;
|
|
88
|
+
limit: QqSessionStartLimit;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One shard's session, kept across reconnects so a resume can name it. */
|
|
92
|
+
interface Shard {
|
|
93
|
+
index: number;
|
|
94
|
+
sessionId?: string;
|
|
95
|
+
/** The last sequence number the listener accepted. */
|
|
96
|
+
seq: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
100
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
101
|
+
? value as Record<string, unknown>
|
|
102
|
+
: undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function numberOr(value: unknown, fallback: number): number {
|
|
106
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function detailOf(error: unknown): string {
|
|
110
|
+
return error instanceof Error ? error.message : String(error);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createQqGateway(options: QqGatewayOptions): QqGateway {
|
|
114
|
+
const port = options.port;
|
|
115
|
+
const log = options.log ?? (() => {});
|
|
116
|
+
const onState = options.onState;
|
|
117
|
+
const now = options.now ?? Date.now;
|
|
118
|
+
const intents = options.intents ?? QQ_INTENTS;
|
|
119
|
+
const baseMs = options.reconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS;
|
|
120
|
+
const capMs = options.reconnectCapMs ?? DEFAULT_RECONNECT_CAP_MS;
|
|
121
|
+
|
|
122
|
+
let state: QqGatewayState = 'idle';
|
|
123
|
+
let stopping = false;
|
|
124
|
+
let handlers: QqGatewayHandlers | undefined;
|
|
125
|
+
let shardCount = 1;
|
|
126
|
+
let reconnects = 0;
|
|
127
|
+
let heartbeatTimeouts = 0;
|
|
128
|
+
let resumes = 0;
|
|
129
|
+
let resumeFailures = 0;
|
|
130
|
+
const loops: Promise<void>[] = [];
|
|
131
|
+
const sockets = new Set<WebSocket>();
|
|
132
|
+
const sleepers = new Set<() => void>();
|
|
133
|
+
|
|
134
|
+
// `/gateway/bot` is fetched once and reused: QQ rate-limits it, and a reconnect storm must not
|
|
135
|
+
// turn into a discovery storm.
|
|
136
|
+
let discovered: GatewayInfo | undefined;
|
|
137
|
+
let discovering: Promise<GatewayInfo> | undefined;
|
|
138
|
+
|
|
139
|
+
let limit: QqSessionStartLimit = { max_concurrency: 1, remaining: 1, reset_after: 0, total: 1 };
|
|
140
|
+
let windowEndsAt = 0;
|
|
141
|
+
let starting = 0;
|
|
142
|
+
|
|
143
|
+
function transition(next: QqGatewayState, fields: Record<string, unknown> = {}): void {
|
|
144
|
+
if (state === next) return;
|
|
145
|
+
const from = state;
|
|
146
|
+
state = next;
|
|
147
|
+
if (onState !== undefined) onState(next, fields);
|
|
148
|
+
else log('connector.state', { platform: 'qq', from, to: next, ...fields });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Sleeps, but a stop wakes it: `stop` must not wait out a 30 s backoff. */
|
|
152
|
+
function pause(ms: number): Promise<void> {
|
|
153
|
+
if (stopping || ms <= 0) return Promise.resolve();
|
|
154
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
155
|
+
const wake = (): void => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
sleepers.delete(wake);
|
|
158
|
+
resolve();
|
|
159
|
+
};
|
|
160
|
+
const timer = setTimeout(wake, ms);
|
|
161
|
+
sleepers.add(wake);
|
|
162
|
+
return promise;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The first reconnect is immediate, then doubling with ±20% jitter so shards do not sync up. */
|
|
166
|
+
function backoff(attempt: number): number {
|
|
167
|
+
if (attempt <= 0) return 0;
|
|
168
|
+
const exponential = Math.min(capMs, baseMs * 2 ** (attempt - 1));
|
|
169
|
+
return Math.round(exponential * (0.8 + Math.random() * 0.4));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchInfo(): Promise<GatewayInfo> {
|
|
173
|
+
const body = recordOf(await port.request<unknown>('GET', '/gateway/bot'));
|
|
174
|
+
const url = typeof body?.url === 'string' && body.url.length > 0 ? body.url : undefined;
|
|
175
|
+
if (url === undefined) throw new Error('The QQ /gateway/bot answer carries no url.');
|
|
176
|
+
const raw = recordOf(body?.session_start_limit) ?? {};
|
|
177
|
+
return {
|
|
178
|
+
url,
|
|
179
|
+
shards: Math.max(1, Math.floor(numberOr(body?.shards, 1))),
|
|
180
|
+
limit: {
|
|
181
|
+
max_concurrency: Math.max(1, Math.floor(numberOr(raw.max_concurrency, 1))),
|
|
182
|
+
remaining: Math.max(0, Math.floor(numberOr(raw.remaining, 1))),
|
|
183
|
+
reset_after: Math.max(0, numberOr(raw.reset_after, 0)),
|
|
184
|
+
total: Math.max(1, Math.floor(numberOr(raw.total, 1))),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function info(refresh: boolean): Promise<GatewayInfo> {
|
|
190
|
+
if (discovered !== undefined && !refresh) return discovered;
|
|
191
|
+
if (discovering !== undefined) return discovering;
|
|
192
|
+
const pending = fetchInfo();
|
|
193
|
+
discovering = pending;
|
|
194
|
+
try {
|
|
195
|
+
const next = await pending;
|
|
196
|
+
discovered = next;
|
|
197
|
+
observeLimit(next.limit);
|
|
198
|
+
return next;
|
|
199
|
+
} finally {
|
|
200
|
+
if (discovering === pending) discovering = undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function observeLimit(next: QqSessionStartLimit): void {
|
|
205
|
+
limit = next;
|
|
206
|
+
windowEndsAt = next.remaining > 0 ? 0 : now() + Math.max(DEFAULT_LIMIT_WINDOW_MS, next.reset_after);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One session start, or `undefined` when a stop interrupted the wait. */
|
|
210
|
+
async function acquire(shard: number): Promise<(() => void) | undefined> {
|
|
211
|
+
let paused = false;
|
|
212
|
+
while (!stopping) {
|
|
213
|
+
if (windowEndsAt !== 0 && now() >= windowEndsAt) {
|
|
214
|
+
limit = { ...limit, remaining: limit.total };
|
|
215
|
+
windowEndsAt = 0;
|
|
216
|
+
}
|
|
217
|
+
if (limit.remaining > 0 && starting < limit.max_concurrency) {
|
|
218
|
+
limit = { ...limit, remaining: limit.remaining - 1 };
|
|
219
|
+
// The last session of the window starts the wait, not the next call: without this the
|
|
220
|
+
// pause would have no end to look forward to.
|
|
221
|
+
if (limit.remaining === 0) windowEndsAt = now() + Math.max(DEFAULT_LIMIT_WINDOW_MS, limit.reset_after);
|
|
222
|
+
starting += 1;
|
|
223
|
+
let released = false;
|
|
224
|
+
return () => {
|
|
225
|
+
if (released) return;
|
|
226
|
+
released = true;
|
|
227
|
+
starting = Math.max(0, starting - 1);
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (!paused) {
|
|
231
|
+
paused = true;
|
|
232
|
+
log('qq gateway session start deferred', {
|
|
233
|
+
shard, remaining: limit.remaining, reset_after: limit.reset_after, concurrency: starting,
|
|
234
|
+
});
|
|
235
|
+
transition('paused', { shard, remaining: limit.remaining });
|
|
236
|
+
}
|
|
237
|
+
await pause(PAUSED_POLL_MS);
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One socket, from Hello to close. Resolves `true` when the session reached READY or RESUMED,
|
|
244
|
+
* so the caller knows whether the next attempt is a resume or a fresh handshake.
|
|
245
|
+
*/
|
|
246
|
+
function connect(shard: Shard, url: string, total: number, release: () => void): Promise<boolean> {
|
|
247
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
248
|
+
const run = (): void => {
|
|
249
|
+
let socket: WebSocket | undefined;
|
|
250
|
+
let heartbeat: ReturnType<typeof setTimeout> | undefined;
|
|
251
|
+
let ack: ReturnType<typeof setTimeout> | undefined;
|
|
252
|
+
let connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
253
|
+
let interval = DEFAULT_HEARTBEAT_MS;
|
|
254
|
+
let online = false;
|
|
255
|
+
let started = false;
|
|
256
|
+
let failed = false;
|
|
257
|
+
let settled = false;
|
|
258
|
+
/** Serialized per connection: one event at a time, in sequence order. */
|
|
259
|
+
let chain: Promise<void> = Promise.resolve();
|
|
260
|
+
|
|
261
|
+
const send = (payload: Record<string, unknown>): void => {
|
|
262
|
+
if (socket?.readyState !== 1) return;
|
|
263
|
+
try {
|
|
264
|
+
socket.send(JSON.stringify(payload));
|
|
265
|
+
} catch (error) {
|
|
266
|
+
log('qq gateway send failed', { shard: shard.index, op: payload.op, error: detailOf(error) });
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const finish = (): void => {
|
|
271
|
+
if (settled) return;
|
|
272
|
+
settled = true;
|
|
273
|
+
clearTimeout(heartbeat);
|
|
274
|
+
clearTimeout(ack);
|
|
275
|
+
clearTimeout(connectTimer);
|
|
276
|
+
release();
|
|
277
|
+
if (socket !== undefined) {
|
|
278
|
+
sockets.delete(socket);
|
|
279
|
+
try {
|
|
280
|
+
socket.close();
|
|
281
|
+
} catch {
|
|
282
|
+
// Already closing: the close event may have been what brought us here.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
resolve(online);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const beat = (): void => {
|
|
289
|
+
if (settled) return;
|
|
290
|
+
send({ op: 1, d: shard.seq });
|
|
291
|
+
// A missed ACK is a dead connection: QQ answers every op 1 with op 11.
|
|
292
|
+
ack = setTimeout(() => {
|
|
293
|
+
heartbeatTimeouts += 1;
|
|
294
|
+
log('qq gateway heartbeat unanswered', { shard: shard.index, seq: shard.seq, interval });
|
|
295
|
+
transition('connecting', { shard: shard.index, reason: 'heartbeat_timeout' });
|
|
296
|
+
finish();
|
|
297
|
+
}, interval);
|
|
298
|
+
heartbeat = setTimeout(beat, interval);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const begin = async (): Promise<void> => {
|
|
302
|
+
if (started) return;
|
|
303
|
+
started = true;
|
|
304
|
+
let token: string;
|
|
305
|
+
try {
|
|
306
|
+
token = await port.token();
|
|
307
|
+
} catch (error) {
|
|
308
|
+
log('qq gateway token unavailable', { shard: shard.index, error: detailOf(error) });
|
|
309
|
+
finish();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (settled) return;
|
|
313
|
+
if (shard.sessionId !== undefined) {
|
|
314
|
+
transition('resuming', { shard: shard.index, session_id: shard.sessionId, seq: shard.seq });
|
|
315
|
+
send({ op: 6, d: { token: `QQBot ${token}`, session_id: shard.sessionId, seq: shard.seq } });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
send({
|
|
319
|
+
op: 2,
|
|
320
|
+
d: {
|
|
321
|
+
token: `QQBot ${token}`,
|
|
322
|
+
intents,
|
|
323
|
+
shard: [shard.index, total],
|
|
324
|
+
properties: { $os: 'linux', $browser: 'onlyne', $device: 'onlyne' },
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const dispatch = (payload: Record<string, unknown>): void => {
|
|
330
|
+
const type = typeof payload.t === 'string' ? payload.t : undefined;
|
|
331
|
+
const seq = typeof payload.s === 'number' ? payload.s : undefined;
|
|
332
|
+
if (type === 'READY') {
|
|
333
|
+
const sessionId = recordOf(payload.d)?.session_id;
|
|
334
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) shard.sessionId = sessionId;
|
|
335
|
+
if (seq !== undefined && seq > shard.seq) shard.seq = seq;
|
|
336
|
+
online = true;
|
|
337
|
+
release();
|
|
338
|
+
transition('online', { shard: shard.index, session_id: shard.sessionId });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (type === 'RESUMED') {
|
|
342
|
+
resumes += 1;
|
|
343
|
+
if (seq !== undefined && seq > shard.seq) shard.seq = seq;
|
|
344
|
+
online = true;
|
|
345
|
+
release();
|
|
346
|
+
transition('online', { shard: shard.index, seq: shard.seq });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (seq === undefined) return;
|
|
350
|
+
chain = chain.then(async () => {
|
|
351
|
+
if (failed || settled) return;
|
|
352
|
+
const listener = handlers;
|
|
353
|
+
try {
|
|
354
|
+
if (listener !== undefined) await listener.dispatch(payload);
|
|
355
|
+
if (seq > shard.seq) shard.seq = seq;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// Stop advancing: the resume replays from `shard.seq`, so this event comes back.
|
|
358
|
+
failed = true;
|
|
359
|
+
log('qq gateway dispatch failed', {
|
|
360
|
+
shard: shard.index, seq, type,
|
|
361
|
+
source: recordOf(payload.d)?.id ?? payload.id, error: detailOf(error),
|
|
362
|
+
});
|
|
363
|
+
finish();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const onMessage = (event: MessageEvent): void => {
|
|
369
|
+
let payload: Record<string, unknown> | undefined;
|
|
370
|
+
try {
|
|
371
|
+
payload = recordOf(JSON.parse(String(event.data)));
|
|
372
|
+
} catch {
|
|
373
|
+
payload = undefined;
|
|
374
|
+
}
|
|
375
|
+
if (payload === undefined) return;
|
|
376
|
+
const op = payload.op;
|
|
377
|
+
if (op === 10) {
|
|
378
|
+
const heartbeatInterval = numberOr(recordOf(payload.d)?.heartbeat_interval, 0);
|
|
379
|
+
if (heartbeatInterval > 0) interval = heartbeatInterval;
|
|
380
|
+
heartbeat = setTimeout(beat, interval);
|
|
381
|
+
void begin();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (op === 11) {
|
|
385
|
+
if (ack !== undefined) {
|
|
386
|
+
clearTimeout(ack);
|
|
387
|
+
ack = undefined;
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (op === 1) {
|
|
392
|
+
send({ op: 1, d: shard.seq });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (op === 7) {
|
|
396
|
+
log('qq gateway asked for a reconnect', { shard: shard.index, seq: shard.seq });
|
|
397
|
+
finish();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (op === 9) {
|
|
401
|
+
const resumable = payload.d === true;
|
|
402
|
+
if (shard.sessionId !== undefined) resumeFailures += 1;
|
|
403
|
+
log('qq gateway rejected the session', {
|
|
404
|
+
shard: shard.index, resumable, session_id: shard.sessionId, seq: shard.seq,
|
|
405
|
+
});
|
|
406
|
+
if (!resumable) {
|
|
407
|
+
shard.sessionId = undefined;
|
|
408
|
+
shard.seq = 0;
|
|
409
|
+
}
|
|
410
|
+
transition('connecting', { shard: shard.index, reason: 'invalid_session' });
|
|
411
|
+
finish();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (op === 0) dispatch(payload);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
socket = new WebSocket(url);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
log('qq gateway socket could not open', { shard: shard.index, url, error: detailOf(error) });
|
|
421
|
+
finish();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
sockets.add(socket);
|
|
425
|
+
if (stopping) {
|
|
426
|
+
// A stop landed between the session start and the dial: close what was just opened.
|
|
427
|
+
finish();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
connectTimer = setTimeout(() => {
|
|
431
|
+
log('qq gateway socket did not open in time', { shard: shard.index, url, timeout_ms: CONNECT_TIMEOUT_MS });
|
|
432
|
+
finish();
|
|
433
|
+
}, CONNECT_TIMEOUT_MS);
|
|
434
|
+
socket.addEventListener('open', () => {
|
|
435
|
+
if (connectTimer !== undefined) {
|
|
436
|
+
clearTimeout(connectTimer);
|
|
437
|
+
connectTimer = undefined;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
socket.addEventListener('message', onMessage);
|
|
441
|
+
socket.addEventListener('error', error => {
|
|
442
|
+
log('qq gateway socket error', { shard: shard.index, error: detailOf(error) });
|
|
443
|
+
finish();
|
|
444
|
+
});
|
|
445
|
+
socket.addEventListener('close', event => {
|
|
446
|
+
if (!settled) log('qq gateway socket closed', { shard: shard.index, code: event.code, seq: shard.seq });
|
|
447
|
+
finish();
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
run();
|
|
451
|
+
return promise;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** One shard's whole life: discover, start a session, and reconnect until a stop. */
|
|
455
|
+
async function session(index: number, total: number): Promise<void> {
|
|
456
|
+
const shard: Shard = { index, seq: 0 };
|
|
457
|
+
let attempt = 0;
|
|
458
|
+
let refresh = false;
|
|
459
|
+
while (!stopping) {
|
|
460
|
+
let target: GatewayInfo;
|
|
461
|
+
try {
|
|
462
|
+
target = await info(refresh);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
log('qq gateway discovery failed', { shard: index, error: detailOf(error), attempt });
|
|
465
|
+
transition('connecting', { shard: index, attempt, error: detailOf(error) });
|
|
466
|
+
await pause(backoff(attempt));
|
|
467
|
+
attempt += 1;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const release = await acquire(index);
|
|
471
|
+
if (release === undefined) return;
|
|
472
|
+
transition(shard.sessionId === undefined ? 'connecting' : 'resuming', {
|
|
473
|
+
shard: index, seq: shard.seq, attempt, ...(shard.sessionId === undefined ? {} : { session_id: shard.sessionId }),
|
|
474
|
+
});
|
|
475
|
+
const online = await connect(shard, target.url, Math.max(1, target.shards), release);
|
|
476
|
+
if (stopping) return;
|
|
477
|
+
reconnects += 1;
|
|
478
|
+
// A session that never came online means the url or the budget is stale: rediscover.
|
|
479
|
+
refresh = !online;
|
|
480
|
+
attempt = online ? 0 : attempt + 1;
|
|
481
|
+
log('qq gateway connection ended', {
|
|
482
|
+
shard: index, reconnects, online, session_id: shard.sessionId, seq: shard.seq, attempt,
|
|
483
|
+
});
|
|
484
|
+
await pause(backoff(online ? 0 : attempt));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function control(): Promise<void> {
|
|
489
|
+
let attempt = 0;
|
|
490
|
+
while (!stopping) {
|
|
491
|
+
try {
|
|
492
|
+
const first = await info(true);
|
|
493
|
+
const count = Math.max(1, first.shards);
|
|
494
|
+
shardCount = count;
|
|
495
|
+
log('qq gateway discovered', {
|
|
496
|
+
url: first.url, shards: count, remaining: first.limit.remaining, reset_after: first.limit.reset_after,
|
|
497
|
+
});
|
|
498
|
+
await Promise.all(Array.from({ length: count }, (_, index) => session(index, count)));
|
|
499
|
+
return;
|
|
500
|
+
} catch (error) {
|
|
501
|
+
log('qq gateway discovery failed', { error: detailOf(error), attempt });
|
|
502
|
+
transition('connecting', { attempt, error: detailOf(error) });
|
|
503
|
+
await pause(backoff(attempt));
|
|
504
|
+
attempt += 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
async start(next: QqGatewayHandlers): Promise<void> {
|
|
511
|
+
if (loops.length > 0) return;
|
|
512
|
+
handlers = next;
|
|
513
|
+
stopping = false;
|
|
514
|
+
transition('connecting');
|
|
515
|
+
// Not awaited: a network outage is a state the operator watches, not a failed start.
|
|
516
|
+
loops.push(control());
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
async stop(): Promise<void> {
|
|
520
|
+
stopping = true;
|
|
521
|
+
for (const wake of [...sleepers]) wake();
|
|
522
|
+
for (const socket of [...sockets]) {
|
|
523
|
+
try {
|
|
524
|
+
socket.close();
|
|
525
|
+
} catch {
|
|
526
|
+
// Already closing.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
await Promise.all(loops);
|
|
530
|
+
loops.length = 0;
|
|
531
|
+
discovered = undefined;
|
|
532
|
+
transition('stopped');
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
status(): QqGatewayStatus {
|
|
536
|
+
return {
|
|
537
|
+
state,
|
|
538
|
+
reconnects,
|
|
539
|
+
heartbeat_timeouts: heartbeatTimeouts,
|
|
540
|
+
resumes,
|
|
541
|
+
resume_failures: resumeFailures,
|
|
542
|
+
shards: shardCount,
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|