@karinjs/adapter-qqbot 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-D1_LqrcJ.js";
1
+ import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-BgCU6apn.js";
2
2
  import { t as log } from "./logger-sza6kuy3.js";
3
- import { 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";
5
- import { t as getMessageStore } from "./message-CVJVFAGR.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, s as QQ_NUMBER_RE, t as fakeEvent } from "./common-DwVsAvIo.js";
4
+ import "./utils-u17tvC2U.js";
5
+ import { t as getMessageStore } from "./message-CydR-wrX.js";
6
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
7
  import { URL as URL$1 } from "url";
8
8
  import lodash from "node-karin/lodash";
@@ -1460,10 +1460,12 @@ var MetaApi = class extends Http {
1460
1460
  //#endregion
1461
1461
  //#region src/core/api/groups.ts
1462
1462
  /**
1463
- * QQ 群信息接口
1463
+ * QQ 群信息与群成员管理接口
1464
1464
  *
1465
1465
  * 注意:以下接口均为白名单接口,未开通权限时平台返回错误码 11253,
1466
- * 需要向平台运营申请后可用。
1466
+ * 需要向平台运营申请后可用。接口文档 v1.28.0(2026-09-03)新增的群成员管理
1467
+ * 接口(成员列表 / 批量移除 / 黑名单)官方标注为「正在内邀接入中」,
1468
+ * 开放范围可能比其它白名单接口更窄。
1467
1469
  */
1468
1470
  var GroupsApi = class extends Http {
1469
1471
  /**
@@ -1481,8 +1483,25 @@ var GroupsApi = class extends Http {
1481
1483
  return this.get(`/v2/groups/${groupOpenid}/bot_state`);
1482
1484
  }
1483
1485
  /**
1486
+ * 获取群成员列表
1487
+ *
1488
+ * 每次最多返回 30 条,只支持 cursor 翻页:首次不传 `cursor`,之后回传上一次
1489
+ * 响应的 `next_cursor`,`next_cursor` 为空串表示已到末页。
1490
+ *
1491
+ * @param groupOpenid 群 openid
1492
+ * @param params 分页参数
1493
+ */
1494
+ getGroupMemberList(groupOpenid, params = {}) {
1495
+ const query = new URLSearchParams();
1496
+ if (params.cursor) query.set("cursor", params.cursor);
1497
+ const search = query.toString();
1498
+ return this.get(`/v2/groups/${groupOpenid}/members${search ? `?${search}` : ""}`);
1499
+ }
1500
+ /**
1484
1501
  * 获取群成员详情
1485
1502
  *
1503
+ * 昵称字段为 `username`;`nick` 是该接口转为公开文档之前的字段名,仅作兼容。
1504
+ *
1486
1505
  * @param groupOpenid 群 openid
1487
1506
  * @param memberOpenid 群成员 openid
1488
1507
  */
@@ -1490,9 +1509,48 @@ var GroupsApi = class extends Http {
1490
1509
  return this.get(`/v2/groups/${groupOpenid}/members/${memberOpenid}`);
1491
1510
  }
1492
1511
  /**
1512
+ * 群成员批量移除
1513
+ *
1514
+ * 单次最多 20 个成员,可通过 `add_to_member_blacklist` 在移除的同时拉黑。
1515
+ * 机器人需拥有群管理员身份。
1516
+ *
1517
+ * @param groupOpenid 群 openid
1518
+ * @param body 待移除的成员列表与是否同时拉黑
1519
+ */
1520
+ batchRemoveGroupMembers(groupOpenid, body) {
1521
+ return this.post(`/v2/groups/${groupOpenid}/batch_remove_members`, body);
1522
+ }
1523
+ /**
1524
+ * 查询群黑名单
1525
+ *
1526
+ * @param groupOpenid 群 openid
1527
+ * @param params 分页参数,`limit` 默认 20、最大 100
1528
+ */
1529
+ getGroupMemberBlacklist(groupOpenid, params = {}) {
1530
+ const query = new URLSearchParams();
1531
+ if (params.cursor) query.set("cursor", params.cursor);
1532
+ if (params.limit !== void 0) query.set("limit", String(params.limit));
1533
+ const search = query.toString();
1534
+ return this.get(`/v2/groups/${groupOpenid}/member_blacklist${search ? `?${search}` : ""}`);
1535
+ }
1536
+ /**
1537
+ * 群黑名单操作
1538
+ *
1539
+ * 只有目标用户不在群中时才能加入黑名单,需要先移除再拉黑;单次最多 20 个。
1540
+ * 返回的 `fail_openids` 在 `op=add` / `op=del` 下同义,都是操作失败的 openid。
1541
+ *
1542
+ * @param groupOpenid 群 openid
1543
+ * @param body 操作类型与目标成员列表
1544
+ */
1545
+ setGroupMemberBlacklist(groupOpenid, body) {
1546
+ return this.post(`/v2/groups/${groupOpenid}/member_blacklist`, body);
1547
+ }
1548
+ /**
1493
1549
  * 设置群成员禁言
1494
1550
  *
1495
- * 机器人需拥有群管理员身份,平台限制最大禁言时长为 30 天。
1551
+ * 机器人需拥有群管理员身份,平台限制最大禁言时长为 30 天;
1552
+ * 只能操作普通成员,不能操作群主、管理员与机器人。
1553
+ * 需要一次设置多个成员时用 {@link GroupsApi.setGroupMuteBatch}。
1496
1554
  *
1497
1555
  * @param groupOpenid 群 openid
1498
1556
  * @param memberOpenid 群成员 openid
@@ -1500,11 +1558,24 @@ var GroupsApi = class extends Http {
1500
1558
  * @param duration 禁言时间使用RFC3339 格式
1501
1559
  */
1502
1560
  setGroupMute(groupOpenid, memberOpenid, op, duration) {
1503
- return this.post(`/v2/groups/${groupOpenid}/restrict_chat_setting`, { members: [{
1561
+ return this.setGroupMuteBatch(groupOpenid, [{
1504
1562
  op,
1505
1563
  member_openid: memberOpenid,
1506
1564
  mute_expire_at: duration
1507
- }] });
1565
+ }]);
1566
+ }
1567
+ /**
1568
+ * 批量设置群成员禁言
1569
+ *
1570
+ * 每项通过自身的 `op` 控制增 / 改 / 删,可以在一次请求里混用。
1571
+ * 平台限制单次最多 20 个成员(接口文档 v1.28.0 由 10 提升至 20),
1572
+ * 超出时平台会直接拒绝整个请求。
1573
+ *
1574
+ * @param groupOpenid 群 openid
1575
+ * @param members 成员禁言设置列表
1576
+ */
1577
+ setGroupMuteBatch(groupOpenid, members) {
1578
+ return this.post(`/v2/groups/${groupOpenid}/restrict_chat_setting`, { members });
1508
1579
  }
1509
1580
  /**
1510
1581
  * 设置群成员禁言
@@ -1530,12 +1601,13 @@ var GroupsApi = class extends Http {
1530
1601
  * 机器人需拥有群管理员身份。
1531
1602
  *
1532
1603
  * @param groupOpenid 群 openid
1533
- * @param params 分页参数,`limit` 默认 20、最大 100
1604
+ * @param params 分页参数,`limit` 默认 20、最大 50(接口文档 v1.28.0 由 100 下调),
1605
+ * 传入更大的值会被收敛到 50,避免平台直接拒绝
1534
1606
  */
1535
1607
  getJoinRequestList(groupOpenid, params = {}) {
1536
1608
  const query = new URLSearchParams();
1537
1609
  if (params.cursor) query.set("cursor", params.cursor);
1538
- if (params.limit !== void 0) query.set("limit", String(params.limit));
1610
+ if (params.limit !== void 0) query.set("limit", String(Math.min(params.limit, 50)));
1539
1611
  const search = query.toString();
1540
1612
  return this.get(`/v2/groups/${groupOpenid}/join_request_list${search ? `?${search}` : ""}`);
1541
1613
  }
@@ -1758,12 +1830,13 @@ var JoinApprovalApi = class extends Http {
1758
1830
  * 查询入群自动审批策略列表
1759
1831
  *
1760
1832
  * 按创建时间倒序,支持分页。
1761
- * @param params 分页参数,`limit` 默认 20、最大 100
1833
+ * @param params 分页参数,`limit` 默认 20、最大 50(接口文档 v1.28.0 由 100 下调),
1834
+ * 传入更大的值会被收敛到 50,避免平台直接拒绝
1762
1835
  */
1763
1836
  getStrategyList(params = {}) {
1764
1837
  const query = new URLSearchParams();
1765
1838
  if (params.cursor) query.set("cursor", params.cursor);
1766
- if (params.limit !== void 0) query.set("limit", String(params.limit));
1839
+ if (params.limit !== void 0) query.set("limit", String(Math.min(params.limit, 50)));
1767
1840
  const search = query.toString();
1768
1841
  return this.get(`/v2/groups/join_approval_strategy${search ? `?${search}` : ""}`);
1769
1842
  }
@@ -1903,7 +1976,7 @@ const buildGuildMsg = {
1903
1976
  * - media:富媒体上传
1904
1977
  * - interaction:按钮回调 ack
1905
1978
  * - meta:@me / dms / gateway / 分享链接
1906
- * - groups:群基础信息 / 机器人群内状态 / 禁言 / 入群申请(部分为白名单接口)
1979
+ * - groups:群基础信息 / 机器人群内状态 / 群成员列表与详情 / 禁言 / 批量移除 / 群黑名单 / 入群申请(白名单接口)
1907
1980
  * - guilds:频道与子频道的查询和增删改
1908
1981
  * - menu:全局自定义菜单(仅单聊)
1909
1982
  * - panels:指令面板
@@ -2093,6 +2166,9 @@ const collectCommandEnterButtons = (buttons, keyboards) => {
2093
2166
  /**
2094
2167
  * 归一 QQ keyboard 按钮字段。
2095
2168
  *
2169
+ * 只重写键盘内唯一的按钮序号,其余字段(含 v1.28.0 新增的 `group_id`、
2170
+ * `action.modal`)原样透传。
2171
+ *
2096
2172
  * @param button node-karin 转换后的 QQBot 按钮。
2097
2173
  * @param id 当前 keyboard 内唯一按钮序号。
2098
2174
  * @returns 可直接放入 QQ 官方 keyboard 的按钮对象。
@@ -2311,9 +2387,16 @@ const resolvePreferredMediaSource = async (ctx, type, source, filename, origin,
2311
2387
  via: "fallback"
2312
2388
  };
2313
2389
  }
2390
+ /** 只有 image 会带宽高,其余类型按声明就没有这两个字段。 */
2391
+ const { width, height } = result;
2392
+ const size = typeof width === "number" && typeof height === "number" ? {
2393
+ width,
2394
+ height
2395
+ } : void 0;
2314
2396
  return {
2315
2397
  source: result.url,
2316
- via: "file-to-url"
2398
+ via: "file-to-url",
2399
+ size
2317
2400
  };
2318
2401
  } catch (err) {
2319
2402
  ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
@@ -2375,6 +2458,24 @@ const buildMarkdownImageLine = async (url, alt = "", size) => {
2375
2458
  return `![${desc} #${resolved.width}px #${resolved.height}px](${url})`;
2376
2459
  };
2377
2460
  /**
2461
+ * 生成一行"来源未解析"的 markdown 图片。
2462
+ *
2463
+ * 用于适配器自己拼 markdown 正文的场景(如合并转发):只把原始来源写进图片语法,
2464
+ * 具体走内置图床、第三方 fileToUrl 还是降级成富媒体,留给发送 pipeline 在
2465
+ * `splitMarkdownImages` 之后统一决定,避免在 pipeline 之外重复一套上传逻辑。
2466
+ *
2467
+ * 本地路径可能带空格、`)` 等会截断图片语法的字符,因此来源统一用尖括号包住——
2468
+ * `splitMarkdownImages` 取出时会剥掉。
2469
+ *
2470
+ * @param source 原始来源:本地路径、`base64://` 或 URL。
2471
+ * @param alt 图片描述;`]` 与换行会截断语法,统一压成空格。
2472
+ * @param size 已知的真实宽高,写成显式尺寸可省掉 pipeline 的一次探测。
2473
+ * @returns markdown 图片行,来源仍是原始 file 字段。
2474
+ */
2475
+ const buildPendingMarkdownImageLine = (source, alt = "", size) => {
2476
+ return `![${alt.replace(/[[\]\r\n]/g, " ").trim() || "karin"}${size?.width && size?.height ? ` #${size.width}px #${size.height}px` : ""}](<${normalizeMediaSource(source)}>)`;
2477
+ };
2478
+ /**
2378
2479
  * 将图片列表合并到一段 markdown 文本内(每张图占一行)
2379
2480
  *
2380
2481
  * @param urls 图片来源列表。
@@ -2447,21 +2548,31 @@ const splitMarkdownImages = (markdown) => {
2447
2548
  //#region src/core/adapter/pipeline-qq.ts
2448
2549
  /**
2449
2550
  * 处理 QQ 场景(单聊 + 群聊)的发送
2551
+ *
2552
+ * QQ 单聊 / 群聊的自定义 Markdown 自 2026-04-23 起已开放给所有机器人,无需申请
2553
+ * (见 `api-docs/server-inter/message/type/markdown.md`),所以这里只保留一条发送通道:
2554
+ * text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
2555
+ * 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 紧随主消息补发。所有
2556
+ * 富媒体都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
2557
+ * 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
2450
2558
  */
2451
2559
  const sendQQ = async (ctx, contact, elements) => {
2560
+ assertOpenIdPeer(contact);
2452
2561
  const target = contact.scene === "friend" ? "user" : "group";
2453
2562
  const grouping = groupElements(contact.scene, elements);
2454
2563
  await resolveOutgoingReferenceQQ(ctx, contact, grouping);
2455
- /**
2456
- * 自动 Markdown 通道:
2457
- * text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
2458
- * 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 单独补发。所有富媒体
2459
- * 都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
2460
- * 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
2461
- * 显式 segment.markdown 不受该自动通道开关影响。
2462
- */
2463
- if (ctx.cfg.markdown.enable) return sendQQMarkdown(ctx, contact, grouping, target);
2464
- return sendQQClassic(ctx, contact, grouping, target);
2564
+ return sendQQMarkdown(ctx, contact, grouping, target);
2565
+ };
2566
+ /**
2567
+ * QQ 官方接口只接受 openid(32 位十六进制或 UUID 形态)寻址。纯数字 peer 一定是
2568
+ * QQ 号/群号,继续走下去只会在媒体上传或消息下发时被平台以 40011028
2569
+ * 「请求的资源不存在」拒绝,且平台错误信息没有指向性(上游常见场景:把 karin
2570
+ * 主人列表里的 QQ 号直接当私聊目标)。这里提前拦截,给出可操作的说明。
2571
+ */
2572
+ const assertOpenIdPeer = (contact) => {
2573
+ if (!QQ_NUMBER_RE.test(contact.peer)) return;
2574
+ const sceneLabel = contact.scene === "friend" ? "私聊" : "群聊";
2575
+ throw new Error(`[sendQQ] ${sceneLabel} peer 不是有效 openid: ${contact.peer}。QQ 官方 Bot API 不使用 QQ 号/群号寻址,请让对方先给机器人发一条消息,从事件中获取其 openid 后填入配置`);
2465
2576
  };
2466
2577
  /**
2467
2578
  * Markdown 通道:合成一条 msg_type=2 + 可选 keyboard
@@ -2500,46 +2611,6 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
2500
2611
  return flushQQ(ctx, contact, grouping, items);
2501
2612
  };
2502
2613
  /**
2503
- * 经典通道:文本走 msg_type=0,图片 / 富媒体走 msg_type=7。
2504
- */
2505
- const sendQQClassic = async (ctx, contact, grouping, target) => {
2506
- const items = [];
2507
- const lines = [];
2508
- const images = [...grouping.qqImages];
2509
- if (grouping.text.length) lines.push(grouping.text.join(""));
2510
- if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
2511
- const content = lines.join("\n").trim();
2512
- if (content) items.push(ctx.super.qq.text(content));
2513
- for (const image of images) items.push(await buildQQMediaItem(ctx, target, contact.peer, "image", image));
2514
- for (const m of grouping.media) items.push(await buildQQMediaItem(ctx, target, contact.peer, m.kind, m.source, m.name));
2515
- await appendExplicitMarkdownItems(ctx, contact, grouping, target, items);
2516
- if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
2517
- return flushQQ(ctx, contact, grouping, items);
2518
- };
2519
- /**
2520
- * 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
2521
- */
2522
- const appendExplicitMarkdownItems = async (ctx, contact, grouping, target, items) => {
2523
- if (!grouping.markdowns.length) return;
2524
- const lines = [];
2525
- const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping, {
2526
- scene: target,
2527
- peer: contact.peer
2528
- });
2529
- if (contact.scene === "group") {
2530
- warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2531
- warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
2532
- }
2533
- const mediaItems = [];
2534
- for (const file of fallbackImages) {
2535
- const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
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
2614
  * 处理「没有任何 markdown 正文,但存在按钮」的边缘情况。
2544
2615
  *
2545
2616
  * 典型触发路径:下游只发一张 markdown 语法图片 + 若干按钮,而这张图片没能通过
@@ -2566,14 +2637,6 @@ const attachKeyboardWithoutMarkdown = (ctx, grouping, mediaItems, items) => {
2566
2637
  first.keyboard = keyboard;
2567
2638
  ctx.logger("debug", "图片未能进入 markdown 通道,按钮已随富媒体一起发送,避免拆成两条消息");
2568
2639
  };
2569
- const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
2570
- const resolved = await resolvePreferredMediaSource(ctx, type, source, name, {
2571
- scene: target,
2572
- peer
2573
- });
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);
2575
- return ctx.super.qq.media(res.file_info);
2576
- };
2577
2640
  /**
2578
2641
  * 将能转成公网 URL 的图片追加到 markdown 行,无法转换的图片返回给富媒体 fallback。
2579
2642
  *
@@ -2591,7 +2654,7 @@ const appendMarkdownImages = async (ctx, lines, files, origin) => {
2591
2654
  fallback.push(file);
2592
2655
  continue;
2593
2656
  }
2594
- await appendImageUrlLine(lines, resolved.source);
2657
+ lines.push(await buildMarkdownImageLine(resolved.source, "", resolved.size));
2595
2658
  }
2596
2659
  return fallback;
2597
2660
  };
@@ -2618,21 +2681,11 @@ const appendExplicitMarkdown = async (ctx, lines, grouping, origin) => {
2618
2681
  continue;
2619
2682
  }
2620
2683
  /** 保留开发者写下的 alt 与显式尺寸,只替换图片来源 */
2621
- lines.push(await buildMarkdownImageLine(resolved.source, part.alt));
2684
+ lines.push(await buildMarkdownImageLine(resolved.source, part.alt, resolved.size));
2622
2685
  }
2623
2686
  return fallback;
2624
2687
  };
2625
2688
  /**
2626
- * 生成单张 markdown 图片行。
2627
- * 尺寸获取失败时使用保守默认尺寸,保证 fileToUrl 成功的图片仍走 markdown。
2628
- *
2629
- * @param lines markdown 行数组。
2630
- * @param url 图片 URL。
2631
- */
2632
- const appendImageUrlLine = async (lines, url) => {
2633
- lines.push(await buildMarkdownImageLine(url));
2634
- };
2635
- /**
2636
2689
  * 判断显式引用回复是否应降级为普通文本消息。
2637
2690
  *
2638
2691
  * QQ 的 `message_reference` 对普通文本最稳定;markdown 带引用在客户端渲染上存在兼容问题。
@@ -2850,18 +2903,95 @@ const buildPassiveQQ = (ctx, scene, grouping) => {
2850
2903
  };
2851
2904
  };
2852
2905
 
2906
+ //#endregion
2907
+ //#region src/core/adapter/guild-markdown.ts
2908
+ /**
2909
+ * 频道 Markdown 能力探测。
2910
+ *
2911
+ * 官方能力说明(`api-docs/server-inter/message/type/markdown.md`):2026-04-23 起
2912
+ * 单聊 / 群聊的自定义 Markdown 已开放到所有机器人,无需申请;**频道场景仍需内邀
2913
+ * 开通**。因此单聊 / 群聊直接走 Markdown,频道场景先乐观尝试,被平台以权限类
2914
+ * 错误拒绝后记住该 bot 并改走普通消息,避免之后每条消息都白跑一次请求。
2915
+ *
2916
+ * 状态只存在于当前进程,也不写配置:拿到内邀后重启(或改一次配置触发 bot 重建)
2917
+ * 即可重新尝试,用户不需要手动去点任何开关。
2918
+ */
2919
+ /** 已确认没有频道 Markdown 权限的 appId。 */
2920
+ const forbidden = /* @__PURE__ */ new Set();
2921
+ /**
2922
+ * 平台拒绝 Markdown 的权限类错误码。
2923
+ *
2924
+ * - 50056 不允许发送 markdown content;
2925
+ * - 304036 没有 markdown 模板的权限。
2926
+ */
2927
+ const FORBIDDEN_CODES = [50056, 304036];
2928
+ /**
2929
+ * 该 bot 当前是否还可以尝试频道 Markdown。
2930
+ *
2931
+ * @param selfId 机器人 appId。
2932
+ * @returns 未被标记为无权限时返回 true。
2933
+ */
2934
+ const canSendGuildMarkdown = (selfId) => !forbidden.has(selfId);
2935
+ /**
2936
+ * 标记该 bot 没有频道 Markdown 权限。
2937
+ *
2938
+ * @param selfId 机器人 appId。
2939
+ * @returns 本次是否是首次标记,供调用方只告警一次。
2940
+ */
2941
+ const disableGuildMarkdown = (selfId) => {
2942
+ if (forbidden.has(selfId)) return false;
2943
+ forbidden.add(selfId);
2944
+ return true;
2945
+ };
2946
+ /**
2947
+ * 清除该 bot 的降级标记,让下一条频道消息重新尝试 Markdown。
2948
+ *
2949
+ * bot 重建(配置变更 / 重新登录)时调用,避免内邀开通后还要重启整个 Karin。
2950
+ *
2951
+ * @param selfId 机器人 appId。
2952
+ */
2953
+ const resetGuildMarkdown = (selfId) => {
2954
+ forbidden.delete(selfId);
2955
+ };
2956
+ /**
2957
+ * 判断发送失败是否来自平台对 Markdown 权限的校验。
2958
+ *
2959
+ * Http 层把平台错误格式化成普通 Error(`[Code 50056] ...`),这里按错误码优先、
2960
+ * 中文文案兜底识别,和 pipeline 里其它降级判断保持一致的做法。
2961
+ *
2962
+ * @param err 捕获到的发送异常。
2963
+ * @returns 是否应降级为普通消息。
2964
+ */
2965
+ const isGuildMarkdownForbiddenError = (err) => {
2966
+ const message = err instanceof Error ? err.message : String(err);
2967
+ if (FORBIDDEN_CODES.some((code) => message.includes(`[Code ${code}]`))) return true;
2968
+ return message.includes("不允许发送 markdown") || message.includes("没有 markdown 模板的权限");
2969
+ };
2970
+
2853
2971
  //#endregion
2854
2972
  //#region src/core/adapter/pipeline-guild.ts
2855
2973
  /**
2856
2974
  * 处理频道场景(子频道 + 私信)的发送
2857
2975
  *
2858
- * 默认把普通文本 / at / 图片通过 markdown 渲染。关闭自动 Markdown 通道后,
2859
- * 普通文本和图片改走经典发送;显式 segment.markdown 仍按调用方指定走 Markdown。
2976
+ * 频道的自定义 Markdown 目前仍需向 QQ 申请内邀开通(见
2977
+ * `api-docs/server-inter/message/type/markdown.md`),所以这里不靠用户配置开关,
2978
+ * 而是先乐观走 Markdown:被平台以权限类错误拒绝后记住这个 bot,本次立刻降级重发,
2979
+ * 之后的频道消息直接走普通消息通道。
2860
2980
  */
2861
2981
  const sendGuild = async (ctx, contact, elements) => {
2862
2982
  const grouping = groupElements(contact.scene, elements);
2863
- if (ctx.cfg.markdown.enable) return sendGuildMarkdown(ctx, contact, grouping);
2864
- return sendGuildClassic(ctx, contact, grouping);
2983
+ if (!canSendGuildMarkdown(ctx.selfId)) return sendGuildClassic(ctx, contact, grouping);
2984
+ try {
2985
+ return await sendGuildMarkdown(ctx, contact, grouping);
2986
+ } catch (err) {
2987
+ if (!isGuildMarkdownForbiddenError(err)) throw err;
2988
+ /**
2989
+ * markdown 主消息一定是 items[0],被拒时本次还没有任何消息发出去,
2990
+ * 因此可以安全地整条重发,不会出现重复消息。
2991
+ */
2992
+ if (disableGuildMarkdown(ctx.selfId)) ctx.logger("warn", "频道 Markdown 未开通(需向 QQ 申请内邀),已降级为普通消息发送;本进程后续频道消息不再尝试 Markdown,开通后重启或改一次配置即可恢复");
2993
+ return sendGuildClassic(ctx, contact, grouping);
2994
+ }
2865
2995
  };
2866
2996
  /**
2867
2997
  * Markdown 通道
@@ -2920,53 +3050,37 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
2920
3050
  return flushGuild(ctx, contact, grouping, items);
2921
3051
  };
2922
3052
  /**
2923
- * 经典通道:文本走 content,图片按文档走 image / file_image 单独发送。
3053
+ * 降级通道:频道 Markdown 权限不可用时使用。
3054
+ *
3055
+ * 文本走 content,图片按文档走 image / file_image 单独发送。这条通道不会产出任何
3056
+ * markdown 消息体——显式 `segment.markdown` 也一样:内嵌图片被拆出来单独发送,
3057
+ * 其余部分按普通文本下发。否则同一条消息只会再被平台以同样的权限错误拒绝一次。
2924
3058
  */
2925
3059
  const sendGuildClassic = async (ctx, contact, grouping) => {
2926
3060
  const items = [];
2927
3061
  const lines = [];
2928
3062
  const images = [...grouping.guildImageUrls, ...grouping.guildImageFiles];
2929
3063
  if (grouping.text.length) lines.push(grouping.text.join(""));
2930
- if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
3064
+ /** 频道 keyboard 只能挂在 markdown 消息体上,没有 markdown 就无处附着。 */
3065
+ if (grouping.buttons.length || grouping.keyboards.length) ctx.logger("warn", "频道 Markdown 不可用,button / keyboard 无法发送,已跳过");
3066
+ if (grouping.markdowns.length) {
3067
+ /** 内容没有丢,只是渲染成纯文本,用 debug 避免每条消息都刷一条 warn。 */
3068
+ ctx.logger("debug", "[sendGuild] Markdown 不可用,segment.markdown 已按普通文本发送,内嵌图片改为单独发送");
3069
+ for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
3070
+ if (part.type === "text") {
3071
+ const text = part.value.trim();
3072
+ if (text) lines.push(text);
3073
+ continue;
3074
+ }
3075
+ images.push(part.source);
3076
+ }
3077
+ }
2931
3078
  const content = lines.join("\n").trim();
2932
3079
  if (content) items.push(ctx.super.guild.text(content));
2933
3080
  for (const image of images) items.push(await buildGuildImageItem(image));
2934
- await appendExplicitGuildMarkdownItems(ctx, contact, grouping, items);
2935
3081
  if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
2936
3082
  return flushGuild(ctx, contact, grouping, items);
2937
3083
  };
2938
- /**
2939
- * 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
2940
- */
2941
- const appendExplicitGuildMarkdownItems = async (ctx, contact, grouping, items) => {
2942
- if (!grouping.markdowns.length) return;
2943
- const extra = {
2944
- selfId: ctx.selfId,
2945
- purpose: "markdown"
2946
- };
2947
- const lines = [];
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
- }
2962
- }
2963
- warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2964
- warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
2965
- const keyboard = buildKeyboard(grouping);
2966
- const content = lines.length ? lines.join("\n") : "​";
2967
- items.push(ctx.super.guild.markdown({ content }, keyboard));
2968
- for (const file of fallbackImages) items.push(await buildGuildImageItem(file));
2969
- };
2970
3084
  const buildGuildImageItem = async (source) => {
2971
3085
  if (/^https?:\/\//i.test(source)) return {
2972
3086
  type: "image",
@@ -3078,11 +3192,12 @@ const shouldCacheSelfMessage = (ctx) => {
3078
3192
  *
3079
3193
  * 静态资源只在这里转一次 HTTP URL,之后发送 pipeline 和缓存都复用该 URL。
3080
3194
  */
3081
- const prepareSelfMessageCache = async (ctx, elements) => {
3195
+ const prepareSelfMessageCache = async (ctx, contact, elements) => {
3082
3196
  const sendElements = [];
3083
3197
  const cacheElements = [];
3198
+ const origin = resolveUploadOrigin(contact);
3084
3199
  for (const element of elements) {
3085
- const prepared = await prepareSelfCacheElement(ctx, element);
3200
+ const prepared = await prepareSelfCacheElement(ctx, element, origin);
3086
3201
  sendElements.push(prepared.send);
3087
3202
  if (prepared.cache) cacheElements.push(prepared.cache);
3088
3203
  }
@@ -3129,7 +3244,7 @@ const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
3129
3244
  /**
3130
3245
  * 转换单个自己消息元素为缓存安全形态。
3131
3246
  */
3132
- const prepareSelfCacheElement = async (ctx, element) => {
3247
+ const prepareSelfCacheElement = async (ctx, element, origin) => {
3133
3248
  switch (element.type) {
3134
3249
  case "text":
3135
3250
  case "at":
@@ -3145,7 +3260,7 @@ const prepareSelfCacheElement = async (ctx, element) => {
3145
3260
  case "image":
3146
3261
  case "video":
3147
3262
  case "record":
3148
- case "file": return prepareStaticElement(ctx, element);
3263
+ case "file": return prepareStaticElement(ctx, element, origin);
3149
3264
  default: return {
3150
3265
  send: element,
3151
3266
  cache: null
@@ -3153,9 +3268,9 @@ const prepareSelfCacheElement = async (ctx, element) => {
3153
3268
  }
3154
3269
  };
3155
3270
  /**
3156
- * 静态资源统一转 HTTP URL 后再缓存;转换失败时跳过该元素。
3271
+ * 静态资源统一转 HTTP URL 后再缓存;转换失败或没有处理器接手时跳过该元素。
3157
3272
  */
3158
- const prepareStaticElement = async (ctx, element) => {
3273
+ const prepareStaticElement = async (ctx, element, origin) => {
3159
3274
  if (element.file.startsWith("http")) {
3160
3275
  const cached = { ...element };
3161
3276
  return {
@@ -3164,54 +3279,41 @@ const prepareStaticElement = async (ctx, element) => {
3164
3279
  };
3165
3280
  }
3166
3281
  try {
3167
- switch (element.type) {
3168
- case "image": {
3169
- const res = await fileToUrl("image", element.file, element.name || "image.jpg");
3170
- const cached = {
3171
- ...element,
3172
- file: res.url,
3173
- width: res.width,
3174
- height: res.height
3175
- };
3176
- return {
3177
- send: cached,
3178
- cache: cached
3179
- };
3180
- }
3181
- case "video": {
3182
- const res = await fileToUrl("video", element.file, element.name || "video.mp4");
3183
- const cached = {
3184
- ...element,
3185
- file: res.url
3186
- };
3187
- return {
3188
- send: cached,
3189
- cache: cached
3190
- };
3191
- }
3192
- case "record": {
3193
- const res = await fileToUrl("record", element.file, element.name || "record.mp3");
3194
- const cached = {
3195
- ...element,
3196
- file: res.url
3197
- };
3198
- return {
3199
- send: cached,
3200
- cache: cached
3201
- };
3202
- }
3203
- case "file": {
3204
- const res = await fileToUrl("file", element.file, element.name || "file.bin");
3205
- const cached = {
3206
- ...element,
3207
- file: res.url
3208
- };
3209
- return {
3210
- send: cached,
3211
- cache: cached
3212
- };
3213
- }
3282
+ /**
3283
+ * 和发送链路传同一套附加信息:处理器需要知道是哪个 bot、哪个会话才能用对
3284
+ * openid。用途是 media —— 缓存要的是能长期访问的地址,内置图床给的是会过期的
3285
+ * 临时直链,按 purpose 主动放行正好。
3286
+ */
3287
+ const extra = {
3288
+ selfId: ctx.selfId,
3289
+ purpose: "media",
3290
+ origin
3291
+ };
3292
+ const res = await fileToUrl(element.type, element.file, element.name || DEFAULT_FILENAME[element.type], extra);
3293
+ /**
3294
+ * 链路上的处理器全部放行时 fileToUrl 实际返回 undefined(类型声明未涵盖
3295
+ * 这种情况),不属于异常:发送继续使用原始来源,仅跳过该元素的缓存。
3296
+ */
3297
+ if (!res?.url) {
3298
+ ctx.logger("debug", `[getMsg] 没有 fileToUrl 处理器接手 ${element.type},跳过该元素的缓存`);
3299
+ return {
3300
+ send: element,
3301
+ cache: null
3302
+ };
3214
3303
  }
3304
+ const cached = {
3305
+ ...element,
3306
+ file: res.url
3307
+ };
3308
+ if (cached.type === "image") {
3309
+ const { width, height } = res;
3310
+ cached.width = width;
3311
+ cached.height = height;
3312
+ }
3313
+ return {
3314
+ send: cached,
3315
+ cache: cached
3316
+ };
3215
3317
  } catch (err) {
3216
3318
  const message = err instanceof Error ? err.message : String(err);
3217
3319
  ctx.logger("debug", `[getMsg] 跳过自己消息资源缓存: ${element.type} ${message}`);
@@ -3222,6 +3324,25 @@ const prepareStaticElement = async (ctx, element) => {
3222
3324
  }
3223
3325
  };
3224
3326
  /**
3327
+ * 解析缓存用 URL 转换的归属会话。
3328
+ *
3329
+ * QQ 的 openid 按 bot 隔离,上传接口只接受该 bot 可见的 group_openid / user_openid;
3330
+ * 频道用 channel_id,没有可用 openid,返回 undefined。
3331
+ *
3332
+ * @param contact 本次发送目标。
3333
+ * @returns 可交给 fileToUrl 处理器的上传会话。
3334
+ */
3335
+ const resolveUploadOrigin = (contact) => {
3336
+ if (contact.scene === "group") return {
3337
+ scene: "group",
3338
+ peer: contact.peer
3339
+ };
3340
+ if (contact.scene === "friend") return {
3341
+ scene: "user",
3342
+ peer: contact.peer
3343
+ };
3344
+ };
3345
+ /**
3225
3346
  * 构造机器人自己的 sender。
3226
3347
  */
3227
3348
  const selfSender = (ctx, contact) => {
@@ -3339,10 +3460,37 @@ const getAdapterConfig = (bot) => {
3339
3460
  //#endregion
3340
3461
  //#region src/core/adapter/base.ts
3341
3462
  /**
3463
+ * 按 Karin 接口约定构造群成员信息。
3464
+ *
3465
+ * `sender` 用 getter 即时构建,避免整个适配器被系统接口序列化时带出多余引用。
3466
+ *
3467
+ * @param userId 成员 openid。
3468
+ * @param role 群内角色。
3469
+ * @param nick 成员昵称。
3470
+ * @param joinTime 入群时间戳(毫秒)。
3471
+ * @returns Karin 标准群成员信息。
3472
+ */
3473
+ const buildGroupMemberInfo = (userId, role, nick, joinTime) => ({
3474
+ userId,
3475
+ role,
3476
+ nick,
3477
+ joinTime,
3478
+ get sender() {
3479
+ return senderGroup({
3480
+ userId,
3481
+ nick: nick || "",
3482
+ name: nick || "",
3483
+ role
3484
+ });
3485
+ }
3486
+ });
3487
+ /**
3342
3488
  * QQ Official Bot 适配器
3343
3489
  *
3344
- * 全场景统一走 msg_type=2 Markdown 通道(详见 pipeline-qq / pipeline-guild)。
3345
- * 视频 / 语音 / 文件由 pipeline 内部以 msg_type=7 紧随主消息补发。
3490
+ * 发送通道由平台能力决定,没有对应的用户配置项:QQ 单聊 / 群聊的自定义 Markdown
3491
+ * 已对所有机器人开放,固定走 msg_type=2;频道场景仍需内邀开通,先乐观尝试
3492
+ * Markdown,被拒后自动降级为普通消息(详见 pipeline-qq / pipeline-guild 与
3493
+ * guild-markdown)。视频 / 语音 / 文件由 pipeline 内部以 msg_type=7 紧随主消息补发。
3346
3494
  */
3347
3495
  var AdapterQQBot = class extends AdapterBase {
3348
3496
  /** 与官方 API 交互 */
@@ -3383,13 +3531,13 @@ var AdapterQQBot = class extends AdapterBase {
3383
3531
  const normalizedElements = normalizeMediaElements(elements);
3384
3532
  /** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
3385
3533
  if (contact.scene === "direct" || contact.scene === "guild") {
3386
- const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
3534
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, contact, normalizedElements) : void 0;
3387
3535
  const result = await sendGuild(this, contact, prepared?.sendElements ?? normalizedElements);
3388
3536
  cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
3389
3537
  return result;
3390
3538
  }
3391
3539
  if (contact.scene === "group" || contact.scene === "friend") {
3392
- const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
3540
+ const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, contact, normalizedElements) : void 0;
3393
3541
  const result = await sendQQ(this, contact, prepared?.sendElements ?? normalizedElements);
3394
3542
  cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
3395
3543
  return result;
@@ -3458,8 +3606,16 @@ var AdapterQQBot = class extends AdapterBase {
3458
3606
  /**
3459
3607
  * 发送合并转发消息
3460
3608
  *
3461
- * 将 NodeElement 拆解为文本 + 图片等元素,拼接成 markdown 发送。
3462
- * 节点之间仅用换行隔开,不添加任何前缀提示文本。
3609
+ * QQ 官方没有合并转发能力,这里把各节点摊平成一段 Markdown 正文,节点之间用
3610
+ * 强制换行隔开,不添加任何前缀提示文本。
3611
+ *
3612
+ * 图片以 markdown 图片语法写入,**保留原始来源**(本地路径 / base64 / URL):
3613
+ * 究竟走内置图床、第三方 fileToUrl,还是降级成 msg_type=7 富媒体,由发送
3614
+ * pipeline 统一决定。这里不自己上传,也不自己判断通道——否则频道场景在没有
3615
+ * Markdown 权限时会绕过降级逻辑,直接被平台拒掉。
3616
+ *
3617
+ * 视频 / 语音 / 文件进不了 markdown,作为普通消息段跟随主消息一起交给 pipeline,
3618
+ * 由它在同一次发送里以 msg_type=7 补发。
3463
3619
  */
3464
3620
  async sendForwardMsg(contact, elements, _options) {
3465
3621
  const parts = [];
@@ -3476,43 +3632,12 @@ var AdapterQQBot = class extends AdapterBase {
3476
3632
  case "text":
3477
3633
  lines.push(el.text);
3478
3634
  break;
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})`);
3484
- } catch {
3485
- lines.push(`![${el.name || "图片"} #300px #300px](${imageSource})`);
3486
- }
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
- }
3513
- }
3635
+ case "image":
3636
+ lines.push(buildPendingMarkdownImageLine(el.file, el.name || "图片", el.width && el.height ? {
3637
+ width: el.width,
3638
+ height: el.height
3639
+ } : void 0));
3514
3640
  break;
3515
- }
3516
3641
  case "at":
3517
3642
  if (contact.scene === "friend" || contact.scene === "direct") break;
3518
3643
  if (el.targetId === "all") lines.push("<qqbot-at-everyone />");
@@ -3538,10 +3663,21 @@ var AdapterQQBot = class extends AdapterBase {
3538
3663
  if (block) parts.push(block);
3539
3664
  }
3540
3665
  const content = parts.join("\r\r\n\n");
3541
- const result = await this.sendMsg(contact, [segment.markdown(content)]);
3542
- if (mediaElements.length) await this.sendMsg(contact, mediaElements);
3666
+ /**
3667
+ * 正文可能整段为空(例如只转发了一个视频节点)。此时不能塞一条空 markdown:
3668
+ * 平台会以 50041「markdown 有空值」拒绝,pipeline 也会因为没有可发内容而
3669
+ * 回落成一条"不支持发送的消息类型"。
3670
+ */
3671
+ const sendElements = content ? [segment.markdown(content), ...mediaElements] : mediaElements;
3672
+ if (!sendElements.length) {
3673
+ this.logger("warn", "[sendForwardMsg] 转发节点里没有可发送的内容,已跳过");
3674
+ return {
3675
+ messageId: "",
3676
+ forwardId: ""
3677
+ };
3678
+ }
3543
3679
  return {
3544
- messageId: result.messageId,
3680
+ messageId: (await this.sendMsg(contact, sendElements)).messageId,
3545
3681
  forwardId: ""
3546
3682
  };
3547
3683
  }
@@ -3664,9 +3800,30 @@ var AdapterQQBot = class extends AdapterBase {
3664
3800
  }
3665
3801
  /**
3666
3802
  * 群踢人
3803
+ *
3804
+ * 走官方 `POST /v2/groups/{group_openid}/batch_remove_members`,机器人需拥有群
3805
+ * 管理员身份。Karin 的 `rejectAddRequest` 映射为官方的 `add_to_member_blacklist`:
3806
+ * 移除的同时拉入群黑名单,该用户之后无法再次入群。官方接口没有踢人理由字段,
3807
+ * `kickReason` 只会被记录到日志。
3808
+ *
3809
+ * 需要一次移除多人时用 `bot.super.groups.batchRemoveGroupMembers()`,单次最多 20 个。
3810
+ *
3811
+ * @param _groupId 群 openid
3812
+ * @param _targetId 群成员 openid
3813
+ * @param _rejectAddRequest 是否同时拉入群黑名单,默认 false
3814
+ * @param _kickReason 官方不支持,仅在传入时输出提示
3667
3815
  */
3668
3816
  async groupKickMember(_groupId, _targetId, _rejectAddRequest, _kickReason) {
3669
- this.logger("warn", "[groupKickMember] QQ Official Bot API 暂不支持群踢人");
3817
+ if (_kickReason) this.logger("warn", `[groupKickMember] QQ Official Bot API 不支持踢人理由,已忽略: ${_kickReason}`);
3818
+ try {
3819
+ const failed = (await this.super.groups.batchRemoveGroupMembers(_groupId, {
3820
+ member_openids: [_targetId],
3821
+ ..._rejectAddRequest ? { add_to_member_blacklist: true } : {}
3822
+ })).add_to_member_blacklist_fail_openids ?? [];
3823
+ if (failed.length) this.logger("warn", `[groupKickMember] 成员已移除,但加入群黑名单失败: ${failed.join("、")}`);
3824
+ } catch (err) {
3825
+ this.logger("error", "[groupKickMember] 移除群成员失败(该接口为白名单接口,未开通权限会返回 11253):", err);
3826
+ }
3670
3827
  }
3671
3828
  /**
3672
3829
  * 禁言群成员
@@ -3801,49 +3958,63 @@ var AdapterQQBot = class extends AdapterBase {
3801
3958
  /**
3802
3959
  * 获取群成员信息
3803
3960
  *
3804
- * - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`(白名单)。
3805
- * - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,
3806
- * 该接口未公开文档,来自腾讯官方 openclaw 插件 v2.0.0,返回结构以平台实际为准。
3807
- * 接口不可用(如白名单未开通返回 11253)时降级为占位结构并告警。
3961
+ * - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`。
3962
+ * - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,该接口在
3963
+ * 接口文档 v1.28.0(2026-09-03)转为公开文档,昵称字段为 `username`,`nick` 是
3964
+ * 转公开之前的字段名,仅作兼容回退。
3965
+ *
3966
+ * 两者都是白名单接口,未开通权限(11253)或查询失败时降级为占位结构并告警。
3808
3967
  */
3809
3968
  async getGroupMemberInfo(_groupId, _targetId, _refresh) {
3810
- /** 按 karin 接口约定构造返回值,sender 由 getter 即时构建 */
3811
- const build = (userId, role, nick, joinTime) => ({
3812
- userId,
3813
- role,
3814
- nick,
3815
- joinTime,
3816
- get sender() {
3817
- return senderGroup({
3818
- userId,
3819
- nick: nick || "",
3820
- name: nick || "",
3821
- role
3822
- });
3823
- }
3824
- });
3825
3969
  if (_targetId === this.selfId) try {
3826
3970
  const state = await this.super.groups.getBotState(_groupId);
3827
- return build(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
3971
+ return buildGroupMemberInfo(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
3828
3972
  } catch (err) {
3829
3973
  this.logger("warn", `[getGroupMemberInfo] 获取机器人群内状态失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
3830
- return build(_targetId, "member");
3974
+ return buildGroupMemberInfo(_targetId, "member");
3831
3975
  }
3832
3976
  try {
3833
3977
  const member = await this.super.groups.getGroupMember(_groupId, _targetId);
3834
- if (member.nick) this.nicknameCache.set(_targetId, member.nick);
3835
- return build(member.member_openid || _targetId, member.member_role || "member", member.nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
3978
+ const nick = member.username || member.nick;
3979
+ if (nick) this.nicknameCache.set(_targetId, nick);
3980
+ return buildGroupMemberInfo(member.member_openid || _targetId, member.member_role || "member", nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
3836
3981
  } catch (err) {
3837
- this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口未公开文档,可能不可用): ${err instanceof Error ? err.message : err}`);
3838
- return build(_targetId, "member");
3982
+ this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
3983
+ return buildGroupMemberInfo(_targetId, "member");
3839
3984
  }
3840
3985
  }
3841
3986
  /**
3842
3987
  * 获取群成员列表
3988
+ *
3989
+ * 走官方 `GET /v2/groups/{group_openid}/members`(白名单接口),每页最多 30 条,
3990
+ * 这里按 `next_cursor` 自动翻完所有页;顺便把昵称写入
3991
+ * {@link AdapterQQBot.nicknameCache},让后续消息事件能直接取到昵称。
3992
+ *
3993
+ * 为防止平台返回的游标不收敛,翻页数有安全上限(约 6000 人),触顶会告警并返回已取到的部分。
3994
+ * 未开通权限(11253)或查询失败时返回已取到的部分并告警。
3995
+ *
3996
+ * @param _groupId 群 openid
3843
3997
  */
3844
3998
  async getGroupMemberList(_groupId, _refresh) {
3845
- this.logger("warn", "[getGroupMemberList] QQ Official Bot API 暂不支持获取群成员列表");
3846
- return [];
3999
+ const result = [];
4000
+ let cursor = "";
4001
+ try {
4002
+ for (let page = 0; page < 200; page++) {
4003
+ const data = await this.super.groups.getGroupMemberList(_groupId, cursor ? { cursor } : {});
4004
+ for (const member of data.members ?? []) {
4005
+ if (member.username) this.nicknameCache.set(member.member_openid, member.username);
4006
+ result.push(buildGroupMemberInfo(member.member_openid, member.member_role || "member", member.username, member.joined_at ? new Date(member.joined_at).getTime() : void 0));
4007
+ }
4008
+ const next = data.next_cursor || "";
4009
+ /** 空游标表示末页;游标不前进说明平台返回异常,直接停止避免死循环 */
4010
+ if (!next || next === cursor) return result;
4011
+ cursor = next;
4012
+ }
4013
+ this.logger("warn", `[getGroupMemberList] 翻页数已达上限 ${200} 页,仅返回前 ${result.length} 名成员`);
4014
+ } catch (err) {
4015
+ this.logger("warn", `[getGroupMemberList] 获取群成员列表失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
4016
+ }
4017
+ return result;
3847
4018
  }
3848
4019
  /**
3849
4020
  * 获取群荣誉信息
@@ -5426,7 +5597,7 @@ const intentBitMap = {
5426
5597
  [2]: "GUILD_MEMBERS (频道成员变更)",
5427
5598
  [512]: "GUILD_MESSAGES (频道消息-私域)",
5428
5599
  [4096]: "DIRECT_MESSAGE (频道私信)",
5429
- [1 << 24]: "群成员进退群事件",
5600
+ [1 << 24]: "GROUP_MEMBER_EVENT (群成员进退群/入群申请)",
5430
5601
  [1 << 25]: "GROUP_AND_C2C_EVENT (群聊/单聊消息)",
5431
5602
  [1 << 26]: "INTERACTION (按钮交互回调)",
5432
5603
  [1 << 30]: "PUBLIC_GUILD_MESSAGES (频道消息-公域)"
@@ -5715,7 +5886,6 @@ const hashConfig = (bot) => JSON.stringify({
5715
5886
  appId: bot.appId,
5716
5887
  secret: bot.secret,
5717
5888
  event: bot.event,
5718
- markdown: bot.markdown,
5719
5889
  messageCache: bot.messageCache
5720
5890
  });
5721
5891
  /**
@@ -5743,6 +5913,8 @@ const createBot = async (input) => {
5743
5913
  stop(appId);
5744
5914
  offAll(appId);
5745
5915
  stopTokenRefresh(appId);
5916
+ /** 重建即重新探测频道 Markdown,拿到内邀后不必重启整个 Karin。 */
5917
+ resetGuildMarkdown(appId);
5746
5918
  logger.info(`[QQ Official Bot][${appId}] 获取 access_token...`);
5747
5919
  await getAccessToken(appId, bot.secret);
5748
5920
  const api = new QQBotApi(createAxiosInstance(appId));