@karinjs/adapter-qqbot 3.2.0 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/apps/cleanup.js +1 -1
- package/dist/apps/login.js +1 -1
- package/dist/apps/qqbot-file-to-url.js +1 -1
- package/dist/{common-OHGAoohl.js → common-DwVsAvIo.js} +6 -1
- package/dist/{config-D1_LqrcJ.js → config-BgCU6apn.js} +2 -6
- package/dist/index.d.ts +315 -28
- package/dist/index.js +457 -252
- package/dist/{message-CVJVFAGR.js → message-CydR-wrX.js} +1 -1
- package/dist/utils-u17tvC2U.js +5 -0
- package/dist/web.config.d.ts +0 -1
- package/dist/web.config.js +4 -12
- package/package.json +8 -6
- package/dist/utils-C0oZi6PR.js +0 -5
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-
|
|
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-
|
|
4
|
-
import "./utils-
|
|
5
|
-
import { t as getMessageStore } from "./message-
|
|
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.
|
|
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 ``;
|
|
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
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
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
|
|
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
|
-
*
|
|
2859
|
-
*
|
|
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.
|
|
2864
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
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) => {
|
|
@@ -3336,13 +3457,68 @@ const getAdapterConfig = (bot) => {
|
|
|
3336
3457
|
return cfg;
|
|
3337
3458
|
};
|
|
3338
3459
|
|
|
3460
|
+
//#endregion
|
|
3461
|
+
//#region src/core/adapter/self-avatar.ts
|
|
3462
|
+
/**
|
|
3463
|
+
* 机器人自身头像的在途请求存储。
|
|
3464
|
+
*
|
|
3465
|
+
* 机器人没有可拼进 qlogo CDN 的自身 openid(selfId 就是 appId),只能走官方
|
|
3466
|
+
* `GET /users/@me` 取 avatar。在途请求挂在 WeakMap 而不是实例字段上,保持
|
|
3467
|
+
* AdapterQQBot 只包含 AdapterType 约束的成员;bot 重建后旧实例随之释放。
|
|
3468
|
+
*/
|
|
3469
|
+
const pending = /* @__PURE__ */ new WeakMap();
|
|
3470
|
+
/**
|
|
3471
|
+
* 获取机器人自身的真实头像,并发调用只请求一次,失败后释放以便下次重试。
|
|
3472
|
+
* @param bot 适配器实例。
|
|
3473
|
+
* @returns `/users/@me` 的 avatar,拉取失败时退回 qlogo 拼接模式(默认头像)。
|
|
3474
|
+
*/
|
|
3475
|
+
const selfAvatarUrl = (bot) => {
|
|
3476
|
+
const existed = pending.get(bot);
|
|
3477
|
+
if (existed) return existed;
|
|
3478
|
+
const fallback = `https://thirdqq.qlogo.cn/qqapp/${bot.cfg.appId}/${bot.selfId}/0`;
|
|
3479
|
+
const task = bot.super.meta.getMe().then((me) => me.avatar || fallback).catch((err) => {
|
|
3480
|
+
bot.logger("warn", `[getAvatarUrl] 获取机器人自身头像失败,退回默认拼接: ${err instanceof Error ? err.message : err}`);
|
|
3481
|
+
pending.delete(bot);
|
|
3482
|
+
return fallback;
|
|
3483
|
+
});
|
|
3484
|
+
pending.set(bot, task);
|
|
3485
|
+
return task;
|
|
3486
|
+
};
|
|
3487
|
+
|
|
3339
3488
|
//#endregion
|
|
3340
3489
|
//#region src/core/adapter/base.ts
|
|
3341
3490
|
/**
|
|
3491
|
+
* 按 Karin 接口约定构造群成员信息。
|
|
3492
|
+
*
|
|
3493
|
+
* `sender` 用 getter 即时构建,避免整个适配器被系统接口序列化时带出多余引用。
|
|
3494
|
+
*
|
|
3495
|
+
* @param userId 成员 openid。
|
|
3496
|
+
* @param role 群内角色。
|
|
3497
|
+
* @param nick 成员昵称。
|
|
3498
|
+
* @param joinTime 入群时间戳(毫秒)。
|
|
3499
|
+
* @returns Karin 标准群成员信息。
|
|
3500
|
+
*/
|
|
3501
|
+
const buildGroupMemberInfo = (userId, role, nick, joinTime) => ({
|
|
3502
|
+
userId,
|
|
3503
|
+
role,
|
|
3504
|
+
nick,
|
|
3505
|
+
joinTime,
|
|
3506
|
+
get sender() {
|
|
3507
|
+
return senderGroup({
|
|
3508
|
+
userId,
|
|
3509
|
+
nick: nick || "",
|
|
3510
|
+
name: nick || "",
|
|
3511
|
+
role
|
|
3512
|
+
});
|
|
3513
|
+
}
|
|
3514
|
+
});
|
|
3515
|
+
/**
|
|
3342
3516
|
* QQ Official Bot 适配器
|
|
3343
3517
|
*
|
|
3344
|
-
*
|
|
3345
|
-
*
|
|
3518
|
+
* 发送通道由平台能力决定,没有对应的用户配置项:QQ 单聊 / 群聊的自定义 Markdown
|
|
3519
|
+
* 已对所有机器人开放,固定走 msg_type=2;频道场景仍需内邀开通,先乐观尝试
|
|
3520
|
+
* Markdown,被拒后自动降级为普通消息(详见 pipeline-qq / pipeline-guild 与
|
|
3521
|
+
* guild-markdown)。视频 / 语音 / 文件由 pipeline 内部以 msg_type=7 紧随主消息补发。
|
|
3346
3522
|
*/
|
|
3347
3523
|
var AdapterQQBot = class extends AdapterBase {
|
|
3348
3524
|
/** 与官方 API 交互 */
|
|
@@ -3383,13 +3559,13 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3383
3559
|
const normalizedElements = normalizeMediaElements(elements);
|
|
3384
3560
|
/** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
|
|
3385
3561
|
if (contact.scene === "direct" || contact.scene === "guild") {
|
|
3386
|
-
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
|
|
3562
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, contact, normalizedElements) : void 0;
|
|
3387
3563
|
const result = await sendGuild(this, contact, prepared?.sendElements ?? normalizedElements);
|
|
3388
3564
|
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
3389
3565
|
return result;
|
|
3390
3566
|
}
|
|
3391
3567
|
if (contact.scene === "group" || contact.scene === "friend") {
|
|
3392
|
-
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, normalizedElements) : void 0;
|
|
3568
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, contact, normalizedElements) : void 0;
|
|
3393
3569
|
const result = await sendQQ(this, contact, prepared?.sendElements ?? normalizedElements);
|
|
3394
3570
|
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
3395
3571
|
return result;
|
|
@@ -3458,8 +3634,16 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3458
3634
|
/**
|
|
3459
3635
|
* 发送合并转发消息
|
|
3460
3636
|
*
|
|
3461
|
-
*
|
|
3462
|
-
*
|
|
3637
|
+
* QQ 官方没有合并转发能力,这里把各节点摊平成一段 Markdown 正文,节点之间用
|
|
3638
|
+
* 强制换行隔开,不添加任何前缀提示文本。
|
|
3639
|
+
*
|
|
3640
|
+
* 图片以 markdown 图片语法写入,**保留原始来源**(本地路径 / base64 / URL):
|
|
3641
|
+
* 究竟走内置图床、第三方 fileToUrl,还是降级成 msg_type=7 富媒体,由发送
|
|
3642
|
+
* pipeline 统一决定。这里不自己上传,也不自己判断通道——否则频道场景在没有
|
|
3643
|
+
* Markdown 权限时会绕过降级逻辑,直接被平台拒掉。
|
|
3644
|
+
*
|
|
3645
|
+
* 视频 / 语音 / 文件进不了 markdown,作为普通消息段跟随主消息一起交给 pipeline,
|
|
3646
|
+
* 由它在同一次发送里以 msg_type=7 补发。
|
|
3463
3647
|
*/
|
|
3464
3648
|
async sendForwardMsg(contact, elements, _options) {
|
|
3465
3649
|
const parts = [];
|
|
@@ -3476,43 +3660,12 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3476
3660
|
case "text":
|
|
3477
3661
|
lines.push(el.text);
|
|
3478
3662
|
break;
|
|
3479
|
-
case "image":
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
} catch {
|
|
3485
|
-
lines.push(``);
|
|
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(``);
|
|
3507
|
-
else
|
|
3508
|
-
/** 没有处理器接手时不能留空 URL 的图片语法(客户端渲染为空白),改走富媒体补发 */
|
|
3509
|
-
mediaElements.push(el);
|
|
3510
|
-
} catch {
|
|
3511
|
-
mediaElements.push(el);
|
|
3512
|
-
}
|
|
3513
|
-
}
|
|
3663
|
+
case "image":
|
|
3664
|
+
lines.push(buildPendingMarkdownImageLine(el.file, el.name || "图片", el.width && el.height ? {
|
|
3665
|
+
width: el.width,
|
|
3666
|
+
height: el.height
|
|
3667
|
+
} : void 0));
|
|
3514
3668
|
break;
|
|
3515
|
-
}
|
|
3516
3669
|
case "at":
|
|
3517
3670
|
if (contact.scene === "friend" || contact.scene === "direct") break;
|
|
3518
3671
|
if (el.targetId === "all") lines.push("<qqbot-at-everyone />");
|
|
@@ -3538,17 +3691,33 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3538
3691
|
if (block) parts.push(block);
|
|
3539
3692
|
}
|
|
3540
3693
|
const content = parts.join("\r\r\n\n");
|
|
3541
|
-
|
|
3542
|
-
|
|
3694
|
+
/**
|
|
3695
|
+
* 正文可能整段为空(例如只转发了一个视频节点)。此时不能塞一条空 markdown:
|
|
3696
|
+
* 平台会以 50041「markdown 有空值」拒绝,pipeline 也会因为没有可发内容而
|
|
3697
|
+
* 回落成一条"不支持发送的消息类型"。
|
|
3698
|
+
*/
|
|
3699
|
+
const sendElements = content ? [segment.markdown(content), ...mediaElements] : mediaElements;
|
|
3700
|
+
if (!sendElements.length) {
|
|
3701
|
+
this.logger("warn", "[sendForwardMsg] 转发节点里没有可发送的内容,已跳过");
|
|
3702
|
+
return {
|
|
3703
|
+
messageId: "",
|
|
3704
|
+
forwardId: ""
|
|
3705
|
+
};
|
|
3706
|
+
}
|
|
3543
3707
|
return {
|
|
3544
|
-
messageId:
|
|
3708
|
+
messageId: (await this.sendMsg(contact, sendElements)).messageId,
|
|
3545
3709
|
forwardId: ""
|
|
3546
3710
|
};
|
|
3547
3711
|
}
|
|
3548
3712
|
/**
|
|
3549
3713
|
* 获取头像 url
|
|
3714
|
+
*
|
|
3715
|
+
* 第三方头像 CDN 的模式是 `qqapp/{appId}/{openid}/{size}`,只有拼用户 openid 才有效;
|
|
3716
|
+
* 机器人自身没有可用的 openid(selfId 就是 appId,拼出 appId/appid 只会得到默认头像),
|
|
3717
|
+
* 因此目标是自己时改走官方 `GET /users/@me` 返回的 avatar 字段,取值与缓存见 self-avatar。
|
|
3550
3718
|
*/
|
|
3551
3719
|
async getAvatarUrl(_userId, _size = 0) {
|
|
3720
|
+
if (_userId && (_userId === this.selfId || _userId === this.selfSubId("id"))) return selfAvatarUrl(this);
|
|
3552
3721
|
return `https://thirdqq.qlogo.cn/qqapp/${this.cfg.appId}/${_userId}/${_size}`;
|
|
3553
3722
|
}
|
|
3554
3723
|
/**
|
|
@@ -3664,9 +3833,30 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3664
3833
|
}
|
|
3665
3834
|
/**
|
|
3666
3835
|
* 群踢人
|
|
3836
|
+
*
|
|
3837
|
+
* 走官方 `POST /v2/groups/{group_openid}/batch_remove_members`,机器人需拥有群
|
|
3838
|
+
* 管理员身份。Karin 的 `rejectAddRequest` 映射为官方的 `add_to_member_blacklist`:
|
|
3839
|
+
* 移除的同时拉入群黑名单,该用户之后无法再次入群。官方接口没有踢人理由字段,
|
|
3840
|
+
* `kickReason` 只会被记录到日志。
|
|
3841
|
+
*
|
|
3842
|
+
* 需要一次移除多人时用 `bot.super.groups.batchRemoveGroupMembers()`,单次最多 20 个。
|
|
3843
|
+
*
|
|
3844
|
+
* @param _groupId 群 openid
|
|
3845
|
+
* @param _targetId 群成员 openid
|
|
3846
|
+
* @param _rejectAddRequest 是否同时拉入群黑名单,默认 false
|
|
3847
|
+
* @param _kickReason 官方不支持,仅在传入时输出提示
|
|
3667
3848
|
*/
|
|
3668
3849
|
async groupKickMember(_groupId, _targetId, _rejectAddRequest, _kickReason) {
|
|
3669
|
-
this.logger("warn",
|
|
3850
|
+
if (_kickReason) this.logger("warn", `[groupKickMember] QQ Official Bot API 不支持踢人理由,已忽略: ${_kickReason}`);
|
|
3851
|
+
try {
|
|
3852
|
+
const failed = (await this.super.groups.batchRemoveGroupMembers(_groupId, {
|
|
3853
|
+
member_openids: [_targetId],
|
|
3854
|
+
..._rejectAddRequest ? { add_to_member_blacklist: true } : {}
|
|
3855
|
+
})).add_to_member_blacklist_fail_openids ?? [];
|
|
3856
|
+
if (failed.length) this.logger("warn", `[groupKickMember] 成员已移除,但加入群黑名单失败: ${failed.join("、")}`);
|
|
3857
|
+
} catch (err) {
|
|
3858
|
+
this.logger("error", "[groupKickMember] 移除群成员失败(该接口为白名单接口,未开通权限会返回 11253):", err);
|
|
3859
|
+
}
|
|
3670
3860
|
}
|
|
3671
3861
|
/**
|
|
3672
3862
|
* 禁言群成员
|
|
@@ -3801,49 +3991,63 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3801
3991
|
/**
|
|
3802
3992
|
* 获取群成员信息
|
|
3803
3993
|
*
|
|
3804
|
-
* - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state
|
|
3805
|
-
* - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}
|
|
3806
|
-
*
|
|
3807
|
-
*
|
|
3994
|
+
* - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`。
|
|
3995
|
+
* - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,该接口在
|
|
3996
|
+
* 接口文档 v1.28.0(2026-09-03)转为公开文档,昵称字段为 `username`,`nick` 是
|
|
3997
|
+
* 转公开之前的字段名,仅作兼容回退。
|
|
3998
|
+
*
|
|
3999
|
+
* 两者都是白名单接口,未开通权限(11253)或查询失败时降级为占位结构并告警。
|
|
3808
4000
|
*/
|
|
3809
4001
|
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
4002
|
if (_targetId === this.selfId) try {
|
|
3826
4003
|
const state = await this.super.groups.getBotState(_groupId);
|
|
3827
|
-
return
|
|
4004
|
+
return buildGroupMemberInfo(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
|
|
3828
4005
|
} catch (err) {
|
|
3829
4006
|
this.logger("warn", `[getGroupMemberInfo] 获取机器人群内状态失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
|
|
3830
|
-
return
|
|
4007
|
+
return buildGroupMemberInfo(_targetId, "member");
|
|
3831
4008
|
}
|
|
3832
4009
|
try {
|
|
3833
4010
|
const member = await this.super.groups.getGroupMember(_groupId, _targetId);
|
|
3834
|
-
|
|
3835
|
-
|
|
4011
|
+
const nick = member.username || member.nick;
|
|
4012
|
+
if (nick) this.nicknameCache.set(_targetId, nick);
|
|
4013
|
+
return buildGroupMemberInfo(member.member_openid || _targetId, member.member_role || "member", nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
|
|
3836
4014
|
} catch (err) {
|
|
3837
|
-
this.logger("warn", `[getGroupMemberInfo]
|
|
3838
|
-
return
|
|
4015
|
+
this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
|
|
4016
|
+
return buildGroupMemberInfo(_targetId, "member");
|
|
3839
4017
|
}
|
|
3840
4018
|
}
|
|
3841
4019
|
/**
|
|
3842
4020
|
* 获取群成员列表
|
|
4021
|
+
*
|
|
4022
|
+
* 走官方 `GET /v2/groups/{group_openid}/members`(白名单接口),每页最多 30 条,
|
|
4023
|
+
* 这里按 `next_cursor` 自动翻完所有页;顺便把昵称写入
|
|
4024
|
+
* {@link AdapterQQBot.nicknameCache},让后续消息事件能直接取到昵称。
|
|
4025
|
+
*
|
|
4026
|
+
* 为防止平台返回的游标不收敛,翻页数有安全上限(约 6000 人),触顶会告警并返回已取到的部分。
|
|
4027
|
+
* 未开通权限(11253)或查询失败时返回已取到的部分并告警。
|
|
4028
|
+
*
|
|
4029
|
+
* @param _groupId 群 openid
|
|
3843
4030
|
*/
|
|
3844
4031
|
async getGroupMemberList(_groupId, _refresh) {
|
|
3845
|
-
|
|
3846
|
-
|
|
4032
|
+
const result = [];
|
|
4033
|
+
let cursor = "";
|
|
4034
|
+
try {
|
|
4035
|
+
for (let page = 0; page < 200; page++) {
|
|
4036
|
+
const data = await this.super.groups.getGroupMemberList(_groupId, cursor ? { cursor } : {});
|
|
4037
|
+
for (const member of data.members ?? []) {
|
|
4038
|
+
if (member.username) this.nicknameCache.set(member.member_openid, member.username);
|
|
4039
|
+
result.push(buildGroupMemberInfo(member.member_openid, member.member_role || "member", member.username, member.joined_at ? new Date(member.joined_at).getTime() : void 0));
|
|
4040
|
+
}
|
|
4041
|
+
const next = data.next_cursor || "";
|
|
4042
|
+
/** 空游标表示末页;游标不前进说明平台返回异常,直接停止避免死循环 */
|
|
4043
|
+
if (!next || next === cursor) return result;
|
|
4044
|
+
cursor = next;
|
|
4045
|
+
}
|
|
4046
|
+
this.logger("warn", `[getGroupMemberList] 翻页数已达上限 ${200} 页,仅返回前 ${result.length} 名成员`);
|
|
4047
|
+
} catch (err) {
|
|
4048
|
+
this.logger("warn", `[getGroupMemberList] 获取群成员列表失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
|
|
4049
|
+
}
|
|
4050
|
+
return result;
|
|
3847
4051
|
}
|
|
3848
4052
|
/**
|
|
3849
4053
|
* 获取群荣誉信息
|
|
@@ -5426,7 +5630,7 @@ const intentBitMap = {
|
|
|
5426
5630
|
[2]: "GUILD_MEMBERS (频道成员变更)",
|
|
5427
5631
|
[512]: "GUILD_MESSAGES (频道消息-私域)",
|
|
5428
5632
|
[4096]: "DIRECT_MESSAGE (频道私信)",
|
|
5429
|
-
[1 << 24]: "
|
|
5633
|
+
[1 << 24]: "GROUP_MEMBER_EVENT (群成员进退群/入群申请)",
|
|
5430
5634
|
[1 << 25]: "GROUP_AND_C2C_EVENT (群聊/单聊消息)",
|
|
5431
5635
|
[1 << 26]: "INTERACTION (按钮交互回调)",
|
|
5432
5636
|
[1 << 30]: "PUBLIC_GUILD_MESSAGES (频道消息-公域)"
|
|
@@ -5715,7 +5919,6 @@ const hashConfig = (bot) => JSON.stringify({
|
|
|
5715
5919
|
appId: bot.appId,
|
|
5716
5920
|
secret: bot.secret,
|
|
5717
5921
|
event: bot.event,
|
|
5718
|
-
markdown: bot.markdown,
|
|
5719
5922
|
messageCache: bot.messageCache
|
|
5720
5923
|
});
|
|
5721
5924
|
/**
|
|
@@ -5743,6 +5946,8 @@ const createBot = async (input) => {
|
|
|
5743
5946
|
stop(appId);
|
|
5744
5947
|
offAll(appId);
|
|
5745
5948
|
stopTokenRefresh(appId);
|
|
5949
|
+
/** 重建即重新探测频道 Markdown,拿到内邀后不必重启整个 Karin。 */
|
|
5950
|
+
resetGuildMarkdown(appId);
|
|
5746
5951
|
logger.info(`[QQ Official Bot][${appId}] 获取 access_token...`);
|
|
5747
5952
|
await getAccessToken(appId, bot.secret);
|
|
5748
5953
|
const api = new QQBotApi(createAxiosInstance(appId));
|