@karinjs/adapter-qqbot 3.1.0 → 3.3.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
- import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-D1_LqrcJ.js";
1
+ import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-BgCU6apn.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";
4
- 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";
3
+ import { a as DATA_URL_BASE64_RE, i as BASE64_PREFIX_RE, n as getImageSize, o as HTTP_URL_RE, r as random, s as QQ_NUMBER_RE, t as fakeEvent } from "./common-DwVsAvIo.js";
4
+ import "./utils-u17tvC2U.js";
5
+ import { t as getMessageStore } from "./message-CydR-wrX.js";
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
  */
@@ -1362,10 +1460,12 @@ var MetaApi = class extends Http {
1362
1460
  //#endregion
1363
1461
  //#region src/core/api/groups.ts
1364
1462
  /**
1365
- * QQ 群信息接口
1463
+ * QQ 群信息与群成员管理接口
1366
1464
  *
1367
1465
  * 注意:以下接口均为白名单接口,未开通权限时平台返回错误码 11253,
1368
- * 需要向平台运营申请后可用。
1466
+ * 需要向平台运营申请后可用。接口文档 v1.28.0(2026-09-03)新增的群成员管理
1467
+ * 接口(成员列表 / 批量移除 / 黑名单)官方标注为「正在内邀接入中」,
1468
+ * 开放范围可能比其它白名单接口更窄。
1369
1469
  */
1370
1470
  var GroupsApi = class extends Http {
1371
1471
  /**
@@ -1383,8 +1483,25 @@ var GroupsApi = class extends Http {
1383
1483
  return this.get(`/v2/groups/${groupOpenid}/bot_state`);
1384
1484
  }
1385
1485
  /**
1486
+ * 获取群成员列表
1487
+ *
1488
+ * 每次最多返回 30 条,只支持 cursor 翻页:首次不传 `cursor`,之后回传上一次
1489
+ * 响应的 `next_cursor`,`next_cursor` 为空串表示已到末页。
1490
+ *
1491
+ * @param groupOpenid 群 openid
1492
+ * @param params 分页参数
1493
+ */
1494
+ getGroupMemberList(groupOpenid, params = {}) {
1495
+ const query = new URLSearchParams();
1496
+ if (params.cursor) query.set("cursor", params.cursor);
1497
+ const search = query.toString();
1498
+ return this.get(`/v2/groups/${groupOpenid}/members${search ? `?${search}` : ""}`);
1499
+ }
1500
+ /**
1386
1501
  * 获取群成员详情
1387
1502
  *
1503
+ * 昵称字段为 `username`;`nick` 是该接口转为公开文档之前的字段名,仅作兼容。
1504
+ *
1388
1505
  * @param groupOpenid 群 openid
1389
1506
  * @param memberOpenid 群成员 openid
1390
1507
  */
@@ -1392,9 +1509,48 @@ var GroupsApi = class extends Http {
1392
1509
  return this.get(`/v2/groups/${groupOpenid}/members/${memberOpenid}`);
1393
1510
  }
1394
1511
  /**
1512
+ * 群成员批量移除
1513
+ *
1514
+ * 单次最多 20 个成员,可通过 `add_to_member_blacklist` 在移除的同时拉黑。
1515
+ * 机器人需拥有群管理员身份。
1516
+ *
1517
+ * @param groupOpenid 群 openid
1518
+ * @param body 待移除的成员列表与是否同时拉黑
1519
+ */
1520
+ batchRemoveGroupMembers(groupOpenid, body) {
1521
+ return this.post(`/v2/groups/${groupOpenid}/batch_remove_members`, body);
1522
+ }
1523
+ /**
1524
+ * 查询群黑名单
1525
+ *
1526
+ * @param groupOpenid 群 openid
1527
+ * @param params 分页参数,`limit` 默认 20、最大 100
1528
+ */
1529
+ getGroupMemberBlacklist(groupOpenid, params = {}) {
1530
+ const query = new URLSearchParams();
1531
+ if (params.cursor) query.set("cursor", params.cursor);
1532
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
1533
+ const search = query.toString();
1534
+ return this.get(`/v2/groups/${groupOpenid}/member_blacklist${search ? `?${search}` : ""}`);
1535
+ }
1536
+ /**
1537
+ * 群黑名单操作
1538
+ *
1539
+ * 只有目标用户不在群中时才能加入黑名单,需要先移除再拉黑;单次最多 20 个。
1540
+ * 返回的 `fail_openids` 在 `op=add` / `op=del` 下同义,都是操作失败的 openid。
1541
+ *
1542
+ * @param groupOpenid 群 openid
1543
+ * @param body 操作类型与目标成员列表
1544
+ */
1545
+ setGroupMemberBlacklist(groupOpenid, body) {
1546
+ return this.post(`/v2/groups/${groupOpenid}/member_blacklist`, body);
1547
+ }
1548
+ /**
1395
1549
  * 设置群成员禁言
1396
1550
  *
1397
- * 机器人需拥有群管理员身份,平台限制最大禁言时长为 30 天。
1551
+ * 机器人需拥有群管理员身份,平台限制最大禁言时长为 30 天;
1552
+ * 只能操作普通成员,不能操作群主、管理员与机器人。
1553
+ * 需要一次设置多个成员时用 {@link GroupsApi.setGroupMuteBatch}。
1398
1554
  *
1399
1555
  * @param groupOpenid 群 openid
1400
1556
  * @param memberOpenid 群成员 openid
@@ -1402,11 +1558,24 @@ var GroupsApi = class extends Http {
1402
1558
  * @param duration 禁言时间使用RFC3339 格式
1403
1559
  */
1404
1560
  setGroupMute(groupOpenid, memberOpenid, op, duration) {
1405
- return this.post(`/v2/groups/${groupOpenid}/restrict_chat_setting`, { members: [{
1561
+ return this.setGroupMuteBatch(groupOpenid, [{
1406
1562
  op,
1407
1563
  member_openid: memberOpenid,
1408
1564
  mute_expire_at: duration
1409
- }] });
1565
+ }]);
1566
+ }
1567
+ /**
1568
+ * 批量设置群成员禁言
1569
+ *
1570
+ * 每项通过自身的 `op` 控制增 / 改 / 删,可以在一次请求里混用。
1571
+ * 平台限制单次最多 20 个成员(接口文档 v1.28.0 由 10 提升至 20),
1572
+ * 超出时平台会直接拒绝整个请求。
1573
+ *
1574
+ * @param groupOpenid 群 openid
1575
+ * @param members 成员禁言设置列表
1576
+ */
1577
+ setGroupMuteBatch(groupOpenid, members) {
1578
+ return this.post(`/v2/groups/${groupOpenid}/restrict_chat_setting`, { members });
1410
1579
  }
1411
1580
  /**
1412
1581
  * 设置群成员禁言
@@ -1432,12 +1601,13 @@ var GroupsApi = class extends Http {
1432
1601
  * 机器人需拥有群管理员身份。
1433
1602
  *
1434
1603
  * @param groupOpenid 群 openid
1435
- * @param params 分页参数,`limit` 默认 20、最大 100
1604
+ * @param params 分页参数,`limit` 默认 20、最大 50(接口文档 v1.28.0 由 100 下调),
1605
+ * 传入更大的值会被收敛到 50,避免平台直接拒绝
1436
1606
  */
1437
1607
  getJoinRequestList(groupOpenid, params = {}) {
1438
1608
  const query = new URLSearchParams();
1439
1609
  if (params.cursor) query.set("cursor", params.cursor);
1440
- if (params.limit !== void 0) query.set("limit", String(params.limit));
1610
+ if (params.limit !== void 0) query.set("limit", String(Math.min(params.limit, 50)));
1441
1611
  const search = query.toString();
1442
1612
  return this.get(`/v2/groups/${groupOpenid}/join_request_list${search ? `?${search}` : ""}`);
1443
1613
  }
@@ -1660,12 +1830,13 @@ var JoinApprovalApi = class extends Http {
1660
1830
  * 查询入群自动审批策略列表
1661
1831
  *
1662
1832
  * 按创建时间倒序,支持分页。
1663
- * @param params 分页参数,`limit` 默认 20、最大 100
1833
+ * @param params 分页参数,`limit` 默认 20、最大 50(接口文档 v1.28.0 由 100 下调),
1834
+ * 传入更大的值会被收敛到 50,避免平台直接拒绝
1664
1835
  */
1665
1836
  getStrategyList(params = {}) {
1666
1837
  const query = new URLSearchParams();
1667
1838
  if (params.cursor) query.set("cursor", params.cursor);
1668
- if (params.limit !== void 0) query.set("limit", String(params.limit));
1839
+ if (params.limit !== void 0) query.set("limit", String(Math.min(params.limit, 50)));
1669
1840
  const search = query.toString();
1670
1841
  return this.get(`/v2/groups/join_approval_strategy${search ? `?${search}` : ""}`);
1671
1842
  }
@@ -1743,11 +1914,17 @@ const buildQQMsg = {
1743
1914
  ark
1744
1915
  };
1745
1916
  },
1746
- /** 构造 msg_type=7 富媒体消息体 */
1747
- media(fileInfo) {
1917
+ /**
1918
+ * 构造 msg_type=7 富媒体消息体
1919
+ *
1920
+ * @param fileInfo 上传富媒体返回的 file_info。
1921
+ * @param keyboard 可选内嵌键盘,用于按钮随富媒体一起发送。
1922
+ */
1923
+ media(fileInfo, keyboard) {
1748
1924
  return {
1749
1925
  msg_type: 7,
1750
- media: { file_info: fileInfo }
1926
+ media: { file_info: fileInfo },
1927
+ keyboard
1751
1928
  };
1752
1929
  }
1753
1930
  };
@@ -1799,7 +1976,7 @@ const buildGuildMsg = {
1799
1976
  * - media:富媒体上传
1800
1977
  * - interaction:按钮回调 ack
1801
1978
  * - meta:@me / dms / gateway / 分享链接
1802
- * - groups:群基础信息 / 机器人群内状态 / 禁言 / 入群申请(部分为白名单接口)
1979
+ * - groups:群基础信息 / 机器人群内状态 / 群成员列表与详情 / 禁言 / 批量移除 / 群黑名单 / 入群申请(白名单接口)
1803
1980
  * - guilds:频道与子频道的查询和增删改
1804
1981
  * - menu:全局自定义菜单(仅单聊)
1805
1982
  * - panels:指令面板
@@ -1989,6 +2166,9 @@ const collectCommandEnterButtons = (buttons, keyboards) => {
1989
2166
  /**
1990
2167
  * 归一 QQ keyboard 按钮字段。
1991
2168
  *
2169
+ * 只重写键盘内唯一的按钮序号,其余字段(含 v1.28.0 新增的 `group_id`、
2170
+ * `action.modal`)原样透传。
2171
+ *
1992
2172
  * @param button node-karin 转换后的 QQBot 按钮。
1993
2173
  * @param id 当前 keyboard 内唯一按钮序号。
1994
2174
  * @returns 可直接放入 QQ 官方 keyboard 的按钮对象。
@@ -2112,13 +2292,46 @@ const groupElements = (scene, elements) => {
2112
2292
  //#endregion
2113
2293
  //#region src/core/adapter/media-source.ts
2114
2294
  /** 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"
2295
+ /**
2296
+ * 把带 MIME 头的完整 data URL(`data:image/png;base64,...`)归一为 Karin 标准
2297
+ * 的 `base64://` 形式,其余来源原样返回。
2298
+ *
2299
+ * 下游插件经常直接给出完整 data URL;QQ 上传层认识它,但 karin 的
2300
+ * `common.buffer` 和不少第三方 fileToUrl 处理器只认 `base64://`,因此在进入
2301
+ * 发送链路时统一归一,避免同一资源在不同环节表现不一致。
2302
+ *
2303
+ * @param source Karin 消息段里的 file 字段。
2304
+ * @returns 归一后的来源字符串。
2305
+ */
2306
+ const normalizeMediaSource = (source) => {
2307
+ return DATA_URL_BASE64_RE.test(source) ? source.replace(DATA_URL_BASE64_RE, "base64://") : source;
2308
+ };
2309
+ /** 带 file 字段的富媒体消息段类型。 */
2310
+ const MEDIA_ELEMENT_TYPES = /* @__PURE__ */ new Set([
2311
+ "image",
2312
+ "video",
2313
+ "record",
2314
+ "file"
2315
+ ]);
2316
+ /**
2317
+ * 把消息段里带 MIME 头的完整 data URL(`data:image/png;base64,...`)统一归一为
2318
+ * Karin 标准的 `base64://` 形式。下游的 fileToUrl 处理器与 karin `common.buffer`
2319
+ * 大多只认后者,在进入发送链路前归一可以保证各环节行为一致。
2320
+ *
2321
+ * @param elements 待发送的消息段。
2322
+ * @returns 归一后的消息段;无需归一时返回原数组。
2323
+ */
2324
+ const normalizeMediaElements = (elements) => {
2325
+ return elements.map((el) => {
2326
+ if (!MEDIA_ELEMENT_TYPES.has(el.type)) return el;
2327
+ const file = el.file;
2328
+ if (typeof file !== "string") return el;
2329
+ const normalized = normalizeMediaSource(file);
2330
+ return normalized === file ? el : {
2331
+ ...el,
2332
+ file: normalized
2333
+ };
2334
+ });
2122
2335
  };
2123
2336
  /**
2124
2337
  * 当前 Karin 运行时是否注册了 fileToUrl 处理器。
@@ -2130,37 +2343,65 @@ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
2130
2343
  * @param source Karin 消息段里的 file 字段。
2131
2344
  * @returns 是否为 HTTP(S) URL。
2132
2345
  */
2133
- const isHttpMediaSource = (source) => HTTP_URL_RE$1.test(source);
2346
+ const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
2134
2347
  /**
2135
2348
  * 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
2136
2349
  *
2137
2350
  * QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
2138
2351
  * fileToUrl 作为主路径,QQ 上传只做兜底。
2139
2352
  *
2140
- * @param ctx 适配器实例,用于记录降级日志。
2353
+ * @param ctx 适配器实例,用于记录降级日志并标明执行发送的 bot。
2141
2354
  * @param type 富媒体类型。
2142
2355
  * @param source Karin 消息段里的 file 字段。
2143
2356
  * @param filename 可选文件名,主要用于 file 消息。
2357
+ * @param origin 本次发送归属的上传会话,供需要借 QQ 通道上传的处理器使用。
2358
+ * @param purpose 拿到 URL 之后的用途,默认按富媒体上传处理。
2144
2359
  * @returns 解析后的首选来源和来源类型。
2145
2360
  */
2146
- const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
2147
- if (isHttpMediaSource(source)) return {
2148
- source,
2361
+ const resolvePreferredMediaSource = async (ctx, type, source, filename, origin, purpose = "media") => {
2362
+ const normalized = normalizeMediaSource(source);
2363
+ if (isHttpMediaSource(normalized)) return {
2364
+ source: normalized,
2149
2365
  via: "public-url"
2150
2366
  };
2151
2367
  if (!hasFileToUrlHandler()) return {
2152
- source,
2368
+ source: normalized,
2153
2369
  via: "fallback"
2154
2370
  };
2155
2371
  try {
2372
+ const extra = {
2373
+ selfId: ctx.selfId,
2374
+ purpose,
2375
+ origin
2376
+ };
2377
+ const result = await fileToUrl(type, normalized, filename || DEFAULT_FILENAME[type], extra);
2378
+ /**
2379
+ * `handler.has` 只判断该 key 下是否注册过处理器,不代表真的有处理器接手:
2380
+ * 所有处理器都调用 next() 放行时,fileToUrl 会返回 undefined。此时不属于
2381
+ * 异常,安静地走 QQ 上传兜底即可,不必输出 warn 噪音。
2382
+ */
2383
+ if (!result?.url) {
2384
+ ctx.logger("debug", `[sendQQ] 没有 fileToUrl 处理器接手 ${type},改用 QQ 上传兜底`);
2385
+ return {
2386
+ source: normalized,
2387
+ via: "fallback"
2388
+ };
2389
+ }
2390
+ /** 只有 image 会带宽高,其余类型按声明就没有这两个字段。 */
2391
+ const { width, height } = result;
2392
+ const size = typeof width === "number" && typeof height === "number" ? {
2393
+ width,
2394
+ height
2395
+ } : void 0;
2156
2396
  return {
2157
- source: (await fileToUrl(type, source, filename || DEFAULT_FILENAME[type])).url,
2158
- via: "file-to-url"
2397
+ source: result.url,
2398
+ via: "file-to-url",
2399
+ size
2159
2400
  };
2160
2401
  } catch (err) {
2161
2402
  ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
2162
2403
  return {
2163
- source,
2404
+ source: normalized,
2164
2405
  via: "fallback"
2165
2406
  };
2166
2407
  }
@@ -2168,21 +2409,105 @@ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
2168
2409
 
2169
2410
  //#endregion
2170
2411
  //#region src/core/adapter/text-to-md.ts
2171
- const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
2412
+ const MARKDOWN_IMAGE_RE = /!\[([^\]]*)]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
2413
+ /**
2414
+ * QQ markdown 图片语法里的显式尺寸:`#100px #100px`。
2415
+ *
2416
+ * `#` 是必需的——`100px 100px` 这种省略写法 QQ 前端不会按尺寸渲染,只能算
2417
+ * 普通描述文本。QQ 客户端按这个声明尺寸渲染而不是图片的物理分辨率——开发者
2418
+ * 显式写出尺寸是有布局意图的(比如把一张高清图当头像展示),重建时必须原样
2419
+ * 保留。
2420
+ */
2421
+ const EXPLICIT_IMAGE_SIZE_RE = /#(\d+)px\s+#(\d+)px/;
2422
+ /**
2423
+ * 调用 fileToUrl 并确认真的拿到了 URL。
2424
+ *
2425
+ * Karin 的 handler 链允许每个处理器调用 next() 放行,全部放行时 fileToUrl 会
2426
+ * 返回 undefined 而不是抛错。这里统一转成明确的错误信息,避免调用方拿到
2427
+ * undefined 后在解构处报出难以定位的 TypeError。
2428
+ *
2429
+ * @param source 图片来源。
2430
+ * @param extra 传给处理器的附加信息,至少标明执行发送的 bot。
2431
+ * @returns fileToUrl 的图片结果。
2432
+ */
2433
+ const imageToUrl = async (source, extra) => {
2434
+ const result = await fileToUrl("image", normalizeMediaSource(source), "image.jpg", extra);
2435
+ if (!result?.url) throw new Error("[Handler][Error]: 没有 fileToUrl 处理器接手图片转 URL");
2436
+ return result;
2437
+ };
2438
+ /**
2439
+ * 生成一行 markdown 图片。
2440
+ *
2441
+ * alt 里带显式尺寸(`#100px #100px`)时原样保留,不探测图片真实尺寸——QQ 按
2442
+ * 声明尺寸渲染,覆盖它会破坏开发者的布局意图。没有显式尺寸时才用传入的或
2443
+ * 探测到的真实宽高补全。
2444
+ *
2445
+ * @param url 图片的最终公网 URL。
2446
+ * @param alt 原始 alt 文本,可能包含显式尺寸声明。
2447
+ * @param size 已知的图片真实尺寸;未提供且 alt 无显式尺寸时探测 URL。
2448
+ * @returns markdown 图片行。
2449
+ */
2450
+ const buildMarkdownImageLine = async (url, alt = "", size) => {
2451
+ const explicit = alt.match(EXPLICIT_IMAGE_SIZE_RE);
2452
+ if (explicit) return `![${alt.replace(EXPLICIT_IMAGE_SIZE_RE, "").trim()} #${explicit[1]}px #${explicit[2]}px](${url})`;
2453
+ const desc = alt.trim() || "karin";
2454
+ const resolved = size ?? await getImageSize(url).catch(() => ({
2455
+ width: 300,
2456
+ height: 300
2457
+ }));
2458
+ return `![${desc} #${resolved.width}px #${resolved.height}px](${url})`;
2459
+ };
2460
+ /**
2461
+ * 生成一行"来源未解析"的 markdown 图片。
2462
+ *
2463
+ * 用于适配器自己拼 markdown 正文的场景(如合并转发):只把原始来源写进图片语法,
2464
+ * 具体走内置图床、第三方 fileToUrl 还是降级成富媒体,留给发送 pipeline 在
2465
+ * `splitMarkdownImages` 之后统一决定,避免在 pipeline 之外重复一套上传逻辑。
2466
+ *
2467
+ * 本地路径可能带空格、`)` 等会截断图片语法的字符,因此来源统一用尖括号包住——
2468
+ * `splitMarkdownImages` 取出时会剥掉。
2469
+ *
2470
+ * @param source 原始来源:本地路径、`base64://` 或 URL。
2471
+ * @param alt 图片描述;`]` 与换行会截断语法,统一压成空格。
2472
+ * @param size 已知的真实宽高,写成显式尺寸可省掉 pipeline 的一次探测。
2473
+ * @returns markdown 图片行,来源仍是原始 file 字段。
2474
+ */
2475
+ const buildPendingMarkdownImageLine = (source, alt = "", size) => {
2476
+ return `![${alt.replace(/[[\]\r\n]/g, " ").trim() || "karin"}${size?.width && size?.height ? ` #${size.width}px #${size.height}px` : ""}](<${normalizeMediaSource(source)}>)`;
2477
+ };
2172
2478
  /**
2173
2479
  * 将图片列表合并到一段 markdown 文本内(每张图占一行)
2480
+ *
2481
+ * @param urls 图片来源列表。
2482
+ * @param extra 传给 fileToUrl 处理器的附加信息。
2483
+ * @returns markdown 图片行。
2174
2484
  */
2175
- const imagesToMarkdown = async (urls) => {
2485
+ const imagesToMarkdown = async (urls, extra) => {
2176
2486
  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})`;
2487
+ if (file.startsWith("http")) return buildMarkdownImageLine(file);
2488
+ const { url, width, height } = await imageToUrl(file, extra);
2489
+ return buildMarkdownImageLine(url, "", {
2490
+ width,
2491
+ height
2492
+ });
2183
2493
  }));
2184
2494
  };
2185
2495
  /**
2496
+ * 把一个拆分出来的 markdown 图片转成图片行,保留原始 alt 与显式尺寸。
2497
+ *
2498
+ * @param part splitMarkdownImages 拆出的图片片段。
2499
+ * @param extra 传给 fileToUrl 处理器的附加信息。
2500
+ * @returns markdown 图片行。
2501
+ */
2502
+ const markdownPartToLine = async (part, extra) => {
2503
+ if (part.source.startsWith("http")) return buildMarkdownImageLine(part.source, part.alt);
2504
+ const { url, width, height } = await imageToUrl(part.source, extra);
2505
+ return buildMarkdownImageLine(url, part.alt, {
2506
+ width,
2507
+ height
2508
+ });
2509
+ };
2510
+ /**
2186
2511
  * 将 markdown 文本拆为普通文本片段与图片来源。
2187
2512
  */
2188
2513
  const splitMarkdownImages = (markdown) => {
@@ -2197,10 +2522,12 @@ const splitMarkdownImages = (markdown) => {
2197
2522
  value: text
2198
2523
  });
2199
2524
  }
2200
- const source = (match[1] || "").trim().replace(/^<|>$/g, "");
2525
+ const alt = match[1] ?? "";
2526
+ const source = (match[2] || "").trim().replace(/^<|>$/g, "");
2201
2527
  if (source) parts.push({
2202
2528
  type: "image",
2203
- source
2529
+ source: normalizeMediaSource(source),
2530
+ alt
2204
2531
  });
2205
2532
  lastIndex = index + match[0].length;
2206
2533
  }
@@ -2219,115 +2546,96 @@ const splitMarkdownImages = (markdown) => {
2219
2546
 
2220
2547
  //#endregion
2221
2548
  //#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
2549
  /**
2242
2550
  * 处理 QQ 场景(单聊 + 群聊)的发送
2551
+ *
2552
+ * QQ 单聊 / 群聊的自定义 Markdown 自 2026-04-23 起已开放给所有机器人,无需申请
2553
+ * (见 `api-docs/server-inter/message/type/markdown.md`),所以这里只保留一条发送通道:
2554
+ * text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
2555
+ * 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 紧随主消息补发。所有
2556
+ * 富媒体都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
2557
+ * 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
2243
2558
  */
2244
2559
  const sendQQ = async (ctx, contact, elements) => {
2560
+ assertOpenIdPeer(contact);
2245
2561
  const target = contact.scene === "friend" ? "user" : "group";
2246
2562
  const grouping = groupElements(contact.scene, elements);
2247
2563
  await resolveOutgoingReferenceQQ(ctx, contact, grouping);
2248
- /**
2249
- * 自动 Markdown 通道:
2250
- * text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
2251
- * 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 单独补发。所有富媒体
2252
- * 都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
2253
- * 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
2254
- * 显式 segment.markdown 不受该自动通道开关影响。
2255
- */
2256
- if (ctx.cfg.markdown.enable) return sendQQMarkdown(ctx, contact, grouping, target);
2257
- return sendQQClassic(ctx, contact, grouping, target);
2564
+ return sendQQMarkdown(ctx, contact, grouping, target);
2565
+ };
2566
+ /**
2567
+ * QQ 官方接口只接受 openid(32 位十六进制或 UUID 形态)寻址。纯数字 peer 一定是
2568
+ * QQ 号/群号,继续走下去只会在媒体上传或消息下发时被平台以 40011028
2569
+ * 「请求的资源不存在」拒绝,且平台错误信息没有指向性(上游常见场景:把 karin
2570
+ * 主人列表里的 QQ 号直接当私聊目标)。这里提前拦截,给出可操作的说明。
2571
+ */
2572
+ const assertOpenIdPeer = (contact) => {
2573
+ if (!QQ_NUMBER_RE.test(contact.peer)) return;
2574
+ const sceneLabel = contact.scene === "friend" ? "私聊" : "群聊";
2575
+ throw new Error(`[sendQQ] ${sceneLabel} peer 不是有效 openid: ${contact.peer}。QQ 官方 Bot API 不使用 QQ 号/群号寻址,请让对方先给机器人发一条消息,从事件中获取其 openid 后填入配置`);
2258
2576
  };
2259
2577
  /**
2260
2578
  * Markdown 通道:合成一条 msg_type=2 + 可选 keyboard
2261
2579
  */
2262
2580
  const sendQQMarkdown = async (ctx, contact, grouping, target) => {
2263
2581
  const items = [];
2582
+ /** 上传归属会话固定为本次发送目标,保证 openid 与执行发送的 bot 同域。 */
2583
+ const origin = {
2584
+ scene: target,
2585
+ peer: contact.peer
2586
+ };
2264
2587
  const lines = [];
2265
2588
  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);
2589
+ const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages, origin);
2590
+ const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping, origin);
2268
2591
  fallbackImages.push(...markdownFallbackImages);
2269
2592
  if (contact.scene === "group") {
2270
2593
  warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2271
2594
  warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
2272
2595
  }
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
- }
2596
+ const mediaItems = [];
2279
2597
  for (const file of fallbackImages) {
2280
2598
  const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
2281
- items.push(ctx.super.qq.media(res.file_info));
2599
+ mediaItems.push(ctx.super.qq.media(res.file_info));
2282
2600
  }
2283
2601
  for (const m of grouping.media) {
2284
- const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name);
2602
+ const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name, origin);
2285
2603
  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));
2604
+ mediaItems.push(ctx.super.qq.media(res.file_info));
2287
2605
  }
2606
+ if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
2607
+ else if (lines.length) items.push(ctx.super.qq.markdown({ content: lines.join("\n") }, buildKeyboard$1(grouping)));
2608
+ else attachKeyboardWithoutMarkdown(ctx, grouping, mediaItems, items);
2609
+ items.push(...mediaItems);
2288
2610
  if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
2289
2611
  return flushQQ(ctx, contact, grouping, items);
2290
2612
  };
2291
2613
  /**
2292
- * 经典通道:文本走 msg_type=0,图片 / 富媒体走 msg_type=7。
2293
- */
2294
- const sendQQClassic = async (ctx, contact, grouping, target) => {
2295
- const items = [];
2296
- const lines = [];
2297
- const images = [...grouping.qqImages];
2298
- if (grouping.text.length) lines.push(grouping.text.join(""));
2299
- if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
2300
- const content = lines.join("\n").trim();
2301
- if (content) items.push(ctx.super.qq.text(content));
2302
- for (const image of images) items.push(await buildQQMediaItem(ctx, target, contact.peer, "image", image));
2303
- for (const m of grouping.media) items.push(await buildQQMediaItem(ctx, target, contact.peer, m.kind, m.source, m.name));
2304
- await appendExplicitMarkdownItems(ctx, contact, grouping, target, items);
2305
- if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
2306
- return flushQQ(ctx, contact, grouping, items);
2307
- };
2308
- /**
2309
- * 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
2614
+ * 处理「没有任何 markdown 正文,但存在按钮」的边缘情况。
2615
+ *
2616
+ * 典型触发路径:下游只发一张 markdown 语法图片 + 若干按钮,而这张图片没能通过
2617
+ * `fileToUrl` 取到公网地址,被降级成 msg_type=7 富媒体。此时 markdown 正文为空,
2618
+ * 旧实现会补一个零宽字符当正文,把按钮单独发成一条消息,客户端就会看到
2619
+ * 「一条空的按钮消息 + 一条富媒体消息」两条消息。
2620
+ *
2621
+ * 这里改为把 keyboard 挂到第一条富媒体上,合并成一条消息;只有在连富媒体都没有时,
2622
+ * 才保留零宽字符 markdown 兜底(纯按钮消息是正常用法,不能丢)。
2623
+ *
2624
+ * @param ctx 适配器实例,用于输出日志。
2625
+ * @param grouping 已归类的消息段。
2626
+ * @param mediaItems 本次待发送的富媒体消息体,keyboard 会挂到第一条上。
2627
+ * @param items 最终发送队列,仅在没有富媒体时追加纯按钮 markdown。
2310
2628
  */
2311
- const appendExplicitMarkdownItems = async (ctx, contact, grouping, target, items) => {
2312
- if (!grouping.markdowns.length) return;
2313
- const lines = [];
2314
- const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
2315
- if (contact.scene === "group") {
2316
- warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2317
- warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
2318
- }
2629
+ const attachKeyboardWithoutMarkdown = (ctx, grouping, mediaItems, items) => {
2319
2630
  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));
2322
- for (const file of fallbackImages) {
2323
- const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
2324
- items.push(ctx.super.qq.media(res.file_info));
2631
+ if (!keyboard) return;
2632
+ const [first] = mediaItems;
2633
+ if (!first) {
2634
+ items.push(ctx.super.qq.markdown({ content: "​" }, keyboard));
2635
+ return;
2325
2636
  }
2326
- };
2327
- const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
2328
- const resolved = await resolvePreferredMediaSource(ctx, type, source, name);
2329
- 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
- return ctx.super.qq.media(res.file_info);
2637
+ first.keyboard = keyboard;
2638
+ ctx.logger("debug", "图片未能进入 markdown 通道,按钮已随富媒体一起发送,避免拆成两条消息");
2331
2639
  };
2332
2640
  /**
2333
2641
  * 将能转成公网 URL 的图片追加到 markdown 行,无法转换的图片返回给富媒体 fallback。
@@ -2335,24 +2643,31 @@ const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
2335
2643
  * @param ctx 适配器实例,用于输出降级日志。
2336
2644
  * @param lines markdown 行数组。
2337
2645
  * @param files QQ 图片消息段的 file 字段。
2646
+ * @param origin 本次发送归属的上传会话。
2338
2647
  * @returns 需要改走 msg_type=7 富媒体发送的图片列表。
2339
2648
  */
2340
- const appendMarkdownImages = async (ctx, lines, files) => {
2649
+ const appendMarkdownImages = async (ctx, lines, files, origin) => {
2341
2650
  const fallback = [];
2342
2651
  for (const file of files) {
2343
- const resolved = await resolvePreferredMediaSource(ctx, "image", file);
2652
+ const resolved = await resolvePreferredMediaSource(ctx, "image", file, void 0, origin, "markdown");
2344
2653
  if (resolved.via === "fallback") {
2345
2654
  fallback.push(file);
2346
2655
  continue;
2347
2656
  }
2348
- await appendImageUrlLine(lines, resolved.source);
2657
+ lines.push(await buildMarkdownImageLine(resolved.source, "", resolved.size));
2349
2658
  }
2350
2659
  return fallback;
2351
2660
  };
2352
2661
  /**
2353
2662
  * 处理显式 segment.markdown 中的图片:可转公网 URL 的继续嵌入,无法转的改走富媒体。
2663
+ *
2664
+ * @param ctx 适配器实例,用于输出降级日志。
2665
+ * @param lines markdown 行数组。
2666
+ * @param grouping 已归类的消息段。
2667
+ * @param origin 本次发送归属的上传会话。
2668
+ * @returns 需要改走 msg_type=7 富媒体发送的图片列表。
2354
2669
  */
2355
- const appendExplicitMarkdown = async (ctx, lines, grouping) => {
2670
+ const appendExplicitMarkdown = async (ctx, lines, grouping, origin) => {
2356
2671
  const fallback = [];
2357
2672
  for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
2358
2673
  if (part.type === "text") {
@@ -2360,31 +2675,17 @@ const appendExplicitMarkdown = async (ctx, lines, grouping) => {
2360
2675
  if (text) lines.push(text);
2361
2676
  continue;
2362
2677
  }
2363
- const resolved = await resolvePreferredMediaSource(ctx, "image", part.source);
2678
+ const resolved = await resolvePreferredMediaSource(ctx, "image", part.source, void 0, origin, "markdown");
2364
2679
  if (resolved.via === "fallback") {
2365
2680
  fallback.push(part.source);
2366
2681
  continue;
2367
2682
  }
2368
- await appendImageUrlLine(lines, resolved.source);
2683
+ /** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
2684
+ lines.push(await buildMarkdownImageLine(resolved.source, part.alt, resolved.size));
2369
2685
  }
2370
2686
  return fallback;
2371
2687
  };
2372
2688
  /**
2373
- * 生成单张 markdown 图片行。
2374
- * 尺寸获取失败时使用保守默认尺寸,保证 fileToUrl 成功的图片仍走 markdown。
2375
- *
2376
- * @param lines markdown 行数组。
2377
- * @param url 图片 URL。
2378
- */
2379
- 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
- }
2386
- };
2387
- /**
2388
2689
  * 判断显式引用回复是否应降级为普通文本消息。
2389
2690
  *
2390
2691
  * QQ 的 `message_reference` 对普通文本最稳定;markdown 带引用在客户端渲染上存在兼容问题。
@@ -2432,8 +2733,8 @@ const buildKeyboard$1 = (grouping) => {
2432
2733
  grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
2433
2734
  const normalizedRows = [];
2434
2735
  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++));
2736
+ for (const row of rows.slice(0, 5)) {
2737
+ const buttons = row.buttons.slice(0, 5).map((button) => normalizeQQBotButton(button, id++));
2437
2738
  if (buttons.length) normalizedRows.push({ buttons });
2438
2739
  }
2439
2740
  if (!normalizedRows.length) return void 0;
@@ -2472,8 +2773,8 @@ const flushQQ = async (ctx, contact, grouping, items) => {
2472
2773
  * 仅单聊 `msg_id` 回复受四次上限约束。`event_id` 事件回复和群聊不适用该限制。
2473
2774
  * 这是 QQ 官方 2026-01-10 更新后的限制,超出部分不请求平台接口。
2474
2775
  */
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} 条`);
2776
+ const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? 4 : Infinity;
2777
+ if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${4} 条,已跳过 ${items.length - maxItems} 条`);
2477
2778
  let referenceHandled = false;
2478
2779
  for (const item of items.slice(0, maxItems)) {
2479
2780
  passive(item);
@@ -2500,6 +2801,12 @@ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
2500
2801
  try {
2501
2802
  return await send(contact.peer, item);
2502
2803
  } catch (err) {
2804
+ if (item.msg_type === 7 && item.keyboard && isInvalidKeyboardError(err)) {
2805
+ ctx.logger("warn", "富媒体消息附带 keyboard 被 QQ 拒绝,已去掉按钮重试,本条消息将不显示按钮");
2806
+ const retryItem = { ...item };
2807
+ delete retryItem.keyboard;
2808
+ return send(contact.peer, retryItem);
2809
+ }
2503
2810
  if (!item.event_id || !isInvalidEventIdError(err)) throw err;
2504
2811
  ctx.logger("warn", `event_id 被 QQ 拒绝,已改用普通消息重试: ${item.event_id}`);
2505
2812
  const retryItem = { ...item };
@@ -2509,6 +2816,19 @@ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
2509
2816
  }
2510
2817
  };
2511
2818
  /**
2819
+ * 判断发送失败是否来自 QQ 对 `keyboard` 的参数校验。
2820
+ *
2821
+ * 官方文档未声明 `keyboard` 与 `media` 互斥,但实际平台行为无法完全确认,
2822
+ * 因此富媒体带按钮失败时基于键盘相关错误码兜底降级,保证图片本身能发出去。
2823
+ *
2824
+ * @param err 捕获到的发送异常。
2825
+ * @returns 是否可以通过移除 `keyboard` 重试。
2826
+ */
2827
+ const isInvalidKeyboardError = (err) => {
2828
+ const message = err instanceof Error ? err.message : String(err);
2829
+ return message.includes("305007") || message.includes("键盘") || message.includes("keyboard");
2830
+ };
2831
+ /**
2512
2832
  * 判断发送失败是否来自 QQ 对 `event_id` 的参数校验。
2513
2833
  *
2514
2834
  * 当前错误由 Http 层格式化为普通 Error,因此这里基于官方错误码和中文错误信息兜底识别。
@@ -2583,23 +2903,95 @@ const buildPassiveQQ = (ctx, scene, grouping) => {
2583
2903
  };
2584
2904
  };
2585
2905
 
2906
+ //#endregion
2907
+ //#region src/core/adapter/guild-markdown.ts
2908
+ /**
2909
+ * 频道 Markdown 能力探测。
2910
+ *
2911
+ * 官方能力说明(`api-docs/server-inter/message/type/markdown.md`):2026-04-23 起
2912
+ * 单聊 / 群聊的自定义 Markdown 已开放到所有机器人,无需申请;**频道场景仍需内邀
2913
+ * 开通**。因此单聊 / 群聊直接走 Markdown,频道场景先乐观尝试,被平台以权限类
2914
+ * 错误拒绝后记住该 bot 并改走普通消息,避免之后每条消息都白跑一次请求。
2915
+ *
2916
+ * 状态只存在于当前进程,也不写配置:拿到内邀后重启(或改一次配置触发 bot 重建)
2917
+ * 即可重新尝试,用户不需要手动去点任何开关。
2918
+ */
2919
+ /** 已确认没有频道 Markdown 权限的 appId。 */
2920
+ const forbidden = /* @__PURE__ */ new Set();
2921
+ /**
2922
+ * 平台拒绝 Markdown 的权限类错误码。
2923
+ *
2924
+ * - 50056 不允许发送 markdown content;
2925
+ * - 304036 没有 markdown 模板的权限。
2926
+ */
2927
+ const FORBIDDEN_CODES = [50056, 304036];
2928
+ /**
2929
+ * 该 bot 当前是否还可以尝试频道 Markdown。
2930
+ *
2931
+ * @param selfId 机器人 appId。
2932
+ * @returns 未被标记为无权限时返回 true。
2933
+ */
2934
+ const canSendGuildMarkdown = (selfId) => !forbidden.has(selfId);
2935
+ /**
2936
+ * 标记该 bot 没有频道 Markdown 权限。
2937
+ *
2938
+ * @param selfId 机器人 appId。
2939
+ * @returns 本次是否是首次标记,供调用方只告警一次。
2940
+ */
2941
+ const disableGuildMarkdown = (selfId) => {
2942
+ if (forbidden.has(selfId)) return false;
2943
+ forbidden.add(selfId);
2944
+ return true;
2945
+ };
2946
+ /**
2947
+ * 清除该 bot 的降级标记,让下一条频道消息重新尝试 Markdown。
2948
+ *
2949
+ * bot 重建(配置变更 / 重新登录)时调用,避免内邀开通后还要重启整个 Karin。
2950
+ *
2951
+ * @param selfId 机器人 appId。
2952
+ */
2953
+ const resetGuildMarkdown = (selfId) => {
2954
+ forbidden.delete(selfId);
2955
+ };
2956
+ /**
2957
+ * 判断发送失败是否来自平台对 Markdown 权限的校验。
2958
+ *
2959
+ * Http 层把平台错误格式化成普通 Error(`[Code 50056] ...`),这里按错误码优先、
2960
+ * 中文文案兜底识别,和 pipeline 里其它降级判断保持一致的做法。
2961
+ *
2962
+ * @param err 捕获到的发送异常。
2963
+ * @returns 是否应降级为普通消息。
2964
+ */
2965
+ const isGuildMarkdownForbiddenError = (err) => {
2966
+ const message = err instanceof Error ? err.message : String(err);
2967
+ if (FORBIDDEN_CODES.some((code) => message.includes(`[Code ${code}]`))) return true;
2968
+ return message.includes("不允许发送 markdown") || message.includes("没有 markdown 模板的权限");
2969
+ };
2970
+
2586
2971
  //#endregion
2587
2972
  //#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
2973
  /**
2594
2974
  * 处理频道场景(子频道 + 私信)的发送
2595
2975
  *
2596
- * 默认把普通文本 / at / 图片通过 markdown 渲染。关闭自动 Markdown 通道后,
2597
- * 普通文本和图片改走经典发送;显式 segment.markdown 仍按调用方指定走 Markdown。
2976
+ * 频道的自定义 Markdown 目前仍需向 QQ 申请内邀开通(见
2977
+ * `api-docs/server-inter/message/type/markdown.md`),所以这里不靠用户配置开关,
2978
+ * 而是先乐观走 Markdown:被平台以权限类错误拒绝后记住这个 bot,本次立刻降级重发,
2979
+ * 之后的频道消息直接走普通消息通道。
2598
2980
  */
2599
2981
  const sendGuild = async (ctx, contact, elements) => {
2600
2982
  const grouping = groupElements(contact.scene, elements);
2601
- if (ctx.cfg.markdown.enable) return sendGuildMarkdown(ctx, contact, grouping);
2602
- return sendGuildClassic(ctx, contact, grouping);
2983
+ if (!canSendGuildMarkdown(ctx.selfId)) return sendGuildClassic(ctx, contact, grouping);
2984
+ try {
2985
+ return await sendGuildMarkdown(ctx, contact, grouping);
2986
+ } catch (err) {
2987
+ if (!isGuildMarkdownForbiddenError(err)) throw err;
2988
+ /**
2989
+ * markdown 主消息一定是 items[0],被拒时本次还没有任何消息发出去,
2990
+ * 因此可以安全地整条重发,不会出现重复消息。
2991
+ */
2992
+ if (disableGuildMarkdown(ctx.selfId)) ctx.logger("warn", "频道 Markdown 未开通(需向 QQ 申请内邀),已降级为普通消息发送;本进程后续频道消息不再尝试 Markdown,开通后重启或改一次配置即可恢复");
2993
+ return sendGuildClassic(ctx, contact, grouping);
2994
+ }
2603
2995
  };
2604
2996
  /**
2605
2997
  * Markdown 通道
@@ -2608,67 +3000,87 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
2608
3000
  const items = [];
2609
3001
  const lines = [];
2610
3002
  if (grouping.text.length) lines.push(grouping.text.join(""));
3003
+ /**
3004
+ * 频道用的是 channel_id,不存在 QQ 上传接口要求的 group_openid / user_openid,
3005
+ * 所以只能标明执行发送的 bot,拿不出可用的上传会话。
3006
+ */
3007
+ const extra = {
3008
+ selfId: ctx.selfId,
3009
+ purpose: "markdown"
3010
+ };
2611
3011
  const allImages = [...grouping.guildImageUrls];
3012
+ /** 没有 fileToUrl 处理器接手的本地图片,降级为 file_image 单独发送。 */
3013
+ const fallbackImages = [];
2612
3014
  for (const file of grouping.guildImageFiles) {
2613
- const { url } = await fileToUrl("image", file, "image.jpg");
2614
- allImages.push(url);
3015
+ const result = await fileToUrl("image", file, "image.jpg", extra);
3016
+ if (!result?.url) {
3017
+ ctx.logger("debug", "[sendGuild] 没有 fileToUrl 处理器接手图片转 URL,改走 file_image 单独发送");
3018
+ fallbackImages.push(file);
3019
+ continue;
3020
+ }
3021
+ allImages.push(result.url);
2615
3022
  }
2616
3023
  if (allImages.length) {
2617
- const mdImages = await imagesToMarkdown(allImages);
3024
+ const mdImages = await imagesToMarkdown(allImages, extra);
2618
3025
  lines.push(...mdImages);
2619
3026
  }
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);
3027
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
3028
+ if (part.type === "text") {
3029
+ const text = part.value.trim();
3030
+ if (text) lines.push(text);
3031
+ continue;
3032
+ }
3033
+ try {
3034
+ /** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
3035
+ lines.push(await markdownPartToLine(part, extra));
3036
+ } catch {
3037
+ ctx.logger("debug", "[sendGuild] markdown 内嵌图片转 URL 失败,改走 file_image 单独发送");
3038
+ fallbackImages.push(part.source);
3039
+ }
2626
3040
  }
2627
3041
  warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2628
3042
  warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
2629
3043
  if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
2630
3044
  const keyboard = buildKeyboard(grouping);
2631
- const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
3045
+ const content = lines.length ? lines.join("\n") : "​";
2632
3046
  items.push(ctx.super.guild.markdown({ content }, keyboard));
2633
3047
  }
3048
+ for (const file of fallbackImages) items.push(await buildGuildImageItem(file));
2634
3049
  if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
2635
3050
  return flushGuild(ctx, contact, grouping, items);
2636
3051
  };
2637
3052
  /**
2638
- * 经典通道:文本走 content,图片按文档走 image / file_image 单独发送。
3053
+ * 降级通道:频道 Markdown 权限不可用时使用。
3054
+ *
3055
+ * 文本走 content,图片按文档走 image / file_image 单独发送。这条通道不会产出任何
3056
+ * markdown 消息体——显式 `segment.markdown` 也一样:内嵌图片被拆出来单独发送,
3057
+ * 其余部分按普通文本下发。否则同一条消息只会再被平台以同样的权限错误拒绝一次。
2639
3058
  */
2640
3059
  const sendGuildClassic = async (ctx, contact, grouping) => {
2641
3060
  const items = [];
2642
3061
  const lines = [];
2643
3062
  const images = [...grouping.guildImageUrls, ...grouping.guildImageFiles];
2644
3063
  if (grouping.text.length) lines.push(grouping.text.join(""));
2645
- if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
3064
+ /** 频道 keyboard 只能挂在 markdown 消息体上,没有 markdown 就无处附着。 */
3065
+ if (grouping.buttons.length || grouping.keyboards.length) ctx.logger("warn", "频道 Markdown 不可用,button / keyboard 无法发送,已跳过");
3066
+ if (grouping.markdowns.length) {
3067
+ /** 内容没有丢,只是渲染成纯文本,用 debug 避免每条消息都刷一条 warn。 */
3068
+ ctx.logger("debug", "[sendGuild] Markdown 不可用,segment.markdown 已按普通文本发送,内嵌图片改为单独发送");
3069
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
3070
+ if (part.type === "text") {
3071
+ const text = part.value.trim();
3072
+ if (text) lines.push(text);
3073
+ continue;
3074
+ }
3075
+ images.push(part.source);
3076
+ }
3077
+ }
2646
3078
  const content = lines.join("\n").trim();
2647
3079
  if (content) items.push(ctx.super.guild.text(content));
2648
3080
  for (const image of images) items.push(await buildGuildImageItem(image));
2649
- await appendExplicitGuildMarkdownItems(ctx, contact, grouping, items);
2650
3081
  if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
2651
3082
  return flushGuild(ctx, contact, grouping, items);
2652
3083
  };
2653
- /**
2654
- * 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
2655
- */
2656
- const appendExplicitGuildMarkdownItems = async (ctx, contact, grouping, items) => {
2657
- if (!grouping.markdowns.length) return;
2658
- 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);
2665
- }
2666
- warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2667
- warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
2668
- const keyboard = buildKeyboard(grouping);
2669
- const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
2670
- items.push(ctx.super.guild.markdown({ content }, keyboard));
2671
- };
2672
3084
  const buildGuildImageItem = async (source) => {
2673
3085
  if (/^https?:\/\//i.test(source)) return {
2674
3086
  type: "image",
@@ -2689,8 +3101,8 @@ const buildKeyboard = (grouping) => {
2689
3101
  grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
2690
3102
  const normalizedRows = [];
2691
3103
  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++));
3104
+ for (const row of rows.slice(0, 5)) {
3105
+ const buttons = row.buttons.slice(0, 5).map((button) => normalizeQQBotButton(button, id++));
2694
3106
  if (buttons.length) normalizedRows.push({ buttons });
2695
3107
  }
2696
3108
  if (!normalizedRows.length) return void 0;
@@ -2780,11 +3192,12 @@ const shouldCacheSelfMessage = (ctx) => {
2780
3192
  *
2781
3193
  * 静态资源只在这里转一次 HTTP URL,之后发送 pipeline 和缓存都复用该 URL。
2782
3194
  */
2783
- const prepareSelfMessageCache = async (ctx, elements) => {
3195
+ const prepareSelfMessageCache = async (ctx, contact, elements) => {
2784
3196
  const sendElements = [];
2785
3197
  const cacheElements = [];
3198
+ const origin = resolveUploadOrigin(contact);
2786
3199
  for (const element of elements) {
2787
- const prepared = await prepareSelfCacheElement(ctx, element);
3200
+ const prepared = await prepareSelfCacheElement(ctx, element, origin);
2788
3201
  sendElements.push(prepared.send);
2789
3202
  if (prepared.cache) cacheElements.push(prepared.cache);
2790
3203
  }
@@ -2831,7 +3244,7 @@ const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
2831
3244
  /**
2832
3245
  * 转换单个自己消息元素为缓存安全形态。
2833
3246
  */
2834
- const prepareSelfCacheElement = async (ctx, element) => {
3247
+ const prepareSelfCacheElement = async (ctx, element, origin) => {
2835
3248
  switch (element.type) {
2836
3249
  case "text":
2837
3250
  case "at":
@@ -2847,7 +3260,7 @@ const prepareSelfCacheElement = async (ctx, element) => {
2847
3260
  case "image":
2848
3261
  case "video":
2849
3262
  case "record":
2850
- case "file": return prepareStaticElement(ctx, element);
3263
+ case "file": return prepareStaticElement(ctx, element, origin);
2851
3264
  default: return {
2852
3265
  send: element,
2853
3266
  cache: null
@@ -2855,9 +3268,9 @@ const prepareSelfCacheElement = async (ctx, element) => {
2855
3268
  }
2856
3269
  };
2857
3270
  /**
2858
- * 静态资源统一转 HTTP URL 后再缓存;转换失败时跳过该元素。
3271
+ * 静态资源统一转 HTTP URL 后再缓存;转换失败或没有处理器接手时跳过该元素。
2859
3272
  */
2860
- const prepareStaticElement = async (ctx, element) => {
3273
+ const prepareStaticElement = async (ctx, element, origin) => {
2861
3274
  if (element.file.startsWith("http")) {
2862
3275
  const cached = { ...element };
2863
3276
  return {
@@ -2866,54 +3279,41 @@ const prepareStaticElement = async (ctx, element) => {
2866
3279
  };
2867
3280
  }
2868
3281
  try {
2869
- switch (element.type) {
2870
- case "image": {
2871
- const res = await fileToUrl("image", element.file, element.name || "image.jpg");
2872
- const cached = {
2873
- ...element,
2874
- file: res.url,
2875
- width: res.width,
2876
- height: res.height
2877
- };
2878
- return {
2879
- send: cached,
2880
- cache: cached
2881
- };
2882
- }
2883
- case "video": {
2884
- const res = await fileToUrl("video", element.file, element.name || "video.mp4");
2885
- const cached = {
2886
- ...element,
2887
- file: res.url
2888
- };
2889
- return {
2890
- send: cached,
2891
- cache: cached
2892
- };
2893
- }
2894
- case "record": {
2895
- const res = await fileToUrl("record", element.file, element.name || "record.mp3");
2896
- const cached = {
2897
- ...element,
2898
- file: res.url
2899
- };
2900
- return {
2901
- send: cached,
2902
- cache: cached
2903
- };
2904
- }
2905
- case "file": {
2906
- const res = await fileToUrl("file", element.file, element.name || "file.bin");
2907
- const cached = {
2908
- ...element,
2909
- file: res.url
2910
- };
2911
- return {
2912
- send: cached,
2913
- cache: cached
2914
- };
2915
- }
3282
+ /**
3283
+ * 和发送链路传同一套附加信息:处理器需要知道是哪个 bot、哪个会话才能用对
3284
+ * openid。用途是 media —— 缓存要的是能长期访问的地址,内置图床给的是会过期的
3285
+ * 临时直链,按 purpose 主动放行正好。
3286
+ */
3287
+ const extra = {
3288
+ selfId: ctx.selfId,
3289
+ purpose: "media",
3290
+ origin
3291
+ };
3292
+ const res = await fileToUrl(element.type, element.file, element.name || DEFAULT_FILENAME[element.type], extra);
3293
+ /**
3294
+ * 链路上的处理器全部放行时 fileToUrl 实际返回 undefined(类型声明未涵盖
3295
+ * 这种情况),不属于异常:发送继续使用原始来源,仅跳过该元素的缓存。
3296
+ */
3297
+ if (!res?.url) {
3298
+ ctx.logger("debug", `[getMsg] 没有 fileToUrl 处理器接手 ${element.type},跳过该元素的缓存`);
3299
+ return {
3300
+ send: element,
3301
+ cache: null
3302
+ };
3303
+ }
3304
+ const cached = {
3305
+ ...element,
3306
+ file: res.url
3307
+ };
3308
+ if (cached.type === "image") {
3309
+ const { width, height } = res;
3310
+ cached.width = width;
3311
+ cached.height = height;
2916
3312
  }
3313
+ return {
3314
+ send: cached,
3315
+ cache: cached
3316
+ };
2917
3317
  } catch (err) {
2918
3318
  const message = err instanceof Error ? err.message : String(err);
2919
3319
  ctx.logger("debug", `[getMsg] 跳过自己消息资源缓存: ${element.type} ${message}`);
@@ -2924,6 +3324,25 @@ const prepareStaticElement = async (ctx, element) => {
2924
3324
  }
2925
3325
  };
2926
3326
  /**
3327
+ * 解析缓存用 URL 转换的归属会话。
3328
+ *
3329
+ * QQ 的 openid 按 bot 隔离,上传接口只接受该 bot 可见的 group_openid / user_openid;
3330
+ * 频道用 channel_id,没有可用 openid,返回 undefined。
3331
+ *
3332
+ * @param contact 本次发送目标。
3333
+ * @returns 可交给 fileToUrl 处理器的上传会话。
3334
+ */
3335
+ const resolveUploadOrigin = (contact) => {
3336
+ if (contact.scene === "group") return {
3337
+ scene: "group",
3338
+ peer: contact.peer
3339
+ };
3340
+ if (contact.scene === "friend") return {
3341
+ scene: "user",
3342
+ peer: contact.peer
3343
+ };
3344
+ };
3345
+ /**
2927
3346
  * 构造机器人自己的 sender。
2928
3347
  */
2929
3348
  const selfSender = (ctx, contact) => {
@@ -3011,17 +3430,67 @@ const clearJoinRequests = (appId) => {
3011
3430
  };
3012
3431
 
3013
3432
  //#endregion
3014
- //#region src/core/adapter/base.ts
3433
+ //#region src/core/adapter/config-store.ts
3434
+ /**
3435
+ * 适配器实例的真实配置存储。
3436
+ *
3437
+ * 配置挂在 WeakMap 而不是实例字段上:系统接口序列化整个适配器时不会把
3438
+ * appSecret 等敏感配置带出去。
3439
+ */
3015
3440
  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;
3441
+ /**
3442
+ * 绑定适配器实例与其配置。
3443
+ * @param bot 适配器实例。
3444
+ * @param cfg 该实例的 QQBot 配置。
3445
+ */
3446
+ const setAdapterConfig = (bot, cfg) => {
3447
+ adapterConfigStore.set(bot, cfg);
3448
+ };
3449
+ /**
3450
+ * 读取适配器实例的配置。
3451
+ * @param bot 适配器实例。
3452
+ * @returns 该实例的 QQBot 配置;未绑定时抛错。
3453
+ */
3454
+ const getAdapterConfig = (bot) => {
3455
+ const cfg = adapterConfigStore.get(bot);
3456
+ if (!cfg) throw new Error("QQBot adapter config missing");
3457
+ return cfg;
3458
+ };
3459
+
3460
+ //#endregion
3461
+ //#region src/core/adapter/base.ts
3462
+ /**
3463
+ * 按 Karin 接口约定构造群成员信息。
3464
+ *
3465
+ * `sender` 用 getter 即时构建,避免整个适配器被系统接口序列化时带出多余引用。
3466
+ *
3467
+ * @param userId 成员 openid。
3468
+ * @param role 群内角色。
3469
+ * @param nick 成员昵称。
3470
+ * @param joinTime 入群时间戳(毫秒)。
3471
+ * @returns Karin 标准群成员信息。
3472
+ */
3473
+ const buildGroupMemberInfo = (userId, role, nick, joinTime) => ({
3474
+ userId,
3475
+ role,
3476
+ nick,
3477
+ joinTime,
3478
+ get sender() {
3479
+ return senderGroup({
3480
+ userId,
3481
+ nick: nick || "",
3482
+ name: nick || "",
3483
+ role
3484
+ });
3485
+ }
3486
+ });
3020
3487
  /**
3021
3488
  * QQ Official Bot 适配器
3022
3489
  *
3023
- * 全场景统一走 msg_type=2 Markdown 通道(详见 pipeline-qq / pipeline-guild)。
3024
- * 视频 / 语音 / 文件由 pipeline 内部以 msg_type=7 紧随主消息补发。
3490
+ * 发送通道由平台能力决定,没有对应的用户配置项:QQ 单聊 / 群聊的自定义 Markdown
3491
+ * 已对所有机器人开放,固定走 msg_type=2;频道场景仍需内邀开通,先乐观尝试
3492
+ * Markdown,被拒后自动降级为普通消息(详见 pipeline-qq / pipeline-guild 与
3493
+ * guild-markdown)。视频 / 语音 / 文件由 pipeline 内部以 msg_type=7 紧随主消息补发。
3025
3494
  */
3026
3495
  var AdapterQQBot = class extends AdapterBase {
3027
3496
  /** 与官方 API 交互 */
@@ -3030,7 +3499,7 @@ var AdapterQQBot = class extends AdapterBase {
3030
3499
  nicknameCache = /* @__PURE__ */ new Map();
3031
3500
  constructor(cfg, api) {
3032
3501
  super();
3033
- adapterConfigStore.set(this, cfg);
3502
+ setAdapterConfig(this, cfg);
3034
3503
  this.super = api;
3035
3504
  this.adapter.name = "QQ Official Bot";
3036
3505
  this.adapter.protocol = "qqbot";
@@ -3039,9 +3508,7 @@ var AdapterQQBot = class extends AdapterBase {
3039
3508
  }
3040
3509
  /** 当前 bot 配置。真实配置存放在 WeakMap,避免被系统接口序列化整个适配器时带出。 */
3041
3510
  get cfg() {
3042
- const cfg = adapterConfigStore.get(this);
3043
- if (!cfg) throw new Error("QQBot adapter config missing");
3044
- return cfg;
3511
+ return getAdapterConfig(this);
3045
3512
  }
3046
3513
  /** 接收消息的一天热缓存 + SQLite 缓存,用于实现 Karin 标准 `getMsg`。 */
3047
3514
  get messageStore() {
@@ -3061,16 +3528,17 @@ var AdapterQQBot = class extends AdapterBase {
3061
3528
  * 发送消息
3062
3529
  */
3063
3530
  async sendMsg(contact, elements, _retryCount) {
3531
+ const normalizedElements = normalizeMediaElements(elements);
3064
3532
  /** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
3065
3533
  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);
3534
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, contact, normalizedElements) : void 0;
3535
+ const result = await sendGuild(this, contact, prepared?.sendElements ?? normalizedElements);
3068
3536
  cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
3069
3537
  return result;
3070
3538
  }
3071
3539
  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);
3540
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, contact, normalizedElements) : void 0;
3541
+ const result = await sendQQ(this, contact, prepared?.sendElements ?? normalizedElements);
3074
3542
  cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
3075
3543
  return result;
3076
3544
  }
@@ -3138,8 +3606,16 @@ var AdapterQQBot = class extends AdapterBase {
3138
3606
  /**
3139
3607
  * 发送合并转发消息
3140
3608
  *
3141
- * 将 NodeElement 拆解为文本 + 图片等元素,拼接成 markdown 发送。
3142
- * 节点之间仅用换行隔开,不添加任何前缀提示文本。
3609
+ * QQ 官方没有合并转发能力,这里把各节点摊平成一段 Markdown 正文,节点之间用
3610
+ * 强制换行隔开,不添加任何前缀提示文本。
3611
+ *
3612
+ * 图片以 markdown 图片语法写入,**保留原始来源**(本地路径 / base64 / URL):
3613
+ * 究竟走内置图床、第三方 fileToUrl,还是降级成 msg_type=7 富媒体,由发送
3614
+ * pipeline 统一决定。这里不自己上传,也不自己判断通道——否则频道场景在没有
3615
+ * Markdown 权限时会绕过降级逻辑,直接被平台拒掉。
3616
+ *
3617
+ * 视频 / 语音 / 文件进不了 markdown,作为普通消息段跟随主消息一起交给 pipeline,
3618
+ * 由它在同一次发送里以 msg_type=7 补发。
3143
3619
  */
3144
3620
  async sendForwardMsg(contact, elements, _options) {
3145
3621
  const parts = [];
@@ -3157,18 +3633,10 @@ var AdapterQQBot = class extends AdapterBase {
3157
3633
  lines.push(el.text);
3158
3634
  break;
3159
3635
  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})`);
3163
- } catch {
3164
- lines.push(`![${el.name || "图片"} #300px #300px](${el.file})`);
3165
- }
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]()`);
3171
- }
3636
+ lines.push(buildPendingMarkdownImageLine(el.file, el.name || "图片", el.width && el.height ? {
3637
+ width: el.width,
3638
+ height: el.height
3639
+ } : void 0));
3172
3640
  break;
3173
3641
  case "at":
3174
3642
  if (contact.scene === "friend" || contact.scene === "direct") break;
@@ -3195,10 +3663,21 @@ var AdapterQQBot = class extends AdapterBase {
3195
3663
  if (block) parts.push(block);
3196
3664
  }
3197
3665
  const content = parts.join("\r\r\n\n");
3198
- const result = await this.sendMsg(contact, [segment.markdown(content)]);
3199
- if (mediaElements.length) await this.sendMsg(contact, mediaElements);
3666
+ /**
3667
+ * 正文可能整段为空(例如只转发了一个视频节点)。此时不能塞一条空 markdown:
3668
+ * 平台会以 50041「markdown 有空值」拒绝,pipeline 也会因为没有可发内容而
3669
+ * 回落成一条"不支持发送的消息类型"。
3670
+ */
3671
+ const sendElements = content ? [segment.markdown(content), ...mediaElements] : mediaElements;
3672
+ if (!sendElements.length) {
3673
+ this.logger("warn", "[sendForwardMsg] 转发节点里没有可发送的内容,已跳过");
3674
+ return {
3675
+ messageId: "",
3676
+ forwardId: ""
3677
+ };
3678
+ }
3200
3679
  return {
3201
- messageId: result.messageId,
3680
+ messageId: (await this.sendMsg(contact, sendElements)).messageId,
3202
3681
  forwardId: ""
3203
3682
  };
3204
3683
  }
@@ -3321,9 +3800,30 @@ var AdapterQQBot = class extends AdapterBase {
3321
3800
  }
3322
3801
  /**
3323
3802
  * 群踢人
3803
+ *
3804
+ * 走官方 `POST /v2/groups/{group_openid}/batch_remove_members`,机器人需拥有群
3805
+ * 管理员身份。Karin 的 `rejectAddRequest` 映射为官方的 `add_to_member_blacklist`:
3806
+ * 移除的同时拉入群黑名单,该用户之后无法再次入群。官方接口没有踢人理由字段,
3807
+ * `kickReason` 只会被记录到日志。
3808
+ *
3809
+ * 需要一次移除多人时用 `bot.super.groups.batchRemoveGroupMembers()`,单次最多 20 个。
3810
+ *
3811
+ * @param _groupId 群 openid
3812
+ * @param _targetId 群成员 openid
3813
+ * @param _rejectAddRequest 是否同时拉入群黑名单,默认 false
3814
+ * @param _kickReason 官方不支持,仅在传入时输出提示
3324
3815
  */
3325
3816
  async groupKickMember(_groupId, _targetId, _rejectAddRequest, _kickReason) {
3326
- this.logger("warn", "[groupKickMember] QQ Official Bot API 暂不支持群踢人");
3817
+ if (_kickReason) this.logger("warn", `[groupKickMember] QQ Official Bot API 不支持踢人理由,已忽略: ${_kickReason}`);
3818
+ try {
3819
+ const failed = (await this.super.groups.batchRemoveGroupMembers(_groupId, {
3820
+ member_openids: [_targetId],
3821
+ ..._rejectAddRequest ? { add_to_member_blacklist: true } : {}
3822
+ })).add_to_member_blacklist_fail_openids ?? [];
3823
+ if (failed.length) this.logger("warn", `[groupKickMember] 成员已移除,但加入群黑名单失败: ${failed.join("、")}`);
3824
+ } catch (err) {
3825
+ this.logger("error", "[groupKickMember] 移除群成员失败(该接口为白名单接口,未开通权限会返回 11253):", err);
3826
+ }
3327
3827
  }
3328
3828
  /**
3329
3829
  * 禁言群成员
@@ -3339,7 +3839,7 @@ var AdapterQQBot = class extends AdapterBase {
3339
3839
  let rfc3339 = "";
3340
3840
  if (duration > 0) {
3341
3841
  let seconds = duration;
3342
- if (seconds > MAX_GROUP_MUTE_SECONDS) {
3842
+ if (seconds > 2592e3) {
3343
3843
  this.logger("warn", `[setGroupMute] 禁言时长 ${seconds}s 超出平台上限 30 天,已截断为 ${MAX_GROUP_MUTE_SECONDS}s`);
3344
3844
  seconds = MAX_GROUP_MUTE_SECONDS;
3345
3845
  }
@@ -3458,49 +3958,63 @@ var AdapterQQBot = class extends AdapterBase {
3458
3958
  /**
3459
3959
  * 获取群成员信息
3460
3960
  *
3461
- * - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`(白名单)。
3462
- * - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,
3463
- * 该接口未公开文档,来自腾讯官方 openclaw 插件 v2.0.0,返回结构以平台实际为准。
3464
- * 接口不可用(如白名单未开通返回 11253)时降级为占位结构并告警。
3961
+ * - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`。
3962
+ * - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,该接口在
3963
+ * 接口文档 v1.28.0(2026-09-03)转为公开文档,昵称字段为 `username`,`nick` 是
3964
+ * 转公开之前的字段名,仅作兼容回退。
3965
+ *
3966
+ * 两者都是白名单接口,未开通权限(11253)或查询失败时降级为占位结构并告警。
3465
3967
  */
3466
3968
  async getGroupMemberInfo(_groupId, _targetId, _refresh) {
3467
- /** 按 karin 接口约定构造返回值,sender 由 getter 即时构建 */
3468
- const build = (userId, role, nick, joinTime) => ({
3469
- userId,
3470
- role,
3471
- nick,
3472
- joinTime,
3473
- get sender() {
3474
- return senderGroup({
3475
- userId,
3476
- nick: nick || "",
3477
- name: nick || "",
3478
- role
3479
- });
3480
- }
3481
- });
3482
3969
  if (_targetId === this.selfId) try {
3483
3970
  const state = await this.super.groups.getBotState(_groupId);
3484
- return build(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
3971
+ return buildGroupMemberInfo(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
3485
3972
  } catch (err) {
3486
3973
  this.logger("warn", `[getGroupMemberInfo] 获取机器人群内状态失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
3487
- return build(_targetId, "member");
3974
+ return buildGroupMemberInfo(_targetId, "member");
3488
3975
  }
