@alemonjs/qq-bot 2.1.32 → 2.1.33

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/sends.js CHANGED
@@ -1,11 +1,31 @@
1
1
  import { readFileSync } from 'fs';
2
- import { createResult, ResultCode } from 'alemonjs';
2
+ import { createResult, ResultCode, logger } from 'alemonjs';
3
3
  import axios from 'axios';
4
4
  import { dataEnumToText, markdownToText, buttonsToText } from './format.js';
5
5
  import { getQQBotConfig } from './config.js';
6
6
 
7
7
  const MAX_BUTTON_ROWS = 5;
8
8
  const MAX_BUTTONS_PER_ROW = 5;
9
+ const BUTTON_STYLE_MAP = {
10
+ gray: 0,
11
+ blue: 1,
12
+ red: 3,
13
+ 'blue-fill': 4
14
+ };
15
+ const resolveButtonStyle = (style) => {
16
+ if (typeof style === 'number') {
17
+ return style;
18
+ }
19
+ if (typeof style === 'string') {
20
+ if (BUTTON_STYLE_MAP[style] !== undefined) {
21
+ return BUTTON_STYLE_MAP[style];
22
+ }
23
+ if (style.trim() !== '' && !Number.isNaN(Number(style))) {
24
+ return Number(style);
25
+ }
26
+ }
27
+ return 0;
28
+ };
9
29
  const createButtonsData = (rows, startId = 0) => {
10
30
  let id = startId;
11
31
  const clippedRows = rows.slice(0, MAX_BUTTON_ROWS);
@@ -18,25 +38,45 @@ const createButtonsData = (rows, startId = 0) => {
18
38
  const typing = options?.type ?? 'command';
19
39
  const typeMap = { command: 2, link: 0, call: 1 };
20
40
  const rowData = options?.rawData ?? {};
41
+ const data = options?.data;
42
+ const modal = options?.modal ??
43
+ (typeof data === 'object' && data
44
+ ? {
45
+ content: data.click,
46
+ confirmText: data.confirm,
47
+ cancelText: data.cancel
48
+ }
49
+ : undefined);
50
+ const action = {
51
+ type: typeMap[typing],
52
+ permission: {
53
+ type: typeof options.permission?.type === 'undefined' ? 2 : options?.permission?.type,
54
+ specify_user_ids: options?.permission?.userIds,
55
+ specify_role_ids: options?.permission?.roleIds
56
+ },
57
+ unsupport_tips: options?.toolTip ?? '',
58
+ data: typeof data === 'string' ? data : '',
59
+ at_bot_show_channel_list: options?.atBotShowChannelList ?? false,
60
+ enter: options?.autoEnter ?? false,
61
+ reply: options?.reply ?? false,
62
+ anchor: options?.anchor ?? 0,
63
+ click_limit: options?.clickLimit
64
+ };
65
+ if (modal) {
66
+ action.modal = {
67
+ content: modal.content ?? '是否确认操作?',
68
+ confirm_text: modal.confirmText ?? '是',
69
+ cancel_text: modal.cancelText ?? '否'
70
+ };
71
+ }
21
72
  return {
22
73
  id: String(id),
23
74
  render_data: {
24
75
  label: value,
25
76
  visited_label: value,
26
- style: options.style !== 'blue' ? 0 : 1
27
- },
28
- action: {
29
- type: typeMap[typing],
30
- permission: {
31
- type: typeof options.permission?.type === 'undefined' ? 2 : options?.permission?.type,
32
- specify_user_ids: options?.permission?.userIds,
33
- specify_role_ids: options?.permission?.roleIds
34
- },
35
- unsupport_tips: options?.toolTip ?? '',
36
- data: options?.data ?? '',
37
- at_bot_show_channel_list: false,
38
- enter: options?.autoEnter ?? false
77
+ style: resolveButtonStyle(options?.style)
39
78
  },
79
+ action,
40
80
  ...rowData
41
81
  };
42
82
  })
@@ -235,6 +275,7 @@ const buildMdAndButtonsParams = (val) => {
235
275
  return null;
236
276
  }
237
277
  const params = {};
