@karinjs/adapter-qqbot 3.0.8 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-D1_LqrcJ.js";
2
2
  import { t as log } from "./logger-sza6kuy3.js";
3
- import { i as random, n as fakeEvent, r as getImageSize, t as QQAPIBASEURL } from "./utils-QpIUJhxK.js";
3
+ import { a as DATA_URL_BASE64_RE, i as BASE64_PREFIX_RE, n as getImageSize, o as HTTP_URL_RE, r as random, t as fakeEvent } from "./common-OHGAoohl.js";
4
+ import "./utils-C0oZi6PR.js";
4
5
  import { t as getMessageStore } from "./message-CVJVFAGR.js";
5
- import karin, { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupApplyRequest, createGroupInviteRequest, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
6
- import { URL } from "url";
6
+ import karin, { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupApplyRequest, createGroupInviteRequest, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, getMimeType, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
7
+ import { URL as URL$1 } from "url";
7
8
  import lodash from "node-karin/lodash";
8
9
  import { createReadStream } from "node:fs";
9
- import FormData from "form-data";
10
10
  import axios from "node-karin/axios";
11
+ import FormData from "form-data";
11
12
  import crypto from "node:crypto";
12
13
  import { open, readFile, stat } from "node:fs/promises";
13
14
  import path from "node:path";
@@ -18,6 +19,97 @@ import { WebSocket } from "node-karin/ws";
18
19
  import express from "node-karin/express";
19
20
  import nacl from "tweetnacl";
20
21
 
22
+ //#region src/core/constants/api.ts
23
+ /** QQBot 统一 API 地址 */
24
+ const QQAPIBASEURL = "https://api.bot.qq.com";
25
+
26
+ //#endregion
27
+ //#region src/core/constants/media.ts
28
+ /** QQ 上传接口的 file_type 取值。 */
29
+ const FILE_TYPE = {
30
+ image: 1,
31
+ video: 2,
32
+ record: 3,
33
+ file: 4
34
+ };
35
+ /** 各类资源的 QQ 上传上限。 */
36
+ const MAX_UPLOAD_SIZE = {
37
+ image: 30 * 1024 * 1024,
38
+ video: 100 * 1024 * 1024,
39
+ record: 20 * 1024 * 1024,
40
+ file: 100 * 1024 * 1024
41
+ };
42
+ /** 上传错误中展示的资源类型名称。 */
43
+ const MEDIA_TYPE_LABEL = {
44
+ image: "图片",
45
+ video: "视频",
46
+ record: "语音",
47
+ file: "文件"
48
+ };
49
+ /** fileToUrl / 分片上传没有业务文件名时使用的默认值。 */
50
+ const DEFAULT_FILENAME = {
51
+ image: "image.jpg",
52
+ video: "video.mp4",
53
+ record: "record.mp3",
54
+ file: "file.bin"
55
+ };
56
+ /**
57
+ * 无法从文件名推断类型时,各富媒体类型使用的兜底 MIME。
58
+ *
59
+ * `file` 不在表内:QQ 对 `file_type=4` 不返回 `raw_url`,拿不到直链。
60
+ */
61
+ const DEFAULT_MIME = {
62
+ image: "image/jpeg",
63
+ video: "video/mp4",
64
+ record: "audio/mpeg"
65
+ };
66
+ /** fallback 上传超过该大小时走 QQ 分片上传。 */
67
+ const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
68
+ /** COS 用于覆盖下载响应类型的 query 参数名。 */
69
+ const RESPONSE_TYPE_PARAM = "response-content-type";
70
+ /** QQ upload_prepare 需要的前 10,002,432 字节 MD5。 */
71
+ const MD5_10M_SIZE = 10002432;
72
+ /** 富媒体上传比普通接口慢,尤其本地视频会走 file_data。 */
73
+ const MEDIA_UPLOAD_TIMEOUT = 12e4;
74
+ /** URL 回源下载兜底超时。 */
75
+ const MEDIA_DOWNLOAD_TIMEOUT = 12e4;
76
+ /** 每个分片 PUT 到 QQ 临时地址的超时时间。 */
77
+ const PART_UPLOAD_TIMEOUT = 3e5;
78
+ /** upload_part_finish 普通失败重试基础间隔。 */
79
+ const PART_FINISH_BASE_DELAY = 1e3;
80
+ /** upload_part_finish 命中这些业务码时,需要按服务端给出的窗口持续重试。 */
81
+ const PART_FINISH_RETRYABLE_CODES = /* @__PURE__ */ new Set([40093001]);
82
+ /** 服务端未返回 retry_timeout 时,持续重试的默认时间。 */
83
+ const PART_FINISH_DEFAULT_RETRY_TIMEOUT = 120 * 1e3;
84
+ /** 持续重试窗口上限,避免服务端返回异常值导致任务长时间占用。 */
85
+ const PART_FINISH_MAX_RETRY_TIMEOUT = 600 * 1e3;
86
+ /** 持续重试时的固定间隔。 */
87
+ const PART_FINISH_RETRY_INTERVAL = 1e3;
88
+ /** 分片完成接口失败重试基础间隔。 */
89
+ const COMPLETE_UPLOAD_BASE_DELAY = 2e3;
90
+ /** upload_prepare 返回该业务码时表示大文件上传额度受限。 */
91
+ const UPLOAD_PREPARE_DAILY_LIMIT_CODE = 40093002;
92
+
93
+ //#endregion
94
+ //#region src/core/constants/message.ts
95
+ /** 群聊 event_id 白名单 */
96
+ const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
97
+ "INTERACTION_CREATE",
98
+ "GROUP_ADD_ROBOT",
99
+ "GROUP_MSG_RECEIVE"
100
+ ]);
101
+ /** 单聊 event_id 白名单 */
102
+ const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
103
+ "INTERACTION_CREATE",
104
+ "C2C_MSG_RECEIVE",
105
+ "FRIEND_ADD"
106
+ ]);
107
+ /** 平台限制的最大禁言时长:30 天 */
108
+ const MAX_GROUP_MUTE_SECONDS = 720 * 60 * 60;
109
+ /** QQ 客户端回调按钮需要快速 ACK,超时必须小于平台等待窗口。 */
110
+ const INTERACTION_ACK_TIMEOUT = 2800;
111
+
112
+ //#endregion
21
113
  //#region src/core/api/error.ts
22
114
  /**
23
115
  * OpenAPI 错误码映射表
@@ -274,7 +366,25 @@ const errorCodeMap = {
274
366
  1100307: "非白名单应用子频道",
275
367
  1100308: "触发频道内限频",
276
368
  1100499: "其他错误",
277
- 3300006: "安全打击"
369
+ 3300006: "安全打击",
370
+ 40030001: "参数错误,请检查请求参数是否正确",
371
+ 40030006: "指令面板不存在,请确认 panel_id 是否正确",
372
+ 40030008: "URL 格式错误,请确认 URL 以 https:// 开头",
373
+ 40030009: "指令面板操作进行中,存在并发操作冲突,请稍后重试",
374
+ 40030011: "生效场景不合法,scope 仅支持 c2c/group/channel/dm",
375
+ 40030012: "生效范围不合法,target_type 仅支持 all/specific,channel/dm 场景仅支持 all",
376
+ 40030013: "超出数量限制,具体限制值见返回信息中的 limit",
377
+ 40030014: "菜单类型不合法,menu.type 仅支持 switch/send_message/link/menu",
378
+ 40030015: "面板元素类型不合法,panel_item.type 仅支持 command/link",
379
+ 40030016: "必填字段缺失,请检查必填字段是否全部正确传入",
380
+ 40030017: "操作类型不合法,op 仅支持 add/del",
381
+ 40030018: "当前场景不支持此操作,请检查 scope 是否支持当前操作",
382
+ 40030020: "内容存在安全风险,请检查菜单/面板内容是否包含敏感信息",
383
+ 40030021: "全局面板不支持添加指定关联对象,请使用 specific 模式",
384
+ 40061001: "请求参数无效,请检查请求参数格式",
385
+ 40062003: "无操作权限,请检查机器人是否为群管理员,或发消息的用户是否为普通用户",
386
+ 40064004: "已超出消息撤回时限,消息发送超过 2 分钟后不可撤回",
387
+ 50065001: "消息撤回失败,请稍后重试"
278
388
  };
279
389
  const rangeDefs = [
280
390
  {
@@ -357,6 +467,23 @@ const formatOpenAPIError = (status, code, message) => {
357
467
  //#endregion
358
468
  //#region src/core/api/http.ts
359
469
  /**
470
+ * 把 {@link RequestOptions} 转为 axios 请求配置。
471
+ *
472
+ * @param options 请求可选配置。
473
+ * @returns axios 请求配置。
474
+ */
475
+ const toAxiosConfig = (options) => {
476
+ if (!options) return {};
477
+ const { headers, timeout, params, signal, responseType } = options;
478
+ return {
479
+ headers,
480
+ timeout,
481
+ params,
482
+ signal,
483
+ responseType
484
+ };
485
+ };
486
+ /**
360
487
  * 包装 axios 错误为可读多行 message
361
488
  * 如果响应中包含 QQ 官方错误码(err_code),会自动映射为中文描述
362
489
  */
@@ -400,56 +527,199 @@ const redactRequestData = (value) => {
400
527
  };
401
528
  /**
402
529
  * Http 基础类,子模块继承此类共享 axios 实例与错误格式化
530
+ *
531
+ * 各请求方法均为 `protected`,只供各领域 API 子类内部使用;
532
+ * 需要给下游插件开放的裸请求入口见 {@link RequestApi}。
403
533
  */