3489
3976
  try {
3490
3977
  const member = await this.super.groups.getGroupMember(_groupId, _targetId);
3491
- if (member.nick) this.nicknameCache.set(_targetId, member.nick);
3492
- return build(member.member_openid || _targetId, member.member_role || "member", member.nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
3978
+ const nick = member.username || member.nick;
3979
+ if (nick) this.nicknameCache.set(_targetId, nick);
3980
+ return buildGroupMemberInfo(member.member_openid || _targetId, member.member_role || "member", nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
3493
3981
  } catch (err) {
3494
- this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口未公开文档,可能不可用): ${err instanceof Error ? err.message : err}`);
3495
- return build(_targetId, "member");
3982
+ this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
3983
+ return buildGroupMemberInfo(_targetId, "member");
3496
3984
  }
3497
3985
  }
3498
3986
  /**
3499
3987
  * 获取群成员列表
3988
+ *
3989
+ * 走官方 `GET /v2/groups/{group_openid}/members`(白名单接口),每页最多 30 条,
3990
+ * 这里按 `next_cursor` 自动翻完所有页;顺便把昵称写入
3991
+ * {@link AdapterQQBot.nicknameCache},让后续消息事件能直接取到昵称。
3992
+ *
3993
+ * 为防止平台返回的游标不收敛,翻页数有安全上限(约 6000 人),触顶会告警并返回已取到的部分。
3994
+ * 未开通权限(11253)或查询失败时返回已取到的部分并告警。
3995
+ *
3996
+ * @param _groupId 群 openid
3500
3997
  */
3501
3998
  async getGroupMemberList(_groupId, _refresh) {
3502
- this.logger("warn", "[getGroupMemberList] QQ Official Bot API 暂不支持获取群成员列表");
3503
- return [];
3999
+ const result = [];
4000
+ let cursor = "";
4001
+ try {
4002
+ for (let page = 0; page < 200; page++) {
4003
+ const data = await this.super.groups.getGroupMemberList(_groupId, cursor ? { cursor } : {});
4004
+ for (const member of data.members ?? []) {
4005
+ if (member.username) this.nicknameCache.set(member.member_openid, member.username);
4006
+ result.push(buildGroupMemberInfo(member.member_openid, member.member_role || "member", member.username, member.joined_at ? new Date(member.joined_at).getTime() : void 0));
4007
+ }
4008
+ const next = data.next_cursor || "";
4009
+ /** 空游标表示末页;游标不前进说明平台返回异常,直接停止避免死循环 */
4010
+ if (!next || next === cursor) return result;
4011
+ cursor = next;
4012
+ }
4013
+ this.logger("warn", `[getGroupMemberList] 翻页数已达上限 ${200} 页,仅返回前 ${result.length} 名成员`);
4014
+ } catch (err) {
4015
+ this.logger("warn", `[getGroupMemberList] 获取群成员列表失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
4016
+ }
4017
+ return result;
3504
4018
  }
