@alemonjs/qq-bot 2.1.30 → 2.1.32
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/README.md +14 -0
- package/dist/assets/index.css +1 -1
- package/dist/assets/index.js +3 -3
- package/dist/index.html +1 -1
- package/lib/config.d.ts +8 -0
- package/lib/config.js +23 -1
- package/lib/desktop.js +8 -1
- package/lib/index.js +5 -8
- package/lib/index.webhook.js +3 -0
- package/lib/index.websoket.d.ts +2 -0
- package/lib/index.websoket.js +18 -16
- package/lib/message/group/C2C_MESSAGE_CREATE.d.ts +7 -0
- package/lib/message/group/GROUP_AT_MESSAGE_CREATE.d.ts +7 -0
- package/lib/message/group/GROUP_MESSAGE_CREATE.d.ts +7 -0
- package/lib/register.d.ts +10 -1
- package/lib/register.js +260 -9
- package/lib/sdk/api.d.ts +58 -0
- package/lib/sdk/api.js +252 -14
- package/lib/sdk/client.webhook.js +14 -14
- package/lib/sdk/client.websoket.d.ts +2 -1
- package/lib/sdk/client.websoket.js +321 -110
- package/lib/sdk/config.d.ts +3 -1
- package/lib/sdk/config.js +6 -2
- package/lib/sdk/registry.d.ts +25 -0
- package/lib/sdk/registry.js +116 -0
- package/lib/sdk/session.d.ts +18 -0
- package/lib/sdk/session.js +56 -0
- package/lib/sdk/typing.d.ts +2 -0
- package/lib/sends.js +25 -4
- package/package.json +5 -3
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import { QQBotAPI } from './api.js';
|
|
3
|
-
import { config } from './config.js';
|
|
4
3
|
import { getIntentsMask } from './intents.js';
|
|
5
|
-
import {
|
|
4
|
+
import { FileSessionStore } from './session.js';
|
|
6
5
|
|
|
7
6
|
const normalizeGatewayMessage = (message) => {
|
|
8
7
|
if (!message?.id || !message?.d || typeof message.d !== 'object' || Array.isArray(message.d)) {
|
|
@@ -14,41 +13,192 @@ const normalizeGatewayMessage = (message) => {
|
|
|
14
13
|
return message;
|
|
15
14
|
};
|
|
16
15
|
class QQBotClients extends QQBotAPI {
|
|
17
|
-
#counter = new Counter(1);
|
|
18
16
|
#isConnected = false;
|
|
19
|
-
#
|
|
20
|
-
#
|
|
17
|
+
#stopped = false;
|
|
18
|
+
#sessionId = null;
|
|
19
|
+
#sessionStore;
|
|
20
|
+
#sessionLoaded = false;
|
|
21
|
+
#sequence = null;
|
|
22
|
+
#heartbeatAcknowledged = true;
|
|
23
|
+
#heartbeatInterval = 30000;
|
|
24
|
+
#heartbeatTimer = null;
|
|
25
|
+
#accessTokenTimer = null;
|
|
26
|
+
#accessTokenTask = null;
|
|
27
|
+
#reconnectTimer = null;
|
|
28
|
+
#reconnectAttempts = 0;
|
|
21
29
|
#gatewayUrl = null;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
#ws = null;
|
|
31
|
+
#events = {};
|
|
32
|
+
constructor(option) {
|
|
33
|
+
super(option);
|
|
34
|
+
this.#sessionStore = option.sessionStore || new FileSessionStore();
|
|
35
|
+
}
|
|
36
|
+
async #loadSession() {
|
|
37
|
+
if (this.#sessionLoaded)
|
|
38
|
+
return;
|
|
39
|
+
this.#sessionLoaded = true;
|
|
40
|
+
const botId = String(this.config.get('app_id') || '');
|
|
41
|
+
const session = botId ? await this.#sessionStore.load(botId) : null;
|
|
42
|
+
if (session) {
|
|
43
|
+
this.#sessionId = session.sessionId;
|
|
44
|
+
this.#sequence = session.sequence;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
#persistSession() {
|
|
48
|
+
const botId = String(this.config.get('app_id') || '');
|
|
49
|
+
if (!botId || !this.#sessionId || this.#sequence === null)
|
|
50
|
+
return;
|
|
51
|
+
void this.#sessionStore.save({ botId, sessionId: this.#sessionId, sequence: this.#sequence }).catch(err => {
|
|
52
|
+
console.warn('[ws-qqbot] session persistence failed', err);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
#clearSession() {
|
|
56
|
+
const botId = String(this.config.get('app_id') || '');
|
|
57
|
+
this.#sessionId = null;
|
|
58
|
+
this.#sequence = null;
|
|
59
|
+
if (botId)
|
|
60
|
+
void this.#sessionStore.clear(botId).catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
#clearHeartbeat() {
|
|
63
|
+
if (this.#heartbeatTimer) {
|
|
64
|
+
clearInterval(this.#heartbeatTimer);
|
|
65
|
+
this.#heartbeatTimer = null;
|
|
26
66
|
}
|
|
27
67
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
68
|
+
#startHeartbeat(ws) {
|
|
69
|
+
this.#clearHeartbeat();
|
|
70
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
71
|
+
if (this.#ws !== ws || !this.#isConnected || ws.readyState !== WebSocket.OPEN) {
|
|
72
|
+
this.#clearHeartbeat();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!this.#heartbeatAcknowledged) {
|
|
76
|
+
this.#requestReconnect('heartbeat acknowledgement timeout');
|
|
33
77
|
return;
|
|
34
78
|
}
|
|
79
|
+
try {
|
|
80
|
+
this.#heartbeatAcknowledged = false;
|
|
81
|
+
this.updateConnectionStatus({ heartbeatAcknowledged: false });
|
|
82
|
+
ws.send(JSON.stringify({
|
|
83
|
+
op: 1,
|
|
84
|
+
d: null
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error('[ws-qqbot] heartbeat failed', err);
|
|
89
|
+
this.#requestReconnect('heartbeat failed');
|
|
90
|
+
}
|
|
91
|
+
}, this.#heartbeatInterval);
|
|
92
|
+
}
|
|
93
|
+
#getReconnectDelay() {
|
|
94
|
+
const delay = Math.min(1000 * 2 ** Math.min(this.#reconnectAttempts, 5), 30000);
|
|
95
|
+
const jitter = Math.floor(Math.random() * Math.min(1000, Math.floor(delay / 4)));
|
|
96
|
+
return delay + jitter;
|
|
97
|
+
}
|
|
98
|
+
#scheduleReconnect(reason) {
|
|
99
|
+
if (this.#stopped || this.#reconnectTimer) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const delay = this.#getReconnectDelay();
|
|
103
|
+
this.#reconnectAttempts++;
|
|
104
|
+
this.updateConnectionStatus({
|
|
105
|
+
state: 'reconnecting',
|
|
106
|
+
reconnectAttempts: this.#reconnectAttempts,
|
|
107
|
+
heartbeatAcknowledged: this.#heartbeatAcknowledged,
|
|
108
|
+
lastError: reason
|
|
109
|
+
});
|
|
110
|
+
console.info(`[ws-qqbot] ${reason}; reconnecting in ${delay}ms (attempt ${this.#reconnectAttempts})`);
|
|
111
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
112
|
+
this.#reconnectTimer = null;
|
|
113
|
+
void this.connect();
|
|
114
|
+
}, delay);
|
|
115
|
+
}
|
|
116
|
+
#requestReconnect(reason) {
|
|
117
|
+
this.#isConnected = false;
|
|
118
|
+
this.#clearHeartbeat();
|
|
119
|
+
this.cancelStreams();
|
|
120
|
+
this.#scheduleReconnect(reason);
|
|
121
|
+
const ws = this.#ws;
|
|
122
|
+
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
|
|
123
|
+
ws.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
disconnect() {
|
|
127
|
+
this.#stopped = true;
|
|
128
|
+
this.#isConnected = false;
|
|
129
|
+
this.#clearHeartbeat();
|
|
130
|
+
if (this.#reconnectTimer) {
|
|
131
|
+
clearTimeout(this.#reconnectTimer);
|
|
132
|
+
}
|
|
133
|
+
if (this.#accessTokenTimer) {
|
|
134
|
+
clearTimeout(this.#accessTokenTimer);
|
|
135
|
+
}
|
|
136
|
+
this.#reconnectTimer = null;
|
|
137
|
+
this.#accessTokenTimer = null;
|
|
138
|
+
const ws = this.#ws;
|
|
139
|
+
this.#ws = null;
|
|
140
|
+
this.cancelStreams();
|
|
141
|
+
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
142
|
+
ws.close(1000, 'client stopped');
|
|
143
|
+
}
|
|
144
|
+
this.updateConnectionStatus({ state: 'stopped', heartbeatAcknowledged: false });
|
|
145
|
+
}
|
|
146
|
+
#scheduleAccessTokenRefresh(delay) {
|
|
147
|
+
if (this.#accessTokenTimer) {
|
|
148
|
+
clearTimeout(this.#accessTokenTimer);
|
|
149
|
+
}
|
|
150
|
+
this.#accessTokenTimer = setTimeout(() => {
|
|
151
|
+
this.#accessTokenTimer = null;
|
|
152
|
+
void this.#startAccessTokenRefresh().catch(() => undefined);
|
|
153
|
+
}, delay);
|
|
154
|
+
}
|
|
155
|
+
async #refreshAccessToken() {
|
|
156
|
+
const appId = this.config.get('app_id');
|
|
157
|
+
const secret = this.config.get('secret');
|
|
158
|
+
if (!appId || !secret) {
|
|
159
|
+
throw new Error('QQ Bot app_id or secret is missing');
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
35
162
|
const data = await this.getAuthentication();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
163
|
+
if (!data?.access_token) {
|
|
164
|
+
throw new Error('QQ Bot access token is empty');
|
|
165
|
+
}
|
|
166
|
+
this.config.set('access_token', data.access_token);
|
|
167
|
+
const expiresIn = Number(data.expires_in);
|
|
168
|
+
const refreshIn = Math.max(60000, (Number.isFinite(expiresIn) ? expiresIn * 1000 : 3600000) - 60000);
|
|
169
|
+
this.#scheduleAccessTokenRefresh(refreshIn);
|
|
170
|
+
console.info(`[ws-qqbot] access token refreshed; next refresh in ${refreshIn}ms`);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
this.#scheduleAccessTokenRefresh(30000);
|
|
174
|
+
console.error('[ws-qqbot] access token refresh failed; retrying in 30000ms', err);
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
#startAccessTokenRefresh() {
|
|
179
|
+
if (this.#accessTokenTask === null) {
|
|
180
|
+
this.#accessTokenTask = this.#refreshAccessToken().finally(() => {
|
|
181
|
+
this.#accessTokenTask = null;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return this.#accessTokenTask;
|
|
185
|
+
}
|
|
186
|
+
async #ensureAccessToken() {
|
|
187
|
+
if (this.#accessTokenTimer) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await this.#startAccessTokenRefresh();
|
|
41
191
|
}
|
|
42
192
|
#aut() {
|
|
43
|
-
const token = config.get('access_token');
|
|
44
|
-
const intents = config.get('intents');
|
|
45
|
-
const shard = config.get('shard');
|
|
193
|
+
const token = this.config.get('access_token');
|
|
194
|
+
const intents = this.config.get('intents');
|
|
195
|
+
const shard = this.config.get('shard');
|
|
46
196
|
return {
|
|
47
197
|
op: 2,
|
|
48
198
|
d: {
|
|
49
199
|
token: `QQBot ${token}`,
|
|
50
200
|
intents: getIntentsMask(intents),
|
|
51
|
-
shard
|
|
201
|
+
shard,
|
|
52
202
|
properties: {
|
|
53
203
|
$os: process.platform,
|
|
54
204
|
$browser: 'alemonjs',
|
|
@@ -57,8 +207,16 @@ class QQBotClients extends QQBotAPI {
|
|
|
57
207
|
}
|
|
58
208
|
};
|
|
59
209
|
}
|
|
60
|
-
#
|
|
61
|
-
|
|
210
|
+
#resume() {
|
|
211
|
+
return {
|
|
212
|
+
op: 6,
|
|
213
|
+
d: {
|
|
214
|
+
token: `QQBot ${this.config.get('access_token')}`,
|
|
215
|
+
session_id: this.#sessionId,
|
|
216
|
+
seq: this.#sequence
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
62
220
|
on(key, val) {
|
|
63
221
|
if (!this.#events[key]) {
|
|
64
222
|
this.#events[key] = [];
|
|
@@ -66,119 +224,172 @@ class QQBotClients extends QQBotAPI {
|
|
|
66
224
|
this.#events[key].push(val);
|
|
67
225
|
return this;
|
|
68
226
|
}
|
|
227
|
+
#emitError(err) {
|
|
228
|
+
for (const event of this.#events.ERROR || []) {
|
|
229
|
+
try {
|
|
230
|
+
event(err);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
console.error('[ws-qqbot] error handler failed', error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
#handleDispatch(t, d) {
|
|
238
|
+
for (const event of this.#events[t] || []) {
|
|
239
|
+
try {
|
|
240
|
+
event(d);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
this.#emitError(err);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
69
247
|
async connect(gatewayURL) {
|
|
70
|
-
|
|
248
|
+
this.#stopped = false;
|
|
249
|
+
if (gatewayURL) {
|
|
250
|
+
this.#gatewayUrl = gatewayURL;
|
|
251
|
+
}
|
|
252
|
+
if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await this.#loadSession();
|
|
256
|
+
try {
|
|
257
|
+
await this.#ensureAccessToken();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
this.#scheduleReconnect('access token unavailable');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
71
263
|
if (!this.#gatewayUrl) {
|
|
72
|
-
|
|
264
|
+
try {
|
|
265
|
+
this.#gatewayUrl = await this.gateway().then(res => res?.url);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
this.#emitError(err);
|
|
269
|
+
this.#scheduleReconnect('gateway request failed');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
73
272
|
}
|
|
74
273
|
if (!this.#gatewayUrl) {
|
|
274
|
+
this.#scheduleReconnect('gateway URL is empty');
|
|
75
275
|
return;
|
|
76
276
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
277
|
+
let ws;
|
|
278
|
+
try {
|
|
279
|
+
ws = new WebSocket(this.#gatewayUrl);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
this.#emitError(err);
|
|
283
|
+
this.#scheduleReconnect('gateway connection creation failed');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.#ws = ws;
|
|
287
|
+
this.updateConnectionStatus({ state: 'connecting', transport: 'websocket', lastError: undefined });
|
|
288
|
+
ws.on('open', () => {
|
|
289
|
+
if (this.#ws === ws) {
|
|
290
|
+
console.info('[ws-qqbot] open');
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
ws.on('message', msg => {
|
|
294
|
+
if (this.#ws !== ws) {
|
|
80
295
|
return;
|
|
81
296
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
};
|
|
88
|
-
const start = () => {
|
|
89
|
-
if (this.#gatewayUrl) {
|
|
297
|
+
try {
|
|
298
|
+
const message = normalizeGatewayMessage(JSON.parse(msg.toString('utf8')));
|
|
299
|
+
if (process.env.NTQQ_WS === 'dev') {
|
|
300
|
+
console.info('message', message);
|
|
301
|
+
}
|
|
90
302
|
const map = {
|
|
91
|
-
0: ({ t, d }) => {
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
for (const event of this.#events[t]) {
|
|
95
|
-
if (typeof event !== 'function') {
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
event(d);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
catch (err) {
|
|
102
|
-
if (this.#events['ERROR']) {
|
|
103
|
-
for (const event of this.#events['ERROR']) {
|
|
104
|
-
if (typeof event !== 'function') {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
event(err);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
303
|
+
0: ({ t, d, s }) => {
|
|
304
|
+
if (typeof s === 'number') {
|
|
305
|
+
this.#sequence = s;
|
|
111
306
|
}
|
|
112
|
-
if (t === 'READY') {
|
|
113
|
-
|
|
114
|
-
clearInterval(this.#IntervalId);
|
|
115
|
-
}
|
|
116
|
-
this.#IntervalId = setInterval(() => {
|
|
117
|
-
if (this.#isConnected && this.#ws) {
|
|
118
|
-
this.#ws.send(JSON.stringify({
|
|
119
|
-
op: 1,
|
|
120
|
-
d: null
|
|
121
|
-
}));
|
|
122
|
-
}
|
|
123
|
-
}, this.#heartbeat_interval);
|
|
307
|
+
if (t === 'READY' && d?.session_id) {
|
|
308
|
+
this.#sessionId = String(d.session_id);
|
|
124
309
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
310
|
+
this.#persistSession();
|
|
311
|
+
this.#handleDispatch(t, d);
|
|
312
|
+
if (t === 'READY' || t === 'RESUMED') {
|
|
313
|
+
this.#reconnectAttempts = 0;
|
|
314
|
+
this.updateConnectionStatus({
|
|
315
|
+
state: 'ready',
|
|
316
|
+
reconnectAttempts: 0,
|
|
317
|
+
heartbeatAcknowledged: this.#heartbeatAcknowledged,
|
|
318
|
+
sessionId: this.#sessionId ?? undefined,
|
|
319
|
+
sequence: this.#sequence ?? undefined,
|
|
320
|
+
resumed: t === 'RESUMED'
|
|
321
|
+
});
|
|
322
|
+
console.info(t === 'READY' ? '[ws-qqbot] ready' : '[ws-qqbot] restored connection');
|
|
128
323
|
}
|
|
129
324
|
},
|
|
130
|
-
6: ({ d }) => {
|
|
131
|
-
console.info('[ws-qqbot] connection attempt', d);
|
|
132
|
-
},
|
|
133
325
|
7: ({ d }) => {
|
|
134
|
-
console.info('[ws-qqbot] reconnect', d);
|
|
135
|
-
|
|
136
|
-
clearInterval(this.#IntervalId);
|
|
137
|
-
}
|
|
326
|
+
console.info('[ws-qqbot] gateway requested reconnect', d);
|
|
327
|
+
this.#requestReconnect('gateway requested reconnect');
|
|
138
328
|
},
|
|
139
329
|
9: ({ d }) => {
|
|
140
|
-
console.
|
|
330
|
+
console.error('[ws-qqbot] invalid session', d);
|
|
331
|
+
this.#clearSession();
|
|
332
|
+
this.#requestReconnect('invalid session');
|
|
141
333
|
},
|
|
142
334
|
10: ({ d }) => {
|
|
143
335
|
this.#isConnected = true;
|
|
144
|
-
this.#
|
|
145
|
-
|
|
146
|
-
|
|
336
|
+
this.#heartbeatInterval = Number(d?.heartbeat_interval) || 30000;
|
|
337
|
+
this.#startHeartbeat(ws);
|
|
338
|
+
try {
|
|
339
|
+
const resumable = this.#sessionId && this.#sequence !== null;
|
|
340
|
+
ws.send(JSON.stringify(resumable ? this.#resume() : this.#aut()));
|
|
341
|
+
this.updateConnectionStatus({
|
|
342
|
+
state: 'connecting',
|
|
343
|
+
heartbeatAcknowledged: true,
|
|
344
|
+
sessionId: this.#sessionId ?? undefined,
|
|
345
|
+
sequence: this.#sequence ?? undefined,
|
|
346
|
+
resumed: Boolean(resumable)
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
console.error('[ws-qqbot] identify failed', err);
|
|
351
|
+
this.#emitError(err);
|
|
352
|
+
this.#requestReconnect('identify failed');
|
|
147
353
|
}
|
|
148
354
|
},
|
|
149
355
|
11: () => {
|
|
150
|
-
|
|
151
|
-
this
|
|
356
|
+
this.#heartbeatAcknowledged = true;
|
|
357
|
+
this.updateConnectionStatus({ heartbeatAcknowledged: true });
|
|
358
|
+
console.debug('[ws-qqbot] heartbeat acknowledged');
|
|
152
359
|
},
|
|
153
360
|
12: ({ d }) => {
|
|
154
361
|
console.debug('[ws-qqbot] platform data', d);
|
|
155
362
|
}
|
|
156
363
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
});
|
|
161
|
-
this.#ws.on('message', msg => {
|
|
162
|
-
const message = normalizeGatewayMessage(JSON.parse(msg.toString('utf8')));
|
|
163
|
-
if (process.env.NTQQ_WS === 'dev') {
|
|
164
|
-
console.info('message', message);
|
|
165
|
-
}
|
|
166
|
-
if (map[message.op]) {
|
|
167
|
-
map[message.op](message);
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
this.#ws.on('close', err => {
|
|
171
|
-
this.#isConnected = false;
|
|
172
|
-
if (this.#IntervalId) {
|
|
173
|
-
clearInterval(this.#IntervalId);
|
|
174
|
-
this.#IntervalId = null;
|
|
175
|
-
}
|
|
176
|
-
void reconnect();
|
|
177
|
-
console.info('[ws-qqbot] close', err);
|
|
178
|
-
});
|
|
364
|
+
if (map[message.op]) {
|
|
365
|
+
map[message.op](message);
|
|
366
|
+
}
|
|
179
367
|
}
|
|
180
|
-
|
|
181
|
-
|
|
368
|
+
catch (err) {
|
|
369
|
+
console.error('[ws-qqbot] invalid gateway message', err);
|
|
370
|
+
this.#emitError(err);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
ws.on('close', (code, reason) => {
|
|
374
|
+
if (this.#ws !== ws) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
this.#ws = null;
|
|
378
|
+
this.#isConnected = false;
|
|
379
|
+
this.#clearHeartbeat();
|
|
380
|
+
this.updateConnectionStatus({ state: 'offline', heartbeatAcknowledged: false });
|
|
381
|
+
console.info(`[ws-qqbot] close ${code}${reason.length ? `: ${reason.toString('utf8')}` : ''}`);
|
|
382
|
+
this.#scheduleReconnect('connection closed');
|
|
383
|
+
});
|
|
384
|
+
ws.on('error', err => {
|
|
385
|
+
if (this.#ws !== ws) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
console.error('[ws-qqbot] error', err);
|
|
389
|
+
this.updateConnectionStatus({ state: 'offline', lastError: err.message });
|
|
390
|
+
this.#emitError(err);
|
|
391
|
+
this.#requestReconnect('connection error');
|
|
392
|
+
});
|
|
182
393
|
}
|
|
183
394
|
}
|
|
184
395
|
|
package/lib/sdk/config.d.ts
CHANGED
package/lib/sdk/config.js
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cbpPlatform } from 'alemonjs';
|
|
2
|
+
import { QQBotClients } from './client.websoket.js';
|
|
3
|
+
import type { Options } from './typing.js';
|
|
4
|
+
export declare class QQBotRegistry {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(defaultBot?: string, cbp?: ReturnType<typeof cbpPlatform>);
|
|
7
|
+
add(botId: string, options: Options): QQBotClients;
|
|
8
|
+
get(botId?: string): QQBotClients;
|
|
9
|
+
getConnectionStatus(botId?: string): {
|
|
10
|
+
Platform: string;
|
|
11
|
+
state: import("./api.js").QQBotConnectionState;
|
|
12
|
+
bots: {
|
|
13
|
+
state: import("./api.js").QQBotConnectionState;
|
|
14
|
+
transport: "websocket" | "webhook" | "proxy" | null;
|
|
15
|
+
reconnectAttempts: number;
|
|
16
|
+
heartbeatAcknowledged: boolean;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
sequence?: number;
|
|
19
|
+
resumed: boolean;
|
|
20
|
+
lastError?: string;
|
|
21
|
+
BotId: string;
|
|
22
|
+
}[];
|
|
23
|
+
};
|
|
24
|
+
disconnect(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { cbpPlatform, createResult, ResultCode } from 'alemonjs';
|
|
2
|
+
import { getQQBotConfig } from '../config.js';
|
|
3
|
+
import { register } from '../register.js';
|
|
4
|
+
import { QQBotClients } from './client.websoket.js';
|
|
5
|
+
|
|
6
|
+
class QQBotRegistry {
|
|
7
|
+
#bots = new Map();
|
|
8
|
+
#streams = new Map();
|
|
9
|
+
#defaultBot;
|
|
10
|
+
#cbp;
|
|
11
|
+
constructor(defaultBot, cbp) {
|
|
12
|
+
this.#defaultBot = defaultBot;
|
|
13
|
+
if (cbp) {
|
|
14
|
+
this.#cbp = cbp;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
const config = getQQBotConfig();
|
|
18
|
+
const port = process.env.port || config.port || 17117;
|
|
19
|
+
this.#cbp = cbpPlatform(`ws://127.0.0.1:${port}`);
|
|
20
|
+
}
|
|
21
|
+
this.#cbp.onactions((data, consume) => void this.#onAction(data, consume));
|
|
22
|
+
this.#cbp.onapis((data, consume) => void this.#onApi(data, consume));
|
|
23
|
+
}
|
|
24
|
+
add(botId, options) {
|
|
25
|
+
if (!botId)
|
|
26
|
+
throw new Error('QQ BotId is required');
|
|
27
|
+
if (this.#bots.has(botId))
|
|
28
|
+
throw new Error(`Duplicate QQ BotId: ${botId}`);
|
|
29
|
+
const client = new QQBotClients({ ...options, app_id: botId });
|
|
30
|
+
client.setStreamLifecycleListener(streamId => this.#streams.delete(streamId));
|
|
31
|
+
const adapter = register(client, { botId, cbp: this.#cbp, bindActions: false });
|
|
32
|
+
this.#bots.set(botId, { client, adapter });
|
|
33
|
+
return client;
|
|
34
|
+
}
|
|
35
|
+
get(botId) {
|
|
36
|
+
return botId ? this.#bots.get(botId)?.client : undefined;
|
|
37
|
+
}
|
|
38
|
+
getConnectionStatus(botId) {
|
|
39
|
+
const bots = [...this.#bots].flatMap(([BotId, entry]) => {
|
|
40
|
+
if (botId && botId !== BotId)
|
|
41
|
+
return [];
|
|
42
|
+
return [{ BotId, ...entry.client.getConnectionStatus() }];
|
|
43
|
+
});
|
|
44
|
+
const state = bots.some(item => item.state === 'ready')
|
|
45
|
+
? 'ready'
|
|
46
|
+
: bots.some(item => item.state === 'connecting' || item.state === 'reconnecting')
|
|
47
|
+
? 'connecting'
|
|
48
|
+
: bots[0]?.state || 'idle';
|
|
49
|
+
return { Platform: 'qq-bot', state, bots };
|
|
50
|
+
}
|
|
51
|
+
disconnect() {
|
|
52
|
+
for (const { client } of this.#bots.values())
|
|
53
|
+
client.disconnect();
|
|
54
|
+
this.#streams.clear();
|
|
55
|
+
}
|
|
56
|
+
#requestedBotId(data) {
|
|
57
|
+
const payload = data?.payload || {};
|
|
58
|
+
return payload.target?.BotId || payload.BotId || payload.event?.BotId || payload.event?.Platform?.BotId;
|
|
59
|
+
}
|
|
60
|
+
#select(data) {
|
|
61
|
+
const requested = this.#requestedBotId(data);
|
|
62
|
+
if (requested) {
|
|
63
|
+
const entry = this.#bots.get(String(requested));
|
|
64
|
+
return entry ? { entry } : { error: `BotId ${requested} is not configured (available: ${[...this.#bots.keys()].join(', ') || 'none'})` };
|
|
65
|
+
}
|
|
66
|
+
if (this.#bots.size === 1)
|
|
67
|
+
return { entry: this.#bots.values().next().value };
|
|
68
|
+
if (this.#defaultBot)
|
|
69
|
+
return { entry: this.#bots.get(this.#defaultBot) };
|
|
70
|
+
return { error: 'BotId is required because multiple QQ bots are active and no default_bot is configured' };
|
|
71
|
+
}
|
|
72
|
+
async #onAction(data, consume) {
|
|
73
|
+
if (data?.action === 'connection.status') {
|
|
74
|
+
const botId = data?.payload?.BotId;
|
|
75
|
+
const status = this.getConnectionStatus(botId);
|
|
76
|
+
if (botId && !status.bots.length) {
|
|
77
|
+
consume([createResult(ResultCode.FailParams, `BotId ${botId} is not configured`, null)]);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
consume([createResult(ResultCode.Ok, data.action, status)]);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { entry, error } = this.#select(data);
|
|
85
|
+
if (!entry) {
|
|
86
|
+
consume([createResult(ResultCode.FailParams, error || 'QQ Bot is unavailable', null)]);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
await entry.adapter.onAction(data, consume);
|
|
90
|
+
}
|
|
91
|
+
async #onApi(data, consume) {
|
|
92
|
+
const params = data?.payload?.params;
|
|
93
|
+
const key = String(data?.payload?.key || '');
|
|
94
|
+
const method = key.split('.').at(-1);
|
|
95
|
+
const streamId = method === 'streamUpdate' || method === 'streamComplete' || method === 'streamCancel' ? params?.[0] : undefined;
|
|
96
|
+
const requested = (Array.isArray(params) ? params[0]?.BotId : undefined) || (streamId ? this.#streams.get(String(streamId)) : undefined);
|
|
97
|
+
const { entry, error } = this.#select({ payload: { BotId: requested, event: data?.payload?.event } });
|
|
98
|
+
if (!entry) {
|
|
99
|
+
consume([createResult(ResultCode.FailParams, error || 'QQ Bot is unavailable', null)]);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
await entry.adapter.onApi(data, results => {
|
|
103
|
+
const result = results.find((item) => item.code === ResultCode.Ok)?.data;
|
|
104
|
+
if (method === 'streamOpen' && result?.streamId)
|
|
105
|
+
this.#streams.set(String(result.streamId), requested || this.#botIdFor(entry));
|
|
106
|
+
if (streamId && (method === 'streamComplete' || method === 'streamCancel'))
|
|
107
|
+
this.#streams.delete(String(streamId));
|
|
108
|
+
consume(results);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
#botIdFor(entry) {
|
|
112
|
+
return [...this.#bots.entries()].find(([, value]) => value === entry)?.[0] || '';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { QQBotRegistry };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type PersistedSession = {
|
|
2
|
+
botId: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
sequence: number;
|
|
5
|
+
};
|
|
6
|
+
export interface SessionStore {
|
|
7
|
+
load(botId: string): Promise<PersistedSession | null>;
|
|
8
|
+
save(session: PersistedSession): Promise<void>;
|
|
9
|
+
clear(botId: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare class FileSessionStore implements SessionStore {
|
|
12
|
+
#private;
|
|
13
|
+
private readonly root;
|
|
14
|
+
constructor(root?: string);
|
|
15
|
+
load(botId: string): Promise<PersistedSession | null>;
|
|
16
|
+
save(session: PersistedSession): Promise<void>;
|
|
17
|
+
clear(botId: string): Promise<void>;
|
|
18
|
+
}
|