@alemonjs/qq-bot 2.1.31 → 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/lib/sdk/api.d.ts CHANGED
@@ -1,9 +1,51 @@
1
1
  import { type AxiosRequestConfig } from 'axios';
2
2
  import { ApiRequestData, FileType } from './typing.js';
3
+ import { QQBotConfig } from './config.js';
3
4
  export declare const BOTS_API_RUL = "https://bots.qq.com";
4
5
  export declare const API_URL_SANDBOX = "https://sandbox.api.sgroup.qq.com";
5
6
  export declare const API_URL = "https://api.sgroup.qq.com";
7
+ export type QQBotConnectionState = 'idle' | 'connecting' | 'ready' | 'reconnecting' | 'offline' | 'stopped';
8
+ export type QQBotConnectionStatus = {
9
+ state: QQBotConnectionState;
10
+ transport: 'websocket' | 'webhook' | 'proxy' | null;
11
+ reconnectAttempts: number;
12
+ heartbeatAcknowledged: boolean;
13
+ sessionId?: string;
14
+ sequence?: number;
15
+ resumed: boolean;
16
+ lastError?: string;
17
+ };
18
+ export type QQBotMediaHashes = {
19
+ sha256: string;
20
+ md5: string;
21
+ sha1: string;
22
+ md5_10m: string;
23
+ };
6
24
  export declare class QQBotAPI {
25
+ #private;
26
+ protected readonly config: QQBotConfig;
27
+ constructor(options?: Record<string, unknown>);
28
+ getConnectionStatus(): QQBotConnectionStatus;
29
+ protected updateConnectionStatus(patch: Partial<QQBotConnectionStatus>): void;
30
+ setStreamLifecycleListener(listener?: (streamId: string) => void): void;
31
+ sendTyping(params: {
32
+ BotId?: string;
33
+ userOpenId: string;
34
+ msgId?: string;
35
+ durationSec?: number;
36
+ }): Promise<any>;
37
+ streamOpen(params: {
38
+ BotId?: string;
39
+ userOpenId: string;
40
+ msgId: string;
41
+ eventId?: string;
42
+ }): {
43
+ streamId: `${string}-${string}-${string}-${string}-${string}`;
44
+ };
45
+ streamUpdate(streamId: string, fullText: string): Promise<unknown>;
46
+ streamComplete(streamId: string): Promise<any>;
47
+ streamCancel(streamId: string): void;
48
+ protected cancelStreams(): void;
7
49
  getAuthentication(): Promise<any>;
8
50
  groupService(options: AxiosRequestConfig): Promise<any>;
9
51
  guildServer(options: AxiosRequestConfig): Promise<any>;
@@ -37,6 +79,22 @@ export declare class QQBotAPI {
37
79
  file_info: string;
38
80
  ttl: number;
39
81
  }>;
82
+ getMediaFileHashes(filePath: string): Promise<QQBotMediaHashes>;
83
+ postChunkedRichMedia(params: {
84
+ scope: 'group' | 'c2c';
85
+ targetId: string;
86
+ fileType: FileType;
87
+ data?: Buffer;
88
+ filePath?: string;
89
+ size?: number;
90
+ hashes?: QQBotMediaHashes;
91
+ name?: string;
92
+ onProgress?: (uploaded: number, total: number) => void;
93
+ }): Promise<{
94
+ file_uuid: string;
95
+ file_info: string;
96
+ ttl: number;
97
+ }>;
40
98
  userMessageDelete(openid: string, message_id: string): Promise<any>;
41
99
  grouMessageDelte(group_openid: string, message_id: string): Promise<any>;
42
100
  channelsMessages(channel_id: string, message: {
package/lib/sdk/api.js CHANGED
@@ -1,18 +1,153 @@
1
1
  import axios from 'axios';
2
- import { config } from './config.js';
2
+ import { QQBotConfig } from './config.js';
3
3
  import FormData from 'form-data';
4
4
  import { createPicFrom } from 'alemonjs/utils';
5
5
  import { createAxiosInstance } from './instance.js';
6
+ import { randomUUID, createHash } from 'node:crypto';
7
+ import { createReadStream } from 'node:fs';
6
8
 
7
9
  const BOTS_API_RUL = 'https://bots.qq.com';
8
10
  const API_URL_SANDBOX = 'https://sandbox.api.sgroup.qq.com';
9
11
  const API_URL = 'https://api.sgroup.qq.com';
10
- const msgMap = new Map();
11
12
  class QQBotAPI {
13
+ config;
14
+ #msgMap = new Map();
15
+ #streams = new Map();
16
+ #onStreamClosed;
17
+ #connectionStatus = {
18
+ state: 'idle',
19
+ transport: null,
20
+ reconnectAttempts: 0,
21
+ heartbeatAcknowledged: false,
22
+ resumed: false
23
+ };
24
+ constructor(options = {}) {
25
+ this.config = new QQBotConfig(options);
26
+ }
27
+ getConnectionStatus() {
28
+ return { ...this.#connectionStatus };
29
+ }
30
+ updateConnectionStatus(patch) {
31
+ this.#connectionStatus = { ...this.#connectionStatus, ...patch };
32
+ }
33
+ setStreamLifecycleListener(listener) {
34
+ this.#onStreamClosed = listener;
35
+ }
36
+ sendTyping(params) {
37
+ if (!params.userOpenId)
38
+ throw new Error('userOpenId is required');
39
+ return this.groupService({
40
+ url: `/v2/users/${params.userOpenId}/input_notify`,
41
+ method: 'post',
42
+ data: {
43
+ ...(params.msgId && { msg_id: params.msgId }),
44
+ input_second: Math.max(1, Math.min(Number(params.durationSec) || 30, 60))
45
+ }
46
+ });
47
+ }
48
+ streamOpen(params) {
49
+ if (!params.userOpenId || !params.msgId)
50
+ throw new Error('C2C streaming requires userOpenId and msgId');
51
+ const streamId = randomUUID();
52
+ const stream = {
53
+ userOpenId: params.userOpenId,
54
+ msgId: params.msgId,
55
+ eventId: params.eventId || params.msgId,
56
+ msgSeq: this.getMessageSeq(params.msgId),
57
+ index: 0,
58
+ lastSentAt: 0,
59
+ lastSentText: ''
60
+ };
61
+ stream.timer = setTimeout(() => this.streamCancel(streamId), 5 * 60 * 1000);
62
+ this.#streams.set(streamId, stream);
63
+ return { streamId };
64
+ }
65
+ async streamUpdate(streamId, fullText) {
66
+ const stream = this.#streams.get(streamId);
67
+ if (!stream)
68
+ throw new Error('Unknown or expired streamId');
69
+ stream.latest = fullText;
70
+ if (!stream.sendTask) {
71
+ stream.sendTask = (async () => {
72
+ let result;
73
+ while (stream.latest !== undefined && this.#streams.get(streamId) === stream) {
74
+ const content = stream.latest;
75
+ stream.latest = undefined;
76
+ const wait = Math.max(0, 500 - (Date.now() - stream.lastSentAt));
77
+ if (wait)
78
+ await new Promise(resolve => setTimeout(resolve, wait));
79
+ if (this.#streams.get(streamId) !== stream)
80
+ break;
81
+ stream.lastSentAt = Date.now();
82
+ const response = await this.groupService({
83
+ url: `/v2/users/${stream.userOpenId}/stream_messages`,
84
+ method: 'post',
85
+ data: {
86
+ msg_id: stream.msgId,
87
+ event_id: stream.eventId,
88
+ msg_seq: stream.msgSeq,
89
+ index: stream.index++,
90
+ input_mode: 'replace',
91
+ input_state: 1,
92
+ content_type: 'markdown',
93
+ content_raw: content,
94
+ ...(stream.streamMessageId && { stream_msg_id: stream.streamMessageId })
95
+ }
96
+ });
97
+ if (response?.id && !stream.streamMessageId)
98
+ stream.streamMessageId = response.id;
99
+ stream.lastSentText = content;
100
+ result = response;
101
+ }
102
+ return result;
103
+ })().finally(() => {
104
+ if (this.#streams.get(streamId) === stream)
105
+ stream.sendTask = undefined;
106
+ });
107
+ }
108
+ return stream.sendTask;
109
+ }
110
+ async streamComplete(streamId) {
111
+ const stream = this.#streams.get(streamId);
112
+ if (!stream)
113
+ throw new Error('Unknown or expired streamId');
114
+ try {
115
+ await stream.sendTask;
116
+ return await this.groupService({
117
+ url: `/v2/users/${stream.userOpenId}/stream_messages`,
118
+ method: 'post',
119
+ data: {
120
+ msg_id: stream.msgId,
121
+ event_id: stream.eventId,
122
+ msg_seq: stream.msgSeq,
123
+ index: stream.index++,
124
+ input_mode: 'replace',
125
+ input_state: 10,
126
+ content_type: 'markdown',
127
+ content_raw: stream.latest ?? stream.lastSentText,
128
+ ...(stream.streamMessageId && { stream_msg_id: stream.streamMessageId })
129
+ }
130
+ });
131
+ }
132
+ finally {
133
+ this.streamCancel(streamId);
134
+ }
135
+ }
136
+ streamCancel(streamId) {
137
+ const stream = this.#streams.get(streamId);
138
+ if (stream?.timer)
139
+ clearTimeout(stream.timer);
140
+ if (this.#streams.delete(streamId))
141
+ this.#onStreamClosed?.(streamId);
142
+ }
143
+ cancelStreams() {
144
+ for (const streamId of this.#streams.keys())
145
+ this.streamCancel(streamId);
146
+ }
12
147
  getAuthentication() {
13
- const app_id = config.get('app_id');
14
- const secret = config.get('secret');
15
- const baseUrlAppAccessToken = config.get('base_url_app_access_token');
148
+ const app_id = this.config.get('app_id');
149
+ const secret = this.config.get('secret');
150
+ const baseUrlAppAccessToken = this.config.get('base_url_app_access_token');
16
151
  const params = {
17
152
  url: '/app/getAppAccessToken'
18
153
  };
@@ -33,9 +168,9 @@ class QQBotAPI {
33
168
  });
34
169
  }
35
170
  groupService(options) {
36
- const app_id = config.get('app_id');
37
- const token = config.get('access_token');
38
- const sandbox = config.get('sandbox');
171
+ const app_id = this.config.get('app_id');
172
+ const token = this.config.get('access_token');
173
+ const sandbox = this.config.get('sandbox');
39
174
  const service = axios.create({
40
175
  baseURL: sandbox ? API_URL_SANDBOX : API_URL,
41
176
  timeout: 20000,
@@ -50,7 +185,7 @@ class QQBotAPI {
50
185
  return this.groupService(options);
51
186
  }
52
187
  gateway() {
53
- const baseUrlGateway = config.get('base_url_gateway');
188
+ const baseUrlGateway = this.config.get('base_url_gateway');
54
189
  const params = {
55
190
  url: '/gateway'
56
191
  };
@@ -75,13 +210,13 @@ class QQBotAPI {
75
210
  });
76
211
  }
77
212
  getMessageSeq(MessageId) {
78
- let seq = msgMap.get(MessageId) || 0;
213
+ let seq = this.#msgMap.get(MessageId) || 0;
79
214
  seq++;
80
- msgMap.set(MessageId, seq);
81
- if (msgMap.size > 100) {
82
- const firstKey = msgMap.keys().next().value;
215
+ this.#msgMap.set(MessageId, seq);
216
+ if (this.#msgMap.size > 100) {
217
+ const firstKey = this.#msgMap.keys().next().value;
83
218
  if (firstKey) {
84
- msgMap.delete(firstKey);
219
+ this.#msgMap.delete(firstKey);
85
220
  }
86
221
  }
87
222
  return seq;
@@ -118,6 +253,109 @@ class QQBotAPI {
118
253
  }
119
254
  });
120
255
  }
256
+ async getMediaFileHashes(filePath) {
257
+ const sha256 = createHash('sha256');
258
+ const md5 = createHash('md5');
259
+ const sha1 = createHash('sha1');
260
+ const md5_10m = createHash('md5');
261
+ let firstBytes = 0;
262
+ const firstLimit = 10_002_432;
263
+ for await (const chunk of createReadStream(filePath)) {
264
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
265
+ sha256.update(data);
266
+ md5.update(data);
267
+ sha1.update(data);
268
+ if (firstBytes < firstLimit) {
269
+ const length = Math.min(data.length, firstLimit - firstBytes);
270
+ md5_10m.update(data.subarray(0, length));
271
+ firstBytes += length;
272
+ }
273
+ }
274
+ return {
275
+ sha256: sha256.digest('hex'),
276
+ md5: md5.digest('hex'),
277
+ sha1: sha1.digest('hex'),
278
+ md5_10m: md5_10m.digest('hex')
279
+ };
280
+ }
281
+ async postChunkedRichMedia(params) {
282
+ const hasBuffer = Buffer.isBuffer(params.data);
283
+ const hasFile = Boolean(params.filePath);
284
+ if (hasBuffer === hasFile)
285
+ throw new Error('Chunked media requires exactly one of data or filePath');
286
+ const total = hasBuffer ? params.data.length : Number(params.size);
287
+ if (!Number.isSafeInteger(total) || total < 1)
288
+ throw new Error('Chunked media requires a valid file size');
289
+ const hashes = params.hashes || (hasBuffer ? this.#getMediaBufferHashes(params.data) : await this.getMediaFileHashes(params.filePath));
290
+ const prefix = params.scope === 'group' ? `/v2/groups/${params.targetId}` : `/v2/users/${params.targetId}`;
291
+ const prepared = await this.groupService({
292
+ url: `${prefix}/upload_prepare`,
293
+ method: 'post',
294
+ data: {
295
+ file_type: params.fileType,
296
+ file_name: params.name || 'file',
297
+ file_size: total,
298
+ md5: hashes.md5,
299
+ sha1: hashes.sha1,
300
+ md5_10m: hashes.md5_10m
301
+ }
302
+ });
303
+ let uploaded = 0;
304
+ for (const part of prepared.parts || []) {
305
+ const offset = (part.index - 1) * prepared.block_size;
306
+ const length = Math.min(prepared.block_size, total - offset);
307
+ if (length <= 0)
308
+ throw new Error(`QQ returned invalid upload part index: ${part.index}`);
309
+ const body = hasBuffer ? params.data.subarray(offset, offset + length) : undefined;
310
+ const partMd5 = body ? createHash('md5').update(body).digest('hex') : await this.#getFilePartHash(params.filePath, offset, length);
311
+ await this.#retry(() => axios.put(part.presigned_url, body || createReadStream(params.filePath, { start: offset, end: offset + length - 1 }), {
312
+ timeout: 300_000,
313
+ maxBodyLength: Infinity,
314
+ maxContentLength: Infinity,
315
+ headers: { 'Content-Length': String(length) }
316
+ }));
317
+ await this.#retry(() => this.groupService({
318
+ url: `${prefix}/upload_part_finish`,
319
+ method: 'post',
320
+ data: { upload_id: prepared.upload_id, part_index: part.index, block_size: length, md5: partMd5 }
321
+ }));
322
+ uploaded += length;
323
+ params.onProgress?.(uploaded, total);
324
+ }
325
+ return this.#retry(() => this.groupService({
326
+ url: `${prefix}/complete_upload`,
327
+ method: 'post',
328
+ data: { upload_id: prepared.upload_id }
329
+ }));
330
+ }
331
+ #getMediaBufferHashes(data) {
332
+ return {
333
+ sha256: createHash('sha256').update(data).digest('hex'),
334
+ md5: createHash('md5').update(data).digest('hex'),
335
+ sha1: createHash('sha1').update(data).digest('hex'),
336
+ md5_10m: createHash('md5').update(data.subarray(0, 10_002_432)).digest('hex')
337
+ };
338
+ }
339
+ async #getFilePartHash(filePath, start, length) {
340
+ const md5 = createHash('md5');
341
+ for await (const chunk of createReadStream(filePath, { start, end: start + length - 1 }))
342
+ md5.update(chunk);
343
+ return md5.digest('hex');
344
+ }
345
+ async #retry(operation, attempts = 3) {
346
+ let lastError;
347
+ for (let attempt = 0; attempt < attempts; attempt++) {
348
+ try {
349
+ return await operation();
350
+ }
351
+ catch (error) {
352
+ lastError = error;
353
+ if (attempt + 1 < attempts)
354
+ await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
355
+ }
356
+ }
357
+ throw lastError;
358
+ }
121
359
  userMessageDelete(openid, message_id) {
122
360
  return this.groupService({
123
361
  url: `/v2/users/${openid}/messages/${message_id}`,
@@ -3,7 +3,6 @@ import bodyParser from 'koa-bodyparser';
3
3
  import Router from 'koa-router';
4
4
  import { WebhookAPI } from './webhook.secret.js';
5
5
  import Koa from 'koa';
6
- import { config } from './config.js';
7
6
  import { v4 } from 'uuid';
8
7
  import { WebSocketServer, WebSocket } from 'ws';
9
8
 
@@ -23,10 +22,7 @@ class QQBotClient extends QQBotAPI {
23
22
  #client = [];
24
23
  #ws = null;
25
24
  constructor(opstion) {
26
- super();
27
- for (const key in opstion) {
28
- config.set(key, opstion[key]);
29
- }
25
+ super(opstion);
30
26
  }
31
27
  on(key, val) {
32
28
  if (!this.#events[key]) {
@@ -37,13 +33,13 @@ class QQBotClient extends QQBotAPI {
37
33
  }
38
34
  async #setTimeoutBotConfig() {
39
35
  const callBack = async () => {
40
- const app_id = config.get('app_id');
41
- const secret = config.get('secret');
36
+ const app_id = this.config.get('app_id');
37
+ const secret = this.config.get('secret');
42
38
  if (!app_id || !secret) {
43
39
  return;
44
40
  }
45
41
  const data = await this.getAuthentication();
46
- config.set('access_token', data.access_token);
42
+ this.config.set('access_token', data.access_token);
47
43
  console.info('refresh', data.expires_in, 's');
48
44
  setTimeout(() => void callBack(), data.expires_in * 1000);
49
45
  };
@@ -51,15 +47,16 @@ class QQBotClient extends QQBotAPI {
51
47
  }
52
48
  connect() {
53
49
  try {
54
- const ws = config.get('ws');
50
+ const ws = this.config.get('ws');
51
+ this.updateConnectionStatus({ state: 'connecting', transport: ws ? 'proxy' : 'webhook' });
55
52
  if (!ws) {
56
53
  void this.#setTimeoutBotConfig();
57
54
  this.#app = new Koa();
58
55
  this.#app.use(bodyParser());
59
56
  const router = new Router();
60
- const port = config.get('port');
61
- const secret = config.get('secret');
62
- const route = config.get('route') ?? '/webhook';
57
+ const port = this.config.get('port');
58
+ const secret = this.config.get('secret');
59
+ const route = this.config.get('route') ?? '/webhook';
63
60
  const cfg = {
64
61
  secret: secret ?? '',
65
62
  port: port ? Number(port) : 17157
@@ -96,7 +93,7 @@ class QQBotClient extends QQBotAPI {
96
93
  for (const event of this.#events[body.t] || []) {
97
94
  event(body.d);
98
95
  }
99
- const accessToken = config.get('access_token');
96
+ const accessToken = this.config.get('access_token');
100
97
  for (const client of this.#client) {
101
98
  try {
102
99
  if (accessToken) {
@@ -114,6 +111,7 @@ class QQBotClient extends QQBotAPI {
114
111
  this.#app.use(router.allowedMethods());
115
112
  const server = this.#app.listen(cfg.port, () => {
116
113
  console.log('Server running at http://localhost:' + cfg.port + route);
114
+ this.updateConnectionStatus({ state: 'ready' });
117
115
  });
118
116
  const wss = new WebSocketServer({ server: server });
119
117
  console.log('Server running at ws://localhost:' + cfg.port + '/');
@@ -144,6 +142,7 @@ class QQBotClient extends QQBotAPI {
144
142
  this.#ws = new WebSocket(ws);
145
143
  this.#ws.on('open', () => {
146
144
  this.#count = 0;
145
+ this.updateConnectionStatus({ state: 'ready' });
147
146
  console.log('ws connected');
148
147
  });
149
148
  this.#ws.on('message', data => {
@@ -151,7 +150,7 @@ class QQBotClient extends QQBotAPI {
151
150
  const body = normalizeGatewayMessage(JSON.parse(data.toString()));
152
151
  const accessToken = body['access_token'];
153
152
  if (accessToken) {
154
- config.set('access_token', accessToken);
153
+ this.config.set('access_token', accessToken);
155
154
  }
156
155
  for (const event of this.#events[body.t] || []) {
157
156
  event(body.d);
@@ -162,6 +161,7 @@ class QQBotClient extends QQBotAPI {
162
161
  }
163
162
  });
164
163
  this.#ws.on('close', () => {
164
+ this.updateConnectionStatus({ state: 'offline' });
165
165
  console.log('ws closed');
166
166
  if (this.#count > 5) {
167
167
  return;
@@ -3,7 +3,8 @@ import { QQBotEventMap } from './message.js';
3
3
  import { Options } from './typing.js';
4
4
  export declare class QQBotClients extends QQBotAPI {
5
5
  #private;
6
- constructor(opstion: Options);
6
+ constructor(option: Options);
7
+ disconnect(): void;
7
8
  on<T extends keyof QQBotEventMap>(key: T, val: (event: QQBotEventMap[T]) => any): this;
8
9
  connect(gatewayURL?: string): Promise<void>;
9
10
  }