@karinjs/adapter-qqbot 2.0.0 → 2.1.0

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