3505
4019
  /**
3506
4020
  * 获取群荣誉信息
@@ -5083,7 +5597,7 @@ const intentBitMap = {
5083
5597
  [2]: "GUILD_MEMBERS (频道成员变更)",
5084
5598
  [512]: "GUILD_MESSAGES (频道消息-私域)",
5085
5599
  [4096]: "DIRECT_MESSAGE (频道私信)",
5086
- [1 << 24]: "群成员进退群事件",
5600
+ [1 << 24]: "GROUP_MEMBER_EVENT (群成员进退群/入群申请)",
5087
5601
  [1 << 25]: "GROUP_AND_C2C_EVENT (群聊/单聊消息)",
5088
5602
  [1 << 26]: "INTERACTION (按钮交互回调)",
5089
5603
  [1 << 30]: "PUBLIC_GUILD_MESSAGES (频道消息-公域)"
@@ -5372,7 +5886,6 @@ const hashConfig = (bot) => JSON.stringify({
5372
5886
  appId: bot.appId,
5373
5887
  secret: bot.secret,
5374
5888
  event: bot.event,
5375
- markdown: bot.markdown,
5376
5889
  messageCache: bot.messageCache
5377
5890
  });
5378
5891
  /**
@@ -5400,6 +5913,8 @@ const createBot = async (input) => {
5400
5913
  stop(appId);
5401
5914
  offAll(appId);
5402
5915
  stopTokenRefresh(appId);
5916
+ /** 重建即重新探测频道 Markdown,拿到内邀后不必重启整个 Karin。 */
5917
+ resetGuildMarkdown(appId);
5403
5918
  logger.info(`[QQ Official Bot][${appId}] 获取 access_token...`);
5404
5919
  await getAccessToken(appId, bot.secret);
5405
5920
  const api = new QQBotApi(createAxiosInstance(appId));
@@ -5408,7 +5923,7 @@ const createBot = async (input) => {
5408
5923
  client.account.name = me.username;
5409
5924
  client.account.avatar = me.avatar;
5410
5925
  client.account.selfId = appId;
5411
- const qq = new URL(me.share_url).searchParams.get("robot_uin");
5926
+ const qq = new URL$1(me.share_url).searchParams.get("robot_uin");
5412
5927
  client.account.subId.id = me.id;
5413
5928
  client.account.subId.qq = qq;
5414
5929
  client.account.subId.appid = appId;