@karinjs/adapter-qqbot 3.1.0 → 3.2.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 +4 -2
- package/dist/apps/qqbot-file-to-url.d.ts +4 -0
- package/dist/apps/qqbot-file-to-url.js +94 -0
- package/dist/common-OHGAoohl.js +42 -0
- package/dist/index.d.ts +46 -1
- package/dist/index.js +558 -215
- package/dist/utils-C0oZi6PR.js +5 -0
- package/dist/web.config.js +1 -1
- package/package.json +1 -1
- package/dist/utils-QpIUJhxK.js +0 -32
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-D1_LqrcJ.js";
|
|
2
2
|
import { t as log } from "./logger-sza6kuy3.js";
|
|
3
|
-
import { i as
|
|
3
|
+
import { a as DATA_URL_BASE64_RE, i as BASE64_PREFIX_RE, n as getImageSize, o as HTTP_URL_RE, r as random, t as fakeEvent } from "./common-OHGAoohl.js";
|
|
4
|
+
import "./utils-C0oZi6PR.js";
|
|
4
5
|
import { t as getMessageStore } from "./message-CVJVFAGR.js";
|
|
5
|
-
import karin, { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupApplyRequest, createGroupInviteRequest, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
|
|
6
|
-
import { URL } from "url";
|
|
6
|
+
import karin, { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupApplyRequest, createGroupInviteRequest, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, getMimeType, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
|
|
7
|
+
import { URL as URL$1 } from "url";
|
|
7
8
|
import lodash from "node-karin/lodash";
|
|
8
9
|
import { createReadStream } from "node:fs";
|
|
9
10
|
import axios from "node-karin/axios";
|
|
@@ -18,6 +19,97 @@ import { WebSocket } from "node-karin/ws";
|
|
|
18
19
|
import express from "node-karin/express";
|
|
19
20
|
import nacl from "tweetnacl";
|
|
20
21
|
|
|
22
|
+
//#region src/core/constants/api.ts
|
|
23
|
+
/** QQBot 统一 API 地址 */
|
|
24
|
+
const QQAPIBASEURL = "https://api.bot.qq.com";
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/core/constants/media.ts
|
|
28
|
+
/** QQ 上传接口的 file_type 取值。 */
|
|
29
|
+
const FILE_TYPE = {
|
|
30
|
+
image: 1,
|
|
31
|
+
video: 2,
|
|
32
|
+
record: 3,
|
|
33
|
+
file: 4
|
|
34
|
+
};
|
|
35
|
+
/** 各类资源的 QQ 上传上限。 */
|
|
36
|
+
const MAX_UPLOAD_SIZE = {
|
|
37
|
+
image: 30 * 1024 * 1024,
|
|
38
|
+
video: 100 * 1024 * 1024,
|
|
39
|
+
record: 20 * 1024 * 1024,
|
|
40
|
+
file: 100 * 1024 * 1024
|
|
41
|
+
};
|
|
42
|
+
/** 上传错误中展示的资源类型名称。 */
|
|
43
|
+
const MEDIA_TYPE_LABEL = {
|
|
44
|
+
image: "图片",
|
|
45
|
+
video: "视频",
|
|
46
|
+
record: "语音",
|
|
47
|
+
file: "文件"
|
|
48
|
+
};
|
|
49
|
+
/** fileToUrl / 分片上传没有业务文件名时使用的默认值。 */
|
|
50
|
+
const DEFAULT_FILENAME = {
|
|
51
|
+
image: "image.jpg",
|
|
52
|
+
video: "video.mp4",
|
|
53
|
+
record: "record.mp3",
|
|
54
|
+
file: "file.bin"
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* 无法从文件名推断类型时,各富媒体类型使用的兜底 MIME。
|
|
58
|
+
*
|
|
59
|
+
* `file` 不在表内:QQ 对 `file_type=4` 不返回 `raw_url`,拿不到直链。
|
|
60
|
+
*/
|
|
61
|
+
const DEFAULT_MIME = {
|
|
62
|
+
image: "image/jpeg",
|
|
63
|
+
video: "video/mp4",
|
|
64
|
+
record: "audio/mpeg"
|
|
65
|
+
};
|
|
66
|
+
/** fallback 上传超过该大小时走 QQ 分片上传。 */
|
|
67
|
+
const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
|
|
68
|
+
/** COS 用于覆盖下载响应类型的 query 参数名。 */
|
|
69
|
+
const RESPONSE_TYPE_PARAM = "response-content-type";
|
|
70
|
+
/** QQ upload_prepare 需要的前 10,002,432 字节 MD5。 */
|
|
71
|
+
const MD5_10M_SIZE = 10002432;
|
|
72
|
+
/** 富媒体上传比普通接口慢,尤其本地视频会走 file_data。 */
|
|
73
|
+
const MEDIA_UPLOAD_TIMEOUT = 12e4;
|
|
74
|
+
/** URL 回源下载兜底超时。 */
|
|
75
|
+
const MEDIA_DOWNLOAD_TIMEOUT = 12e4;
|
|
76
|
+
/** 每个分片 PUT 到 QQ 临时地址的超时时间。 */
|
|
77
|
+
const PART_UPLOAD_TIMEOUT = 3e5;
|
|
78
|
+
/** upload_part_finish 普通失败重试基础间隔。 */
|
|
79
|
+
const PART_FINISH_BASE_DELAY = 1e3;
|
|
80
|
+
/** upload_part_finish 命中这些业务码时,需要按服务端给出的窗口持续重试。 */
|
|
81
|
+
const PART_FINISH_RETRYABLE_CODES = /* @__PURE__ */ new Set([40093001]);
|
|
82
|
+
/** 服务端未返回 retry_timeout 时,持续重试的默认时间。 */
|
|
83
|
+
const PART_FINISH_DEFAULT_RETRY_TIMEOUT = 120 * 1e3;
|
|
84
|
+
/** 持续重试窗口上限,避免服务端返回异常值导致任务长时间占用。 */
|
|
85
|
+
const PART_FINISH_MAX_RETRY_TIMEOUT = 600 * 1e3;
|
|
86
|
+
/** 持续重试时的固定间隔。 */
|
|
87
|
+
const PART_FINISH_RETRY_INTERVAL = 1e3;
|
|
88
|
+
/** 分片完成接口失败重试基础间隔。 */
|
|
89
|
+
const COMPLETE_UPLOAD_BASE_DELAY = 2e3;
|
|
90
|
+
/** upload_prepare 返回该业务码时表示大文件上传额度受限。 */
|
|
91
|
+
const UPLOAD_PREPARE_DAILY_LIMIT_CODE = 40093002;
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/core/constants/message.ts
|
|
95
|
+
/** 群聊 event_id 白名单 */
|
|
96
|
+
const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
97
|
+
"INTERACTION_CREATE",
|
|
98
|
+
"GROUP_ADD_ROBOT",
|
|
99
|
+
"GROUP_MSG_RECEIVE"
|
|
100
|
+
]);
|
|
101
|
+
/** 单聊 event_id 白名单 */
|
|
102
|
+
const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
103
|
+
"INTERACTION_CREATE",
|
|
104
|
+
"C2C_MSG_RECEIVE",
|
|
105
|
+
"FRIEND_ADD"
|
|
106
|
+
]);
|
|
107
|
+
/** 平台限制的最大禁言时长:30 天 */
|
|
108
|
+
const MAX_GROUP_MUTE_SECONDS = 720 * 60 * 60;
|
|
109
|
+
/** QQ 客户端回调按钮需要快速 ACK,超时必须小于平台等待窗口。 */
|
|
110
|
+
const INTERACTION_ACK_TIMEOUT = 2800;
|
|
111
|
+
|
|
112
|
+
//#endregion
|
|
21
113
|
//#region src/core/api/error.ts
|
|
22
114
|
/**
|
|
23
115
|
* OpenAPI 错误码映射表
|
|
@@ -628,8 +720,6 @@ var RequestApi = class extends Http {
|
|
|
628
720
|
|
|
629
721
|
//#endregion
|
|
630
722
|
//#region src/core/api/messages.ts
|
|
631
|
-
/** 输入中状态默认展示时长(秒),平台 typing 窗口约 60s */
|
|
632
|
-
const INPUT_NOTIFY_DEFAULT_SECOND = 60;
|
|
633
723
|
/**
|
|
634
724
|
* 消息发送 + 撤回
|
|
635
725
|
*/
|
|
@@ -648,7 +738,7 @@ var MessagesApi = class extends Http {
|
|
|
648
738
|
* @param inputSecond 展示时长(秒),默认 60
|
|
649
739
|
* @param passive 被动消息参数(msg_id / event_id / msg_seq),可选
|
|
650
740
|
*/
|
|
651
|
-
sendFriendInputNotify(openid, inputSecond =
|
|
741
|
+
sendFriendInputNotify(openid, inputSecond = 60, passive) {
|
|
652
742
|
return this.post(`/v2/users/${openid}/messages`, {
|
|
653
743
|
msg_type: 6,
|
|
654
744
|
input_notify: {
|
|
@@ -758,73 +848,50 @@ const setCachedUpload = async (scene, peer, type, contentHash, response) => {
|
|
|
758
848
|
|
|
759
849
|
//#endregion
|
|
760
850
|
//#region src/core/api/media.ts
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
851
|
+
/**
|
|
852
|
+
* 推断直链应当声明的 MIME。
|
|
853
|
+
*
|
|
854
|
+
* 优先按文件名判断,这样 png / gif 不会被统一说成 jpeg;文件名没有扩展名或
|
|
855
|
+
* 扩展名不认识时,退回该富媒体类型的兜底值。
|
|
856
|
+
*
|
|
857
|
+
* @param type 富媒体类型。
|
|
858
|
+
* @param fileName 可选文件名。
|
|
859
|
+
* @returns MIME 字符串;该类型拿不到直链时返回 undefined。
|
|
860
|
+
*/
|
|
861
|
+
const resolveDirectLinkMime = (type, fileName) => {
|
|
862
|
+
const fallback = DEFAULT_MIME[type];
|
|
863
|
+
if (!fallback) return void 0;
|
|
864
|
+
if (!fileName) return fallback;
|
|
865
|
+
const guessed = getMimeType(fileName);
|
|
866
|
+
/** karin 认不出的扩展名会返回 octet-stream,此时用类型兜底更准确。 */
|
|
867
|
+
if (!guessed || guessed === "application/octet-stream") return fallback;
|
|
868
|
+
/** 扩展名与富媒体类型不是同一族时同样以类型为准,避免声明成错误的族。 */
|
|
869
|
+
const family = fallback.split("/")[0];
|
|
870
|
+
return guessed.startsWith(`${family}/`) ? guessed : fallback;
|
|
780
871
|
};
|
|
781
|
-
/**
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
872
|
+
/**
|
|
873
|
+
* 把 QQ 返回的下载直链改造成可直接嵌入消息的地址。
|
|
874
|
+
*
|
|
875
|
+
* 直链本身不声明 Content-Type,客户端可能因此拒绝按图片/视频渲染,所以要追加
|
|
876
|
+
* COS 的响应类型参数。这里用 URL 对象改写 query,避免手工拼接破坏预签名参数。
|
|
877
|
+
*
|
|
878
|
+
* @param rawUrl QQ 分片合并返回的下载直链。
|
|
879
|
+
* @param type 富媒体类型。
|
|
880
|
+
* @param fileName 可选文件名,用于推断更精确的 MIME。
|
|
881
|
+
* @returns 已声明响应类型的直链;类型不支持或直链无法解析时原样返回。
|
|
882
|
+
*/
|
|
883
|
+
const buildDirectLink = (rawUrl, type, fileName) => {
|
|
884
|
+
const mime = resolveDirectLinkMime(type, fileName);
|
|
885
|
+
if (!mime) return rawUrl;
|
|
886
|
+
try {
|
|
887
|
+
const url = new URL(rawUrl);
|
|
888
|
+
url.searchParams.set(RESPONSE_TYPE_PARAM, mime);
|
|
889
|
+
return url.toString();
|
|
890
|
+
} catch {
|
|
891
|
+
/** QQ 返回了非法 URL 时不做改写,交给调用方按原样使用。 */
|
|
892
|
+
return rawUrl;
|
|
893
|
+
}
|
|
791
894
|
};
|
|
792
|
-
/** 富媒体上传比普通接口慢,尤其本地视频会走 file_data。 */
|
|
793
|
-
const MEDIA_UPLOAD_TIMEOUT = 12e4;
|
|
794
|
-
/** URL 回源下载兜底超时。 */
|
|
795
|
-
const MEDIA_DOWNLOAD_TIMEOUT = 12e4;
|
|
796
|
-
/** 每个分片 PUT 到 QQ 临时地址的超时时间。 */
|
|
797
|
-
const PART_UPLOAD_TIMEOUT = 3e5;
|
|
798
|
-
/** 每个分片 PUT 失败后的重试次数。 */
|
|
799
|
-
const PART_UPLOAD_RETRIES = 2;
|
|
800
|
-
/** upload_part_finish 普通失败重试次数。 */
|
|
801
|
-
const PART_FINISH_RETRIES = 2;
|
|
802
|
-
/** upload_part_finish 普通失败重试基础间隔。 */
|
|
803
|
-
const PART_FINISH_BASE_DELAY = 1e3;
|
|
804
|
-
/** upload_part_finish 命中这些业务码时,需要按服务端给出的窗口持续重试。 */
|
|
805
|
-
const PART_FINISH_RETRYABLE_CODES = /* @__PURE__ */ new Set([40093001]);
|
|
806
|
-
/** 服务端未返回 retry_timeout 时,持续重试的默认时间。 */
|
|
807
|
-
const PART_FINISH_DEFAULT_RETRY_TIMEOUT = 120 * 1e3;
|
|
808
|
-
/** 持续重试窗口上限,避免服务端返回异常值导致任务长时间占用。 */
|
|
809
|
-
const PART_FINISH_MAX_RETRY_TIMEOUT = 600 * 1e3;
|
|
810
|
-
/** 持续重试时的固定间隔。 */
|
|
811
|
-
const PART_FINISH_RETRY_INTERVAL = 1e3;
|
|
812
|
-
/** 分片完成接口失败重试次数。 */
|
|
813
|
-
const COMPLETE_UPLOAD_RETRIES = 2;
|
|
814
|
-
/** 分片完成接口失败重试基础间隔。 */
|
|
815
|
-
const COMPLETE_UPLOAD_BASE_DELAY = 2e3;
|
|
816
|
-
/** upload_prepare 返回该业务码时表示大文件上传额度受限。 */
|
|
817
|
-
const UPLOAD_PREPARE_DAILY_LIMIT_CODE = 40093002;
|
|
818
|
-
/** QQ 没返回建议并发数时的默认并发。 */
|
|
819
|
-
const DEFAULT_PART_CONCURRENCY = 1;
|
|
820
|
-
/** 本地限制的最大分片并发,避免服务端返回过大值。 */
|
|
821
|
-
const MAX_PART_CONCURRENCY = 10;
|
|
822
|
-
/** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
|
|
823
|
-
const HTTP_URL_RE$2 = /^https?:\/\//i;
|
|
824
|
-
/** Karin 常见 base64 图片/媒体前缀。 */
|
|
825
|
-
const BASE64_PREFIX_RE = /^base64:\/\//i;
|
|
826
|
-
/** 标准 data URL 的 base64 前缀。 */
|
|
827
|
-
const DATA_URL_BASE64_RE = /^data:[^;,]+;base64,/i;
|
|
828
895
|
/**
|
|
829
896
|
* 判断字符串是否明显是本地文件路径。
|
|
830
897
|
* 不明显的字符串按裸 base64 兼容处理,避免破坏旧调用方式。
|
|
@@ -866,8 +933,8 @@ const formatBytes = (bytes) => {
|
|
|
866
933
|
*/
|
|
867
934
|
const normalizeUploadFileName = (type, name) => {
|
|
868
935
|
const value = name?.trim();
|
|
869
|
-
if (!value) return DEFAULT_FILENAME
|
|
870
|
-
return path.basename(value) || DEFAULT_FILENAME
|
|
936
|
+
if (!value) return DEFAULT_FILENAME[type];
|
|
937
|
+
return path.basename(value) || DEFAULT_FILENAME[type];
|
|
871
938
|
};
|
|
872
939
|
/**
|
|
873
940
|
* 检查 fallback 资源大小是否超过 QQ 对应类型上限。
|
|
@@ -916,7 +983,7 @@ const downloadRemoteAsBase64 = async (url) => {
|
|
|
916
983
|
*/
|
|
917
984
|
const buildUploadSource = async (source) => {
|
|
918
985
|
const value = source.trim();
|
|
919
|
-
if (HTTP_URL_RE
|
|
986
|
+
if (HTTP_URL_RE.test(value)) return { url: value };
|
|
920
987
|
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
|
|
921
988
|
if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
|
|
922
989
|
return { file_data: value };
|
|
@@ -932,7 +999,7 @@ const buildUploadSource = async (source) => {
|
|
|
932
999
|
const buildFallbackSource = async (type, source, fileName) => {
|
|
933
1000
|
const value = source.trim();
|
|
934
1001
|
const normalizedName = normalizeUploadFileName(type, fileName);
|
|
935
|
-
if (HTTP_URL_RE
|
|
1002
|
+
if (HTTP_URL_RE.test(value)) {
|
|
936
1003
|
const buffer = await downloadRemoteAsBuffer(value);
|
|
937
1004
|
return {
|
|
938
1005
|
kind: "buffer",
|
|
@@ -990,7 +1057,7 @@ const computeUploadHashes = async (source) => {
|
|
|
990
1057
|
return {
|
|
991
1058
|
md5,
|
|
992
1059
|
sha1: crypto.createHash("sha1").update(source.buffer).digest("hex"),
|
|
993
|
-
md510m: source.size >
|
|
1060
|
+
md510m: source.size > 10002432 ? crypto.createHash("md5").update(source.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
|
|
994
1061
|
};
|
|
995
1062
|
}
|
|
996
1063
|
return new Promise((resolve, reject) => {
|
|
@@ -1046,7 +1113,7 @@ const readPart = async (source, handle, offset, length) => {
|
|
|
1046
1113
|
*/
|
|
1047
1114
|
const putPart = async (url, data, partIndex, totalParts) => {
|
|
1048
1115
|
let lastError;
|
|
1049
|
-
for (let attempt = 0; attempt <=
|
|
1116
|
+
for (let attempt = 0; attempt <= 2; attempt++) try {
|
|
1050
1117
|
await axios.put(url, data, {
|
|
1051
1118
|
headers: {
|
|
1052
1119
|
"Content-Type": "application/octet-stream",
|
|
@@ -1059,7 +1126,7 @@ const putPart = async (url, data, partIndex, totalParts) => {
|
|
|
1059
1126
|
return;
|
|
1060
1127
|
} catch (err) {
|
|
1061
1128
|
lastError = err;
|
|
1062
|
-
if (attempt <
|
|
1129
|
+
if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
|
|
1063
1130
|
}
|
|
1064
1131
|
throw new Error(`上传分片失败:${partIndex}/${totalParts},${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
1065
1132
|
};
|
|
@@ -1173,7 +1240,7 @@ var MediaApi = class extends Http {
|
|
|
1173
1240
|
const cached = await getCachedUpload(scene, peer, type, hashes.md5);
|
|
1174
1241
|
if (cached) return cached;
|
|
1175
1242
|
}
|
|
1176
|
-
if (!srvSendMsg && fallbackSource.size >=
|
|
1243
|
+
if (!srvSendMsg && fallbackSource.size >= 5242880) {
|
|
1177
1244
|
const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
|
|
1178
1245
|
if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1179
1246
|
return response;
|
|
@@ -1189,6 +1256,39 @@ var MediaApi = class extends Http {
|
|
|
1189
1256
|
return response;
|
|
1190
1257
|
}
|
|
1191
1258
|
/**
|
|
1259
|
+
* 上传资源并取回它的公网直链。
|
|
1260
|
+
*
|
|
1261
|
+
* 分片上传的合并响应里带 `raw_url`,等价于一个临时图床:拿到地址后图片就能以
|
|
1262
|
+
* markdown 图片语法嵌进 msg_type=2,和文本、按钮合成一条消息,不必退化成
|
|
1263
|
+
* 独立的 msg_type=7 富媒体消息。
|
|
1264
|
+
*
|
|
1265
|
+
* 与 `uploadFallback` 的差别只有一处:那边按体积决定是否分片,目标是拿
|
|
1266
|
+
* `file_info`;这里必须无条件分片,否则服务端不会给出 `raw_url`。
|
|
1267
|
+
*
|
|
1268
|
+
* 直链的存活时间等于响应里的 `ttl`,属于临时地址,只适合当次发送使用。
|
|
1269
|
+
*
|
|
1270
|
+
* @param scene user(单聊) / group(群聊)
|
|
1271
|
+
* @param peer openid 或 group_openid,决定上传归属的会话
|
|
1272
|
+
* @param type 文件类型;`file` 无直链可取
|
|
1273
|
+
* @param source 本地路径、http url、data URL、base64:// 或裸 base64
|
|
1274
|
+
* @param fileName 文件名,同时用于推断直链声明的 MIME
|
|
1275
|
+
* @returns 可直接嵌入消息的直链,以及原始上传响应
|
|
1276
|
+
*/
|
|
1277
|
+
async uploadForUrl(scene, peer, type, source, fileName) {
|
|
1278
|
+
if (type === "file") throw new Error(`QQ 不为${MEDIA_TYPE_LABEL.file}类型返回下载直链,无法当图床使用`);
|
|
1279
|
+
const prepared = await buildFallbackSource(type, source, fileName);
|
|
1280
|
+
assertUploadSize(type, prepared.size);
|
|
1281
|
+
const hashes = await computeUploadHashes(prepared);
|
|
1282
|
+
const reused = await getCachedUpload(scene, peer, type, hashes.md5);
|
|
1283
|
+
const response = reused?.raw_url ? reused : await this.uploadChunked(scene, peer, type, prepared, hashes);
|
|
1284
|
+
if (!response.raw_url) throw new Error(`${MEDIA_TYPE_LABEL[type]}上传完成但未返回下载直链,无法当图床使用`);
|
|
1285
|
+
if (response !== reused) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1286
|
+
return {
|
|
1287
|
+
url: buildDirectLink(response.raw_url, type, prepared.fileName),
|
|
1288
|
+
response
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1192
1292
|
* 确认单个分片已完成上传。
|
|
1193
1293
|
*
|
|
1194
1294
|
* QQ 有时会在分片刚 PUT 完时短暂返回 40093001,此时需要在服务端给出的
|
|
@@ -1201,7 +1301,7 @@ var MediaApi = class extends Http {
|
|
|
1201
1301
|
*/
|
|
1202
1302
|
async finishUploadPart(scene, peer, body, retryTimeout) {
|
|
1203
1303
|
let lastError;
|
|
1204
|
-
for (let attempt = 0; attempt <=
|
|
1304
|
+
for (let attempt = 0; attempt <= 2; attempt++) try {
|
|
1205
1305
|
await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
|
|
1206
1306
|
return;
|
|
1207
1307
|
} catch (err) {
|
|
@@ -1210,7 +1310,7 @@ var MediaApi = class extends Http {
|
|
|
1210
1310
|
return;
|
|
1211
1311
|
}
|
|
1212
1312
|
lastError = err;
|
|
1213
|
-
if (attempt <
|
|
1313
|
+
if (attempt < 2) await sleep(PART_FINISH_BASE_DELAY * 2 ** attempt);
|
|
1214
1314
|
}
|
|
1215
1315
|
throwError(lastError);
|
|
1216
1316
|
}
|
|
@@ -1249,11 +1349,11 @@ var MediaApi = class extends Http {
|
|
|
1249
1349
|
async completeChunkedUpload(scene, peer, uploadId) {
|
|
1250
1350
|
let lastError;
|
|
1251
1351
|
const body = { upload_id: uploadId };
|
|
1252
|
-
for (let attempt = 0; attempt <=
|
|
1352
|
+
for (let attempt = 0; attempt <= 2; attempt++) try {
|
|
1253
1353
|
return await this.post(`/v2/${scene}s/${peer}/files`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
|
|
1254
1354
|
} catch (err) {
|
|
1255
1355
|
lastError = err;
|
|
1256
|
-
if (attempt <
|
|
1356
|
+
if (attempt < 2) await sleep(COMPLETE_UPLOAD_BASE_DELAY * 2 ** attempt);
|
|
1257
1357
|
}
|
|
1258
1358
|
return throwError(lastError);
|
|
1259
1359
|
}
|
|
@@ -1280,11 +1380,11 @@ var MediaApi = class extends Http {
|
|
|
1280
1380
|
};
|
|
1281
1381
|
prepareBody["md5_10m"] = hashes.md510m;
|
|
1282
1382
|
const prepare = await this.post(`/v2/${scene}s/${peer}/upload_prepare`, prepareBody, { timeout: MEDIA_UPLOAD_TIMEOUT }).catch((err) => {
|
|
1283
|
-
if (openApiCode(err) ===
|
|
1383
|
+
if (openApiCode(err) === 40093002) throw new Error(`QQ 大文件上传额度已达上限,${MEDIA_TYPE_LABEL[type]}暂时无法通过 fallback 直传:${source.fileName} (${formatBytes(source.size)})`);
|
|
1284
1384
|
return throwError(err);
|
|
1285
1385
|
});
|
|
1286
1386
|
if (!prepare.parts.length) throw new Error("分片上传准备失败:QQ 未返回分片信息");
|
|
1287
|
-
const concurrency = Math.min(prepare.concurrency ||
|
|
1387
|
+
const concurrency = Math.min(prepare.concurrency || 1, 10, prepare.parts.length);
|
|
1288
1388
|
const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
|
|
1289
1389
|
try {
|
|
1290
1390
|
const tasks = prepare.parts.map((part) => async () => {
|
|
@@ -1310,8 +1410,6 @@ var MediaApi = class extends Http {
|
|
|
1310
1410
|
|
|
1311
1411
|
//#endregion
|
|
1312
1412
|
//#region src/core/api/interaction.ts
|
|
1313
|
-
/** QQ 客户端回调按钮需要快速 ACK,超时必须小于平台等待窗口。 */
|
|
1314
|
-
const INTERACTION_ACK_TIMEOUT = 2800;
|
|
1315
1413
|
/**
|
|
1316
1414
|
* INTERACTION_CREATE 回应
|
|
1317
1415
|
*/
|
|
@@ -1743,11 +1841,17 @@ const buildQQMsg = {
|
|
|
1743
1841
|
ark
|
|
1744
1842
|
};
|
|
1745
1843
|
},
|
|
1746
|
-
/**
|
|
1747
|
-
|
|
1844
|
+
/**
|
|
1845
|
+
* 构造 msg_type=7 富媒体消息体
|
|
1846
|
+
*
|
|
1847
|
+
* @param fileInfo 上传富媒体返回的 file_info。
|
|
1848
|
+
* @param keyboard 可选内嵌键盘,用于按钮随富媒体一起发送。
|
|
1849
|
+
*/
|
|
1850
|
+
media(fileInfo, keyboard) {
|
|
1748
1851
|
return {
|
|
1749
1852
|
msg_type: 7,
|
|
1750
|
-
media: { file_info: fileInfo }
|
|
1853
|
+
media: { file_info: fileInfo },
|
|
1854
|
+
keyboard
|
|
1751
1855
|
};
|
|
1752
1856
|
}
|
|
1753
1857
|
};
|
|
@@ -2112,13 +2216,46 @@ const groupElements = (scene, elements) => {
|
|
|
2112
2216
|
//#endregion
|
|
2113
2217
|
//#region src/core/adapter/media-source.ts
|
|
2114
2218
|
/** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2219
|
+
/**
|
|
2220
|
+
* 把带 MIME 头的完整 data URL(`data:image/png;base64,...`)归一为 Karin 标准
|
|
2221
|
+
* 的 `base64://` 形式,其余来源原样返回。
|
|
2222
|
+
*
|
|
2223
|
+
* 下游插件经常直接给出完整 data URL;QQ 上传层认识它,但 karin 的
|
|
2224
|
+
* `common.buffer` 和不少第三方 fileToUrl 处理器只认 `base64://`,因此在进入
|
|
2225
|
+
* 发送链路时统一归一,避免同一资源在不同环节表现不一致。
|
|
2226
|
+
*
|
|
2227
|
+
* @param source Karin 消息段里的 file 字段。
|
|
2228
|
+
* @returns 归一后的来源字符串。
|
|
2229
|
+
*/
|
|
2230
|
+
const normalizeMediaSource = (source) => {
|
|
2231
|
+
return DATA_URL_BASE64_RE.test(source) ? source.replace(DATA_URL_BASE64_RE, "base64://") : source;
|
|
2232
|
+
};
|
|
2233
|
+
/** 带 file 字段的富媒体消息段类型。 */
|
|
2234
|
+
const MEDIA_ELEMENT_TYPES = /* @__PURE__ */ new Set([
|
|
2235
|
+
"image",
|
|
2236
|
+
"video",
|
|
2237
|
+
"record",
|
|
2238
|
+
"file"
|
|
2239
|
+
]);
|
|
2240
|
+
/**
|
|
2241
|
+
* 把消息段里带 MIME 头的完整 data URL(`data:image/png;base64,...`)统一归一为
|
|
2242
|
+
* Karin 标准的 `base64://` 形式。下游的 fileToUrl 处理器与 karin `common.buffer`
|
|
2243
|
+
* 大多只认后者,在进入发送链路前归一可以保证各环节行为一致。
|
|
2244
|
+
*
|
|
2245
|
+
* @param elements 待发送的消息段。
|
|
2246
|
+
* @returns 归一后的消息段;无需归一时返回原数组。
|
|
2247
|
+
*/
|
|
2248
|
+
const normalizeMediaElements = (elements) => {
|
|
2249
|
+
return elements.map((el) => {
|
|
2250
|
+
if (!MEDIA_ELEMENT_TYPES.has(el.type)) return el;
|
|
2251
|
+
const file = el.file;
|
|
2252
|
+
if (typeof file !== "string") return el;
|
|
2253
|
+
const normalized = normalizeMediaSource(file);
|
|
2254
|
+
return normalized === file ? el : {
|
|
2255
|
+
...el,
|
|
2256
|
+
file: normalized
|
|
2257
|
+
};
|
|
2258
|
+
});
|
|
2122
2259
|
};
|
|
2123
2260
|
/**
|
|
2124
2261
|
* 当前 Karin 运行时是否注册了 fileToUrl 处理器。
|
|
@@ -2130,37 +2267,58 @@ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
|
|
|
2130
2267
|
* @param source Karin 消息段里的 file 字段。
|
|
2131
2268
|
* @returns 是否为 HTTP(S) URL。
|
|
2132
2269
|
*/
|
|
2133
|
-
const isHttpMediaSource = (source) => HTTP_URL_RE
|
|
2270
|
+
const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
|
|
2134
2271
|
/**
|
|
2135
2272
|
* 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
|
|
2136
2273
|
*
|
|
2137
2274
|
* QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
|
|
2138
2275
|
* fileToUrl 作为主路径,QQ 上传只做兜底。
|
|
2139
2276
|
*
|
|
2140
|
-
* @param ctx
|
|
2277
|
+
* @param ctx 适配器实例,用于记录降级日志并标明执行发送的 bot。
|
|
2141
2278
|
* @param type 富媒体类型。
|
|
2142
2279
|
* @param source Karin 消息段里的 file 字段。
|
|
2143
2280
|
* @param filename 可选文件名,主要用于 file 消息。
|
|
2281
|
+
* @param origin 本次发送归属的上传会话,供需要借 QQ 通道上传的处理器使用。
|
|
2282
|
+
* @param purpose 拿到 URL 之后的用途,默认按富媒体上传处理。
|
|
2144
2283
|
* @returns 解析后的首选来源和来源类型。
|
|
2145
2284
|
*/
|
|
2146
|
-
const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
|
|
2147
|
-
|
|
2148
|
-
|
|
2285
|
+
const resolvePreferredMediaSource = async (ctx, type, source, filename, origin, purpose = "media") => {
|
|
2286
|
+
const normalized = normalizeMediaSource(source);
|
|
2287
|
+
if (isHttpMediaSource(normalized)) return {
|
|
2288
|
+
source: normalized,
|
|
2149
2289
|
via: "public-url"
|
|
2150
2290
|
};
|
|
2151
2291
|
if (!hasFileToUrlHandler()) return {
|
|
2152
|
-
source,
|
|
2292
|
+
source: normalized,
|
|
2153
2293
|
via: "fallback"
|
|
2154
2294
|
};
|
|
2155
2295
|
try {
|
|
2296
|
+
const extra = {
|
|
2297
|
+
selfId: ctx.selfId,
|
|
2298
|
+
purpose,
|
|
2299
|
+
origin
|
|
2300
|
+
};
|
|
2301
|
+
const result = await fileToUrl(type, normalized, filename || DEFAULT_FILENAME[type], extra);
|
|
2302
|
+
/**
|
|
2303
|
+
* `handler.has` 只判断该 key 下是否注册过处理器,不代表真的有处理器接手:
|
|
2304
|
+
* 所有处理器都调用 next() 放行时,fileToUrl 会返回 undefined。此时不属于
|
|
2305
|
+
* 异常,安静地走 QQ 上传兜底即可,不必输出 warn 噪音。
|
|
2306
|
+
*/
|
|
2307
|
+
if (!result?.url) {
|
|
2308
|
+
ctx.logger("debug", `[sendQQ] 没有 fileToUrl 处理器接手 ${type},改用 QQ 上传兜底`);
|
|
2309
|
+
return {
|
|
2310
|
+
source: normalized,
|
|
2311
|
+
via: "fallback"
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2156
2314
|
return {
|
|
2157
|
-
source:
|
|
2315
|
+
source: result.url,
|
|
2158
2316
|
via: "file-to-url"
|
|
2159
2317
|
};
|
|
2160
2318
|
} catch (err) {
|
|
2161
2319
|
ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
|
|
2162
2320
|
return {
|
|
2163
|
-
source,
|
|
2321
|
+
source: normalized,
|
|
2164
2322
|
via: "fallback"
|
|
2165
2323
|
};
|
|
2166
2324
|
}
|
|
@@ -2168,21 +2326,87 @@ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
|
|
|
2168
2326
|
|
|
2169
2327
|
//#endregion
|
|
2170
2328
|
//#region src/core/adapter/text-to-md.ts
|
|
2171
|
-
const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
2329
|
+
const MARKDOWN_IMAGE_RE = /!\[([^\]]*)]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
2330
|
+
/**
|
|
2331
|
+
* QQ markdown 图片语法里的显式尺寸:`#100px #100px`。
|
|
2332
|
+
*
|
|
2333
|
+
* `#` 是必需的——`100px 100px` 这种省略写法 QQ 前端不会按尺寸渲染,只能算
|
|
2334
|
+
* 普通描述文本。QQ 客户端按这个声明尺寸渲染而不是图片的物理分辨率——开发者
|
|
2335
|
+
* 显式写出尺寸是有布局意图的(比如把一张高清图当头像展示),重建时必须原样
|
|
2336
|
+
* 保留。
|
|
2337
|
+
*/
|
|
2338
|
+
const EXPLICIT_IMAGE_SIZE_RE = /#(\d+)px\s+#(\d+)px/;
|
|
2339
|
+
/**
|
|
2340
|
+
* 调用 fileToUrl 并确认真的拿到了 URL。
|
|
2341
|
+
*
|
|
2342
|
+
* Karin 的 handler 链允许每个处理器调用 next() 放行,全部放行时 fileToUrl 会
|
|
2343
|
+
* 返回 undefined 而不是抛错。这里统一转成明确的错误信息,避免调用方拿到
|
|
2344
|
+
* undefined 后在解构处报出难以定位的 TypeError。
|
|
2345
|
+
*
|
|
2346
|
+
* @param source 图片来源。
|
|
2347
|
+
* @param extra 传给处理器的附加信息,至少标明执行发送的 bot。
|
|
2348
|
+
* @returns fileToUrl 的图片结果。
|
|
2349
|
+
*/
|
|
2350
|
+
const imageToUrl = async (source, extra) => {
|
|
2351
|
+
const result = await fileToUrl("image", normalizeMediaSource(source), "image.jpg", extra);
|
|
2352
|
+
if (!result?.url) throw new Error("[Handler][Error]: 没有 fileToUrl 处理器接手图片转 URL");
|
|
2353
|
+
return result;
|
|
2354
|
+
};
|
|
2355
|
+
/**
|
|
2356
|
+
* 生成一行 markdown 图片。
|
|
2357
|
+
*
|
|
2358
|
+
* alt 里带显式尺寸(`#100px #100px`)时原样保留,不探测图片真实尺寸——QQ 按
|
|
2359
|
+
* 声明尺寸渲染,覆盖它会破坏开发者的布局意图。没有显式尺寸时才用传入的或
|
|
2360
|
+
* 探测到的真实宽高补全。
|
|
2361
|
+
*
|
|
2362
|
+
* @param url 图片的最终公网 URL。
|
|
2363
|
+
* @param alt 原始 alt 文本,可能包含显式尺寸声明。
|
|
2364
|
+
* @param size 已知的图片真实尺寸;未提供且 alt 无显式尺寸时探测 URL。
|
|
2365
|
+
* @returns markdown 图片行。
|
|
2366
|
+
*/
|
|
2367
|
+
const buildMarkdownImageLine = async (url, alt = "", size) => {
|
|
2368
|
+
const explicit = alt.match(EXPLICIT_IMAGE_SIZE_RE);
|
|
2369
|
+
if (explicit) return `![${alt.replace(EXPLICIT_IMAGE_SIZE_RE, "").trim()} #${explicit[1]}px #${explicit[2]}px](${url})`;
|
|
2370
|
+
const desc = alt.trim() || "karin";
|
|
2371
|
+
const resolved = size ?? await getImageSize(url).catch(() => ({
|
|
2372
|
+
width: 300,
|
|
2373
|
+
height: 300
|
|
2374
|
+
}));
|
|
2375
|
+
return ``;
|
|
2376
|
+
};
|
|
2172
2377
|
/**
|
|
2173
2378
|
* 将图片列表合并到一段 markdown 文本内(每张图占一行)
|
|
2379
|
+
*
|
|
2380
|
+
* @param urls 图片来源列表。
|
|
2381
|
+
* @param extra 传给 fileToUrl 处理器的附加信息。
|
|
2382
|
+
* @returns markdown 图片行。
|
|
2174
2383
|
*/
|
|
2175
|
-
const imagesToMarkdown = async (urls) => {
|
|
2384
|
+
const imagesToMarkdown = async (urls, extra) => {
|
|
2176
2385
|
return Promise.all(urls.map(async (file) => {
|
|
2177
|
-
if (file.startsWith("http"))
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2386
|
+
if (file.startsWith("http")) return buildMarkdownImageLine(file);
|
|
2387
|
+
const { url, width, height } = await imageToUrl(file, extra);
|
|
2388
|
+
return buildMarkdownImageLine(url, "", {
|
|
2389
|
+
width,
|
|
2390
|
+
height
|
|
2391
|
+
});
|
|
2183
2392
|
}));
|
|
2184
2393
|
};
|
|
2185
2394
|
/**
|
|
2395
|
+
* 把一个拆分出来的 markdown 图片转成图片行,保留原始 alt 与显式尺寸。
|
|
2396
|
+
*
|
|
2397
|
+
* @param part splitMarkdownImages 拆出的图片片段。
|
|
2398
|
+
* @param extra 传给 fileToUrl 处理器的附加信息。
|
|
2399
|
+
* @returns markdown 图片行。
|
|
2400
|
+
*/
|
|
2401
|
+
const markdownPartToLine = async (part, extra) => {
|
|
2402
|
+
if (part.source.startsWith("http")) return buildMarkdownImageLine(part.source, part.alt);
|
|
2403
|
+
const { url, width, height } = await imageToUrl(part.source, extra);
|
|
2404
|
+
return buildMarkdownImageLine(url, part.alt, {
|
|
2405
|
+
width,
|
|
2406
|
+
height
|
|
2407
|
+
});
|
|
2408
|
+
};
|
|
2409
|
+
/**
|
|
2186
2410
|
* 将 markdown 文本拆为普通文本片段与图片来源。
|
|
2187
2411
|
*/
|
|
2188
2412
|
const splitMarkdownImages = (markdown) => {
|
|
@@ -2197,10 +2421,12 @@ const splitMarkdownImages = (markdown) => {
|
|
|
2197
2421
|
value: text
|
|
2198
2422
|
});
|
|
2199
2423
|
}
|
|
2200
|
-
const
|
|
2424
|
+
const alt = match[1] ?? "";
|
|
2425
|
+
const source = (match[2] || "").trim().replace(/^<|>$/g, "");
|
|
2201
2426
|
if (source) parts.push({
|
|
2202
2427
|
type: "image",
|
|
2203
|
-
source
|
|
2428
|
+
source: normalizeMediaSource(source),
|
|
2429
|
+
alt
|
|
2204
2430
|
});
|
|
2205
2431
|
lastIndex = index + match[0].length;
|
|
2206
2432
|
}
|
|
@@ -2219,25 +2445,6 @@ const splitMarkdownImages = (markdown) => {
|
|
|
2219
2445
|
|
|
2220
2446
|
//#endregion
|
|
2221
2447
|
//#region src/core/adapter/pipeline-qq.ts
|
|
2222
|
-
/** QQ 官方限制:单聊中同一 `msg_id` 最多发送四次被动回复。 */
|
|
2223
|
-
const C2C_PASSIVE_REPLY_LIMIT = 4;
|
|
2224
|
-
/** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
|
|
2225
|
-
const KEYBOARD_MAX_ROWS$1 = 5;
|
|
2226
|
-
const KEYBOARD_MAX_BUTTONS_PER_ROW$1 = 5;
|
|
2227
|
-
/** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
|
|
2228
|
-
const BUTTON_ONLY_MARKDOWN$1 = "";
|
|
2229
|
-
/** 群聊 event_id 白名单 */
|
|
2230
|
-
const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
2231
|
-
"INTERACTION_CREATE",
|
|
2232
|
-
"GROUP_ADD_ROBOT",
|
|
2233
|
-
"GROUP_MSG_RECEIVE"
|
|
2234
|
-
]);
|
|
2235
|
-
/** 单聊 event_id 白名单 */
|
|
2236
|
-
const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
2237
|
-
"INTERACTION_CREATE",
|
|
2238
|
-
"C2C_MSG_RECEIVE",
|
|
2239
|
-
"FRIEND_ADD"
|
|
2240
|
-
]);
|
|
2241
2448
|
/**
|
|
2242
2449
|
* 处理 QQ 场景(单聊 + 群聊)的发送
|
|
2243
2450
|
*/
|
|
@@ -2261,30 +2468,34 @@ const sendQQ = async (ctx, contact, elements) => {
|
|
|
2261
2468
|
*/
|
|
2262
2469
|
const sendQQMarkdown = async (ctx, contact, grouping, target) => {
|
|
2263
2470
|
const items = [];
|
|
2471
|
+
/** 上传归属会话固定为本次发送目标,保证 openid 与执行发送的 bot 同域。 */
|
|
2472
|
+
const origin = {
|
|
2473
|
+
scene: target,
|
|
2474
|
+
peer: contact.peer
|
|
2475
|
+
};
|
|
2264
2476
|
const lines = [];
|
|
2265
2477
|
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
2266
|
-
const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages);
|
|
2267
|
-
const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
|
|
2478
|
+
const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages, origin);
|
|
2479
|
+
const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping, origin);
|
|
2268
2480
|
fallbackImages.push(...markdownFallbackImages);
|
|
2269
2481
|
if (contact.scene === "group") {
|
|
2270
2482
|
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2271
2483
|
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
2272
2484
|
}
|
|
2273
|
-
|
|
2274
|
-
else {
|
|
2275
|
-
const keyboard = buildKeyboard$1(grouping);
|
|
2276
|
-
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
2277
|
-
items.push(ctx.super.qq.markdown({ content }, keyboard));
|
|
2278
|
-
}
|
|
2485
|
+
const mediaItems = [];
|
|
2279
2486
|
for (const file of fallbackImages) {
|
|
2280
2487
|
const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
|
|
2281
|
-
|
|
2488
|
+
mediaItems.push(ctx.super.qq.media(res.file_info));
|
|
2282
2489
|
}
|
|
2283
2490
|
for (const m of grouping.media) {
|
|
2284
|
-
const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name);
|
|
2491
|
+
const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name, origin);
|
|
2285
2492
|
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);
|
|
2286
|
-
|
|
2493
|
+
mediaItems.push(ctx.super.qq.media(res.file_info));
|
|
2287
2494
|
}
|
|
2495
|
+
if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
|
|
2496
|
+
else if (lines.length) items.push(ctx.super.qq.markdown({ content: lines.join("\n") }, buildKeyboard$1(grouping)));
|
|
2497
|
+
else attachKeyboardWithoutMarkdown(ctx, grouping, mediaItems, items);
|
|
2498
|
+
items.push(...mediaItems);
|
|
2288
2499
|
if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
|
|
2289
2500
|
return flushQQ(ctx, contact, grouping, items);
|
|
2290
2501
|
};
|
|
@@ -2311,21 +2522,55 @@ const sendQQClassic = async (ctx, contact, grouping, target) => {
|
|
|
2311
2522
|
const appendExplicitMarkdownItems = async (ctx, contact, grouping, target, items) => {
|
|
2312
2523
|
if (!grouping.markdowns.length) return;
|
|
2313
2524
|
const lines = [];
|
|
2314
|
-
const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping
|
|
2525
|
+
const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping, {
|
|
2526
|
+
scene: target,
|
|
2527
|
+
peer: contact.peer
|
|
2528
|
+
});
|
|
2315
2529
|
if (contact.scene === "group") {
|
|
2316
2530
|
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2317
2531
|
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
2318
2532
|
}
|
|
2319
|
-
const
|
|
2320
|
-
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
2321
|
-
items.push(ctx.super.qq.markdown({ content }, keyboard));
|
|
2533
|
+
const mediaItems = [];
|
|
2322
2534
|
for (const file of fallbackImages) {
|
|
2323
2535
|
const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
|
|
2324
|
-
|
|
2536
|
+
mediaItems.push(ctx.super.qq.media(res.file_info));
|
|
2325
2537
|
}
|
|
2538
|
+
if (lines.length) items.push(ctx.super.qq.markdown({ content: lines.join("\n") }, buildKeyboard$1(grouping)));
|
|
2539
|
+
else attachKeyboardWithoutMarkdown(ctx, grouping, mediaItems, items);
|
|
2540
|
+
items.push(...mediaItems);
|
|
2541
|
+
};
|
|
2542
|
+
/**
|
|
2543
|
+
* 处理「没有任何 markdown 正文,但存在按钮」的边缘情况。
|
|
2544
|
+
*
|
|
2545
|
+
* 典型触发路径:下游只发一张 markdown 语法图片 + 若干按钮,而这张图片没能通过
|
|
2546
|
+
* `fileToUrl` 取到公网地址,被降级成 msg_type=7 富媒体。此时 markdown 正文为空,
|
|
2547
|
+
* 旧实现会补一个零宽字符当正文,把按钮单独发成一条消息,客户端就会看到
|
|
2548
|
+
* 「一条空的按钮消息 + 一条富媒体消息」两条消息。
|
|
2549
|
+
*
|
|
2550
|
+
* 这里改为把 keyboard 挂到第一条富媒体上,合并成一条消息;只有在连富媒体都没有时,
|
|
2551
|
+
* 才保留零宽字符 markdown 兜底(纯按钮消息是正常用法,不能丢)。
|
|
2552
|
+
*
|
|
2553
|
+
* @param ctx 适配器实例,用于输出日志。
|
|
2554
|
+
* @param grouping 已归类的消息段。
|
|
2555
|
+
* @param mediaItems 本次待发送的富媒体消息体,keyboard 会挂到第一条上。
|
|
2556
|
+
* @param items 最终发送队列,仅在没有富媒体时追加纯按钮 markdown。
|
|
2557
|
+
*/
|
|
2558
|
+
const attachKeyboardWithoutMarkdown = (ctx, grouping, mediaItems, items) => {
|
|
2559
|
+
const keyboard = buildKeyboard$1(grouping);
|
|
2560
|
+
if (!keyboard) return;
|
|
2561
|
+
const [first] = mediaItems;
|
|
2562
|
+
if (!first) {
|
|
2563
|
+
items.push(ctx.super.qq.markdown({ content: "" }, keyboard));
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
first.keyboard = keyboard;
|
|
2567
|
+
ctx.logger("debug", "图片未能进入 markdown 通道,按钮已随富媒体一起发送,避免拆成两条消息");
|
|
2326
2568
|
};
|
|
2327
2569
|
const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
|
|
2328
|
-
const resolved = await resolvePreferredMediaSource(ctx, type, source, name
|
|
2570
|
+
const resolved = await resolvePreferredMediaSource(ctx, type, source, name, {
|
|
2571
|
+
scene: target,
|
|
2572
|
+
peer
|
|
2573
|
+
});
|
|
2329
2574
|
const res = resolved.via === "fallback" ? await ctx.super.media.uploadFallback(target, peer, type, resolved.source, false, name) : await ctx.super.media.upload(target, peer, type, resolved.source, false, name);
|
|
2330
2575
|
return ctx.super.qq.media(res.file_info);
|
|
2331
2576
|
};
|
|
@@ -2335,12 +2580,13 @@ const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
|
|
|
2335
2580
|
* @param ctx 适配器实例,用于输出降级日志。
|
|
2336
2581
|
* @param lines markdown 行数组。
|
|
2337
2582
|
* @param files QQ 图片消息段的 file 字段。
|
|
2583
|
+
* @param origin 本次发送归属的上传会话。
|
|
2338
2584
|
* @returns 需要改走 msg_type=7 富媒体发送的图片列表。
|
|
2339
2585
|
*/
|
|
2340
|
-
const appendMarkdownImages = async (ctx, lines, files) => {
|
|
2586
|
+
const appendMarkdownImages = async (ctx, lines, files, origin) => {
|
|
2341
2587
|
const fallback = [];
|
|
2342
2588
|
for (const file of files) {
|
|
2343
|
-
const resolved = await resolvePreferredMediaSource(ctx, "image", file);
|
|
2589
|
+
const resolved = await resolvePreferredMediaSource(ctx, "image", file, void 0, origin, "markdown");
|
|
2344
2590
|
if (resolved.via === "fallback") {
|
|
2345
2591
|
fallback.push(file);
|
|
2346
2592
|
continue;
|
|
@@ -2351,8 +2597,14 @@ const appendMarkdownImages = async (ctx, lines, files) => {
|
|
|
2351
2597
|
};
|
|
2352
2598
|
/**
|
|
2353
2599
|
* 处理显式 segment.markdown 中的图片:可转公网 URL 的继续嵌入,无法转的改走富媒体。
|
|
2600
|
+
*
|
|
2601
|
+
* @param ctx 适配器实例,用于输出降级日志。
|
|
2602
|
+
* @param lines markdown 行数组。
|
|
2603
|
+
* @param grouping 已归类的消息段。
|
|
2604
|
+
* @param origin 本次发送归属的上传会话。
|
|
2605
|
+
* @returns 需要改走 msg_type=7 富媒体发送的图片列表。
|
|
2354
2606
|
*/
|
|
2355
|
-
const appendExplicitMarkdown = async (ctx, lines, grouping) => {
|
|
2607
|
+
const appendExplicitMarkdown = async (ctx, lines, grouping, origin) => {
|
|
2356
2608
|
const fallback = [];
|
|
2357
2609
|
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
|
|
2358
2610
|
if (part.type === "text") {
|
|
@@ -2360,12 +2612,13 @@ const appendExplicitMarkdown = async (ctx, lines, grouping) => {
|
|
|
2360
2612
|
if (text) lines.push(text);
|
|
2361
2613
|
continue;
|
|
2362
2614
|
}
|
|
2363
|
-
const resolved = await resolvePreferredMediaSource(ctx, "image", part.source);
|
|
2615
|
+
const resolved = await resolvePreferredMediaSource(ctx, "image", part.source, void 0, origin, "markdown");
|
|
2364
2616
|
if (resolved.via === "fallback") {
|
|
2365
2617
|
fallback.push(part.source);
|
|
2366
2618
|
continue;
|
|
2367
2619
|
}
|
|
2368
|
-
|
|
2620
|
+
/** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
|
|
2621
|
+
lines.push(await buildMarkdownImageLine(resolved.source, part.alt));
|
|
2369
2622
|
}
|
|
2370
2623
|
return fallback;
|
|
2371
2624
|
};
|
|
@@ -2377,12 +2630,7 @@ const appendExplicitMarkdown = async (ctx, lines, grouping) => {
|
|
|
2377
2630
|
* @param url 图片 URL。
|
|
2378
2631
|
*/
|
|
2379
2632
|
const appendImageUrlLine = async (lines, url) => {
|
|
2380
|
-
|
|
2381
|
-
const [line] = await imagesToMarkdown([url]);
|
|
2382
|
-
lines.push(line);
|
|
2383
|
-
} catch {
|
|
2384
|
-
lines.push(``);
|
|
2385
|
-
}
|
|
2633
|
+
lines.push(await buildMarkdownImageLine(url));
|
|
2386
2634
|
};
|
|
2387
2635
|
/**
|
|
2388
2636
|
* 判断显式引用回复是否应降级为普通文本消息。
|
|
@@ -2432,8 +2680,8 @@ const buildKeyboard$1 = (grouping) => {
|
|
|
2432
2680
|
grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
|
|
2433
2681
|
const normalizedRows = [];
|
|
2434
2682
|
let id = 0;
|
|
2435
|
-
for (const row of rows.slice(0,
|
|
2436
|
-
const buttons = row.buttons.slice(0,
|
|
2683
|
+
for (const row of rows.slice(0, 5)) {
|
|
2684
|
+
const buttons = row.buttons.slice(0, 5).map((button) => normalizeQQBotButton(button, id++));
|
|
2437
2685
|
if (buttons.length) normalizedRows.push({ buttons });
|
|
2438
2686
|
}
|
|
2439
2687
|
if (!normalizedRows.length) return void 0;
|
|
@@ -2472,8 +2720,8 @@ const flushQQ = async (ctx, contact, grouping, items) => {
|
|
|
2472
2720
|
* 仅单聊 `msg_id` 回复受四次上限约束。`event_id` 事件回复和群聊不适用该限制。
|
|
2473
2721
|
* 这是 QQ 官方 2026-01-10 更新后的限制,超出部分不请求平台接口。
|
|
2474
2722
|
*/
|
|
2475
|
-
const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ?
|
|
2476
|
-
if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${
|
|
2723
|
+
const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? 4 : Infinity;
|
|
2724
|
+
if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${4} 条,已跳过 ${items.length - maxItems} 条`);
|
|
2477
2725
|
let referenceHandled = false;
|
|
2478
2726
|
for (const item of items.slice(0, maxItems)) {
|
|
2479
2727
|
passive(item);
|
|
@@ -2500,6 +2748,12 @@ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
|
|
|
2500
2748
|
try {
|
|
2501
2749
|
return await send(contact.peer, item);
|
|
2502
2750
|
} catch (err) {
|
|
2751
|
+
if (item.msg_type === 7 && item.keyboard && isInvalidKeyboardError(err)) {
|
|
2752
|
+
ctx.logger("warn", "富媒体消息附带 keyboard 被 QQ 拒绝,已去掉按钮重试,本条消息将不显示按钮");
|
|
2753
|
+
const retryItem = { ...item };
|
|
2754
|
+
delete retryItem.keyboard;
|
|
2755
|
+
return send(contact.peer, retryItem);
|
|
2756
|
+
}
|
|
2503
2757
|
if (!item.event_id || !isInvalidEventIdError(err)) throw err;
|
|
2504
2758
|
ctx.logger("warn", `event_id 被 QQ 拒绝,已改用普通消息重试: ${item.event_id}`);
|
|
2505
2759
|
const retryItem = { ...item };
|
|
@@ -2509,6 +2763,19 @@ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
|
|
|
2509
2763
|
}
|
|
2510
2764
|
};
|
|
2511
2765
|
/**
|
|
2766
|
+
* 判断发送失败是否来自 QQ 对 `keyboard` 的参数校验。
|
|
2767
|
+
*
|
|
2768
|
+
* 官方文档未声明 `keyboard` 与 `media` 互斥,但实际平台行为无法完全确认,
|
|
2769
|
+
* 因此富媒体带按钮失败时基于键盘相关错误码兜底降级,保证图片本身能发出去。
|
|
2770
|
+
*
|
|
2771
|
+
* @param err 捕获到的发送异常。
|
|
2772
|
+
* @returns 是否可以通过移除 `keyboard` 重试。
|
|
2773
|
+
*/
|
|
2774
|
+
const isInvalidKeyboardError = (err) => {
|
|
2775
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2776
|
+
return message.includes("305007") || message.includes("键盘") || message.includes("keyboard");
|
|
2777
|
+
};
|
|
2778
|
+
/**
|
|
2512
2779
|
* 判断发送失败是否来自 QQ 对 `event_id` 的参数校验。
|
|
2513
2780
|
*
|
|
2514
2781
|
* 当前错误由 Http 层格式化为普通 Error,因此这里基于官方错误码和中文错误信息兜底识别。
|
|
@@ -2585,11 +2852,6 @@ const buildPassiveQQ = (ctx, scene, grouping) => {
|
|
|
2585
2852
|
|
|
2586
2853
|
//#endregion
|
|
2587
2854
|
//#region src/core/adapter/pipeline-guild.ts
|
|
2588
|
-
/** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
|
|
2589
|
-
const KEYBOARD_MAX_ROWS = 5;
|
|
2590
|
-
const KEYBOARD_MAX_BUTTONS_PER_ROW = 5;
|
|
2591
|
-
/** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
|
|
2592
|
-
const BUTTON_ONLY_MARKDOWN = "";
|
|
2593
2855
|
/**
|
|
2594
2856
|
* 处理频道场景(子频道 + 私信)的发送
|
|
2595
2857
|
*
|
|
@@ -2608,29 +2870,52 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
|
|
|
2608
2870
|
const items = [];
|
|
2609
2871
|
const lines = [];
|
|
2610
2872
|
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
2873
|
+
/**
|
|
2874
|
+
* 频道用的是 channel_id,不存在 QQ 上传接口要求的 group_openid / user_openid,
|
|
2875
|
+
* 所以只能标明执行发送的 bot,拿不出可用的上传会话。
|
|
2876
|
+
*/
|
|
2877
|
+
const extra = {
|
|
2878
|
+
selfId: ctx.selfId,
|
|
2879
|
+
purpose: "markdown"
|
|
2880
|
+
};
|
|
2611
2881
|
const allImages = [...grouping.guildImageUrls];
|
|
2882
|
+
/** 没有 fileToUrl 处理器接手的本地图片,降级为 file_image 单独发送。 */
|
|
2883
|
+
const fallbackImages = [];
|
|
2612
2884
|
for (const file of grouping.guildImageFiles) {
|
|
2613
|
-
const
|
|
2614
|
-
|
|
2885
|
+
const result = await fileToUrl("image", file, "image.jpg", extra);
|
|
2886
|
+
if (!result?.url) {
|
|
2887
|
+
ctx.logger("debug", "[sendGuild] 没有 fileToUrl 处理器接手图片转 URL,改走 file_image 单独发送");
|
|
2888
|
+
fallbackImages.push(file);
|
|
2889
|
+
continue;
|
|
2890
|
+
}
|
|
2891
|
+
allImages.push(result.url);
|
|
2615
2892
|
}
|
|
2616
2893
|
if (allImages.length) {
|
|
2617
|
-
const mdImages = await imagesToMarkdown(allImages);
|
|
2894
|
+
const mdImages = await imagesToMarkdown(allImages, extra);
|
|
2618
2895
|
lines.push(...mdImages);
|
|
2619
2896
|
}
|
|
2620
|
-
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown))
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2897
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
|
|
2898
|
+
if (part.type === "text") {
|
|
2899
|
+
const text = part.value.trim();
|
|
2900
|
+
if (text) lines.push(text);
|
|
2901
|
+
continue;
|
|
2902
|
+
}
|
|
2903
|
+
try {
|
|
2904
|
+
/** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
|
|
2905
|
+
lines.push(await markdownPartToLine(part, extra));
|
|
2906
|
+
} catch {
|
|
2907
|
+
ctx.logger("debug", "[sendGuild] markdown 内嵌图片转 URL 失败,改走 file_image 单独发送");
|
|
2908
|
+
fallbackImages.push(part.source);
|
|
2909
|
+
}
|
|
2626
2910
|
}
|
|
2627
2911
|
warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2628
2912
|
warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
|
|
2629
2913
|
if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
|
|
2630
2914
|
const keyboard = buildKeyboard(grouping);
|
|
2631
|
-
const content = lines.length ? lines.join("\n") :
|
|
2915
|
+
const content = lines.length ? lines.join("\n") : "";
|
|
2632
2916
|
items.push(ctx.super.guild.markdown({ content }, keyboard));
|
|
2633
2917
|
}
|
|
2918
|
+
for (const file of fallbackImages) items.push(await buildGuildImageItem(file));
|
|
2634
2919
|
if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
|
|
2635
2920
|
return flushGuild(ctx, contact, grouping, items);
|
|
2636
2921
|
};
|
|
@@ -2655,19 +2940,32 @@ const sendGuildClassic = async (ctx, contact, grouping) => {
|
|
|
2655
2940
|
*/
|
|
2656
2941
|
const appendExplicitGuildMarkdownItems = async (ctx, contact, grouping, items) => {
|
|
2657
2942
|
if (!grouping.markdowns.length) return;
|
|
2943
|
+
const extra = {
|
|
2944
|
+
selfId: ctx.selfId,
|
|
2945
|
+
purpose: "markdown"
|
|
2946
|
+
};
|
|
2658
2947
|
const lines = [];
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
if (text)
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2948
|
+
const fallbackImages = [];
|
|
2949
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
|
|
2950
|
+
if (part.type === "text") {
|
|
2951
|
+
const text = part.value.trim();
|
|
2952
|
+
if (text) lines.push(text);
|
|
2953
|
+
continue;
|
|
2954
|
+
}
|
|
2955
|
+
try {
|
|
2956
|
+
/** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
|
|
2957
|
+
lines.push(await markdownPartToLine(part, extra));
|
|
2958
|
+
} catch {
|
|
2959
|
+
ctx.logger("debug", "[sendGuild] markdown 内嵌图片转 URL 失败,改走 file_image 单独发送");
|
|
2960
|
+
fallbackImages.push(part.source);
|
|
2961
|
+
}
|
|
2665
2962
|
}
|
|
2666
2963
|
warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2667
2964
|
warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
|
|
2668
2965
|
const keyboard = buildKeyboard(grouping);
|
|
2669
|
-
const content = lines.length ? lines.join("\n") :
|
|
2966
|
+
const content = lines.length ? lines.join("\n") : "";
|
|
2670
2967
|
items.push(ctx.super.guild.markdown({ content }, keyboard));
|
|
2968
|
+
for (const file of fallbackImages) items.push(await buildGuildImageItem(file));
|
|
2671
2969
|
};
|
|
2672
2970
|
const buildGuildImageItem = async (source) => {
|
|
2673
2971
|
if (/^https?:\/\//i.test(source)) return {
|
|
@@ -2689,8 +2987,8 @@ const buildKeyboard = (grouping) => {
|
|
|
2689
2987
|
grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
|
|
2690
2988
|
const normalizedRows = [];
|
|
2691
2989
|
let id = 0;
|
|
2692
|
-
for (const row of rows.slice(0,
|
|
2693
|
-
const buttons = row.buttons.slice(0,
|
|
2990
|
+
for (const row of rows.slice(0, 5)) {
|
|
2991
|
+
const buttons = row.buttons.slice(0, 5).map((button) => normalizeQQBotButton(button, id++));
|
|
2694
2992
|
if (buttons.length) normalizedRows.push({ buttons });
|
|
2695
2993
|
}
|
|
2696
2994
|
if (!normalizedRows.length) return void 0;
|
|
@@ -3011,12 +3309,35 @@ const clearJoinRequests = (appId) => {
|
|
|
3011
3309
|
};
|
|
3012
3310
|
|
|
3013
3311
|
//#endregion
|
|
3014
|
-
//#region src/core/adapter/
|
|
3312
|
+
//#region src/core/adapter/config-store.ts
|
|
3313
|
+
/**
|
|
3314
|
+
* 适配器实例的真实配置存储。
|
|
3315
|
+
*
|
|
3316
|
+
* 配置挂在 WeakMap 而不是实例字段上:系统接口序列化整个适配器时不会把
|
|
3317
|
+
* appSecret 等敏感配置带出去。
|
|
3318
|
+
*/
|
|
3015
3319
|
const adapterConfigStore = /* @__PURE__ */ new WeakMap();
|
|
3016
|
-
/**
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3320
|
+
/**
|
|
3321
|
+
* 绑定适配器实例与其配置。
|
|
3322
|
+
* @param bot 适配器实例。
|
|
3323
|
+
* @param cfg 该实例的 QQBot 配置。
|
|
3324
|
+
*/
|
|
3325
|
+
const setAdapterConfig = (bot, cfg) => {
|
|
3326
|
+
adapterConfigStore.set(bot, cfg);
|
|
3327
|
+
};
|
|
3328
|
+
/**
|
|
3329
|
+
* 读取适配器实例的配置。
|
|
3330
|
+
* @param bot 适配器实例。
|
|
3331
|
+
* @returns 该实例的 QQBot 配置;未绑定时抛错。
|
|
3332
|
+
*/
|
|
3333
|
+
const getAdapterConfig = (bot) => {
|
|
3334
|
+
const cfg = adapterConfigStore.get(bot);
|
|
3335
|
+
if (!cfg) throw new Error("QQBot adapter config missing");
|
|
3336
|
+
return cfg;
|
|
3337
|
+
};
|
|
3338
|
+
|
|
3339
|
+
//#endregion
|
|
3340
|
+
//#region src/core/adapter/base.ts
|
|
3020
3341
|
/**
|
|
3021
3342
|
* QQ Official Bot 适配器
|
|
3022
3343
|
*
|
|
@@ -3030,7 +3351,7 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3030
3351
|
nicknameCache = /* @__PURE__ */ new Map();
|
|
3031
3352
|
constructor(cfg, api) {
|
|
3032
3353
|
super();
|
|
3033
|
-
|
|
3354
|
+
setAdapterConfig(this, cfg);
|
|
3034
3355
|
this.super = api;
|
|
3035
3356
|
this.adapter.name = "QQ Official Bot";
|
|
3036
3357
|
this.adapter.protocol = "qqbot";
|
|
@@ -3039,9 +3360,7 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3039
3360
|
}
|
|
3040
3361
|
/** 当前 bot 配置。真实配置存放在 WeakMap,避免被系统接口序列化整个适配器时带出。 */
|
|
3041
3362
|
get cfg() {
|
|
3042
|
-
|
|
3043
|
-
if (!cfg) throw new Error("QQBot adapter config missing");
|
|
3044
|
-
return cfg;
|
|
3363
|
+
return getAdapterConfig(this);
|
|
3045
3364
|
}
|
|
3046
3365
|
/** 接收消息的一天热缓存 + SQLite 缓存,用于实现 Karin 标准 `getMsg`。 */
|
|
3047
3366
|
get messageStore() {
|
|
@@ -3061,16 +3380,17 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3061
3380
|
* 发送消息
|
|
3062
3381
|
*/
|
|
3063
3382
|
async sendMsg(contact, elements, _retryCount) {
|
|
3383
|
+
const normalizedElements = normalizeMediaElements(elements);
|
|
3064
3384
|
/** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
|
|
3065
3385
|
if (contact.scene === "direct" || contact.scene === "guild") {
|
|
3066
|
-
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this,
|
|
3067
|
-
const result = await sendGuild(this, contact, prepared?.sendElements ??
|
|
3386
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
|
|
3387
|
+
const result = await sendGuild(this, contact, prepared?.sendElements ?? normalizedElements);
|
|
3068
3388
|
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
3069
3389
|
return result;
|
|
3070
3390
|
}
|
|
3071
3391
|
if (contact.scene === "group" || contact.scene === "friend") {
|
|
3072
|
-
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this,
|
|
3073
|
-
const result = await sendQQ(this, contact, prepared?.sendElements ??
|
|
3392
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
|
|
3393
|
+
const result = await sendQQ(this, contact, prepared?.sendElements ?? normalizedElements);
|
|
3074
3394
|
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
3075
3395
|
return result;
|
|
3076
3396
|
}
|
|
@@ -3156,20 +3476,43 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3156
3476
|
case "text":
|
|
3157
3477
|
lines.push(el.text);
|
|
3158
3478
|
break;
|
|
3159
|
-
case "image":
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3479
|
+
case "image": {
|
|
3480
|
+
const imageSource = typeof el.file === "string" ? normalizeMediaSource(el.file) : el.file;
|
|
3481
|
+
if (typeof imageSource === "string" && imageSource.startsWith("http")) try {
|
|
3482
|
+
const { width, height } = await getImageSize(imageSource);
|
|
3483
|
+
lines.push(``);
|
|
3163
3484
|
} catch {
|
|
3164
|
-
lines.push(``);
|
|
3165
3486
|
}
|
|
3166
|
-
else
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3487
|
+
else {
|
|
3488
|
+
/**
|
|
3489
|
+
* 必须带上 selfId / purpose / origin:内置图床按这三个字段决定
|
|
3490
|
+
* 是否接手,缺任何一个都会直接放行给后续处理器。频道场景没有
|
|
3491
|
+
* 可用的 openid,origin 只能是 undefined。
|
|
3492
|
+
*/
|
|
3493
|
+
const origin = contact.scene === "group" ? {
|
|
3494
|
+
scene: "group",
|
|
3495
|
+
peer: contact.peer
|
|
3496
|
+
} : contact.scene === "friend" ? {
|
|
3497
|
+
scene: "user",
|
|
3498
|
+
peer: contact.peer
|
|
3499
|
+
} : void 0;
|
|
3500
|
+
try {
|
|
3501
|
+
const result = await fileToUrl("image", imageSource, el.name || "image.jpg", {
|
|
3502
|
+
selfId: this.selfId,
|
|
3503
|
+
purpose: "markdown",
|
|
3504
|
+
origin
|
|
3505
|
+
});
|
|
3506
|
+
if (result?.url) lines.push(``);
|
|
3507
|
+
else
|
|
3508
|
+
/** 没有处理器接手时不能留空 URL 的图片语法(客户端渲染为空白),改走富媒体补发 */
|
|
3509
|
+
mediaElements.push(el);
|
|
3510
|
+
} catch {
|
|
3511
|
+
mediaElements.push(el);
|
|
3512
|
+
}
|
|
3171
3513
|
}
|
|
3172
3514
|
break;
|
|
3515
|
+
}
|
|
3173
3516
|
case "at":
|
|
3174
3517
|
if (contact.scene === "friend" || contact.scene === "direct") break;
|
|
3175
3518
|
if (el.targetId === "all") lines.push("<qqbot-at-everyone />");
|
|
@@ -3339,7 +3682,7 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3339
3682
|
let rfc3339 = "";
|
|
3340
3683
|
if (duration > 0) {
|
|
3341
3684
|
let seconds = duration;
|
|
3342
|
-
if (seconds >
|
|
3685
|
+
if (seconds > 2592e3) {
|
|
3343
3686
|
this.logger("warn", `[setGroupMute] 禁言时长 ${seconds}s 超出平台上限 30 天,已截断为 ${MAX_GROUP_MUTE_SECONDS}s`);
|
|
3344
3687
|
seconds = MAX_GROUP_MUTE_SECONDS;
|
|
3345
3688
|
}
|
|
@@ -5408,7 +5751,7 @@ const createBot = async (input) => {
|
|
|
5408
5751
|
client.account.name = me.username;
|
|
5409
5752
|
client.account.avatar = me.avatar;
|
|
5410
5753
|
client.account.selfId = appId;
|
|
5411
|
-
const qq = new URL(me.share_url).searchParams.get("robot_uin");
|
|
5754
|
+
const qq = new URL$1(me.share_url).searchParams.get("robot_uin");
|
|
5412
5755
|
client.account.subId.id = me.id;
|
|
5413
5756
|
client.account.subId.qq = qq;
|
|
5414
5757
|
client.account.subId.appid = appId;
|