@karinjs/adapter-qqbot 2.2.1 → 3.0.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,9 @@
1
- import { c as joinHttpPath, d as normalizeWsGatewayUrl, f as pkg, h as basename, l as normalizeHttpUrl, m as log, n as config, r as getConfig, s as buildFallbackWsUrlFromApi, t as bindHandlers, u as normalizeProxyConfig } from "./config-B_Gc9JKZ.js";
1
+ import { c as joinHttpPath, d as normalizeWsGatewayUrl, f as pkg, h as basename, l as normalizeHttpUrl, m as log, n as config, r as getConfig, s as buildFallbackWsUrlFromApi, t as bindHandlers, u as normalizeProxyConfig } from "./config-BUuU6FnQ.js";
2
2
  import { i as random, n as getImageSize, r as handleUrl, t as fakeEvent } from "./common-by1GpV0b.js";
3
- import "./utils-DIq0lGMt.js";
4
- import { t as getMessageStore } from "./message-Dt4QkDYn.js";
5
- import { t as needQrOnboard } from "./onboard-BXQ1cG8h.js";
6
- import { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, unregisterBot } from "node-karin";
3
+ import "./utils-N9PNiHjE.js";
4
+ import { t as getMessageStore } from "./message-BTGL3TSn.js";
5
+ import { t as needQrOnboard } from "./onboard-vIEoe9dB.js";
6
+ import { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
7
7
  import { URL } from "url";
8
8
  import lodash from "node-karin/lodash";
9
9
  import { createReadStream } from "node:fs";
@@ -22,7 +22,7 @@ import nacl from "tweetnacl";
22
22
  //#region src/core/api/error.ts
23
23
  /**
24
24
  * OpenAPI 错误码映射表
25
- * 来源: api-v2/openapi/error/error.md
25
+ * 来源: api-docs/openapi/error/error.md
26
26
  *
27
27
  * 规则:
28
28
  * - 区间码 (如 301000~301099) 用范围匹配
@@ -41,7 +41,7 @@ const httpStatusMap = {
41
41
  500: "处理失败",
42
42
  504: "处理失败"
43
43
  };
44
- /** 业务错误码(code)映射 */
44
+ /** 业务错误码(err_code)映射 */
45
45
  const errorCodeMap = {
46
46
  10001: "UnknownAccount 账号异常",
47
47
  10003: "UnknownChannel 子频道异常",
@@ -359,15 +359,16 @@ const formatOpenAPIError = (status, code, message) => {
359
359
  //#region src/core/api/http.ts
360
360
  /**
361
361
  * 包装 axios 错误为可读多行 message
362
- * 如果响应中包含 QQ 官方错误码(code),会自动映射为中文描述
362
+ * 如果响应中包含 QQ 官方错误码(err_code),会自动映射为中文描述
363
363
  */
364
364
  const formatError = (path, options, err) => {
365
365
  if (axios.isAxiosError(err)) {
366
366
  const response = err.response;
367
367
  const status = response?.status ?? 0;
368
368
  const data = response?.data;
369
- const code = typeof data?.code === "number" ? data.code : void 0;
369
+ const code = typeof data?.err_code === "number" ? data.err_code : typeof data?.code === "number" ? data.code : void 0;
370
370
  const msg = typeof data?.message === "string" ? data.message : void 0;
371
+ const traceId = typeof data?.trace_id === "string" ? data.trace_id : void 0;
371
372
  const lines = [];
372
373
  lines.push("[axios] 请求失败");
373
374
  lines.push(`请求路径: ${path}`);
@@ -377,6 +378,7 @@ const formatError = (path, options, err) => {
377
378
  if (reason) lines.push(`请求错误: ${reason}`);
378
379
  }
379
380
  if (code !== void 0 || status > 0) lines.push(`错误详情: ${formatOpenAPIError(status, code, msg)}`);
381
+ if (traceId) lines.push(`TraceID: ${traceId}`);
380
382
  lines.push(`响应数据: ${JSON.stringify(data)}`);
381
383
  return new Error(lines.join("\n"));
382
384
  }
@@ -447,6 +449,8 @@ var Http = class {
447
449
 
448
450
  //#endregion
449
451
  //#region src/core/api/messages.ts
452
+ /** 输入中状态默认展示时长(秒),平台 typing 窗口约 60s */
453
+ const INPUT_NOTIFY_DEFAULT_SECOND = 60;
450
454
  /**
451
455
  * 消息发送 + 撤回
452
456
  */
@@ -459,6 +463,33 @@ var MessagesApi = class extends Http {
459
463
  sendGroupMsg(groupOpenid, body) {
460
464
  return this.post(`/v2/groups/${groupOpenid}/messages`, body);
461
465
  }
466
+ /**
467
+ * 单聊输入中状态(msg_type=6,仅单聊)
468
+ * @param openid 用户 openid
469
+ * @param inputSecond 展示时长(秒),默认 60
470
+ * @param passive 被动消息参数(msg_id / event_id / msg_seq),可选
471
+ */
472
+ sendFriendInputNotify(openid, inputSecond = INPUT_NOTIFY_DEFAULT_SECOND, passive) {
473
+ return this.post(`/v2/users/${openid}/messages`, {
474
+ msg_type: 6,
475
+ input_notify: {
476
+ input_type: 1,
477
+ input_second: inputSecond
478
+ },
479
+ ...passive
480
+ });
481
+ }
482
+ /**
483
+ * 单聊流式消息(仅单聊)
484
+ *
485
+ * 首帧不带 stream_msg_id,响应的 id 作为后续帧的 stream_msg_id 回传;
486
+ * 同一次流 index 从 0 递增、msg_seq 保持一致;建议帧间隔 300~500ms。
487
+ * @param openid 用户 openid
488
+ * @param body 流式消息帧
489
+ */
490
+ sendFriendStreamMsg(openid, body) {
491
+ return this.post(`/v2/users/${openid}/stream_messages`, body);
492
+ }
462
493
  /** 文字子频道消息 */
463
494
  sendChannelMsg(channelId, body) {
464
495
  const headers = body instanceof FormData ? body.getHeaders() : void 0;
@@ -485,13 +516,10 @@ var MessagesApi = class extends Http {
485
516
 
486
517
  //#endregion
487
518
  //#region src/core/api/upload-cache.ts
488
- /** 单进程内最多缓存的 file_info 数量,避免长期运行时内存无限增长。 */
489
- const MAX_CACHE_SIZE = 500;
490
519
  /** 比 QQ 返回的 ttl 提前失效,避免临界点使用到刚过期的 file_info。 */
491
520
  const TTL_SAFETY_MARGIN_SECONDS = 60;
492
521
  /** QQ 返回极短 ttl 时仍保留一个最小可用窗口。 */
493
522
  const MIN_EFFECTIVE_TTL_SECONDS = 10;
494
- const cache = /* @__PURE__ */ new Map();
495
523
  /**
496
524
  * 构建 file_info 缓存键。
497
525
  *
@@ -511,41 +539,25 @@ const cacheKey = (scene, peer, type, contentHash) => [
511
539
  contentHash
512
540
  ].join("");
513
541
  /**
514
- * 惰性清理过期缓存。
515
- * @param now 当前时间戳。
516
- */
517
- const cleanupExpired = (now = Date.now()) => {
518
- for (const [key, entry] of cache) if (entry.expiresAt <= now) cache.delete(key);
519
- };
520
- /**
521
- * 缓存满时淘汰最旧的一批条目。
522
- */
523
- const evictOldest = () => {
524
- if (cache.size < MAX_CACHE_SIZE) return;
525
- cleanupExpired();
526
- if (cache.size < MAX_CACHE_SIZE) return;
527
- const entries = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
528
- const removeCount = Math.max(1, Math.ceil(MAX_CACHE_SIZE / 2));
529
- for (const [key] of entries.slice(0, removeCount)) cache.delete(key);
530
- };
531
- /**
532
542
  * 读取可复用的上传结果。
533
543
  *
544
+ * 缓存持久化在 SQLite(file_info ttl 可达数天),进程重启后同一份媒体
545
+ * 不需要重新上传。缓存层异常一律按未命中处理,不影响上传主链路。
546
+ *
534
547
  * @param scene 上传场景。
535
548
  * @param peer openid 或 group_openid。
536
549
  * @param type 富媒体类型。
537
550
  * @param contentHash 文件内容 hash。
538
551
  * @returns 未命中或过期时返回 null。
539
552
  */
540
- const getCachedUpload = (scene, peer, type, contentHash) => {
541
- const key = cacheKey(scene, peer, type, contentHash);
542
- const entry = cache.get(key);
543
- if (!entry) return null;
544
- if (entry.expiresAt <= Date.now()) {
545
- cache.delete(key);
553
+ const getCachedUpload = async (scene, peer, type, contentHash) => {
554
+ try {
555
+ const raw = await getMessageStore().getUploadCache(cacheKey(scene, peer, type, contentHash));
556
+ if (!raw) return null;
557
+ return JSON.parse(raw);
558
+ } catch {
546
559
  return null;
547
560
  }
548
- return { ...entry.response };
549
561
  };
550
562
  /**
551
563
  * 写入上传结果缓存。
@@ -556,17 +568,13 @@ const getCachedUpload = (scene, peer, type, contentHash) => {
556
568
  * @param contentHash 文件内容 hash。
557
569
  * @param response 上传接口返回。
558
570
  */
559
- const setCachedUpload = (scene, peer, type, contentHash, response) => {
571
+ const setCachedUpload = async (scene, peer, type, contentHash, response) => {
560
572
  if (!response.file_info) return;
561
- evictOldest();
562
- const now = Date.now();
563
573
  const ttl = Number(response.ttl);
564
- const effectiveTtl = ttl > 0 ? Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : Number.POSITIVE_INFINITY;
565
- cache.set(cacheKey(scene, peer, type, contentHash), {
566
- response: { ...response },
567
- expiresAt: ttl > 0 ? now + effectiveTtl : Number.POSITIVE_INFINITY,
568
- createdAt: now
569
- });
574
+ const expiresAt = ttl > 0 ? Date.now() + Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : 0;
575
+ try {
576
+ await getMessageStore().setUploadCache(cacheKey(scene, peer, type, contentHash), JSON.stringify(response), expiresAt);
577
+ } catch {}
570
578
  };
571
579
 
572
580
  //#endregion
@@ -633,7 +641,7 @@ const DEFAULT_PART_CONCURRENCY = 1;
633
641
  /** 本地限制的最大分片并发,避免服务端返回过大值。 */
634
642
  const MAX_PART_CONCURRENCY = 10;
635
643
  /** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
636
- const HTTP_URL_RE$1 = /^https?:\/\//i;
644
+ const HTTP_URL_RE$2 = /^https?:\/\//i;
637
645
  /** Karin 常见 base64 图片/媒体前缀。 */
638
646
  const BASE64_PREFIX_RE = /^base64:\/\//i;
639
647
  /** 标准 data URL 的 base64 前缀。 */
@@ -729,7 +737,7 @@ const downloadRemoteAsBase64 = async (url) => {
729
737
  */
730
738
  const buildUploadSource = async (source) => {
731
739
  const value = source.trim();
732
- if (HTTP_URL_RE$1.test(value)) return { url: value };
740
+ if (HTTP_URL_RE$2.test(value)) return { url: value };
733
741
  if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
734
742
  if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
735
743
  return { file_data: value };
@@ -745,7 +753,7 @@ const buildUploadSource = async (source) => {
745
753
  const buildFallbackSource = async (type, source, fileName) => {
746
754
  const value = source.trim();
747
755
  const normalizedName = normalizeUploadFileName(type, fileName);
748
- if (HTTP_URL_RE$1.test(value)) {
756
+ if (HTTP_URL_RE$2.test(value)) {
749
757
  const buffer = await downloadRemoteAsBuffer(value);
750
758
  return {
751
759
  kind: "buffer",
@@ -983,12 +991,12 @@ var MediaApi = class extends Http {
983
991
  assertUploadSize(type, fallbackSource.size);
984
992
  const hashes = !srvSendMsg ? await computeUploadHashes(fallbackSource) : void 0;
985
993
  if (hashes) {
986
- const cached = getCachedUpload(scene, peer, type, hashes.md5);
994
+ const cached = await getCachedUpload(scene, peer, type, hashes.md5);
987
995
  if (cached) return cached;
988
996
  }
989
997
  if (!srvSendMsg && fallbackSource.size >= LARGE_UPLOAD_THRESHOLD) {
990
998
  const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
991
- if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
999
+ if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
992
1000
  return response;
993
1001
  }
994
1002
  const body = {
@@ -998,7 +1006,7 @@ var MediaApi = class extends Http {
998
1006
  };
999
1007
  if (type === "file") body.file_name = fallbackSource.fileName;
1000
1008
  const response = await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1001
- if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
1009
+ if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
1002
1010
  return response;
1003
1011
  }
1004
1012
  /**
@@ -1100,9 +1108,10 @@ var MediaApi = class extends Http {
1100
1108
  const concurrency = Math.min(prepare.concurrency || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
1101
1109
  const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
1102
1110
  try {
1103
- await runWithConcurrency(prepare.parts.map((part) => async () => {
1111
+ const tasks = prepare.parts.map((part) => async () => {
1104
1112
  const offset = (part.index - 1) * prepare.block_size;
1105
- const data = await readPart(source, handle, offset, Math.min(prepare.block_size, source.size - offset));
1113
+ const length = Math.min(prepare.block_size, source.size - offset);
1114
+ const data = await readPart(source, handle, offset, length);
1106
1115
  const md5 = crypto.createHash("md5").update(data).digest("hex");
1107
1116
  await putPart(part.presigned_url, data, part.index, prepare.parts.length);
1108
1117
  await this.finishUploadPart(scene, peer, {
@@ -1111,7 +1120,8 @@ var MediaApi = class extends Http {
1111
1120
  block_size: data.length,
1112
1121
  md5
1113
1122
  }, prepare.retry_timeout);
1114
- }), concurrency);
1123
+ });
1124
+ await runWithConcurrency(tasks, concurrency);
1115
1125
  } finally {
1116
1126
  await handle?.close().catch(() => void 0);
1117
1127
  }
@@ -1159,6 +1169,41 @@ var MetaApi = class extends Http {
1159
1169
  }
1160
1170
  };
1161
1171
 
1172
+ //#endregion
1173
+ //#region src/core/api/groups.ts
1174
+ /**
1175
+ * QQ 群信息接口
1176
+ *
1177
+ * 注意:以下接口均为白名单接口,未开通权限时平台返回错误码 11253,
1178
+ * 需要向平台运营申请后可用。
1179
+ */
1180
+ var GroupsApi = class extends Http {
1181
+ /**
1182
+ * 获取群基础信息
1183
+ * @param groupOpenid 群 openid
1184
+ */
1185
+ getGroupInfo(groupOpenid) {
1186
+ return this.get(`/v2/groups/${groupOpenid}/info`);
1187
+ }
1188
+ /**
1189
+ * 获取机器人在群内的状态(角色、入群时间、主动消息开关等)
1190
+ * @param groupOpenid 群 openid
1191
+ */
1192
+ getBotState(groupOpenid) {
1193
+ return this.get(`/v2/groups/${groupOpenid}/bot_state`);
1194
+ }
1195
+ /**
1196
+ * 获取群成员详情
1197
+ *
1198
+ * 未公开文档,来自腾讯官方 openclaw 插件(v2.0.0)的接口速查。
1199
+ * @param groupOpenid 群 openid
1200
+ * @param memberOpenid 群成员 openid
1201
+ */
1202
+ getGroupMember(groupOpenid, memberOpenid) {
1203
+ return this.get(`/v2/groups/${groupOpenid}/members/${memberOpenid}`);
1204
+ }
1205
+ };
1206
+
1162
1207
  //#endregion
1163
1208
  //#region src/core/api/builders.ts
1164
1209
  /**
@@ -1240,6 +1285,7 @@ const buildGuildMsg = {
1240
1285
  * - media:富媒体上传
1241
1286
  * - interaction:按钮回调 ack
1242
1287
  * - meta:@me / dms / gateway
1288
+ * - groups:群基础信息 / 机器人群内状态(白名单接口)
1243
1289
  *
1244
1290
  * 请求体构造器 {@link buildQQMsg} / {@link buildGuildMsg} 独立导出,避免与 HTTP 调用耦合
1245
1291
  */
@@ -1249,6 +1295,7 @@ var QQBotApi = class {
1249
1295
  media;
1250
1296
  interaction;
1251
1297
  meta;
1298
+ groups;
1252
1299
  /** 请求体构造器(静态便利) */
1253
1300
  qq = buildQQMsg;
1254
1301
  guild = buildGuildMsg;
@@ -1258,6 +1305,7 @@ var QQBotApi = class {
1258
1305
  this.media = new MediaApi(axios);
1259
1306
  this.interaction = new InteractionApi(axios);
1260
1307
  this.meta = new MetaApi(axios);
1308
+ this.groups = new GroupsApi(axios);
1261
1309
  }
1262
1310
  };
1263
1311
 
@@ -1522,7 +1570,7 @@ const groupElements = (scene, elements) => {
1522
1570
  //#endregion
1523
1571
  //#region src/core/adapter/media-source.ts
1524
1572
  /** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
1525
- const HTTP_URL_RE = /^https?:\/\//i;
1573
+ const HTTP_URL_RE$1 = /^https?:\/\//i;
1526
1574
  /** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
1527
1575
  const DEFAULT_FILENAME = {
1528
1576
  image: "image.jpg",
@@ -1540,7 +1588,7 @@ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
1540
1588
  * @param source Karin 消息段里的 file 字段。
1541
1589
  * @returns 是否为 HTTP(S) URL。
1542
1590
  */
1543
- const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
1591
+ const isHttpMediaSource = (source) => HTTP_URL_RE$1.test(source);
1544
1592
  /**
1545
1593
  * 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
1546
1594
  *
@@ -1576,157 +1624,6 @@ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
1576
1624
  }
1577
1625
  };
1578
1626
 
1579
- //#endregion
1580
- //#region src/core/adapter/message-id-map.ts
1581
- /** QQ 消息 ID 与 REFIDX 映射的短期缓存时长,和本地消息缓存窗口保持一致。 */
1582
- const MESSAGE_ID_LINK_CACHE_TTL = 1440 * 60 * 1e3;
1583
- /** QQ 消息 ID 映射表清理间隔。 */
1584
- const MESSAGE_ID_LINK_CLEANUP_INTERVAL = 60 * 1e3;
1585
- /** 按适配器实例隔离的消息 ID 映射状态。 */
1586
- const states = /* @__PURE__ */ new WeakMap();
1587
- /**
1588
- * 获取适配器实例对应的消息 ID 映射状态。
1589
- *
1590
- * @param ctx 当前 QQBot 适配器实例。
1591
- * @returns 该实例的映射状态。
1592
- */
1593
- const getState = (ctx) => {
1594
- const existing = states.get(ctx);
1595
- if (existing) return existing;
1596
- const created = {
1597
- apiMessageIdCache: /* @__PURE__ */ new Map(),
1598
- referenceMessageIdCache: /* @__PURE__ */ new Map(),
1599
- ownMessageIdCache: /* @__PURE__ */ new Map(),
1600
- lastCleanup: 0
1601
- };
1602
- states.set(ctx, created);
1603
- return created;
1604
- };
1605
- /**
1606
- * 构造 REFIDX/API ID 映射的会话级 key。
1607
- *
1608
- * @param contact 消息所在会话。
1609
- * @param messageId 消息 ID。
1610
- * @returns 可用于 Map 的稳定 key。
1611
- */
1612
- const messageIdKey = (contact, messageId) => {
1613
- return [
1614
- contact.scene,
1615
- contact.peer,
1616
- contact.subPeer || "",
1617
- messageId
1618
- ].join("");
1619
- };
1620
- /**
1621
- * 清理过期的消息 ID 映射。
1622
- *
1623
- * @param ctx 当前 QQBot 适配器实例。
1624
- */
1625
- const cleanup = (ctx) => {
1626
- const state = getState(ctx);
1627
- const now = Date.now();
1628
- if (now - state.lastCleanup < MESSAGE_ID_LINK_CLEANUP_INTERVAL) return;
1629
- state.lastCleanup = now;
1630
- for (const [key, entry] of state.apiMessageIdCache) if (entry.expiresAt <= now) state.apiMessageIdCache.delete(key);
1631
- for (const [key, entry] of state.referenceMessageIdCache) if (entry.expiresAt <= now) state.referenceMessageIdCache.delete(key);
1632
- for (const [key, entry] of state.ownMessageIdCache) if (entry.expiresAt <= now) state.ownMessageIdCache.delete(key);
1633
- };
1634
- /**
1635
- * 记住机器人自己发送的 QQ API 消息 ID。
1636
- *
1637
- * 单聊场景只允许撤回机器人自己发送的消息。收到用户消息时的 `e.messageId`
1638
- * 也是 API 消息 ID,但不能用于单聊撤回,因此需要独立标记来源。
1639
- *
1640
- * @param ctx 当前 QQBot 适配器实例。
1641
- * @param contact 消息所在会话。
1642
- * @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
1643
- */
1644
- const rememberOwnMessageId = (ctx, contact, apiMessageId) => {
1645
- if (!apiMessageId) return;
1646
- cleanup(ctx);
1647
- getState(ctx).ownMessageIdCache.set(messageIdKey(contact, apiMessageId), { expiresAt: Date.now() + MESSAGE_ID_LINK_CACHE_TTL });
1648
- };
1649
- /**
1650
- * 判断给定 QQ API 消息 ID 是否由机器人自己发送。
1651
- *
1652
- * @param ctx 当前 QQBot 适配器实例。
1653
- * @param contact 消息所在会话。
1654
- * @param apiMessageId QQ API 消息 ID。
1655
- * @returns true 表示这条消息是当前适配器实例发送的。
1656
- */
1657
- const isOwnMessageId = (ctx, contact, apiMessageId) => {
1658
- cleanup(ctx);
1659
- const state = getState(ctx);
1660
- const key = messageIdKey(contact, apiMessageId);
1661
- const cached = state.ownMessageIdCache.get(key);
1662
- if (!cached) return false;
1663
- if (cached.expiresAt > Date.now()) return true;
1664
- state.ownMessageIdCache.delete(key);
1665
- return false;
1666
- };
1667
- /**
1668
- * 记住 QQ 引用索引对应的 API 消息 ID。
1669
- *
1670
- * QQ 群聊/单聊的 `message_reference.message_id` 使用 REFIDX,但撤回等 API 路径使用
1671
- * ROBOT1.0 开头的消息 ID。映射按适配器实例和会话隔离,避免不同群里的 REFIDX 冲突。
1672
- *
1673
- * @param ctx 当前 QQBot 适配器实例。
1674
- * @param contact 消息所在会话。
1675
- * @param referenceMessageId QQ 引用索引,通常为 REFIDX_...。
1676
- * @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
1677
- */
1678
- const rememberApiMessageId = (ctx, contact, referenceMessageId, apiMessageId) => {
1679
- if (!referenceMessageId || !apiMessageId) return;
1680
- if (referenceMessageId === apiMessageId) return;
1681
- cleanup(ctx);
1682
- const state = getState(ctx);
1683
- const expiresAt = Date.now() + MESSAGE_ID_LINK_CACHE_TTL;
1684
- state.apiMessageIdCache.set(messageIdKey(contact, referenceMessageId), {
1685
- apiMessageId,
1686
- expiresAt
1687
- });
1688
- state.referenceMessageIdCache.set(messageIdKey(contact, apiMessageId), {
1689
- referenceMessageId,
1690
- expiresAt
1691
- });
1692
- };
1693
- /**
1694
- * 将 QQ API 消息 ID 解析为客户端可见引用使用的 REFIDX。
1695
- *
1696
- * @param ctx 当前 QQBot 适配器实例。
1697
- * @param contact 消息所在会话。
1698
- * @param messageId Karin 侧传入的消息 ID,可能是 ROBOT1.0 或 REFIDX。
1699
- * @returns 可用于 `message_reference.message_id` 的 ID;没有映射时返回原值。
1700
- */
1701
- const resolveReferenceMessageId = (ctx, contact, messageId) => {
1702
- cleanup(ctx);
1703
- const state = getState(ctx);
1704
- const key = messageIdKey(contact, messageId);
1705
- const cached = state.referenceMessageIdCache.get(key);
1706
- if (!cached) return messageId;
1707
- if (cached.expiresAt > Date.now()) return cached.referenceMessageId;
1708
- state.referenceMessageIdCache.delete(key);
1709
- return messageId;
1710
- };
1711
- /**
1712
- * 将 Karin 侧传入的消息 ID 解析为 QQ API 可用的消息 ID。
1713
- *
1714
- * @param ctx 当前 QQBot 适配器实例。
1715
- * @param contact 消息所在会话。
1716
- * @param messageId Karin 侧传入的消息 ID,可能是 REFIDX。
1717
- * @returns QQ API 路径可用的消息 ID;没有映射时返回原值。
1718
- */
1719
- const resolveApiMessageId = (ctx, contact, messageId) => {
1720
- cleanup(ctx);
1721
- const state = getState(ctx);
1722
- const key = messageIdKey(contact, messageId);
1723
- const cached = state.apiMessageIdCache.get(key);
1724
- if (!cached) return messageId;
1725
- if (cached.expiresAt > Date.now()) return cached.apiMessageId;
1726
- state.apiMessageIdCache.delete(key);
1727
- return messageId;
1728
- };
1729
-
1730
1627
  //#endregion
1731
1628
  //#region src/core/adapter/text-to-md.ts
1732
1629
  const MAX_BUTTONS = 5;
@@ -1833,6 +1730,7 @@ const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
1833
1730
  const sendQQ = async (ctx, contact, elements) => {
1834
1731
  const target = contact.scene === "friend" ? "user" : "group";
1835
1732
  const grouping = groupElements(contact.scene, elements);
1733
+ await resolveOutgoingReferenceQQ(ctx, contact, grouping);
1836
1734
  if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
1837
1735
  const { text, buttons } = extractUrlButtons(grouping.text.join(""), contact.scene === "friend");
1838
1736
  grouping.text = [text];
@@ -1863,7 +1761,7 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
1863
1761
  warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
1864
1762
  warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
1865
1763
  }
1866
- if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(ctx, contact, grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
1764
+ if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
1867
1765
  else {
1868
1766
  const keyboard = buildKeyboard$1(grouping);
1869
1767
  const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
@@ -1986,8 +1884,8 @@ const appendImageUrlLine = async (lines, url) => {
1986
1884
  * @param grouping 已归类的消息段。
1987
1885
  * @returns 是否使用普通文本发送主消息。
1988
1886
  */
1989
- const shouldUseTextForReference = (ctx, contact, grouping) => {
1990
- return isQQReferenceMessageId(resolveReferenceMessageId(ctx, contact, grouping.reply.messageId)) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
1887
+ const shouldUseTextForReference = (grouping) => {
1888
+ return isQQReferenceMessageId(grouping.reply.messageId) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
1991
1889
  };
1992
1890
  /**
1993
1891
  * 判断给定 ID 是否是 QQ 客户端引用使用的 REFIDX。
@@ -2000,6 +1898,23 @@ const shouldUseTextForReference = (ctx, contact, grouping) => {
2000
1898
  */
2001
1899
  const isQQReferenceMessageId = (messageId) => messageId.startsWith("REFIDX_");
2002
1900
  /**
1901
+ * 发送前把显式引用的 API 消息 ID 解析为 REFIDX。
1902
+ *
1903
+ * 映射只存在于 SQLite 消息缓存(ID 映射行不受缓存开关影响,始终落库)。
1904
+ * 解析结果直接写回 grouping,后续同步路径(降级判断、message_reference
1905
+ * 附加)无需再查库。
1906
+ *
1907
+ * @param ctx 适配器实例。
1908
+ * @param contact 消息目标会话。
1909
+ * @param grouping 已归类的消息段。
1910
+ */
1911
+ const resolveOutgoingReferenceQQ = async (ctx, contact, grouping) => {
1912
+ const original = grouping.reply.messageId;
1913
+ if (!original || isQQReferenceMessageId(original)) return;
1914
+ const fromStore = await ctx.messageStore.resolveRefIdx(String(ctx.cfg.appId), contact, original).catch(() => null);
1915
+ if (fromStore) grouping.reply.messageId = fromStore;
1916
+ };
1917
+ /**
2003
1918
  * 构造 keyboard 字段(buttons + keyboards 合并)
2004
1919
  */
2005
1920
  const buildKeyboard$1 = (grouping) => {
@@ -2053,10 +1968,8 @@ const flushQQ = async (ctx, contact, grouping, items) => {
2053
1968
  let referenceHandled = false;
2054
1969
  for (const item of items.slice(0, maxItems)) {
2055
1970
  passive(item);
2056
- if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(ctx, contact, item, grouping);
1971
+ if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(item, grouping);
2057
1972
  const res = await sendQQWithEventFallback(ctx, contact, send, item);
2058
- rememberOwnMessageId(ctx, contact, res.id);
2059
- if (res.ext_info?.ref_idx) rememberApiMessageId(ctx, contact, res.ext_info.ref_idx, res.id);
2060
1973
  result.rawData.push(res);
2061
1974
  }
2062
1975
  return ctx.handleResponse(result);
@@ -2115,14 +2028,14 @@ const resolvePassiveQQ = (grouping) => {
2115
2028
  * 附加 QQ 客户端可见的引用对象。
2116
2029
  *
2117
2030
  * 群聊/单聊里真正发给 QQ 的值必须是 `message_scene.ext` 的 `msg_idx=REFIDX_xxx`。
2118
- * 如果插件传入的是 Karin 暴露的官方 API 消息 ID,这里会先按同会话映射转换。
2031
+ * API 消息 ID 到 REFIDX 的转换已由 `resolveOutgoingReferenceQQ` 在发送前完成。
2119
2032
  *
2120
2033
  * @param item 即将发送的 QQ 消息体。
2121
2034
  * @param grouping 已归类的消息段。
2122
2035
  * @returns 是否已经消费一次显式引用。
2123
2036
  */
2124
- const attachVisibleReferenceQQ = (ctx, contact, item, grouping) => {
2125
- const messageId = resolveReferenceMessageId(ctx, contact, grouping.reply.messageId);
2037
+ const attachVisibleReferenceQQ = (item, grouping) => {
2038
+ const messageId = grouping.reply.messageId;
2126
2039
  if (!isQQReferenceMessageId(messageId)) return false;
2127
2040
  item.message_reference = { message_id: messageId };
2128
2041
  return true;
@@ -2377,31 +2290,38 @@ const prepareSelfMessageCache = async (ctx, elements) => {
2377
2290
  };
2378
2291
  };
2379
2292
  /**
2380
- * 发送成功后缓存机器人自己的消息,供 bot.getMsg(messageId) 查询。
2293
+ * 发送成功后记录机器人自己的消息。
2294
+ *
2295
+ * 无论缓存开关如何,都会落库最小 ID 映射行(msg_id/ref_idx/is_self)——
2296
+ * 单聊撤回判定和引用解析没有任何内存映射,完全依赖数据库。
2297
+ * 只有 self 缓存开启时才附带消息正文,供 bot.getMsg(messageId) 查询。
2381
2298
  */
2382
2299
  const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
2383
- if (!shouldCacheSelfMessage(ctx)) return;
2384
- if (!cacheElements.length) return;
2385
2300
  const responses = (Array.isArray(result.rawData) ? result.rawData : [result.rawData]).filter((item) => item?.id);
2386
2301
  if (!responses.length) return;
2302
+ const withContent = shouldCacheSelfMessage(ctx) && cacheElements.length > 0;
2303
+ const elements = withContent ? cacheElements : [];
2304
+ const level = withContent ? ctx.cfg.messageCache.level : "minimal";
2387
2305
  const sender = selfSender(ctx, contact);
2388
2306
  const seen = /* @__PURE__ */ new Set();
2389
2307
  for (const response of responses) {
2390
2308
  const apiMessageId = String(response.id);
2391
2309
  const referenceMessageId = typeof response.ext_info?.ref_idx === "string" ? response.ext_info.ref_idx : "";
2392
2310
  const messageId = apiMessageId;
2393
- const aliases = [referenceMessageId].filter((id) => id && id !== messageId);
2394
2311
  if (!messageId || seen.has(messageId)) continue;
2395
2312
  seen.add(messageId);
2396
- if (referenceMessageId) rememberApiMessageId(ctx, contact, referenceMessageId, apiMessageId);
2397
2313
  ctx.messageStore.save(String(ctx.cfg.appId), {
2398
2314
  messageId,
2399
2315
  messageSeq: 0,
2400
2316
  time: responseTime(response.timestamp),
2401
2317
  contact,
2402
2318
  sender,
2403
- elements: cacheElements
2404
- }, aliases).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
2319
+ elements
2320
+ }, {
2321
+ refIdx: referenceMessageId && referenceMessageId !== messageId ? referenceMessageId : void 0,
2322
+ isSelf: true,
2323
+ level
2324
+ }).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
2405
2325
  }
2406
2326
  };
2407
2327
  /**
@@ -2523,6 +2443,8 @@ const responseTime = (timestamp) => {
2523
2443
  //#endregion
2524
2444
  //#region src/core/adapter/base.ts
2525
2445
  const adapterConfigStore = /* @__PURE__ */ new WeakMap();
2446
+ /** HTTP(S) URL 可直接交给 QQ 平台拉取上传 */
2447
+ const HTTP_URL_RE = /^https?:\/\//i;
2526
2448
  /**
2527
2449
  * QQ Official Bot 适配器
2528
2450
  *
@@ -2567,18 +2489,17 @@ var AdapterQQBot = class extends AdapterBase {
2567
2489
  * 发送消息
2568
2490
  */
2569
2491
  async sendMsg(contact, elements, _retryCount) {
2492
+ /** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
2570
2493
  if (contact.scene === "direct" || contact.scene === "guild") {
2571
- if (!shouldCacheSelfMessage(this)) return sendGuild(this, contact, elements);
2572
- const prepared = await prepareSelfMessageCache(this, elements);
2573
- const result = await sendGuild(this, contact, prepared.sendElements);
2574
- cacheSelfMessage(this, contact, prepared.cacheElements, result);
2494
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
2495
+ const result = await sendGuild(this, contact, prepared?.sendElements ?? elements);
2496
+ cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
2575
2497
  return result;
2576
2498
  }
2577
2499
  if (contact.scene === "group" || contact.scene === "friend") {
2578
- if (!shouldCacheSelfMessage(this)) return sendQQ(this, contact, elements);
2579
- const prepared = await prepareSelfMessageCache(this, elements);
2580
- const result = await sendQQ(this, contact, prepared.sendElements);
2581
- cacheSelfMessage(this, contact, prepared.cacheElements, result);
2500
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
2501
+ const result = await sendQQ(this, contact, prepared?.sendElements ?? elements);
2502
+ cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
2582
2503
  return result;
2583
2504
  }
2584
2505
  throw new Error(`不支持的消息场景: ${contact.scene}`);
@@ -2587,12 +2508,18 @@ var AdapterQQBot = class extends AdapterBase {
2587
2508
  * 撤回消息
2588
2509
  *
2589
2510
  * 群聊中机器人被群主设为管理员后,也可撤回成员消息;平台仍限制消息发送后两分钟内。
2511
+ * REFIDX 与 API 消息 ID 的映射、单聊"是否自己发送"的判定都直接查询
2512
+ * SQLite 消息缓存(ID 映射行不受缓存开关影响,始终落库)。
2590
2513
  */
2591
2514
  async recallMsg(contact, messageId) {
2592
- const apiMessageId = resolveApiMessageId(this, contact, messageId);
2515
+ let apiMessageId = messageId;
2516
+ if (apiMessageId.startsWith("REFIDX_")) {
2517
+ const resolved = await this.messageStore.resolveApiMessageId(String(this.cfg.appId), contact, apiMessageId).catch(() => null);
2518
+ if (resolved) apiMessageId = resolved;
2519
+ }
2593
2520
  try {
2594
2521
  if (contact.scene === "friend") {
2595
- if (!isOwnMessageId(this, contact, apiMessageId)) {
2522
+ if (!await this.messageStore.isSelfMessage(String(this.cfg.appId), contact, apiMessageId).catch(() => false)) {
2596
2523
  this.logger("warn", `[recallMsg] QQ 单聊只能撤回机器人自己发送的消息,已跳过撤回: ${messageId}`);
2597
2524
  return;
2598
2525
  }
@@ -2887,10 +2814,12 @@ var AdapterQQBot = class extends AdapterBase {
2887
2814
  }
2888
2815
  /**
2889
2816
  * 获取群信息
2817
+ *
2818
+ * 走官方 `GET /v2/groups/{group_openid}/info`(白名单接口)。
2819
+ * 未开通权限(11253)或查询失败时降级为空结构并告警。
2890
2820
  */
2891
2821
  async getGroupInfo(_groupId, _noCache) {
2892
- this.logger("warn", "[getGroupInfo] QQ Official Bot API 暂不支持获取群信息");
2893
- return {
2822
+ const empty = {
2894
2823
  groupId: _groupId,
2895
2824
  groupName: "",
2896
2825
  owner: "",
@@ -2901,6 +2830,19 @@ var AdapterQQBot = class extends AdapterBase {
2901
2830
  groupDesc: "",
2902
2831
  avatar: ""
2903
2832
  };
2833
+ try {
2834
+ const info = await this.super.groups.getGroupInfo(_groupId);
2835
+ return {
2836
+ ...empty,
2837
+ groupName: info.group_name || "",
2838
+ memberCount: info.group_member_num ?? 0,
2839
+ groupDesc: info.group_finger_memo || "",
2840
+ avatar: await this.getGroupAvatarUrl(_groupId)
2841
+ };
2842
+ } catch (err) {
2843
+ this.logger("warn", `[getGroupInfo] 获取群信息失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
2844
+ return empty;
2845
+ }
2904
2846
  }
2905
2847
  /**
2906
2848
  * 获取群列表
@@ -2911,13 +2853,43 @@ var AdapterQQBot = class extends AdapterBase {
2911
2853
  }
2912
2854
  /**
2913
2855
  * 获取群成员信息
2856
+ *
2857
+ * - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`(白名单)。
2858
+ * - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,
2859
+ * 该接口未公开文档,来自腾讯官方 openclaw 插件 v2.0.0,返回结构以平台实际为准。
2860
+ * 接口不可用(如白名单未开通返回 11253)时降级为占位结构并告警。
2914
2861
  */
2915
2862
  async getGroupMemberInfo(_groupId, _targetId, _refresh) {
2916
- this.logger("warn", "[getGroupMemberInfo] QQ Official Bot API 暂不支持获取群成员信息");
2917
- return {
2918
- userId: _targetId,
2919
- role: "member"
2920
- };
2863
+ /** 按 karin 接口约定构造返回值,sender 由 getter 即时构建 */
2864
+ const build = (userId, role, nick, joinTime) => ({
2865
+ userId,
2866
+ role,
2867
+ nick,
2868
+ joinTime,
2869
+ get sender() {
2870
+ return senderGroup({
2871
+ userId,
2872
+ nick: nick || "",
2873
+ name: nick || "",
2874
+ role
2875
+ });
2876
+ }
2877
+ });
2878
+ if (_targetId === this.selfId) try {
2879
+ const state = await this.super.groups.getBotState(_groupId);
2880
+ return build(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
2881
+ } catch (err) {
2882
+ this.logger("warn", `[getGroupMemberInfo] 获取机器人群内状态失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
2883
+ return build(_targetId, "member");
2884
+ }
2885
+ try {
2886
+ const member = await this.super.groups.getGroupMember(_groupId, _targetId);
2887
+ if (member.nick) this.nicknameCache.set(_targetId, member.nick);
2888
+ return build(member.member_openid || _targetId, member.member_role || "member", member.nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
2889
+ } catch (err) {
2890
+ this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口未公开文档,可能不可用): ${err instanceof Error ? err.message : err}`);
2891
+ return build(_targetId, "member");
2892
+ }
2921
2893
  }
2922
2894
  /**
2923
2895
  * 获取群成员列表
@@ -2959,9 +2931,19 @@ var AdapterQQBot = class extends AdapterBase {
2959
2931
  }
2960
2932
  /**
2961
2933
  * 上传群文件、私聊文件
2934
+ *
2935
+ * QQ 官方 Bot API 没有群文件系统,上传走 `POST /v2/{users|groups}/{openid}/files`
2936
+ * (file_type=4 + srv_send_msg=true),由服务端直接发送文件消息。
2937
+ * 频道 / 频道私信场景官方无文件接口,暂不支持。
2962
2938
  */
2963
2939
  async uploadFile(_contact, _file, _name, _folder) {
2964
- this.logger("warn", "[uploadFile] QQ Official Bot API 暂不支持上传文件");
2940
+ if (_contact.scene !== "friend" && _contact.scene !== "group") {
2941
+ this.logger("warn", "[uploadFile] 频道场景 QQ Official Bot API 暂不支持上传文件");
2942
+ return;
2943
+ }
2944
+ const scene = _contact.scene === "friend" ? "user" : "group";
2945
+ if (HTTP_URL_RE.test(_file)) await this.super.media.upload(scene, _contact.peer, "file", _file, true, _name);
2946
+ else await this.super.media.uploadFallback(scene, _contact.peer, "file", _file, true, _name);
2965
2947
  }
2966
2948
  /**
2967
2949
  * 下载文件到协议端本地
@@ -2996,10 +2978,18 @@ var AdapterQQBot = class extends AdapterBase {
2996
2978
  }
2997
2979
  /**
2998
2980
  * 上传群文件
2981
+ *
2982
+ * 实现同 {@link uploadFile}:官方无群文件系统,走 files 接口由服务端直发文件消息。
2999
2983
  */
3000
2984
  async uploadGroupFile(_groupId, _file, _name) {
3001
- this.logger("warn", "[uploadGroupFile] QQ Official Bot API 暂不支持上传群文件");
3002
- return false;
2985
+ try {
2986
+ if (HTTP_URL_RE.test(_file)) await this.super.media.upload("group", _groupId, "file", _file, true, _name);
2987
+ else await this.super.media.uploadFallback("group", _groupId, "file", _file, true, _name);
2988
+ return true;
2989
+ } catch (err) {
2990
+ this.logger("warn", `[uploadGroupFile] 上传群文件失败: ${err instanceof Error ? err.message : err}`);
2991
+ return false;
2992
+ }
3003
2993
  }
3004
2994
  /**
3005
2995
  * 获取文件 url
@@ -3363,7 +3353,7 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
3363
3353
  * QQ 不会提供引用消息的正式消息 ID;调用方应以 `msg_idx` 作为缓存键。
3364
3354
  */
3365
3355
  const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
3366
- return convertToKarin(appid, {
3356
+ const referenceEvent = {
3367
3357
  ...ev,
3368
3358
  d: {
3369
3359
  ...ev.d,
@@ -3374,26 +3364,33 @@ const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
3374
3364
  msg_elements: void 0,
3375
3365
  message_type: reference.message_type
3376
3366
  }
3377
- }, selfSubId);
3367
+ };
3368
+ return convertToKarin(appid, referenceEvent, selfSubId);
3378
3369
  };
3379
3370
 
3380
3371
  //#endregion
3381
3372
  //#region src/core/event/message.ts
3382
3373
  /**
3383
- * 写入 getMsg 热缓存并投递 SQLite 后台队列。
3374
+ * 把消息投递 SQLite 后台写入队列。
3384
3375
  *
3385
3376
  * 不等待数据库落库,避免缓存特性拖慢 Karin 消息事件下发。
3386
3377
  *
3378
+ * 关闭消息缓存时仍按 minimal 级落库最小 ID 映射行(msg_id/ref_idx)——
3379
+ * 撤回和引用解析没有任何内存映射,完全依赖数据库。
3380
+ *
3387
3381
  * @param client 当前 QQBot 适配器实例。
3388
3382
  * @param message 需要缓存的消息快照。
3389
- * @param aliases 可用于查询同一消息的 QQ 侧索引,例如 msg_idx。
3383
+ * @param refIdx QQ `msg_idx` 引用索引,作为该消息的第二查询索引。
3390
3384
  */
3391
- const cacheMessage = (client, message, aliases = []) => {
3392
- if (!client.cfg.messageCache.enable) return;
3385
+ const cacheMessage = (client, message, refIdx) => {
3386
+ const { enable, level } = client.cfg.messageCache;
3393
3387
  client.messageStore.save(String(client.cfg.appId), {
3394
3388
  ...message,
3395
3389
  messageSeq: 0
3396
- }, aliases).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
3390
+ }, {
3391
+ refIdx,
3392
+ level: enable ? level : "minimal"
3393
+ }).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
3397
3394
  };
3398
3395
  /**
3399
3396
  * 写入引用消息上下文缓存。
@@ -3408,7 +3405,7 @@ const cacheReference = (client, message) => {
3408
3405
  client.messageStore.saveReferenceIfAbsent(String(client.cfg.appId), {
3409
3406
  ...message,
3410
3407
  messageSeq: 0
3411
- }).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
3408
+ }, client.cfg.messageCache.level).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
3412
3409
  };
3413
3410
  /**
3414
3411
  * 将 Karin 快速回复自动生成的当前消息引用 ID 转成 QQ 客户端可见引用 ID。
@@ -3446,7 +3443,6 @@ const onGroupMsg = (client, ev, opts) => {
3446
3443
  const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
3447
3444
  const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
3448
3445
  const karinMessageId = ev.d.id;
3449
- if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
3450
3446
  if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
3451
3447
  const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3452
3448
  cacheMessage(client, {
@@ -3455,7 +3451,7 @@ const onGroupMsg = (client, ev, opts) => {
3455
3451
  contact,
3456
3452
  sender,
3457
3453
  elements
3458
- }, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
3454
+ }, messageIndex && messageIndex !== referenceIndex ? messageIndex : void 0);
3459
3455
  const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
3460
3456
  if (reference && referenceIndex) cacheReference(client, {
3461
3457
  messageId: referenceIndex,
@@ -3492,7 +3488,6 @@ const onFriendMsg = (client, ev) => {
3492
3488
  const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
3493
3489
  const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
3494
3490
  const karinMessageId = ev.d.id;
3495
- if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
3496
3491
  if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
3497
3492
  const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3498
3493
  cacheMessage(client, {
@@ -3501,7 +3496,7 @@ const onFriendMsg = (client, ev) => {
3501
3496
  contact,
3502
3497
  sender,
3503
3498
  elements
3504
- }, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
3499
+ }, messageIndex && messageIndex !== referenceIndex ? messageIndex : void 0);
3505
3500
  const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
3506
3501
  if (reference && referenceIndex) cacheReference(client, {
3507
3502
  messageId: referenceIndex,
@@ -4466,7 +4461,7 @@ const fetchGateway = async (cfg) => {
4466
4461
  const response = err.response;
4467
4462
  const status = response?.status ?? 0;
4468
4463
  const data = response?.data;
4469
- const detail = formatOpenAPIError(status, typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
4464
+ const detail = formatOpenAPIError(status, typeof data?.err_code === "number" ? data.err_code : typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
4470
4465
  log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WS ${fallback}`);
4471
4466
  } else {
4472
4467
  const reason = err?.message || "unknown";