@karinjs/adapter-qqbot 2.0.0 → 2.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/README.md +102 -255
- package/dist/apps/cleanup.d.ts +5 -0
- package/dist/apps/cleanup.js +22 -0
- package/dist/apps/login.d.ts +1 -3
- package/dist/apps/login.js +65 -16
- package/dist/common-by1GpV0b.js +49 -0
- package/dist/{config-H7LrvlNU.js → config-DKa7596f.js} +21 -48
- package/dist/index.d.ts +490 -19
- package/dist/index.js +2118 -220
- package/dist/message-O73AeL_P.js +1739 -0
- package/dist/{onboard-BX8g8Wik.js → onboard-DPWgX39H.js} +10 -7
- package/dist/utils-BPbnNAix.js +4 -0
- package/dist/web.config.d.ts +3 -3
- package/dist/web.config.js +18 -2
- package/package.json +5 -10
- package/dist/utils-DO4gEkVN.js +0 -3
package/dist/index.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import "./
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
1
|
+
import { c as log, l as basename, n as config, o as pkg, r as getConfig, t as bindHandlers } from "./config-DKa7596f.js";
|
|
2
|
+
import { i as random, n as getImageSize, r as handleUrl, t as fakeEvent } from "./common-by1GpV0b.js";
|
|
3
|
+
import "./utils-BPbnNAix.js";
|
|
4
|
+
import { t as getMessageStore } from "./message-O73AeL_P.js";
|
|
5
|
+
import { t as needQrOnboard } from "./onboard-DPWgX39H.js";
|
|
6
|
+
import { AdapterBase, app, buttonHandle, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, unregisterBot } from "node-karin";
|
|
5
7
|
import { URL } from "url";
|
|
6
8
|
import lodash from "node-karin/lodash";
|
|
9
|
+
import { createReadStream } from "node:fs";
|
|
7
10
|
import FormData from "form-data";
|
|
8
11
|
import axios from "node-karin/axios";
|
|
9
|
-
import
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath as fileURLToPath$1 } from "node:url";
|
|
10
16
|
import os from "node:os";
|
|
17
|
+
import EventEmitter from "node:events";
|
|
11
18
|
import { WebSocket } from "node-karin/ws";
|
|
12
19
|
import express from "node-karin/express";
|
|
13
20
|
import nacl from "tweetnacl";
|
|
@@ -364,7 +371,11 @@ const formatError = (path, options, err) => {
|
|
|
364
371
|
const lines = [];
|
|
365
372
|
lines.push("[axios] 请求失败");
|
|
366
373
|
lines.push(`请求路径: ${path}`);
|
|
367
|
-
lines.push(`请求数据: ${lodash.truncate(JSON.stringify(options), { length: 500 })}`);
|
|
374
|
+
lines.push(`请求数据: ${lodash.truncate(JSON.stringify(redactRequestData(options)), { length: 500 })}`);
|
|
375
|
+
if (!response) {
|
|
376
|
+
const reason = [err.code, err.message].filter(Boolean).join(" | ");
|
|
377
|
+
if (reason) lines.push(`请求错误: ${reason}`);
|
|
378
|
+
}
|
|
368
379
|
if (code !== void 0 || status > 0) lines.push(`错误详情: ${formatOpenAPIError(status, code, msg)}`);
|
|
369
380
|
lines.push(`响应数据: ${JSON.stringify(data)}`);
|
|
370
381
|
return new Error(lines.join("\n"));
|
|
@@ -373,6 +384,20 @@ const formatError = (path, options, err) => {
|
|
|
373
384
|
return new Error(typeof err === "string" ? err : JSON.stringify(err));
|
|
374
385
|
};
|
|
375
386
|
/**
|
|
387
|
+
* 请求失败日志脱敏,避免富媒体 file_data/base64 被完整 stringify。
|
|
388
|
+
* @param value 请求数据。
|
|
389
|
+
* @returns 脱敏后的请求数据。
|
|
390
|
+
*/
|
|
391
|
+
const redactRequestData = (value) => {
|
|
392
|
+
if (!value || typeof value !== "object") return value;
|
|
393
|
+
if (Array.isArray(value)) return value.map((item) => redactRequestData(item));
|
|
394
|
+
const source = value;
|
|
395
|
+
const result = {};
|
|
396
|
+
for (const [key, item] of Object.entries(source)) if (key === "file_data" && typeof item === "string") result[key] = `<redacted ${item.length} chars>`;
|
|
397
|
+
else result[key] = redactRequestData(item);
|
|
398
|
+
return result;
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
376
401
|
* Http 基础类,子模块继承此类共享 axios 实例与错误格式化
|
|
377
402
|
*/
|
|
378
403
|
var Http = class {
|
|
@@ -388,17 +413,23 @@ var Http = class {
|
|
|
388
413
|
throw formatError(path, void 0, err);
|
|
389
414
|
}
|
|
390
415
|
}
|
|
391
|
-
async post(path, body = {}, headers) {
|
|
416
|
+
async post(path, body = {}, headers, timeout) {
|
|
392
417
|
try {
|
|
393
|
-
const { data } = await this.axios.post(path, body, {
|
|
418
|
+
const { data } = await this.axios.post(path, body, {
|
|
419
|
+
headers,
|
|
420
|
+
timeout
|
|
421
|
+
});
|
|
394
422
|
return data;
|
|
395
423
|
} catch (err) {
|
|
396
424
|
throw formatError(path, body, err);
|
|
397
425
|
}
|
|
398
426
|
}
|
|
399
|
-
async put(path, body = {}, headers) {
|
|
427
|
+
async put(path, body = {}, headers, timeout) {
|
|
400
428
|
try {
|
|
401
|
-
const { data } = await this.axios.put(path, body, {
|
|
429
|
+
const { data } = await this.axios.put(path, body, {
|
|
430
|
+
headers,
|
|
431
|
+
timeout
|
|
432
|
+
});
|
|
402
433
|
return data;
|
|
403
434
|
} catch (err) {
|
|
404
435
|
throw formatError(path, body, err);
|
|
@@ -452,6 +483,92 @@ var MessagesApi = class extends Http {
|
|
|
452
483
|
}
|
|
453
484
|
};
|
|
454
485
|
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/core/api/upload-cache.ts
|
|
488
|
+
/** 单进程内最多缓存的 file_info 数量,避免长期运行时内存无限增长。 */
|
|
489
|
+
const MAX_CACHE_SIZE = 500;
|
|
490
|
+
/** 比 QQ 返回的 ttl 提前失效,避免临界点使用到刚过期的 file_info。 */
|
|
491
|
+
const TTL_SAFETY_MARGIN_SECONDS = 60;
|
|
492
|
+
/** QQ 返回极短 ttl 时仍保留一个最小可用窗口。 */
|
|
493
|
+
const MIN_EFFECTIVE_TTL_SECONDS = 10;
|
|
494
|
+
const cache = /* @__PURE__ */ new Map();
|
|
495
|
+
/**
|
|
496
|
+
* 构建 file_info 缓存键。
|
|
497
|
+
*
|
|
498
|
+
* 虽然 QQ 文档说同一类会话内 file_info 可复用,但这里仍按 peer 隔离,
|
|
499
|
+
* 以避开未文档化的服务端限制。
|
|
500
|
+
*
|
|
501
|
+
* @param scene 上传场景。
|
|
502
|
+
* @param peer openid 或 group_openid。
|
|
503
|
+
* @param type 富媒体类型。
|
|
504
|
+
* @param contentHash 文件内容 hash。
|
|
505
|
+
* @returns 缓存键。
|
|
506
|
+
*/
|
|
507
|
+
const cacheKey = (scene, peer, type, contentHash) => [
|
|
508
|
+
scene,
|
|
509
|
+
peer,
|
|
510
|
+
type,
|
|
511
|
+
contentHash
|
|
512
|
+
].join("");
|
|
513
|
+
/**
|
|
514
|
+
* 惰性清理过期缓存。
|
|
515
|
+
* @param now 当前时间戳。
|
|
516
|
+
*/
|
|
517
|
+
const cleanupExpired = (now = Date.now()) => {
|
|
518
|
+
for (const [key, entry] of cache) if (entry.expiresAt <= now) cache.delete(key);
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* 缓存满时淘汰最旧的一批条目。
|
|
522
|
+
*/
|
|
523
|
+
const evictOldest = () => {
|
|
524
|
+
if (cache.size < MAX_CACHE_SIZE) return;
|
|
525
|
+
cleanupExpired();
|
|
526
|
+
if (cache.size < MAX_CACHE_SIZE) return;
|
|
527
|
+
const entries = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
528
|
+
const removeCount = Math.max(1, Math.ceil(MAX_CACHE_SIZE / 2));
|
|
529
|
+
for (const [key] of entries.slice(0, removeCount)) cache.delete(key);
|
|
530
|
+
};
|
|
531
|
+
/**
|
|
532
|
+
* 读取可复用的上传结果。
|
|
533
|
+
*
|
|
534
|
+
* @param scene 上传场景。
|
|
535
|
+
* @param peer openid 或 group_openid。
|
|
536
|
+
* @param type 富媒体类型。
|
|
537
|
+
* @param contentHash 文件内容 hash。
|
|
538
|
+
* @returns 未命中或过期时返回 null。
|
|
539
|
+
*/
|
|
540
|
+
const getCachedUpload = (scene, peer, type, contentHash) => {
|
|
541
|
+
const key = cacheKey(scene, peer, type, contentHash);
|
|
542
|
+
const entry = cache.get(key);
|
|
543
|
+
if (!entry) return null;
|
|
544
|
+
if (entry.expiresAt <= Date.now()) {
|
|
545
|
+
cache.delete(key);
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
return { ...entry.response };
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* 写入上传结果缓存。
|
|
552
|
+
*
|
|
553
|
+
* @param scene 上传场景。
|
|
554
|
+
* @param peer openid 或 group_openid。
|
|
555
|
+
* @param type 富媒体类型。
|
|
556
|
+
* @param contentHash 文件内容 hash。
|
|
557
|
+
* @param response 上传接口返回。
|
|
558
|
+
*/
|
|
559
|
+
const setCachedUpload = (scene, peer, type, contentHash, response) => {
|
|
560
|
+
if (!response.file_info) return;
|
|
561
|
+
evictOldest();
|
|
562
|
+
const now = Date.now();
|
|
563
|
+
const ttl = Number(response.ttl);
|
|
564
|
+
const effectiveTtl = ttl > 0 ? Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : Number.POSITIVE_INFINITY;
|
|
565
|
+
cache.set(cacheKey(scene, peer, type, contentHash), {
|
|
566
|
+
response: { ...response },
|
|
567
|
+
expiresAt: ttl > 0 ? now + effectiveTtl : Number.POSITIVE_INFINITY,
|
|
568
|
+
createdAt: now
|
|
569
|
+
});
|
|
570
|
+
};
|
|
571
|
+
|
|
455
572
|
//#endregion
|
|
456
573
|
//#region src/core/api/media.ts
|
|
457
574
|
const FILE_TYPE = {
|
|
@@ -460,6 +577,360 @@ const FILE_TYPE = {
|
|
|
460
577
|
record: 3,
|
|
461
578
|
file: 4
|
|
462
579
|
};
|
|
580
|
+
/** 各类资源的 QQ 上传上限。 */
|
|
581
|
+
const MAX_UPLOAD_SIZE = {
|
|
582
|
+
image: 30 * 1024 * 1024,
|
|
583
|
+
video: 100 * 1024 * 1024,
|
|
584
|
+
record: 20 * 1024 * 1024,
|
|
585
|
+
file: 100 * 1024 * 1024
|
|
586
|
+
};
|
|
587
|
+
/** 上传错误中展示的资源类型名称。 */
|
|
588
|
+
const MEDIA_TYPE_LABEL = {
|
|
589
|
+
image: "图片",
|
|
590
|
+
video: "视频",
|
|
591
|
+
record: "语音",
|
|
592
|
+
file: "文件"
|
|
593
|
+
};
|
|
594
|
+
/** fallback 上传超过该大小时走 QQ 分片上传。 */
|
|
595
|
+
const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
|
|
596
|
+
/** QQ upload_prepare 需要的前 10,002,432 字节 MD5。 */
|
|
597
|
+
const MD5_10M_SIZE = 10002432;
|
|
598
|
+
/** 分片上传时没有业务文件名的默认值。 */
|
|
599
|
+
const DEFAULT_FILENAME$1 = {
|
|
600
|
+
image: "image.jpg",
|
|
601
|
+
video: "video.mp4",
|
|
602
|
+
record: "record.mp3",
|
|
603
|
+
file: "file.bin"
|
|
604
|
+
};
|
|
605
|
+
/** 富媒体上传比普通接口慢,尤其本地视频会走 file_data。 */
|
|
606
|
+
const MEDIA_UPLOAD_TIMEOUT = 12e4;
|
|
607
|
+
/** URL 回源下载兜底超时。 */
|
|
608
|
+
const MEDIA_DOWNLOAD_TIMEOUT = 12e4;
|
|
609
|
+
/** 每个分片 PUT 到 QQ 临时地址的超时时间。 */
|
|
610
|
+
const PART_UPLOAD_TIMEOUT = 3e5;
|
|
611
|
+
/** 每个分片 PUT 失败后的重试次数。 */
|
|
612
|
+
const PART_UPLOAD_RETRIES = 2;
|
|
613
|
+
/** upload_part_finish 普通失败重试次数。 */
|
|
614
|
+
const PART_FINISH_RETRIES = 2;
|
|
615
|
+
/** upload_part_finish 普通失败重试基础间隔。 */
|
|
616
|
+
const PART_FINISH_BASE_DELAY = 1e3;
|
|
617
|
+
/** upload_part_finish 命中这些业务码时,需要按服务端给出的窗口持续重试。 */
|
|
618
|
+
const PART_FINISH_RETRYABLE_CODES = /* @__PURE__ */ new Set([40093001]);
|
|
619
|
+
/** 服务端未返回 retry_timeout 时,持续重试的默认时间。 */
|
|
620
|
+
const PART_FINISH_DEFAULT_RETRY_TIMEOUT = 120 * 1e3;
|
|
621
|
+
/** 持续重试窗口上限,避免服务端返回异常值导致任务长时间占用。 */
|
|
622
|
+
const PART_FINISH_MAX_RETRY_TIMEOUT = 600 * 1e3;
|
|
623
|
+
/** 持续重试时的固定间隔。 */
|
|
624
|
+
const PART_FINISH_RETRY_INTERVAL = 1e3;
|
|
625
|
+
/** 分片完成接口失败重试次数。 */
|
|
626
|
+
const COMPLETE_UPLOAD_RETRIES = 2;
|
|
627
|
+
/** 分片完成接口失败重试基础间隔。 */
|
|
628
|
+
const COMPLETE_UPLOAD_BASE_DELAY = 2e3;
|
|
629
|
+
/** upload_prepare 返回该业务码时表示大文件上传额度受限。 */
|
|
630
|
+
const UPLOAD_PREPARE_DAILY_LIMIT_CODE = 40093002;
|
|
631
|
+
/** QQ 没返回建议并发数时的默认并发。 */
|
|
632
|
+
const DEFAULT_PART_CONCURRENCY = 1;
|
|
633
|
+
/** 本地限制的最大分片并发,避免服务端返回过大值。 */
|
|
634
|
+
const MAX_PART_CONCURRENCY = 10;
|
|
635
|
+
/** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
|
|
636
|
+
const HTTP_URL_RE$1 = /^https?:\/\//i;
|
|
637
|
+
/** Karin 常见 base64 图片/媒体前缀。 */
|
|
638
|
+
const BASE64_PREFIX_RE = /^base64:\/\//i;
|
|
639
|
+
/** 标准 data URL 的 base64 前缀。 */
|
|
640
|
+
const DATA_URL_BASE64_RE = /^data:[^;,]+;base64,/i;
|
|
641
|
+
/**
|
|
642
|
+
* 判断字符串是否明显是本地文件路径。
|
|
643
|
+
* 不明显的字符串按裸 base64 兼容处理,避免破坏旧调用方式。
|
|
644
|
+
*/
|
|
645
|
+
const isLocalPathSource = (source) => {
|
|
646
|
+
return source.startsWith("file://") || /^[a-zA-Z]:[\\/]/.test(source) || source.startsWith("/") || source.startsWith("\\") || source.startsWith("./") || source.startsWith("../") || source.startsWith(".\\") || source.startsWith("..\\");
|
|
647
|
+
};
|
|
648
|
+
/**
|
|
649
|
+
* 将 file:// 或普通路径统一为本地文件系统路径。
|
|
650
|
+
* @param source 本地路径或 file:// URL。
|
|
651
|
+
* @returns 可直接传给 fs 的本地路径。
|
|
652
|
+
*/
|
|
653
|
+
const normalizeLocalPath = (source) => {
|
|
654
|
+
return source.startsWith("file://") ? fileURLToPath$1(source) : source;
|
|
655
|
+
};
|
|
656
|
+
/**
|
|
657
|
+
* 去掉 Karin/标准 data URL 的 base64 前缀。
|
|
658
|
+
* @param source base64 字符串。
|
|
659
|
+
* @returns QQ 上传接口需要的纯 base64 数据。
|
|
660
|
+
*/
|
|
661
|
+
const stripBase64Prefix = (source) => {
|
|
662
|
+
return source.replace(DATA_URL_BASE64_RE, "").replace(BASE64_PREFIX_RE, "");
|
|
663
|
+
};
|
|
664
|
+
/**
|
|
665
|
+
* 将文件大小格式化为便于阅读的字符串。
|
|
666
|
+
* @param bytes 文件字节数。
|
|
667
|
+
* @returns 格式化后的文件大小。
|
|
668
|
+
*/
|
|
669
|
+
const formatBytes = (bytes) => {
|
|
670
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
671
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
672
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
673
|
+
};
|
|
674
|
+
/**
|
|
675
|
+
* 规范化上传文件名,避免把路径当成文件名交给 QQ。
|
|
676
|
+
* @param type 富媒体类型。
|
|
677
|
+
* @param name 候选文件名。
|
|
678
|
+
* @returns 安全的基础文件名。
|
|
679
|
+
*/
|
|
680
|
+
const normalizeUploadFileName = (type, name) => {
|
|
681
|
+
const value = name?.trim();
|
|
682
|
+
if (!value) return DEFAULT_FILENAME$1[type];
|
|
683
|
+
return path.basename(value) || DEFAULT_FILENAME$1[type];
|
|
684
|
+
};
|
|
685
|
+
/**
|
|
686
|
+
* 检查 fallback 资源大小是否超过 QQ 对应类型上限。
|
|
687
|
+
* @param type 富媒体类型。
|
|
688
|
+
* @param size 文件大小。
|
|
689
|
+
*/
|
|
690
|
+
const assertUploadSize = (type, size) => {
|
|
691
|
+
const limit = MAX_UPLOAD_SIZE[type];
|
|
692
|
+
if (size <= limit) return;
|
|
693
|
+
throw new Error(`${MEDIA_TYPE_LABEL[type]}过大:${formatBytes(size)},QQ 上传上限为 ${formatBytes(limit)}`);
|
|
694
|
+
};
|
|
695
|
+
/**
|
|
696
|
+
* 读取本地媒体文件并转为 QQ 上传接口需要的 base64。
|
|
697
|
+
* @param source 本地路径或 file:// URL。
|
|
698
|
+
* @returns 文件内容的 base64 字符串。
|
|
699
|
+
*/
|
|
700
|
+
const readLocalFileAsBase64 = async (source) => {
|
|
701
|
+
return readFile(normalizeLocalPath(source), "base64");
|
|
702
|
+
};
|
|
703
|
+
/**
|
|
704
|
+
* 下载远程媒体并返回 Buffer。
|
|
705
|
+
* @param url 远程媒体 URL。
|
|
706
|
+
* @returns 文件内容。
|
|
707
|
+
*/
|
|
708
|
+
const downloadRemoteAsBuffer = async (url) => {
|
|
709
|
+
const { data } = await axios.get(url, {
|
|
710
|
+
responseType: "arraybuffer",
|
|
711
|
+
timeout: MEDIA_DOWNLOAD_TIMEOUT,
|
|
712
|
+
maxBodyLength: Infinity,
|
|
713
|
+
maxContentLength: Infinity
|
|
714
|
+
});
|
|
715
|
+
return Buffer.from(data);
|
|
716
|
+
};
|
|
717
|
+
/**
|
|
718
|
+
* 下载远程媒体并转为 QQ 上传接口需要的 base64。
|
|
719
|
+
* @param url 远程媒体 URL。
|
|
720
|
+
* @returns 文件内容的 base64 字符串。
|
|
721
|
+
*/
|
|
722
|
+
const downloadRemoteAsBase64 = async (url) => {
|
|
723
|
+
return (await downloadRemoteAsBuffer(url)).toString("base64");
|
|
724
|
+
};
|
|
725
|
+
/**
|
|
726
|
+
* 将 Karin 的 file 字段归一为 QQ 上传接口支持的 url / file_data。
|
|
727
|
+
* @param source HTTP URL、本地路径、data URL、base64:// 或裸 base64。
|
|
728
|
+
* @returns 可合并进上传请求体的来源字段。
|
|
729
|
+
*/
|
|
730
|
+
const buildUploadSource = async (source) => {
|
|
731
|
+
const value = source.trim();
|
|
732
|
+
if (HTTP_URL_RE$1.test(value)) return { url: value };
|
|
733
|
+
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
|
|
734
|
+
if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
|
|
735
|
+
return { file_data: value };
|
|
736
|
+
};
|
|
737
|
+
/**
|
|
738
|
+
* 将 fallback 来源归一为可判断大小、可分片读取的形式。
|
|
739
|
+
*
|
|
740
|
+
* @param type 富媒体类型。
|
|
741
|
+
* @param source Karin 消息段里的 file 字段。
|
|
742
|
+
* @param fileName 可选文件名。
|
|
743
|
+
* @returns 可用于普通上传或分片上传的来源。
|
|
744
|
+
*/
|
|
745
|
+
const buildFallbackSource = async (type, source, fileName) => {
|
|
746
|
+
const value = source.trim();
|
|
747
|
+
const normalizedName = normalizeUploadFileName(type, fileName);
|
|
748
|
+
if (HTTP_URL_RE$1.test(value)) {
|
|
749
|
+
const buffer = await downloadRemoteAsBuffer(value);
|
|
750
|
+
return {
|
|
751
|
+
kind: "buffer",
|
|
752
|
+
buffer,
|
|
753
|
+
size: buffer.length,
|
|
754
|
+
fileName: normalizedName
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) {
|
|
758
|
+
const buffer = Buffer.from(stripBase64Prefix(value), "base64");
|
|
759
|
+
return {
|
|
760
|
+
kind: "buffer",
|
|
761
|
+
buffer,
|
|
762
|
+
size: buffer.length,
|
|
763
|
+
fileName: normalizedName
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
if (isLocalPathSource(value)) {
|
|
767
|
+
const file = normalizeLocalPath(value);
|
|
768
|
+
const info = await stat(file);
|
|
769
|
+
const name = normalizeUploadFileName(type, fileName || path.basename(file));
|
|
770
|
+
return {
|
|
771
|
+
kind: "local",
|
|
772
|
+
file,
|
|
773
|
+
size: info.size,
|
|
774
|
+
fileName: name
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
const buffer = Buffer.from(value, "base64");
|
|
778
|
+
return {
|
|
779
|
+
kind: "buffer",
|
|
780
|
+
buffer,
|
|
781
|
+
size: buffer.length,
|
|
782
|
+
fileName: normalizedName
|
|
783
|
+
};
|
|
784
|
+
};
|
|
785
|
+
/**
|
|
786
|
+
* 读取 fallback 来源的小文件内容并转成 base64。
|
|
787
|
+
* @param source fallback 来源。
|
|
788
|
+
* @returns base64 字符串。
|
|
789
|
+
*/
|
|
790
|
+
const readFallbackSourceAsBase64 = async (source) => {
|
|
791
|
+
if (source.kind === "buffer") return source.buffer.toString("base64");
|
|
792
|
+
return readFile(source.file, "base64");
|
|
793
|
+
};
|
|
794
|
+
/**
|
|
795
|
+
* 计算 QQ upload_prepare 所需的 hash。
|
|
796
|
+
*
|
|
797
|
+
* @param source fallback 来源。
|
|
798
|
+
* @returns 完整 MD5、完整 SHA1 与前 10MB MD5。
|
|
799
|
+
*/
|
|
800
|
+
const computeUploadHashes = async (source) => {
|
|
801
|
+
if (source.kind === "buffer") {
|
|
802
|
+
const md5 = crypto.createHash("md5").update(source.buffer).digest("hex");
|
|
803
|
+
return {
|
|
804
|
+
md5,
|
|
805
|
+
sha1: crypto.createHash("sha1").update(source.buffer).digest("hex"),
|
|
806
|
+
md510m: source.size > MD5_10M_SIZE ? crypto.createHash("md5").update(source.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return new Promise((resolve, reject) => {
|
|
810
|
+
const md5 = crypto.createHash("md5");
|
|
811
|
+
const sha1 = crypto.createHash("sha1");
|
|
812
|
+
const md510m = crypto.createHash("md5");
|
|
813
|
+
const needsMd510m = source.size > MD5_10M_SIZE;
|
|
814
|
+
let consumed = 0;
|
|
815
|
+
const stream = createReadStream(source.file);
|
|
816
|
+
stream.on("data", (chunk) => {
|
|
817
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
818
|
+
md5.update(buffer);
|
|
819
|
+
sha1.update(buffer);
|
|
820
|
+
if (needsMd510m) {
|
|
821
|
+
const remaining = MD5_10M_SIZE - consumed;
|
|
822
|
+
if (remaining > 0) md510m.update(remaining >= buffer.length ? buffer : buffer.subarray(0, remaining));
|
|
823
|
+
}
|
|
824
|
+
consumed += buffer.length;
|
|
825
|
+
});
|
|
826
|
+
stream.on("end", () => {
|
|
827
|
+
const md5Hex = md5.digest("hex");
|
|
828
|
+
resolve({
|
|
829
|
+
md5: md5Hex,
|
|
830
|
+
sha1: sha1.digest("hex"),
|
|
831
|
+
md510m: needsMd510m ? md510m.digest("hex") : md5Hex
|
|
832
|
+
});
|
|
833
|
+
});
|
|
834
|
+
stream.on("error", reject);
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
/**
|
|
838
|
+
* 从 fallback 来源读取指定分片。
|
|
839
|
+
* @param source fallback 来源。
|
|
840
|
+
* @param handle 本地文件句柄,buffer 来源不需要。
|
|
841
|
+
* @param offset 分片起始位置。
|
|
842
|
+
* @param length 分片长度。
|
|
843
|
+
* @returns 分片内容。
|
|
844
|
+
*/
|
|
845
|
+
const readPart = async (source, handle, offset, length) => {
|
|
846
|
+
if (source.kind === "buffer") return source.buffer.subarray(offset, offset + length);
|
|
847
|
+
if (!handle) throw new Error("读取分片失败:文件句柄不存在");
|
|
848
|
+
const buffer = Buffer.alloc(length);
|
|
849
|
+
const { bytesRead } = await handle.read(buffer, 0, length, offset);
|
|
850
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
851
|
+
};
|
|
852
|
+
/**
|
|
853
|
+
* 带有限重试地把单个分片 PUT 到 QQ 返回的临时地址。
|
|
854
|
+
*
|
|
855
|
+
* @param url QQ 返回的分片上传地址。
|
|
856
|
+
* @param data 分片内容。
|
|
857
|
+
* @param partIndex 分片序号。
|
|
858
|
+
* @param totalParts 总分片数。
|
|
859
|
+
*/
|
|
860
|
+
const putPart = async (url, data, partIndex, totalParts) => {
|
|
861
|
+
let lastError;
|
|
862
|
+
for (let attempt = 0; attempt <= PART_UPLOAD_RETRIES; attempt++) try {
|
|
863
|
+
await axios.put(url, data, {
|
|
864
|
+
headers: {
|
|
865
|
+
"Content-Type": "application/octet-stream",
|
|
866
|
+
"Content-Length": String(data.length)
|
|
867
|
+
},
|
|
868
|
+
timeout: PART_UPLOAD_TIMEOUT,
|
|
869
|
+
maxBodyLength: Infinity,
|
|
870
|
+
maxContentLength: Infinity
|
|
871
|
+
});
|
|
872
|
+
return;
|
|
873
|
+
} catch (err) {
|
|
874
|
+
lastError = err;
|
|
875
|
+
if (attempt < PART_UPLOAD_RETRIES) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
|
|
876
|
+
}
|
|
877
|
+
throw new Error(`上传分片失败:${partIndex}/${totalParts},${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
878
|
+
};
|
|
879
|
+
/**
|
|
880
|
+
* 使用固定并发执行异步任务。
|
|
881
|
+
* @param tasks 待执行任务。
|
|
882
|
+
* @param concurrency 最大并发。
|
|
883
|
+
*/
|
|
884
|
+
const runWithConcurrency = async (tasks, concurrency) => {
|
|
885
|
+
let index = 0;
|
|
886
|
+
const workerCount = Math.max(1, Math.min(concurrency, tasks.length));
|
|
887
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
888
|
+
while (index < tasks.length) {
|
|
889
|
+
const current = index;
|
|
890
|
+
index += 1;
|
|
891
|
+
await tasks[current]();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
await Promise.all(workers);
|
|
895
|
+
};
|
|
896
|
+
/**
|
|
897
|
+
* 等待指定时间。
|
|
898
|
+
* @param ms 毫秒数。
|
|
899
|
+
*/
|
|
900
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
901
|
+
/**
|
|
902
|
+
* 从 axios 原始错误或已格式化错误文本里提取 QQ 业务错误码。
|
|
903
|
+
* @param err 待检查的错误。
|
|
904
|
+
* @returns 业务错误码。
|
|
905
|
+
*/
|
|
906
|
+
const openApiCode = (err) => {
|
|
907
|
+
if (axios.isAxiosError(err)) {
|
|
908
|
+
const data = err.response?.data;
|
|
909
|
+
const code = typeof data?.code === "number" ? data.code : typeof data?.err_code === "number" ? data.err_code : void 0;
|
|
910
|
+
if (code !== void 0) return code;
|
|
911
|
+
}
|
|
912
|
+
if (err instanceof Error) {
|
|
913
|
+
const match = err.message.match(/\[Code\s+(\d+)\]/);
|
|
914
|
+
if (match) return Number(match[1]);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
/**
|
|
918
|
+
* 判断 upload_part_finish 是否命中需要持续重试的服务端状态。
|
|
919
|
+
* @param err 请求错误。
|
|
920
|
+
* @returns 是否需要持续重试。
|
|
921
|
+
*/
|
|
922
|
+
const shouldRetryPartFinishPersistently = (err) => {
|
|
923
|
+
const code = openApiCode(err);
|
|
924
|
+
return code !== void 0 && PART_FINISH_RETRYABLE_CODES.has(code);
|
|
925
|
+
};
|
|
926
|
+
/**
|
|
927
|
+
* 将未知错误重新抛出为 Error。
|
|
928
|
+
* @param err 原始错误。
|
|
929
|
+
*/
|
|
930
|
+
const throwError = (err) => {
|
|
931
|
+
if (err instanceof Error) throw err;
|
|
932
|
+
throw new Error(typeof err === "string" ? err : JSON.stringify(err));
|
|
933
|
+
};
|
|
463
934
|
/**
|
|
464
935
|
* 富媒体上传
|
|
465
936
|
*/
|
|
@@ -468,22 +939,190 @@ var MediaApi = class extends Http {
|
|
|
468
939
|
* @param scene user(单聊) / group(群聊)
|
|
469
940
|
* @param peer openid 或 group_openid
|
|
470
941
|
* @param type 文件类型
|
|
471
|
-
* @param source
|
|
942
|
+
* @param source http url、本地路径或 base64 字符串
|
|
472
943
|
* @param srvSendMsg 上传后是否由服务端直接发送
|
|
944
|
+
* @param fileName 文件消息的展示文件名
|
|
473
945
|
*/
|
|
474
|
-
upload(scene, peer, type, source, srvSendMsg = false) {
|
|
946
|
+
async upload(scene, peer, type, source, srvSendMsg = false, fileName) {
|
|
947
|
+
const sourceBody = await buildUploadSource(source);
|
|
475
948
|
const body = {
|
|
476
949
|
file_type: FILE_TYPE[type],
|
|
477
950
|
srv_send_msg: srvSendMsg
|
|
478
951
|
};
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
952
|
+
Object.assign(body, sourceBody);
|
|
953
|
+
if (type === "file" && fileName) body.file_name = fileName;
|
|
954
|
+
const path = `/v2/${scene}s/${peer}/files`;
|
|
955
|
+
try {
|
|
956
|
+
return await this.post(path, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
957
|
+
} catch (err) {
|
|
958
|
+
if (!sourceBody.url) throw err;
|
|
959
|
+
const fallbackBody = {
|
|
960
|
+
...body,
|
|
961
|
+
file_data: await downloadRemoteAsBase64(sourceBody.url)
|
|
962
|
+
};
|
|
963
|
+
delete fallbackBody.url;
|
|
964
|
+
return this.post(path, fallbackBody, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* 上传没有公网 URL 处理器可用时的 fallback 富媒体资源。
|
|
969
|
+
*
|
|
970
|
+
* 较大的 fallback 资源会优先使用 QQ 分片上传,提高直接发送成功率。
|
|
971
|
+
* `srvSendMsg` 为 true 时保留普通上传路径,避免分片完成接口与服务端直接发送语义冲突。
|
|
972
|
+
*
|
|
973
|
+
* @param scene user(单聊) / group(群聊)
|
|
974
|
+
* @param peer openid 或 group_openid
|
|
975
|
+
* @param type 文件类型
|
|
976
|
+
* @param source 本地路径、http url、data URL、base64:// 或裸 base64
|
|
977
|
+
* @param srvSendMsg 上传后是否由服务端直接发送
|
|
978
|
+
* @param fileName 文件消息的展示文件名
|
|
979
|
+
* @returns 上传结果,包含后续发送消息所需的 file_info
|
|
980
|
+
*/
|
|
981
|
+
async uploadFallback(scene, peer, type, source, srvSendMsg = false, fileName) {
|
|
982
|
+
const fallbackSource = await buildFallbackSource(type, source, fileName);
|
|
983
|
+
assertUploadSize(type, fallbackSource.size);
|
|
984
|
+
const hashes = !srvSendMsg ? await computeUploadHashes(fallbackSource) : void 0;
|
|
985
|
+
if (hashes) {
|
|
986
|
+
const cached = getCachedUpload(scene, peer, type, hashes.md5);
|
|
987
|
+
if (cached) return cached;
|
|
988
|
+
}
|
|
989
|
+
if (!srvSendMsg && fallbackSource.size >= LARGE_UPLOAD_THRESHOLD) {
|
|
990
|
+
const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
|
|
991
|
+
if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
992
|
+
return response;
|
|
993
|
+
}
|
|
994
|
+
const body = {
|
|
995
|
+
file_type: FILE_TYPE[type],
|
|
996
|
+
srv_send_msg: srvSendMsg,
|
|
997
|
+
file_data: await readFallbackSourceAsBase64(fallbackSource)
|
|
998
|
+
};
|
|
999
|
+
if (type === "file") body.file_name = fallbackSource.fileName;
|
|
1000
|
+
const response = await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1001
|
+
if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1002
|
+
return response;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* 确认单个分片已完成上传。
|
|
1006
|
+
*
|
|
1007
|
+
* QQ 有时会在分片刚 PUT 完时短暂返回 40093001,此时需要在服务端给出的
|
|
1008
|
+
* retry_timeout 窗口内持续重试,而不是直接判定整个大文件上传失败。
|
|
1009
|
+
*
|
|
1010
|
+
* @param scene user(单聊) / group(群聊)
|
|
1011
|
+
* @param peer openid 或 group_openid
|
|
1012
|
+
* @param body upload_part_finish 请求体
|
|
1013
|
+
* @param retryTimeout 服务端返回的持续重试秒数
|
|
1014
|
+
*/
|
|
1015
|
+
async finishUploadPart(scene, peer, body, retryTimeout) {
|
|
1016
|
+
let lastError;
|
|
1017
|
+
for (let attempt = 0; attempt <= PART_FINISH_RETRIES; attempt++) try {
|
|
1018
|
+
await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1019
|
+
return;
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
if (shouldRetryPartFinishPersistently(err)) {
|
|
1022
|
+
await this.retryUploadPartFinish(scene, peer, body, retryTimeout);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
lastError = err;
|
|
1026
|
+
if (attempt < PART_FINISH_RETRIES) await sleep(PART_FINISH_BASE_DELAY * 2 ** attempt);
|
|
1027
|
+
}
|
|
1028
|
+
throwError(lastError);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* 持续重试 upload_part_finish 的短暂服务端状态。
|
|
1032
|
+
*
|
|
1033
|
+
* @param scene user(单聊) / group(群聊)
|
|
1034
|
+
* @param peer openid 或 group_openid
|
|
1035
|
+
* @param body upload_part_finish 请求体
|
|
1036
|
+
* @param retryTimeout 服务端返回的持续重试秒数
|
|
1037
|
+
*/
|
|
1038
|
+
async retryUploadPartFinish(scene, peer, body, retryTimeout) {
|
|
1039
|
+
const retryTimeoutMs = retryTimeout ? Math.min(Number(retryTimeout) * 1e3, PART_FINISH_MAX_RETRY_TIMEOUT) : PART_FINISH_DEFAULT_RETRY_TIMEOUT;
|
|
1040
|
+
const deadline = Date.now() + retryTimeoutMs;
|
|
1041
|
+
let attempts = 0;
|
|
1042
|
+
let lastError;
|
|
1043
|
+
while (Date.now() < deadline) try {
|
|
1044
|
+
await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1045
|
+
return;
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
lastError = err;
|
|
1048
|
+
if (!shouldRetryPartFinishPersistently(err)) throwError(err);
|
|
1049
|
+
attempts += 1;
|
|
1050
|
+
await sleep(Math.min(PART_FINISH_RETRY_INTERVAL, Math.max(0, deadline - Date.now())));
|
|
1051
|
+
}
|
|
1052
|
+
throw new Error(`upload_part_finish 持续重试超时:${Math.round(retryTimeoutMs / 1e3)}s,已重试 ${attempts} 次,最后错误:${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* 完成 QQ 分片上传,并对短暂失败做有限重试。
|
|
1056
|
+
*
|
|
1057
|
+
* @param scene user(单聊) / group(群聊)
|
|
1058
|
+
* @param peer openid 或 group_openid
|
|
1059
|
+
* @param uploadId upload_prepare 返回的上传任务 ID
|
|
1060
|
+
* @returns 上传结果
|
|
1061
|
+
*/
|
|
1062
|
+
async completeChunkedUpload(scene, peer, uploadId) {
|
|
1063
|
+
let lastError;
|
|
1064
|
+
const body = { upload_id: uploadId };
|
|
1065
|
+
for (let attempt = 0; attempt <= COMPLETE_UPLOAD_RETRIES; attempt++) try {
|
|
1066
|
+
return await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
lastError = err;
|
|
1069
|
+
if (attempt < COMPLETE_UPLOAD_RETRIES) await sleep(COMPLETE_UPLOAD_BASE_DELAY * 2 ** attempt);
|
|
1070
|
+
}
|
|
1071
|
+
return throwError(lastError);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* 使用 QQ 官方分片协议上传 fallback 大资源。
|
|
1075
|
+
*
|
|
1076
|
+
* 流程为:准备上传 -> PUT 每个分片 -> 确认每个分片 -> 完成上传。
|
|
1077
|
+
*
|
|
1078
|
+
* @param scene user(单聊) / group(群聊)
|
|
1079
|
+
* @param peer openid 或 group_openid
|
|
1080
|
+
* @param type 文件类型
|
|
1081
|
+
* @param source 已归一化的 fallback 来源
|
|
1082
|
+
* @param preparedHashes 预计算的文件 hash
|
|
1083
|
+
* @returns 上传结果,包含后续发送消息所需的 file_info
|
|
1084
|
+
*/
|
|
1085
|
+
async uploadChunked(scene, peer, type, source, preparedHashes) {
|
|
1086
|
+
const hashes = preparedHashes || await computeUploadHashes(source);
|
|
1087
|
+
const prepareBody = {
|
|
1088
|
+
file_type: FILE_TYPE[type],
|
|
1089
|
+
file_name: source.fileName,
|
|
1090
|
+
file_size: source.size,
|
|
1091
|
+
md5: hashes.md5,
|
|
1092
|
+
sha1: hashes.sha1
|
|
1093
|
+
};
|
|
1094
|
+
prepareBody["md5_10m"] = hashes.md510m;
|
|
1095
|
+
const prepare = await this.post(`/v2/${scene}s/${peer}/upload_prepare`, prepareBody, void 0, MEDIA_UPLOAD_TIMEOUT).catch((err) => {
|
|
1096
|
+
if (openApiCode(err) === UPLOAD_PREPARE_DAILY_LIMIT_CODE) throw new Error(`QQ 大文件上传额度已达上限,${MEDIA_TYPE_LABEL[type]}暂时无法通过 fallback 直传:${source.fileName} (${formatBytes(source.size)})`);
|
|
1097
|
+
return throwError(err);
|
|
1098
|
+
});
|
|
1099
|
+
if (!prepare.parts.length) throw new Error("分片上传准备失败:QQ 未返回分片信息");
|
|
1100
|
+
const concurrency = Math.min(prepare.concurrency || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
|
|
1101
|
+
const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
|
|
1102
|
+
try {
|
|
1103
|
+
await runWithConcurrency(prepare.parts.map((part) => async () => {
|
|
1104
|
+
const offset = (part.index - 1) * prepare.block_size;
|
|
1105
|
+
const data = await readPart(source, handle, offset, Math.min(prepare.block_size, source.size - offset));
|
|
1106
|
+
const md5 = crypto.createHash("md5").update(data).digest("hex");
|
|
1107
|
+
await putPart(part.presigned_url, data, part.index, prepare.parts.length);
|
|
1108
|
+
await this.finishUploadPart(scene, peer, {
|
|
1109
|
+
upload_id: prepare.upload_id,
|
|
1110
|
+
part_index: part.index,
|
|
1111
|
+
block_size: data.length,
|
|
1112
|
+
md5
|
|
1113
|
+
}, prepare.retry_timeout);
|
|
1114
|
+
}), concurrency);
|
|
1115
|
+
} finally {
|
|
1116
|
+
await handle?.close().catch(() => void 0);
|
|
1117
|
+
}
|
|
1118
|
+
return this.completeChunkedUpload(scene, peer, prepare.upload_id);
|
|
482
1119
|
}
|
|
483
1120
|
};
|
|
484
1121
|
|
|
485
1122
|
//#endregion
|
|
486
1123
|
//#region src/core/api/interaction.ts
|
|
1124
|
+
/** QQ 客户端回调按钮需要快速 ACK,超时必须小于平台等待窗口。 */
|
|
1125
|
+
const INTERACTION_ACK_TIMEOUT = 2800;
|
|
487
1126
|
/**
|
|
488
1127
|
* INTERACTION_CREATE 回应
|
|
489
1128
|
*/
|
|
@@ -493,7 +1132,7 @@ var InteractionApi = class extends Http {
|
|
|
493
1132
|
* @param code 0 成功 / 1 操作失败 / 2 操作频繁 / 3 重复操作 / 4 无权限 / 5 仅管理员
|
|
494
1133
|
*/
|
|
495
1134
|
async ack(interactionId, code = 0) {
|
|
496
|
-
await this.put(`/interactions/${interactionId}`, { code });
|
|
1135
|
+
await this.put(`/interactions/${interactionId}`, { code }, void 0, INTERACTION_ACK_TIMEOUT);
|
|
497
1136
|
}
|
|
498
1137
|
};
|
|
499
1138
|
|
|
@@ -545,12 +1184,11 @@ const buildQQMsg = {
|
|
|
545
1184
|
ark
|
|
546
1185
|
};
|
|
547
1186
|
},
|
|
548
|
-
/** msg_type=7
|
|
1187
|
+
/** 构造 msg_type=7 富媒体消息体 */
|
|
549
1188
|
media(fileInfo) {
|
|
550
1189
|
return {
|
|
551
1190
|
msg_type: 7,
|
|
552
|
-
media: { file_info: fileInfo }
|
|
553
|
-
content: " "
|
|
1191
|
+
media: { file_info: fileInfo }
|
|
554
1192
|
};
|
|
555
1193
|
}
|
|
556
1194
|
};
|
|
@@ -623,6 +1261,16 @@ var QQBotApi = class {
|
|
|
623
1261
|
}
|
|
624
1262
|
};
|
|
625
1263
|
|
|
1264
|
+
//#endregion
|
|
1265
|
+
//#region src/utils/user-agent.ts
|
|
1266
|
+
const USER_AGENT_SUFFIX = "Karin";
|
|
1267
|
+
/**
|
|
1268
|
+
* 构建 QQBot HTTP / WebSocket 使用的 User-Agent。
|
|
1269
|
+
*/
|
|
1270
|
+
const getUserAgent = () => {
|
|
1271
|
+
return `QQBotAdapter/${String(pkg().version || "unknown")} (Node/${process.versions.node}; ${os.platform()}; ${USER_AGENT_SUFFIX}/${process.env.KARIN_VERSION || "unknown"})`;
|
|
1272
|
+
};
|
|
1273
|
+
|
|
626
1274
|
//#endregion
|
|
627
1275
|
//#region src/core/internal/axios.ts
|
|
628
1276
|
const tokens = /* @__PURE__ */ new Map();
|
|
@@ -637,7 +1285,7 @@ const getAccessToken = async (url, appId, secret) => {
|
|
|
637
1285
|
res = await axios.post(url, {
|
|
638
1286
|
appId: String(appId),
|
|
639
1287
|
clientSecret: secret
|
|
640
|
-
});
|
|
1288
|
+
}, { headers: { "User-Agent": getUserAgent() } });
|
|
641
1289
|
} catch (err) {
|
|
642
1290
|
if (axios.isAxiosError(err)) {
|
|
643
1291
|
const response = err.response;
|
|
@@ -684,7 +1332,10 @@ const stopTokenRefresh = (appId) => {
|
|
|
684
1332
|
const createAxiosInstance = (baseURL, appId) => {
|
|
685
1333
|
const instance = axios.create({
|
|
686
1334
|
baseURL,
|
|
687
|
-
headers: {
|
|
1335
|
+
headers: {
|
|
1336
|
+
"Content-Type": "application/json",
|
|
1337
|
+
"User-Agent": getUserAgent()
|
|
1338
|
+
},
|
|
688
1339
|
timeout: 5500
|
|
689
1340
|
});
|
|
690
1341
|
instance.interceptors.request.use((config) => {
|
|
@@ -695,9 +1346,92 @@ const createAxiosInstance = (baseURL, appId) => {
|
|
|
695
1346
|
return instance;
|
|
696
1347
|
};
|
|
697
1348
|
|
|
1349
|
+
//#endregion
|
|
1350
|
+
//#region src/core/adapter/button-enter.ts
|
|
1351
|
+
/** QQ 私聊直发文本链标签。 */
|
|
1352
|
+
const COMMAND_ENTER_TEXT_CHAIN_REGEXP = /<\s*qqbot-cmd-enter\b/i;
|
|
1353
|
+
/**
|
|
1354
|
+
* 判断按钮是否是 `enter: true` 指令按钮。
|
|
1355
|
+
*
|
|
1356
|
+
* 链接按钮和回调按钮有自己的平台行为,只有普通指令按钮才需要提示
|
|
1357
|
+
* `enter: true` 在非私聊场景不生效。
|
|
1358
|
+
*
|
|
1359
|
+
* @param button Karin 按钮配置。
|
|
1360
|
+
* @returns 是否为直发指令按钮。
|
|
1361
|
+
*/
|
|
1362
|
+
const isCommandEnterButton = (button) => {
|
|
1363
|
+
if (button.link || button.callback) return false;
|
|
1364
|
+
return (button.type ?? 2) === 2 && button.enter === true;
|
|
1365
|
+
};
|
|
1366
|
+
/**
|
|
1367
|
+
* 判断 markdown 内容是否手写了 QQ 私聊直发文本链。
|
|
1368
|
+
*
|
|
1369
|
+
* @param markdown markdown 内容。
|
|
1370
|
+
* @returns 是否包含 `<qqbot-cmd-enter ...>`。
|
|
1371
|
+
*/
|
|
1372
|
+
const hasCommandEnterTextChain = (markdown) => {
|
|
1373
|
+
return COMMAND_ENTER_TEXT_CHAIN_REGEXP.test(markdown);
|
|
1374
|
+
};
|
|
1375
|
+
/**
|
|
1376
|
+
* 收集 button/keyboard 中所有 `enter: true` 指令按钮。
|
|
1377
|
+
*
|
|
1378
|
+
* @param buttons 单行按钮元素列表。
|
|
1379
|
+
* @param keyboards 多行 keyboard 元素列表。
|
|
1380
|
+
* @returns 需要特殊处理的直发指令按钮。
|
|
1381
|
+
*/
|
|
1382
|
+
const collectCommandEnterButtons = (buttons, keyboards) => {
|
|
1383
|
+
const result = [];
|
|
1384
|
+
buttons.forEach((element) => {
|
|
1385
|
+
element.data.forEach((button) => {
|
|
1386
|
+
if (isCommandEnterButton(button)) result.push(button);
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
keyboards.forEach((element) => {
|
|
1390
|
+
element.rows.forEach((row) => {
|
|
1391
|
+
row.forEach((button) => {
|
|
1392
|
+
if (isCommandEnterButton(button)) result.push(button);
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
return result;
|
|
1397
|
+
};
|
|
1398
|
+
/**
|
|
1399
|
+
* 归一 QQ keyboard 按钮字段。
|
|
1400
|
+
*
|
|
1401
|
+
* @param button node-karin 转换后的 QQBot 按钮。
|
|
1402
|
+
* @param id 当前 keyboard 内唯一按钮序号。
|
|
1403
|
+
* @returns 可直接放入 QQ 官方 keyboard 的按钮对象。
|
|
1404
|
+
*/
|
|
1405
|
+
const normalizeQQBotButton = (button, id) => {
|
|
1406
|
+
return {
|
|
1407
|
+
...button,
|
|
1408
|
+
id: String(id)
|
|
1409
|
+
};
|
|
1410
|
+
};
|
|
1411
|
+
/**
|
|
1412
|
+
* 生成直发按钮不支持提示中的按钮名称列表。
|
|
1413
|
+
*
|
|
1414
|
+
* @param buttons 直发指令按钮列表。
|
|
1415
|
+
* @returns 便于写入日志的按钮名称。
|
|
1416
|
+
*/
|
|
1417
|
+
const formatCommandEnterButtonNames = (buttons) => {
|
|
1418
|
+
return buttons.map((button) => button.text || button.data || "未命名按钮").join("、");
|
|
1419
|
+
};
|
|
1420
|
+
|
|
698
1421
|
//#endregion
|
|
699
1422
|
//#region src/core/adapter/grouping.ts
|
|
700
1423
|
/**
|
|
1424
|
+
* 生成 QQ 被动回复使用的 msg_seq 种子。
|
|
1425
|
+
*
|
|
1426
|
+
* QQ 官方使用 `msg_id + msg_seq` 判重。这里和 openclaw qqbot 适配器一样限制在
|
|
1427
|
+
* 16-bit 范围内,后续每发一条再递增取模。
|
|
1428
|
+
*
|
|
1429
|
+
* @returns 0..65535 范围内的初始 msg_seq。
|
|
1430
|
+
*/
|
|
1431
|
+
const createMsgSeqSeed = () => {
|
|
1432
|
+
return (Date.now() % 1e8 ^ Math.floor(Math.random() * 65536)) % 65536;
|
|
1433
|
+
};
|
|
1434
|
+
/**
|
|
701
1435
|
* 创建空容器
|
|
702
1436
|
*/
|
|
703
1437
|
const createGrouping = () => ({
|
|
@@ -713,7 +1447,7 @@ const createGrouping = () => ({
|
|
|
713
1447
|
pasmsg: {
|
|
714
1448
|
type: "msg",
|
|
715
1449
|
id: "",
|
|
716
|
-
seq:
|
|
1450
|
+
seq: createMsgSeqSeed()
|
|
717
1451
|
},
|
|
718
1452
|
faces: []
|
|
719
1453
|
});
|
|
@@ -761,19 +1495,22 @@ const groupElements = (scene, elements) => {
|
|
|
761
1495
|
case "video":
|
|
762
1496
|
g.media.push({
|
|
763
1497
|
kind: "video",
|
|
764
|
-
source: v.file
|
|
1498
|
+
source: v.file,
|
|
1499
|
+
name: v.name
|
|
765
1500
|
});
|
|
766
1501
|
break;
|
|
767
1502
|
case "record":
|
|
768
1503
|
g.media.push({
|
|
769
1504
|
kind: "record",
|
|
770
|
-
source: v.file
|
|
1505
|
+
source: v.file,
|
|
1506
|
+
name: v.name
|
|
771
1507
|
});
|
|
772
1508
|
break;
|
|
773
1509
|
case "file":
|
|
774
1510
|
g.media.push({
|
|
775
1511
|
kind: "file",
|
|
776
|
-
source: v.file
|
|
1512
|
+
source: v.file,
|
|
1513
|
+
name: v.name
|
|
777
1514
|
});
|
|
778
1515
|
break;
|
|
779
1516
|
default: break;
|
|
@@ -781,6 +1518,214 @@ const groupElements = (scene, elements) => {
|
|
|
781
1518
|
return g;
|
|
782
1519
|
};
|
|
783
1520
|
|
|
1521
|
+
//#endregion
|
|
1522
|
+
//#region src/core/adapter/media-source.ts
|
|
1523
|
+
/** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
|
|
1524
|
+
const HTTP_URL_RE = /^https?:\/\//i;
|
|
1525
|
+
/** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
|
|
1526
|
+
const DEFAULT_FILENAME = {
|
|
1527
|
+
image: "image.jpg",
|
|
1528
|
+
video: "video.mp4",
|
|
1529
|
+
record: "record.mp3",
|
|
1530
|
+
file: "file.bin"
|
|
1531
|
+
};
|
|
1532
|
+
/**
|
|
1533
|
+
* 当前 Karin 运行时是否注册了 fileToUrl 处理器。
|
|
1534
|
+
* @returns true 表示可以优先通过 fileToUrl 产出公网 URL。
|
|
1535
|
+
*/
|
|
1536
|
+
const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
|
|
1537
|
+
/**
|
|
1538
|
+
* 判断一个来源是否已经是公网 HTTP(S) 地址。
|
|
1539
|
+
* @param source Karin 消息段里的 file 字段。
|
|
1540
|
+
* @returns 是否为 HTTP(S) URL。
|
|
1541
|
+
*/
|
|
1542
|
+
const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
|
|
1543
|
+
/**
|
|
1544
|
+
* 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
|
|
1545
|
+
*
|
|
1546
|
+
* QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
|
|
1547
|
+
* fileToUrl 作为主路径,QQ 上传只做兜底。
|
|
1548
|
+
*
|
|
1549
|
+
* @param ctx 适配器实例,用于记录降级日志。
|
|
1550
|
+
* @param type 富媒体类型。
|
|
1551
|
+
* @param source Karin 消息段里的 file 字段。
|
|
1552
|
+
* @param filename 可选文件名,主要用于 file 消息。
|
|
1553
|
+
* @returns 解析后的首选来源和来源类型。
|
|
1554
|
+
*/
|
|
1555
|
+
const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
|
|
1556
|
+
if (isHttpMediaSource(source)) return {
|
|
1557
|
+
source,
|
|
1558
|
+
via: "public-url"
|
|
1559
|
+
};
|
|
1560
|
+
if (!hasFileToUrlHandler()) return {
|
|
1561
|
+
source,
|
|
1562
|
+
via: "fallback"
|
|
1563
|
+
};
|
|
1564
|
+
try {
|
|
1565
|
+
return {
|
|
1566
|
+
source: (await fileToUrl(type, source, filename || DEFAULT_FILENAME[type])).url,
|
|
1567
|
+
via: "file-to-url"
|
|
1568
|
+
};
|
|
1569
|
+
} catch (err) {
|
|
1570
|
+
ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
|
|
1571
|
+
return {
|
|
1572
|
+
source,
|
|
1573
|
+
via: "fallback"
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
//#endregion
|
|
1579
|
+
//#region src/core/adapter/message-id-map.ts
|
|
1580
|
+
/** QQ 消息 ID 与 REFIDX 映射的短期缓存时长,和本地消息缓存窗口保持一致。 */
|
|
1581
|
+
const MESSAGE_ID_LINK_CACHE_TTL = 1440 * 60 * 1e3;
|
|
1582
|
+
/** QQ 消息 ID 映射表清理间隔。 */
|
|
1583
|
+
const MESSAGE_ID_LINK_CLEANUP_INTERVAL = 60 * 1e3;
|
|
1584
|
+
/** 按适配器实例隔离的消息 ID 映射状态。 */
|
|
1585
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
1586
|
+
/**
|
|
1587
|
+
* 获取适配器实例对应的消息 ID 映射状态。
|
|
1588
|
+
*
|
|
1589
|
+
* @param ctx 当前 QQBot 适配器实例。
|
|
1590
|
+
* @returns 该实例的映射状态。
|
|
1591
|
+
*/
|
|
1592
|
+
const getState = (ctx) => {
|
|
1593
|
+
const existing = states.get(ctx);
|
|
1594
|
+
if (existing) return existing;
|
|
1595
|
+
const created = {
|
|
1596
|
+
apiMessageIdCache: /* @__PURE__ */ new Map(),
|
|
1597
|
+
referenceMessageIdCache: /* @__PURE__ */ new Map(),
|
|
1598
|
+
ownMessageIdCache: /* @__PURE__ */ new Map(),
|
|
1599
|
+
lastCleanup: 0
|
|
1600
|
+
};
|
|
1601
|
+
states.set(ctx, created);
|
|
1602
|
+
return created;
|
|
1603
|
+
};
|
|
1604
|
+
/**
|
|
1605
|
+
* 构造 REFIDX/API ID 映射的会话级 key。
|
|
1606
|
+
*
|
|
1607
|
+
* @param contact 消息所在会话。
|
|
1608
|
+
* @param messageId 消息 ID。
|
|
1609
|
+
* @returns 可用于 Map 的稳定 key。
|
|
1610
|
+
*/
|
|
1611
|
+
const messageIdKey = (contact, messageId) => {
|
|
1612
|
+
return [
|
|
1613
|
+
contact.scene,
|
|
1614
|
+
contact.peer,
|
|
1615
|
+
contact.subPeer || "",
|
|
1616
|
+
messageId
|
|
1617
|
+
].join("");
|
|
1618
|
+
};
|
|
1619
|
+
/**
|
|
1620
|
+
* 清理过期的消息 ID 映射。
|
|
1621
|
+
*
|
|
1622
|
+
* @param ctx 当前 QQBot 适配器实例。
|
|
1623
|
+
*/
|
|
1624
|
+
const cleanup = (ctx) => {
|
|
1625
|
+
const state = getState(ctx);
|
|
1626
|
+
const now = Date.now();
|
|
1627
|
+
if (now - state.lastCleanup < MESSAGE_ID_LINK_CLEANUP_INTERVAL) return;
|
|
1628
|
+
state.lastCleanup = now;
|
|
1629
|
+
for (const [key, entry] of state.apiMessageIdCache) if (entry.expiresAt <= now) state.apiMessageIdCache.delete(key);
|
|
1630
|
+
for (const [key, entry] of state.referenceMessageIdCache) if (entry.expiresAt <= now) state.referenceMessageIdCache.delete(key);
|
|
1631
|
+
for (const [key, entry] of state.ownMessageIdCache) if (entry.expiresAt <= now) state.ownMessageIdCache.delete(key);
|
|
1632
|
+
};
|
|
1633
|
+
/**
|
|
1634
|
+
* 记住机器人自己发送的 QQ API 消息 ID。
|
|
1635
|
+
*
|
|
1636
|
+
* 单聊场景只允许撤回机器人自己发送的消息。收到用户消息时的 `e.messageId`
|
|
1637
|
+
* 也是 API 消息 ID,但不能用于单聊撤回,因此需要独立标记来源。
|
|
1638
|
+
*
|
|
1639
|
+
* @param ctx 当前 QQBot 适配器实例。
|
|
1640
|
+
* @param contact 消息所在会话。
|
|
1641
|
+
* @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
|
|
1642
|
+
*/
|
|
1643
|
+
const rememberOwnMessageId = (ctx, contact, apiMessageId) => {
|
|
1644
|
+
if (!apiMessageId) return;
|
|
1645
|
+
cleanup(ctx);
|
|
1646
|
+
getState(ctx).ownMessageIdCache.set(messageIdKey(contact, apiMessageId), { expiresAt: Date.now() + MESSAGE_ID_LINK_CACHE_TTL });
|
|
1647
|
+
};
|
|
1648
|
+
/**
|
|
1649
|
+
* 判断给定 QQ API 消息 ID 是否由机器人自己发送。
|
|
1650
|
+
*
|
|
1651
|
+
* @param ctx 当前 QQBot 适配器实例。
|
|
1652
|
+
* @param contact 消息所在会话。
|
|
1653
|
+
* @param apiMessageId QQ API 消息 ID。
|
|
1654
|
+
* @returns true 表示这条消息是当前适配器实例发送的。
|
|
1655
|
+
*/
|
|
1656
|
+
const isOwnMessageId = (ctx, contact, apiMessageId) => {
|
|
1657
|
+
cleanup(ctx);
|
|
1658
|
+
const state = getState(ctx);
|
|
1659
|
+
const key = messageIdKey(contact, apiMessageId);
|
|
1660
|
+
const cached = state.ownMessageIdCache.get(key);
|
|
1661
|
+
if (!cached) return false;
|
|
1662
|
+
if (cached.expiresAt > Date.now()) return true;
|
|
1663
|
+
state.ownMessageIdCache.delete(key);
|
|
1664
|
+
return false;
|
|
1665
|
+
};
|
|
1666
|
+
/**
|
|
1667
|
+
* 记住 QQ 引用索引对应的 API 消息 ID。
|
|
1668
|
+
*
|
|
1669
|
+
* QQ 群聊/单聊的 `message_reference.message_id` 使用 REFIDX,但撤回等 API 路径使用
|
|
1670
|
+
* ROBOT1.0 开头的消息 ID。映射按适配器实例和会话隔离,避免不同群里的 REFIDX 冲突。
|
|
1671
|
+
*
|
|
1672
|
+
* @param ctx 当前 QQBot 适配器实例。
|
|
1673
|
+
* @param contact 消息所在会话。
|
|
1674
|
+
* @param referenceMessageId QQ 引用索引,通常为 REFIDX_...。
|
|
1675
|
+
* @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
|
|
1676
|
+
*/
|
|
1677
|
+
const rememberApiMessageId = (ctx, contact, referenceMessageId, apiMessageId) => {
|
|
1678
|
+
if (!referenceMessageId || !apiMessageId) return;
|
|
1679
|
+
if (referenceMessageId === apiMessageId) return;
|
|
1680
|
+
cleanup(ctx);
|
|
1681
|
+
const state = getState(ctx);
|
|
1682
|
+
const expiresAt = Date.now() + MESSAGE_ID_LINK_CACHE_TTL;
|
|
1683
|
+
state.apiMessageIdCache.set(messageIdKey(contact, referenceMessageId), {
|
|
1684
|
+
apiMessageId,
|
|
1685
|
+
expiresAt
|
|
1686
|
+
});
|
|
1687
|
+
state.referenceMessageIdCache.set(messageIdKey(contact, apiMessageId), {
|
|
1688
|
+
referenceMessageId,
|
|
1689
|
+
expiresAt
|
|
1690
|
+
});
|
|
1691
|
+
};
|
|
1692
|
+
/**
|
|
1693
|
+
* 将 QQ API 消息 ID 解析为客户端可见引用使用的 REFIDX。
|
|
1694
|
+
*
|
|
1695
|
+
* @param ctx 当前 QQBot 适配器实例。
|
|
1696
|
+
* @param contact 消息所在会话。
|
|
1697
|
+
* @param messageId Karin 侧传入的消息 ID,可能是 ROBOT1.0 或 REFIDX。
|
|
1698
|
+
* @returns 可用于 `message_reference.message_id` 的 ID;没有映射时返回原值。
|
|
1699
|
+
*/
|
|
1700
|
+
const resolveReferenceMessageId = (ctx, contact, messageId) => {
|
|
1701
|
+
cleanup(ctx);
|
|
1702
|
+
const state = getState(ctx);
|
|
1703
|
+
const key = messageIdKey(contact, messageId);
|
|
1704
|
+
const cached = state.referenceMessageIdCache.get(key);
|
|
1705
|
+
if (!cached) return messageId;
|
|
1706
|
+
if (cached.expiresAt > Date.now()) return cached.referenceMessageId;
|
|
1707
|
+
state.referenceMessageIdCache.delete(key);
|
|
1708
|
+
return messageId;
|
|
1709
|
+
};
|
|
1710
|
+
/**
|
|
1711
|
+
* 将 Karin 侧传入的消息 ID 解析为 QQ API 可用的消息 ID。
|
|
1712
|
+
*
|
|
1713
|
+
* @param ctx 当前 QQBot 适配器实例。
|
|
1714
|
+
* @param contact 消息所在会话。
|
|
1715
|
+
* @param messageId Karin 侧传入的消息 ID,可能是 REFIDX。
|
|
1716
|
+
* @returns QQ API 路径可用的消息 ID;没有映射时返回原值。
|
|
1717
|
+
*/
|
|
1718
|
+
const resolveApiMessageId = (ctx, contact, messageId) => {
|
|
1719
|
+
cleanup(ctx);
|
|
1720
|
+
const state = getState(ctx);
|
|
1721
|
+
const key = messageIdKey(contact, messageId);
|
|
1722
|
+
const cached = state.apiMessageIdCache.get(key);
|
|
1723
|
+
if (!cached) return messageId;
|
|
1724
|
+
if (cached.expiresAt > Date.now()) return cached.apiMessageId;
|
|
1725
|
+
state.apiMessageIdCache.delete(key);
|
|
1726
|
+
return messageId;
|
|
1727
|
+
};
|
|
1728
|
+
|
|
784
1729
|
//#endregion
|
|
785
1730
|
//#region src/core/adapter/text-to-md.ts
|
|
786
1731
|
const MAX_BUTTONS = 5;
|
|
@@ -827,14 +1772,21 @@ const imagesToMarkdown = async (urls) => {
|
|
|
827
1772
|
|
|
828
1773
|
//#endregion
|
|
829
1774
|
//#region src/core/adapter/pipeline-qq.ts
|
|
1775
|
+
/** QQ 官方限制:单聊中同一 `msg_id` 最多发送四次被动回复。 */
|
|
1776
|
+
const C2C_PASSIVE_REPLY_LIMIT = 4;
|
|
1777
|
+
/** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
|
|
1778
|
+
const KEYBOARD_MAX_ROWS$1 = 5;
|
|
1779
|
+
const KEYBOARD_MAX_BUTTONS_PER_ROW$1 = 5;
|
|
1780
|
+
/** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
|
|
1781
|
+
const BUTTON_ONLY_MARKDOWN$1 = "";
|
|
830
1782
|
/** 群聊 event_id 白名单 */
|
|
831
|
-
const GROUP_EVENT_WHITELIST = new Set([
|
|
1783
|
+
const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
832
1784
|
"INTERACTION_CREATE",
|
|
833
1785
|
"GROUP_ADD_ROBOT",
|
|
834
1786
|
"GROUP_MSG_RECEIVE"
|
|
835
1787
|
]);
|
|
836
1788
|
/** 单聊 event_id 白名单 */
|
|
837
|
-
const FRIEND_EVENT_WHITELIST = new Set([
|
|
1789
|
+
const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
838
1790
|
"INTERACTION_CREATE",
|
|
839
1791
|
"C2C_MSG_RECEIVE",
|
|
840
1792
|
"FRIEND_ADD"
|
|
@@ -851,10 +1803,11 @@ const sendQQ = async (ctx, contact, elements) => {
|
|
|
851
1803
|
grouping.buttons.push(...buttons);
|
|
852
1804
|
}
|
|
853
1805
|
/**
|
|
854
|
-
*
|
|
855
|
-
* text / at /
|
|
856
|
-
*
|
|
857
|
-
*
|
|
1806
|
+
* 默认走 markdown 通道:
|
|
1807
|
+
* text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
|
|
1808
|
+
* 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 单独补发。所有富媒体
|
|
1809
|
+
* 都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
|
|
1810
|
+
* 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
|
|
858
1811
|
*/
|
|
859
1812
|
return sendQQMarkdown(ctx, contact, grouping, target);
|
|
860
1813
|
};
|
|
@@ -865,87 +1818,256 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
|
|
|
865
1818
|
const items = [];
|
|
866
1819
|
const lines = [];
|
|
867
1820
|
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
868
|
-
|
|
869
|
-
const urlImages = [];
|
|
870
|
-
for (const file of grouping.qqImages) if (file.startsWith("http")) urlImages.push(file);
|
|
871
|
-
else {
|
|
872
|
-
const { url } = await fileToUrl("image", file, "image.jpg");
|
|
873
|
-
urlImages.push(url);
|
|
874
|
-
}
|
|
875
|
-
const mdImages = await imagesToMarkdown(urlImages);
|
|
876
|
-
lines.push(...mdImages);
|
|
877
|
-
}
|
|
1821
|
+
const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages);
|
|
878
1822
|
if (grouping.markdowns.length) grouping.markdowns.forEach((m) => lines.push(m.markdown));
|
|
879
|
-
if (
|
|
880
|
-
|
|
1823
|
+
if (contact.scene === "group") {
|
|
1824
|
+
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
1825
|
+
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
1826
|
+
}
|
|
1827
|
+
if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(ctx, contact, grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
|
|
1828
|
+
else {
|
|
881
1829
|
const keyboard = buildKeyboard$1(grouping);
|
|
1830
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
882
1831
|
items.push(ctx.super.qq.markdown({ content }, keyboard));
|
|
883
1832
|
}
|
|
1833
|
+
for (const file of fallbackImages) {
|
|
1834
|
+
const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
|
|
1835
|
+
items.push(ctx.super.qq.media(res.file_info));
|
|
1836
|
+
}
|
|
884
1837
|
for (const m of grouping.media) {
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const ext = m.kind === "record" ? "mp3" : m.kind === "video" ? "mp4" : "bin";
|
|
888
|
-
url = (await fileToUrl(m.kind, url, `${m.kind}.${ext}`)).url;
|
|
889
|
-
}
|
|
890
|
-
const res = await ctx.super.media.upload(target, contact.peer, m.kind, url, false);
|
|
1838
|
+
const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name);
|
|
1839
|
+
const res = source.via === "fallback" ? await ctx.super.media.uploadFallback(target, contact.peer, m.kind, source.source, false, m.name) : await ctx.super.media.upload(target, contact.peer, m.kind, source.source, false, m.name);
|
|
891
1840
|
items.push(ctx.super.qq.media(res.file_info));
|
|
892
1841
|
}
|
|
893
1842
|
if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
|
|
894
1843
|
return flushQQ(ctx, contact, grouping, items);
|
|
895
1844
|
};
|
|
896
1845
|
/**
|
|
1846
|
+
* 将能转成公网 URL 的图片追加到 markdown 行,无法转换的图片返回给富媒体 fallback。
|
|
1847
|
+
*
|
|
1848
|
+
* @param ctx 适配器实例,用于输出降级日志。
|
|
1849
|
+
* @param lines markdown 行数组。
|
|
1850
|
+
* @param files QQ 图片消息段的 file 字段。
|
|
1851
|
+
* @returns 需要改走 msg_type=7 富媒体发送的图片列表。
|
|
1852
|
+
*/
|
|
1853
|
+
const appendMarkdownImages = async (ctx, lines, files) => {
|
|
1854
|
+
const fallback = [];
|
|
1855
|
+
for (const file of files) {
|
|
1856
|
+
const resolved = await resolvePreferredMediaSource(ctx, "image", file);
|
|
1857
|
+
if (resolved.via === "fallback") {
|
|
1858
|
+
fallback.push(file);
|
|
1859
|
+
continue;
|
|
1860
|
+
}
|
|
1861
|
+
await appendImageUrlLine(lines, resolved.source);
|
|
1862
|
+
}
|
|
1863
|
+
return fallback;
|
|
1864
|
+
};
|
|
1865
|
+
/**
|
|
1866
|
+
* 生成单张 markdown 图片行。
|
|
1867
|
+
* 尺寸获取失败时使用保守默认尺寸,保证 fileToUrl 成功的图片仍走 markdown。
|
|
1868
|
+
*
|
|
1869
|
+
* @param lines markdown 行数组。
|
|
1870
|
+
* @param url 图片 URL。
|
|
1871
|
+
*/
|
|
1872
|
+
const appendImageUrlLine = async (lines, url) => {
|
|
1873
|
+
try {
|
|
1874
|
+
const [line] = await imagesToMarkdown([url]);
|
|
1875
|
+
lines.push(line);
|
|
1876
|
+
} catch {
|
|
1877
|
+
lines.push(``);
|
|
1878
|
+
}
|
|
1879
|
+
};
|
|
1880
|
+
/**
|
|
1881
|
+
* 判断显式引用回复是否应降级为普通文本消息。
|
|
1882
|
+
*
|
|
1883
|
+
* QQ 的 `message_reference` 对普通文本最稳定;markdown 带引用在客户端渲染上存在兼容问题。
|
|
1884
|
+
* 因此只有纯文本显式引用回复会走 msg_type=0,图片、按钮、keyboard、markdown 段仍走原通道。
|
|
1885
|
+
*
|
|
1886
|
+
* @param grouping 已归类的消息段。
|
|
1887
|
+
* @returns 是否使用普通文本发送主消息。
|
|
1888
|
+
*/
|
|
1889
|
+
const shouldUseTextForReference = (ctx, contact, grouping) => {
|
|
1890
|
+
return isQQReferenceMessageId(resolveReferenceMessageId(ctx, contact, grouping.reply.messageId)) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
|
|
1891
|
+
};
|
|
1892
|
+
/**
|
|
1893
|
+
* 判断给定 ID 是否是 QQ 客户端引用使用的 REFIDX。
|
|
1894
|
+
*
|
|
1895
|
+
* `ROBOT1.0_...` 这类官方消息 ID 只能作为被动发送的 `msg_id`,不能放进
|
|
1896
|
+
* `message_reference.message_id`。
|
|
1897
|
+
*
|
|
1898
|
+
* @param messageId 待判断的消息 ID。
|
|
1899
|
+
* @returns 是否为 QQ 引用索引。
|
|
1900
|
+
*/
|
|
1901
|
+
const isQQReferenceMessageId = (messageId) => messageId.startsWith("REFIDX_");
|
|
1902
|
+
/**
|
|
897
1903
|
* 构造 keyboard 字段(buttons + keyboards 合并)
|
|
898
1904
|
*/
|
|
899
1905
|
const buildKeyboard$1 = (grouping) => {
|
|
900
1906
|
const rows = [];
|
|
901
1907
|
grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
|
|
902
1908
|
grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
|
|
903
|
-
|
|
904
|
-
|
|
1909
|
+
const normalizedRows = [];
|
|
1910
|
+
let id = 0;
|
|
1911
|
+
for (const row of rows.slice(0, KEYBOARD_MAX_ROWS$1)) {
|
|
1912
|
+
const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW$1).map((button) => normalizeQQBotButton(button, id++));
|
|
1913
|
+
if (buttons.length) normalizedRows.push({ buttons });
|
|
1914
|
+
}
|
|
1915
|
+
if (!normalizedRows.length) return void 0;
|
|
1916
|
+
return { content: { rows: normalizedRows } };
|
|
1917
|
+
};
|
|
1918
|
+
/**
|
|
1919
|
+
* 群聊不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
|
|
1920
|
+
*
|
|
1921
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
1922
|
+
* @param buttons 本次消息中的直发指令按钮。
|
|
1923
|
+
*/
|
|
1924
|
+
const warnUnsupportedCommandEnterButtons$1 = (ctx, buttons) => {
|
|
1925
|
+
if (!buttons.length) return;
|
|
1926
|
+
const names = formatCommandEnterButtonNames(buttons);
|
|
1927
|
+
ctx.logger("debug", `群聊不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
|
|
1928
|
+
};
|
|
1929
|
+
/**
|
|
1930
|
+
* 群聊不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
|
|
1931
|
+
*
|
|
1932
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
1933
|
+
* @param markdowns 本次消息中的 markdown 内容。
|
|
1934
|
+
*/
|
|
1935
|
+
const warnUnsupportedCommandEnterMarkdowns$1 = (ctx, markdowns) => {
|
|
1936
|
+
if (!markdowns.some(hasCommandEnterTextChain)) return;
|
|
1937
|
+
ctx.logger("debug", "群聊不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送");
|
|
905
1938
|
};
|
|
906
1939
|
/**
|
|
907
1940
|
* 逐条发送 + 被动消息附加 + 引用回复
|
|
908
1941
|
*/
|
|
909
|
-
const flushQQ = async (ctx, contact, grouping, items) => {
|
|
910
|
-
const result = ctx.initSendMsgResults();
|
|
911
|
-
const
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1942
|
+
const flushQQ = async (ctx, contact, grouping, items) => {
|
|
1943
|
+
const result = ctx.initSendMsgResults();
|
|
1944
|
+
const passiveSource = resolvePassiveQQ(grouping);
|
|
1945
|
+
const passive = buildPassiveQQ(ctx, contact.scene, grouping);
|
|
1946
|
+
const send = contact.scene === "friend" ? (peer, item) => ctx.super.messages.sendFriendMsg(peer, item) : (peer, item) => ctx.super.messages.sendGroupMsg(peer, item);
|
|
1947
|
+
/**
|
|
1948
|
+
* 仅单聊 `msg_id` 回复受四次上限约束。`event_id` 事件回复和群聊不适用该限制。
|
|
1949
|
+
* 这是 QQ 官方 2026-01-10 更新后的限制,超出部分不请求平台接口。
|
|
1950
|
+
*/
|
|
1951
|
+
const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? C2C_PASSIVE_REPLY_LIMIT : Infinity;
|
|
1952
|
+
if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${C2C_PASSIVE_REPLY_LIMIT} 条,已跳过 ${items.length - maxItems} 条`);
|
|
1953
|
+
let referenceHandled = false;
|
|
1954
|
+
for (const item of items.slice(0, maxItems)) {
|
|
1955
|
+
passive(item);
|
|
1956
|
+
if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(ctx, contact, item, grouping);
|
|
1957
|
+
const res = await sendQQWithEventFallback(ctx, contact, send, item);
|
|
1958
|
+
rememberOwnMessageId(ctx, contact, res.id);
|
|
1959
|
+
if (res.ext_info?.ref_idx) rememberApiMessageId(ctx, contact, res.ext_info.ref_idx, res.id);
|
|
1960
|
+
result.rawData.push(res);
|
|
1961
|
+
}
|
|
1962
|
+
return ctx.handleResponse(result);
|
|
1963
|
+
};
|
|
1964
|
+
/**
|
|
1965
|
+
* 发送 QQ 消息,并在平台拒绝 `event_id` 时降级为普通消息重试。
|
|
1966
|
+
*
|
|
1967
|
+
* QQ 文档说明 `INTERACTION_CREATE.d.id` 可用于被动消息发送,但实测群聊接口会出现
|
|
1968
|
+
* 40034025。为了避免按钮回调业务回复把 Karin 命令链路打断,这里只对
|
|
1969
|
+
* `event_id` 参数无效做一次无 `event_id` 重试。
|
|
1970
|
+
*
|
|
1971
|
+
* @param ctx 适配器实例,用于输出降级日志。
|
|
1972
|
+
* @param contact 消息目标。
|
|
1973
|
+
* @param send 当前场景的发送函数。
|
|
1974
|
+
* @param item 即将发送的 QQ 消息体。
|
|
1975
|
+
* @returns QQ 消息发送响应。
|
|
1976
|
+
*/
|
|
1977
|
+
const sendQQWithEventFallback = async (ctx, contact, send, item) => {
|
|
1978
|
+
try {
|
|
1979
|
+
return await send(contact.peer, item);
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
if (!item.event_id || !isInvalidEventIdError(err)) throw err;
|
|
1982
|
+
ctx.logger("warn", `event_id 被 QQ 拒绝,已改用普通消息重试: ${item.event_id}`);
|
|
1983
|
+
const retryItem = { ...item };
|
|
1984
|
+
delete retryItem.event_id;
|
|
1985
|
+
delete retryItem.msg_seq;
|
|
1986
|
+
return send(contact.peer, retryItem);
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
/**
|
|
1990
|
+
* 判断发送失败是否来自 QQ 对 `event_id` 的参数校验。
|
|
1991
|
+
*
|
|
1992
|
+
* 当前错误由 Http 层格式化为普通 Error,因此这里基于官方错误码和中文错误信息兜底识别。
|
|
1993
|
+
*
|
|
1994
|
+
* @param err 捕获到的发送异常。
|
|
1995
|
+
* @returns 是否可以通过移除 `event_id` 重试。
|
|
1996
|
+
*/
|
|
1997
|
+
const isInvalidEventIdError = (err) => {
|
|
1998
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1999
|
+
return message.includes("40034025") || message.includes("请求参数event_id无效");
|
|
2000
|
+
};
|
|
2001
|
+
/**
|
|
2002
|
+
* 解析 QQ 场景应使用的被动回复来源。
|
|
2003
|
+
*
|
|
2004
|
+
* `msg_id/event_id` 是 QQBot 被动发送凭证,不等于客户端展示引用使用的
|
|
2005
|
+
* `message_reference.message_id`。因此这里只读取事件入口追加的 pasmsg,显式
|
|
2006
|
+
* `segment.reply` 交给 `attachVisibleReferenceQQ` 处理。
|
|
2007
|
+
*
|
|
2008
|
+
* @param grouping 已归类的消息段。
|
|
2009
|
+
* @returns 可附加到发送体的被动回复来源;没有可用来源时返回 undefined。
|
|
2010
|
+
*/
|
|
2011
|
+
const resolvePassiveQQ = (grouping) => {
|
|
2012
|
+
if (grouping.pasmsg.id) return grouping.pasmsg;
|
|
2013
|
+
};
|
|
2014
|
+
/**
|
|
2015
|
+
* 附加 QQ 客户端可见的引用对象。
|
|
2016
|
+
*
|
|
2017
|
+
* 群聊/单聊里真正发给 QQ 的值必须是 `message_scene.ext` 的 `msg_idx=REFIDX_xxx`。
|
|
2018
|
+
* 如果插件传入的是 Karin 暴露的官方 API 消息 ID,这里会先按同会话映射转换。
|
|
2019
|
+
*
|
|
2020
|
+
* @param item 即将发送的 QQ 消息体。
|
|
2021
|
+
* @param grouping 已归类的消息段。
|
|
2022
|
+
* @returns 是否已经消费一次显式引用。
|
|
2023
|
+
*/
|
|
2024
|
+
const attachVisibleReferenceQQ = (ctx, contact, item, grouping) => {
|
|
2025
|
+
const messageId = resolveReferenceMessageId(ctx, contact, grouping.reply.messageId);
|
|
2026
|
+
if (!isQQReferenceMessageId(messageId)) return false;
|
|
2027
|
+
item.message_reference = { message_id: messageId };
|
|
2028
|
+
return true;
|
|
2029
|
+
};
|
|
2030
|
+
/**
|
|
2031
|
+
* 递增并返回下一条 QQ 被动回复序号。
|
|
2032
|
+
*
|
|
2033
|
+
* @param grouping 已归类的消息段。
|
|
2034
|
+
* @returns 0..65535 范围内的 msg_seq。
|
|
2035
|
+
*/
|
|
2036
|
+
const nextPassiveMsgSeq = (grouping) => {
|
|
2037
|
+
grouping.pasmsg.seq = (grouping.pasmsg.seq + 1) % 65536;
|
|
2038
|
+
return grouping.pasmsg.seq;
|
|
924
2039
|
};
|
|
925
2040
|
/**
|
|
926
2041
|
* 构造被动消息附加器(含白名单校验)
|
|
927
2042
|
*/
|
|
928
|
-
const buildPassiveQQ = (scene, grouping) => {
|
|
929
|
-
|
|
2043
|
+
const buildPassiveQQ = (ctx, scene, grouping) => {
|
|
2044
|
+
const source = resolvePassiveQQ(grouping);
|
|
2045
|
+
if (!source?.id) return (_item) => void 0;
|
|
930
2046
|
const whitelist = scene === "friend" ? FRIEND_EVENT_WHITELIST : GROUP_EVENT_WHITELIST;
|
|
931
|
-
if (
|
|
932
|
-
const eventName =
|
|
933
|
-
if (eventName && !whitelist.has(eventName)) {
|
|
2047
|
+
if (source.type === "event") {
|
|
2048
|
+
const eventName = source.id.split(":")[0];
|
|
2049
|
+
if (eventName && !whitelist.has(eventName)) {
|
|
2050
|
+
ctx.logger("warn", `跳过无效 event_id: ${source.id}`);
|
|
2051
|
+
return (_item) => void 0;
|
|
2052
|
+
}
|
|
934
2053
|
return (item) => {
|
|
935
|
-
grouping
|
|
936
|
-
item.
|
|
937
|
-
item.event_id = grouping.pasmsg.id;
|
|
2054
|
+
item.msg_seq = nextPassiveMsgSeq(grouping);
|
|
2055
|
+
item.event_id = source.id;
|
|
938
2056
|
};
|
|
939
2057
|
}
|
|
940
2058
|
return (item) => {
|
|
941
|
-
grouping
|
|
942
|
-
item.
|
|
943
|
-
item.msg_id = grouping.pasmsg.id;
|
|
2059
|
+
item.msg_seq = nextPassiveMsgSeq(grouping);
|
|
2060
|
+
item.msg_id = source.id;
|
|
944
2061
|
};
|
|
945
2062
|
};
|
|
946
2063
|
|
|
947
2064
|
//#endregion
|
|
948
2065
|
//#region src/core/adapter/pipeline-guild.ts
|
|
2066
|
+
/** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
|
|
2067
|
+
const KEYBOARD_MAX_ROWS = 5;
|
|
2068
|
+
const KEYBOARD_MAX_BUTTONS_PER_ROW = 5;
|
|
2069
|
+
/** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
|
|
2070
|
+
const BUTTON_ONLY_MARKDOWN = "";
|
|
949
2071
|
/**
|
|
950
2072
|
* 处理频道场景(子频道 + 私信)的发送
|
|
951
2073
|
*
|
|
@@ -978,9 +2100,12 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
|
|
|
978
2100
|
lines.push(...mdImages);
|
|
979
2101
|
}
|
|
980
2102
|
grouping.markdowns.forEach((m) => lines.push(m.markdown));
|
|
2103
|
+
warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2104
|
+
warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
|
|
981
2105
|
if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
|
|
982
2106
|
const keyboard = buildKeyboard(grouping);
|
|
983
|
-
|
|
2107
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
|
|
2108
|
+
items.push(ctx.super.guild.markdown({ content }, keyboard));
|
|
984
2109
|
}
|
|
985
2110
|
if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
|
|
986
2111
|
return flushGuild(ctx, contact, grouping, items);
|
|
@@ -989,39 +2114,259 @@ const buildKeyboard = (grouping) => {
|
|
|
989
2114
|
const rows = [];
|
|
990
2115
|
grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
|
|
991
2116
|
grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
|
|
992
|
-
|
|
993
|
-
|
|
2117
|
+
const normalizedRows = [];
|
|
2118
|
+
let id = 0;
|
|
2119
|
+
for (const row of rows.slice(0, KEYBOARD_MAX_ROWS)) {
|
|
2120
|
+
const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW).map((button) => normalizeQQBotButton(button, id++));
|
|
2121
|
+
if (buttons.length) normalizedRows.push({ buttons });
|
|
2122
|
+
}
|
|
2123
|
+
if (!normalizedRows.length) return void 0;
|
|
2124
|
+
return { content: { rows: normalizedRows } };
|
|
2125
|
+
};
|
|
2126
|
+
/**
|
|
2127
|
+
* 频道消息不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
|
|
2128
|
+
*
|
|
2129
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
2130
|
+
* @param scene 频道消息场景。
|
|
2131
|
+
* @param buttons 本次消息中的直发指令按钮。
|
|
2132
|
+
*/
|
|
2133
|
+
const warnUnsupportedCommandEnterButtons = (ctx, scene, buttons) => {
|
|
2134
|
+
if (!buttons.length) return;
|
|
2135
|
+
const names = formatCommandEnterButtonNames(buttons);
|
|
2136
|
+
const name = scene === "guild" ? "频道" : "频道私信";
|
|
2137
|
+
ctx.logger("debug", `${name}不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
|
|
2138
|
+
};
|
|
2139
|
+
/**
|
|
2140
|
+
* 频道消息不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
|
|
2141
|
+
*
|
|
2142
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
2143
|
+
* @param scene 频道消息场景。
|
|
2144
|
+
* @param markdowns 本次消息中的 markdown 内容。
|
|
2145
|
+
*/
|
|
2146
|
+
const warnUnsupportedCommandEnterMarkdowns = (ctx, scene, markdowns) => {
|
|
2147
|
+
if (!markdowns.some(hasCommandEnterTextChain)) return;
|
|
2148
|
+
const name = scene === "guild" ? "频道" : "频道私信";
|
|
2149
|
+
ctx.logger("debug", `${name}不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送`);
|
|
994
2150
|
};
|
|
995
2151
|
const flushGuild = async (ctx, contact, grouping, items) => {
|
|
996
2152
|
const result = ctx.initSendMsgResults();
|
|
997
2153
|
const passive = buildPassiveGuild(grouping);
|
|
998
2154
|
const send = contact.scene === "guild" ? (peer, subPeer, item) => ctx.super.messages.sendChannelMsg(subPeer, item) : (peer, _subPeer, item) => ctx.super.messages.sendDmsMsg(peer, item);
|
|
999
|
-
let replyHandled = false;
|
|
1000
2155
|
for (const item of items) {
|
|
1001
2156
|
passive(item);
|
|
1002
|
-
if (!replyHandled && grouping.reply.messageId && !(item instanceof FormData)) {
|
|
1003
|
-
item.message_reference = { message_id: grouping.reply.messageId };
|
|
1004
|
-
replyHandled = true;
|
|
1005
|
-
}
|
|
1006
2157
|
const res = await send(contact.peer, contact.subPeer, item);
|
|
1007
2158
|
result.rawData.push(res);
|
|
1008
2159
|
}
|
|
1009
2160
|
return ctx.handleResponse(result);
|
|
1010
2161
|
};
|
|
2162
|
+
/**
|
|
2163
|
+
* 解析频道/私信场景的被动回复来源。
|
|
2164
|
+
*
|
|
2165
|
+
* openclaw 的 qqbot 适配器对 channel/dm 也只传 `msg_id`。这里让显式
|
|
2166
|
+
* `segment.reply` 优先,其次使用事件入口追加的 pasmsg。
|
|
2167
|
+
*
|
|
2168
|
+
* @param grouping 已归类的消息段。
|
|
2169
|
+
* @returns 被动回复来源;没有可用来源时返回 undefined。
|
|
2170
|
+
*/
|
|
2171
|
+
const resolvePassiveGuild = (grouping) => {
|
|
2172
|
+
if (grouping.reply.messageId) return {
|
|
2173
|
+
type: "msg",
|
|
2174
|
+
id: grouping.reply.messageId
|
|
2175
|
+
};
|
|
2176
|
+
if (grouping.pasmsg.id) return grouping.pasmsg;
|
|
2177
|
+
};
|
|
2178
|
+
/**
|
|
2179
|
+
* 构造频道/私信被动消息附加器。
|
|
2180
|
+
*
|
|
2181
|
+
* @param grouping 已归类的消息段。
|
|
2182
|
+
* @returns 可直接修改发送体或 FormData 的附加函数。
|
|
2183
|
+
*/
|
|
1011
2184
|
const buildPassiveGuild = (grouping) => {
|
|
1012
|
-
|
|
1013
|
-
if (
|
|
1014
|
-
|
|
1015
|
-
|
|
2185
|
+
const source = resolvePassiveGuild(grouping);
|
|
2186
|
+
if (!source?.id) return (_item) => void 0;
|
|
2187
|
+
if (source.type === "event") return (item) => {
|
|
2188
|
+
if (item instanceof FormData) item.append("event_id", source.id);
|
|
2189
|
+
else item.event_id = source.id;
|
|
1016
2190
|
};
|
|
1017
2191
|
return (item) => {
|
|
1018
|
-
if (item instanceof FormData) item.append("msg_id",
|
|
1019
|
-
else item.msg_id =
|
|
2192
|
+
if (item instanceof FormData) item.append("msg_id", source.id);
|
|
2193
|
+
else item.msg_id = source.id;
|
|
2194
|
+
};
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
//#endregion
|
|
2198
|
+
//#region src/core/adapter/self-message-cache.ts
|
|
2199
|
+
/**
|
|
2200
|
+
* 只有两个缓存开关同时开启时才需要等待发送结果并缓存自己消息。
|
|
2201
|
+
*/
|
|
2202
|
+
const shouldCacheSelfMessage = (ctx) => {
|
|
2203
|
+
return ctx.cfg.messageCache.enable && ctx.cfg.messageCache.self;
|
|
2204
|
+
};
|
|
2205
|
+
/**
|
|
2206
|
+
* 预处理自己消息元素。
|
|
2207
|
+
*
|
|
2208
|
+
* 静态资源只在这里转一次 HTTP URL,之后发送 pipeline 和缓存都复用该 URL。
|
|
2209
|
+
*/
|
|
2210
|
+
const prepareSelfMessageCache = async (ctx, elements) => {
|
|
2211
|
+
const sendElements = [];
|
|
2212
|
+
const cacheElements = [];
|
|
2213
|
+
for (const element of elements) {
|
|
2214
|
+
const prepared = await prepareSelfCacheElement(ctx, element);
|
|
2215
|
+
sendElements.push(prepared.send);
|
|
2216
|
+
if (prepared.cache) cacheElements.push(prepared.cache);
|
|
2217
|
+
}
|
|
2218
|
+
return {
|
|
2219
|
+
sendElements,
|
|
2220
|
+
cacheElements
|
|
1020
2221
|
};
|
|
1021
2222
|
};
|
|
2223
|
+
/**
|
|
2224
|
+
* 发送成功后缓存机器人自己的消息,供 bot.getMsg(messageId) 查询。
|
|
2225
|
+
*/
|
|
2226
|
+
const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
|
|
2227
|
+
if (!shouldCacheSelfMessage(ctx)) return;
|
|
2228
|
+
if (!cacheElements.length) return;
|
|
2229
|
+
const responses = (Array.isArray(result.rawData) ? result.rawData : [result.rawData]).filter((item) => item?.id);
|
|
2230
|
+
if (!responses.length) return;
|
|
2231
|
+
const sender = selfSender(ctx, contact);
|
|
2232
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2233
|
+
for (const response of responses) {
|
|
2234
|
+
const apiMessageId = String(response.id);
|
|
2235
|
+
const referenceMessageId = typeof response.ext_info?.ref_idx === "string" ? response.ext_info.ref_idx : "";
|
|
2236
|
+
const messageId = apiMessageId;
|
|
2237
|
+
const aliases = [referenceMessageId].filter((id) => id && id !== messageId);
|
|
2238
|
+
if (!messageId || seen.has(messageId)) continue;
|
|
2239
|
+
seen.add(messageId);
|
|
2240
|
+
if (referenceMessageId) rememberApiMessageId(ctx, contact, referenceMessageId, apiMessageId);
|
|
2241
|
+
ctx.messageStore.save(String(ctx.cfg.appId), {
|
|
2242
|
+
messageId,
|
|
2243
|
+
messageSeq: 0,
|
|
2244
|
+
time: responseTime(response.timestamp),
|
|
2245
|
+
contact,
|
|
2246
|
+
sender,
|
|
2247
|
+
elements: cacheElements
|
|
2248
|
+
}, aliases).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
/**
|
|
2252
|
+
* 转换单个自己消息元素为缓存安全形态。
|
|
2253
|
+
*/
|
|
2254
|
+
const prepareSelfCacheElement = async (ctx, element) => {
|
|
2255
|
+
switch (element.type) {
|
|
2256
|
+
case "text":
|
|
2257
|
+
case "at":
|
|
2258
|
+
case "reply":
|
|
2259
|
+
case "face":
|
|
2260
|
+
case "markdown": {
|
|
2261
|
+
const cached = { ...element };
|
|
2262
|
+
return {
|
|
2263
|
+
send: cached,
|
|
2264
|
+
cache: cached
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
case "image":
|
|
2268
|
+
case "video":
|
|
2269
|
+
case "record":
|
|
2270
|
+
case "file": return prepareStaticElement(ctx, element);
|
|
2271
|
+
default: return {
|
|
2272
|
+
send: element,
|
|
2273
|
+
cache: null
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
};
|
|
2277
|
+
/**
|
|
2278
|
+
* 静态资源统一转 HTTP URL 后再缓存;转换失败时跳过该元素。
|
|
2279
|
+
*/
|
|
2280
|
+
const prepareStaticElement = async (ctx, element) => {
|
|
2281
|
+
if (element.file.startsWith("http")) {
|
|
2282
|
+
const cached = { ...element };
|
|
2283
|
+
return {
|
|
2284
|
+
send: cached,
|
|
2285
|
+
cache: cached
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
try {
|
|
2289
|
+
switch (element.type) {
|
|
2290
|
+
case "image": {
|
|
2291
|
+
const res = await fileToUrl("image", element.file, element.name || "image.jpg");
|
|
2292
|
+
const cached = {
|
|
2293
|
+
...element,
|
|
2294
|
+
file: res.url,
|
|
2295
|
+
width: res.width,
|
|
2296
|
+
height: res.height
|
|
2297
|
+
};
|
|
2298
|
+
return {
|
|
2299
|
+
send: cached,
|
|
2300
|
+
cache: cached
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
case "video": {
|
|
2304
|
+
const res = await fileToUrl("video", element.file, element.name || "video.mp4");
|
|
2305
|
+
const cached = {
|
|
2306
|
+
...element,
|
|
2307
|
+
file: res.url
|
|
2308
|
+
};
|
|
2309
|
+
return {
|
|
2310
|
+
send: cached,
|
|
2311
|
+
cache: cached
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
case "record": {
|
|
2315
|
+
const res = await fileToUrl("record", element.file, element.name || "record.mp3");
|
|
2316
|
+
const cached = {
|
|
2317
|
+
...element,
|
|
2318
|
+
file: res.url
|
|
2319
|
+
};
|
|
2320
|
+
return {
|
|
2321
|
+
send: cached,
|
|
2322
|
+
cache: cached
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
case "file": {
|
|
2326
|
+
const res = await fileToUrl("file", element.file, element.name || "file.bin");
|
|
2327
|
+
const cached = {
|
|
2328
|
+
...element,
|
|
2329
|
+
file: res.url
|
|
2330
|
+
};
|
|
2331
|
+
return {
|
|
2332
|
+
send: cached,
|
|
2333
|
+
cache: cached
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
} catch (err) {
|
|
2338
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2339
|
+
ctx.logger("debug", `[getMsg] 跳过自己消息资源缓存: ${element.type} ${message}`);
|
|
2340
|
+
return {
|
|
2341
|
+
send: element,
|
|
2342
|
+
cache: null
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
/**
|
|
2347
|
+
* 构造机器人自己的 sender。
|
|
2348
|
+
*/
|
|
2349
|
+
const selfSender = (ctx, contact) => {
|
|
2350
|
+
const userId = ctx.selfSubId("id") || ctx.selfId;
|
|
2351
|
+
const name = ctx.selfName || ctx.cfg.name || "";
|
|
2352
|
+
if (contact.scene === "friend" || contact.scene === "direct") return karin$1.friendSender(userId, name);
|
|
2353
|
+
return karin$1.groupSender(userId, "member", name);
|
|
2354
|
+
};
|
|
2355
|
+
/**
|
|
2356
|
+
* QQ / 频道发送接口 timestamp 格式不同,统一转成毫秒时间戳。
|
|
2357
|
+
*/
|
|
2358
|
+
const responseTime = (timestamp) => {
|
|
2359
|
+
if (typeof timestamp === "string") {
|
|
2360
|
+
const time = new Date(timestamp).getTime();
|
|
2361
|
+
return Number.isFinite(time) ? time : Date.now();
|
|
2362
|
+
}
|
|
2363
|
+
if (typeof timestamp === "number") return timestamp < 0xe8d4a51000 ? timestamp * 1e3 : timestamp;
|
|
2364
|
+
return Date.now();
|
|
2365
|
+
};
|
|
1022
2366
|
|
|
1023
2367
|
//#endregion
|
|
1024
2368
|
//#region src/core/adapter/base.ts
|
|
2369
|
+
const adapterConfigStore = /* @__PURE__ */ new WeakMap();
|
|
1025
2370
|
/**
|
|
1026
2371
|
* QQ Official Bot 适配器
|
|
1027
2372
|
*
|
|
@@ -1031,19 +2376,27 @@ const buildPassiveGuild = (grouping) => {
|
|
|
1031
2376
|
var AdapterQQBot = class extends AdapterBase {
|
|
1032
2377
|
/** 与官方 API 交互 */
|
|
1033
2378
|
super;
|
|
1034
|
-
/** 当前 bot 配置 */
|
|
1035
|
-
cfg;
|
|
1036
2379
|
/** openId / member_openid -> 昵称 缓存 */
|
|
1037
2380
|
nicknameCache = /* @__PURE__ */ new Map();
|
|
1038
2381
|
constructor(cfg, api) {
|
|
1039
2382
|
super();
|
|
1040
|
-
this
|
|
2383
|
+
adapterConfigStore.set(this, cfg);
|
|
1041
2384
|
this.super = api;
|
|
1042
2385
|
this.adapter.name = "QQ Official Bot";
|
|
1043
2386
|
this.adapter.protocol = "qqbot";
|
|
1044
2387
|
this.adapter.platform = "qq";
|
|
1045
2388
|
this.adapter.standard = "other";
|
|
1046
2389
|
}
|
|
2390
|
+
/** 当前 bot 配置。真实配置存放在 WeakMap,避免被系统接口序列化整个适配器时带出。 */
|
|
2391
|
+
get cfg() {
|
|
2392
|
+
const cfg = adapterConfigStore.get(this);
|
|
2393
|
+
if (!cfg) throw new Error("QQBot adapter config missing");
|
|
2394
|
+
return cfg;
|
|
2395
|
+
}
|
|
2396
|
+
/** 接收消息的一天热缓存 + SQLite 缓存,用于实现 Karin 标准 `getMsg`。 */
|
|
2397
|
+
get messageStore() {
|
|
2398
|
+
return getMessageStore();
|
|
2399
|
+
}
|
|
1047
2400
|
logger(level, ...args) {
|
|
1048
2401
|
logger.bot(level, this.selfId, ...args);
|
|
1049
2402
|
}
|
|
@@ -1051,26 +2404,46 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1051
2404
|
* 主消息回复入口(karin 调用)
|
|
1052
2405
|
*/
|
|
1053
2406
|
async srcReply(e, elements) {
|
|
1054
|
-
const extra =
|
|
2407
|
+
const extra = await buttonHandle(e.msg, { e });
|
|
1055
2408
|
return this.sendMsg(e.contact, [...elements, ...extra]);
|
|
1056
2409
|
}
|
|
1057
2410
|
/**
|
|
1058
2411
|
* 发送消息
|
|
1059
2412
|
*/
|
|
1060
2413
|
async sendMsg(contact, elements, _retryCount) {
|
|
1061
|
-
if (contact.scene === "direct" || contact.scene === "guild")
|
|
1062
|
-
|
|
2414
|
+
if (contact.scene === "direct" || contact.scene === "guild") {
|
|
2415
|
+
if (!shouldCacheSelfMessage(this)) return sendGuild(this, contact, elements);
|
|
2416
|
+
const prepared = await prepareSelfMessageCache(this, elements);
|
|
2417
|
+
const result = await sendGuild(this, contact, prepared.sendElements);
|
|
2418
|
+
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2419
|
+
return result;
|
|
2420
|
+
}
|
|
2421
|
+
if (contact.scene === "group" || contact.scene === "friend") {
|
|
2422
|
+
if (!shouldCacheSelfMessage(this)) return sendQQ(this, contact, elements);
|
|
2423
|
+
const prepared = await prepareSelfMessageCache(this, elements);
|
|
2424
|
+
const result = await sendQQ(this, contact, prepared.sendElements);
|
|
2425
|
+
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2426
|
+
return result;
|
|
2427
|
+
}
|
|
1063
2428
|
throw new Error(`不支持的消息场景: ${contact.scene}`);
|
|
1064
2429
|
}
|
|
1065
2430
|
/**
|
|
1066
2431
|
* 撤回消息
|
|
2432
|
+
*
|
|
2433
|
+
* 群聊中机器人被群主设为管理员后,也可撤回成员消息;平台仍限制消息发送后两分钟内。
|
|
1067
2434
|
*/
|
|
1068
2435
|
async recallMsg(contact, messageId) {
|
|
2436
|
+
const apiMessageId = resolveApiMessageId(this, contact, messageId);
|
|
1069
2437
|
try {
|
|
1070
|
-
if (contact.scene === "friend")
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
2438
|
+
if (contact.scene === "friend") {
|
|
2439
|
+
if (!isOwnMessageId(this, contact, apiMessageId)) {
|
|
2440
|
+
this.logger("warn", `[recallMsg] QQ 单聊只能撤回机器人自己发送的消息,已跳过撤回: ${messageId}`);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2443
|
+
await this.super.messages.recall("user", contact.peer, apiMessageId);
|
|
2444
|
+
} else if (contact.scene === "group") await this.super.messages.recall("group", contact.peer, apiMessageId);
|
|
2445
|
+
else if (contact.scene === "direct") await this.super.messages.recall("dms", contact.peer, apiMessageId);
|
|
2446
|
+
else if (contact.scene === "guild") await this.super.messages.recall("channels", contact.peer, apiMessageId);
|
|
1074
2447
|
} catch (err) {
|
|
1075
2448
|
logger.error("撤回消息失败:", err);
|
|
1076
2449
|
}
|
|
@@ -1187,20 +2560,54 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1187
2560
|
return `https://p.qlogo.cn/gh/${_groupId}/${_groupId}/${_size || 100}`;
|
|
1188
2561
|
}
|
|
1189
2562
|
/**
|
|
1190
|
-
*
|
|
2563
|
+
* 按消息 ID 获取已接收的消息。
|
|
2564
|
+
*
|
|
2565
|
+
* QQ 官方 Bot API 没有历史消息查询接口,因此仅从本地缓存读取。
|
|
2566
|
+
* 消息在接收时已转换为 Karin elements,最长保留一天;引用消息的 `REFIDX`
|
|
2567
|
+
* 索引同样可作为 `messageId` 查询。
|
|
2568
|
+
*
|
|
2569
|
+
* @param input OneBot v11 标准单参 messageId,或用于限定查询范围的 Contact。
|
|
2570
|
+
* @param messageId 当 input 是 Contact 时传入的目标消息 ID。
|
|
2571
|
+
* @returns 命中的消息;未命中时返回空消息结构。
|
|
1191
2572
|
*/
|
|
1192
|
-
async getMsg(
|
|
1193
|
-
|
|
2573
|
+
async getMsg(input, messageId) {
|
|
2574
|
+
/** OneBot v11 标准单参形式:bot.getMsg(messageId)。 */
|
|
2575
|
+
const targetId = typeof input === "string" ? input : messageId || "";
|
|
2576
|
+
const contact = typeof input === "string" ? void 0 : input;
|
|
2577
|
+
if (!this.cfg.messageCache.enable) {
|
|
2578
|
+
this.logger("debug", `[getMsg] 消息缓存已关闭: ${targetId || "(empty)"}`);
|
|
2579
|
+
return {
|
|
2580
|
+
time: 0,
|
|
2581
|
+
messageId: targetId,
|
|
2582
|
+
messageSeq: 0,
|
|
2583
|
+
contact: contact || {
|
|
2584
|
+
scene: "group",
|
|
2585
|
+
peer: "",
|
|
2586
|
+
subPeer: "",
|
|
2587
|
+
name: ""
|
|
2588
|
+
},
|
|
2589
|
+
sender: {
|
|
2590
|
+
userId: "",
|
|
2591
|
+
nick: "",
|
|
2592
|
+
name: "",
|
|
2593
|
+
role: "member"
|
|
2594
|
+
},
|
|
2595
|
+
elements: []
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
2598
|
+
const cached = await this.messageStore.get(String(this.cfg.appId), targetId, contact);
|
|
2599
|
+
if (cached) return cached;
|
|
2600
|
+
this.logger("debug", `[getMsg] 本地一天消息缓存未命中: ${targetId || "(empty)"}`);
|
|
1194
2601
|
return {
|
|
1195
2602
|
time: 0,
|
|
1196
|
-
messageId:
|
|
2603
|
+
messageId: targetId,
|
|
1197
2604
|
messageSeq: 0,
|
|
1198
|
-
contact:
|
|
2605
|
+
contact: contact || {
|
|
1199
2606
|
scene: "group",
|
|
1200
|
-
peer:
|
|
2607
|
+
peer: "",
|
|
1201
2608
|
subPeer: "",
|
|
1202
2609
|
name: ""
|
|
1203
|
-
}
|
|
2610
|
+
},
|
|
1204
2611
|
sender: {
|
|
1205
2612
|
userId: "",
|
|
1206
2613
|
nick: "",
|
|
@@ -1211,9 +2618,18 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1211
2618
|
};
|
|
1212
2619
|
}
|
|
1213
2620
|
/** @internal */
|
|
1214
|
-
async getHistoryMsg(
|
|
1215
|
-
|
|
1216
|
-
|
|
2621
|
+
async getHistoryMsg(contact, start, count = 1) {
|
|
2622
|
+
if (typeof start === "number") {
|
|
2623
|
+
this.logger("warn", "[getHistoryMsg] QQ Official Bot 仅支持通过 message_id 获取历史消息,不支持 message_seq");
|
|
2624
|
+
return [];
|
|
2625
|
+
}
|
|
2626
|
+
if (!this.cfg.messageCache.enable) {
|
|
2627
|
+
this.logger("warn", "[getHistoryMsg] 历史消息缓存为空,请打开“启用数据库缓存消息”开关后再记录历史消息");
|
|
2628
|
+
return [];
|
|
2629
|
+
}
|
|
2630
|
+
const history = await this.messageStore.getHistory(String(this.cfg.appId), contact, start, count);
|
|
2631
|
+
if (!history.length) this.logger("debug", `[getHistoryMsg] 本地一天历史消息缓存未命中: ${start || "(empty)"}`);
|
|
2632
|
+
return history;
|
|
1217
2633
|
}
|
|
1218
2634
|
/**
|
|
1219
2635
|
* 获取合并转发消息
|
|
@@ -1571,6 +2987,94 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
1571
2987
|
//#region src/core/event/conver.ts
|
|
1572
2988
|
/** 反转义 HTML 实体 */
|
|
1573
2989
|
const unescape = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2990
|
+
/** 从 QQ `message_scene.ext` 中提取消息索引。 */
|
|
2991
|
+
const getMessageSceneIndex = (scene, key) => {
|
|
2992
|
+
const prefix = `${key}=`;
|
|
2993
|
+
return scene?.ext?.find((item) => item.startsWith(prefix))?.slice(prefix.length) || "";
|
|
2994
|
+
};
|
|
2995
|
+
/** 原生 Markdown 不能从 QQ 回显中完全区分,只识别适配器发送后可稳定还原的常用语法。 */
|
|
2996
|
+
const isNativeMarkdown = (text) => {
|
|
2997
|
+
return /(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|~~|`{1,3}|\[[^\]]+\]\([^\s)]+\)|(^|\n)\s*\|.+\|\s*\n\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?/m.test(text);
|
|
2998
|
+
};
|
|
2999
|
+
/** 将非图片 Markdown 片段恢复为 text 或 markdown 元素。 */
|
|
3000
|
+
const pushMarkdownText = (text, elements) => {
|
|
3001
|
+
if (!text) return;
|
|
3002
|
+
elements.push(isNativeMarkdown(text) ? segment.markdown(text) : segment.text(text));
|
|
3003
|
+
};
|
|
3004
|
+
/**
|
|
3005
|
+
* 反向解析适配器发送的 QQ Markdown。
|
|
3006
|
+
*
|
|
3007
|
+
* 发送侧会将 Karin 图片编码为 ``,并以换行拼接文本、
|
|
3008
|
+
* 图片和原生 Markdown;这里按原顺序恢复为 Karin `text`、`image`、`markdown`。
|
|
3009
|
+
*/
|
|
3010
|
+
const pushQQMarkdown = (text, elements) => {
|
|
3011
|
+
const imageReg = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
3012
|
+
let lastIndex = 0;
|
|
3013
|
+
let matched = false;
|
|
3014
|
+
let match;
|
|
3015
|
+
while (match = imageReg.exec(text)) {
|
|
3016
|
+
matched = true;
|
|
3017
|
+
if (match.index > lastIndex) pushMarkdownText(text.slice(lastIndex, match.index), elements);
|
|
3018
|
+
const alt = match[1];
|
|
3019
|
+
const dimension = alt.match(/\s+#(\d+)px\s+#(\d+)px\s*$/);
|
|
3020
|
+
const name = alt.replace(/\s+#\d+px\s+#\d+px\s*$/, "") || "图片";
|
|
3021
|
+
elements.push(segment.image(match[2], {
|
|
3022
|
+
name,
|
|
3023
|
+
width: dimension ? Number(dimension[1]) : void 0,
|
|
3024
|
+
height: dimension ? Number(dimension[2]) : void 0
|
|
3025
|
+
}));
|
|
3026
|
+
lastIndex = imageReg.lastIndex;
|
|
3027
|
+
}
|
|
3028
|
+
if (matched && lastIndex < text.length) pushMarkdownText(text.slice(lastIndex), elements);
|
|
3029
|
+
return matched;
|
|
3030
|
+
};
|
|
3031
|
+
/**
|
|
3032
|
+
* 只拆 QQ 协议标签,保留普通文本中的 HTML/Markdown 片段。
|
|
3033
|
+
*
|
|
3034
|
+
* 普通文本或 Markdown 里可能包含 `<br>`、`<table>` 等内容;如果用 `[^<]+`
|
|
3035
|
+
* 直接切分,会把这些内容误当成协议标签边界,导致一条文本被拆成多段甚至丢失 `<`。
|
|
3036
|
+
*/
|
|
3037
|
+
const tokenizeContent = (content = "") => {
|
|
3038
|
+
const tokens = [];
|
|
3039
|
+
const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[A-Za-z0-9]+>/g;
|
|
3040
|
+
let lastIndex = 0;
|
|
3041
|
+
let match;
|
|
3042
|
+
while (match = knownTag.exec(content)) {
|
|
3043
|
+
if (match.index > lastIndex) tokens.push(content.slice(lastIndex, match.index));
|
|
3044
|
+
tokens.push(match[0]);
|
|
3045
|
+
lastIndex = knownTag.lastIndex;
|
|
3046
|
+
}
|
|
3047
|
+
if (lastIndex < content.length) tokens.push(content.slice(lastIndex));
|
|
3048
|
+
return tokens;
|
|
3049
|
+
};
|
|
3050
|
+
/**
|
|
3051
|
+
* 判断 QQ 回显内容是否应恢复成 Karin markdown 段。
|
|
3052
|
+
*
|
|
3053
|
+
* QQBot 自己收到自己发出的 markdown 时可能是 `message_type=103`;收到其他
|
|
3054
|
+
* QQBot 发出的 markdown 时,官方报文仍可能给 `message_type=0`,只能结合
|
|
3055
|
+
* `author.bot` 与内容语法做保守识别。
|
|
3056
|
+
*/
|
|
3057
|
+
const shouldUseMarkdown = (messageType, fromBot, text) => {
|
|
3058
|
+
return (messageType === 103 || fromBot) && isNativeMarkdown(text);
|
|
3059
|
+
};
|
|
3060
|
+
/**
|
|
3061
|
+
* 从 QQ 表情标签的 base64 ext 中提取可读文本。
|
|
3062
|
+
*
|
|
3063
|
+
* faceType=4 的表情包套图没有图片附件,只能通过 ext 中的 JSON 文本展示。
|
|
3064
|
+
*
|
|
3065
|
+
* @param ext QQ 表情标签里的 base64 字段。
|
|
3066
|
+
* @returns ext JSON 中的 text;解析失败时返回空字符串。
|
|
3067
|
+
*/
|
|
3068
|
+
const getFaceExtText = (ext) => {
|
|
3069
|
+
if (!ext) return "";
|
|
3070
|
+
try {
|
|
3071
|
+
const decoded = Buffer.from(ext, "base64").toString("utf-8");
|
|
3072
|
+
const payload = JSON.parse(decoded);
|
|
3073
|
+
return typeof payload.text === "string" ? payload.text : "";
|
|
3074
|
+
} catch {
|
|
3075
|
+
return "";
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
1574
3078
|
/**
|
|
1575
3079
|
* QQ Bot 报文 → karin elements
|
|
1576
3080
|
*
|
|
@@ -1582,17 +3086,39 @@ const unescape = (text) => text.replace(/&/g, "&").replace(/</g, "<").rep
|
|
|
1582
3086
|
const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
1583
3087
|
const elements = [];
|
|
1584
3088
|
const data = ev.d;
|
|
3089
|
+
const messageType = "message_type" in data ? data.message_type : void 0;
|
|
3090
|
+
const fromBot = !!data.author?.bot;
|
|
1585
3091
|
/** mentions 索引 id -> displayName */
|
|
1586
3092
|
const mentionMap = /* @__PURE__ */ new Map();
|
|
3093
|
+
const selfMentionIds = new Set([appid, selfSubId].filter(Boolean));
|
|
1587
3094
|
if (ev.t === "MESSAGE_CREATE" || ev.t === "AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
|
|
1588
3095
|
mentionMap.set(v.id, v.username);
|
|
3096
|
+
if (v.id === selfSubId) selfMentionIds.add(v.id);
|
|
1589
3097
|
});
|
|
1590
3098
|
else if (ev.t === "GROUP_MESSAGE_CREATE" || ev.t === "GROUP_AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
|
|
1591
3099
|
mentionMap.set(v.id, v.username);
|
|
1592
3100
|
mentionMap.set(v.member_openid, v.username);
|
|
3101
|
+
if (v.is_you) {
|
|
3102
|
+
selfMentionIds.add(v.id);
|
|
3103
|
+
selfMentionIds.add(v.member_openid);
|
|
3104
|
+
}
|
|
1593
3105
|
});
|
|
1594
|
-
|
|
1595
|
-
|
|
3106
|
+
const isSelfAt = (element) => element.type === "at" && selfMentionIds.has(element.targetId);
|
|
3107
|
+
const hasSelfAt = () => elements.some(isSelfAt);
|
|
3108
|
+
const pushAt = (id, name = "") => {
|
|
3109
|
+
const targetId = selfMentionIds.has(id) ? appid : id;
|
|
3110
|
+
if (targetId === appid && hasSelfAt()) {
|
|
3111
|
+
const prev = elements.at(-1);
|
|
3112
|
+
const beforePrev = elements.at(-2);
|
|
3113
|
+
if (prev?.type === "text" && !prev.text.trim() && beforePrev && isSelfAt(beforePrev)) elements.pop();
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
elements.push(segment.at(targetId, name));
|
|
3117
|
+
};
|
|
3118
|
+
/** 将 QQ 附件转换为 Karin 元素。 */
|
|
3119
|
+
const attachments = data.attachments || [];
|
|
3120
|
+
let attachmentIndex = 0;
|
|
3121
|
+
const pushAttachment = (v) => {
|
|
1596
3122
|
const url = v.url.startsWith("http") ? v.url : `https://${v.url}`;
|
|
1597
3123
|
if (v.content_type.startsWith("image/")) elements.push(segment.image(url, {
|
|
1598
3124
|
subType: v.content_type.split("/")[1],
|
|
@@ -1602,29 +3128,49 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
1602
3128
|
}));
|
|
1603
3129
|
else if (v.content_type === "video/mp4") elements.push(segment.video(url));
|
|
1604
3130
|
else if (v.content_type === "voice") elements.push(segment.record(v.voice_wav_url || url));
|
|
1605
|
-
else if (v.content_type === "file") elements.push(segment.
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
}))
|
|
1609
|
-
}
|
|
1610
|
-
|
|
3131
|
+
else if (v.content_type === "file") elements.push(segment.file(url, {
|
|
3132
|
+
name: v.filename,
|
|
3133
|
+
size: v.size
|
|
3134
|
+
}));
|
|
3135
|
+
};
|
|
3136
|
+
/** content 解析 */
|
|
3137
|
+
const tokens = tokenizeContent(data?.content);
|
|
1611
3138
|
const cfg = getConfig(appid);
|
|
1612
3139
|
for (const tok of tokens) {
|
|
1613
3140
|
if (tok.startsWith("<faceType=")) {
|
|
1614
|
-
const m = tok.match(/
|
|
1615
|
-
|
|
3141
|
+
const m = tok.match(/faceType=(\d+),faceId="([^"]*)",ext="([^"]*)"/);
|
|
3142
|
+
const faceType = Number(m?.[1]);
|
|
3143
|
+
const faceIdText = m?.[2] || "";
|
|
3144
|
+
const faceId = Number(faceIdText);
|
|
3145
|
+
const attachment = attachments[attachmentIndex];
|
|
3146
|
+
if (!faceIdText) {
|
|
3147
|
+
if (attachment?.content_type.startsWith("image/")) {
|
|
3148
|
+
pushAttachment(attachment);
|
|
3149
|
+
attachmentIndex++;
|
|
3150
|
+
} else {
|
|
3151
|
+
const text = getFaceExtText(m?.[3] || "");
|
|
3152
|
+
if (text) elements.push(segment.text(text));
|
|
3153
|
+
}
|
|
3154
|
+
continue;
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
3157
|
+
* QQ 将用户收藏表情图片编码为 `faceId=0` + 图片附件。附件在 content 中
|
|
3158
|
+
* 没有独立标签,必须在这个占位符位置消费,才能保留原始元素顺序。
|
|
3159
|
+
*/
|
|
3160
|
+
if (faceId === 0 && (messageType === 103 || faceType === 6) && attachment?.content_type.startsWith("image/")) {
|
|
3161
|
+
pushAttachment(attachment);
|
|
3162
|
+
attachmentIndex++;
|
|
3163
|
+
} else if (faceType === 6 && faceId === 0) {} else if (m) elements.push(segment.face(faceId, faceType === 3));
|
|
1616
3164
|
continue;
|
|
1617
3165
|
}
|
|
1618
|
-
if (tok.startsWith("
|
|
1619
|
-
const id = tok.replace(
|
|
1620
|
-
|
|
1621
|
-
elements.push(segment.at(id === selfSubId ? appid : id, name));
|
|
3166
|
+
if (tok.startsWith("<@")) {
|
|
3167
|
+
const id = tok.replace(/^<@!?|>$/g, "");
|
|
3168
|
+
pushAt(id, mentionMap.get(id) || "");
|
|
1622
3169
|
continue;
|
|
1623
3170
|
}
|
|
1624
3171
|
if (tok.startsWith("<qqbot-at-user")) {
|
|
1625
3172
|
const id = tok.match(/id="([^"]+)"/)?.[1] || "";
|
|
1626
|
-
|
|
1627
|
-
elements.push(segment.at(id === selfSubId ? appid : id, name));
|
|
3173
|
+
pushAt(id, mentionMap.get(id) || "");
|
|
1628
3174
|
continue;
|
|
1629
3175
|
}
|
|
1630
3176
|
if (tok.startsWith("<qqbot-at-everyone")) {
|
|
@@ -1634,10 +3180,18 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
1634
3180
|
let text = unescape(tok);
|
|
1635
3181
|
if (cfg?.regex) for (const r of cfg.regex) {
|
|
1636
3182
|
const reg = r.reg instanceof RegExp ? r.reg : new RegExp(r.reg);
|
|
1637
|
-
text = text.
|
|
3183
|
+
text = text.replace(reg, r.rep);
|
|
3184
|
+
}
|
|
3185
|
+
if (!text) continue;
|
|
3186
|
+
if (messageType === 103 && pushQQMarkdown(text, elements)) continue;
|
|
3187
|
+
if (shouldUseMarkdown(messageType, fromBot, text)) {
|
|
3188
|
+
elements.push(segment.markdown(text));
|
|
3189
|
+
continue;
|
|
1638
3190
|
}
|
|
1639
3191
|
elements.push(segment.text(text));
|
|
1640
3192
|
}
|
|
3193
|
+
/** content 中未出现占位符的普通附件,按 QQ 报文顺序追加。 */
|
|
3194
|
+
for (; attachmentIndex < attachments.length; attachmentIndex++) pushAttachment(attachments[attachmentIndex]);
|
|
1641
3195
|
/** 群消息是否需要在头部补 self at */
|
|
1642
3196
|
if (ev.t === "GROUP_AT_MESSAGE_CREATE" || ev.t === "GROUP_MESSAGE_CREATE") {
|
|
1643
3197
|
const youMention = (ev.d.mentions || []).find((m) => m.is_you);
|
|
@@ -1647,10 +3201,60 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
1647
3201
|
}
|
|
1648
3202
|
return elements;
|
|
1649
3203
|
};
|
|
3204
|
+
/**
|
|
3205
|
+
* 将 QQ 引用上下文转换为 Karin 元素。
|
|
3206
|
+
*
|
|
3207
|
+
* QQ 不会提供引用消息的正式消息 ID;调用方应以 `msg_idx` 作为缓存键。
|
|
3208
|
+
*/
|
|
3209
|
+
const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
|
|
3210
|
+
return convertToKarin(appid, {
|
|
3211
|
+
...ev,
|
|
3212
|
+
d: {
|
|
3213
|
+
...ev.d,
|
|
3214
|
+
content: reference.content,
|
|
3215
|
+
attachments: reference.attachments,
|
|
3216
|
+
mentions: void 0,
|
|
3217
|
+
message_scene: void 0,
|
|
3218
|
+
msg_elements: void 0,
|
|
3219
|
+
message_type: reference.message_type
|
|
3220
|
+
}
|
|
3221
|
+
}, selfSubId);
|
|
3222
|
+
};
|
|
1650
3223
|
|
|
1651
3224
|
//#endregion
|
|
1652
3225
|
//#region src/core/event/message.ts
|
|
1653
3226
|
/**
|
|
3227
|
+
* 写入 getMsg 热缓存并投递 SQLite 后台队列。
|
|
3228
|
+
*
|
|
3229
|
+
* 不等待数据库落库,避免缓存特性拖慢 Karin 消息事件下发。
|
|
3230
|
+
*
|
|
3231
|
+
* @param client 当前 QQBot 适配器实例。
|
|
3232
|
+
* @param message 需要缓存的消息快照。
|
|
3233
|
+
* @param aliases 可用于查询同一消息的 QQ 侧索引,例如 msg_idx。
|
|
3234
|
+
*/
|
|
3235
|
+
const cacheMessage = (client, message, aliases = []) => {
|
|
3236
|
+
if (!client.cfg.messageCache.enable) return;
|
|
3237
|
+
client.messageStore.save(String(client.cfg.appId), {
|
|
3238
|
+
...message,
|
|
3239
|
+
messageSeq: 0
|
|
3240
|
+
}, aliases).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
|
|
3241
|
+
};
|
|
3242
|
+
/**
|
|
3243
|
+
* 写入引用消息上下文缓存。
|
|
3244
|
+
*
|
|
3245
|
+
* 引用上下文没有正式消息 ID 时,以 QQ 的 `ref_msg_idx` 建立只写入一次的缓存。
|
|
3246
|
+
*
|
|
3247
|
+
* @param client 当前 QQBot 适配器实例。
|
|
3248
|
+
* @param message 以 ref_msg_idx 作为 messageId 的引用消息快照。
|
|
3249
|
+
*/
|
|
3250
|
+
const cacheReference = (client, message) => {
|
|
3251
|
+
if (!client.cfg.messageCache.enable) return;
|
|
3252
|
+
client.messageStore.saveReferenceIfAbsent(String(client.cfg.appId), {
|
|
3253
|
+
...message,
|
|
3254
|
+
messageSeq: 0
|
|
3255
|
+
}).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
|
|
3256
|
+
};
|
|
3257
|
+
/**
|
|
1654
3258
|
* 群消息(@机器人 / 普通消息合并入口)
|
|
1655
3259
|
*/
|
|
1656
3260
|
const onGroupMsg = (client, ev, opts) => {
|
|
@@ -1661,16 +3265,38 @@ const onGroupMsg = (client, ev, opts) => {
|
|
|
1661
3265
|
ev.d.mentions?.forEach((m) => {
|
|
1662
3266
|
if (m.member_openid && m.username) client.nicknameCache.set(m.member_openid, m.username);
|
|
1663
3267
|
});
|
|
1664
|
-
const contact = karin.contactGroup(ev.d.group_openid || ev.d.group_id);
|
|
1665
|
-
const sender = karin.groupSender(userId, "
|
|
3268
|
+
const contact = karin$1.contactGroup(ev.d.group_openid || ev.d.group_id);
|
|
3269
|
+
const sender = karin$1.groupSender(userId, ev.d.author.member_role || "unknown", username);
|
|
3270
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"), { forceAtSelf: opts.forceAtSelf });
|
|
3271
|
+
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3272
|
+
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3273
|
+
const karinMessageId = ev.d.id;
|
|
3274
|
+
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3275
|
+
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3276
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3277
|
+
cacheMessage(client, {
|
|
3278
|
+
messageId: karinMessageId,
|
|
3279
|
+
time,
|
|
3280
|
+
contact,
|
|
3281
|
+
sender,
|
|
3282
|
+
elements
|
|
3283
|
+
}, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
|
|
3284
|
+
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3285
|
+
if (reference && referenceIndex) cacheReference(client, {
|
|
3286
|
+
messageId: referenceIndex,
|
|
3287
|
+
time,
|
|
3288
|
+
contact,
|
|
3289
|
+
sender: karin$1.groupSender("", "member", reference.author?.username || ""),
|
|
3290
|
+
elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
|
|
3291
|
+
});
|
|
1666
3292
|
const e = createGroupMessage({
|
|
1667
3293
|
bot: client,
|
|
1668
|
-
elements
|
|
3294
|
+
elements,
|
|
1669
3295
|
eventId: ev.id,
|
|
1670
|
-
messageId:
|
|
3296
|
+
messageId: karinMessageId,
|
|
1671
3297
|
messageSeq: 0,
|
|
1672
3298
|
rawEvent: ev,
|
|
1673
|
-
time
|
|
3299
|
+
time,
|
|
1674
3300
|
contact,
|
|
1675
3301
|
sender,
|
|
1676
3302
|
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
@@ -1685,16 +3311,38 @@ const onFriendMsg = (client, ev) => {
|
|
|
1685
3311
|
const userId = ev.d.author.user_openid || ev.d.author.id;
|
|
1686
3312
|
const username = ev.d.author.username || "";
|
|
1687
3313
|
if (userId && username) client.nicknameCache.set(userId, username);
|
|
1688
|
-
const contact = karin.contactFriend(userId);
|
|
1689
|
-
const sender = karin.friendSender(userId, username);
|
|
3314
|
+
const contact = karin$1.contactFriend(userId);
|
|
3315
|
+
const sender = karin$1.friendSender(userId, username);
|
|
3316
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
|
|
3317
|
+
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3318
|
+
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3319
|
+
const karinMessageId = ev.d.id;
|
|
3320
|
+
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3321
|
+
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3322
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3323
|
+
cacheMessage(client, {
|
|
3324
|
+
messageId: karinMessageId,
|
|
3325
|
+
time,
|
|
3326
|
+
contact,
|
|
3327
|
+
sender,
|
|
3328
|
+
elements
|
|
3329
|
+
}, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
|
|
3330
|
+
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3331
|
+
if (reference && referenceIndex) cacheReference(client, {
|
|
3332
|
+
messageId: referenceIndex,
|
|
3333
|
+
time,
|
|
3334
|
+
contact,
|
|
3335
|
+
sender: karin$1.friendSender("", reference.author?.username || ""),
|
|
3336
|
+
elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
|
|
3337
|
+
});
|
|
1690
3338
|
const e = createFriendMessage({
|
|
1691
3339
|
bot: client,
|
|
1692
|
-
elements
|
|
3340
|
+
elements,
|
|
1693
3341
|
eventId: ev.id,
|
|
1694
|
-
messageId:
|
|
3342
|
+
messageId: karinMessageId,
|
|
1695
3343
|
messageSeq: 0,
|
|
1696
3344
|
rawEvent: ev,
|
|
1697
|
-
time
|
|
3345
|
+
time,
|
|
1698
3346
|
contact,
|
|
1699
3347
|
sender,
|
|
1700
3348
|
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
@@ -1707,16 +3355,25 @@ const onFriendMsg = (client, ev) => {
|
|
|
1707
3355
|
const onChannelMsg = (client, ev) => {
|
|
1708
3356
|
const selfId = client.selfId;
|
|
1709
3357
|
const userId = ev.d.author.id;
|
|
1710
|
-
const contact = karin.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
1711
|
-
const sender = karin.groupSender(userId, "unknown", ev.d.author.username || "");
|
|
3358
|
+
const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
3359
|
+
const sender = karin$1.groupSender(userId, "unknown", ev.d.author.username || "");
|
|
3360
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
|
|
3361
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3362
|
+
cacheMessage(client, {
|
|
3363
|
+
messageId: ev.d.id,
|
|
3364
|
+
time,
|
|
3365
|
+
contact,
|
|
3366
|
+
sender,
|
|
3367
|
+
elements
|
|
3368
|
+
});
|
|
1712
3369
|
const e = createGuildMessage({
|
|
1713
3370
|
bot: client,
|
|
1714
|
-
elements
|
|
3371
|
+
elements,
|
|
1715
3372
|
eventId: ev.id,
|
|
1716
3373
|
messageId: ev.d.id,
|
|
1717
3374
|
messageSeq: 0,
|
|
1718
3375
|
rawEvent: ev,
|
|
1719
|
-
time
|
|
3376
|
+
time,
|
|
1720
3377
|
contact,
|
|
1721
3378
|
sender,
|
|
1722
3379
|
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
@@ -1729,16 +3386,25 @@ const onChannelMsg = (client, ev) => {
|
|
|
1729
3386
|
const onDirectMsg = (client, ev) => {
|
|
1730
3387
|
const selfId = client.selfId;
|
|
1731
3388
|
const userId = ev.d.author.id;
|
|
1732
|
-
const contact = karin.contact("direct", ev.d.guild_id, ev.d.channel_id);
|
|
1733
|
-
const sender = karin.friendSender(userId, ev.d.author.username || "");
|
|
3389
|
+
const contact = karin$1.contact("direct", ev.d.guild_id, ev.d.channel_id);
|
|
3390
|
+
const sender = karin$1.friendSender(userId, ev.d.author.username || "");
|
|
3391
|
+
const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
|
|
3392
|
+
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3393
|
+
cacheMessage(client, {
|
|
3394
|
+
messageId: ev.d.id,
|
|
3395
|
+
time,
|
|
3396
|
+
contact,
|
|
3397
|
+
sender,
|
|
3398
|
+
elements
|
|
3399
|
+
});
|
|
1734
3400
|
const e = createDirectMessage({
|
|
1735
3401
|
bot: client,
|
|
1736
|
-
elements
|
|
3402
|
+
elements,
|
|
1737
3403
|
eventId: ev.id,
|
|
1738
3404
|
messageId: ev.d.id,
|
|
1739
3405
|
messageSeq: 0,
|
|
1740
3406
|
rawEvent: ev,
|
|
1741
|
-
time
|
|
3407
|
+
time,
|
|
1742
3408
|
contact,
|
|
1743
3409
|
sender,
|
|
1744
3410
|
srcGuildId: ev.d.src_guild_id,
|
|
@@ -1758,8 +3424,8 @@ const onGroupAddRobot = (client, event) => {
|
|
|
1758
3424
|
const selfId = client.selfId;
|
|
1759
3425
|
const userId = event.d.op_member_openid;
|
|
1760
3426
|
const groupId = event.d.group_openid;
|
|
1761
|
-
const contact = karin.contactGroup(groupId);
|
|
1762
|
-
const sender = karin.groupSender(
|
|
3427
|
+
const contact = karin$1.contactGroup(groupId);
|
|
3428
|
+
const sender = karin$1.groupSender(selfId, "member");
|
|
1763
3429
|
const eventId = event.id;
|
|
1764
3430
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1765
3431
|
createGroupMemberAddNotice({
|
|
@@ -1786,8 +3452,8 @@ const onGroupDelRobot = (client, event) => {
|
|
|
1786
3452
|
const selfId = client.selfId;
|
|
1787
3453
|
const userId = event.d.op_member_openid;
|
|
1788
3454
|
const groupId = event.d.group_openid;
|
|
1789
|
-
const contact = karin.contactGroup(groupId);
|
|
1790
|
-
const sender = karin.groupSender(
|
|
3455
|
+
const contact = karin$1.contactGroup(groupId);
|
|
3456
|
+
const sender = karin$1.groupSender(selfId, "unknown");
|
|
1791
3457
|
const eventId = event.id;
|
|
1792
3458
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1793
3459
|
createGroupMemberDelNotice({
|
|
@@ -1806,6 +3472,64 @@ const onGroupDelRobot = (client, event) => {
|
|
|
1806
3472
|
});
|
|
1807
3473
|
};
|
|
1808
3474
|
/**
|
|
3475
|
+
* 群成员加入事件。
|
|
3476
|
+
*
|
|
3477
|
+
* QQ 官方仅下发加入成员,未提供操作人和加入方式;Karin 的通知模型要求
|
|
3478
|
+
* `type`,因此以 `approve` 表示已加入,并将 `operatorId` 留空。
|
|
3479
|
+
* 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
|
|
3480
|
+
*
|
|
3481
|
+
* @param client 机器人实例
|
|
3482
|
+
* @param event 官方群成员加入事件
|
|
3483
|
+
*/
|
|
3484
|
+
const onGroupMemberAdd = (client, event) => {
|
|
3485
|
+
const memberId = event.d.member_openid;
|
|
3486
|
+
const contact = karin$1.contactGroup(event.d.group_openid);
|
|
3487
|
+
const sender = karin$1.groupSender(memberId, "member");
|
|
3488
|
+
createGroupMemberAddNotice({
|
|
3489
|
+
contact,
|
|
3490
|
+
eventId: event.id,
|
|
3491
|
+
sender,
|
|
3492
|
+
srcReply: (elements) => client.sendMsg(contact, elements),
|
|
3493
|
+
bot: client,
|
|
3494
|
+
rawEvent: event,
|
|
3495
|
+
time: event.d.timestamp,
|
|
3496
|
+
content: {
|
|
3497
|
+
operatorId: "",
|
|
3498
|
+
targetId: memberId,
|
|
3499
|
+
type: "approve"
|
|
3500
|
+
}
|
|
3501
|
+
});
|
|
3502
|
+
};
|
|
3503
|
+
/**
|
|
3504
|
+
* 群成员退出事件。
|
|
3505
|
+
*
|
|
3506
|
+
* QQ 官方仅下发退出成员,未提供操作人和退出原因;Karin 的通知模型要求
|
|
3507
|
+
* `type`,因此以 `leave` 表示成员已离开,并将 `operatorId` 留空。
|
|
3508
|
+
* 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
|
|
3509
|
+
*
|
|
3510
|
+
* @param client 机器人实例
|
|
3511
|
+
* @param event 官方群成员退出事件
|
|
3512
|
+
*/
|
|
3513
|
+
const onGroupMemberRemove = (client, event) => {
|
|
3514
|
+
const memberId = event.d.member_openid;
|
|
3515
|
+
const contact = karin$1.contactGroup(event.d.group_openid);
|
|
3516
|
+
const sender = karin$1.groupSender(memberId, "unknown");
|
|
3517
|
+
createGroupMemberDelNotice({
|
|
3518
|
+
contact,
|
|
3519
|
+
eventId: event.id,
|
|
3520
|
+
sender,
|
|
3521
|
+
srcReply: (elements) => client.sendMsg(contact, elements),
|
|
3522
|
+
bot: client,
|
|
3523
|
+
rawEvent: event,
|
|
3524
|
+
time: event.d.timestamp,
|
|
3525
|
+
content: {
|
|
3526
|
+
operatorId: "",
|
|
3527
|
+
targetId: memberId,
|
|
3528
|
+
type: "leave"
|
|
3529
|
+
}
|
|
3530
|
+
});
|
|
3531
|
+
};
|
|
3532
|
+
/**
|
|
1809
3533
|
* 群聊接受机器人主动消息
|
|
1810
3534
|
* @param client 机器人实例
|
|
1811
3535
|
* @param event 事件
|
|
@@ -1833,8 +3557,8 @@ const onGroupMsgReject = (client, event) => {
|
|
|
1833
3557
|
const onFriendAdd = (client, event) => {
|
|
1834
3558
|
const selfId = client.selfId;
|
|
1835
3559
|
const userId = event.d.openid;
|
|
1836
|
-
const contact = karin.contactFriend(userId);
|
|
1837
|
-
const sender = karin.friendSender(userId, "");
|
|
3560
|
+
const contact = karin$1.contactFriend(userId);
|
|
3561
|
+
const sender = karin$1.friendSender(userId, "");
|
|
1838
3562
|
const eventId = event.id;
|
|
1839
3563
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1840
3564
|
createFriendIncreaseNotice({
|
|
@@ -1856,8 +3580,8 @@ const onFriendAdd = (client, event) => {
|
|
|
1856
3580
|
const onFriendDel = (client, event) => {
|
|
1857
3581
|
const selfId = client.selfId;
|
|
1858
3582
|
const userId = event.d.openid;
|
|
1859
|
-
const contact = karin.contactFriend(userId);
|
|
1860
|
-
const sender = karin.friendSender(userId, "");
|
|
3583
|
+
const contact = karin$1.contactFriend(userId);
|
|
3584
|
+
const sender = karin$1.friendSender(userId, "");
|
|
1861
3585
|
const eventId = event.id;
|
|
1862
3586
|
const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
|
|
1863
3587
|
createFriendDecreaseNotice({
|
|
@@ -1893,46 +3617,103 @@ const onC2CMsgReject = (client, event) => {
|
|
|
1893
3617
|
//#endregion
|
|
1894
3618
|
//#region src/core/event/interaction.ts
|
|
1895
3619
|
/**
|
|
3620
|
+
* 取得互动事件真正用于 ACK 的平台 ID。
|
|
3621
|
+
*
|
|
3622
|
+
* WebSocket Dispatch 外层也有一个 `id`,形如 `INTERACTION_CREATE:uuid`;按钮 ACK
|
|
3623
|
+
* 接口需要的是事件体内的 `d.id`,也就是不带事件名前缀的 uuid。
|
|
3624
|
+
*
|
|
3625
|
+
* @param ev QQBot 原始互动事件。
|
|
3626
|
+
* @returns 可传给 `/interactions/{interaction_id}` 的 ID。
|
|
3627
|
+
*/
|
|
3628
|
+
const getInteractionId = (ev) => ev.d.id || ev.id;
|
|
3629
|
+
/**
|
|
3630
|
+
* 构造按钮回调附加给 Karin 消息的元信息。
|
|
3631
|
+
*
|
|
3632
|
+
* QQBot 不同场景下下发字段差异很大:群聊通常只有 button_data/button_id,
|
|
3633
|
+
* 频道才可能带 user_id/message_id/feature_id。这里只写入实际存在的字段,
|
|
3634
|
+
* 避免插件侧看到一堆语义不明的空值。
|
|
3635
|
+
*
|
|
3636
|
+
* @param ev QQBot 原始互动事件。
|
|
3637
|
+
* @param interactionId 已确认可用于 ACK 的平台互动 ID。
|
|
3638
|
+
* @returns 可放入 segment.json 的精简元信息。
|
|
3639
|
+
*/
|
|
3640
|
+
const createInteractionMeta = (ev, interactionId) => {
|
|
3641
|
+
const resolved = ev.d.data?.resolved;
|
|
3642
|
+
const meta = {
|
|
3643
|
+
tag: "qqbot-button-click",
|
|
3644
|
+
interaction_id: interactionId,
|
|
3645
|
+
dispatch_id: ev.id,
|
|
3646
|
+
interaction_type: ev.d.type
|
|
3647
|
+
};
|
|
3648
|
+
const optional = {
|
|
3649
|
+
data_type: ev.d.data?.type,
|
|
3650
|
+
chat_type: ev.d.chat_type,
|
|
3651
|
+
scene: ev.d.scene,
|
|
3652
|
+
button_id: resolved?.button_id,
|
|
3653
|
+
button_data: resolved?.button_data,
|
|
3654
|
+
application_id: ev.d.application_id,
|
|
3655
|
+
group_openid: ev.d.group_openid,
|
|
3656
|
+
group_member_openid: ev.d.group_member_openid,
|
|
3657
|
+
user_openid: ev.d.user_openid,
|
|
3658
|
+
guild_id: ev.d.guild_id,
|
|
3659
|
+
channel_id: ev.d.channel_id,
|
|
3660
|
+
guild_user_id: resolved?.user_id,
|
|
3661
|
+
feature_id: resolved?.feature_id,
|
|
3662
|
+
message_id: resolved?.message_id,
|
|
3663
|
+
require_mention: resolved?.require_mention,
|
|
3664
|
+
group_policy: resolved?.group_policy,
|
|
3665
|
+
mention_patterns: resolved?.mention_patterns,
|
|
3666
|
+
version: ev.d.version
|
|
3667
|
+
};
|
|
3668
|
+
for (const [key, value] of Object.entries(optional)) if (value !== void 0 && value !== "") meta[key] = value;
|
|
3669
|
+
return meta;
|
|
3670
|
+
};
|
|
3671
|
+
/**
|
|
1896
3672
|
* INTERACTION_CREATE 处理
|
|
1897
3673
|
*
|
|
1898
3674
|
* - 先 ack 客户端(必须在 3s 内),失败仅 warn
|
|
1899
3675
|
* - 借用普通消息事件投递给业务层,业务层通过 e.rawEvent.t === 'INTERACTION_CREATE' 区分
|
|
1900
|
-
* - elements
|
|
3676
|
+
* - elements 只放 button_data 文本,避免 at/json 段污染 e.msg 导致 command 无法精确匹配
|
|
1901
3677
|
*/
|
|
1902
3678
|
const onInteraction = (client, ev) => {
|
|
1903
|
-
|
|
3679
|
+
const interactionId = getInteractionId(ev);
|
|
3680
|
+
const ackStart = Date.now();
|
|
3681
|
+
client.logger("debug", `[INTERACTION] ack 准备: ${interactionId} dispatch=${ev.id}`);
|
|
3682
|
+
client.super.interaction.ack(interactionId, 0).then(() => {
|
|
3683
|
+
client.logger("debug", `[INTERACTION] ack 成功: ${interactionId} (${Date.now() - ackStart}ms)`);
|
|
3684
|
+
}).catch((err) => {
|
|
1904
3685
|
client.logger("warn", "[INTERACTION] ack 失败:", err);
|
|
3686
|
+
}).finally(() => {
|
|
3687
|
+
emitInteractionMessage(client, ev, interactionId);
|
|
1905
3688
|
});
|
|
1906
|
-
|
|
3689
|
+
};
|
|
3690
|
+
/**
|
|
3691
|
+
* 将按钮互动事件转换为 Karin 消息事件。
|
|
3692
|
+
*
|
|
3693
|
+
* ACK 请求先发出并结束后再投递业务层,避免业务回复请求抢在 ACK 前占用回调窗口。
|
|
3694
|
+
*
|
|
3695
|
+
* @param client 当前 QQBot 适配器实例。
|
|
3696
|
+
* @param ev QQBot 原始互动事件。
|
|
3697
|
+
* @param interactionId 已确认可用于 ACK 的平台互动 ID。
|
|
3698
|
+
*/
|
|
3699
|
+
const emitInteractionMessage = (client, ev, interactionId) => {
|
|
1907
3700
|
const buttonData = ev.d.data?.resolved?.button_data || "";
|
|
1908
|
-
const
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
button_id: ev.d.data?.resolved?.button_id,
|
|
1914
|
-
button_data: ev.d.data?.resolved?.button_data,
|
|
1915
|
-
feature_id: ev.d.data?.resolved?.feature_id,
|
|
1916
|
-
message_id: ev.d.data?.resolved?.message_id,
|
|
1917
|
-
user_id: ev.d.data?.resolved?.user_id,
|
|
1918
|
-
version: ev.d.version
|
|
1919
|
-
}));
|
|
1920
|
-
const time = Number(new Date(ev.d.timestamp).getTime() || Date.now());
|
|
3701
|
+
const rawEvent = {
|
|
3702
|
+
...ev,
|
|
3703
|
+
buttonMeta: createInteractionMeta(ev, interactionId)
|
|
3704
|
+
};
|
|
3705
|
+
const time = Number(ev.d.timestamp ? new Date(ev.d.timestamp).getTime() : Date.now()) || Date.now();
|
|
1921
3706
|
if (ev.d.chat_type === 1 && ev.d.group_openid) {
|
|
1922
3707
|
const userId = ev.d.group_member_openid || "";
|
|
1923
|
-
const contact = karin.contactGroup(ev.d.group_openid);
|
|
1924
|
-
const sender = karin.groupSender(userId, "unknown");
|
|
3708
|
+
const contact = karin$1.contactGroup(ev.d.group_openid);
|
|
3709
|
+
const sender = karin$1.groupSender(userId, "unknown");
|
|
1925
3710
|
const e = createGroupMessage({
|
|
1926
3711
|
bot: client,
|
|
1927
|
-
elements: [
|
|
1928
|
-
segment.at(selfId),
|
|
1929
|
-
segment.text(buttonData),
|
|
1930
|
-
meta
|
|
1931
|
-
],
|
|
3712
|
+
elements: [segment.text(buttonData)],
|
|
1932
3713
|
eventId: ev.id,
|
|
1933
|
-
messageId:
|
|
3714
|
+
messageId: interactionId,
|
|
1934
3715
|
messageSeq: 0,
|
|
1935
|
-
rawEvent
|
|
3716
|
+
rawEvent,
|
|
1936
3717
|
time,
|
|
1937
3718
|
contact,
|
|
1938
3719
|
sender,
|
|
@@ -1942,19 +3723,15 @@ const onInteraction = (client, ev) => {
|
|
|
1942
3723
|
}
|
|
1943
3724
|
if (ev.d.chat_type === 2 && ev.d.user_openid) {
|
|
1944
3725
|
const userId = ev.d.user_openid;
|
|
1945
|
-
const contact = karin.contactFriend(userId);
|
|
1946
|
-
const sender = karin.friendSender(userId, "");
|
|
3726
|
+
const contact = karin$1.contactFriend(userId);
|
|
3727
|
+
const sender = karin$1.friendSender(userId, "");
|
|
1947
3728
|
const e = createFriendMessage({
|
|
1948
3729
|
bot: client,
|
|
1949
|
-
elements: [
|
|
1950
|
-
segment.at(selfId),
|
|
1951
|
-
segment.text(buttonData),
|
|
1952
|
-
meta
|
|
1953
|
-
],
|
|
3730
|
+
elements: [segment.text(buttonData)],
|
|
1954
3731
|
eventId: ev.id,
|
|
1955
|
-
messageId:
|
|
3732
|
+
messageId: interactionId,
|
|
1956
3733
|
messageSeq: 0,
|
|
1957
|
-
rawEvent
|
|
3734
|
+
rawEvent,
|
|
1958
3735
|
time,
|
|
1959
3736
|
contact,
|
|
1960
3737
|
sender,
|
|
@@ -1964,15 +3741,15 @@ const onInteraction = (client, ev) => {
|
|
|
1964
3741
|
}
|
|
1965
3742
|
if (ev.d.chat_type === 0 && ev.d.guild_id && ev.d.channel_id) {
|
|
1966
3743
|
const userId = ev.d.data?.resolved?.user_id || "";
|
|
1967
|
-
const contact = karin.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
1968
|
-
const sender = karin.groupSender(userId, "unknown");
|
|
3744
|
+
const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
|
|
3745
|
+
const sender = karin$1.groupSender(userId, "unknown");
|
|
1969
3746
|
const e = createGuildMessage({
|
|
1970
3747
|
bot: client,
|
|
1971
|
-
elements: [segment.text(buttonData)
|
|
3748
|
+
elements: [segment.text(buttonData)],
|
|
1972
3749
|
eventId: ev.id,
|
|
1973
|
-
messageId:
|
|
3750
|
+
messageId: interactionId,
|
|
1974
3751
|
messageSeq: 0,
|
|
1975
|
-
rawEvent
|
|
3752
|
+
rawEvent,
|
|
1976
3753
|
time,
|
|
1977
3754
|
contact,
|
|
1978
3755
|
sender,
|
|
@@ -1997,6 +3774,8 @@ const eventLabel = {
|
|
|
1997
3774
|
["DIRECT_MESSAGE_CREATE"]: "频道私信",
|
|
1998
3775
|
["GROUP_ADD_ROBOT"]: "机器人入群",
|
|
1999
3776
|
["GROUP_DEL_ROBOT"]: "机器人退群",
|
|
3777
|
+
["GROUP_MEMBER_ADD"]: "群成员加入",
|
|
3778
|
+
["GROUP_MEMBER_REMOVE"]: "群成员退出",
|
|
2000
3779
|
["GROUP_MSG_RECEIVE"]: "群聊开启通知",
|
|
2001
3780
|
["GROUP_MSG_REJECT"]: "群聊关闭通知",
|
|
2002
3781
|
["FRIEND_ADD"]: "添加好友",
|
|
@@ -2039,6 +3818,12 @@ const dispatch$1 = (client, ev) => {
|
|
|
2039
3818
|
case "GROUP_DEL_ROBOT":
|
|
2040
3819
|
onGroupDelRobot(client, ev);
|
|
2041
3820
|
return;
|
|
3821
|
+
case "GROUP_MEMBER_ADD":
|
|
3822
|
+
onGroupMemberAdd(client, ev);
|
|
3823
|
+
return;
|
|
3824
|
+
case "GROUP_MEMBER_REMOVE":
|
|
3825
|
+
onGroupMemberRemove(client, ev);
|
|
3826
|
+
return;
|
|
2042
3827
|
case "GROUP_MSG_RECEIVE":
|
|
2043
3828
|
onGroupMsgReceive(client, ev);
|
|
2044
3829
|
return;
|
|
@@ -2062,7 +3847,7 @@ const dispatch$1 = (client, ev) => {
|
|
|
2062
3847
|
return;
|
|
2063
3848
|
case "MESSAGE_AUDIT_PASS": return client.logger("debug", `消息审核通过: audit_id=${ev.d.audit_id}`);
|
|
2064
3849
|
case "MESSAGE_AUDIT_REJECT": return client.logger("debug", `消息审核不通过: audit_id=${ev.d.audit_id}`);
|
|
2065
|
-
default: logger
|
|
3850
|
+
default: client.logger("debug", `[QQ Official Bot] 未处理事件: ${ev.t}`);
|
|
2066
3851
|
}
|
|
2067
3852
|
};
|
|
2068
3853
|
|
|
@@ -2105,6 +3890,11 @@ const wsErrorMap = {
|
|
|
2105
3890
|
canResume: false,
|
|
2106
3891
|
canIdentify: false
|
|
2107
3892
|
},
|
|
3893
|
+
4004: {
|
|
3894
|
+
message: "鉴权失败或 access_token 无效",
|
|
3895
|
+
canResume: false,
|
|
3896
|
+
canIdentify: true
|
|
3897
|
+
},
|
|
2108
3898
|
4007: {
|
|
2109
3899
|
message: "seq 错误",
|
|
2110
3900
|
canResume: false,
|
|
@@ -2215,6 +4005,8 @@ var WSClient = class {
|
|
|
2215
4005
|
seq = 0;
|
|
2216
4006
|
closed = false;
|
|
2217
4007
|
closedReason = "manual";
|
|
4008
|
+
openedAt = 0;
|
|
4009
|
+
lastAuthAction;
|
|
2218
4010
|
constructor(opts) {
|
|
2219
4011
|
this.opts = opts;
|
|
2220
4012
|
if (opts.resume) {
|
|
@@ -2230,21 +4022,30 @@ var WSClient = class {
|
|
|
2230
4022
|
};
|
|
2231
4023
|
}
|
|
2232
4024
|
start() {
|
|
2233
|
-
const ws = new WebSocket(this.opts.gatewayUrl);
|
|
4025
|
+
const ws = new WebSocket(this.opts.gatewayUrl, { headers: { "User-Agent": this.opts.userAgent } });
|
|
2234
4026
|
this.socket = ws;
|
|
2235
4027
|
ws.on("open", () => {
|
|
4028
|
+
this.openedAt = Date.now();
|
|
2236
4029
|
log("debug", `${this.opts.appId}: WebSocket opened (${this.opts.gatewayUrl})`);
|
|
2237
4030
|
});
|
|
2238
4031
|
ws.on("close", (code, reason) => {
|
|
2239
|
-
const
|
|
4032
|
+
const remoteReason = reason?.toString() || "";
|
|
4033
|
+
const closeInfo = formatWSClose(code, remoteReason);
|
|
4034
|
+
const info = getWSErrorInfo(code);
|
|
2240
4035
|
log("error", `${this.opts.appId}: WebSocket 关闭 | ${closeInfo}`);
|
|
2241
4036
|
if (this.closed) return;
|
|
2242
|
-
this.close("closed"
|
|
4037
|
+
this.close("closed", {
|
|
4038
|
+
code,
|
|
4039
|
+
remoteReason: remoteReason || void 0,
|
|
4040
|
+
durationMs: this.durationMs(),
|
|
4041
|
+
canResume: info?.canResume,
|
|
4042
|
+
canIdentify: info?.canIdentify
|
|
4043
|
+
});
|
|
2243
4044
|
});
|
|
2244
4045
|
ws.on("error", (err) => {
|
|
2245
4046
|
log("error", `${this.opts.appId}: WebSocket error:`, err);
|
|
2246
4047
|
if (this.closed) return;
|
|
2247
|
-
this.close("error");
|
|
4048
|
+
this.close("error", { durationMs: this.durationMs() });
|
|
2248
4049
|
});
|
|
2249
4050
|
ws.on("unexpected-response", (_req, res) => {
|
|
2250
4051
|
log("error", `${this.opts.appId}: WebSocket unexpected-response status=${res.statusCode} ${res.statusMessage}`);
|
|
@@ -2257,7 +4058,7 @@ var WSClient = class {
|
|
|
2257
4058
|
stop() {
|
|
2258
4059
|
this.close("manual");
|
|
2259
4060
|
}
|
|
2260
|
-
close(reason) {
|
|
4061
|
+
close(reason, event = {}) {
|
|
2261
4062
|
if (this.closed) return;
|
|
2262
4063
|
this.closed = true;
|
|
2263
4064
|
this.closedReason = reason;
|
|
@@ -2269,7 +4070,13 @@ var WSClient = class {
|
|
|
2269
4070
|
this.socket?.removeAllListeners();
|
|
2270
4071
|
this.socket?.close();
|
|
2271
4072
|
} catch {}
|
|
2272
|
-
this.opts.onClose(
|
|
4073
|
+
this.opts.onClose({
|
|
4074
|
+
reason,
|
|
4075
|
+
...event
|
|
4076
|
+
});
|
|
4077
|
+
}
|
|
4078
|
+
durationMs() {
|
|
4079
|
+
return this.openedAt ? Date.now() - this.openedAt : void 0;
|
|
2273
4080
|
}
|
|
2274
4081
|
send(payload) {
|
|
2275
4082
|
try {
|
|
@@ -2289,6 +4096,7 @@ var WSClient = class {
|
|
|
2289
4096
|
}, this.heartbeatInterval);
|
|
2290
4097
|
}
|
|
2291
4098
|
identify() {
|
|
4099
|
+
this.lastAuthAction = "identify";
|
|
2292
4100
|
this.send({
|
|
2293
4101
|
op: 2,
|
|
2294
4102
|
d: {
|
|
@@ -2305,6 +4113,7 @@ var WSClient = class {
|
|
|
2305
4113
|
log("debug", `${this.opts.appId}: Identify sent, intents=${this.opts.intents}`);
|
|
2306
4114
|
}
|
|
2307
4115
|
resume() {
|
|
4116
|
+
this.lastAuthAction = "resume";
|
|
2308
4117
|
this.send({
|
|
2309
4118
|
op: 6,
|
|
2310
4119
|
d: {
|
|
@@ -2339,6 +4148,7 @@ var WSClient = class {
|
|
|
2339
4148
|
case 0:
|
|
2340
4149
|
if (typeof msg.s === "number") this.seq = msg.s;
|
|
2341
4150
|
if (msg.t === "READY") {
|
|
4151
|
+
this.lastAuthAction = void 0;
|
|
2342
4152
|
this.sessionId = msg.d?.session_id || "";
|
|
2343
4153
|
log("debug", `${this.opts.appId}: READY received, session_id=${this.sessionId}`);
|
|
2344
4154
|
}
|
|
@@ -2352,17 +4162,31 @@ var WSClient = class {
|
|
|
2352
4162
|
log("debug", `${this.opts.appId}: server asked reconnect (op:7)`);
|
|
2353
4163
|
this.close("reconnect");
|
|
2354
4164
|
break;
|
|
2355
|
-
case 9:
|
|
2356
|
-
|
|
2357
|
-
|
|
4165
|
+
case 9: {
|
|
4166
|
+
const canResume = msg.d === true;
|
|
4167
|
+
if (this.lastAuthAction === "identify") {
|
|
4168
|
+
log("error", `${this.opts.appId}: identify rejected (op:9), will try fallback intents, canResume=${canResume}`);
|
|
4169
|
+
this.close("auth_fail", {
|
|
4170
|
+
durationMs: this.durationMs(),
|
|
4171
|
+
invalidSessionCanResume: canResume
|
|
4172
|
+
});
|
|
4173
|
+
} else if (canResume && this.sessionId) {
|
|
4174
|
+
log("debug", `${this.opts.appId}: invalid session, server allows resume`);
|
|
4175
|
+
this.close("reconnect", {
|
|
4176
|
+
durationMs: this.durationMs(),
|
|
4177
|
+
invalidSessionCanResume: true
|
|
4178
|
+
});
|
|
4179
|
+
} else {
|
|
4180
|
+
log("debug", `${this.opts.appId}: invalid session, will retry with fresh identify, canResume=${canResume}`);
|
|
2358
4181
|
this.sessionId = "";
|
|
2359
4182
|
this.seq = 0;
|
|
2360
|
-
this.close("session_lost"
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
4183
|
+
this.close("session_lost", {
|
|
4184
|
+
durationMs: this.durationMs(),
|
|
4185
|
+
invalidSessionCanResume: canResume
|
|
4186
|
+
});
|
|
2364
4187
|
}
|
|
2365
4188
|
break;
|
|
4189
|
+
}
|
|
2366
4190
|
default: log("debug", `${this.opts.appId}: unhandled opcode ${msg.op}`);
|
|
2367
4191
|
}
|
|
2368
4192
|
}
|
|
@@ -2378,7 +4202,9 @@ const intentBitMap = {
|
|
|
2378
4202
|
[2]: "GUILD_MEMBERS (频道成员变更)",
|
|
2379
4203
|
[512]: "GUILD_MESSAGES (频道消息-私域)",
|
|
2380
4204
|
[4096]: "DIRECT_MESSAGE (频道私信)",
|
|
4205
|
+
[1 << 24]: "群成员进退群事件",
|
|
2381
4206
|
[1 << 25]: "GROUP_AND_C2C_EVENT (群聊/单聊消息)",
|
|
4207
|
+
[1 << 26]: "INTERACTION (按钮交互回调)",
|
|
2382
4208
|
[1 << 30]: "PUBLIC_GUILD_MESSAGES (频道消息-公域)"
|
|
2383
4209
|
};
|
|
2384
4210
|
/** 优先级从高到低,回退时从最前面剥离 */
|
|
@@ -2387,6 +4213,8 @@ const priorityIntents = [
|
|
|
2387
4213
|
512,
|
|
2388
4214
|
1 << 30,
|
|
2389
4215
|
4096,
|
|
4216
|
+
1 << 24,
|
|
4217
|
+
1 << 26,
|
|
2390
4218
|
1 << 25,
|
|
2391
4219
|
1
|
|
2392
4220
|
];
|
|
@@ -2416,6 +4244,10 @@ const formatIntentNames = (intents, prefix = "") => {
|
|
|
2416
4244
|
const conns = /* @__PURE__ */ new Map();
|
|
2417
4245
|
/** /gateway 接口超时(毫秒) */
|
|
2418
4246
|
const GATEWAY_FETCH_TIMEOUT_MS = 5e3;
|
|
4247
|
+
const RATE_LIMIT_DELAY_MS = 6e4;
|
|
4248
|
+
const QUICK_DISCONNECT_THRESHOLD_MS = 5e3;
|
|
4249
|
+
const QUICK_DISCONNECT_LIMIT = 3;
|
|
4250
|
+
const QUICK_DISCONNECT_DELAY_MS = 6e4;
|
|
2419
4251
|
/**
|
|
2420
4252
|
* 从 prodApi / sandboxApi 推导兜底的 WSS 地址
|
|
2421
4253
|
* 官方实际长期返回的也是 wss://{host}/websocket/,不依赖 /gateway 也能直连
|
|
@@ -2436,7 +4268,10 @@ const fetchGateway = async (cfg) => {
|
|
|
2436
4268
|
}
|
|
2437
4269
|
try {
|
|
2438
4270
|
const { data } = await axios.get(`${apiUrl}/gateway`, {
|
|
2439
|
-
headers: {
|
|
4271
|
+
headers: {
|
|
4272
|
+
Authorization: `QQBot ${accessToken}`,
|
|
4273
|
+
"User-Agent": getUserAgent()
|
|
4274
|
+
},
|
|
2440
4275
|
timeout: GATEWAY_FETCH_TIMEOUT_MS
|
|
2441
4276
|
});
|
|
2442
4277
|
if (data?.url) return data.url;
|
|
@@ -2493,6 +4328,7 @@ const start = async (cfg) => {
|
|
|
2493
4328
|
cfg,
|
|
2494
4329
|
intents,
|
|
2495
4330
|
intentHistory: [intents],
|
|
4331
|
+
quickDisconnects: 0,
|
|
2496
4332
|
aborted: false
|
|
2497
4333
|
});
|
|
2498
4334
|
await connect(cfg, intents, 0);
|
|
@@ -2516,12 +4352,15 @@ const schedule = (appId, delay, fn) => {
|
|
|
2516
4352
|
const connect = async (cfg, intents, attempt, resume) => {
|
|
2517
4353
|
let conn = conns.get(cfg.appId);
|
|
2518
4354
|
if (!conn || conn.aborted) return;
|
|
2519
|
-
|
|
4355
|
+
let gatewayUrl = conn.gatewayUrl;
|
|
4356
|
+
if (!gatewayUrl) gatewayUrl = await fetchGateway(cfg);
|
|
2520
4357
|
conn = conns.get(cfg.appId);
|
|
2521
4358
|
if (!conn || conn.aborted) return;
|
|
4359
|
+
conn.gatewayUrl = gatewayUrl;
|
|
2522
4360
|
const client = new WSClient({
|
|
2523
4361
|
appId: cfg.appId,
|
|
2524
4362
|
gatewayUrl,
|
|
4363
|
+
userAgent: getUserAgent(),
|
|
2525
4364
|
getAccessToken: () => getBotAccessToken(cfg.appId) || "",
|
|
2526
4365
|
intents,
|
|
2527
4366
|
resume,
|
|
@@ -2529,7 +4368,7 @@ const connect = async (cfg, intents, attempt, resume) => {
|
|
|
2529
4368
|
if (ev.t === "READY") summarize(cfg.appId, intents, true);
|
|
2530
4369
|
dispatch(cfg.appId, ev);
|
|
2531
4370
|
},
|
|
2532
|
-
onClose: (
|
|
4371
|
+
onClose: (event) => onClose(cfg, intents, attempt, event, client)
|
|
2533
4372
|
});
|
|
2534
4373
|
conn.client = client;
|
|
2535
4374
|
conn.cfg = cfg;
|
|
@@ -2540,16 +4379,19 @@ const connect = async (cfg, intents, attempt, resume) => {
|
|
|
2540
4379
|
/**
|
|
2541
4380
|
* 关闭回调处理
|
|
2542
4381
|
*/
|
|
2543
|
-
const onClose = (cfg, intents, attempt,
|
|
2544
|
-
if (reason === "manual") {
|
|
4382
|
+
const onClose = (cfg, intents, attempt, event, client) => {
|
|
4383
|
+
if (event.reason === "manual") {
|
|
2545
4384
|
log("debug", `${cfg.appId}: WebSocket 已主动关闭`);
|
|
2546
4385
|
return;
|
|
2547
4386
|
}
|
|
2548
4387
|
const conn = conns.get(cfg.appId);
|
|
2549
4388
|
if (!conn || conn.aborted) return;
|
|
2550
|
-
|
|
4389
|
+
const code = event.code;
|
|
4390
|
+
const closeLabel = formatCloseLabel(event);
|
|
4391
|
+
if (event.reason === "auth_fail" || code === 4013 || code === 4014) {
|
|
2551
4392
|
const fallback = computeFallback(intents);
|
|
2552
4393
|
if (fallback && fallback !== intents) {
|
|
4394
|
+
log("warn", `${cfg.appId}: Identify 被拒,尝试降级 intents ${intents} -> ${fallback}`);
|
|
2553
4395
|
schedule(cfg.appId, 1e3, () => connect(cfg, fallback, 0));
|
|
2554
4396
|
return;
|
|
2555
4397
|
}
|
|
@@ -2559,11 +4401,64 @@ const onClose = (cfg, intents, attempt, reason, client) => {
|
|
|
2559
4401
|
conns.delete(cfg.appId);
|
|
2560
4402
|
return;
|
|
2561
4403
|
}
|
|
4404
|
+
if (code === 4914 || code === 4915) {
|
|
4405
|
+
summarize(cfg.appId, intents, false);
|
|
4406
|
+
log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),机器人状态不允许连接,已停止自动重连`);
|
|
4407
|
+
conn.aborted = true;
|
|
4408
|
+
conns.delete(cfg.appId);
|
|
4409
|
+
return;
|
|
4410
|
+
}
|
|
4411
|
+
if (event.canResume === false && event.canIdentify === false) {
|
|
4412
|
+
summarize(cfg.appId, intents, false);
|
|
4413
|
+
log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),该错误不建议重试,已停止自动重连`);
|
|
4414
|
+
conn.aborted = true;
|
|
4415
|
+
conns.delete(cfg.appId);
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
if (code === 4004) {
|
|
4419
|
+
log("warn", `${cfg.appId}: access_token 已失效,刷新 token 后重连...`);
|
|
4420
|
+
schedule(cfg.appId, 1e3, () => {
|
|
4421
|
+
getAccessToken(cfg.tokenApi, cfg.appId, cfg.secret).then(() => connect(cfg, intents, 0)).catch(() => {
|
|
4422
|
+
log("error", `${cfg.appId}: access_token 刷新失败,${RATE_LIMIT_DELAY_MS}ms 后重试连接...`);
|
|
4423
|
+
schedule(cfg.appId, RATE_LIMIT_DELAY_MS, () => connect(cfg, intents, attempt + 1));
|
|
4424
|
+
});
|
|
4425
|
+
});
|
|
4426
|
+
return;
|
|
4427
|
+
}
|
|
2562
4428
|
const snap = client.snapshot();
|
|
2563
|
-
const resume = reason === "session_lost" ? void 0 : snap.sessionId ? snap : void 0;
|
|
2564
|
-
const delay =
|
|
2565
|
-
|
|
2566
|
-
|
|
4429
|
+
const resume = event.reason === "session_lost" || shouldFreshIdentify(code) ? void 0 : snap.sessionId ? snap : void 0;
|
|
4430
|
+
const delay = reconnectDelay(conn, attempt, event, code === 4008 ? RATE_LIMIT_DELAY_MS : void 0);
|
|
4431
|
+
const nextAttempt = shouldResetAttempt(event) ? 0 : attempt + 1;
|
|
4432
|
+
log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),${delay}ms 后${resume ? "Resume" : "Identify"}重连...`);
|
|
4433
|
+
schedule(cfg.appId, delay, () => connect(cfg, intents, nextAttempt, resume));
|
|
4434
|
+
};
|
|
4435
|
+
const shouldFreshIdentify = (code) => {
|
|
4436
|
+
if (code === 4006 || code === 4007 || code === 4009) return true;
|
|
4437
|
+
return typeof code === "number" && code >= 4900 && code <= 4913;
|
|
4438
|
+
};
|
|
4439
|
+
const shouldResetAttempt = (event) => {
|
|
4440
|
+
return typeof event.durationMs === "number" && event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS;
|
|
4441
|
+
};
|
|
4442
|
+
const reconnectDelay = (conn, attempt, event, forcedDelay) => {
|
|
4443
|
+
const stable = shouldResetAttempt(event);
|
|
4444
|
+
let delay = forcedDelay ?? backoff(stable ? 0 : attempt);
|
|
4445
|
+
if (typeof event.durationMs !== "number") return delay;
|
|
4446
|
+
if (event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS) {
|
|
4447
|
+
conn.quickDisconnects = 0;
|
|
4448
|
+
return delay;
|
|
4449
|
+
}
|
|
4450
|
+
conn.quickDisconnects += 1;
|
|
4451
|
+
if (conn.quickDisconnects >= QUICK_DISCONNECT_LIMIT) {
|
|
4452
|
+
delay = Math.max(delay, QUICK_DISCONNECT_DELAY_MS);
|
|
4453
|
+
log("warn", `${conn.cfg.appId}: WebSocket 连续快速断开 ${conn.quickDisconnects} 次,延长重连间隔到 ${delay}ms`);
|
|
4454
|
+
}
|
|
4455
|
+
return delay;
|
|
4456
|
+
};
|
|
4457
|
+
const formatCloseLabel = (event) => {
|
|
4458
|
+
const parts = [event.reason];
|
|
4459
|
+
if (typeof event.code === "number") parts.push(String(event.code));
|
|
4460
|
+
if (event.remoteReason) parts.push(event.remoteReason);
|
|
4461
|
+
return parts.join(" / ");
|
|
2567
4462
|
};
|
|
2568
4463
|
/**
|
|
2569
4464
|
* 停止连接(同步:标记 abort + 清 pendingTimer + 关 client)
|
|
@@ -2609,7 +4504,8 @@ const hashConfig = (bot) => JSON.stringify({
|
|
|
2609
4504
|
sandboxApi: bot.sandboxApi,
|
|
2610
4505
|
sandbox: bot.sandbox,
|
|
2611
4506
|
event: bot.event,
|
|
2612
|
-
keyboard: bot.keyboard
|
|
4507
|
+
keyboard: bot.keyboard,
|
|
4508
|
+
messageCache: bot.messageCache
|
|
2613
4509
|
});
|
|
2614
4510
|
/**
|
|
2615
4511
|
* 创建单个 Bot 实例
|
|
@@ -2649,9 +4545,11 @@ const createBot = async (bot) => {
|
|
|
2649
4545
|
client.account.subId.qq = qq;
|
|
2650
4546
|
client.account.subId.appid = appId;
|
|
2651
4547
|
client.account.subId.union_openid = me.union_openid;
|
|
2652
|
-
bus.on(appId, (ev) =>
|
|
4548
|
+
bus.on(appId, (ev) => {
|
|
4549
|
+
dispatch$1(client, ev);
|
|
4550
|
+
});
|
|
2653
4551
|
client.adapter.address = baseUrl;
|
|
2654
|
-
client.adapter.secret =
|
|
4552
|
+
client.adapter.secret = "*********";
|
|
2655
4553
|
client.adapter.version = pkg().version;
|
|
2656
4554
|
if (bot.event.type === 2) {
|
|
2657
4555
|
client.adapter.index = registerBot("webSocketClient", client);
|