@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/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 random, n as fakeEvent, r as getImageSize, t as QQAPIBASEURL } from "./utils-QpIUJhxK.js";
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 = INPUT_NOTIFY_DEFAULT_SECOND, passive) {
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
- const FILE_TYPE = {
762
- image: 1,
763
- video: 2,
764
- record: 3,
765
- file: 4
766
- };
767
- /** 各类资源的 QQ 上传上限。 */
768
- const MAX_UPLOAD_SIZE = {
769
- image: 30 * 1024 * 1024,
770
- video: 100 * 1024 * 1024,
771
- record: 20 * 1024 * 1024,
772
- file: 100 * 1024 * 1024
773
- };
774
- /** 上传错误中展示的资源类型名称。 */
775
- const MEDIA_TYPE_LABEL = {
776
- image: "图片",
777
- video: "视频",
778
- record: "语音",
779
- file: "文件"
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
- /** fallback 上传超过该大小时走 QQ 分片上传。 */
782
- const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
783
- /** QQ upload_prepare 需要的前 10,002,432 字节 MD5。 */
784
- const MD5_10M_SIZE = 10002432;
785
- /** 分片上传时没有业务文件名的默认值。 */
786
- const DEFAULT_FILENAME$1 = {
787
- image: "image.jpg",
788
- video: "video.mp4",
789
- record: "record.mp3",
790
- file: "file.bin"
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$1[type];
870
- return path.basename(value) || DEFAULT_FILENAME$1[type];
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$2.test(value)) return { url: value };
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$2.test(value)) {
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 > MD5_10M_SIZE ? crypto.createHash("md5").update(source.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
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 <= PART_UPLOAD_RETRIES; attempt++) try {
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 < PART_UPLOAD_RETRIES) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
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 >= LARGE_UPLOAD_THRESHOLD) {
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 <= PART_FINISH_RETRIES; attempt++) try {
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 < PART_FINISH_RETRIES) await sleep(PART_FINISH_BASE_DELAY * 2 ** 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 <= COMPLETE_UPLOAD_RETRIES; attempt++) try {
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 < COMPLETE_UPLOAD_RETRIES) await sleep(COMPLETE_UPLOAD_BASE_DELAY * 2 ** 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) === UPLOAD_PREPARE_DAILY_LIMIT_CODE) throw new Error(`QQ 大文件上传额度已达上限,${MEDIA_TYPE_LABEL[type]}暂时无法通过 fallback 直传:${source.fileName} (${formatBytes(source.size)})`);
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 || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
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
- /** 构造 msg_type=7 富媒体消息体 */
1747
- media(fileInfo) {
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
- const HTTP_URL_RE$1 = /^https?:\/\//i;
2116
- /** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
2117
- const DEFAULT_FILENAME = {
2118
- image: "image.jpg",
2119
- video: "video.mp4",
2120
- record: "record.mp3",
2121
- file: "file.bin"
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$1.test(source);
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
- if (isHttpMediaSource(source)) return {
2148
- source,
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: (await fileToUrl(type, source, filename || DEFAULT_FILENAME[type])).url,
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 `![${desc} #${resolved.width}px #${resolved.height}px](${url})`;
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
- const { width, height } = await getImageSize(file);
2179
- return `![karin #${width}px #${height}px](${file})`;
2180
- }
2181
- const { url, width, height } = await fileToUrl("image", file, "image.jpg");
2182
- return `![karin #${width}px #${height}px](${url})`;
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 source = (match[1] || "").trim().replace(/^<|>$/g, "");
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
- if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
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
- items.push(ctx.super.qq.media(res.file_info));
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
- items.push(ctx.super.qq.media(res.file_info));
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 keyboard = buildKeyboard$1(grouping);
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
- items.push(ctx.super.qq.media(res.file_info));
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
- await appendImageUrlLine(lines, resolved.source);
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
- try {
2381
- const [line] = await imagesToMarkdown([url]);
2382
- lines.push(line);
2383
- } catch {
2384
- lines.push(`![karin #300px #300px](${url})`);
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, KEYBOARD_MAX_ROWS$1)) {
2436
- const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW$1).map((button) => normalizeQQBotButton(button, id++));
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 ? C2C_PASSIVE_REPLY_LIMIT : Infinity;
2476
- if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${C2C_PASSIVE_REPLY_LIMIT} 条,已跳过 ${items.length - maxItems} 条`);
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 { url } = await fileToUrl("image", file, "image.jpg");
2614
- allImages.push(url);
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)) if (part.type === "text") {
2621
- const text = part.value.trim();
2622
- if (text) lines.push(text);
2623
- } else {
2624
- const [mdImage] = await imagesToMarkdown([part.source]);
2625
- lines.push(mdImage);
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") : BUTTON_ONLY_MARKDOWN;
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
- for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
2660
- const text = part.value.trim();
2661
- if (text) lines.push(text);
2662
- } else {
2663
- const [mdImage] = await imagesToMarkdown([part.source]);
2664
- lines.push(mdImage);
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") : BUTTON_ONLY_MARKDOWN;
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, KEYBOARD_MAX_ROWS)) {
2693
- const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW).map((button) => normalizeQQBotButton(button, id++));
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/base.ts
3312
+ //#region src/core/adapter/config-store.ts
3313
+ /**
3314
+ * 适配器实例的真实配置存储。
3315
+ *
3316
+ * 配置挂在 WeakMap 而不是实例字段上:系统接口序列化整个适配器时不会把
3317
+ * appSecret 等敏感配置带出去。
3318
+ */
3015
3319
  const adapterConfigStore = /* @__PURE__ */ new WeakMap();
3016
- /** HTTP(S) URL 可直接交给 QQ 平台拉取上传 */
3017
- const HTTP_URL_RE = /^https?:\/\//i;
3018
- /** 平台限制的最大禁言时长:30 天 */
3019
- const MAX_GROUP_MUTE_SECONDS = 720 * 60 * 60;
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
- adapterConfigStore.set(this, cfg);
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
- const cfg = adapterConfigStore.get(this);
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, elements) : void 0;
3067
- const result = await sendGuild(this, contact, prepared?.sendElements ?? elements);
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, elements) : void 0;
3073
- const result = await sendQQ(this, contact, prepared?.sendElements ?? elements);
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
- if (el.file.startsWith("http")) try {
3161
- const { width, height } = await getImageSize(el.file);
3162
- lines.push(`![${el.name || "图片"} #${width}px #${height}px](${el.file})`);
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(`![${el.name || "图片"} #${width}px #${height}px](${imageSource})`);
3163
3484
  } catch {
3164
- lines.push(`![${el.name || "图片"} #300px #300px](${el.file})`);
3485
+ lines.push(`![${el.name || "图片"} #300px #300px](${imageSource})`);
3165
3486
  }
3166
- else try {
3167
- const { url, width, height } = await fileToUrl("image", el.file, el.name || "image.jpg");
3168
- lines.push(`![${el.name || "图片"} #${width}px #${height}px](${url})`);
3169
- } catch {
3170
- lines.push(`![${el.name || "图片"} #300px #300px]()`);
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(`![${el.name || "图片"} #${result.width || 300}px #${result.height || 300}px](${result.url})`);
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 > MAX_GROUP_MUTE_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;