404
534
  var Http = class {
405
535
  axios;
406
536
  constructor(axios) {
407
537
  this.axios = axios;
408
538
  }
409
- async get(path) {
539
+ async get(path, options) {
410
540
  try {
411
- const { data } = await this.axios.get(path);
541
+ const { data } = await this.axios.get(path, toAxiosConfig(options));
412
542
  return data;
413
543
  } catch (err) {
414
544
  throw formatError(path, void 0, err);
415
545
  }
416
546
  }
417
- async post(path, body = {}, headers, timeout) {
547
+ async post(path, body = {}, options) {
418
548
  try {
419
- const { data } = await this.axios.post(path, body, {
420
- headers,
421
- timeout
422
- });
549
+ const { data } = await this.axios.post(path, body, toAxiosConfig(options));
423
550
  return data;
424
551
  } catch (err) {
425
552
  throw formatError(path, body, err);
426
553
  }
427
554
  }
428
- async put(path, body = {}, headers, timeout) {
555
+ async put(path, body = {}, options) {
429
556
  try {
430
- const { data } = await this.axios.put(path, body, {
431
- headers,
432
- timeout
433
- });
557
+ const { data } = await this.axios.put(path, body, toAxiosConfig(options));
434
558
  return data;
435
559
  } catch (err) {
436
560
  throw formatError(path, body, err);
437
561
  }
438
562
  }
439
- async delete(path) {
563
+ async patch(path, body = {}, options) {
440
564
  try {
441
- const { data } = await this.axios.delete(path);
565
+ const { data } = await this.axios.patch(path, body, toAxiosConfig(options));
566
+ return data;
567
+ } catch (err) {
568
+ throw formatError(path, body, err);
569
+ }
570
+ }
571
+ async delete(path, options) {
572
+ try {
573
+ const { data } = await this.axios.delete(path, toAxiosConfig(options));
442
574
  return data;
443
575
  } catch (err) {
444
576
  throw formatError(path, void 0, err);
445
577
  }
446
578
  }
579
+ /**
580
+ * 任意 method 的裸请求,返回响应体。
581
+ *
582
+ * @param config axios 请求配置,`url` 相对 `baseURL`。
583
+ * @returns 响应体 `data`。
584
+ */
585
+ async request(config) {
586
+ const label = `${String(config.method ?? "GET").toUpperCase()} ${config.url ?? ""}`;
587
+ try {
588
+ const { data } = await this.axios.request(config);
589
+ return data;
590
+ } catch (err) {
591
+ throw formatError(label, config.data, err);
592
+ }
593
+ }
594
+ /**
595
+ * 任意 method 的裸请求,返回完整 axios 响应。
596
+ *
597
+ * 需要读取响应头或状态码时使用。
598
+ *
599
+ * @param config axios 请求配置,`url` 相对 `baseURL`。
600
+ * @returns 完整 axios 响应。
601
+ */
602
+ async requestRaw(config) {
603
+ const label = `${String(config.method ?? "GET").toUpperCase()} ${config.url ?? ""}`;
604
+ try {
605
+ return await this.axios.request(config);
606
+ } catch (err) {
607
+ throw formatError(label, config.data, err);
608
+ }
609
+ }
610
+ };
611
+
612
+ //#endregion
613
+ //#region src/core/api/request.ts
614
+ /**
615
+ * 裸请求入口,供下游插件直接调用官方 OpenAPI。
616
+ *
617
+ * {@link Http} 的请求方法是 `protected`,只供各领域 API 子类内部使用;
618
+ * 本类把同一套方法以 `public` 暴露出来,用于官方新接口尚未被适配器封装、
619
+ * 或需要传自定义参数的场景:
620
+ *
621
+ * ```ts
622
+ * const bot = e.bot as AdapterQQBot
623
+ *
624
+ * // GET,query 走 params
625
+ * const menu = await bot.super.request.get('/v2/menu')
626
+ * const list = await bot.super.request.get('/v2/panels', { params: { scope: 'group', limit: 20 } })
627
+ *
628
+ * // POST / PUT / PATCH / DELETE
629
+ * await bot.super.request.post(`/v2/groups/${groupId}/messages`, { content: 'hi', msg_type: 0 })
630
+ * await bot.super.request.put('/v2/menu', { menu })
631
+ *
632
+ * // 任意 method
633
+ * await bot.super.request.request({ method: 'POST', url: '/v2/xxx', data: {} })
634
+ * ```
635
+ *
636
+ * 与直接用 `bot.super.axios` 的区别:
637
+ * - 自动解包 `response.data`;
638
+ * - 失败时抛出的 Error 已带请求路径、脱敏请求体、QQ 错误码中文描述和 TraceID。
639
+ *
640
+ * 需要读响应头或状态码时用 {@link RequestApi.requestRaw};
641
+ * 需要完全裸的 axios 时用 `bot.super.axios`。
642
+ *
643
+ * 路径相对官方 API baseURL,鉴权头由拦截器自动注入,无需自己传 Authorization。
644
+ */
645
+ var RequestApi = class extends Http {
646
+ /**
647
+ * GET 请求。
648
+ *
649
+ * @param path 接口路径,相对 baseURL。
650
+ * @param options 可选配置(params / headers / timeout 等)。
651
+ * @returns 响应体。
652
+ */
653
+ get(path, options) {
654
+ return super.get(path, options);
655
+ }
656
+ /**
657
+ * POST 请求。
658
+ *
659
+ * @param path 接口路径,相对 baseURL。
660
+ * @param body 请求体,默认空对象。
661
+ * @param options 可选配置(params / headers / timeout 等)。
662
+ * @returns 响应体。
663
+ */
664
+ post(path, body, options) {
665
+ return super.post(path, body, options);
666
+ }
667
+ /**
668
+ * PUT 请求。
669
+ *
670
+ * @param path 接口路径,相对 baseURL。
671
+ * @param body 请求体,默认空对象。
672
+ * @param options 可选配置(params / headers / timeout 等)。
673
+ * @returns 响应体。
674
+ */
675
+ put(path, body, options) {
676
+ return super.put(path, body, options);
677
+ }
678
+ /**
679
+ * PATCH 请求。
680
+ *
681
+ * @param path 接口路径,相对 baseURL。
682
+ * @param body 请求体,默认空对象。
683
+ * @param options 可选配置(params / headers / timeout 等)。
684
+ * @returns 响应体。
685
+ */
686
+ patch(path, body, options) {
687
+ return super.patch(path, body, options);
688
+ }
689
+ /**
690
+ * DELETE 请求。
691
+ *
692
+ * @param path 接口路径,相对 baseURL。
693
+ * @param options 可选配置(params / headers / timeout 等)。
694
+ * @returns 响应体。
695
+ */
696
+ delete(path, options) {
697
+ return super.delete(path, options);
698
+ }
699
+ /**
700
+ * 任意 method 的请求,返回响应体。
701
+ *
702
+ * @param config axios 请求配置,`url` 相对 baseURL。
703
+ * @returns 响应体。
704
+ */
705
+ request(config) {
706
+ return super.request(config);
707
+ }
708
+ /**
709
+ * 任意 method 的请求,返回完整 axios 响应。
710
+ *
711
+ * 需要读取响应头或状态码时使用。
712
+ *
713
+ * @param config axios 请求配置,`url` 相对 baseURL。
714
+ * @returns 完整 axios 响应。
715
+ */
716
+ requestRaw(config) {
717
+ return super.requestRaw(config);
718
+ }
447
719
  };
448
720
 
449
721
  //#endregion
450
722
  //#region src/core/api/messages.ts
451
- /** 输入中状态默认展示时长(秒),平台 typing 窗口约 60s */
452
- const INPUT_NOTIFY_DEFAULT_SECOND = 60;
453
723
  /**
454
724
  * 消息发送 + 撤回
455
725
  */
@@ -468,7 +738,7 @@ var MessagesApi = class extends Http {
468
738
  * @param inputSecond 展示时长(秒),默认 60
469
739
  * @param passive 被动消息参数(msg_id / event_id / msg_seq),可选
470
740
  */
471
- sendFriendInputNotify(openid, inputSecond = INPUT_NOTIFY_DEFAULT_SECOND, passive) {
741
+ sendFriendInputNotify(openid, inputSecond = 60, passive) {
472
742
  return this.post(`/v2/users/${openid}/messages`, {
473
743
  msg_type: 6,
474
744
  input_notify: {
@@ -492,7 +762,7 @@ var MessagesApi = class extends Http {
492
762
  /** 文字子频道消息 */
493
763
  sendChannelMsg(channelId, body) {
494
764
  const headers = body instanceof FormData ? body.getHeaders() : void 0;
495
- return this.post(`/channels/${channelId}/messages`, body, headers);
765
+ return this.post(`/channels/${channelId}/messages`, body, { headers });
496
766
  }
497
767
  async sendDmsMsg(a, b, c) {
498
768
  if (typeof b === "string") {
@@ -501,10 +771,10 @@ var MessagesApi = class extends Http {
501
771
  source_guild_id: b
502
772
  });
503
773
  const headers = c instanceof FormData ? c.getHeaders() : void 0;
504
- return this.post(`/dms/${dms.guild_id}/messages`, c, headers);
774
+ return this.post(`/dms/${dms.guild_id}/messages`, c, { headers });
505
775
  }
506
776
  const headers = b instanceof FormData ? b.getHeaders() : void 0;
507
- return this.post(`/dms/${a}/messages`, b, headers);
777
+ return this.post(`/dms/${a}/messages`, b, { headers });
508
778
  }
509
779
  async recall(type, peer, messageId, hidetip = false) {
510
780
  const path = type === "user" || type === "group" ? `/v2/${type}s/${peer}/messages/${messageId}` : `/${type}/${peer}/messages/${messageId}?hidetip=${hidetip}`;
@@ -531,7 +801,7 @@ const MIN_EFFECTIVE_TTL_SECONDS = 10;
531
801
  * @param contentHash 文件内容 hash。
532
802
  * @returns 缓存键。
533
803
  */
534
- const cacheKey = (scene, peer, type, contentHash) => [
804
+ const cacheKey$1 = (scene, peer, type, contentHash) => [
535
805
  scene,
536
806
  peer,
537
807
  type,
@@ -551,7 +821,7 @@ const cacheKey = (scene, peer, type, contentHash) => [
551
821
  */
552
822
  const getCachedUpload = async (scene, peer, type, contentHash) => {
553
823
  try {
554
- const raw = await getMessageStore().getUploadCache(cacheKey(scene, peer, type, contentHash));
824
+ const raw = await getMessageStore().getUploadCache(cacheKey$1(scene, peer, type, contentHash));
555
825
  if (!raw) return null;
556
826
  return JSON.parse(raw);
557
827
  } catch {
@@ -572,79 +842,56 @@ const setCachedUpload = async (scene, peer, type, contentHash, response) => {
572
842
  const ttl = Number(response.ttl);
573
843
  const expiresAt = ttl > 0 ? Date.now() + Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : 0;
574
844
  try {
575
- await getMessageStore().setUploadCache(cacheKey(scene, peer, type, contentHash), JSON.stringify(response), expiresAt);
845
+ await getMessageStore().setUploadCache(cacheKey$1(scene, peer, type, contentHash), JSON.stringify(response), expiresAt);
576
846
  } catch {}
577
847
  };
578
848
 
579
849
  //#endregion
580
850
  //#region src/core/api/media.ts
581
- const FILE_TYPE = {
582
- image: 1,
583
- video: 2,
584
- record: 3,
585
- file: 4
586
- };
587
- /** 各类资源的 QQ 上传上限。 */
588
- const MAX_UPLOAD_SIZE = {
589
- image: 30 * 1024 * 1024,
590
- video: 100 * 1024 * 1024,
591
- record: 20 * 1024 * 1024,
592
- file: 100 * 1024 * 1024
593
- };
594
- /** 上传错误中展示的资源类型名称。 */
595
- const MEDIA_TYPE_LABEL = {
596
- image: "图片",
597
- video: "视频",
598
- record: "语音",
599
- file: "文件"
851
+ /**
852
+ * 推断直链应当声明的 MIME。
853
+ *
854
+ * 优先按文件名判断,这样 png / gif 不会被统一说成 jpeg;文件名没有扩展名或
855
+ * 扩展名不认识时,退回该富媒体类型的兜底值。
856
+ *
857
+ * @param type 富媒体类型。
858
+ * @param fileName 可选文件名。
859
+ * @returns MIME 字符串;该类型拿不到直链时返回 undefined。
860
+ */
861
+ const resolveDirectLinkMime = (type, fileName) => {
862
+ const fallback = DEFAULT_MIME[type];
863
+ if (!fallback) return void 0;
864
+ if (!fileName) return fallback;
865
+ const guessed = getMimeType(fileName);
866
+ /** karin 认不出的扩展名会返回 octet-stream,此时用类型兜底更准确。 */
867
+ if (!guessed || guessed === "application/octet-stream") return fallback;
868
+ /** 扩展名与富媒体类型不是同一族时同样以类型为准,避免声明成错误的族。 */
869
+ const family = fallback.split("/")[0];
870
+ return guessed.startsWith(`${family}/`) ? guessed : fallback;
600
871
  };
601
- /** fallback 上传超过该大小时走 QQ 分片上传。 */
602
- const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
603
- /** QQ upload_prepare 需要的前 10,002,432 字节 MD5。 */
604
- const MD5_10M_SIZE = 10002432;
605
- /** 分片上传时没有业务文件名的默认值。 */
606
- const DEFAULT_FILENAME$1 = {
607
- image: "image.jpg",
608
- video: "video.mp4",
609
- record: "record.mp3",
610
- file: "file.bin"
872
+ /**
873
+ * 把 QQ 返回的下载直链改造成可直接嵌入消息的地址。
874
+ *
875
+ * 直链本身不声明 Content-Type,客户端可能因此拒绝按图片/视频渲染,所以要追加
876
+ * COS 的响应类型参数。这里用 URL 对象改写 query,避免手工拼接破坏预签名参数。
877
+ *
878
+ * @param rawUrl QQ 分片合并返回的下载直链。
879
+ * @param type 富媒体类型。
880
+ * @param fileName 可选文件名,用于推断更精确的 MIME。
881
+ * @returns 已声明响应类型的直链;类型不支持或直链无法解析时原样返回。
882
+ */
883
+ const buildDirectLink = (rawUrl, type, fileName) => {
884
+ const mime = resolveDirectLinkMime(type, fileName);
885
+ if (!mime) return rawUrl;
886
+ try {
887
+ const url = new URL(rawUrl);
888
+ url.searchParams.set(RESPONSE_TYPE_PARAM, mime);
889
+ return url.toString();
890
+ } catch {
891
+ /** QQ 返回了非法 URL 时不做改写,交给调用方按原样使用。 */
892
+ return rawUrl;
893
+ }
611
894
  };
612
- /** 富媒体上传比普通接口慢,尤其本地视频会走 file_data。 */
613
- const MEDIA_UPLOAD_TIMEOUT = 12e4;
614
- /** URL 回源下载兜底超时。 */
615
- const MEDIA_DOWNLOAD_TIMEOUT = 12e4;
616
- /** 每个分片 PUT 到 QQ 临时地址的超时时间。 */
617
- const PART_UPLOAD_TIMEOUT = 3e5;
618
- /** 每个分片 PUT 失败后的重试次数。 */
619
- const PART_UPLOAD_RETRIES = 2;
620
- /** upload_part_finish 普通失败重试次数。 */
621
- const PART_FINISH_RETRIES = 2;
622
- /** upload_part_finish 普通失败重试基础间隔。 */
623
- const PART_FINISH_BASE_DELAY = 1e3;
624
- /** upload_part_finish 命中这些业务码时,需要按服务端给出的窗口持续重试。 */
625
- const PART_FINISH_RETRYABLE_CODES = /* @__PURE__ */ new Set([40093001]);
626
- /** 服务端未返回 retry_timeout 时,持续重试的默认时间。 */
627
- const PART_FINISH_DEFAULT_RETRY_TIMEOUT = 120 * 1e3;
628
- /** 持续重试窗口上限,避免服务端返回异常值导致任务长时间占用。 */
629
- const PART_FINISH_MAX_RETRY_TIMEOUT = 600 * 1e3;
630
- /** 持续重试时的固定间隔。 */
631
- const PART_FINISH_RETRY_INTERVAL = 1e3;
632
- /** 分片完成接口失败重试次数。 */
633
- const COMPLETE_UPLOAD_RETRIES = 2;
634
- /** 分片完成接口失败重试基础间隔。 */
635
- const COMPLETE_UPLOAD_BASE_DELAY = 2e3;
636
- /** upload_prepare 返回该业务码时表示大文件上传额度受限。 */
637
- const UPLOAD_PREPARE_DAILY_LIMIT_CODE = 40093002;
638
- /** QQ 没返回建议并发数时的默认并发。 */
639
- const DEFAULT_PART_CONCURRENCY = 1;
640
- /** 本地限制的最大分片并发,避免服务端返回过大值。 */
641
- const MAX_PART_CONCURRENCY = 10;
642
- /** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
643
- const HTTP_URL_RE$2 = /^https?:\/\//i;
644
- /** Karin 常见 base64 图片/媒体前缀。 */
645
- const BASE64_PREFIX_RE = /^base64:\/\//i;
646
- /** 标准 data URL 的 base64 前缀。 */
647
- const DATA_URL_BASE64_RE = /^data:[^;,]+;base64,/i;
648
895
  /**
649
896
  * 判断字符串是否明显是本地文件路径。
650
897
  * 不明显的字符串按裸 base64 兼容处理,避免破坏旧调用方式。
@@ -686,8 +933,8 @@ const formatBytes = (bytes) => {
686
933
  */
687
934
  const normalizeUploadFileName = (type, name) => {
688
935
  const value = name?.trim();
689
- if (!value) return DEFAULT_FILENAME$1[type];
690
- return path.basename(value) || DEFAULT_FILENAME$1[type];
936
+ if (!value) return DEFAULT_FILENAME[type];
937
+ return path.basename(value) || DEFAULT_FILENAME[type];
691
938
  };
692
939
  /**
693
940
  * 检查 fallback 资源大小是否超过 QQ 对应类型上限。
@@ -736,7 +983,7 @@ const downloadRemoteAsBase64 = async (url) => {
736
983
  */
737
984
  const buildUploadSource = async (source) => {
738
985
  const value = source.trim();
739
- if (HTTP_URL_RE$2.test(value)) return { url: value };
986
+ if (HTTP_URL_RE.test(value)) return { url: value };
740
987
  if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
741
988
  if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
742
989
  return { file_data: value };
@@ -752,7 +999,7 @@ const buildUploadSource = async (source) => {
752
999
  const buildFallbackSource = async (type, source, fileName) => {
753
1000
  const value = source.trim();
754
1001
  const normalizedName = normalizeUploadFileName(type, fileName);
755
- if (HTTP_URL_RE$2.test(value)) {
1002
+ if (HTTP_URL_RE.test(value)) {
756
1003
  const buffer = await downloadRemoteAsBuffer(value);
757
1004
  return {
758
1005
  kind: "buffer",
@@ -810,7 +1057,7 @@ const computeUploadHashes = async (source) => {
810
1057
  return {
811
1058
  md5,
812
1059
  sha1: crypto.createHash("sha1").update(source.buffer).digest("hex"),
813
- md510m: source.size > MD5_10M_SIZE ? crypto.createHash("md5").update(source.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
1060
+ md510m: source.size > 10002432 ? crypto.createHash("md5").update(source.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
814
1061
  };
815
1062
  }
816
1063
  return new Promise((resolve, reject) => {
@@ -866,7 +1113,7 @@ const readPart = async (source, handle, offset, length) => {
866
1113
  */
867
1114
  const putPart = async (url, data, partIndex, totalParts) => {
868
1115
  let lastError;
869
- for (let attempt = 0; attempt <= PART_UPLOAD_RETRIES; attempt++) try {
1116
+ for (let attempt = 0; attempt <= 2; attempt++) try {
870
1117
  await axios.put(url, data, {
871
1118
  headers: {
872
1119
  "Content-Type": "application/octet-stream",
@@ -879,7 +1126,7 @@ const putPart = async (url, data, partIndex, totalParts) => {
879
1126
  return;
880
1127
  } catch (err) {
881
1128
  lastError = err;
882
- if (attempt < PART_UPLOAD_RETRIES) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
1129
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
883
1130
  }
884
1131
  throw new Error(`上传分片失败:${partIndex}/${totalParts},${lastError instanceof Error ? lastError.message : String(lastError)}`);
885
1132
  };
@@ -960,7 +1207,7 @@ var MediaApi = class extends Http {
960
1207
  if (type === "file" && fileName) body.file_name = fileName;
961
1208
  const path = `/v2/${scene}s/${peer}/files`;
962
1209
  try {
963
- return await this.post(path, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1210
+ return await this.post(path, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
964
1211
  } catch (err) {
965
1212
  if (!sourceBody.url) throw err;
966
1213
  const fallbackBody = {
@@ -968,7 +1215,7 @@ var MediaApi = class extends Http {
968
1215
  file_data: await downloadRemoteAsBase64(sourceBody.url)
969
1216
  };
970
1217
  delete fallbackBody.url;
971
- return this.post(path, fallbackBody, void 0, MEDIA_UPLOAD_TIMEOUT);
1218
+ return this.post(path, fallbackBody, { timeout: MEDIA_UPLOAD_TIMEOUT });
972
1219
  }
973
1220
  }
974
1221
  /**
@@ -993,7 +1240,7 @@ var MediaApi = class extends Http {
993
1240
  const cached = await getCachedUpload(scene, peer, type, hashes.md5);
994
1241
  if (cached) return cached;
995
1242
  }
996
- if (!srvSendMsg && fallbackSource.size >= LARGE_UPLOAD_THRESHOLD) {
1243
+ if (!srvSendMsg && fallbackSource.size >= 5242880) {
997
1244
  const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
998
1245
  if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
999
1246
  return response;
@@ -1004,11 +1251,44 @@ var MediaApi = class extends Http {
1004
1251
  file_data: await readFallbackSourceAsBase64(fallbackSource)
1005
1252
  };
1006
1253
  if (type === "file") body.file_name = fallbackSource.fileName;
1007
- const response = await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1254
+ const response = await this.post(`/v2/${scene}s/${peer}/files`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
1008
1255
  if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
1009
1256
  return response;
1010
1257
  }
1011
1258
  /**
1259
+ * 上传资源并取回它的公网直链。
1260
+ *
1261
+ * 分片上传的合并响应里带 `raw_url`,等价于一个临时图床:拿到地址后图片就能以
1262
+ * markdown 图片语法嵌进 msg_type=2,和文本、按钮合成一条消息,不必退化成
1263
+ * 独立的 msg_type=7 富媒体消息。
1264
+ *
1265
+ * 与 `uploadFallback` 的差别只有一处:那边按体积决定是否分片,目标是拿
1266
+ * `file_info`;这里必须无条件分片,否则服务端不会给出 `raw_url`。
1267
+ *
1268
+ * 直链的存活时间等于响应里的 `ttl`,属于临时地址,只适合当次发送使用。
1269
+ *
1270
+ * @param scene user(单聊) / group(群聊)
1271
+ * @param peer openid 或 group_openid,决定上传归属的会话
1272
+ * @param type 文件类型;`file` 无直链可取
1273
+ * @param source 本地路径、http url、data URL、base64:// 或裸 base64
1274
+ * @param fileName 文件名,同时用于推断直链声明的 MIME
1275
+ * @returns 可直接嵌入消息的直链,以及原始上传响应
1276
+ */
1277
+ async uploadForUrl(scene, peer, type, source, fileName) {
1278
+ if (type === "file") throw new Error(`QQ 不为${MEDIA_TYPE_LABEL.file}类型返回下载直链,无法当图床使用`);
1279
+ const prepared = await buildFallbackSource(type, source, fileName);
1280
+ assertUploadSize(type, prepared.size);
1281
+ const hashes = await computeUploadHashes(prepared);
1282
+ const reused = await getCachedUpload(scene, peer, type, hashes.md5);
1283
+ const response = reused?.raw_url ? reused : await this.uploadChunked(scene, peer, type, prepared, hashes);
1284
+ if (!response.raw_url) throw new Error(`${MEDIA_TYPE_LABEL[type]}上传完成但未返回下载直链,无法当图床使用`);
1285
+ if (response !== reused) await setCachedUpload(scene, peer, type, hashes.md5, response);
1286
+ return {
1287
+ url: buildDirectLink(response.raw_url, type, prepared.fileName),
1288
+ response
1289
+ };
1290
+ }
1291
+ /**
1012
1292
  * 确认单个分片已完成上传。
1013
1293
  *
1014
1294
  * QQ 有时会在分片刚 PUT 完时短暂返回 40093001,此时需要在服务端给出的
@@ -1021,8 +1301,8 @@ var MediaApi = class extends Http {
1021
1301
  */
1022
1302
  async finishUploadPart(scene, peer, body, retryTimeout) {
1023
1303
  let lastError;
1024
- for (let attempt = 0; attempt <= PART_FINISH_RETRIES; attempt++) try {
1025
- await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1304
+ for (let attempt = 0; attempt <= 2; attempt++) try {
1305
+ await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
1026
1306
  return;
1027
1307
  } catch (err) {
1028
1308
  if (shouldRetryPartFinishPersistently(err)) {
@@ -1030,7 +1310,7 @@ var MediaApi = class extends Http {
1030
1310
  return;
1031
1311
  }
1032
1312
  lastError = err;
1033
- if (attempt < PART_FINISH_RETRIES) await sleep(PART_FINISH_BASE_DELAY * 2 ** attempt);
1313
+ if (attempt < 2) await sleep(PART_FINISH_BASE_DELAY * 2 ** attempt);
1034
1314
  }
1035
1315
  throwError(lastError);
1036
1316
  }
@@ -1048,7 +1328,7 @@ var MediaApi = class extends Http {
1048
1328
  let attempts = 0;
1049
1329
  let lastError;
1050
1330
  while (Date.now() < deadline) try {
1051
- await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1331
+ await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
1052
1332
  return;
1053
1333
  } catch (err) {
1054
1334
  lastError = err;
@@ -1069,11 +1349,11 @@ var MediaApi = class extends Http {
1069
1349
  async completeChunkedUpload(scene, peer, uploadId) {
1070
1350
  let lastError;
1071
1351
  const body = { upload_id: uploadId };
1072
- for (let attempt = 0; attempt <= COMPLETE_UPLOAD_RETRIES; attempt++) try {
1073
- return await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1352
+ for (let attempt = 0; attempt <= 2; attempt++) try {
1353
+ return await this.post(`/v2/${scene}s/${peer}/files`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
1074
1354
  } catch (err) {
1075
1355
  lastError = err;
1076
- if (attempt < COMPLETE_UPLOAD_RETRIES) await sleep(COMPLETE_UPLOAD_BASE_DELAY * 2 ** attempt);
1356
+ if (attempt < 2) await sleep(COMPLETE_UPLOAD_BASE_DELAY * 2 ** attempt);
1077
1357
  }
1078
1358
  return throwError(lastError);
1079
1359
  }
@@ -1099,12 +1379,12 @@ var MediaApi = class extends Http {
1099
1379
  sha1: hashes.sha1
1100
1380
  };
1101
1381
  prepareBody["md5_10m"] = hashes.md510m;
1102
- const prepare = await this.post(`/v2/${scene}s/${peer}/upload_prepare`, prepareBody, void 0, MEDIA_UPLOAD_TIMEOUT).catch((err) => {
1103
- if (openApiCode(err) === UPLOAD_PREPARE_DAILY_LIMIT_CODE) throw new Error(`QQ 大文件上传额度已达上限,${MEDIA_TYPE_LABEL[type]}暂时无法通过 fallback 直传:${source.fileName} (${formatBytes(source.size)})`);
1382
+ const prepare = await this.post(`/v2/${scene}s/${peer}/upload_prepare`, prepareBody, { timeout: MEDIA_UPLOAD_TIMEOUT }).catch((err) => {
1383
+ if (openApiCode(err) === 40093002) throw new Error(`QQ 大文件上传额度已达上限,${MEDIA_TYPE_LABEL[type]}暂时无法通过 fallback 直传:${source.fileName} (${formatBytes(source.size)})`);
1104
1384
  return throwError(err);
1105
1385
  });
1106
1386
  if (!prepare.parts.length) throw new Error("分片上传准备失败:QQ 未返回分片信息");
1107
- const concurrency = Math.min(prepare.concurrency || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
1387
+ const concurrency = Math.min(prepare.concurrency || 1, 10, prepare.parts.length);
1108
1388
  const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
1109
1389
  try {
1110
1390
  const tasks = prepare.parts.map((part) => async () => {
@@ -1130,89 +1410,409 @@ var MediaApi = class extends Http {
1130
1410
 
1131
1411
  //#endregion
1132
1412
  //#region src/core/api/interaction.ts
1133
- /** QQ 客户端回调按钮需要快速 ACK,超时必须小于平台等待窗口。 */
1134
- const INTERACTION_ACK_TIMEOUT = 2800;
1135
1413
  /**
1136
1414
  * INTERACTION_CREATE 回应
1137
1415
  */
1138
1416
  var InteractionApi = class extends Http {
1139
1417
  /**
1140
- * @param interactionId INTERACTION_CREATE 事件中的 id
1141
- * @param code 0 成功 / 1 操作失败 / 2 操作频繁 / 3 重复操作 / 4 无权限 / 5 仅管理员
1418
+ * @param interactionId INTERACTION_CREATE 事件中的 id
1419
+ * @param code 0 成功 / 1 操作失败 / 2 操作频繁 / 3 重复操作 / 4 无权限 / 5 仅管理员
1420
+ */
1421
+ async ack(interactionId, code = 0) {
1422
+ await this.put(`/interactions/${interactionId}`, { code }, { timeout: INTERACTION_ACK_TIMEOUT });
1423
+ }
1424
+ };
1425
+
1426
+ //#endregion
1427
+ //#region src/core/api/meta.ts
1428
+ /**
1429
+ * 元信息接口:@me / dms / gateway / 分享链接
1430
+ */
1431
+ var MetaApi = class extends Http {
1432
+ /** 当前机器人详情 */
1433
+ getMe() {
1434
+ return this.get("/users/@me");
1435
+ }
1436
+ /** 创建频道私信会话 */
1437
+ createDms(recipientId, srcGuildId) {
1438
+ return this.post("/users/@me/dms", {
1439
+ recipient_id: recipientId,
1440
+ source_guild_id: srcGuildId
1441
+ });
1442
+ }
1443
+ /** 获取通用 WSS 地址 */
1444
+ getGateway() {
1445
+ return this.get("/gateway");
1446
+ }
1447
+ /**
1448
+ * 生成机器人分享链接
1449
+ *
1450
+ * 用于邀请用户添加机器人为好友;`callback_data`(最长 32 字符)会在用户通过该
1451
+ * 链接添加机器人时透传到 `FRIEND_ADD` 事件的 `scene_param`。
1452
+ *
1453
+ * @param body 分享链接参数,均为可选
1454
+ */
1455
+ generateUrlLink(body = {}) {
1456
+ return this.post("/v2/generate_url_link", body);
1457
+ }
1458
+ };
1459
+
1460
+ //#endregion
1461
+ //#region src/core/api/groups.ts
1462
+ /**
1463
+ * QQ 群信息接口
1464
+ *
1465
+ * 注意:以下接口均为白名单接口,未开通权限时平台返回错误码 11253,
1466
+ * 需要向平台运营申请后可用。
1467
+ */
1468
+ var GroupsApi = class extends Http {
1469
+ /**
1470
+ * 获取群基础信息
1471
+ * @param groupOpenid 群 openid
1472
+ */
1473
+ getGroupInfo(groupOpenid) {
1474
+ return this.get(`/v2/groups/${groupOpenid}/info`);
1475
+ }
1476
+ /**
1477
+ * 获取机器人在群内的状态(角色、入群时间、主动消息开关等)
1478
+ * @param groupOpenid 群 openid
1479
+ */
1480
+ getBotState(groupOpenid) {
1481
+ return this.get(`/v2/groups/${groupOpenid}/bot_state`);
1482
+ }
1483
+ /**
1484
+ * 获取群成员详情
1485
+ *
1486
+ * @param groupOpenid 群 openid
1487
+ * @param memberOpenid 群成员 openid
1488
+ */
1489
+ getGroupMember(groupOpenid, memberOpenid) {
1490
+ return this.get(`/v2/groups/${groupOpenid}/members/${memberOpenid}`);
1491
+ }
1492
+ /**
1493
+ * 设置群成员禁言
1494
+ *
1495
+ * 机器人需拥有群管理员身份,平台限制最大禁言时长为 30 天。
1496
+ *
1497
+ * @param groupOpenid 群 openid
1498
+ * @param memberOpenid 群成员 openid
1499
+ * @param op 操作: add 增加禁言,update 更新禁言到期时间,del 解除禁言
1500
+ * @param duration 禁言时间使用RFC3339 格式
1501
+ */
1502
+ setGroupMute(groupOpenid, memberOpenid, op, duration) {
1503
+ return this.post(`/v2/groups/${groupOpenid}/restrict_chat_setting`, { members: [{
1504
+ op,
1505
+ member_openid: memberOpenid,
1506
+ mute_expire_at: duration
1507
+ }] });
1508
+ }
1509
+ /**
1510
+ * 设置群成员禁言
1511
+ * @deprecated 方法名拼写错误,请使用 {@link GroupsApi.setGroupMute}
1512
+ */
1513
+ setGroupMetu(groupOpenid, memberOpenid, op, duration) {
1514
+ return this.setGroupMute(groupOpenid, memberOpenid, op, duration);
1515
+ }
1516
+ /**
1517
+ * 查询群禁言状态
1518
+ *
1519
+ * 返回全员禁言模式(含定时 / 周期规则)与当前禁言中的成员列表(不含已过期)。
1520
+ * 机器人需拥有群管理员身份。
1521
+ *
1522
+ * @param groupOpenid 群 openid
1523
+ */
1524
+ getGroupMuteSetting(groupOpenid) {
1525
+ return this.get(`/v2/groups/${groupOpenid}/restrict_chat_setting`);
1526
+ }
1527
+ /**
1528
+ * 拉取入群申请列表
1529
+ *
1530
+ * 机器人需拥有群管理员身份。
1531
+ *
1532
+ * @param groupOpenid 群 openid
1533
+ * @param params 分页参数,`limit` 默认 20、最大 100
1534
+ */
1535
+ getJoinRequestList(groupOpenid, params = {}) {
1536
+ const query = new URLSearchParams();
1537
+ if (params.cursor) query.set("cursor", params.cursor);
1538
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
1539
+ const search = query.toString();
1540
+ return this.get(`/v2/groups/${groupOpenid}/join_request_list${search ? `?${search}` : ""}`);
1541
+ }
1542
+ /**
1543
+ * 审批入群申请
1544
+ *
1545
+ * 机器人需拥有群管理员身份。
1546
+ *
1547
+ * @param groupOpenid 群 openid
1548
+ * @param memberOpenid 申请人 openid
1549
+ * @param body 审批动作,`op=decline` 时可附带拒绝理由与拉黑标记
1550
+ */
1551
+ approvalJoinRequest(groupOpenid, memberOpenid, body) {
1552
+ return this.post(`/v2/groups/${groupOpenid}/approval_join_request/${memberOpenid}`, body);
1553
+ }
1554
+ };
1555
+
1556
+ //#endregion
1557
+ //#region src/core/api/guilds.ts
1558
+ /**
1559
+ * 频道 / 子频道接口
1560
+ *
1561
+ * 子频道的创建、修改、删除均需要管理员权限且属于私域接口,
1562
+ * 操作成功后会触发 `CHANNEL_CREATE` / `CHANNEL_UPDATE` / `CHANNEL_DELETE` 事件。
1563
+ */
1564
+ var GuildsApi = class extends Http {
1565
+ /**
1566
+ * 获取机器人加入的频道列表
1567
+ *
1568
+ * 官方文档响应体标注为 `{ guilds: [] }`,但示例与实际返回均为裸数组;
1569
+ * 这里对两种形态都做兼容。
1570
+ *
1571
+ * @param params 分页参数,`limit` 默认 100、最大 100
1572
+ */
1573
+ async getGuildList(params = {}) {
1574
+ const query = new URLSearchParams();
1575
+ if (params.before) query.set("before", params.before);
1576
+ if (params.after) query.set("after", params.after);
1577
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
1578
+ const search = query.toString();
1579
+ const data = await this.get(`/users/@me/guilds${search ? `?${search}` : ""}`);
1580
+ if (Array.isArray(data)) return data;
1581
+ return data?.guilds ?? [];
1582
+ }
1583
+ /**
1584
+ * 获取频道详情
1585
+ * @param guildId 频道 ID
1586
+ */
1587
+ getGuild(guildId) {
1588
+ return this.get(`/guilds/${guildId}`);
1589
+ }
1590
+ /**
1591
+ * 获取子频道列表
1592
+ *
1593
+ * 官方文档响应体标注为 `{ channels: [] }`,但示例与实际返回均为裸数组;
1594
+ * 这里对两种形态都做兼容。
1595
+ *
1596
+ * @param guildId 频道 ID
1597
+ */
1598
+ async getChannelList(guildId) {
1599
+ const data = await this.get(`/guilds/${guildId}/channels`);
1600
+ if (Array.isArray(data)) return data;
1601
+ return data?.channels ?? [];
1602
+ }
1603
+ /**
1604
+ * 创建子频道(需要管理员权限,私域接口)
1605
+ * @param guildId 频道 ID
1606
+ * @param body 子频道配置
1607
+ */
1608
+ createChannel(guildId, body) {
1609
+ return this.post(`/guilds/${guildId}/channels`, body);
1610
+ }
1611
+ /**
1612
+ * 获取子频道详情
1613
+ * @param channelId 子频道 ID
1614
+ */
1615
+ getChannel(channelId) {
1616
+ return this.get(`/channels/${channelId}`);
1617
+ }
1618
+ /**
1619
+ * 修改子频道(需要管理员权限,私域接口)
1620
+ *
1621
+ * 只需传入要修改的字段。
1622
+ * @param channelId 子频道 ID
1623
+ * @param body 待修改的字段
1624
+ */
1625
+ updateChannel(channelId, body) {
1626
+ return this.patch(`/channels/${channelId}`, body);
1627
+ }
1628
+ /**
1629
+ * 删除子频道(需要管理员权限,私域接口)
1630
+ *
1631
+ * 子频道删除后无法恢复。
1632
+ * @param channelId 子频道 ID
1633
+ */
1634
+ deleteChannel(channelId) {
1635
+ return this.delete(`/channels/${channelId}`);
1636
+ }
1637
+ };
1638
+
1639
+ //#endregion
1640
+ //#region src/core/api/menu.ts
1641
+ /**
1642
+ * 全局自定义菜单接口
1643
+ *
1644
+ * 自定义菜单展示在机器人单聊窗口底部,仅支持 C2C(单聊)场景,
1645
+ * 设置后对所有用户生效,不支持按用户维度区分。
1646
+ *
1647
+ * {@link https://bot.q.qq.com/wiki/develop/api-v2/server-inter/menu-panel/}
1648
+ */
1649
+ var MenuApi = class extends Http {
1650
+ /**
1651
+ * 查询全局自定义菜单
1652
+ *
1653
+ * 未设置过菜单时响应的 `menu` 字段为空。
1654
+ * @returns 当前生效的菜单配置与版本号
1655
+ */
1656
+ getMenu() {
1657
+ return this.get("/v2/menu");
1658
+ }
1659
+ /**
1660
+ * 修改全局自定义菜单
1661
+ *
1662
+ * 传入后会覆盖原有的完整菜单配置;不传 `menu` 表示清空菜单。
1663
+ * @param menu 菜单配置,最多 10 个一级菜单项
1664
+ * @returns 本次修改后的菜单版本号
1665
+ */
1666
+ updateMenu(menu) {
1667
+ return this.put("/v2/menu", menu ? { menu } : {});
1668
+ }
1669
+ };
1670
+
1671
+ //#endregion
1672
+ //#region src/core/api/panels.ts
1673
+ /**
1674
+ * 指令面板接口
1675
+ *
1676
+ * 指令面板以面板形式展示指令或链接,支持 c2c(单聊)、group(群聊)、
1677
+ * channel(文字子频道)、dm(频道私信)四种场景;其中 c2c / group 支持
1678
+ * 按指定用户或群生效(target_type=specific),channel / dm 仅支持全局配置。
1679
+ *
1680
+ * 一个机器人最多创建 20 个指令面板。
1681
+ *
1682
+ * {@link https://bot.q.qq.com/wiki/develop/api-v2/server-inter/menu-panel/}
1683
+ */
1684
+ var PanelsApi = class extends Http {
1685
+ /**
1686
+ * 查询指令面板列表
1687
+ *
1688
+ * 分页拉取指定场景下已生效的面板,按设置时间倒序排列。
1689
+ * @param params 查询参数,`scope` 必填
1690
+ * @returns 面板记录列表与分页游标
1691
+ */
1692
+ getPanelList(params) {
1693
+ const query = new URLSearchParams({ scope: params.scope });
1694
+ if (params.cursor) query.set("cursor", params.cursor);
1695
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
1696
+ return this.get(`/v2/panels?${query.toString()}`);
1697
+ }
1698
+ /**
1699
+ * 创建指令面板
1700
+ * @param body 面板场景、作用范围与配置内容
1701
+ * @returns 新创建的面板 ID
1702
+ */
1703
+ createPanel(body) {
1704
+ return this.post("/v2/panels", body);
1705
+ }
1706
+ /**
1707
+ * 查询指令面板详情
1708
+ *
1709
+ * 返回面板完整配置,target_type=specific 时同时返回关联的用户 / 群 openid 列表。
1710
+ * @param panelId 面板 ID
1711
+ */
1712
+ getPanel(panelId) {
1713
+ return this.get(`/v2/panels/${panelId}`);
1714
+ }
1715
+ /**
1716
+ * 修改指令面板
1717
+ *
1718
+ * 传入后会覆盖原有的面板元素列表和备注,不影响已关联的用户 / 群列表。
1719
+ * @param panelId 面板 ID
1720
+ * @param panel 面板配置内容
1721
+ * @returns 本次修改后的面板版本号
1722
+ */
1723
+ updatePanel(panelId, panel) {
1724
+ return this.put(`/v2/panels/${panelId}`, { panel });
1725
+ }
1726
+ /**
1727
+ * 删除指令面板
1728
+ *
1729
+ * 删除后该面板不再对任何用户或群生效。
1730
+ * @param panelId 面板 ID
1731
+ */
1732
+ deletePanel(panelId) {
1733
+ return this.delete(`/v2/panels/${panelId}`);
1734
+ }
1735
+ /**
1736
+ * 修改指令面板关联对象
1737
+ *
1738
+ * c2c 场景操作 `user_openids`,group 场景操作 `group_openids`,一次最多 20 个;
1739
+ * channel / dm 场景为全局配置,不支持此操作。
1740
+ * @param panelId 面板 ID
1741
+ * @param body 操作类型与关联对象列表
1142
1742
  */
1143
- async ack(interactionId, code = 0) {
1144
- await this.put(`/interactions/${interactionId}`, { code }, void 0, INTERACTION_ACK_TIMEOUT);
1743
+ updatePanelTarget(panelId, body) {
1744
+ return this.put(`/v2/panels/${panelId}/target`, body);
1145
1745
  }
1146
1746
  };
1147
1747
 
1148
1748
  //#endregion
1149
- //#region src/core/api/meta.ts
1749
+ //#region src/core/api/join-approval.ts
1150
1750
  /**
1151
- * 元信息接口:@me / dms / gateway
1751
+ * 入群自动审批策略接口
1752
+ *
1753
+ * 策略把「关联群 + 白名单 QQ 号码」绑在一起,命中白名单的入群申请会自动审批通过。
1754
+ * 规则仅在机器人拥有群管理员身份时才会实际运行;一个机器人最多 20 个策略。
1152
1755
  */
1153
- var MetaApi = class extends Http {
1154
- /** 当前机器人详情 */
1155
- getMe() {
1156
- return this.get("/users/@me");
1157
- }
1158
- /** 创建频道私信会话 */
1159
- createDms(recipientId, srcGuildId) {
1160
- return this.post("/users/@me/dms", {
1161
- recipient_id: recipientId,
1162
- source_guild_id: srcGuildId
1163
- });
1756
+ var JoinApprovalApi = class extends Http {
1757
+ /**
1758
+ * 查询入群自动审批策略列表
1759
+ *
1760
+ * 按创建时间倒序,支持分页。
1761
+ * @param params 分页参数,`limit` 默认 20、最大 100
1762
+ */
1763
+ getStrategyList(params = {}) {
1764
+ const query = new URLSearchParams();
1765
+ if (params.cursor) query.set("cursor", params.cursor);
1766
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
1767
+ const search = query.toString();
1768
+ return this.get(`/v2/groups/join_approval_strategy${search ? `?${search}` : ""}`);
1164
1769
  }
1165
- /** 获取通用 WSS 地址 */
1166
- getGateway() {
1167
- return this.get("/gateway");
1770
+ /**
1771
+ * 创建入群自动审批策略
1772
+ *
1773
+ * `group_openids` 与 `group_ids` 二选一必填,同时传入或均未传入都会报错。
1774
+ * @param body 策略配置
1775
+ */
1776
+ createStrategy(body) {
1777
+ return this.post("/v2/groups/join_approval_strategy", body);
1168
1778
  }
1169
- };
1170
-
1171
- //#endregion
1172
- //#region src/core/api/groups.ts
1173
- /**
1174
- * QQ 群信息接口
1175
- *
1176
- * 注意:以下接口均为白名单接口,未开通权限时平台返回错误码 11253,
1177
- * 需要向平台运营申请后可用。
1178
- */
1179
- var GroupsApi = class extends Http {
1180
1779
  /**
1181
- * 获取群基础信息
1182
- * @param groupOpenid 群 openid
1780
+ * 修改入群自动审批策略
1781
+ *
1782
+ * 可修改生效状态、失效时间、备注,或增删关联群;
1783
+ * 增删关联群时的群标识形式必须与创建时一致。
1784
+ * @param strategyId 策略 ID
1785
+ * @param body 待修改的字段
1183
1786
  */
1184
- getGroupInfo(groupOpenid) {
1185
- return this.get(`/v2/groups/${groupOpenid}/info`);
1787
+ updateStrategy(strategyId, body) {
1788
+ return this.patch(`/v2/groups/join_approval_strategy/${strategyId}`, body);
1186
1789
  }
1187
1790
  /**
1188
- * 获取机器人在群内的状态(角色、入群时间、主动消息开关等)
1189
- * @param groupOpenid 群 openid
1791
+ * 删除入群自动审批策略
1792
+ * @param strategyId 策略 ID
1190
1793
  */
1191
- getBotState(groupOpenid) {
1192
- return this.get(`/v2/groups/${groupOpenid}/bot_state`);
1794
+ deleteStrategy(strategyId) {
1795
+ return this.delete(`/v2/groups/join_approval_strategy/${strategyId}`);
1193
1796
  }
1194
1797
  /**
1195
- * 获取群成员详情
1798
+ * 执行入群自动审批策略
1196
1799
  *
1197
- * @param groupOpenid 群 openid
1198
- * @param memberOpenid 群成员 openid
1800
+ * 对策略关联的全部群发起全量扫描,命中白名单号码的入群申请自动审批通过。
1801
+ * 异步执行,约 10 分钟完成。
1802
+ * @param strategyId 策略 ID
1199
1803
  */
1200
- getGroupMember(groupOpenid, memberOpenid) {
1201
- return this.get(`/v2/groups/${groupOpenid}/members/${memberOpenid}`);
1804
+ executeStrategy(strategyId) {
1805
+ return this.post(`/v2/groups/join_approval_strategy/${strategyId}/execute`);
1202
1806
  }
1203
1807
  /**
1204
- * 设置群成员禁言
1205
- * @param groupOpenid 群 openid
1206
- * @param memberOpenid 群成员 openid
1207
- * @param op 操作: add 增加禁言,update 更新禁言到期时间,del 解除禁言
1208
- * @param duration 禁言时间使用RFC3339 格式
1808
+ * 修改入群自动审批策略的白名单号码
1809
+ *
1810
+ * 单次最多 10000 个,策略号码上限 10W。
1811
+ * @param strategyId 策略 ID
1812
+ * @param body 操作类型与号码列表
1209
1813
  */
1210
- setGroupMetu(groupOpenid, memberOpenid, op, duration) {
1211
- return this.post(`/v2/groups/${groupOpenid}/restrict_chat_setting`, { members: [{
1212
- op,
1213
- member_openid: memberOpenid,
1214
- mute_expire_at: duration
1215
- }] });
1814
+ updateWhitelistUsers(strategyId, body) {
1815
+ return this.post(`/v2/groups/join_approval_strategy/${strategyId}/whitelist_users`, body);
1216
1816
  }
1217
1817
  };
1218
1818
 
@@ -1241,11 +1841,17 @@ const buildQQMsg = {
1241
1841
  ark
1242
1842
  };
1243
1843
  },
1244
- /** 构造 msg_type=7 富媒体消息体 */
1245
- media(fileInfo) {
1844
+ /**
1845
+ * 构造 msg_type=7 富媒体消息体
1846
+ *
1847
+ * @param fileInfo 上传富媒体返回的 file_info。
1848
+ * @param keyboard 可选内嵌键盘,用于按钮随富媒体一起发送。
1849
+ */
1850
+ media(fileInfo, keyboard) {
1246
1851
  return {
1247
1852
  msg_type: 7,
1248
- media: { file_info: fileInfo }
1853
+ media: { file_info: fileInfo },
1854
+ keyboard
1249
1855
  };
1250
1856
  }
1251
1857
  };
@@ -1296,28 +1902,56 @@ const buildGuildMsg = {
1296
1902
  * - messages:发送 / 撤回
1297
1903
  * - media:富媒体上传
1298
1904
  * - interaction:按钮回调 ack
1299
- * - meta:@me / dms / gateway
1300
- * - groups:群基础信息 / 机器人群内状态(白名单接口)
1905
+ * - meta:@me / dms / gateway / 分享链接
1906
+ * - groups:群基础信息 / 机器人群内状态 / 禁言 / 入群申请(部分为白名单接口)
1907
+ * - guilds:频道与子频道的查询和增删改
1908
+ * - menu:全局自定义菜单(仅单聊)
1909
+ * - panels:指令面板
1910
+ * - joinApproval:入群自动审批策略
1911
+ * - request:裸请求入口,用于调用尚未封装的官方接口
1301
1912
  *
1302
1913
  * 请求体构造器 {@link buildQQMsg} / {@link buildGuildMsg} 独立导出,避免与 HTTP 调用耦合
1303
1914
  */
1304
1915
  var QQBotApi = class {
1305
1916
  axios;
1917
+ /**
1918
+ * 裸请求入口,直接调用官方 OpenAPI。
1919
+ *
1920
+ * 官方新接口还没被适配器封装时可用它兜底,会自动带鉴权、解包 `data`、
1921
+ * 格式化错误码:
1922
+ *
1923
+ * ```ts
1924
+ * await bot.super.request.get('/v2/menu')
1925
+ * await bot.super.request.post('/v2/xxx', { foo: 1 })
1926
+ * ```
1927
+ *
1928
+ * @see {@link RequestApi}
1929
+ */
1930
+ request;
1306
1931
  messages;
1307
1932
  media;
1308
1933
  interaction;
1309
1934
  meta;
1310
1935
  groups;
1936
+ guilds;
1937
+ menu;
1938
+ panels;
1939
+ joinApproval;
1311
1940
  /** 请求体构造器(静态便利) */
1312
1941
  qq = buildQQMsg;
1313
1942
  guild = buildGuildMsg;
1314
1943
  constructor(axios) {
1315
1944
  this.axios = axios;
1945
+ this.request = new RequestApi(axios);
1316
1946
  this.messages = new MessagesApi(axios);
1317
1947
  this.media = new MediaApi(axios);
1318
1948
  this.interaction = new InteractionApi(axios);
1319
1949
  this.meta = new MetaApi(axios);
1320
1950
  this.groups = new GroupsApi(axios);
1951
+ this.guilds = new GuildsApi(axios);
1952
+ this.menu = new MenuApi(axios);
1953
+ this.panels = new PanelsApi(axios);
1954
+ this.joinApproval = new JoinApprovalApi(axios);
1321
1955
  }
1322
1956
  };
1323
1957
 
@@ -1582,13 +2216,46 @@ const groupElements = (scene, elements) => {
1582
2216
  //#endregion
1583
2217
  //#region src/core/adapter/media-source.ts
1584
2218
  /** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
1585
- const HTTP_URL_RE$1 = /^https?:\/\//i;
1586
- /** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
1587
- const DEFAULT_FILENAME = {
1588
- image: "image.jpg",
1589
- video: "video.mp4",
1590
- record: "record.mp3",
1591
- file: "file.bin"
2219
+ /**
2220
+ * 把带 MIME 头的完整 data URL(`data:image/png;base64,...`)归一为 Karin 标准
2221
+ * 的 `base64://` 形式,其余来源原样返回。
2222
+ *
2223
+ * 下游插件经常直接给出完整 data URL;QQ 上传层认识它,但 karin 的
2224
+ * `common.buffer` 和不少第三方 fileToUrl 处理器只认 `base64://`,因此在进入
2225
+ * 发送链路时统一归一,避免同一资源在不同环节表现不一致。
2226
+ *
2227
+ * @param source Karin 消息段里的 file 字段。
2228
+ * @returns 归一后的来源字符串。
2229
+ */
2230
+ const normalizeMediaSource = (source) => {
2231
+ return DATA_URL_BASE64_RE.test(source) ? source.replace(DATA_URL_BASE64_RE, "base64://") : source;
2232
+ };
2233
+ /** 带 file 字段的富媒体消息段类型。 */
2234
+ const MEDIA_ELEMENT_TYPES = /* @__PURE__ */ new Set([
2235
+ "image",
2236
+ "video",
2237
+ "record",
2238
+ "file"
2239
+ ]);
2240
+ /**
2241
+ * 把消息段里带 MIME 头的完整 data URL(`data:image/png;base64,...`)统一归一为
2242
+ * Karin 标准的 `base64://` 形式。下游的 fileToUrl 处理器与 karin `common.buffer`
2243
+ * 大多只认后者,在进入发送链路前归一可以保证各环节行为一致。
2244
+ *
2245
+ * @param elements 待发送的消息段。
2246
+ * @returns 归一后的消息段;无需归一时返回原数组。
2247
+ */
2248
+ const normalizeMediaElements = (elements) => {
2249
+ return elements.map((el) => {
2250
+ if (!MEDIA_ELEMENT_TYPES.has(el.type)) return el;
2251
+ const file = el.file;
2252
+ if (typeof file !== "string") return el;
2253
+ const normalized = normalizeMediaSource(file);
2254
+ return normalized === file ? el : {
2255
+ ...el,
2256
+ file: normalized
2257
+ };
2258
+ });
1592
2259
  };
1593
2260
  /**
1594
2261
  * 当前 Karin 运行时是否注册了 fileToUrl 处理器。
@@ -1600,37 +2267,58 @@ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
1600
2267
  * @param source Karin 消息段里的 file 字段。
1601
2268
  * @returns 是否为 HTTP(S) URL。
1602
2269
  */
1603
- const isHttpMediaSource = (source) => HTTP_URL_RE$1.test(source);
2270
+ const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
1604
2271
  /**
1605
2272
  * 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
1606
2273
  *
1607
2274
  * QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
1608
2275
  * fileToUrl 作为主路径,QQ 上传只做兜底。
1609
2276
  *
1610
- * @param ctx 适配器实例,用于记录降级日志。
2277
+ * @param ctx 适配器实例,用于记录降级日志并标明执行发送的 bot。
1611
2278
  * @param type 富媒体类型。
1612
2279
  * @param source Karin 消息段里的 file 字段。
1613
2280
  * @param filename 可选文件名,主要用于 file 消息。
2281
+ * @param origin 本次发送归属的上传会话,供需要借 QQ 通道上传的处理器使用。
2282
+ * @param purpose 拿到 URL 之后的用途,默认按富媒体上传处理。
1614
2283
  * @returns 解析后的首选来源和来源类型。
1615
2284
  */
1616
- const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
1617
- if (isHttpMediaSource(source)) return {
1618
- source,
2285
+ const resolvePreferredMediaSource = async (ctx, type, source, filename, origin, purpose = "media") => {
2286
+ const normalized = normalizeMediaSource(source);
2287
+ if (isHttpMediaSource(normalized)) return {
2288
+ source: normalized,
1619
2289
  via: "public-url"
1620
2290
  };
1621
2291
  if (!hasFileToUrlHandler()) return {
1622
- source,
2292
+ source: normalized,
1623
2293
  via: "fallback"
1624
2294
  };
1625
2295
  try {
2296
+ const extra = {
2297
+ selfId: ctx.selfId,
2298
+ purpose,
2299
+ origin
2300
+ };
2301
+ const result = await fileToUrl(type, normalized, filename || DEFAULT_FILENAME[type], extra);
2302
+ /**
2303
+ * `handler.has` 只判断该 key 下是否注册过处理器,不代表真的有处理器接手:
2304
+ * 所有处理器都调用 next() 放行时,fileToUrl 会返回 undefined。此时不属于
2305
+ * 异常,安静地走 QQ 上传兜底即可,不必输出 warn 噪音。
2306
+ */
2307
+ if (!result?.url) {
2308
+ ctx.logger("debug", `[sendQQ] 没有 fileToUrl 处理器接手 ${type},改用 QQ 上传兜底`);
2309
+ return {
2310
+ source: normalized,
2311
+ via: "fallback"
2312
+ };
2313
+ }
1626
2314
  return {
1627
- source: (await fileToUrl(type, source, filename || DEFAULT_FILENAME[type])).url,
2315
+ source: result.url,
1628
2316
  via: "file-to-url"
1629
2317
  };
1630
2318
  } catch (err) {
1631
2319
  ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
1632
2320
  return {
1633
- source,
2321
+ source: normalized,
1634
2322
  via: "fallback"
1635
2323
  };
1636
2324
  }
@@ -1638,21 +2326,87 @@ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
1638
2326
 
1639
2327
  //#endregion
1640
2328
  //#region src/core/adapter/text-to-md.ts
1641
- const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
2329
+ const MARKDOWN_IMAGE_RE = /!\[([^\]]*)]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
2330
+ /**
2331
+ * QQ markdown 图片语法里的显式尺寸:`#100px #100px`。
2332
+ *
2333
+ * `#` 是必需的——`100px 100px` 这种省略写法 QQ 前端不会按尺寸渲染,只能算
2334
+ * 普通描述文本。QQ 客户端按这个声明尺寸渲染而不是图片的物理分辨率——开发者
2335
+ * 显式写出尺寸是有布局意图的(比如把一张高清图当头像展示),重建时必须原样
2336
+ * 保留。
2337
+ */
2338
+ const EXPLICIT_IMAGE_SIZE_RE = /#(\d+)px\s+#(\d+)px/;
2339
+ /**
2340
+ * 调用 fileToUrl 并确认真的拿到了 URL。
2341
+ *
2342
+ * Karin 的 handler 链允许每个处理器调用 next() 放行,全部放行时 fileToUrl 会
2343
+ * 返回 undefined 而不是抛错。这里统一转成明确的错误信息,避免调用方拿到
2344
+ * undefined 后在解构处报出难以定位的 TypeError。
2345
+ *
2346
+ * @param source 图片来源。
2347
+ * @param extra 传给处理器的附加信息,至少标明执行发送的 bot。
2348
+ * @returns fileToUrl 的图片结果。
2349
+ */
2350
+ const imageToUrl = async (source, extra) => {
2351
+ const result = await fileToUrl("image", normalizeMediaSource(source), "image.jpg", extra);
2352
+ if (!result?.url) throw new Error("[Handler][Error]: 没有 fileToUrl 处理器接手图片转 URL");
2353
+ return result;
2354
+ };
2355
+ /**
2356
+ * 生成一行 markdown 图片。
2357
+ *
2358
+ * alt 里带显式尺寸(`#100px #100px`)时原样保留,不探测图片真实尺寸——QQ 按
2359
+ * 声明尺寸渲染,覆盖它会破坏开发者的布局意图。没有显式尺寸时才用传入的或
2360
+ * 探测到的真实宽高补全。
2361
+ *
2362
+ * @param url 图片的最终公网 URL。
2363
+ * @param alt 原始 alt 文本,可能包含显式尺寸声明。
2364
+ * @param size 已知的图片真实尺寸;未提供且 alt 无显式尺寸时探测 URL。
2365
+ * @returns markdown 图片行。
2366
+ */
2367
+ const buildMarkdownImageLine = async (url, alt = "", size) => {
2368
+ const explicit = alt.match(EXPLICIT_IMAGE_SIZE_RE);
2369
+ if (explicit) return `![${alt.replace(EXPLICIT_IMAGE_SIZE_RE, "").trim()} #${explicit[1]}px #${explicit[2]}px](${url})`;
2370
+ const desc = alt.trim() || "karin";
2371
+ const resolved = size ?? await getImageSize(url).catch(() => ({
2372
+ width: 300,
2373
+ height: 300
2374
+ }));
2375
+ return `![${desc} #${resolved.width}px #${resolved.height}px](${url})`;
2376
+ };
1642
2377
  /**
1643
2378
  * 将图片列表合并到一段 markdown 文本内(每张图占一行)
2379
+ *
2380
+ * @param urls 图片来源列表。
2381
+ * @param extra 传给 fileToUrl 处理器的附加信息。
2382
+ * @returns markdown 图片行。
1644
2383
  */
1645
- const imagesToMarkdown = async (urls) => {
2384
+ const imagesToMarkdown = async (urls, extra) => {
1646
2385
  return Promise.all(urls.map(async (file) => {
1647
- if (file.startsWith("http")) {
1648
- const { width, height } = await getImageSize(file);
1649
- return `![karin #${width}px #${height}px](${file})`;
1650
- }
1651
- const { url, width, height } = await fileToUrl("image", file, "image.jpg");
1652
- return `![karin #${width}px #${height}px](${url})`;
2386
+ if (file.startsWith("http")) return buildMarkdownImageLine(file);
2387
+ const { url, width, height } = await imageToUrl(file, extra);
2388
+ return buildMarkdownImageLine(url, "", {
2389
+ width,
2390
+ height
2391
+ });
1653
2392
  }));
1654
2393
  };
1655
2394
  /**
2395
+ * 把一个拆分出来的 markdown 图片转成图片行,保留原始 alt 与显式尺寸。
2396
+ *
2397
+ * @param part splitMarkdownImages 拆出的图片片段。
2398
+ * @param extra 传给 fileToUrl 处理器的附加信息。
2399
+ * @returns markdown 图片行。
2400
+ */
2401
+ const markdownPartToLine = async (part, extra) => {
2402
+ if (part.source.startsWith("http")) return buildMarkdownImageLine(part.source, part.alt);
2403
+ const { url, width, height } = await imageToUrl(part.source, extra);
2404
+ return buildMarkdownImageLine(url, part.alt, {
2405
+ width,
2406
+ height
2407
+ });
2408
+ };
2409
+ /**
1656
2410
  * 将 markdown 文本拆为普通文本片段与图片来源。
1657
2411
  */
1658
2412
  const splitMarkdownImages = (markdown) => {
@@ -1667,10 +2421,12 @@ const splitMarkdownImages = (markdown) => {
1667
2421
  value: text
1668
2422
  });
1669
2423
  }
1670
- const source = (match[1] || "").trim().replace(/^<|>$/g, "");
2424
+ const alt = match[1] ?? "";
2425
+ const source = (match[2] || "").trim().replace(/^<|>$/g, "");
1671
2426
  if (source) parts.push({
1672
2427
  type: "image",
1673
- source
2428
+ source: normalizeMediaSource(source),
2429
+ alt
1674
2430
  });
1675
2431
  lastIndex = index + match[0].length;
1676
2432
  }
@@ -1689,25 +2445,6 @@ const splitMarkdownImages = (markdown) => {
1689
2445
 
1690
2446
  //#endregion
1691
2447
  //#region src/core/adapter/pipeline-qq.ts
1692
- /** QQ 官方限制:单聊中同一 `msg_id` 最多发送四次被动回复。 */
1693
- const C2C_PASSIVE_REPLY_LIMIT = 4;
1694
- /** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
1695
- const KEYBOARD_MAX_ROWS$1 = 5;
1696
- const KEYBOARD_MAX_BUTTONS_PER_ROW$1 = 5;
1697
- /** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
1698
- const BUTTON_ONLY_MARKDOWN$1 = "​";
1699
- /** 群聊 event_id 白名单 */
1700
- const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
1701
- "INTERACTION_CREATE",
1702
- "GROUP_ADD_ROBOT",
1703
- "GROUP_MSG_RECEIVE"
1704
- ]);
1705
- /** 单聊 event_id 白名单 */
1706
- const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
1707
- "INTERACTION_CREATE",
1708
- "C2C_MSG_RECEIVE",
1709
- "FRIEND_ADD"
1710
- ]);
1711
2448
  /**
1712
2449
  * 处理 QQ 场景(单聊 + 群聊)的发送
1713
2450
  */
@@ -1731,30 +2468,34 @@ const sendQQ = async (ctx, contact, elements) => {
1731
2468
  */
1732
2469
  const sendQQMarkdown = async (ctx, contact, grouping, target) => {
1733
2470
  const items = [];
2471
+ /** 上传归属会话固定为本次发送目标,保证 openid 与执行发送的 bot 同域。 */
2472
+ const origin = {
2473
+ scene: target,
2474
+ peer: contact.peer
2475
+ };
1734
2476
  const lines = [];
1735
2477
  if (grouping.text.length) lines.push(grouping.text.join(""));
1736
- const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages);
1737
- const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
2478
+ const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages, origin);
2479
+ const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping, origin);
1738
2480
  fallbackImages.push(...markdownFallbackImages);
1739
2481
  if (contact.scene === "group") {
1740
2482
  warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
1741
2483
  warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
1742
2484
  }
1743
- if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
1744
- else {
1745
- const keyboard = buildKeyboard$1(grouping);
1746
- const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
1747
- items.push(ctx.super.qq.markdown({ content }, keyboard));
1748
- }
2485
+ const mediaItems = [];
1749
2486
  for (const file of fallbackImages) {
1750
2487
  const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
1751
- items.push(ctx.super.qq.media(res.file_info));
2488
+ mediaItems.push(ctx.super.qq.media(res.file_info));
1752
2489
  }
1753
2490
  for (const m of grouping.media) {
1754
- const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name);
2491
+ const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name, origin);
1755
2492
  const res = source.via === "fallback" ? await ctx.super.media.uploadFallback(target, contact.peer, m.kind, source.source, false, m.name) : await ctx.super.media.upload(target, contact.peer, m.kind, source.source, false, m.name);
1756
- items.push(ctx.super.qq.media(res.file_info));
2493
+ mediaItems.push(ctx.super.qq.media(res.file_info));
1757
2494
  }
2495
+ if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
2496
+ else if (lines.length) items.push(ctx.super.qq.markdown({ content: lines.join("\n") }, buildKeyboard$1(grouping)));
2497
+ else attachKeyboardWithoutMarkdown(ctx, grouping, mediaItems, items);
2498
+ items.push(...mediaItems);
1758
2499
  if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
1759
2500
  return flushQQ(ctx, contact, grouping, items);
1760
2501
  };
@@ -1781,21 +2522,55 @@ const sendQQClassic = async (ctx, contact, grouping, target) => {
1781
2522
  const appendExplicitMarkdownItems = async (ctx, contact, grouping, target, items) => {
1782
2523
  if (!grouping.markdowns.length) return;
1783
2524
  const lines = [];
1784
- const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
2525
+ const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping, {
2526
+ scene: target,
2527
+ peer: contact.peer
2528
+ });
1785
2529
  if (contact.scene === "group") {
1786
2530
  warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
1787
2531
  warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
1788
2532
  }
1789
- const keyboard = buildKeyboard$1(grouping);
1790
- const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
1791
- items.push(ctx.super.qq.markdown({ content }, keyboard));
2533
+ const mediaItems = [];
1792
2534
  for (const file of fallbackImages) {
1793
2535
  const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
1794
- items.push(ctx.super.qq.media(res.file_info));
2536
+ mediaItems.push(ctx.super.qq.media(res.file_info));
2537
+ }
2538
+ if (lines.length) items.push(ctx.super.qq.markdown({ content: lines.join("\n") }, buildKeyboard$1(grouping)));
2539
+ else attachKeyboardWithoutMarkdown(ctx, grouping, mediaItems, items);
2540
+ items.push(...mediaItems);
2541
+ };
2542
+ /**
2543
+ * 处理「没有任何 markdown 正文,但存在按钮」的边缘情况。
2544
+ *
2545
+ * 典型触发路径:下游只发一张 markdown 语法图片 + 若干按钮,而这张图片没能通过
2546
+ * `fileToUrl` 取到公网地址,被降级成 msg_type=7 富媒体。此时 markdown 正文为空,
2547
+ * 旧实现会补一个零宽字符当正文,把按钮单独发成一条消息,客户端就会看到
2548
+ * 「一条空的按钮消息 + 一条富媒体消息」两条消息。
2549
+ *
2550
+ * 这里改为把 keyboard 挂到第一条富媒体上,合并成一条消息;只有在连富媒体都没有时,
2551
+ * 才保留零宽字符 markdown 兜底(纯按钮消息是正常用法,不能丢)。
2552
+ *
2553
+ * @param ctx 适配器实例,用于输出日志。
2554
+ * @param grouping 已归类的消息段。
2555
+ * @param mediaItems 本次待发送的富媒体消息体,keyboard 会挂到第一条上。
2556
+ * @param items 最终发送队列,仅在没有富媒体时追加纯按钮 markdown。
2557
+ */
2558
+ const attachKeyboardWithoutMarkdown = (ctx, grouping, mediaItems, items) => {
2559
+ const keyboard = buildKeyboard$1(grouping);
2560
+ if (!keyboard) return;
2561
+ const [first] = mediaItems;
2562
+ if (!first) {
2563
+ items.push(ctx.super.qq.markdown({ content: "​" }, keyboard));
2564
+ return;
1795
2565
  }
2566
+ first.keyboard = keyboard;
2567
+ ctx.logger("debug", "图片未能进入 markdown 通道,按钮已随富媒体一起发送,避免拆成两条消息");
1796
2568
  };
1797
2569
  const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
1798
- const resolved = await resolvePreferredMediaSource(ctx, type, source, name);
2570
+ const resolved = await resolvePreferredMediaSource(ctx, type, source, name, {
2571
+ scene: target,
2572
+ peer
2573
+ });
1799
2574
  const res = resolved.via === "fallback" ? await ctx.super.media.uploadFallback(target, peer, type, resolved.source, false, name) : await ctx.super.media.upload(target, peer, type, resolved.source, false, name);
1800
2575
  return ctx.super.qq.media(res.file_info);
1801
2576
  };
@@ -1805,12 +2580,13 @@ const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
1805
2580
  * @param ctx 适配器实例,用于输出降级日志。
1806
2581
  * @param lines markdown 行数组。
1807
2582
  * @param files QQ 图片消息段的 file 字段。
2583
+ * @param origin 本次发送归属的上传会话。
1808
2584
  * @returns 需要改走 msg_type=7 富媒体发送的图片列表。
1809
2585
  */
1810
- const appendMarkdownImages = async (ctx, lines, files) => {
2586
+ const appendMarkdownImages = async (ctx, lines, files, origin) => {
1811
2587
  const fallback = [];
1812
2588
  for (const file of files) {
1813
- const resolved = await resolvePreferredMediaSource(ctx, "image", file);
2589
+ const resolved = await resolvePreferredMediaSource(ctx, "image", file, void 0, origin, "markdown");
1814
2590
  if (resolved.via === "fallback") {
1815
2591
  fallback.push(file);
1816
2592
  continue;
@@ -1821,8 +2597,14 @@ const appendMarkdownImages = async (ctx, lines, files) => {
1821
2597
  };
1822
2598
  /**
1823
2599
  * 处理显式 segment.markdown 中的图片:可转公网 URL 的继续嵌入,无法转的改走富媒体。
2600
+ *
2601
+ * @param ctx 适配器实例,用于输出降级日志。
2602
+ * @param lines markdown 行数组。
2603
+ * @param grouping 已归类的消息段。
2604
+ * @param origin 本次发送归属的上传会话。
2605
+ * @returns 需要改走 msg_type=7 富媒体发送的图片列表。
1824
2606
  */
1825
- const appendExplicitMarkdown = async (ctx, lines, grouping) => {
2607
+ const appendExplicitMarkdown = async (ctx, lines, grouping, origin) => {
1826
2608
  const fallback = [];
1827
2609
  for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
1828
2610
  if (part.type === "text") {
@@ -1830,12 +2612,13 @@ const appendExplicitMarkdown = async (ctx, lines, grouping) => {
1830
2612
  if (text) lines.push(text);
1831
2613
  continue;
1832
2614
  }
1833
- const resolved = await resolvePreferredMediaSource(ctx, "image", part.source);
2615
+ const resolved = await resolvePreferredMediaSource(ctx, "image", part.source, void 0, origin, "markdown");
1834
2616
  if (resolved.via === "fallback") {
1835
2617
  fallback.push(part.source);
1836
2618
  continue;
1837
2619
  }
1838
- await appendImageUrlLine(lines, resolved.source);
2620
+ /** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
2621
+ lines.push(await buildMarkdownImageLine(resolved.source, part.alt));
1839
2622
  }
1840
2623
  return fallback;
1841
2624
  };
@@ -1847,12 +2630,7 @@ const appendExplicitMarkdown = async (ctx, lines, grouping) => {
1847
2630
  * @param url 图片 URL。
1848
2631
  */
1849
2632
  const appendImageUrlLine = async (lines, url) => {
1850
- try {
1851
- const [line] = await imagesToMarkdown([url]);
1852
- lines.push(line);
1853
- } catch {
1854
- lines.push(`![karin #300px #300px](${url})`);
1855
- }
2633
+ lines.push(await buildMarkdownImageLine(url));
1856
2634
  };
1857
2635
  /**
1858
2636
  * 判断显式引用回复是否应降级为普通文本消息。
@@ -1902,8 +2680,8 @@ const buildKeyboard$1 = (grouping) => {
1902
2680
  grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
1903
2681
  const normalizedRows = [];
1904
2682
  let id = 0;
1905
- for (const row of rows.slice(0, KEYBOARD_MAX_ROWS$1)) {
1906
- const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW$1).map((button) => normalizeQQBotButton(button, id++));
2683
+ for (const row of rows.slice(0, 5)) {
2684
+ const buttons = row.buttons.slice(0, 5).map((button) => normalizeQQBotButton(button, id++));
1907
2685
  if (buttons.length) normalizedRows.push({ buttons });
1908
2686
  }
1909
2687
  if (!normalizedRows.length) return void 0;
@@ -1942,8 +2720,8 @@ const flushQQ = async (ctx, contact, grouping, items) => {
1942
2720
  * 仅单聊 `msg_id` 回复受四次上限约束。`event_id` 事件回复和群聊不适用该限制。
1943
2721
  * 这是 QQ 官方 2026-01-10 更新后的限制,超出部分不请求平台接口。
1944
2722
  */
1945
- const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? C2C_PASSIVE_REPLY_LIMIT : Infinity;
1946
- if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${C2C_PASSIVE_REPLY_LIMIT} 条,已跳过 ${items.length - maxItems} 条`);
2723
+ const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? 4 : Infinity;
2724
+ if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${4} 条,已跳过 ${items.length - maxItems} 条`);
1947
2725
  let referenceHandled = false;
1948
2726
  for (const item of items.slice(0, maxItems)) {
1949
2727
  passive(item);
@@ -1970,6 +2748,12 @@ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
1970
2748
  try {
1971
2749
  return await send(contact.peer, item);
1972
2750
  } catch (err) {
2751
+ if (item.msg_type === 7 && item.keyboard && isInvalidKeyboardError(err)) {
2752
+ ctx.logger("warn", "富媒体消息附带 keyboard 被 QQ 拒绝,已去掉按钮重试,本条消息将不显示按钮");
2753
+ const retryItem = { ...item };
2754
+ delete retryItem.keyboard;
2755
+ return send(contact.peer, retryItem);
2756
+ }
1973
2757
  if (!item.event_id || !isInvalidEventIdError(err)) throw err;
1974
2758
  ctx.logger("warn", `event_id 被 QQ 拒绝,已改用普通消息重试: ${item.event_id}`);
1975
2759
  const retryItem = { ...item };
@@ -1979,6 +2763,19 @@ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
1979
2763
  }
1980
2764
  };
1981
2765
  /**
2766
+ * 判断发送失败是否来自 QQ 对 `keyboard` 的参数校验。
2767
+ *
2768
+ * 官方文档未声明 `keyboard` 与 `media` 互斥,但实际平台行为无法完全确认,
2769
+ * 因此富媒体带按钮失败时基于键盘相关错误码兜底降级,保证图片本身能发出去。
2770
+ *
2771
+ * @param err 捕获到的发送异常。
2772
+ * @returns 是否可以通过移除 `keyboard` 重试。
2773
+ */
2774
+ const isInvalidKeyboardError = (err) => {
2775
+ const message = err instanceof Error ? err.message : String(err);
2776
+ return message.includes("305007") || message.includes("键盘") || message.includes("keyboard");
2777
+ };
2778
+ /**
1982
2779
  * 判断发送失败是否来自 QQ 对 `event_id` 的参数校验。
1983
2780
  *
1984
2781
  * 当前错误由 Http 层格式化为普通 Error,因此这里基于官方错误码和中文错误信息兜底识别。
@@ -2055,11 +2852,6 @@ const buildPassiveQQ = (ctx, scene, grouping) => {
2055
2852
 
2056
2853
  //#endregion
2057
2854
  //#region src/core/adapter/pipeline-guild.ts
2058
- /** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
2059
- const KEYBOARD_MAX_ROWS = 5;
2060
- const KEYBOARD_MAX_BUTTONS_PER_ROW = 5;
2061
- /** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
2062
- const BUTTON_ONLY_MARKDOWN = "​";
2063
2855
  /**
2064
2856
  * 处理频道场景(子频道 + 私信)的发送
2065
2857
  *
@@ -2078,29 +2870,52 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
2078
2870
  const items = [];
2079
2871
  const lines = [];
2080
2872
  if (grouping.text.length) lines.push(grouping.text.join(""));
2873
+ /**
2874
+ * 频道用的是 channel_id,不存在 QQ 上传接口要求的 group_openid / user_openid,
2875
+ * 所以只能标明执行发送的 bot,拿不出可用的上传会话。
2876
+ */
2877
+ const extra = {
2878
+ selfId: ctx.selfId,
2879
+ purpose: "markdown"
2880
+ };
2081
2881
  const allImages = [...grouping.guildImageUrls];
2882
+ /** 没有 fileToUrl 处理器接手的本地图片,降级为 file_image 单独发送。 */
2883
+ const fallbackImages = [];
2082
2884
  for (const file of grouping.guildImageFiles) {
2083
- const { url } = await fileToUrl("image", file, "image.jpg");
2084
- allImages.push(url);
2885
+ const result = await fileToUrl("image", file, "image.jpg", extra);
2886
+ if (!result?.url) {
2887
+ ctx.logger("debug", "[sendGuild] 没有 fileToUrl 处理器接手图片转 URL,改走 file_image 单独发送");
2888
+ fallbackImages.push(file);
2889
+ continue;
2890
+ }
2891
+ allImages.push(result.url);
2085
2892
  }
2086
2893
  if (allImages.length) {
2087
- const mdImages = await imagesToMarkdown(allImages);
2894
+ const mdImages = await imagesToMarkdown(allImages, extra);
2088
2895
  lines.push(...mdImages);
2089
2896
  }
2090
- for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
2091
- const text = part.value.trim();
2092
- if (text) lines.push(text);
2093
- } else {
2094
- const [mdImage] = await imagesToMarkdown([part.source]);
2095
- lines.push(mdImage);
2897
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
2898
+ if (part.type === "text") {
2899
+ const text = part.value.trim();
2900
+ if (text) lines.push(text);
2901
+ continue;
2902
+ }
2903
+ try {
2904
+ /** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
2905
+ lines.push(await markdownPartToLine(part, extra));
2906
+ } catch {
2907
+ ctx.logger("debug", "[sendGuild] markdown 内嵌图片转 URL 失败,改走 file_image 单独发送");
2908
+ fallbackImages.push(part.source);
2909
+ }
2096
2910
  }
2097
2911
  warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2098
2912
  warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
2099
2913
  if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
2100
2914
  const keyboard = buildKeyboard(grouping);
2101
- const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
2915
+ const content = lines.length ? lines.join("\n") : "​";
2102
2916
  items.push(ctx.super.guild.markdown({ content }, keyboard));
2103
2917
  }
2918
+ for (const file of fallbackImages) items.push(await buildGuildImageItem(file));
2104
2919
  if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
2105
2920
  return flushGuild(ctx, contact, grouping, items);
2106
2921
  };
@@ -2125,19 +2940,32 @@ const sendGuildClassic = async (ctx, contact, grouping) => {
2125
2940
  */
2126
2941
  const appendExplicitGuildMarkdownItems = async (ctx, contact, grouping, items) => {
2127
2942
  if (!grouping.markdowns.length) return;
2943
+ const extra = {
2944
+ selfId: ctx.selfId,
2945
+ purpose: "markdown"
2946
+ };
2128
2947
  const lines = [];
2129
- for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
2130
- const text = part.value.trim();
2131
- if (text) lines.push(text);
2132
- } else {
2133
- const [mdImage] = await imagesToMarkdown([part.source]);
2134
- lines.push(mdImage);
2948
+ const fallbackImages = [];
2949
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
2950
+ if (part.type === "text") {
2951
+ const text = part.value.trim();
2952
+ if (text) lines.push(text);
2953
+ continue;
2954
+ }
2955
+ try {
2956
+ /** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
2957
+ lines.push(await markdownPartToLine(part, extra));
2958
+ } catch {
2959
+ ctx.logger("debug", "[sendGuild] markdown 内嵌图片转 URL 失败,改走 file_image 单独发送");
2960
+ fallbackImages.push(part.source);
2961
+ }
2135
2962
  }
2136
2963
  warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2137
2964
  warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
2138
2965
  const keyboard = buildKeyboard(grouping);
2139
- const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
2966
+ const content = lines.length ? lines.join("\n") : "​";
2140
2967
  items.push(ctx.super.guild.markdown({ content }, keyboard));
2968
+ for (const file of fallbackImages) items.push(await buildGuildImageItem(file));
2141
2969
  };
2142
2970
  const buildGuildImageItem = async (source) => {
2143
2971
  if (/^https?:\/\//i.test(source)) return {
@@ -2159,8 +2987,8 @@ const buildKeyboard = (grouping) => {
2159
2987
  grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
2160
2988
  const normalizedRows = [];
2161
2989
  let id = 0;
2162
- for (const row of rows.slice(0, KEYBOARD_MAX_ROWS)) {
2163
- const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW).map((button) => normalizeQQBotButton(button, id++));
2990
+ for (const row of rows.slice(0, 5)) {
2991
+ const buttons = row.buttons.slice(0, 5).map((button) => normalizeQQBotButton(button, id++));
2164
2992
  if (buttons.length) normalizedRows.push({ buttons });
2165
2993
  }
2166
2994
  if (!normalizedRows.length) return void 0;
@@ -2415,10 +3243,101 @@ const responseTime = (timestamp) => {
2415
3243
  };
2416
3244
 
2417
3245
  //#endregion
2418
- //#region src/core/adapter/base.ts
3246
+ //#region src/core/adapter/join-request-cache.ts
3247
+ /** 入群申请索引缓存上限,仅用于把 Karin 的 flag 还原成群 / 成员 openid */
3248
+ const JOIN_REQUEST_CACHE_MAX = 1e3;
3249
+ /**
3250
+ * `appId:join_request_id` -> 群 / 申请人 openid。
3251
+ *
3252
+ * Karin 的 `setGroupApplyResult` / `setInvitedJoinGroupResult` 只透传 flag
3253
+ * (即 `join_request_id`),而官方审批接口的路径需要 group_openid 与
3254
+ * member_openid,因此在收到入群申请事件时先把对应关系记下来。
3255
+ *
3256
+ * 按 appId 加前缀,避免多 bot 共用进程时互相覆盖。
3257
+ */
3258
+ const joinRequests = /* @__PURE__ */ new Map();
3259
+ /**
3260
+ * 拼接缓存键。
3261
+ * @param appId 机器人 appId。
3262
+ * @param joinRequestId 申请 ID。
3263
+ */
3264
+ const cacheKey = (appId, joinRequestId) => `${appId}:${joinRequestId}`;
3265
+ /**
3266
+ * 记录入群申请索引,供后续审批接口还原群 / 申请人 openid。
3267
+ *
3268
+ * 超出上限时按写入顺序淘汰最旧的一条。
3269
+ *
3270
+ * @param ctx 当前 QQBot 适配器实例。
3271
+ * @param joinRequestId 申请 ID,同时是 Karin 请求事件的 flag。
3272
+ * @param target 该申请对应的群与申请人 openid。
3273
+ */
3274
+ const cacheJoinRequest = (ctx, joinRequestId, target) => {
3275
+ if (!joinRequestId) return;
3276
+ if (joinRequests.size >= JOIN_REQUEST_CACHE_MAX) {
3277
+ const oldest = joinRequests.keys().next().value;
3278
+ if (oldest !== void 0) joinRequests.delete(oldest);
3279
+ }
3280
+ joinRequests.set(cacheKey(String(ctx.cfg.appId), joinRequestId), target);
3281
+ };
3282
+ /**
3283
+ * 读取入群申请索引。
3284
+ *
3285
+ * @param ctx 当前 QQBot 适配器实例。
3286
+ * @param joinRequestId 申请 ID。
3287
+ * @returns 命中的群 / 申请人 openid;未命中返回 undefined。
3288
+ */
3289
+ const getJoinRequest = (ctx, joinRequestId) => {
3290
+ return joinRequests.get(cacheKey(String(ctx.cfg.appId), joinRequestId));
3291
+ };
3292
+ /**
3293
+ * 审批完成后移除索引。
3294
+ *
3295
+ * @param ctx 当前 QQBot 适配器实例。
3296
+ * @param joinRequestId 申请 ID。
3297
+ */
3298
+ const removeJoinRequest = (ctx, joinRequestId) => {
3299
+ joinRequests.delete(cacheKey(String(ctx.cfg.appId), joinRequestId));
3300
+ };
3301
+ /**
3302
+ * 清理某个 bot 的全部入群申请索引,销毁 bot 时调用。
3303
+ *
3304
+ * @param appId 机器人 appId。
3305
+ */
3306
+ const clearJoinRequests = (appId) => {
3307
+ const prefix = `${appId}:`;
3308
+ for (const key of joinRequests.keys()) if (key.startsWith(prefix)) joinRequests.delete(key);
3309
+ };
3310
+
3311
+ //#endregion
3312
+ //#region src/core/adapter/config-store.ts
3313
+ /**
3314
+ * 适配器实例的真实配置存储。
3315
+ *
3316
+ * 配置挂在 WeakMap 而不是实例字段上:系统接口序列化整个适配器时不会把
3317
+ * appSecret 等敏感配置带出去。
3318
+ */
2419
3319
  const adapterConfigStore = /* @__PURE__ */ new WeakMap();
2420
- /** HTTP(S) URL 可直接交给 QQ 平台拉取上传 */
2421
- const HTTP_URL_RE = /^https?:\/\//i;
3320
+ /**
3321
+ * 绑定适配器实例与其配置。
3322
+ * @param bot 适配器实例。
3323
+ * @param cfg 该实例的 QQBot 配置。
3324
+ */
3325
+ const setAdapterConfig = (bot, cfg) => {
3326
+ adapterConfigStore.set(bot, cfg);
3327
+ };
3328
+ /**
3329
+ * 读取适配器实例的配置。
3330
+ * @param bot 适配器实例。
3331
+ * @returns 该实例的 QQBot 配置;未绑定时抛错。
3332
+ */
3333
+ const getAdapterConfig = (bot) => {
3334
+ const cfg = adapterConfigStore.get(bot);
3335
+ if (!cfg) throw new Error("QQBot adapter config missing");
3336
+ return cfg;
3337
+ };
3338
+
3339
+ //#endregion
3340
+ //#region src/core/adapter/base.ts
2422
3341
  /**
2423
3342
  * QQ Official Bot 适配器
2424
3343
  *
@@ -2432,7 +3351,7 @@ var AdapterQQBot = class extends AdapterBase {
2432
3351
  nicknameCache = /* @__PURE__ */ new Map();
2433
3352
  constructor(cfg, api) {
2434
3353
  super();
2435
- adapterConfigStore.set(this, cfg);
3354
+ setAdapterConfig(this, cfg);
2436
3355
  this.super = api;
2437
3356
  this.adapter.name = "QQ Official Bot";
2438
3357
  this.adapter.protocol = "qqbot";
@@ -2441,9 +3360,7 @@ var AdapterQQBot = class extends AdapterBase {
2441
3360
  }
2442
3361
  /** 当前 bot 配置。真实配置存放在 WeakMap,避免被系统接口序列化整个适配器时带出。 */
2443
3362
  get cfg() {
2444
- const cfg = adapterConfigStore.get(this);
2445
- if (!cfg) throw new Error("QQBot adapter config missing");
2446
- return cfg;
3363
+ return getAdapterConfig(this);
2447
3364
  }
2448
3365
  /** 接收消息的一天热缓存 + SQLite 缓存,用于实现 Karin 标准 `getMsg`。 */
2449
3366
  get messageStore() {
@@ -2463,16 +3380,17 @@ var AdapterQQBot = class extends AdapterBase {
2463
3380
  * 发送消息
2464
3381
  */
2465
3382
  async sendMsg(contact, elements, _retryCount) {
3383
+ const normalizedElements = normalizeMediaElements(elements);
2466
3384
  /** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
2467
3385
  if (contact.scene === "direct" || contact.scene === "guild") {
2468
- const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
2469
- const result = await sendGuild(this, contact, prepared?.sendElements ?? elements);
3386
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
3387
+ const result = await sendGuild(this, contact, prepared?.sendElements ?? normalizedElements);
2470
3388
  cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
2471
3389
  return result;
2472
3390
  }
2473
3391
  if (contact.scene === "group" || contact.scene === "friend") {
2474
- const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
2475
- const result = await sendQQ(this, contact, prepared?.sendElements ?? elements);
3392
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
3393
+ const result = await sendQQ(this, contact, prepared?.sendElements ?? normalizedElements);
2476
3394
  cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
2477
3395
  return result;
2478
3396
  }
@@ -2558,20 +3476,43 @@ var AdapterQQBot = class extends AdapterBase {
2558
3476
  case "text":
2559
3477
  lines.push(el.text);
2560
3478
  break;
2561
- case "image":
2562
- if (el.file.startsWith("http")) try {
2563
- const { width, height } = await getImageSize(el.file);
2564
- lines.push(`![${el.name || "图片"} #${width}px #${height}px](${el.file})`);
3479
+ case "image": {
3480
+ const imageSource = typeof el.file === "string" ? normalizeMediaSource(el.file) : el.file;
3481
+ if (typeof imageSource === "string" && imageSource.startsWith("http")) try {
3482
+ const { width, height } = await getImageSize(imageSource);
3483
+ lines.push(`![${el.name || "图片"} #${width}px #${height}px](${imageSource})`);
2565
3484
  } catch {
2566
- lines.push(`![${el.name || "图片"} #300px #300px](${el.file})`);
3485
+ lines.push(`![${el.name || "图片"} #300px #300px](${imageSource})`);
2567
3486
  }
2568
- else try {
2569
- const { url, width, height } = await fileToUrl("image", el.file, el.name || "image.jpg");
2570
- lines.push(`![${el.name || "图片"} #${width}px #${height}px](${url})`);
2571
- } catch {
2572
- lines.push(`![${el.name || "图片"} #300px #300px]()`);
3487
+ else {
3488
+ /**
3489
+ * 必须带上 selfId / purpose / origin:内置图床按这三个字段决定
3490
+ * 是否接手,缺任何一个都会直接放行给后续处理器。频道场景没有
3491
+ * 可用的 openid,origin 只能是 undefined。
3492
+ */
3493
+ const origin = contact.scene === "group" ? {
3494
+ scene: "group",
3495
+ peer: contact.peer
3496
+ } : contact.scene === "friend" ? {
3497
+ scene: "user",
3498
+ peer: contact.peer
3499
+ } : void 0;
3500
+ try {
3501
+ const result = await fileToUrl("image", imageSource, el.name || "image.jpg", {
3502
+ selfId: this.selfId,
3503
+ purpose: "markdown",
3504
+ origin
3505
+ });
3506
+ if (result?.url) lines.push(`![${el.name || "图片"} #${result.width || 300}px #${result.height || 300}px](${result.url})`);
3507
+ else
3508
+ /** 没有处理器接手时不能留空 URL 的图片语法(客户端渲染为空白),改走富媒体补发 */
3509
+ mediaElements.push(el);
3510
+ } catch {
3511
+ mediaElements.push(el);
3512
+ }
2573
3513
  }
2574
3514
  break;
3515
+ }
2575
3516
  case "at":
2576
3517
  if (contact.scene === "friend" || contact.scene === "direct") break;
2577
3518
  if (el.targetId === "all") lines.push("<qqbot-at-everyone />");
@@ -2729,20 +3670,31 @@ var AdapterQQBot = class extends AdapterBase {
2729
3670
  }
2730
3671
  /**
2731
3672
  * 禁言群成员
3673
+ *
3674
+ * 机器人需拥有群管理员身份;平台限制最大禁言时长为 30 天,
3675
+ * 超出部分会被截断到 30 天而不是直接失败。
3676
+ *
3677
+ * @param _groupId 群 openid
3678
+ * @param _targetId 群成员 openid
3679
+ * @param duration 禁言时长(秒),传 0 或负数表示解除禁言
2732
3680
  */
2733
3681
  async setGroupMute(_groupId, _targetId, duration) {
2734
3682
  let rfc3339 = "";
2735
3683
  if (duration > 0) {
2736
- const time = Date.now() + duration * 1e3;
2737
- rfc3339 = new Date(time).toISOString();
3684
+ let seconds = duration;
3685
+ if (seconds > 2592e3) {
3686
+ this.logger("warn", `[setGroupMute] 禁言时长 ${seconds}s 超出平台上限 30 天,已截断为 ${MAX_GROUP_MUTE_SECONDS}s`);
3687
+ seconds = MAX_GROUP_MUTE_SECONDS;
3688
+ }
3689
+ rfc3339 = new Date(Date.now() + seconds * 1e3).toISOString();
2738
3690
  }
2739
- this.super.groups.setGroupMetu(_groupId, _targetId, rfc3339 ? "add" : "del", rfc3339);
3691
+ await this.super.groups.setGroupMute(_groupId, _targetId, rfc3339 ? "add" : "del", rfc3339);
2740
3692
  }
2741
3693
  /**
2742
3694
  * 群全员禁言
2743
3695
  */
2744
3696
  async setGroupAllMute(_groupId, _isBan) {
2745
- this.logger("warn", "[setGroupAllMute] QQ Official Bot API 暂不支持群全员禁言");
3697
+ this.logger("warn", "[setGroupAllMute] QQ Official Bot API 暂不支持设置群全员禁言,仅可通过 bot.super.groups.getGroupMuteSetting() 查询 global_rule");
2746
3698
  }
2747
3699
  /**
2748
3700
  * 设置群管理员
@@ -2794,8 +3746,11 @@ var AdapterQQBot = class extends AdapterBase {
2794
3746
  /**
2795
3747
  * 获取群信息
2796
3748
  *
2797
- * 走官方 `GET /v2/groups/{group_openid}/info`(白名单接口)。
2798
- * 未开通权限(11253)或查询失败时降级为空结构并告警。
3749
+ * QQ 群走官方 `GET /v2/groups/{group_openid}/info`(白名单接口);传入的是频道
3750
+ * ID 时退回 `GET /guilds/{guild_id}`,这样频道场景下的插件调用也能拿到名称和
3751
+ * 人数。两者都不可用(如白名单未开通返回 11253)时降级为空结构并告警。
3752
+ *
3753
+ * @param _groupId 群 openid 或频道 ID。
2799
3754
  */
2800
3755
  async getGroupInfo(_groupId, _noCache) {
2801
3756
  const empty = {
@@ -2819,15 +3774,28 @@ var AdapterQQBot = class extends AdapterBase {
2819
3774
  avatar: await this.getGroupAvatarUrl(_groupId)
2820
3775
  };
2821
3776
  } catch (err) {
3777
+ const guild = await this.super.guilds.getGuild(_groupId).catch(() => null);
3778
+ if (guild) return {
3779
+ ...empty,
3780
+ groupName: guild.name || "",
3781
+ owner: guild.owner_id || "",
3782
+ memberCount: guild.member_count ?? 0,
3783
+ maxMemberCount: guild.max_members ?? 0,
3784
+ groupDesc: guild.description || "",
3785
+ avatar: guild.icon || ""
3786
+ };
2822
3787
  this.logger("warn", `[getGroupInfo] 获取群信息失败: ${err instanceof Error ? err.message : err}`);
2823
3788
  return empty;
2824
3789
  }
2825
3790
  }
2826
3791
  /**
2827
3792
  * 获取群列表
3793
+ *
3794
+ * 官方没有 QQ 群列表接口。频道列表可用 `bot.super.guilds.getGuildList()` 获取,
3795
+ * 但频道在 Karin 里属于 guild 场景,与 QQ 群不是同一类会话,因此不在此处混用。
2828
3796
  */
2829
3797
  async getGroupList(_refresh) {
2830
- this.logger("warn", "[getGroupList] QQ Official Bot API 暂不支持获取群列表");
3798
+ this.logger("warn", "[getGroupList] QQ Official Bot API 暂不支持获取群列表,频道列表请用 bot.super.guilds.getGuildList()");
2831
3799
  return [];
2832
3800
  }
2833
3801
  /**
@@ -2891,16 +3859,46 @@ var AdapterQQBot = class extends AdapterBase {
2891
3859
  this.logger("warn", "[setFriendApplyResult] QQ Official Bot API 暂不支持设置好友请求结果");
2892
3860
  }
2893
3861
  /**
2894
- * 设置申请加入群请求结果
3862
+ * 审批入群申请。
3863
+ *
3864
+ * 走官方 `POST /v2/groups/{group_openid}/approval_join_request/{member_openid}`,
3865
+ * 机器人需拥有群管理员身份。Karin 只透传 flag(`join_request_id`),群与申请人
3866
+ * openid 由 `join-request-cache` 在收到申请事件时记录,因此仅支持审批本次运行
3867
+ * 期间收到过的申请;历史申请请改用
3868
+ * `bot.super.groups.getJoinRequestList()` + `bot.super.groups.approvalJoinRequest()`。
3869
+ *
3870
+ * @param requestId 申请 ID(Karin 请求事件的 flag)。
3871
+ * @param isApprove 是否通过。
3872
+ * @param denyReason 拒绝理由,仅拒绝时生效。
2895
3873
  */
2896
- async setGroupApplyResult(_requestId, _isApprove, _denyReason) {
2897
- this.logger("warn", "[setGroupApplyResult] QQ Official Bot API 暂不支持设置群申请结果");
3874
+ async setGroupApplyResult(requestId, isApprove, denyReason) {
3875
+ const cached = getJoinRequest(this, requestId);
3876
+ if (!cached) {
3877
+ this.logger("warn", `[setGroupApplyResult] 未找到入群申请索引,无法还原群/申请人 openid: ${requestId || "(empty)"}`);
3878
+ return;
3879
+ }
3880
+ try {
3881
+ await this.super.groups.approvalJoinRequest(cached.groupId, cached.memberId, {
3882
+ op: isApprove ? "approve" : "decline",
3883
+ join_request_id: requestId,
3884
+ ...isApprove ? {} : { reject_reason: denyReason }
3885
+ });
3886
+ removeJoinRequest(this, requestId);
3887
+ } catch (err) {
3888
+ this.logger("error", "[setGroupApplyResult] 审批入群申请失败:", err);
3889
+ }
2898
3890
  }
2899
3891
  /**
2900
- * 设置邀请加入群请求结果
3892
+ * 设置邀请加入群请求结果。
3893
+ *
3894
+ * 机器人被邀请入群与用户主动申请入群在官方侧是同一条 `GROUP_JOIN_REQUEST`
3895
+ * 事件,审批走同一个接口,因此直接复用 {@link AdapterQQBot.setGroupApplyResult}。
3896
+ *
3897
+ * @param requestId 申请 ID(Karin 请求事件的 flag)。
3898
+ * @param isApprove 是否通过。
2901
3899
  */
2902
- async setInvitedJoinGroupResult(_requestId, _isApprove) {
2903
- this.logger("warn", "[setInvitedJoinGroupResult] QQ Official Bot API 暂不支持设置邀请入群结果");
3900
+ async setInvitedJoinGroupResult(requestId, isApprove) {
3901
+ await this.setGroupApplyResult(requestId, isApprove);
2904
3902
  }
2905
3903
  /**
2906
3904
  * 设置消息表情回应
@@ -3043,10 +4041,30 @@ var AdapterQQBot = class extends AdapterBase {
3043
4041
  }
3044
4042
  /**
3045
4043
  * 获取群被禁言用户列表
4044
+ *
4045
+ * 走官方 `GET /v2/groups/{group_openid}/restrict_chat_setting`,只返回仍在
4046
+ * 禁言中的成员(不含已过期)。机器人需拥有群管理员身份,无权限或查询失败时
4047
+ * 降级为空数组并告警。
4048
+ *
4049
+ * `muteTime` 为禁言到期时间的 Unix 秒,而非剩余时长;全员禁言规则不在该返回
4050
+ * 结构内,需要时请用 `bot.super.groups.getGroupMuteSetting()` 读取 `global_rule`。
4051
+ *
4052
+ * @param _groupId 群 openid
3046
4053
  */
3047
4054
  async getGroupMuteList(_groupId) {
3048
- this.logger("warn", "[getGroupMuteList] QQ Official Bot API 暂不支持获取群被禁言用户列表");
3049
- return [];
4055
+ try {
4056
+ return ((await this.super.groups.getGroupMuteSetting(_groupId)).members ?? []).map((member) => {
4057
+ if (member.username) this.nicknameCache.set(member.member_openid, member.username);
4058
+ const expireAt = member.mute_expire_at ? Date.parse(member.mute_expire_at) : NaN;
4059
+ return {
4060
+ userId: member.member_openid,
4061
+ muteTime: Number.isNaN(expireAt) ? 0 : Math.floor(expireAt / 1e3)
4062
+ };
4063
+ });
4064
+ } catch (err) {
4065
+ this.logger("warn", `[getGroupMuteList] 获取群禁言列表失败(需机器人具备群管理员身份): ${err instanceof Error ? err.message : err}`);
4066
+ return [];
4067
+ }
3050
4068
  }
3051
4069
  /**
3052
4070
  * 戳一戳用户
@@ -3768,6 +4786,12 @@ const onGroupJoinRequest = (client, event) => {
3768
4786
  const contact = karin$1.contactGroup(event.d.group_openid);
3769
4787
  const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
3770
4788
  const time = Date.parse(event.d.apply_at);
4789
+ /** 审批接口需要群 / 申请人 openid,而 Karin 只透传 join_request_id */
4790
+ cacheJoinRequest(client, event.d.join_request_id, {
4791
+ groupId: event.d.group_openid,
4792
+ memberId: event.d.member_openid
4793
+ });
4794
+ if (event.d.username) client.nicknameCache.set(event.d.member_openid, event.d.username);
3771
4795
  if (event.d.member_openid === client.selfId) createGroupInviteRequest({
3772
4796
  bot: client,
3773
4797
  time,
@@ -3813,12 +4837,16 @@ const onGroupJoinRequest = (client, event) => {
3813
4837
  * 取得互动事件真正用于 ACK 的平台 ID。
3814
4838
  *
3815
4839
  * WebSocket Dispatch 外层也有一个 `id`,形如 `INTERACTION_CREATE:uuid`;按钮 ACK
3816
- * 接口需要的是事件体内的 `d.id`,也就是不带事件名前缀的 uuid。
4840
+ * 接口需要的是事件体内的 `d.id`,也就是不带事件名前缀的 uuid。缺少 `d.id` 时
4841
+ * 退回外层 `id`,并按官方要求剥掉 `INTERACTION_CREATE:` 前缀。
3817
4842
  *
3818
4843
  * @param ev QQBot 原始互动事件。
3819
4844
  * @returns 可传给 `/interactions/{interaction_id}` 的 ID。
3820
4845
  */
3821
- const getInteractionId = (ev) => ev.d.id || ev.id;
4846
+ const getInteractionId = (ev) => {
4847
+ if (ev.d.id) return ev.d.id;
4848
+ return ev.id.replace(/^INTERACTION_CREATE:/, "");
4849
+ };
3822
4850
  /**
3823
4851
  * 构造按钮回调附加给 Karin 消息的元信息。
3824
4852
  *
@@ -4723,7 +5751,7 @@ const createBot = async (input) => {
4723
5751
  client.account.name = me.username;
4724
5752
  client.account.avatar = me.avatar;
4725
5753
  client.account.selfId = appId;
4726
- const qq = new URL(me.share_url).searchParams.get("robot_uin");
5754
+ const qq = new URL$1(me.share_url).searchParams.get("robot_uin");
4727
5755
  client.account.subId.id = me.id;
4728
5756
  client.account.subId.qq = qq;
4729
5757
  client.account.subId.appid = appId;
@@ -4753,13 +5781,14 @@ const createBot = async (input) => {
4753
5781
  return promise;
4754
5782
  };
4755
5783
  /**
4756
- * 销毁 bot:注销 + 关 WS + 取消监听 + 停止 token 刷新
5784
+ * 销毁 bot:注销 + 关 WS + 取消监听 + 停止 token 刷新 + 清理入群申请索引
4757
5785
  */
4758
5786
  const destroyBot = (appId) => {
4759
5787
  unregisterBot("selfId", appId);
4760
5788
  stop(appId);
4761
5789
  offAll(appId);
4762
5790
  stopTokenRefresh(appId);
5791
+ clearJoinRequests(appId);
4763
5792
  };
4764
5793
  bindHandlers(createBot, destroyBot);
4765
5794
 
@@ -4909,4 +5938,4 @@ const bootstrap = async () => {
4909
5938
  bootstrap();
4910
5939
 
4911
5940
  //#endregion
4912
- export { };
5941
+ export { Http, QQBotApi, RequestApi };