@karinjs/adapter-qqbot 2.0.0 → 2.2.1

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 joinHttpPath, d as normalizeWsGatewayUrl, f as pkg, h as basename, l as normalizeHttpUrl, m as log, n as config, r as getConfig, s as buildFallbackWsUrlFromApi, t as bindHandlers, u as normalizeProxyConfig } from "./config-B_Gc9JKZ.js";
2
+ import { i as random, n as getImageSize, r as handleUrl, t as fakeEvent } from "./common-by1GpV0b.js";
3
+ import "./utils-DIq0lGMt.js";
4
+ import { t as getMessageStore } from "./message-Dt4QkDYn.js";
5
+ import { t as needQrOnboard } from "./onboard-BXQ1cG8h.js";
6
+ import { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, unregisterBot } from "node-karin";
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();
@@ -632,12 +1280,13 @@ const tokens = /* @__PURE__ */ new Map();
632
1280
  * - 同一 appId 旧定时器会被清理,避免并发刷新链
633
1281
  */
634
1282
  const getAccessToken = async (url, appId, secret) => {
1283
+ const tokenUrl = normalizeHttpUrl(url);
635
1284
  let res;
636
1285
  try {
637
- res = await axios.post(url, {
1286
+ res = await axios.post(tokenUrl, {
638
1287
  appId: String(appId),
639
1288
  clientSecret: secret
640
- });
1289
+ }, { headers: { "User-Agent": getUserAgent() } });
641
1290
  } catch (err) {
642
1291
  if (axios.isAxiosError(err)) {
643
1292
  const response = err.response;
@@ -656,7 +1305,7 @@ const getAccessToken = async (url, appId, secret) => {
656
1305
  tokens.set(appId, state);
657
1306
  const delay = Math.max(5e3, expiresIn * 1e3 - 5e4);
658
1307
  state.refreshTimer = setTimeout(() => {
659
- getAccessToken(url, appId, secret).catch(() => {});
1308
+ getAccessToken(tokenUrl, appId, secret).catch(() => {});
660
1309
  }, delay);
661
1310
  return {
662
1311
  accessToken,
@@ -683,8 +1332,11 @@ const stopTokenRefresh = (appId) => {
683
1332
  */
684
1333
  const createAxiosInstance = (baseURL, appId) => {
685
1334
  const instance = axios.create({
686
- baseURL,
687
- headers: { "Content-Type": "application/json" },
1335
+ baseURL: normalizeHttpUrl(baseURL),
1336
+ headers: {
1337
+ "Content-Type": "application/json",
1338
+ "User-Agent": getUserAgent()
1339
+ },
688
1340
  timeout: 5500
689
1341
  });
690
1342
  instance.interceptors.request.use((config) => {
@@ -695,9 +1347,92 @@ const createAxiosInstance = (baseURL, appId) => {
695
1347
  return instance;
696
1348
  };
697
1349
 
1350
+ //#endregion
1351
+ //#region src/core/adapter/button-enter.ts
1352
+ /** QQ 私聊直发文本链标签。 */
1353
+ const COMMAND_ENTER_TEXT_CHAIN_REGEXP = /<\s*qqbot-cmd-enter\b/i;
1354
+ /**
1355
+ * 判断按钮是否是 `enter: true` 指令按钮。
1356
+ *
1357
+ * 链接按钮和回调按钮有自己的平台行为,只有普通指令按钮才需要提示
1358
+ * `enter: true` 在非私聊场景不生效。
1359
+ *
1360
+ * @param button Karin 按钮配置。
1361
+ * @returns 是否为直发指令按钮。
1362
+ */
1363
+ const isCommandEnterButton = (button) => {
1364
+ if (button.link || button.callback) return false;
1365
+ return (button.type ?? 2) === 2 && button.enter === true;
1366
+ };
1367
+ /**
1368
+ * 判断 markdown 内容是否手写了 QQ 私聊直发文本链。
1369
+ *
1370
+ * @param markdown markdown 内容。
1371
+ * @returns 是否包含 `<qqbot-cmd-enter ...>`。
1372
+ */
1373
+ const hasCommandEnterTextChain = (markdown) => {
1374
+ return COMMAND_ENTER_TEXT_CHAIN_REGEXP.test(markdown);
1375
+ };
1376
+ /**
1377
+ * 收集 button/keyboard 中所有 `enter: true` 指令按钮。
1378
+ *
1379
+ * @param buttons 单行按钮元素列表。
1380
+ * @param keyboards 多行 keyboard 元素列表。
1381
+ * @returns 需要特殊处理的直发指令按钮。
1382
+ */
1383
+ const collectCommandEnterButtons = (buttons, keyboards) => {
1384
+ const result = [];
1385
+ buttons.forEach((element) => {
1386
+ element.data.forEach((button) => {
1387
+ if (isCommandEnterButton(button)) result.push(button);
1388
+ });
1389
+ });
1390
+ keyboards.forEach((element) => {
1391
+ element.rows.forEach((row) => {
1392
+ row.forEach((button) => {
1393
+ if (isCommandEnterButton(button)) result.push(button);
1394
+ });
1395
+ });
1396
+ });
1397
+ return result;
1398
+ };
1399
+ /**
1400
+ * 归一 QQ keyboard 按钮字段。
1401
+ *
1402
+ * @param button node-karin 转换后的 QQBot 按钮。
1403
+ * @param id 当前 keyboard 内唯一按钮序号。
1404
+ * @returns 可直接放入 QQ 官方 keyboard 的按钮对象。
1405
+ */
1406
+ const normalizeQQBotButton = (button, id) => {
1407
+ return {
1408
+ ...button,
1409
+ id: String(id)
1410
+ };
1411
+ };
1412
+ /**
1413
+ * 生成直发按钮不支持提示中的按钮名称列表。
1414
+ *
1415
+ * @param buttons 直发指令按钮列表。
1416
+ * @returns 便于写入日志的按钮名称。
1417
+ */
1418
+ const formatCommandEnterButtonNames = (buttons) => {
1419
+ return buttons.map((button) => button.text || button.data || "未命名按钮").join("、");
1420
+ };
1421
+
698
1422
  //#endregion
699
1423
  //#region src/core/adapter/grouping.ts
700
1424
  /**
1425
+ * 生成 QQ 被动回复使用的 msg_seq 种子。
1426
+ *
1427
+ * QQ 官方使用 `msg_id + msg_seq` 判重。这里和 openclaw qqbot 适配器一样限制在
1428
+ * 16-bit 范围内,后续每发一条再递增取模。
1429
+ *
1430
+ * @returns 0..65535 范围内的初始 msg_seq。
1431
+ */
1432
+ const createMsgSeqSeed = () => {
1433
+ return (Date.now() % 1e8 ^ Math.floor(Math.random() * 65536)) % 65536;
1434
+ };
1435
+ /**
701
1436
  * 创建空容器
702
1437
  */
703
1438
  const createGrouping = () => ({
@@ -713,7 +1448,7 @@ const createGrouping = () => ({
713
1448
  pasmsg: {
714
1449
  type: "msg",
715
1450
  id: "",
716
- seq: random(1, 9999999)
1451
+ seq: createMsgSeqSeed()
717
1452
  },
718
1453
  faces: []
719
1454
  });
@@ -761,19 +1496,22 @@ const groupElements = (scene, elements) => {
761
1496
  case "video":
762
1497
  g.media.push({
763
1498
  kind: "video",
764
- source: v.file
1499
+ source: v.file,
1500
+ name: v.name
765
1501
  });
766
1502
  break;
767
1503
  case "record":
768
1504
  g.media.push({
769
1505
  kind: "record",
770
- source: v.file
1506
+ source: v.file,
1507
+ name: v.name
771
1508
  });
772
1509
  break;
773
1510
  case "file":
774
1511
  g.media.push({
775
1512
  kind: "file",
776
- source: v.file
1513
+ source: v.file,
1514
+ name: v.name
777
1515
  });
778
1516
  break;
779
1517
  default: break;
@@ -781,9 +1519,218 @@ const groupElements = (scene, elements) => {
781
1519
  return g;
782
1520
  };
783
1521
 
1522
+ //#endregion
1523
+ //#region src/core/adapter/media-source.ts
1524
+ /** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
1525
+ const HTTP_URL_RE = /^https?:\/\//i;
1526
+ /** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
1527
+ const DEFAULT_FILENAME = {
1528
+ image: "image.jpg",
1529
+ video: "video.mp4",
1530
+ record: "record.mp3",
1531
+ file: "file.bin"
1532
+ };
1533
+ /**
1534
+ * 当前 Karin 运行时是否注册了 fileToUrl 处理器。
1535
+ * @returns true 表示可以优先通过 fileToUrl 产出公网 URL。
1536
+ */
1537
+ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
1538
+ /**
1539
+ * 判断一个来源是否已经是公网 HTTP(S) 地址。
1540
+ * @param source Karin 消息段里的 file 字段。
1541
+ * @returns 是否为 HTTP(S) URL。
1542
+ */
1543
+ const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
1544
+ /**
1545
+ * 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
1546
+ *
1547
+ * QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
1548
+ * fileToUrl 作为主路径,QQ 上传只做兜底。
1549
+ *
1550
+ * @param ctx 适配器实例,用于记录降级日志。
1551
+ * @param type 富媒体类型。
1552
+ * @param source Karin 消息段里的 file 字段。
1553
+ * @param filename 可选文件名,主要用于 file 消息。
1554
+ * @returns 解析后的首选来源和来源类型。
1555
+ */
1556
+ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
1557
+ if (isHttpMediaSource(source)) return {
1558
+ source,
1559
+ via: "public-url"
1560
+ };
1561
+ if (!hasFileToUrlHandler()) return {
1562
+ source,
1563
+ via: "fallback"
1564
+ };
1565
+ try {
1566
+ return {
1567
+ source: (await fileToUrl(type, source, filename || DEFAULT_FILENAME[type])).url,
1568
+ via: "file-to-url"
1569
+ };
1570
+ } catch (err) {
1571
+ ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
1572
+ return {
1573
+ source,
1574
+ via: "fallback"
1575
+ };
1576
+ }
1577
+ };
1578
+
1579
+ //#endregion
1580
+ //#region src/core/adapter/message-id-map.ts
1581
+ /** QQ 消息 ID 与 REFIDX 映射的短期缓存时长,和本地消息缓存窗口保持一致。 */
1582
+ const MESSAGE_ID_LINK_CACHE_TTL = 1440 * 60 * 1e3;
1583
+ /** QQ 消息 ID 映射表清理间隔。 */
1584
+ const MESSAGE_ID_LINK_CLEANUP_INTERVAL = 60 * 1e3;
1585
+ /** 按适配器实例隔离的消息 ID 映射状态。 */
1586
+ const states = /* @__PURE__ */ new WeakMap();
1587
+ /**
1588
+ * 获取适配器实例对应的消息 ID 映射状态。
1589
+ *
1590
+ * @param ctx 当前 QQBot 适配器实例。
1591
+ * @returns 该实例的映射状态。
1592
+ */
1593
+ const getState = (ctx) => {
1594
+ const existing = states.get(ctx);
1595
+ if (existing) return existing;
1596
+ const created = {
1597
+ apiMessageIdCache: /* @__PURE__ */ new Map(),
1598
+ referenceMessageIdCache: /* @__PURE__ */ new Map(),
1599
+ ownMessageIdCache: /* @__PURE__ */ new Map(),
1600
+ lastCleanup: 0
1601
+ };
1602
+ states.set(ctx, created);
1603
+ return created;
1604
+ };
1605
+ /**
1606
+ * 构造 REFIDX/API ID 映射的会话级 key。
1607
+ *
1608
+ * @param contact 消息所在会话。
1609
+ * @param messageId 消息 ID。
1610
+ * @returns 可用于 Map 的稳定 key。
1611
+ */
1612
+ const messageIdKey = (contact, messageId) => {
1613
+ return [
1614
+ contact.scene,
1615
+ contact.peer,
1616
+ contact.subPeer || "",
1617
+ messageId
1618
+ ].join("");
1619
+ };
1620
+ /**
1621
+ * 清理过期的消息 ID 映射。
1622
+ *
1623
+ * @param ctx 当前 QQBot 适配器实例。
1624
+ */
1625
+ const cleanup = (ctx) => {
1626
+ const state = getState(ctx);
1627
+ const now = Date.now();
1628
+ if (now - state.lastCleanup < MESSAGE_ID_LINK_CLEANUP_INTERVAL) return;
1629
+ state.lastCleanup = now;
1630
+ for (const [key, entry] of state.apiMessageIdCache) if (entry.expiresAt <= now) state.apiMessageIdCache.delete(key);
1631
+ for (const [key, entry] of state.referenceMessageIdCache) if (entry.expiresAt <= now) state.referenceMessageIdCache.delete(key);
1632
+ for (const [key, entry] of state.ownMessageIdCache) if (entry.expiresAt <= now) state.ownMessageIdCache.delete(key);
1633
+ };
1634
+ /**
1635
+ * 记住机器人自己发送的 QQ API 消息 ID。
1636
+ *
1637
+ * 单聊场景只允许撤回机器人自己发送的消息。收到用户消息时的 `e.messageId`
1638
+ * 也是 API 消息 ID,但不能用于单聊撤回,因此需要独立标记来源。
1639
+ *
1640
+ * @param ctx 当前 QQBot 适配器实例。
1641
+ * @param contact 消息所在会话。
1642
+ * @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
1643
+ */
1644
+ const rememberOwnMessageId = (ctx, contact, apiMessageId) => {
1645
+ if (!apiMessageId) return;
1646
+ cleanup(ctx);
1647
+ getState(ctx).ownMessageIdCache.set(messageIdKey(contact, apiMessageId), { expiresAt: Date.now() + MESSAGE_ID_LINK_CACHE_TTL });
1648
+ };
1649
+ /**
1650
+ * 判断给定 QQ API 消息 ID 是否由机器人自己发送。
1651
+ *
1652
+ * @param ctx 当前 QQBot 适配器实例。
1653
+ * @param contact 消息所在会话。
1654
+ * @param apiMessageId QQ API 消息 ID。
1655
+ * @returns true 表示这条消息是当前适配器实例发送的。
1656
+ */
1657
+ const isOwnMessageId = (ctx, contact, apiMessageId) => {
1658
+ cleanup(ctx);
1659
+ const state = getState(ctx);
1660
+ const key = messageIdKey(contact, apiMessageId);
1661
+ const cached = state.ownMessageIdCache.get(key);
1662
+ if (!cached) return false;
1663
+ if (cached.expiresAt > Date.now()) return true;
1664
+ state.ownMessageIdCache.delete(key);
1665
+ return false;
1666
+ };
1667
+ /**
1668
+ * 记住 QQ 引用索引对应的 API 消息 ID。
1669
+ *
1670
+ * QQ 群聊/单聊的 `message_reference.message_id` 使用 REFIDX,但撤回等 API 路径使用
1671
+ * ROBOT1.0 开头的消息 ID。映射按适配器实例和会话隔离,避免不同群里的 REFIDX 冲突。
1672
+ *
1673
+ * @param ctx 当前 QQBot 适配器实例。
1674
+ * @param contact 消息所在会话。
1675
+ * @param referenceMessageId QQ 引用索引,通常为 REFIDX_...。
1676
+ * @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
1677
+ */
1678
+ const rememberApiMessageId = (ctx, contact, referenceMessageId, apiMessageId) => {
1679
+ if (!referenceMessageId || !apiMessageId) return;
1680
+ if (referenceMessageId === apiMessageId) return;
1681
+ cleanup(ctx);
1682
+ const state = getState(ctx);
1683
+ const expiresAt = Date.now() + MESSAGE_ID_LINK_CACHE_TTL;
1684
+ state.apiMessageIdCache.set(messageIdKey(contact, referenceMessageId), {
1685
+ apiMessageId,
1686
+ expiresAt
1687
+ });
1688
+ state.referenceMessageIdCache.set(messageIdKey(contact, apiMessageId), {
1689
+ referenceMessageId,
1690
+ expiresAt
1691
+ });
1692
+ };
1693
+ /**
1694
+ * 将 QQ API 消息 ID 解析为客户端可见引用使用的 REFIDX。
1695
+ *
1696
+ * @param ctx 当前 QQBot 适配器实例。
1697
+ * @param contact 消息所在会话。
1698
+ * @param messageId Karin 侧传入的消息 ID,可能是 ROBOT1.0 或 REFIDX。
1699
+ * @returns 可用于 `message_reference.message_id` 的 ID;没有映射时返回原值。
1700
+ */
1701
+ const resolveReferenceMessageId = (ctx, contact, messageId) => {
1702
+ cleanup(ctx);
1703
+ const state = getState(ctx);
1704
+ const key = messageIdKey(contact, messageId);
1705
+ const cached = state.referenceMessageIdCache.get(key);
1706
+ if (!cached) return messageId;
1707
+ if (cached.expiresAt > Date.now()) return cached.referenceMessageId;
1708
+ state.referenceMessageIdCache.delete(key);
1709
+ return messageId;
1710
+ };
1711
+ /**
1712
+ * 将 Karin 侧传入的消息 ID 解析为 QQ API 可用的消息 ID。
1713
+ *
1714
+ * @param ctx 当前 QQBot 适配器实例。
1715
+ * @param contact 消息所在会话。
1716
+ * @param messageId Karin 侧传入的消息 ID,可能是 REFIDX。
1717
+ * @returns QQ API 路径可用的消息 ID;没有映射时返回原值。
1718
+ */
1719
+ const resolveApiMessageId = (ctx, contact, messageId) => {
1720
+ cleanup(ctx);
1721
+ const state = getState(ctx);
1722
+ const key = messageIdKey(contact, messageId);
1723
+ const cached = state.apiMessageIdCache.get(key);
1724
+ if (!cached) return messageId;
1725
+ if (cached.expiresAt > Date.now()) return cached.apiMessageId;
1726
+ state.apiMessageIdCache.delete(key);
1727
+ return messageId;
1728
+ };
1729
+
784
1730
  //#endregion
785
1731
  //#region src/core/adapter/text-to-md.ts
786
1732
  const MAX_BUTTONS = 5;
1733
+ const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
787
1734
  /**
788
1735
  * 提取文本中的 URL,将其替换为 `[按钮 N]` 占位文本并返回 button 列表
789
1736
  *
@@ -824,17 +1771,58 @@ const imagesToMarkdown = async (urls) => {
824
1771
  return `![karin #${width}px #${height}px](${url})`;
825
1772
  }));
826
1773
  };
1774
+ /**
1775
+ * 将 markdown 文本拆为普通文本片段与图片来源。
1776
+ */
1777
+ const splitMarkdownImages = (markdown) => {
1778
+ const parts = [];
1779
+ let lastIndex = 0;
1780
+ for (const match of markdown.matchAll(MARKDOWN_IMAGE_RE)) {
1781
+ const index = match.index ?? 0;
1782
+ if (index > lastIndex) {
1783
+ const text = markdown.slice(lastIndex, index);
1784
+ if (text) parts.push({
1785
+ type: "text",
1786
+ value: text
1787
+ });
1788
+ }
1789
+ const source = (match[1] || "").trim().replace(/^<|>$/g, "");
1790
+ if (source) parts.push({
1791
+ type: "image",
1792
+ source
1793
+ });
1794
+ lastIndex = index + match[0].length;
1795
+ }
1796
+ if (lastIndex < markdown.length) {
1797
+ const text = markdown.slice(lastIndex);
1798
+ if (text) parts.push({
1799
+ type: "text",
1800
+ value: text
1801
+ });
1802
+ }
1803
+ return parts.length ? parts : [{
1804
+ type: "text",
1805
+ value: markdown
1806
+ }];
1807
+ };
827
1808
 
828
1809
  //#endregion
829
1810
  //#region src/core/adapter/pipeline-qq.ts
1811
+ /** QQ 官方限制:单聊中同一 `msg_id` 最多发送四次被动回复。 */
1812
+ const C2C_PASSIVE_REPLY_LIMIT = 4;
1813
+ /** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
1814
+ const KEYBOARD_MAX_ROWS$1 = 5;
1815
+ const KEYBOARD_MAX_BUTTONS_PER_ROW$1 = 5;
1816
+ /** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
1817
+ const BUTTON_ONLY_MARKDOWN$1 = "​";
830
1818
  /** 群聊 event_id 白名单 */
831
- const GROUP_EVENT_WHITELIST = new Set([
1819
+ const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
832
1820
  "INTERACTION_CREATE",
833
1821
  "GROUP_ADD_ROBOT",
834
1822
  "GROUP_MSG_RECEIVE"
835
1823
  ]);
836
1824
  /** 单聊 event_id 白名单 */
837
- const FRIEND_EVENT_WHITELIST = new Set([
1825
+ const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
838
1826
  "INTERACTION_CREATE",
839
1827
  "C2C_MSG_RECEIVE",
840
1828
  "FRIEND_ADD"
@@ -845,18 +1833,21 @@ const FRIEND_EVENT_WHITELIST = new Set([
845
1833
  const sendQQ = async (ctx, contact, elements) => {
846
1834
  const target = contact.scene === "friend" ? "user" : "group";
847
1835
  const grouping = groupElements(contact.scene, elements);
848
- if (ctx.cfg.keyboard.enable && grouping.text.length) {
1836
+ if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
849
1837
  const { text, buttons } = extractUrlButtons(grouping.text.join(""), contact.scene === "friend");
850
1838
  grouping.text = [text];
851
1839
  grouping.buttons.push(...buttons);
852
1840
  }
853
1841
  /**
854
- * 永远走 markdown 通道:
855
- * text / at / image 通过 markdown content 渲染(at 用内嵌标签);
856
- * 视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 并发补发。
857
- * Markdown / Keyboard 已全量开放,无需再走老的 msg_type=0 文本通道。
1842
+ * 自动 Markdown 通道:
1843
+ * text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
1844
+ * 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 单独补发。所有富媒体
1845
+ * 都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
1846
+ * 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
1847
+ * 显式 segment.markdown 不受该自动通道开关影响。
858
1848
  */
859
- return sendQQMarkdown(ctx, contact, grouping, target);
1849
+ if (ctx.cfg.markdown.enable) return sendQQMarkdown(ctx, contact, grouping, target);
1850
+ return sendQQClassic(ctx, contact, grouping, target);
860
1851
  };
861
1852
  /**
862
1853
  * Markdown 通道:合成一条 msg_type=2 + 可选 keyboard
@@ -865,101 +1856,333 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
865
1856
  const items = [];
866
1857
  const lines = [];
867
1858
  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
- }
878
- 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");
1859
+ const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages);
1860
+ const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
1861
+ fallbackImages.push(...markdownFallbackImages);
1862
+ if (contact.scene === "group") {
1863
+ warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
1864
+ warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
1865
+ }
1866
+ if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(ctx, contact, grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
1867
+ else {
881
1868
  const keyboard = buildKeyboard$1(grouping);
1869
+ const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
882
1870
  items.push(ctx.super.qq.markdown({ content }, keyboard));
883
1871
  }
1872
+ for (const file of fallbackImages) {
1873
+ const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
1874
+ items.push(ctx.super.qq.media(res.file_info));
1875
+ }
884
1876
  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);
1877
+ const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name);
1878
+ 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
1879
  items.push(ctx.super.qq.media(res.file_info));
892
1880
  }
893
1881
  if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
894
1882
  return flushQQ(ctx, contact, grouping, items);
895
1883
  };
896
1884
  /**
1885
+ * 经典通道:文本走 msg_type=0,图片 / 富媒体走 msg_type=7。
1886
+ */
1887
+ const sendQQClassic = async (ctx, contact, grouping, target) => {
1888
+ const items = [];
1889
+ const lines = [];
1890
+ const images = [...grouping.qqImages];
1891
+ if (grouping.text.length) lines.push(grouping.text.join(""));
1892
+ if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
1893
+ const content = lines.join("\n").trim();
1894
+ if (content) items.push(ctx.super.qq.text(content));
1895
+ for (const image of images) items.push(await buildQQMediaItem(ctx, target, contact.peer, "image", image));
1896
+ for (const m of grouping.media) items.push(await buildQQMediaItem(ctx, target, contact.peer, m.kind, m.source, m.name));
1897
+ await appendExplicitMarkdownItems(ctx, contact, grouping, target, items);
1898
+ if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
1899
+ return flushQQ(ctx, contact, grouping, items);
1900
+ };
1901
+ /**
1902
+ * 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
1903
+ */
1904
+ const appendExplicitMarkdownItems = async (ctx, contact, grouping, target, items) => {
1905
+ if (!grouping.markdowns.length) return;
1906
+ const lines = [];
1907
+ const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
1908
+ if (contact.scene === "group") {
1909
+ warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
1910
+ warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
1911
+ }
1912
+ const keyboard = buildKeyboard$1(grouping);
1913
+ const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
1914
+ items.push(ctx.super.qq.markdown({ content }, keyboard));
1915
+ for (const file of fallbackImages) {
1916
+ const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
1917
+ items.push(ctx.super.qq.media(res.file_info));
1918
+ }
1919
+ };
1920
+ const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
1921
+ const resolved = await resolvePreferredMediaSource(ctx, type, source, name);
1922
+ const res = resolved.via === "fallback" ? await ctx.super.media.uploadFallback(target, peer, type, resolved.source, false, name) : await ctx.super.media.upload(target, peer, type, resolved.source, false, name);
1923
+ return ctx.super.qq.media(res.file_info);
1924
+ };
1925
+ /**
1926
+ * 将能转成公网 URL 的图片追加到 markdown 行,无法转换的图片返回给富媒体 fallback。
1927
+ *
1928
+ * @param ctx 适配器实例,用于输出降级日志。
1929
+ * @param lines markdown 行数组。
1930
+ * @param files QQ 图片消息段的 file 字段。
1931
+ * @returns 需要改走 msg_type=7 富媒体发送的图片列表。
1932
+ */
1933
+ const appendMarkdownImages = async (ctx, lines, files) => {
1934
+ const fallback = [];
1935
+ for (const file of files) {
1936
+ const resolved = await resolvePreferredMediaSource(ctx, "image", file);
1937
+ if (resolved.via === "fallback") {
1938
+ fallback.push(file);
1939
+ continue;
1940
+ }
1941
+ await appendImageUrlLine(lines, resolved.source);
1942
+ }
1943
+ return fallback;
1944
+ };
1945
+ /**
1946
+ * 处理显式 segment.markdown 中的图片:可转公网 URL 的继续嵌入,无法转的改走富媒体。
1947
+ */
1948
+ const appendExplicitMarkdown = async (ctx, lines, grouping) => {
1949
+ const fallback = [];
1950
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
1951
+ if (part.type === "text") {
1952
+ const text = part.value.trim();
1953
+ if (text) lines.push(text);
1954
+ continue;
1955
+ }
1956
+ const resolved = await resolvePreferredMediaSource(ctx, "image", part.source);
1957
+ if (resolved.via === "fallback") {
1958
+ fallback.push(part.source);
1959
+ continue;
1960
+ }
1961
+ await appendImageUrlLine(lines, resolved.source);
1962
+ }
1963
+ return fallback;
1964
+ };
1965
+ /**
1966
+ * 生成单张 markdown 图片行。
1967
+ * 尺寸获取失败时使用保守默认尺寸,保证 fileToUrl 成功的图片仍走 markdown。
1968
+ *
1969
+ * @param lines markdown 行数组。
1970
+ * @param url 图片 URL。
1971
+ */
1972
+ const appendImageUrlLine = async (lines, url) => {
1973
+ try {
1974
+ const [line] = await imagesToMarkdown([url]);
1975
+ lines.push(line);
1976
+ } catch {
1977
+ lines.push(`![karin #300px #300px](${url})`);
1978
+ }
1979
+ };
1980
+ /**
1981
+ * 判断显式引用回复是否应降级为普通文本消息。
1982
+ *
1983
+ * QQ 的 `message_reference` 对普通文本最稳定;markdown 带引用在客户端渲染上存在兼容问题。
1984
+ * 因此只有纯文本显式引用回复会走 msg_type=0,图片、按钮、keyboard、markdown 段仍走原通道。
1985
+ *
1986
+ * @param grouping 已归类的消息段。
1987
+ * @returns 是否使用普通文本发送主消息。
1988
+ */
1989
+ const shouldUseTextForReference = (ctx, contact, grouping) => {
1990
+ return isQQReferenceMessageId(resolveReferenceMessageId(ctx, contact, grouping.reply.messageId)) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
1991
+ };
1992
+ /**
1993
+ * 判断给定 ID 是否是 QQ 客户端引用使用的 REFIDX。
1994
+ *
1995
+ * `ROBOT1.0_...` 这类官方消息 ID 只能作为被动发送的 `msg_id`,不能放进
1996
+ * `message_reference.message_id`。
1997
+ *
1998
+ * @param messageId 待判断的消息 ID。
1999
+ * @returns 是否为 QQ 引用索引。
2000
+ */
2001
+ const isQQReferenceMessageId = (messageId) => messageId.startsWith("REFIDX_");
2002
+ /**
897
2003
  * 构造 keyboard 字段(buttons + keyboards 合并)
898
2004
  */
899
2005
  const buildKeyboard$1 = (grouping) => {
900
2006
  const rows = [];
901
2007
  grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
902
2008
  grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
903
- if (!rows.length) return void 0;
904
- return { content: { rows } };
2009
+ const normalizedRows = [];
2010
+ let id = 0;
2011
+ for (const row of rows.slice(0, KEYBOARD_MAX_ROWS$1)) {
2012
+ const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW$1).map((button) => normalizeQQBotButton(button, id++));
2013
+ if (buttons.length) normalizedRows.push({ buttons });
2014
+ }
2015
+ if (!normalizedRows.length) return void 0;
2016
+ return { content: { rows: normalizedRows } };
2017
+ };
2018
+ /**
2019
+ * 群聊不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
2020
+ *
2021
+ * @param ctx 适配器实例,用于输出日志。
2022
+ * @param buttons 本次消息中的直发指令按钮。
2023
+ */
2024
+ const warnUnsupportedCommandEnterButtons$1 = (ctx, buttons) => {
2025
+ if (!buttons.length) return;
2026
+ const names = formatCommandEnterButtonNames(buttons);
2027
+ ctx.logger("debug", `群聊不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
2028
+ };
2029
+ /**
2030
+ * 群聊不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
2031
+ *
2032
+ * @param ctx 适配器实例,用于输出日志。
2033
+ * @param markdowns 本次消息中的 markdown 内容。
2034
+ */
2035
+ const warnUnsupportedCommandEnterMarkdowns$1 = (ctx, markdowns) => {
2036
+ if (!markdowns.some(hasCommandEnterTextChain)) return;
2037
+ ctx.logger("debug", "群聊不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送");
905
2038
  };
906
2039
  /**
907
2040
  * 逐条发送 + 被动消息附加 + 引用回复
908
2041
  */
909
2042
  const flushQQ = async (ctx, contact, grouping, items) => {
910
2043
  const result = ctx.initSendMsgResults();
911
- const passive = buildPassiveQQ(contact.scene, grouping);
2044
+ const passiveSource = resolvePassiveQQ(grouping);
2045
+ const passive = buildPassiveQQ(ctx, contact.scene, grouping);
912
2046
  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) {
2047
+ /**
2048
+ * 仅单聊 `msg_id` 回复受四次上限约束。`event_id` 事件回复和群聊不适用该限制。
2049
+ * 这是 QQ 官方 2026-01-10 更新后的限制,超出部分不请求平台接口。
2050
+ */
2051
+ const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? C2C_PASSIVE_REPLY_LIMIT : Infinity;
2052
+ if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${C2C_PASSIVE_REPLY_LIMIT} 条,已跳过 ${items.length - maxItems} 条`);
2053
+ let referenceHandled = false;
2054
+ for (const item of items.slice(0, maxItems)) {
915
2055
  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);
2056
+ if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(ctx, contact, item, grouping);
2057
+ const res = await sendQQWithEventFallback(ctx, contact, send, item);
2058
+ rememberOwnMessageId(ctx, contact, res.id);
2059
+ if (res.ext_info?.ref_idx) rememberApiMessageId(ctx, contact, res.ext_info.ref_idx, res.id);
921
2060
  result.rawData.push(res);
922
2061
  }
923
2062
  return ctx.handleResponse(result);
924
2063
  };
925
2064
  /**
2065
+ * 发送 QQ 消息,并在平台拒绝 `event_id` 时降级为普通消息重试。
2066
+ *
2067
+ * QQ 文档说明 `INTERACTION_CREATE.d.id` 可用于被动消息发送,但实测群聊接口会出现
2068
+ * 40034025。为了避免按钮回调业务回复把 Karin 命令链路打断,这里只对
2069
+ * `event_id` 参数无效做一次无 `event_id` 重试。
2070
+ *
2071
+ * @param ctx 适配器实例,用于输出降级日志。
2072
+ * @param contact 消息目标。
2073
+ * @param send 当前场景的发送函数。
2074
+ * @param item 即将发送的 QQ 消息体。
2075
+ * @returns QQ 消息发送响应。
2076
+ */
2077
+ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
2078
+ try {
2079
+ return await send(contact.peer, item);
2080
+ } catch (err) {
2081
+ if (!item.event_id || !isInvalidEventIdError(err)) throw err;
2082
+ ctx.logger("warn", `event_id 被 QQ 拒绝,已改用普通消息重试: ${item.event_id}`);
2083
+ const retryItem = { ...item };
2084
+ delete retryItem.event_id;
2085
+ delete retryItem.msg_seq;
2086
+ return send(contact.peer, retryItem);
2087
+ }
2088
+ };
2089
+ /**
2090
+ * 判断发送失败是否来自 QQ 对 `event_id` 的参数校验。
2091
+ *
2092
+ * 当前错误由 Http 层格式化为普通 Error,因此这里基于官方错误码和中文错误信息兜底识别。
2093
+ *
2094
+ * @param err 捕获到的发送异常。
2095
+ * @returns 是否可以通过移除 `event_id` 重试。
2096
+ */
2097
+ const isInvalidEventIdError = (err) => {
2098
+ const message = err instanceof Error ? err.message : String(err);
2099
+ return message.includes("40034025") || message.includes("请求参数event_id无效");
2100
+ };
2101
+ /**
2102
+ * 解析 QQ 场景应使用的被动回复来源。
2103
+ *
2104
+ * `msg_id/event_id` 是 QQBot 被动发送凭证,不等于客户端展示引用使用的
2105
+ * `message_reference.message_id`。因此这里只读取事件入口追加的 pasmsg,显式
2106
+ * `segment.reply` 交给 `attachVisibleReferenceQQ` 处理。
2107
+ *
2108
+ * @param grouping 已归类的消息段。
2109
+ * @returns 可附加到发送体的被动回复来源;没有可用来源时返回 undefined。
2110
+ */
2111
+ const resolvePassiveQQ = (grouping) => {
2112
+ if (grouping.pasmsg.id) return grouping.pasmsg;
2113
+ };
2114
+ /**
2115
+ * 附加 QQ 客户端可见的引用对象。
2116
+ *
2117
+ * 群聊/单聊里真正发给 QQ 的值必须是 `message_scene.ext` 的 `msg_idx=REFIDX_xxx`。
2118
+ * 如果插件传入的是 Karin 暴露的官方 API 消息 ID,这里会先按同会话映射转换。
2119
+ *
2120
+ * @param item 即将发送的 QQ 消息体。
2121
+ * @param grouping 已归类的消息段。
2122
+ * @returns 是否已经消费一次显式引用。
2123
+ */
2124
+ const attachVisibleReferenceQQ = (ctx, contact, item, grouping) => {
2125
+ const messageId = resolveReferenceMessageId(ctx, contact, grouping.reply.messageId);
2126
+ if (!isQQReferenceMessageId(messageId)) return false;
2127
+ item.message_reference = { message_id: messageId };
2128
+ return true;
2129
+ };
2130
+ /**
2131
+ * 递增并返回下一条 QQ 被动回复序号。
2132
+ *
2133
+ * @param grouping 已归类的消息段。
2134
+ * @returns 0..65535 范围内的 msg_seq。
2135
+ */
2136
+ const nextPassiveMsgSeq = (grouping) => {
2137
+ grouping.pasmsg.seq = (grouping.pasmsg.seq + 1) % 65536;
2138
+ return grouping.pasmsg.seq;
2139
+ };
2140
+ /**
926
2141
  * 构造被动消息附加器(含白名单校验)
927
2142
  */
928
- const buildPassiveQQ = (scene, grouping) => {
929
- if (!grouping.pasmsg.id) return (_item) => void 0;
2143
+ const buildPassiveQQ = (ctx, scene, grouping) => {
2144
+ const source = resolvePassiveQQ(grouping);
2145
+ if (!source?.id) return (_item) => void 0;
930
2146
  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)) {}
2147
+ if (source.type === "event") {
2148
+ const eventName = source.id.split(":")[0];
2149
+ if (eventName && !whitelist.has(eventName)) {
2150
+ ctx.logger("warn", `跳过无效 event_id: ${source.id}`);
2151
+ return (_item) => void 0;
2152
+ }
934
2153
  return (item) => {
935
- grouping.pasmsg.seq++;
936
- item.msg_seq = grouping.pasmsg.seq;
937
- item.event_id = grouping.pasmsg.id;
2154
+ item.msg_seq = nextPassiveMsgSeq(grouping);
2155
+ item.event_id = source.id;
938
2156
  };
939
2157
  }
940
2158
  return (item) => {
941
- grouping.pasmsg.seq++;
942
- item.msg_seq = grouping.pasmsg.seq;
943
- item.msg_id = grouping.pasmsg.id;
2159
+ item.msg_seq = nextPassiveMsgSeq(grouping);
2160
+ item.msg_id = source.id;
944
2161
  };
945
2162
  };
946
2163
 
947
2164
  //#endregion
948
2165
  //#region src/core/adapter/pipeline-guild.ts
2166
+ /** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
2167
+ const KEYBOARD_MAX_ROWS = 5;
2168
+ const KEYBOARD_MAX_BUTTONS_PER_ROW = 5;
2169
+ /** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
2170
+ const BUTTON_ONLY_MARKDOWN = "​";
949
2171
  /**
950
2172
  * 处理频道场景(子频道 + 私信)的发送
951
2173
  *
952
- * 永远走 markdown 通道(msg_type=2),文本 / at / 图片均通过 markdown
953
- * 渲染。Markdown / Keyboard 已全量开放,老的 type=text/image 通道不再使用。
2174
+ * 默认把普通文本 / at / 图片通过 markdown 渲染。关闭自动 Markdown 通道后,
2175
+ * 普通文本和图片改走经典发送;显式 segment.markdown 仍按调用方指定走 Markdown。
954
2176
  */
955
2177
  const sendGuild = async (ctx, contact, elements) => {
956
2178
  const grouping = groupElements(contact.scene, elements);
957
- if (ctx.cfg.keyboard.enable && grouping.text.length) {
2179
+ if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
958
2180
  const { text, buttons } = extractUrlButtons(grouping.text.join(""), false);
959
2181
  grouping.text = [text];
960
2182
  grouping.buttons.push(...buttons);
961
2183
  }
962
- return sendGuildMarkdown(ctx, contact, grouping);
2184
+ if (ctx.cfg.markdown.enable) return sendGuildMarkdown(ctx, contact, grouping);
2185
+ return sendGuildClassic(ctx, contact, grouping);
963
2186
  };
964
2187
  /**
965
2188
  * Markdown 通道
@@ -977,51 +2200,329 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
977
2200
  const mdImages = await imagesToMarkdown(allImages);
978
2201
  lines.push(...mdImages);
979
2202
  }
980
- grouping.markdowns.forEach((m) => lines.push(m.markdown));
2203
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
2204
+ const text = part.value.trim();
2205
+ if (text) lines.push(text);
2206
+ } else {
2207
+ const [mdImage] = await imagesToMarkdown([part.source]);
2208
+ lines.push(mdImage);
2209
+ }
2210
+ warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2211
+ warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
981
2212
  if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
982
2213
  const keyboard = buildKeyboard(grouping);
983
- items.push(ctx.super.guild.markdown({ content: lines.join("\n") }, keyboard));
2214
+ const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
2215
+ items.push(ctx.super.guild.markdown({ content }, keyboard));
984
2216
  }
985
2217
  if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
986
2218
  return flushGuild(ctx, contact, grouping, items);
987
2219
  };
2220
+ /**
2221
+ * 经典通道:文本走 content,图片按文档走 image / file_image 单独发送。
2222
+ */
2223
+ const sendGuildClassic = async (ctx, contact, grouping) => {
2224
+ const items = [];
2225
+ const lines = [];
2226
+ const images = [...grouping.guildImageUrls, ...grouping.guildImageFiles];
2227
+ if (grouping.text.length) lines.push(grouping.text.join(""));
2228
+ if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
2229
+ const content = lines.join("\n").trim();
2230
+ if (content) items.push(ctx.super.guild.text(content));
2231
+ for (const image of images) items.push(await buildGuildImageItem(image));
2232
+ await appendExplicitGuildMarkdownItems(ctx, contact, grouping, items);
2233
+ if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
2234
+ return flushGuild(ctx, contact, grouping, items);
2235
+ };
2236
+ /**
2237
+ * 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
2238
+ */
2239
+ const appendExplicitGuildMarkdownItems = async (ctx, contact, grouping, items) => {
2240
+ if (!grouping.markdowns.length) return;
2241
+ const lines = [];
2242
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
2243
+ const text = part.value.trim();
2244
+ if (text) lines.push(text);
2245
+ } else {
2246
+ const [mdImage] = await imagesToMarkdown([part.source]);
2247
+ lines.push(mdImage);
2248
+ }
2249
+ warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2250
+ warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
2251
+ const keyboard = buildKeyboard(grouping);
2252
+ const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
2253
+ items.push(ctx.super.guild.markdown({ content }, keyboard));
2254
+ };
2255
+ const buildGuildImageItem = async (source) => {
2256
+ if (/^https?:\/\//i.test(source)) return {
2257
+ type: "image",
2258
+ image: source
2259
+ };
2260
+ const form = new FormData();
2261
+ form.append("file_image", await common.buffer(source, { http: false }), { filename: resolveImageFilename(source) });
2262
+ return form;
2263
+ };
2264
+ const resolveImageFilename = (source) => {
2265
+ const file = source.split(/[?#]/)[0] || "";
2266
+ const name = path.basename(file);
2267
+ return name.includes(".") ? name : "image.jpg";
2268
+ };
988
2269
  const buildKeyboard = (grouping) => {
989
2270
  const rows = [];
990
2271
  grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
991
2272
  grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
992
- if (!rows.length) return void 0;
993
- return { content: { rows } };
2273
+ const normalizedRows = [];
2274
+ let id = 0;
2275
+ for (const row of rows.slice(0, KEYBOARD_MAX_ROWS)) {
2276
+ const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW).map((button) => normalizeQQBotButton(button, id++));
2277
+ if (buttons.length) normalizedRows.push({ buttons });
2278
+ }
2279
+ if (!normalizedRows.length) return void 0;
2280
+ return { content: { rows: normalizedRows } };
2281
+ };
2282
+ /**
2283
+ * 频道消息不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
2284
+ *
2285
+ * @param ctx 适配器实例,用于输出日志。
2286
+ * @param scene 频道消息场景。
2287
+ * @param buttons 本次消息中的直发指令按钮。
2288
+ */
2289
+ const warnUnsupportedCommandEnterButtons = (ctx, scene, buttons) => {
2290
+ if (!buttons.length) return;
2291
+ const names = formatCommandEnterButtonNames(buttons);
2292
+ const name = scene === "guild" ? "频道" : "频道私信";
2293
+ ctx.logger("debug", `${name}不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
2294
+ };
2295
+ /**
2296
+ * 频道消息不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
2297
+ *
2298
+ * @param ctx 适配器实例,用于输出日志。
2299
+ * @param scene 频道消息场景。
2300
+ * @param markdowns 本次消息中的 markdown 内容。
2301
+ */
2302
+ const warnUnsupportedCommandEnterMarkdowns = (ctx, scene, markdowns) => {
2303
+ if (!markdowns.some(hasCommandEnterTextChain)) return;
2304
+ const name = scene === "guild" ? "频道" : "频道私信";
2305
+ ctx.logger("debug", `${name}不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送`);
994
2306
  };
995
2307
  const flushGuild = async (ctx, contact, grouping, items) => {
996
2308
  const result = ctx.initSendMsgResults();
997
2309
  const passive = buildPassiveGuild(grouping);
998
2310
  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
2311
  for (const item of items) {
1001
2312
  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
2313
  const res = await send(contact.peer, contact.subPeer, item);
1007
2314
  result.rawData.push(res);
1008
2315
  }
1009
2316
  return ctx.handleResponse(result);
1010
2317
  };
2318
+ /**
2319
+ * 解析频道/私信场景的被动回复来源。
2320
+ *
2321
+ * openclaw 的 qqbot 适配器对 channel/dm 也只传 `msg_id`。这里让显式
2322
+ * `segment.reply` 优先,其次使用事件入口追加的 pasmsg。
2323
+ *
2324
+ * @param grouping 已归类的消息段。
2325
+ * @returns 被动回复来源;没有可用来源时返回 undefined。
2326
+ */
2327
+ const resolvePassiveGuild = (grouping) => {
2328
+ if (grouping.reply.messageId) return {
2329
+ type: "msg",
2330
+ id: grouping.reply.messageId
2331
+ };
2332
+ if (grouping.pasmsg.id) return grouping.pasmsg;
2333
+ };
2334
+ /**
2335
+ * 构造频道/私信被动消息附加器。
2336
+ *
2337
+ * @param grouping 已归类的消息段。
2338
+ * @returns 可直接修改发送体或 FormData 的附加函数。
2339
+ */
1011
2340
  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;
2341
+ const source = resolvePassiveGuild(grouping);
2342
+ if (!source?.id) return (_item) => void 0;
2343
+ if (source.type === "event") return (item) => {
2344
+ if (item instanceof FormData) item.append("event_id", source.id);
2345
+ else item.event_id = source.id;
1016
2346
  };
1017
2347
  return (item) => {
1018
- if (item instanceof FormData) item.append("msg_id", grouping.pasmsg.id);
1019
- else item.msg_id = grouping.pasmsg.id;
2348
+ if (item instanceof FormData) item.append("msg_id", source.id);
2349
+ else item.msg_id = source.id;
2350
+ };
2351
+ };
2352
+
2353
+ //#endregion
2354
+ //#region src/core/adapter/self-message-cache.ts
2355
+ /**
2356
+ * 只有两个缓存开关同时开启时才需要等待发送结果并缓存自己消息。
2357
+ */
2358
+ const shouldCacheSelfMessage = (ctx) => {
2359
+ return ctx.cfg.messageCache.enable && ctx.cfg.messageCache.self;
2360
+ };
2361
+ /**
2362
+ * 预处理自己消息元素。
2363
+ *
2364
+ * 静态资源只在这里转一次 HTTP URL,之后发送 pipeline 和缓存都复用该 URL。
2365
+ */
2366
+ const prepareSelfMessageCache = async (ctx, elements) => {
2367
+ const sendElements = [];
2368
+ const cacheElements = [];
2369
+ for (const element of elements) {
2370
+ const prepared = await prepareSelfCacheElement(ctx, element);
2371
+ sendElements.push(prepared.send);
2372
+ if (prepared.cache) cacheElements.push(prepared.cache);
2373
+ }
2374
+ return {
2375
+ sendElements,
2376
+ cacheElements
1020
2377
  };
1021
2378
  };
2379
+ /**
2380
+ * 发送成功后缓存机器人自己的消息,供 bot.getMsg(messageId) 查询。
2381
+ */
2382
+ const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
2383
+ if (!shouldCacheSelfMessage(ctx)) return;
2384
+ if (!cacheElements.length) return;
2385
+ const responses = (Array.isArray(result.rawData) ? result.rawData : [result.rawData]).filter((item) => item?.id);
2386
+ if (!responses.length) return;
2387
+ const sender = selfSender(ctx, contact);
2388
+ const seen = /* @__PURE__ */ new Set();
2389
+ for (const response of responses) {
2390
+ const apiMessageId = String(response.id);
2391
+ const referenceMessageId = typeof response.ext_info?.ref_idx === "string" ? response.ext_info.ref_idx : "";
2392
+ const messageId = apiMessageId;
2393
+ const aliases = [referenceMessageId].filter((id) => id && id !== messageId);
2394
+ if (!messageId || seen.has(messageId)) continue;
2395
+ seen.add(messageId);
2396
+ if (referenceMessageId) rememberApiMessageId(ctx, contact, referenceMessageId, apiMessageId);
2397
+ ctx.messageStore.save(String(ctx.cfg.appId), {
2398
+ messageId,
2399
+ messageSeq: 0,
2400
+ time: responseTime(response.timestamp),
2401
+ contact,
2402
+ sender,
2403
+ elements: cacheElements
2404
+ }, aliases).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
2405
+ }
2406
+ };
2407
+ /**
2408
+ * 转换单个自己消息元素为缓存安全形态。
2409
+ */
2410
+ const prepareSelfCacheElement = async (ctx, element) => {
2411
+ switch (element.type) {
2412
+ case "text":
2413
+ case "at":
2414
+ case "reply":
2415
+ case "face":
2416
+ case "markdown": {
2417
+ const cached = { ...element };
2418
+ return {
2419
+ send: cached,
2420
+ cache: cached
2421
+ };
2422
+ }
2423
+ case "image":
2424
+ case "video":
2425
+ case "record":
2426
+ case "file": return prepareStaticElement(ctx, element);
2427
+ default: return {
2428
+ send: element,
2429
+ cache: null
2430
+ };
2431
+ }
2432
+ };
2433
+ /**
2434
+ * 静态资源统一转 HTTP URL 后再缓存;转换失败时跳过该元素。
2435
+ */
2436
+ const prepareStaticElement = async (ctx, element) => {
2437
+ if (element.file.startsWith("http")) {
2438
+ const cached = { ...element };
2439
+ return {
2440
+ send: cached,
2441
+ cache: cached
2442
+ };
2443
+ }
2444
+ try {
2445
+ switch (element.type) {
2446
+ case "image": {
2447
+ const res = await fileToUrl("image", element.file, element.name || "image.jpg");
2448
+ const cached = {
2449
+ ...element,
2450
+ file: res.url,
2451
+ width: res.width,
2452
+ height: res.height
2453
+ };
2454
+ return {
2455
+ send: cached,
2456
+ cache: cached
2457
+ };
2458
+ }
2459
+ case "video": {
2460
+ const res = await fileToUrl("video", element.file, element.name || "video.mp4");
2461
+ const cached = {
2462
+ ...element,
2463
+ file: res.url
2464
+ };
2465
+ return {
2466
+ send: cached,
2467
+ cache: cached
2468
+ };
2469
+ }
2470
+ case "record": {
2471
+ const res = await fileToUrl("record", element.file, element.name || "record.mp3");
2472
+ const cached = {
2473
+ ...element,
2474
+ file: res.url
2475
+ };
2476
+ return {
2477
+ send: cached,
2478
+ cache: cached
2479
+ };
2480
+ }
2481
+ case "file": {
2482
+ const res = await fileToUrl("file", element.file, element.name || "file.bin");
2483
+ const cached = {
2484
+ ...element,
2485
+ file: res.url
2486
+ };
2487
+ return {
2488
+ send: cached,
2489
+ cache: cached
2490
+ };
2491
+ }
2492
+ }
2493
+ } catch (err) {
2494
+ const message = err instanceof Error ? err.message : String(err);
2495
+ ctx.logger("debug", `[getMsg] 跳过自己消息资源缓存: ${element.type} ${message}`);
2496
+ return {
2497
+ send: element,
2498
+ cache: null
2499
+ };
2500
+ }
2501
+ };
2502
+ /**
2503
+ * 构造机器人自己的 sender。
2504
+ */
2505
+ const selfSender = (ctx, contact) => {
2506
+ const userId = ctx.selfSubId("id") || ctx.selfId;
2507
+ const name = ctx.selfName || ctx.cfg.name || "";
2508
+ if (contact.scene === "friend" || contact.scene === "direct") return karin$1.friendSender(userId, name);
2509
+ return karin$1.groupSender(userId, "member", name);
2510
+ };
2511
+ /**
2512
+ * QQ / 频道发送接口 timestamp 格式不同,统一转成毫秒时间戳。
2513
+ */
2514
+ const responseTime = (timestamp) => {
2515
+ if (typeof timestamp === "string") {
2516
+ const time = new Date(timestamp).getTime();
2517
+ return Number.isFinite(time) ? time : Date.now();
2518
+ }
2519
+ if (typeof timestamp === "number") return timestamp < 0xe8d4a51000 ? timestamp * 1e3 : timestamp;
2520
+ return Date.now();
2521
+ };
1022
2522
 
1023
2523
  //#endregion
1024
2524
  //#region src/core/adapter/base.ts
2525
+ const adapterConfigStore = /* @__PURE__ */ new WeakMap();
1025
2526
  /**
1026
2527
  * QQ Official Bot 适配器
1027
2528
  *
@@ -1031,19 +2532,27 @@ const buildPassiveGuild = (grouping) => {
1031
2532
  var AdapterQQBot = class extends AdapterBase {
1032
2533
  /** 与官方 API 交互 */
1033
2534
  super;
1034
- /** 当前 bot 配置 */
1035
- cfg;
1036
2535
  /** openId / member_openid -> 昵称 缓存 */
1037
2536
  nicknameCache = /* @__PURE__ */ new Map();
1038
2537
  constructor(cfg, api) {
1039
2538
  super();
1040
- this.cfg = cfg;
2539
+ adapterConfigStore.set(this, cfg);
1041
2540
  this.super = api;
1042
2541
  this.adapter.name = "QQ Official Bot";
1043
2542
  this.adapter.protocol = "qqbot";
1044
2543
  this.adapter.platform = "qq";
1045
2544
  this.adapter.standard = "other";
1046
2545
  }
2546
+ /** 当前 bot 配置。真实配置存放在 WeakMap,避免被系统接口序列化整个适配器时带出。 */
2547
+ get cfg() {
2548
+ const cfg = adapterConfigStore.get(this);
2549
+ if (!cfg) throw new Error("QQBot adapter config missing");
2550
+ return cfg;
2551
+ }
2552
+ /** 接收消息的一天热缓存 + SQLite 缓存,用于实现 Karin 标准 `getMsg`。 */
2553
+ get messageStore() {
2554
+ return getMessageStore();
2555
+ }
1047
2556
  logger(level, ...args) {
1048
2557
  logger.bot(level, this.selfId, ...args);
1049
2558
  }
@@ -1051,26 +2560,46 @@ var AdapterQQBot = class extends AdapterBase {
1051
2560
  * 主消息回复入口(karin 调用)
1052
2561
  */
1053
2562
  async srcReply(e, elements) {
1054
- const extra = this.cfg.keyboard.enable ? await buttonHandle(e.msg, { e }) : [];
2563
+ const extra = await buttonHandle(e.msg, { e });
1055
2564
  return this.sendMsg(e.contact, [...elements, ...extra]);
1056
2565
  }
1057
2566
  /**
1058
2567
  * 发送消息
1059
2568
  */
1060
2569
  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);
2570
+ if (contact.scene === "direct" || contact.scene === "guild") {
2571
+ if (!shouldCacheSelfMessage(this)) return sendGuild(this, contact, elements);
2572
+ const prepared = await prepareSelfMessageCache(this, elements);
2573
+ const result = await sendGuild(this, contact, prepared.sendElements);
2574
+ cacheSelfMessage(this, contact, prepared.cacheElements, result);
2575
+ return result;
2576
+ }
2577
+ if (contact.scene === "group" || contact.scene === "friend") {
2578
+ if (!shouldCacheSelfMessage(this)) return sendQQ(this, contact, elements);
2579
+ const prepared = await prepareSelfMessageCache(this, elements);
2580
+ const result = await sendQQ(this, contact, prepared.sendElements);
2581
+ cacheSelfMessage(this, contact, prepared.cacheElements, result);
2582
+ return result;
2583
+ }
1063
2584
  throw new Error(`不支持的消息场景: ${contact.scene}`);
1064
2585
  }
1065
2586
  /**
1066
2587
  * 撤回消息
2588
+ *
2589
+ * 群聊中机器人被群主设为管理员后,也可撤回成员消息;平台仍限制消息发送后两分钟内。
1067
2590
  */
1068
2591
  async recallMsg(contact, messageId) {
2592
+ const apiMessageId = resolveApiMessageId(this, contact, messageId);
1069
2593
  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);
2594
+ if (contact.scene === "friend") {
2595
+ if (!isOwnMessageId(this, contact, apiMessageId)) {
2596
+ this.logger("warn", `[recallMsg] QQ 单聊只能撤回机器人自己发送的消息,已跳过撤回: ${messageId}`);
2597
+ return;
2598
+ }
2599
+ await this.super.messages.recall("user", contact.peer, apiMessageId);
2600
+ } else if (contact.scene === "group") await this.super.messages.recall("group", contact.peer, apiMessageId);
2601
+ else if (contact.scene === "direct") await this.super.messages.recall("dms", contact.peer, apiMessageId);
2602
+ else if (contact.scene === "guild") await this.super.messages.recall("channels", contact.peer, apiMessageId);
1074
2603
  } catch (err) {
1075
2604
  logger.error("撤回消息失败:", err);
1076
2605
  }
@@ -1187,20 +2716,54 @@ var AdapterQQBot = class extends AdapterBase {
1187
2716
  return `https://p.qlogo.cn/gh/${_groupId}/${_groupId}/${_size || 100}`;
1188
2717
  }
1189
2718
  /**
1190
- * 获取消息
2719
+ * 按消息 ID 获取已接收的消息。
2720
+ *
2721
+ * QQ 官方 Bot API 没有历史消息查询接口,因此仅从本地缓存读取。
2722
+ * 消息在接收时已转换为 Karin elements,最长保留一天;引用消息的 `REFIDX`
2723
+ * 索引同样可作为 `messageId` 查询。
2724
+ *
2725
+ * @param input OneBot v11 标准单参 messageId,或用于限定查询范围的 Contact。
2726
+ * @param messageId 当 input 是 Contact 时传入的目标消息 ID。
2727
+ * @returns 命中的消息;未命中时返回空消息结构。
1191
2728
  */
1192
- async getMsg(_contact, _messageId) {
1193
- this.logger("warn", "[getMsg] QQ Official Bot API 暂不支持获取消息内容");
2729
+ async getMsg(input, messageId) {
2730
+ /** OneBot v11 标准单参形式:bot.getMsg(messageId)。 */
2731
+ const targetId = typeof input === "string" ? input : messageId || "";
2732
+ const contact = typeof input === "string" ? void 0 : input;
2733
+ if (!this.cfg.messageCache.enable) {
2734
+ this.logger("debug", `[getMsg] 消息缓存已关闭: ${targetId || "(empty)"}`);
2735
+ return {
2736
+ time: 0,
2737
+ messageId: targetId,
2738
+ messageSeq: 0,
2739
+ contact: contact || {
2740
+ scene: "group",
2741
+ peer: "",
2742
+ subPeer: "",
2743
+ name: ""
2744
+ },
2745
+ sender: {
2746
+ userId: "",
2747
+ nick: "",
2748
+ name: "",
2749
+ role: "member"
2750
+ },
2751
+ elements: []
2752
+ };
2753
+ }
2754
+ const cached = await this.messageStore.get(String(this.cfg.appId), targetId, contact);
2755
+ if (cached) return cached;
2756
+ this.logger("debug", `[getMsg] 本地一天消息缓存未命中: ${targetId || "(empty)"}`);
1194
2757
  return {
1195
2758
  time: 0,
1196
- messageId: _messageId || "",
2759
+ messageId: targetId,
1197
2760
  messageSeq: 0,
1198
- contact: typeof _contact === "string" ? {
2761
+ contact: contact || {
1199
2762
  scene: "group",
1200
- peer: _contact,
2763
+ peer: "",
1201
2764
  subPeer: "",
1202
2765
  name: ""
1203
- } : _contact,
2766
+ },
1204
2767
  sender: {
1205
2768
  userId: "",
1206
2769
  nick: "",
@@ -1211,9 +2774,18 @@ var AdapterQQBot = class extends AdapterBase {
1211
2774
  };
1212
2775
  }
1213
2776
  /** @internal */
1214
- async getHistoryMsg(_contact, _start, _count) {
1215
- this.logger("warn", "[getHistoryMsg] QQ Official Bot API 暂不支持获取历史消息");
1216
- return [];
2777
+ async getHistoryMsg(contact, start, count = 1) {
2778
+ if (typeof start === "number") {
2779
+ this.logger("warn", "[getHistoryMsg] QQ Official Bot 仅支持通过 message_id 获取历史消息,不支持 message_seq");
2780
+ return [];
2781
+ }
2782
+ if (!this.cfg.messageCache.enable) {
2783
+ this.logger("warn", "[getHistoryMsg] 历史消息缓存为空,请打开“启用数据库缓存消息”开关后再记录历史消息");
2784
+ return [];
2785
+ }
2786
+ const history = await this.messageStore.getHistory(String(this.cfg.appId), contact, start, count);
2787
+ if (!history.length) this.logger("debug", `[getHistoryMsg] 本地一天历史消息缓存未命中: ${start || "(empty)"}`);
2788
+ return history;
1217
2789
  }
1218
2790
  /**
1219
2791
  * 获取合并转发消息
@@ -1571,6 +3143,94 @@ var AdapterQQBot = class extends AdapterBase {
1571
3143
  //#region src/core/event/conver.ts
1572
3144
  /** 反转义 HTML 实体 */
1573
3145
  const unescape = (text) => text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
3146
+ /** 从 QQ `message_scene.ext` 中提取消息索引。 */
3147
+ const getMessageSceneIndex = (scene, key) => {
3148
+ const prefix = `${key}=`;
3149
+ return scene?.ext?.find((item) => item.startsWith(prefix))?.slice(prefix.length) || "";
3150
+ };
3151
+ /** 原生 Markdown 不能从 QQ 回显中完全区分,只识别适配器发送后可稳定还原的常用语法。 */
3152
+ const isNativeMarkdown = (text) => {
3153
+ 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);
3154
+ };
3155
+ /** 将非图片 Markdown 片段恢复为 text 或 markdown 元素。 */
3156
+ const pushMarkdownText = (text, elements) => {
3157
+ if (!text) return;
3158
+ elements.push(isNativeMarkdown(text) ? segment.markdown(text) : segment.text(text));
3159
+ };
3160
+ /**
3161
+ * 反向解析适配器发送的 QQ Markdown。
3162
+ *
3163
+ * 发送侧会将 Karin 图片编码为 `![名称 #宽px #高px](URL)`,并以换行拼接文本、
3164
+ * 图片和原生 Markdown;这里按原顺序恢复为 Karin `text`、`image`、`markdown`。
3165
+ */
3166
+ const pushQQMarkdown = (text, elements) => {
3167
+ const imageReg = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
3168
+ let lastIndex = 0;
3169
+ let matched = false;
3170
+ let match;
3171
+ while (match = imageReg.exec(text)) {
3172
+ matched = true;
3173
+ if (match.index > lastIndex) pushMarkdownText(text.slice(lastIndex, match.index), elements);
3174
+ const alt = match[1];
3175
+ const dimension = alt.match(/\s+#(\d+)px\s+#(\d+)px\s*$/);
3176
+ const name = alt.replace(/\s+#\d+px\s+#\d+px\s*$/, "") || "图片";
3177
+ elements.push(segment.image(match[2], {
3178
+ name,
3179
+ width: dimension ? Number(dimension[1]) : void 0,
3180
+ height: dimension ? Number(dimension[2]) : void 0
3181
+ }));
3182
+ lastIndex = imageReg.lastIndex;
3183
+ }
3184
+ if (matched && lastIndex < text.length) pushMarkdownText(text.slice(lastIndex), elements);
3185
+ return matched;
3186
+ };
3187
+ /**
3188
+ * 只拆 QQ 协议标签,保留普通文本中的 HTML/Markdown 片段。
3189
+ *
3190
+ * 普通文本或 Markdown 里可能包含 `<br>`、`<table>` 等内容;如果用 `[^<]+`
3191
+ * 直接切分,会把这些内容误当成协议标签边界,导致一条文本被拆成多段甚至丢失 `<`。
3192
+ */
3193
+ const tokenizeContent = (content = "") => {
3194
+ const tokens = [];
3195
+ const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[A-Za-z0-9]+>/g;
3196
+ let lastIndex = 0;
3197
+ let match;
3198
+ while (match = knownTag.exec(content)) {
3199
+ if (match.index > lastIndex) tokens.push(content.slice(lastIndex, match.index));
3200
+ tokens.push(match[0]);
3201
+ lastIndex = knownTag.lastIndex;
3202
+ }
3203
+ if (lastIndex < content.length) tokens.push(content.slice(lastIndex));
3204
+ return tokens;
3205
+ };
3206
+ /**
3207
+ * 判断 QQ 回显内容是否应恢复成 Karin markdown 段。
3208
+ *
3209
+ * QQBot 自己收到自己发出的 markdown 时可能是 `message_type=103`;收到其他
3210
+ * QQBot 发出的 markdown 时,官方报文仍可能给 `message_type=0`,只能结合
3211
+ * `author.bot` 与内容语法做保守识别。
3212
+ */
3213
+ const shouldUseMarkdown = (messageType, fromBot, text) => {
3214
+ return (messageType === 103 || fromBot) && isNativeMarkdown(text);
3215
+ };
3216
+ /**
3217
+ * 从 QQ 表情标签的 base64 ext 中提取可读文本。
3218
+ *
3219
+ * faceType=4 的表情包套图没有图片附件,只能通过 ext 中的 JSON 文本展示。
3220
+ *
3221
+ * @param ext QQ 表情标签里的 base64 字段。
3222
+ * @returns ext JSON 中的 text;解析失败时返回空字符串。
3223
+ */
3224
+ const getFaceExtText = (ext) => {
3225
+ if (!ext) return "";
3226
+ try {
3227
+ const decoded = Buffer.from(ext, "base64").toString("utf-8");
3228
+ const payload = JSON.parse(decoded);
3229
+ return typeof payload.text === "string" ? payload.text : "";
3230
+ } catch {
3231
+ return "";
3232
+ }
3233
+ };
1574
3234
  /**
1575
3235
  * QQ Bot 报文 → karin elements
1576
3236
  *
@@ -1582,17 +3242,39 @@ const unescape = (text) => text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").rep
1582
3242
  const convertToKarin = (appid, ev, selfSubId, options = {}) => {
1583
3243
  const elements = [];
1584
3244
  const data = ev.d;
3245
+ const messageType = "message_type" in data ? data.message_type : void 0;
3246
+ const fromBot = !!data.author?.bot;
1585
3247
  /** mentions 索引 id -> displayName */
1586
3248
  const mentionMap = /* @__PURE__ */ new Map();
3249
+ const selfMentionIds = new Set([appid, selfSubId].filter(Boolean));
1587
3250
  if (ev.t === "MESSAGE_CREATE" || ev.t === "AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
1588
3251
  mentionMap.set(v.id, v.username);
3252
+ if (v.id === selfSubId) selfMentionIds.add(v.id);
1589
3253
  });
1590
3254
  else if (ev.t === "GROUP_MESSAGE_CREATE" || ev.t === "GROUP_AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
1591
3255
  mentionMap.set(v.id, v.username);
1592
3256
  mentionMap.set(v.member_openid, v.username);
3257
+ if (v.is_you) {
3258
+ selfMentionIds.add(v.id);
3259
+ selfMentionIds.add(v.member_openid);
3260
+ }
1593
3261
  });
1594
- /** 附件 */
1595
- for (const v of data.attachments || []) {
3262
+ const isSelfAt = (element) => element.type === "at" && selfMentionIds.has(element.targetId);
3263
+ const hasSelfAt = () => elements.some(isSelfAt);
3264
+ const pushAt = (id, name = "") => {
3265
+ const targetId = selfMentionIds.has(id) ? appid : id;
3266
+ if (targetId === appid && hasSelfAt()) {
3267
+ const prev = elements.at(-1);
3268
+ const beforePrev = elements.at(-2);
3269
+ if (prev?.type === "text" && !prev.text.trim() && beforePrev && isSelfAt(beforePrev)) elements.pop();
3270
+ return;
3271
+ }
3272
+ elements.push(segment.at(targetId, name));
3273
+ };
3274
+ /** 将 QQ 附件转换为 Karin 元素。 */
3275
+ const attachments = data.attachments || [];
3276
+ let attachmentIndex = 0;
3277
+ const pushAttachment = (v) => {
1596
3278
  const url = v.url.startsWith("http") ? v.url : `https://${v.url}`;
1597
3279
  if (v.content_type.startsWith("image/")) elements.push(segment.image(url, {
1598
3280
  subType: v.content_type.split("/")[1],
@@ -1602,29 +3284,49 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
1602
3284
  }));
1603
3285
  else if (v.content_type === "video/mp4") elements.push(segment.video(url));
1604
3286
  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) || [];
3287
+ else if (v.content_type === "file") elements.push(segment.file(url, {
3288
+ name: v.filename,
3289
+ size: v.size
3290
+ }));
3291
+ };
3292
+ /** content 解析 */
3293
+ const tokens = tokenizeContent(data?.content);
1611
3294
  const cfg = getConfig(appid);
1612
3295
  for (const tok of tokens) {
1613
3296
  if (tok.startsWith("<faceType=")) {
1614
- const m = tok.match(/faceId="(\d+)"/);
1615
- if (m) elements.push(segment.face(Number(m[1])));
3297
+ const m = tok.match(/faceType=(\d+),faceId="([^"]*)",ext="([^"]*)"/);
3298
+ const faceType = Number(m?.[1]);
3299
+ const faceIdText = m?.[2] || "";
3300
+ const faceId = Number(faceIdText);
3301
+ const attachment = attachments[attachmentIndex];
3302
+ if (!faceIdText) {
3303
+ if (attachment?.content_type.startsWith("image/")) {
3304
+ pushAttachment(attachment);
3305
+ attachmentIndex++;
3306
+ } else {
3307
+ const text = getFaceExtText(m?.[3] || "");
3308
+ if (text) elements.push(segment.text(text));
3309
+ }
3310
+ continue;
3311
+ }
3312
+ /**
3313
+ * QQ 将用户收藏表情图片编码为 `faceId=0` + 图片附件。附件在 content 中
3314
+ * 没有独立标签,必须在这个占位符位置消费,才能保留原始元素顺序。
3315
+ */
3316
+ if (faceId === 0 && (messageType === 103 || faceType === 6) && attachment?.content_type.startsWith("image/")) {
3317
+ pushAttachment(attachment);
3318
+ attachmentIndex++;
3319
+ } else if (faceType === 6 && faceId === 0) {} else if (m) elements.push(segment.face(faceId, faceType === 3));
1616
3320
  continue;
1617
3321
  }
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));
3322
+ if (tok.startsWith("<@")) {
3323
+ const id = tok.replace(/^<@!?|>$/g, "");
3324
+ pushAt(id, mentionMap.get(id) || "");
1622
3325
  continue;
1623
3326
  }
1624
3327
  if (tok.startsWith("<qqbot-at-user")) {
1625
3328
  const id = tok.match(/id="([^"]+)"/)?.[1] || "";
1626
- const name = mentionMap.get(id) || "";
1627
- elements.push(segment.at(id === selfSubId ? appid : id, name));
3329
+ pushAt(id, mentionMap.get(id) || "");
1628
3330
  continue;
1629
3331
  }
1630
3332
  if (tok.startsWith("<qqbot-at-everyone")) {
@@ -1634,10 +3336,18 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
1634
3336
  let text = unescape(tok);
1635
3337
  if (cfg?.regex) for (const r of cfg.regex) {
1636
3338
  const reg = r.reg instanceof RegExp ? r.reg : new RegExp(r.reg);
1637
- text = text.trim().replace(reg, r.rep);
3339
+ text = text.replace(reg, r.rep);
3340
+ }
3341
+ if (!text) continue;
3342
+ if (messageType === 103 && pushQQMarkdown(text, elements)) continue;
3343
+ if (shouldUseMarkdown(messageType, fromBot, text)) {
3344
+ elements.push(segment.markdown(text));
3345
+ continue;
1638
3346
  }
1639
3347
  elements.push(segment.text(text));
1640
3348
  }
3349
+ /** content 中未出现占位符的普通附件,按 QQ 报文顺序追加。 */
3350
+ for (; attachmentIndex < attachments.length; attachmentIndex++) pushAttachment(attachments[attachmentIndex]);
1641
3351
  /** 群消息是否需要在头部补 self at */
1642
3352
  if (ev.t === "GROUP_AT_MESSAGE_CREATE" || ev.t === "GROUP_MESSAGE_CREATE") {
1643
3353
  const youMention = (ev.d.mentions || []).find((m) => m.is_you);
@@ -1647,10 +3357,79 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
1647
3357
  }
1648
3358
  return elements;
1649
3359
  };
3360
+ /**
3361
+ * 将 QQ 引用上下文转换为 Karin 元素。
3362
+ *
3363
+ * QQ 不会提供引用消息的正式消息 ID;调用方应以 `msg_idx` 作为缓存键。
3364
+ */
3365
+ const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
3366
+ return convertToKarin(appid, {
3367
+ ...ev,
3368
+ d: {
3369
+ ...ev.d,
3370
+ content: reference.content,
3371
+ attachments: reference.attachments,
3372
+ mentions: void 0,
3373
+ message_scene: void 0,
3374
+ msg_elements: void 0,
3375
+ message_type: reference.message_type
3376
+ }
3377
+ }, selfSubId);
3378
+ };
1650
3379
 
1651
3380
  //#endregion
1652
3381
  //#region src/core/event/message.ts
1653
3382
  /**
3383
+ * 写入 getMsg 热缓存并投递 SQLite 后台队列。
3384
+ *
3385
+ * 不等待数据库落库,避免缓存特性拖慢 Karin 消息事件下发。
3386
+ *
3387
+ * @param client 当前 QQBot 适配器实例。
3388
+ * @param message 需要缓存的消息快照。
3389
+ * @param aliases 可用于查询同一消息的 QQ 侧索引,例如 msg_idx。
3390
+ */
3391
+ const cacheMessage = (client, message, aliases = []) => {
3392
+ if (!client.cfg.messageCache.enable) return;
3393
+ client.messageStore.save(String(client.cfg.appId), {
3394
+ ...message,
3395
+ messageSeq: 0
3396
+ }, aliases).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
3397
+ };
3398
+ /**
3399
+ * 写入引用消息上下文缓存。
3400
+ *
3401
+ * 引用上下文没有正式消息 ID 时,以 QQ 的 `ref_msg_idx` 建立只写入一次的缓存。
3402
+ *
3403
+ * @param client 当前 QQBot 适配器实例。
3404
+ * @param message 以 ref_msg_idx 作为 messageId 的引用消息快照。
3405
+ */
3406
+ const cacheReference = (client, message) => {
3407
+ if (!client.cfg.messageCache.enable) return;
3408
+ client.messageStore.saveReferenceIfAbsent(String(client.cfg.appId), {
3409
+ ...message,
3410
+ messageSeq: 0
3411
+ }).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
3412
+ };
3413
+ /**
3414
+ * 将 Karin 快速回复自动生成的当前消息引用 ID 转成 QQ 客户端可见引用 ID。
3415
+ *
3416
+ * Karin 核心只能根据 `e.message_id` 生成 `segment.reply(e.message_id)`;而 QQ 群/单聊
3417
+ * 的可见引用需要 `message_scene.ext` 里的 `msg_idx=REFIDX_xxx`。这里仅转换
3418
+ * `reply.messageId === ev.d.id` 的那一个自动引用段,保留 `ev.d.id` 作为 Karin 标准
3419
+ * messageId,避免影响 getMsg / recallMsg / 被动回复 msg_id 等主链路。
3420
+ */
3421
+ const normalizeQuickReplyReference = (elements, sourceMessageId, referenceMessageId) => {
3422
+ if (!sourceMessageId || !referenceMessageId) return elements;
3423
+ let normalized = false;
3424
+ return elements.map((element) => {
3425
+ if (!normalized && element.type === "reply" && element.messageId === sourceMessageId) {
3426
+ normalized = true;
3427
+ return segment.reply(referenceMessageId);
3428
+ }
3429
+ return element;
3430
+ });
3431
+ };
3432
+ /**
1654
3433
  * 群消息(@机器人 / 普通消息合并入口)
1655
3434
  */
1656
3435
  const onGroupMsg = (client, ev, opts) => {
@@ -1661,19 +3440,41 @@ const onGroupMsg = (client, ev, opts) => {
1661
3440
  ev.d.mentions?.forEach((m) => {
1662
3441
  if (m.member_openid && m.username) client.nicknameCache.set(m.member_openid, m.username);
1663
3442
  });
1664
- const contact = karin.contactGroup(ev.d.group_openid || ev.d.group_id);
1665
- const sender = karin.groupSender(userId, "member", username);
3443
+ const contact = karin$1.contactGroup(ev.d.group_openid || ev.d.group_id);
3444
+ const sender = karin$1.groupSender(userId, ev.d.author.member_role || "unknown", username);
3445
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"), { forceAtSelf: opts.forceAtSelf });
3446
+ const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
3447
+ const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
3448
+ const karinMessageId = ev.d.id;
3449
+ if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
3450
+ if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
3451
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3452
+ cacheMessage(client, {
3453
+ messageId: karinMessageId,
3454
+ time,
3455
+ contact,
3456
+ sender,
3457
+ elements
3458
+ }, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
3459
+ const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
3460
+ if (reference && referenceIndex) cacheReference(client, {
3461
+ messageId: referenceIndex,
3462
+ time,
3463
+ contact,
3464
+ sender: karin$1.groupSender("", "member", reference.author?.username || ""),
3465
+ elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
3466
+ });
1666
3467
  const e = createGroupMessage({
1667
3468
  bot: client,
1668
- elements: convertToKarin(selfId, ev, client.selfSubId("id"), { forceAtSelf: opts.forceAtSelf }),
3469
+ elements,
1669
3470
  eventId: ev.id,
1670
- messageId: ev.d.id,
3471
+ messageId: karinMessageId,
1671
3472
  messageSeq: 0,
1672
3473
  rawEvent: ev,
1673
- time: Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp),
3474
+ time,
1674
3475
  contact,
1675
3476
  sender,
1676
- srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
3477
+ srcReply: (elements) => client.srcReply(e, [...normalizeQuickReplyReference(elements, karinMessageId, messageIndex), segment.pasmsg(ev.d.id)])
1677
3478
  });
1678
3479
  return e;
1679
3480
  };
@@ -1685,19 +3486,41 @@ const onFriendMsg = (client, ev) => {
1685
3486
  const userId = ev.d.author.user_openid || ev.d.author.id;
1686
3487
  const username = ev.d.author.username || "";
1687
3488
  if (userId && username) client.nicknameCache.set(userId, username);
1688
- const contact = karin.contactFriend(userId);
1689
- const sender = karin.friendSender(userId, username);
3489
+ const contact = karin$1.contactFriend(userId);
3490
+ const sender = karin$1.friendSender(userId, username);
3491
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
3492
+ const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
3493
+ const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
3494
+ const karinMessageId = ev.d.id;
3495
+ if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
3496
+ if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
3497
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3498
+ cacheMessage(client, {
3499
+ messageId: karinMessageId,
3500
+ time,
3501
+ contact,
3502
+ sender,
3503
+ elements
3504
+ }, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
3505
+ const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
3506
+ if (reference && referenceIndex) cacheReference(client, {
3507
+ messageId: referenceIndex,
3508
+ time,
3509
+ contact,
3510
+ sender: karin$1.friendSender("", reference.author?.username || ""),
3511
+ elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
3512
+ });
1690
3513
  const e = createFriendMessage({
1691
3514
  bot: client,
1692
- elements: convertToKarin(selfId, ev, client.selfSubId("id")),
3515
+ elements,
1693
3516
  eventId: ev.id,
1694
- messageId: ev.d.id,
3517
+ messageId: karinMessageId,
1695
3518
  messageSeq: 0,
1696
3519
  rawEvent: ev,
1697
- time: Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp),
3520
+ time,
1698
3521
  contact,
1699
3522
  sender,
1700
- srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
3523
+ srcReply: (elements) => client.srcReply(e, [...normalizeQuickReplyReference(elements, karinMessageId, messageIndex), segment.pasmsg(ev.d.id)])
1701
3524
  });
1702
3525
  return e;
1703
3526
  };
@@ -1707,16 +3530,25 @@ const onFriendMsg = (client, ev) => {
1707
3530
  const onChannelMsg = (client, ev) => {
1708
3531
  const selfId = client.selfId;
1709
3532
  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 || "");
3533
+ const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
3534
+ const sender = karin$1.groupSender(userId, "unknown", ev.d.author.username || "");
3535
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
3536
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3537
+ cacheMessage(client, {
3538
+ messageId: ev.d.id,
3539
+ time,
3540
+ contact,
3541
+ sender,
3542
+ elements
3543
+ });
1712
3544
  const e = createGuildMessage({
1713
3545
  bot: client,
1714
- elements: convertToKarin(selfId, ev, client.selfSubId("id")),
3546
+ elements,
1715
3547
  eventId: ev.id,
1716
3548
  messageId: ev.d.id,
1717
3549
  messageSeq: 0,
1718
3550
  rawEvent: ev,
1719
- time: Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp),
3551
+ time,
1720
3552
  contact,
1721
3553
  sender,
1722
3554
  srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
@@ -1729,16 +3561,25 @@ const onChannelMsg = (client, ev) => {
1729
3561
  const onDirectMsg = (client, ev) => {
1730
3562
  const selfId = client.selfId;
1731
3563
  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 || "");
3564
+ const contact = karin$1.contact("direct", ev.d.guild_id, ev.d.channel_id);
3565
+ const sender = karin$1.friendSender(userId, ev.d.author.username || "");
3566
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
3567
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3568
+ cacheMessage(client, {
3569
+ messageId: ev.d.id,
3570
+ time,
3571
+ contact,
3572
+ sender,
3573
+ elements
3574
+ });
1734
3575
  const e = createDirectMessage({
1735
3576
  bot: client,
1736
- elements: convertToKarin(selfId, ev, client.selfSubId("id")),
3577
+ elements,
1737
3578
  eventId: ev.id,
1738
3579
  messageId: ev.d.id,
1739
3580
  messageSeq: 0,
1740
3581
  rawEvent: ev,
1741
- time: Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp),
3582
+ time,
1742
3583
  contact,
1743
3584
  sender,
1744
3585
  srcGuildId: ev.d.src_guild_id,
@@ -1758,8 +3599,8 @@ const onGroupAddRobot = (client, event) => {
1758
3599
  const selfId = client.selfId;
1759
3600
  const userId = event.d.op_member_openid;
1760
3601
  const groupId = event.d.group_openid;
1761
- const contact = karin.contactGroup(groupId);
1762
- const sender = karin.groupSender(userId, "unknown");
3602
+ const contact = karin$1.contactGroup(groupId);
3603
+ const sender = karin$1.groupSender(selfId, "member");
1763
3604
  const eventId = event.id;
1764
3605
  const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
1765
3606
  createGroupMemberAddNotice({
@@ -1786,8 +3627,8 @@ const onGroupDelRobot = (client, event) => {
1786
3627
  const selfId = client.selfId;
1787
3628
  const userId = event.d.op_member_openid;
1788
3629
  const groupId = event.d.group_openid;
1789
- const contact = karin.contactGroup(groupId);
1790
- const sender = karin.groupSender(userId, "unknown");
3630
+ const contact = karin$1.contactGroup(groupId);
3631
+ const sender = karin$1.groupSender(selfId, "unknown");
1791
3632
  const eventId = event.id;
1792
3633
  const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
1793
3634
  createGroupMemberDelNotice({
@@ -1806,6 +3647,64 @@ const onGroupDelRobot = (client, event) => {
1806
3647
  });
1807
3648
  };
1808
3649
  /**
3650
+ * 群成员加入事件。
3651
+ *
3652
+ * QQ 官方仅下发加入成员,未提供操作人和加入方式;Karin 的通知模型要求
3653
+ * `type`,因此以 `approve` 表示已加入,并将 `operatorId` 留空。
3654
+ * 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
3655
+ *
3656
+ * @param client 机器人实例
3657
+ * @param event 官方群成员加入事件
3658
+ */
3659
+ const onGroupMemberAdd = (client, event) => {
3660
+ const memberId = event.d.member_openid;
3661
+ const contact = karin$1.contactGroup(event.d.group_openid);
3662
+ const sender = karin$1.groupSender(memberId, "member");
3663
+ createGroupMemberAddNotice({
3664
+ contact,
3665
+ eventId: event.id,
3666
+ sender,
3667
+ srcReply: (elements) => client.sendMsg(contact, elements),
3668
+ bot: client,
3669
+ rawEvent: event,
3670
+ time: event.d.timestamp,
3671
+ content: {
3672
+ operatorId: "",
3673
+ targetId: memberId,
3674
+ type: "approve"
3675
+ }
3676
+ });
3677
+ };
3678
+ /**
3679
+ * 群成员退出事件。
3680
+ *
3681
+ * QQ 官方仅下发退出成员,未提供操作人和退出原因;Karin 的通知模型要求
3682
+ * `type`,因此以 `leave` 表示成员已离开,并将 `operatorId` 留空。
3683
+ * 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
3684
+ *
3685
+ * @param client 机器人实例
3686
+ * @param event 官方群成员退出事件
3687
+ */
3688
+ const onGroupMemberRemove = (client, event) => {
3689
+ const memberId = event.d.member_openid;
3690
+ const contact = karin$1.contactGroup(event.d.group_openid);
3691
+ const sender = karin$1.groupSender(memberId, "unknown");
3692
+ createGroupMemberDelNotice({
3693
+ contact,
3694
+ eventId: event.id,
3695
+ sender,
3696
+ srcReply: (elements) => client.sendMsg(contact, elements),
3697
+ bot: client,
3698
+ rawEvent: event,
3699
+ time: event.d.timestamp,
3700
+ content: {
3701
+ operatorId: "",
3702
+ targetId: memberId,
3703
+ type: "leave"
3704
+ }
3705
+ });
3706
+ };
3707
+ /**
1809
3708
  * 群聊接受机器人主动消息
1810
3709
  * @param client 机器人实例
1811
3710
  * @param event 事件
@@ -1833,8 +3732,8 @@ const onGroupMsgReject = (client, event) => {
1833
3732
  const onFriendAdd = (client, event) => {
1834
3733
  const selfId = client.selfId;
1835
3734
  const userId = event.d.openid;
1836
- const contact = karin.contactFriend(userId);
1837
- const sender = karin.friendSender(userId, "");
3735
+ const contact = karin$1.contactFriend(userId);
3736
+ const sender = karin$1.friendSender(userId, "");
1838
3737
  const eventId = event.id;
1839
3738
  const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
1840
3739
  createFriendIncreaseNotice({
@@ -1856,8 +3755,8 @@ const onFriendAdd = (client, event) => {
1856
3755
  const onFriendDel = (client, event) => {
1857
3756
  const selfId = client.selfId;
1858
3757
  const userId = event.d.openid;
1859
- const contact = karin.contactFriend(userId);
1860
- const sender = karin.friendSender(userId, "");
3758
+ const contact = karin$1.contactFriend(userId);
3759
+ const sender = karin$1.friendSender(userId, "");
1861
3760
  const eventId = event.id;
1862
3761
  const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
1863
3762
  createFriendDecreaseNotice({
@@ -1893,46 +3792,103 @@ const onC2CMsgReject = (client, event) => {
1893
3792
  //#endregion
1894
3793
  //#region src/core/event/interaction.ts
1895
3794
  /**
3795
+ * 取得互动事件真正用于 ACK 的平台 ID。
3796
+ *
3797
+ * WebSocket Dispatch 外层也有一个 `id`,形如 `INTERACTION_CREATE:uuid`;按钮 ACK
3798
+ * 接口需要的是事件体内的 `d.id`,也就是不带事件名前缀的 uuid。
3799
+ *
3800
+ * @param ev QQBot 原始互动事件。
3801
+ * @returns 可传给 `/interactions/{interaction_id}` 的 ID。
3802
+ */
3803
+ const getInteractionId = (ev) => ev.d.id || ev.id;
3804
+ /**
3805
+ * 构造按钮回调附加给 Karin 消息的元信息。
3806
+ *
3807
+ * QQBot 不同场景下下发字段差异很大:群聊通常只有 button_data/button_id,
3808
+ * 频道才可能带 user_id/message_id/feature_id。这里只写入实际存在的字段,
3809
+ * 避免插件侧看到一堆语义不明的空值。
3810
+ *
3811
+ * @param ev QQBot 原始互动事件。
3812
+ * @param interactionId 已确认可用于 ACK 的平台互动 ID。
3813
+ * @returns 可放入 segment.json 的精简元信息。
3814
+ */
3815
+ const createInteractionMeta = (ev, interactionId) => {
3816
+ const resolved = ev.d.data?.resolved;
3817
+ const meta = {
3818
+ tag: "qqbot-button-click",
3819
+ interaction_id: interactionId,
3820
+ dispatch_id: ev.id,
3821
+ interaction_type: ev.d.type
3822
+ };
3823
+ const optional = {
3824
+ data_type: ev.d.data?.type,
3825
+ chat_type: ev.d.chat_type,
3826
+ scene: ev.d.scene,
3827
+ button_id: resolved?.button_id,
3828
+ button_data: resolved?.button_data,
3829
+ application_id: ev.d.application_id,
3830
+ group_openid: ev.d.group_openid,
3831
+ group_member_openid: ev.d.group_member_openid,
3832
+ user_openid: ev.d.user_openid,
3833
+ guild_id: ev.d.guild_id,
3834
+ channel_id: ev.d.channel_id,
3835
+ guild_user_id: resolved?.user_id,
3836
+ feature_id: resolved?.feature_id,
3837
+ message_id: resolved?.message_id,
3838
+ require_mention: resolved?.require_mention,
3839
+ group_policy: resolved?.group_policy,
3840
+ mention_patterns: resolved?.mention_patterns,
3841
+ version: ev.d.version
3842
+ };
3843
+ for (const [key, value] of Object.entries(optional)) if (value !== void 0 && value !== "") meta[key] = value;
3844
+ return meta;
3845
+ };
3846
+ /**
1896
3847
  * INTERACTION_CREATE 处理
1897
3848
  *
1898
3849
  * - 先 ack 客户端(必须在 3s 内),失败仅 warn
1899
3850
  * - 借用普通消息事件投递给业务层,业务层通过 e.rawEvent.t === 'INTERACTION_CREATE' 区分
1900
- * - elements: [self at] + text(button_data) + json(meta) + pasmsg(event_id, 'event')
3851
+ * - elements 只放 button_data 文本,避免 at/json 段污染 e.msg 导致 command 无法精确匹配
1901
3852
  */
1902
3853
  const onInteraction = (client, ev) => {
1903
- client.super.interaction.ack(ev.id, 0).catch((err) => {
3854
+ const interactionId = getInteractionId(ev);
3855
+ const ackStart = Date.now();
3856
+ client.logger("debug", `[INTERACTION] ack 准备: ${interactionId} dispatch=${ev.id}`);
3857
+ client.super.interaction.ack(interactionId, 0).then(() => {
3858
+ client.logger("debug", `[INTERACTION] ack 成功: ${interactionId} (${Date.now() - ackStart}ms)`);
3859
+ }).catch((err) => {
1904
3860
  client.logger("warn", "[INTERACTION] ack 失败:", err);
3861
+ }).finally(() => {
3862
+ emitInteractionMessage(client, ev, interactionId);
1905
3863
  });
1906
- const selfId = client.selfId;
3864
+ };
3865
+ /**
3866
+ * 将按钮互动事件转换为 Karin 消息事件。
3867
+ *
3868
+ * ACK 请求先发出并结束后再投递业务层,避免业务回复请求抢在 ACK 前占用回调窗口。
3869
+ *
3870
+ * @param client 当前 QQBot 适配器实例。
3871
+ * @param ev QQBot 原始互动事件。
3872
+ * @param interactionId 已确认可用于 ACK 的平台互动 ID。
3873
+ */
3874
+ const emitInteractionMessage = (client, ev, interactionId) => {
1907
3875
  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());
3876
+ const rawEvent = {
3877
+ ...ev,
3878
+ buttonMeta: createInteractionMeta(ev, interactionId)
3879
+ };
3880
+ const time = Number(ev.d.timestamp ? new Date(ev.d.timestamp).getTime() : Date.now()) || Date.now();
1921
3881
  if (ev.d.chat_type === 1 && ev.d.group_openid) {
1922
3882
  const userId = ev.d.group_member_openid || "";
1923
- const contact = karin.contactGroup(ev.d.group_openid);
1924
- const sender = karin.groupSender(userId, "unknown");
3883
+ const contact = karin$1.contactGroup(ev.d.group_openid);
3884
+ const sender = karin$1.groupSender(userId, "unknown");
1925
3885
  const e = createGroupMessage({
1926
3886
  bot: client,
1927
- elements: [
1928
- segment.at(selfId),
1929
- segment.text(buttonData),
1930
- meta
1931
- ],
3887
+ elements: [segment.text(buttonData)],
1932
3888
  eventId: ev.id,
1933
- messageId: ev.id,
3889
+ messageId: interactionId,
1934
3890
  messageSeq: 0,
1935
- rawEvent: ev,
3891
+ rawEvent,
1936
3892
  time,
1937
3893
  contact,
1938
3894
  sender,
@@ -1942,19 +3898,15 @@ const onInteraction = (client, ev) => {
1942
3898
  }
1943
3899
  if (ev.d.chat_type === 2 && ev.d.user_openid) {
1944
3900
  const userId = ev.d.user_openid;
1945
- const contact = karin.contactFriend(userId);
1946
- const sender = karin.friendSender(userId, "");
3901
+ const contact = karin$1.contactFriend(userId);
3902
+ const sender = karin$1.friendSender(userId, "");
1947
3903
  const e = createFriendMessage({
1948
3904
  bot: client,
1949
- elements: [
1950
- segment.at(selfId),
1951
- segment.text(buttonData),
1952
- meta
1953
- ],
3905
+ elements: [segment.text(buttonData)],
1954
3906
  eventId: ev.id,
1955
- messageId: ev.id,
3907
+ messageId: interactionId,
1956
3908
  messageSeq: 0,
1957
- rawEvent: ev,
3909
+ rawEvent,
1958
3910
  time,
1959
3911
  contact,
1960
3912
  sender,
@@ -1964,15 +3916,15 @@ const onInteraction = (client, ev) => {
1964
3916
  }
1965
3917
  if (ev.d.chat_type === 0 && ev.d.guild_id && ev.d.channel_id) {
1966
3918
  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");
3919
+ const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
3920
+ const sender = karin$1.groupSender(userId, "unknown");
1969
3921
  const e = createGuildMessage({
1970
3922
  bot: client,
1971
- elements: [segment.text(buttonData), meta],
3923
+ elements: [segment.text(buttonData)],
1972
3924
  eventId: ev.id,
1973
- messageId: ev.id,
3925
+ messageId: interactionId,
1974
3926
  messageSeq: 0,
1975
- rawEvent: ev,
3927
+ rawEvent,
1976
3928
  time,
1977
3929
  contact,
1978
3930
  sender,
@@ -1997,6 +3949,8 @@ const eventLabel = {
1997
3949
  ["DIRECT_MESSAGE_CREATE"]: "频道私信",
1998
3950
  ["GROUP_ADD_ROBOT"]: "机器人入群",
1999
3951
  ["GROUP_DEL_ROBOT"]: "机器人退群",
3952
+ ["GROUP_MEMBER_ADD"]: "群成员加入",
3953
+ ["GROUP_MEMBER_REMOVE"]: "群成员退出",
2000
3954
  ["GROUP_MSG_RECEIVE"]: "群聊开启通知",
2001
3955
  ["GROUP_MSG_REJECT"]: "群聊关闭通知",
2002
3956
  ["FRIEND_ADD"]: "添加好友",
@@ -2039,6 +3993,12 @@ const dispatch$1 = (client, ev) => {
2039
3993
  case "GROUP_DEL_ROBOT":
2040
3994
  onGroupDelRobot(client, ev);
2041
3995
  return;
3996
+ case "GROUP_MEMBER_ADD":
3997
+ onGroupMemberAdd(client, ev);
3998
+ return;
3999
+ case "GROUP_MEMBER_REMOVE":
4000
+ onGroupMemberRemove(client, ev);
4001
+ return;
2042
4002
  case "GROUP_MSG_RECEIVE":
2043
4003
  onGroupMsgReceive(client, ev);
2044
4004
  return;
@@ -2062,7 +4022,7 @@ const dispatch$1 = (client, ev) => {
2062
4022
  return;
2063
4023
  case "MESSAGE_AUDIT_PASS": return client.logger("debug", `消息审核通过: audit_id=${ev.d.audit_id}`);
2064
4024
  case "MESSAGE_AUDIT_REJECT": return client.logger("debug", `消息审核不通过: audit_id=${ev.d.audit_id}`);
2065
- default: logger.debug(`[QQ Official Bot] 未处理事件: ${ev.t}`);
4025
+ default: client.logger("debug", `[QQ Official Bot] 未处理事件: ${ev.t}`);
2066
4026
  }
2067
4027
  };
2068
4028
 
@@ -2105,6 +4065,11 @@ const wsErrorMap = {
2105
4065
  canResume: false,
2106
4066
  canIdentify: false
2107
4067
  },
4068
+ 4004: {
4069
+ message: "鉴权失败或 access_token 无效",
4070
+ canResume: false,
4071
+ canIdentify: true
4072
+ },
2108
4073
  4007: {
2109
4074
  message: "seq 错误",
2110
4075
  canResume: false,
@@ -2215,6 +4180,8 @@ var WSClient = class {
2215
4180
  seq = 0;
2216
4181
  closed = false;
2217
4182
  closedReason = "manual";
4183
+ openedAt = 0;
4184
+ lastAuthAction;
2218
4185
  constructor(opts) {
2219
4186
  this.opts = opts;
2220
4187
  if (opts.resume) {
@@ -2230,21 +4197,30 @@ var WSClient = class {
2230
4197
  };
2231
4198
  }
2232
4199
  start() {
2233
- const ws = new WebSocket(this.opts.gatewayUrl);
4200
+ const ws = new WebSocket(this.opts.gatewayUrl, { headers: { "User-Agent": this.opts.userAgent } });
2234
4201
  this.socket = ws;
2235
4202
  ws.on("open", () => {
4203
+ this.openedAt = Date.now();
2236
4204
  log("debug", `${this.opts.appId}: WebSocket opened (${this.opts.gatewayUrl})`);
2237
4205
  });
2238
4206
  ws.on("close", (code, reason) => {
2239
- const closeInfo = formatWSClose(code, reason?.toString() || "(empty)");
4207
+ const remoteReason = reason?.toString() || "";
4208
+ const closeInfo = formatWSClose(code, remoteReason);
4209
+ const info = getWSErrorInfo(code);
2240
4210
  log("error", `${this.opts.appId}: WebSocket 关闭 | ${closeInfo}`);
2241
4211
  if (this.closed) return;
2242
- this.close("closed");
4212
+ this.close("closed", {
4213
+ code,
4214
+ remoteReason: remoteReason || void 0,
4215
+ durationMs: this.durationMs(),
4216
+ canResume: info?.canResume,
4217
+ canIdentify: info?.canIdentify
4218
+ });
2243
4219
  });
2244
4220
  ws.on("error", (err) => {
2245
4221
  log("error", `${this.opts.appId}: WebSocket error:`, err);
2246
4222
  if (this.closed) return;
2247
- this.close("error");
4223
+ this.close("error", { durationMs: this.durationMs() });
2248
4224
  });
2249
4225
  ws.on("unexpected-response", (_req, res) => {
2250
4226
  log("error", `${this.opts.appId}: WebSocket unexpected-response status=${res.statusCode} ${res.statusMessage}`);
@@ -2257,7 +4233,7 @@ var WSClient = class {
2257
4233
  stop() {
2258
4234
  this.close("manual");
2259
4235
  }
2260
- close(reason) {
4236
+ close(reason, event = {}) {
2261
4237
  if (this.closed) return;
2262
4238
  this.closed = true;
2263
4239
  this.closedReason = reason;
@@ -2269,7 +4245,13 @@ var WSClient = class {
2269
4245
  this.socket?.removeAllListeners();
2270
4246
  this.socket?.close();
2271
4247
  } catch {}
2272
- this.opts.onClose(reason);
4248
+ this.opts.onClose({
4249
+ reason,
4250
+ ...event
4251
+ });
4252
+ }
4253
+ durationMs() {
4254
+ return this.openedAt ? Date.now() - this.openedAt : void 0;
2273
4255
  }
2274
4256
  send(payload) {
2275
4257
  try {
@@ -2289,6 +4271,7 @@ var WSClient = class {
2289
4271
  }, this.heartbeatInterval);
2290
4272
  }
2291
4273
  identify() {
4274
+ this.lastAuthAction = "identify";
2292
4275
  this.send({
2293
4276
  op: 2,
2294
4277
  d: {
@@ -2305,6 +4288,7 @@ var WSClient = class {
2305
4288
  log("debug", `${this.opts.appId}: Identify sent, intents=${this.opts.intents}`);
2306
4289
  }
2307
4290
  resume() {
4291
+ this.lastAuthAction = "resume";
2308
4292
  this.send({
2309
4293
  op: 6,
2310
4294
  d: {
@@ -2339,6 +4323,7 @@ var WSClient = class {
2339
4323
  case 0:
2340
4324
  if (typeof msg.s === "number") this.seq = msg.s;
2341
4325
  if (msg.t === "READY") {
4326
+ this.lastAuthAction = void 0;
2342
4327
  this.sessionId = msg.d?.session_id || "";
2343
4328
  log("debug", `${this.opts.appId}: READY received, session_id=${this.sessionId}`);
2344
4329
  }
@@ -2352,17 +4337,31 @@ var WSClient = class {
2352
4337
  log("debug", `${this.opts.appId}: server asked reconnect (op:7)`);
2353
4338
  this.close("reconnect");
2354
4339
  break;
2355
- case 9:
2356
- if (this.sessionId) {
2357
- log("debug", `${this.opts.appId}: invalid session, will retry with fresh identify`);
4340
+ case 9: {
4341
+ const canResume = msg.d === true;
4342
+ if (this.lastAuthAction === "identify") {
4343
+ log("error", `${this.opts.appId}: identify rejected (op:9), will try fallback intents, canResume=${canResume}`);
4344
+ this.close("auth_fail", {
4345
+ durationMs: this.durationMs(),
4346
+ invalidSessionCanResume: canResume
4347
+ });
4348
+ } else if (canResume && this.sessionId) {
4349
+ log("debug", `${this.opts.appId}: invalid session, server allows resume`);
4350
+ this.close("reconnect", {
4351
+ durationMs: this.durationMs(),
4352
+ invalidSessionCanResume: true
4353
+ });
4354
+ } else {
4355
+ log("debug", `${this.opts.appId}: invalid session, will retry with fresh identify, canResume=${canResume}`);
2358
4356
  this.sessionId = "";
2359
4357
  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");
4358
+ this.close("session_lost", {
4359
+ durationMs: this.durationMs(),
4360
+ invalidSessionCanResume: canResume
4361
+ });
2364
4362
  }
2365
4363
  break;
4364
+ }
2366
4365
  default: log("debug", `${this.opts.appId}: unhandled opcode ${msg.op}`);
2367
4366
  }
2368
4367
  }
@@ -2378,7 +4377,9 @@ const intentBitMap = {
2378
4377
  [2]: "GUILD_MEMBERS (频道成员变更)",
2379
4378
  [512]: "GUILD_MESSAGES (频道消息-私域)",
2380
4379
  [4096]: "DIRECT_MESSAGE (频道私信)",
4380
+ [1 << 24]: "群成员进退群事件",
2381
4381
  [1 << 25]: "GROUP_AND_C2C_EVENT (群聊/单聊消息)",
4382
+ [1 << 26]: "INTERACTION (按钮交互回调)",
2382
4383
  [1 << 30]: "PUBLIC_GUILD_MESSAGES (频道消息-公域)"
2383
4384
  };
2384
4385
  /** 优先级从高到低,回退时从最前面剥离 */
@@ -2387,6 +4388,8 @@ const priorityIntents = [
2387
4388
  512,
2388
4389
  1 << 30,
2389
4390
  4096,
4391
+ 1 << 24,
4392
+ 1 << 26,
2390
4393
  1 << 25,
2391
4394
  1
2392
4395
  ];
@@ -2416,31 +4419,47 @@ const formatIntentNames = (intents, prefix = "") => {
2416
4419
  const conns = /* @__PURE__ */ new Map();
2417
4420
  /** /gateway 接口超时(毫秒) */
2418
4421
  const GATEWAY_FETCH_TIMEOUT_MS = 5e3;
4422
+ const RATE_LIMIT_DELAY_MS = 6e4;
4423
+ const QUICK_DISCONNECT_THRESHOLD_MS = 5e3;
4424
+ const QUICK_DISCONNECT_LIMIT = 3;
4425
+ const QUICK_DISCONNECT_DELAY_MS = 6e4;
2419
4426
  /**
2420
- * 从 prodApi / sandboxApi 推导兜底的 WSS 地址
4427
+ * 从 proxy.prodApi / proxy.sandboxApi 推导兜底的 WS 地址
2421
4428
  * 官方实际长期返回的也是 wss://{host}/websocket/,不依赖 /gateway 也能直连
2422
4429
  */
2423
- const buildFallbackWssUrl = (cfg) => {
2424
- return `${(cfg.sandbox ? cfg.sandboxApi : cfg.prodApi).replace(/^https?:\/\//i, "wss://").replace(/\/+$/, "")}/websocket/`;
4430
+ const buildFallbackWsUrl = (cfg) => {
4431
+ return buildFallbackWsUrlFromApi(cfg.sandbox ? cfg.proxy.sandboxApi : cfg.proxy.prodApi);
2425
4432
  };
2426
4433
  /**
2427
4434
  * 获取网关地址:优先调 /gateway;失败 / 超时 / 限频 → 直接 fallback 到硬编码
2428
4435
  */
2429
4436
  const fetchGateway = async (cfg) => {
2430
- const apiUrl = cfg.sandbox ? cfg.sandboxApi : cfg.prodApi;
2431
- const fallback = buildFallbackWssUrl(cfg);
4437
+ const apiUrl = normalizeHttpUrl(cfg.sandbox ? cfg.proxy.sandboxApi : cfg.proxy.prodApi);
4438
+ const fallback = buildFallbackWsUrl(cfg);
4439
+ const proxyWsValue = cfg.sandbox ? cfg.proxy.sandboxWs : cfg.proxy.prodWs;
4440
+ if (proxyWsValue.trim()) try {
4441
+ const proxyWsUrl = normalizeWsGatewayUrl(proxyWsValue);
4442
+ log("debug", `${cfg.appId}: 使用自定义 WS ${proxyWsUrl}`);
4443
+ return proxyWsUrl;
4444
+ } catch (err) {
4445
+ const reason = err?.message || "unknown";
4446
+ log("warn", `${cfg.appId}: 自定义 WS 地址无效 (${reason}),尝试获取 /gateway`);
4447
+ }
2432
4448
  const accessToken = getBotAccessToken(cfg.appId);
2433
4449
  if (!accessToken) {
2434
- log("warn", `${cfg.appId}: 无 access_token,使用硬编码 WSS ${fallback}`);
4450
+ log("warn", `${cfg.appId}: 无 access_token,使用硬编码 WS ${fallback}`);
2435
4451
  return fallback;
2436
4452
  }
2437
4453
  try {
2438
- const { data } = await axios.get(`${apiUrl}/gateway`, {
2439
- headers: { Authorization: `QQBot ${accessToken}` },
4454
+ const { data } = await axios.get(joinHttpPath(apiUrl, "gateway"), {
4455
+ headers: {
4456
+ Authorization: `QQBot ${accessToken}`,
4457
+ "User-Agent": getUserAgent()
4458
+ },
2440
4459
  timeout: GATEWAY_FETCH_TIMEOUT_MS
2441
4460
  });
2442
4461
  if (data?.url) return data.url;
2443
- log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码 WSS ${fallback}`);
4462
+ log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码 WS ${fallback}`);
2444
4463
  return fallback;
2445
4464
  } catch (err) {
2446
4465
  if (axios.isAxiosError(err)) {
@@ -2448,10 +4467,10 @@ const fetchGateway = async (cfg) => {
2448
4467
  const status = response?.status ?? 0;
2449
4468
  const data = response?.data;
2450
4469
  const detail = formatOpenAPIError(status, typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
2451
- log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WSS ${fallback}`);
4470
+ log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WS ${fallback}`);
2452
4471
  } else {
2453
4472
  const reason = err?.message || "unknown";
2454
- log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码 WSS ${fallback}`);
4473
+ log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码 WS ${fallback}`);
2455
4474
  }
2456
4475
  return fallback;
2457
4476
  }
@@ -2493,6 +4512,7 @@ const start = async (cfg) => {
2493
4512
  cfg,
2494
4513
  intents,
2495
4514
  intentHistory: [intents],
4515
+ quickDisconnects: 0,
2496
4516
  aborted: false
2497
4517
  });
2498
4518
  await connect(cfg, intents, 0);
@@ -2516,12 +4536,15 @@ const schedule = (appId, delay, fn) => {
2516
4536
  const connect = async (cfg, intents, attempt, resume) => {
2517
4537
  let conn = conns.get(cfg.appId);
2518
4538
  if (!conn || conn.aborted) return;
2519
- const gatewayUrl = await fetchGateway(cfg);
4539
+ let gatewayUrl = conn.gatewayUrl;
4540
+ if (!gatewayUrl) gatewayUrl = await fetchGateway(cfg);
2520
4541
  conn = conns.get(cfg.appId);
2521
4542
  if (!conn || conn.aborted) return;
4543
+ conn.gatewayUrl = gatewayUrl;
2522
4544
  const client = new WSClient({
2523
4545
  appId: cfg.appId,
2524
4546
  gatewayUrl,
4547
+ userAgent: getUserAgent(),
2525
4548
  getAccessToken: () => getBotAccessToken(cfg.appId) || "",
2526
4549
  intents,
2527
4550
  resume,
@@ -2529,7 +4552,7 @@ const connect = async (cfg, intents, attempt, resume) => {
2529
4552
  if (ev.t === "READY") summarize(cfg.appId, intents, true);
2530
4553
  dispatch(cfg.appId, ev);
2531
4554
  },
2532
- onClose: (reason) => onClose(cfg, intents, attempt, reason, client)
4555
+ onClose: (event) => onClose(cfg, intents, attempt, event, client)
2533
4556
  });
2534
4557
  conn.client = client;
2535
4558
  conn.cfg = cfg;
@@ -2540,16 +4563,19 @@ const connect = async (cfg, intents, attempt, resume) => {
2540
4563
  /**
2541
4564
  * 关闭回调处理
2542
4565
  */
2543
- const onClose = (cfg, intents, attempt, reason, client) => {
2544
- if (reason === "manual") {
4566
+ const onClose = (cfg, intents, attempt, event, client) => {
4567
+ if (event.reason === "manual") {
2545
4568
  log("debug", `${cfg.appId}: WebSocket 已主动关闭`);
2546
4569
  return;
2547
4570
  }
2548
4571
  const conn = conns.get(cfg.appId);
2549
4572
  if (!conn || conn.aborted) return;
2550
- if (reason === "auth_fail") {
4573
+ const code = event.code;
4574
+ const closeLabel = formatCloseLabel(event);
4575
+ if (event.reason === "auth_fail" || code === 4013 || code === 4014) {
2551
4576
  const fallback = computeFallback(intents);
2552
4577
  if (fallback && fallback !== intents) {
4578
+ log("warn", `${cfg.appId}: Identify 被拒,尝试降级 intents ${intents} -> ${fallback}`);
2553
4579
  schedule(cfg.appId, 1e3, () => connect(cfg, fallback, 0));
2554
4580
  return;
2555
4581
  }
@@ -2559,11 +4585,64 @@ const onClose = (cfg, intents, attempt, reason, client) => {
2559
4585
  conns.delete(cfg.appId);
2560
4586
  return;
2561
4587
  }
4588
+ if (code === 4914 || code === 4915) {
4589
+ summarize(cfg.appId, intents, false);
4590
+ log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),机器人状态不允许连接,已停止自动重连`);
4591
+ conn.aborted = true;
4592
+ conns.delete(cfg.appId);
4593
+ return;
4594
+ }
4595
+ if (event.canResume === false && event.canIdentify === false) {
4596
+ summarize(cfg.appId, intents, false);
4597
+ log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),该错误不建议重试,已停止自动重连`);
4598
+ conn.aborted = true;
4599
+ conns.delete(cfg.appId);
4600
+ return;
4601
+ }
4602
+ if (code === 4004) {
4603
+ log("warn", `${cfg.appId}: access_token 已失效,刷新 token 后重连...`);
4604
+ schedule(cfg.appId, 1e3, () => {
4605
+ getAccessToken(cfg.proxy.tokenApi, cfg.appId, cfg.secret).then(() => connect(cfg, intents, 0)).catch(() => {
4606
+ log("error", `${cfg.appId}: access_token 刷新失败,${RATE_LIMIT_DELAY_MS}ms 后重试连接...`);
4607
+ schedule(cfg.appId, RATE_LIMIT_DELAY_MS, () => connect(cfg, intents, attempt + 1));
4608
+ });
4609
+ });
4610
+ return;
4611
+ }
2562
4612
  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));
4613
+ const resume = event.reason === "session_lost" || shouldFreshIdentify(code) ? void 0 : snap.sessionId ? snap : void 0;
4614
+ const delay = reconnectDelay(conn, attempt, event, code === 4008 ? RATE_LIMIT_DELAY_MS : void 0);
4615
+ const nextAttempt = shouldResetAttempt(event) ? 0 : attempt + 1;
4616
+ log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),${delay}ms 后${resume ? "Resume" : "Identify"}重连...`);
4617
+ schedule(cfg.appId, delay, () => connect(cfg, intents, nextAttempt, resume));
4618
+ };
4619
+ const shouldFreshIdentify = (code) => {
4620
+ if (code === 4006 || code === 4007 || code === 4009) return true;
4621
+ return typeof code === "number" && code >= 4900 && code <= 4913;
4622
+ };
4623
+ const shouldResetAttempt = (event) => {
4624
+ return typeof event.durationMs === "number" && event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS;
4625
+ };
4626
+ const reconnectDelay = (conn, attempt, event, forcedDelay) => {
4627
+ const stable = shouldResetAttempt(event);
4628
+ let delay = forcedDelay ?? backoff(stable ? 0 : attempt);
4629
+ if (typeof event.durationMs !== "number") return delay;
4630
+ if (event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS) {
4631
+ conn.quickDisconnects = 0;
4632
+ return delay;
4633
+ }
4634
+ conn.quickDisconnects += 1;
4635
+ if (conn.quickDisconnects >= QUICK_DISCONNECT_LIMIT) {
4636
+ delay = Math.max(delay, QUICK_DISCONNECT_DELAY_MS);
4637
+ log("warn", `${conn.cfg.appId}: WebSocket 连续快速断开 ${conn.quickDisconnects} 次,延长重连间隔到 ${delay}ms`);
4638
+ }
4639
+ return delay;
4640
+ };
4641
+ const formatCloseLabel = (event) => {
4642
+ const parts = [event.reason];
4643
+ if (typeof event.code === "number") parts.push(String(event.code));
4644
+ if (event.remoteReason) parts.push(event.remoteReason);
4645
+ return parts.join(" / ");
2567
4646
  };
2568
4647
  /**
2569
4648
  * 停止连接(同步:标记 abort + 清 pendingTimer + 关 client)
@@ -2604,17 +4683,21 @@ const initState = /* @__PURE__ */ new Map();
2604
4683
  const hashConfig = (bot) => JSON.stringify({
2605
4684
  appId: bot.appId,
2606
4685
  secret: bot.secret,
2607
- tokenApi: bot.tokenApi,
2608
- prodApi: bot.prodApi,
2609
- sandboxApi: bot.sandboxApi,
4686
+ proxy: bot.proxy,
2610
4687
  sandbox: bot.sandbox,
2611
4688
  event: bot.event,
2612
- keyboard: bot.keyboard
4689
+ keyboard: bot.keyboard,
4690
+ markdown: bot.markdown,
4691
+ messageCache: bot.messageCache
2613
4692
  });
2614
4693
  /**
2615
4694
  * 创建单个 Bot 实例
2616
4695
  */
2617
- const createBot = async (bot) => {
4696
+ const createBot = async (input) => {
4697
+ const bot = {
4698
+ ...input,
4699
+ proxy: normalizeProxyConfig(input.proxy)
4700
+ };
2618
4701
  const appId = String(bot.appId);
2619
4702
  if (!appId) {
2620
4703
  logger.warn("[QQ Official Bot] 配置缺少 appId,跳过");
@@ -2636,8 +4719,8 @@ const createBot = async (bot) => {
2636
4719
  offAll(appId);
2637
4720
  stopTokenRefresh(appId);
2638
4721
  logger.info(`[QQ Official Bot][${appId}] 获取 access_token...`);
2639
- await getAccessToken(bot.tokenApi, appId, bot.secret);
2640
- const baseUrl = bot.sandbox ? bot.sandboxApi : bot.prodApi;
4722
+ await getAccessToken(bot.proxy.tokenApi, appId, bot.secret);
4723
+ const baseUrl = bot.sandbox ? bot.proxy.sandboxApi : bot.proxy.prodApi;
2641
4724
  const api = new QQBotApi(createAxiosInstance(baseUrl, appId));
2642
4725
  const me = await api.meta.getMe();
2643
4726
  const client = new AdapterQQBot(bot, api);
@@ -2649,9 +4732,11 @@ const createBot = async (bot) => {
2649
4732
  client.account.subId.qq = qq;
2650
4733
  client.account.subId.appid = appId;
2651
4734
  client.account.subId.union_openid = me.union_openid;
2652
- bus.on(appId, (ev) => dispatch$1(client, ev));
4735
+ bus.on(appId, (ev) => {
4736
+ dispatch$1(client, ev);
4737
+ });
2653
4738
  client.adapter.address = baseUrl;
2654
- client.adapter.secret = bot.secret;
4739
+ client.adapter.secret = "*********";
2655
4740
  client.adapter.version = pkg().version;
2656
4741
  if (bot.event.type === 2) {
2657
4742
  client.adapter.index = registerBot("webSocketClient", client);