278
+ let keyboardExtra;
238
279
  for (const item of items) {
239
280
  if (item.type === 'ButtonTemplate') {
240
281
  if (item?.value) {
@@ -242,6 +283,13 @@ const buildMdAndButtonsParams = (val) => {
242
283
  }
243
284
  }
244
285
  else if (item.type === 'BT.group' && typeof item?.value !== 'string') {
286
+ const groupOptions = item?.options ?? {};
287
+ if (groupOptions.smallButton || groupOptions.rawData) {
288
+ keyboardExtra = {
289
+ smallButton: keyboardExtra?.smallButton || groupOptions.smallButton,
290
+ rawData: groupOptions.rawData
291
+ };
292
+ }
245
293
  if (params['keyboard']?.content?.rows) {
246
294
  const existingRows = params['keyboard'].content.rows;
247
295
  const currentId = params['keyboard'].content.nextId ?? existingRows.length;
@@ -277,6 +325,14 @@ const buildMdAndButtonsParams = (val) => {
277
325
  }
278
326
  }
279
327
  }
328
+ if (keyboardExtra && params['keyboard']?.content) {
329
+ if (keyboardExtra.smallButton) {
330
+ params['keyboard'].content.style = { font_size: 'small' };
331
+ }
332
+ if (keyboardExtra.rawData) {
333
+ Object.assign(params['keyboard'].content, keyboardExtra.rawData);
334
+ }
335
+ }
280
336
  if (params['keyboard']?.content?.nextId !== undefined) {
281
337
  delete params['keyboard'].content.nextId;
282
338
  }
@@ -301,50 +357,66 @@ const buildArkParams = (val) => {
301
357
  }
302
358
  return params;
303
359
  };
360
+ const MEDIA_FILE_TYPE = {
361
+ Image: 1,
362
+ ImageFile: 1,
363
+ ImageURL: 1,
364
+ Video: 2,
365
+ Audio: 3,
366
+ Attachment: 4
367
+ };
368
+ const filterMedia = (val) => {
369
+ return val.filter(item => MEDIA_FILE_TYPE[item.type] !== undefined);
370
+ };
304
371
  const filterImages = (val) => {
305
372
  return val.filter(item => item.type === 'Image' || item.type === 'ImageFile' || item.type === 'ImageURL');
306
373
  };
307
- const resolveRichMediaUrl = async (items, uploadMedia) => {
308
- for (const item of items) {
309
- let fileData;
310
- let fileInfo;
311
- const fileType = item.type === 'Video' ? 2 : item.type === 'Audio' ? 3 : item.type === 'Attachment' ? 4 : 1;
312
- if (item.type === 'ImageURL') {
313
- fileData = await axios.get(item.value, { responseType: 'arraybuffer' }).then(res => Buffer.from(res.data, 'binary').toString('base64'));
374
+ const resolveFileData = async (item) => {
375
+ if (item.type === 'ImageURL') {
376
+ return await axios.get(item.value, { responseType: 'arraybuffer' }).then(res => Buffer.from(res.data, 'binary').toString('base64'));
377
+ }
378
+ if (item.type === 'ImageFile') {
379
+ return readFileSync(item.value, 'base64');
380
+ }
381
+ const value = item.value;
382
+ if (typeof value === 'string') {
383
+ if (value.startsWith('https://') || value.startsWith('http://')) {
384
+ return await axios.get(value, { responseType: 'arraybuffer' }).then(res => Buffer.from(res.data, 'binary').toString('base64'));
314
385
  }
315
- else if (item.type === 'Image' || item.type === 'Audio' || item.type === 'Video' || item.type === 'Attachment') {
316
- if (typeof item.value === 'string' && (item.value.startsWith('https://') || item.value.startsWith('http://'))) {
317
- fileData = await axios.get(item.value, { responseType: 'arraybuffer' }).then(res => Buffer.from(res.data, 'binary').toString('base64'));
318
- }
319
- else if (typeof item.value === 'string' && item.value.startsWith('file://')) {
320
- const localFilePath = item.value.replace('file://', '');
321
- fileData = readFileSync(localFilePath, 'base64');
322
- }
323
- else if (typeof item.value === 'string' && item.value.startsWith('base64://')) {
324
- fileData = item.value.replace('base64://', '');
325
- }
326
- else if (Buffer.isBuffer(item.value)) {
327
- fileData = item.value.toString('base64');
328
- }
329
- else if (typeof item.value === 'string' && item.value.startsWith('buffer://')) {
330
- fileData = item.value.replace('buffer://', '');
331
- }
332
- else if (typeof item.value === 'string') {
333
- fileData = item.value;
334
- }
386
+ if (value.startsWith('buffer://')) {
387
+ return value.replace('buffer://', '');
335
388
  }
336
- else if (item.type === 'ImageFile') {
337
- fileData = readFileSync(item.value, 'base64');
389
+ if (value.startsWith('file://')) {
390
+ return readFileSync(value.replace('file://', ''), 'base64');
338
391
  }
339
- if (fileData) {
340
- fileInfo = await uploadMedia({ file_type: fileType, file_data: fileData }).then(res => res?.file_info);
392
+ if (value.startsWith('base64://')) {
393
+ return value.replace('base64://', '');
341
394
  }
342
- if (fileInfo) {
343
- return fileInfo;
395
+ return value;
396
+ }
397
+ if (Buffer.isBuffer(value)) {
398
+ return value.toString('base64');
399
+ }
400
+ return undefined;
401
+ };
402
+ const resolveMediaUrl = async (media, uploadMedia) => {
403
+ for (const item of media) {
404
+ const fileData = await resolveFileData(item);
405
+ if (fileData) {
406
+ const fileInfo = await uploadMedia({ file_type: MEDIA_FILE_TYPE[item.type] ?? 1, file_data: fileData }).then(res => res?.file_info);
407
+ if (fileInfo) {
408
+ return fileInfo;
409
+ }
344
410
  }
345
411
  }
346
412
  return undefined;
347
413
  };
414
+ const stripMediaPlaceholders = (text) => text
415
+ .replace(/\[附件[^\]]*\]/g, '')
416
+ .replace(/\[音频\]/g, '')
417
+ .replace(/\[视频\]/g, '')
418
+ .replace(/\n{2,}/g, '\n')
419
+ .trim();
348
420
  const flattenMdToText = (content, val) => {
349
421
  const mdItems = val.filter(item => item.type === 'Markdown');
350
422
  const btnItems = val.filter(item => item.type === 'BT.group');
@@ -364,32 +436,18 @@ const flattenMdToText = (content, val) => {
364
436
  .join('\n')
365
437
  .replace(/^[^\S\n\r]+|[^\S\n\r]+$/g, '');
366
438
  };
367
- const sendOpenApiMessage = async (content, val, baseParams, uploadMedia, sendMessage, label) => {
439
+ const sendOpenApiMessage = async (content, val, baseParams, uploadMedia, sendMessage, label, options) => {
368
440
  const config = getQQBotConfig();
369
441
  const mdToText = config.markdownToText === true;
370
- const images = filterImages(val);
371
- const richMedia = val.filter(item => item.type === 'Audio' || item.type === 'Video' || item.type === 'Attachment');
372
- if (images.length > 0) {
373
- const url = await resolveRichMediaUrl(images, uploadMedia);
374
- if (!url) {
375
- return [createResult(ResultCode.Fail, '图片上传失败', null)];
376
- }
377
- const imgContent = flattenMdToText(content, val);
378
- const res = await sendMessage({
379
- content: imgContent,
380
- media: { file_info: url },
381
- msg_type: 7,
382
- ...baseParams
383
- });
384
- return [createResult(ResultCode.Ok, label, { id: res.id })];
385
- }
386
- if (richMedia.length > 0) {
387
- const fileInfo = await resolveRichMediaUrl(richMedia, uploadMedia);
442
+ const media = filterMedia(val);
443
+ if (media.length > 0) {
444
+ const fileInfo = await resolveMediaUrl(media, uploadMedia);
388
445
  if (!fileInfo) {
389
- return [createResult(ResultCode.Fail, '富媒体上传失败', null)];
446
+ return [createResult(ResultCode.Fail, '媒体上传失败', null)];
390
447
  }
448
+ const mediaContent = stripMediaPlaceholders(flattenMdToText(content, val));
391
449
  const res = await sendMessage({
392
- content: flattenMdToText(content, val),
450
+ content: mediaContent,
393
451
  media: { file_info: fileInfo },
394
452
  msg_type: 7,
395
453
  ...baseParams
@@ -413,7 +471,7 @@ const sendOpenApiMessage = async (content, val, baseParams, uploadMedia, sendMes
413
471
  if (mdParams.markdown?.content && content) {
414
472
  mdParams.markdown.content = content + '\n' + mdParams.markdown.content;
415
473
  }
416
- const res = await sendMessage({ content, msg_type: 2, ...mdParams, ...baseParams });
474
+ const res = await sendMessage({ content, msg_type: 2, ...mdParams, ...baseParams, force_verify_image_resource: options?.forceVerifyImageResource });
417
475
  return [createResult(ResultCode.Ok, label, { id: res.id })];
418
476
  }
419
477
  const arkParams = buildArkParams(val);
@@ -494,21 +552,21 @@ const sendGuildMessage = async (content, val, baseParams, sendMessage, label) =>
494
552
  }
495
553
  return [];
496
554
  };
497
- const GROUP_AT_MESSAGE_CREATE = async (client, event, val) => {
555
+ const GROUP_AT_MESSAGE_CREATE = async (client, event, val, options) => {
498
556
  const baseParams = buildBaseParams(event._tag, event.MessageId, 'INTERACTION_CREATE_GROUP');
499
557
  const content = extractContent(val, 'group');
500
558
  try {
501
- return await sendOpenApiMessage(content, val, baseParams, data => client.postRichMediaByGroup(event.ChannelId, data), data => client.groupOpenMessages(event.ChannelId, data), 'client.groupOpenMessages');
559
+ return await sendOpenApiMessage(content, val, baseParams, data => client.postRichMediaByGroup(event.ChannelId, data), data => client.groupOpenMessages(event.ChannelId, data), 'client.groupOpenMessages', options);
502
560
  }
503
561
  catch (err) {
504
562
  return [createResult(ResultCode.Fail, err?.response?.data ?? err?.message ?? err, null)];
505
563
  }
506
564
  };
507
- const C2C_MESSAGE_CREATE = async (client, event, val) => {
565
+ const C2C_MESSAGE_CREATE = async (client, event, val, options) => {
508
566
  const baseParams = buildBaseParams(event._tag, event.MessageId, 'INTERACTION_CREATE_C2C');
509
567
  const content = extractContent(val, 'group');
510
568
  try {
511
- return await sendOpenApiMessage(content, val, baseParams, data => client.postRichMediaByUser(event.UserId, data), data => client.usersOpenMessages(event.UserId, data), 'client.usersOpenMessages');
569
+ return await sendOpenApiMessage(content, val, baseParams, data => client.postRichMediaByUser(event.UserId, data), data => client.usersOpenMessages(event.UserId, data), 'client.usersOpenMessages', options);
512
570
  }
513
571
  catch (err) {
514
572
  return [createResult(ResultCode.Fail, err?.response?.data ?? err?.message ?? err, null)];
@@ -0,0 +1,21 @@
1
+ import type { QQBotAPI } from './sdk/api';
2
+ import { FileType } from './sdk/typing';
3
+ export declare const CHUNK_THRESHOLD = 10002432;
4
+ export declare const fileDataToBuffer: (fileData: any) => Buffer | null;
5
+ export declare const computeFileDigests: (filePath: string) => {
6
+ buffer: NonSharedBuffer;
7
+ md5: string;
8
+ sha1: string;
9
+ md5_10m: string;
10
+ };
11
+ export declare const chunkedUpload: (client: QQBotAPI, scope: "user" | "group", openId: string, file: string | Buffer, options: {
12
+ file_type: FileType;
13
+ file_name?: string;
14
+ srv_send_msg?: boolean;
15
+ }) => Promise<{
16
+ file_uuid: string;
17
+ file_info: string;
18
+ ttl: number;
19
+ id?: string;
20
+ raw_url?: string;
21
+ }>;
package/lib/upload.js ADDED
@@ -0,0 +1,60 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync } from 'fs';
3
+
4
+ const CHUNK_THRESHOLD = 10002432;
5
+ const fileDataToBuffer = (fileData) => {
6
+ if (!fileData) {
7
+ return null;
8
+ }
9
+ if (Buffer.isBuffer(fileData)) {
10
+ return fileData;
11
+ }
12
+ if (typeof fileData === 'string') {
13
+ if (fileData.indexOf('base64://') === 0) {
14
+ return Buffer.from(fileData.replace('base64://', ''), 'base64');
15
+ }
16
+ return Buffer.from(fileData, 'base64');
17
+ }
18
+ return null;
19
+ };
20
+ const computeFileDigests = (filePath) => {
21
+ const buffer = readFileSync(filePath);
22
+ const md5 = createHash('md5').update(buffer).digest('hex');
23
+ const sha1 = createHash('sha1').update(buffer).digest('hex');
24
+ const md5_10m = createHash('md5').update(buffer.subarray(0, CHUNK_THRESHOLD)).digest('hex');
25
+ return { buffer, md5, sha1, md5_10m };
26
+ };
27
+ const chunkedUpload = async (client, scope, openId, file, options) => {
28
+ const buffer = typeof file === 'string' ? readFileSync(file) : file;
29
+ const md5 = createHash('md5').update(buffer).digest('hex');
30
+ const sha1 = createHash('sha1').update(buffer).digest('hex');
31
+ const md5_10m = createHash('md5').update(buffer.subarray(0, CHUNK_THRESHOLD)).digest('hex');
32
+ const file_name = options.file_name ?? (typeof file === 'string' ? (file.split(/[\\/]/).pop() ?? 'file') : 'file');
33
+ const prepareData = {
34
+ file_type: options.file_type,
35
+ file_name,
36
+ file_size: String(buffer.byteLength),
37
+ md5,
38
+ sha1,
39
+ md5_10m
40
+ };
41
+ const prepare = await (scope === 'user' ? client.usersUploadPrepare(openId, prepareData) : client.groupUploadPrepare(openId, prepareData));
42
+ const blockSize = Number(prepare.block_size);
43
+ for (const part of prepare.parts) {
44
+ const start = part.index * blockSize;
45
+ const chunk = Buffer.from(buffer.subarray(start, start + Number(part.block_size)));
46
+ const partMd5 = createHash('md5').update(chunk).digest('hex');
47
+ await client.uploadPartDirect(part.presigned_url, chunk);
48
+ const finishData = {
49
+ upload_id: prepare.upload_id,
50
+ part_index: part.index,
51
+ block_size: part.block_size,
52
+ md5: partMd5
53
+ };
54
+ await (scope === 'user' ? client.usersUploadPartFinish(openId, finishData) : client.groupUploadPartFinish(openId, finishData));
55
+ }
56
+ const merge = { file_type: options.file_type, upload_id: prepare.upload_id, file_name, srv_send_msg: options.srv_send_msg };
57
+ return scope === 'user' ? client.postRichMediaByUser(openId, merge) : client.postRichMediaByGroup(openId, merge);
58
+ };
59
+
60
+ export { CHUNK_THRESHOLD, chunkedUpload, computeFileDigests, fileDataToBuffer };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alemonjs/qq-bot",
3
- "version": "2.1.32",
4
- "description": "阿柠檬qq-bot平台连接",
3
+ "version": "2.1.33",
4
+ "description": "QQ官方平台",
5
5
  "author": "lemonade",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -55,7 +55,7 @@
55
55
  }
56
56
  ],
57
57
  "config-source": {
58
- "readme": "https://github.com/lemonade-lab/alemonjs/blob/main/packages/qq-bot/README.md",
58
+ "readme": "https://raw.githubusercontent.com/lemonade-lab/alemonjs/main/packages/qq-bot/README.md",
59
59
  "official": "https://bot.q.qq.com/wiki/develop/api-v2/",
60
60
  "platform": "https://q.qq.com/"
61
61
  },
@@ -94,5 +94,5 @@
94
94
  "type": "git",
95
95
  "url": "https://github.com/lemonade-lab/alemonjs.git"
96
96
  },
97
- "gitHead": "ebea3f64e13a92c671a0ecd8af856c17ca44846d"
97
+ "gitHead": "6c93b0455b8db78ffa1a83becc1c2de19ba544db"
98
98
  }