@karinjs/adapter-qqbot 1.3.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,100 +1,4745 @@
1
- import {
2
- basename,
3
- config_exports,
4
- event,
5
- fakeEvent,
6
- getConfig,
7
- initQQBotAdapter,
8
- sign
9
- } from "./chunk-PYOUSZ6B.js";
10
-
11
- // src/index.ts
12
- import { logger as logger2 } from "node-karin";
13
-
14
- // src/connection/routing.ts
15
- import { app } from "node-karin";
1
+ import { c as log, l as basename, n as config, o as pkg, r as getConfig, t as bindHandlers } from "./config-DKa7596f.js";
2
+ import { i as random, n as getImageSize, r as handleUrl, t as fakeEvent } from "./common-by1GpV0b.js";
3
+ import "./utils-BPbnNAix.js";
4
+ import { t as getMessageStore } from "./message-O73AeL_P.js";
5
+ import { t as needQrOnboard } from "./onboard-DPWgX39H.js";
6
+ import { AdapterBase, app, buttonHandle, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, unregisterBot } from "node-karin";
7
+ import { URL } from "url";
8
+ import lodash from "node-karin/lodash";
9
+ import { createReadStream } from "node:fs";
10
+ import FormData from "form-data";
11
+ import axios from "node-karin/axios";
12
+ import crypto from "node:crypto";
13
+ import { open, readFile, stat } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { fileURLToPath as fileURLToPath$1 } from "node:url";
16
+ import os from "node:os";
17
+ import EventEmitter from "node:events";
18
+ import { WebSocket } from "node-karin/ws";
16
19
  import express from "node-karin/express";
20
+ import nacl from "tweetnacl";
17
21
 
18
- // src/connection/webhook.ts
19
- import { logger } from "node-karin";
20
- var webhookRouting = async (req, res) => {
21
- var _a, _b, _c, _d;
22
- const data = await checkAppid(req, res);
23
- if (!data) return;
24
- const cfg = getConfig(data.appid);
25
- if (!cfg) {
26
- logger.error(`[\u914D\u7F6E\u9519\u8BEF][${data.appid}] \u914D\u7F6E\u6587\u4EF6\u4E2D\u672A\u627E\u5230\u5BF9\u5E94\u7684appid\uFF0C\u8BF7\u68C0\u67E5\u914D\u7F6E\u6587\u4EF6`);
27
- return;
28
- }
29
- if (cfg.event.type !== 1) {
30
- logger.error(`[\u914D\u7F6E\u9519\u8BEF][${data.appid}] webhook\u672A\u542F\u7528\uFF0C\u8BF7\u68C0\u67E5\u914D\u7F6E\u6587\u4EF6`);
31
- return;
32
- }
33
- if (data.body.op === 13) {
34
- const eventTs = (_b = (_a = data.body) == null ? void 0 : _a.d) == null ? void 0 : _b.event_ts;
35
- const plainToken = (_d = (_c = data.body) == null ? void 0 : _c.d) == null ? void 0 : _d.plain_token;
36
- if (!eventTs || !plainToken) {
37
- logger.error(`[\u8BF7\u6C42\u6570\u636E\u9519\u8BEF][${data.appid}] \u672A\u627E\u5230 event_ts \u6216 plain_token: ${data.rawBody}`);
38
- return;
39
- }
40
- const signature2 = sign(cfg.secret, eventTs, plainToken);
41
- logger.mark(`[signature][${data.appid}] ${signature2}`);
42
- res.setHeader("Content-Type", "application/json");
43
- res.status(200).end(JSON.stringify({ plain_token: plainToken, signature: signature2 }));
44
- return;
45
- }
46
- const ed25519 = req.headers["x-signature-ed25519"];
47
- const signature = sign(cfg.secret, req.headers["x-signature-timestamp"], data.rawBody);
48
- if (ed25519 !== signature) {
49
- fakeEvent(`\u7B7E\u540D\u9A8C\u8BC1\u5931\u8D25:
50
- appid: ${data.appid}
51
- ed25519: ${ed25519}
52
- \u5B9E\u9645\u7B7E\u540D: ${signature}
53
- body: ${data.rawBody}`);
54
- return;
55
- }
56
- event.emit(data.appid, data.body);
57
- };
58
- var checkAppid = async (req, res) => {
59
- let response = true;
60
- try {
61
- const ip = req.socket.remoteAddress;
62
- const appid = req.headers["x-bot-appid"];
63
- const rawBody = await new Promise((resolve) => {
64
- const raw = [];
65
- req.on("data", (chunk) => raw.push(chunk));
66
- req.on("end", () => resolve(raw.join("")));
67
- });
68
- if (!appid) {
69
- fakeEvent(`\u672A\u627E\u5230 x-bot-appid: ${ip} body: ${rawBody}`);
70
- return;
71
- }
72
- const userAgent = req.headers["user-agent"];
73
- if (userAgent !== "QQBot-Callback") {
74
- fakeEvent(`\u672A\u627E\u5230 User-Agent: ${ip} body: ${rawBody}`);
75
- return;
76
- }
77
- const body = JSON.parse(rawBody) || {};
78
- if (typeof body.op !== "number") {
79
- fakeEvent(`\u975E\u6CD5\u8BF7\u6C42\u4F53\uFF0C\u672A\u627E\u5230 op: ${ip} body: ${rawBody}`);
80
- return;
81
- } else if (body.op === 13) {
82
- response = false;
83
- }
84
- return { appid, rawBody, body };
85
- } finally {
86
- response && res.status(200).end();
87
- }
88
- };
89
-
90
- // src/connection/routing.ts
91
- var createRouting = () => {
92
- const router = express.Router();
93
- router.use("/webhook", webhookRouting);
94
- app.use("/qqbot", router);
95
- };
96
-
97
- // src/index.ts
98
- logger2.info(`${logger2.violet(`[\u63D2\u4EF6:${config_exports.pkg().version}]`)} ${logger2.green(basename)} \u521D\u59CB\u5316\u5B8C\u6210~`);
22
+ //#region src/core/api/error.ts
23
+ /**
24
+ * OpenAPI 错误码映射表
25
+ * 来源: api-v2/openapi/error/error.md
26
+ *
27
+ * 规则:
28
+ * - 区间码 (如 301000~301099) 用范围匹配
29
+ * - 精确码直接查表
30
+ */
31
+ /** HTTP 状态码映射 */
32
+ const httpStatusMap = {
33
+ 200: "成功",
34
+ 204: "成功,但是无包体,一般用于删除操作",
35
+ 201: "异步操作成功,虽然说成功,但是会返回一个 error body,需要特殊处理",
36
+ 202: "异步操作成功,虽然说成功,但是会返回一个 error body,需要特殊处理",
37
+ 401: "认证失败",
38
+ 404: "未找到 API",
39
+ 405: "http method 不允许",
40
+ 429: "频率限制",
41
+ 500: "处理失败",
42
+ 504: "处理失败"
43
+ };
44
+ /** 业务错误码(code)映射 */
45
+ const errorCodeMap = {
46
+ 10001: "UnknownAccount 账号异常",
47
+ 10003: "UnknownChannel 子频道异常",
48
+ 10004: "UnknownGuild 频道异常",
49
+ 11281: "ErrorCheckAdminFailed 检查是否是管理员失败,系统错误,一般重试一次会好,最多只能重试一次",
50
+ 11282: "ErrorCheckAdminNotPass 检查是否是管理员未通过,该接口需要管理员权限,但是用户在添加机器人的时候并未授予该权限,属于逻辑错误,可以提示用户进行授权",
51
+ 11251: "ErrorWrongAppid 参数中的 appid 错误,开发者填的 token 错误,appid 无法识别",
52
+ 11252: "ErrorCheckAppPrivilegeFailed 检查应用权限失败,系统错误,一般重试一次会好,最多只能重试一次",
53
+ 11253: "ErrorCheckAppPrivilegeNotPass 检查应用权限不通过,该机器人应用未获得调用该接口的权限,需要向平台申请",
54
+ 11254: "ErrorInterfaceForbidden 应用接口被封禁,该机器人虽然获得了该接口权限,但是被封禁了",
55
+ 11261: "ErrorWrongAppid 参数中缺少 appid,同 11251",
56
+ 11262: "ErrorCheckRobot 当前接口不支持使用机器人 Bot Token 调用",
57
+ 11263: "ErrorCheckGuildAuth 检查频道权限失败,系统错误,一般重试一次会好,最多只能重试一次",
58
+ 11264: "ErrorGuildAuthNotPass 检查小站权限未通过,管理员添加机器人的时候未授予该接口权限,属于逻辑错误,可提示用户进行授权,如果已经给予授权,请检查传递的 guild id 是否正确",
59
+ 11265: "ErrorRobotHasBaned 机器人已经被封禁",
60
+ 11241: "ErrorWrongToken 参数中缺少 token",
61
+ 11242: "ErrorCheckTokenFailed 校验 token 失败,系统错误,一般重试一次会好,最多只能重试一次",
62
+ 11243: "ErrorCheckTokenNotPass 校验 token 未通过,用户填充的 token 错误,需要开发者进行检查",
63
+ 11273: "ErrorCheckUserAuth 检查用户权限失败,当前接口不支持使用 Bearer Token 调用",
64
+ 11274: "ErrorUserAuthNotPass 检查用户权限未通过,用户 OAuth 授权时未给与该接口权限,可提示用户重新进行授权",
65
+ 11275: "ErrorWrongAppid 无 appid,同 11251",
66
+ 11301: "ErrorGetHTTPHeader HTTP Header 无效",
67
+ 11302: "ErrorGetHeaderUIN HTTP Header 无效",
68
+ 11303: "ErrorGetNick 获取昵称失败",
69
+ 11304: "ErrorGetAvatar 获取头像失败",
70
+ 11305: "ErrorGetGuildID 获取频道 ID 失败",
71
+ 11306: "ErrorGetGuildInfo 获取频道信息失败",
72
+ 12001: "ReplaceIDFailed 替换 id 失败",
73
+ 12002: "RequestInvalid 请求体错误",
74
+ 12003: "ResponseInvalid 回包错误",
75
+ 20028: "ChannelHitWriteRateLimit 子频道消息触发限频",
76
+ 50006: "CannotSendEmptyMessage 消息为空",
77
+ 50035: "InvalidFormBody form-data 内容异常",
78
+ 50037: "带有 markdown 消息只支持 markdown 或者 keyboard 组合",
79
+ 50038: "非同频道同子频道",
80
+ 50039: "获取消息失败",
81
+ 50040: "消息模版类型错误",
82
+ 50041: "markdown 有空值",
83
+ 50042: "markdown 列表长达最大值",
84
+ 50043: "guild_id 转换失败",
85
+ 50045: "不能回复机器人自己产生的消息",
86
+ 50046: "非 at 机器人消息",
87
+ 50047: "非机器人产生的消息 或者 at 机器人消息",
88
+ 50048: "message id 不能为空",
89
+ 50049: "只能修改含有 keyboard 元素的消息",
90
+ 50050: "修改消息时,keyboard 元素不能为空",
91
+ 50051: "只能修改机器人自己发送的消息",
92
+ 50053: "修改消息错误",
93
+ 50054: "markdown 模版参数错误",
94
+ 50055: "无效的 markdown content",
95
+ 50056: "不允许发送 markdown content",
96
+ 50057: "markdown 参数只支持原生语法或者模版二选一",
97
+ 301e3: "参数错误",
98
+ 301001: "查询频道信息错误",
99
+ 301002: "查询子频道权限错误",
100
+ 301003: "修改子频道权限错误",
101
+ 301004: "私密子频道关联的人数到达上限",
102
+ 301005: "调用 Rpc 服务失败",
103
+ 301006: "非群成员没有查询权限",
104
+ 301007: "参数超过数量限制",
105
+ 302e3: "参数错误",
106
+ 302001: "查询频道信息错误",
107
+ 302002: "查询日程列表失败",
108
+ 302003: "查询日程失败",
109
+ 302004: "修改日程失败",
110
+ 302005: "删除日程失败",
111
+ 302006: "创建日程失败",
112
+ 302007: "获取创建者信息失败",
113
+ 302008: "子频道 ID 不能为空",
114
+ 302009: "频道系统错误,请联系客服",
115
+ 302010: "暂无修改日程权限",
116
+ 302011: "日程活动已被删除",
117
+ 302012: "每天只能创建 10 个日程,明天再来吧!",
118
+ 302013: "创建日程触发安全打击",
119
+ 302014: "日程持续时间超过 7 天,请重新选择",
120
+ 302015: "开始时间不能早于当前时间",
121
+ 302016: "结束时间不能早于开始时间",
122
+ 302017: "Schedule 对象为空",
123
+ 302018: "参数类型转换失败",
124
+ 302019: "调用下游失败,请联系客服",
125
+ 302020: "日程内容违规、账号违规",
126
+ 302021: "频道内当日新增活动达上限",
127
+ 302022: "不能绑定非当前频道的子频道",
128
+ 302023: "开始时跳转不可绑定日程子频道",
129
+ 302024: "绑定的子频道不存在",
130
+ 304003: "URL_NOT_ALLOWED url 未报备",
131
+ 304004: "ARK_NOT_ALLOWED 没有发 ark 消息权限",
132
+ 304005: "EMBED_LIMIT embed 长度超限",
133
+ 304006: "SERVER_CONFIG 后台配置错误",
134
+ 304007: "GET_GUILD 查询频道异常",
135
+ 304008: "GET_BOT 查询机器人异常",
136
+ 304009: "GET_CHENNAL 查询子频道异常",
137
+ 304010: "CHANGE_IMAGE_URL 图片转存错误",
138
+ 304011: "NO_TEMPLATE 模板不存在",
139
+ 304012: "GET_TEMPLATE 取模板错误",
140
+ 304014: "TEMPLATE_PRIVILEGE 没有模板权限",
141
+ 304016: "SEND_ERROR 发消息错误",
142
+ 304017: "UPLOAD_IMAGE 图片上传错误",
143
+ 304018: "SESSION_NOT_EXIST 机器人没连上 gateway",
144
+ 304019: "AT_EVERYONE_TIMES @全体成员次数超限",
145
+ 304020: "FILE_SIZE 文件大小超限",
146
+ 304021: "GET_FILE 下载文件错误",
147
+ 304022: "PUSH_TIME 推送消息时间限制",
148
+ 304023: "PUSH_MSG_ASYNC_OK 推送消息异步调用成功,等待人工审核",
149
+ 304024: "REPLY_MSG_ASYNC_OK 回复消息异步调用成功,等待人工审核",
150
+ 304025: "BEAT 消息被打击",
151
+ 304026: "MSG_ID 回复的消息 id 错误",
152
+ 304027: "MSG_EXPIRE 回复的消息过期",
153
+ 304028: "MSG_PROTECT 非 At 当前用户的消息不允许回复",
154
+ 304029: "CORPUS_ERROR 调语料服务错误",
155
+ 304030: "CORPUS_NOT_MATCH 语料不匹配",
156
+ 304031: "私信已关闭",
157
+ 304032: "私信不存在",
158
+ 304033: "拉私信错误",
159
+ 304034: "不是私信成员",
160
+ 304035: "推送消息超过子频道数量限制",
161
+ 304036: "没有 markdown 模板的权限",
162
+ 304037: "没有发消息按钮组件的权限",
163
+ 304038: "消息按钮组件不存在",
164
+ 304039: "消息按钮组件解析错误",
165
+ 304040: "消息按钮组件消息内容错误",
166
+ 304044: "取消息设置错误",
167
+ 304045: "子频道主动消息数限频",
168
+ 304046: "不允许在此子频道发主动消息",
169
+ 304047: "主动消息推送超过限制的子频道数",
170
+ 304048: "不允许在此频道发主动消息",
171
+ 304049: "私信主动消息数限频",
172
+ 304050: "私信主动消息总量限频",
173
+ 304051: "消息设置引导请求构造错误",
174
+ 304052: "发消息设置引导超频",
175
+ 306001: "param invalid 撤回消息参数错误",
176
+ 306002: "msgid error 消息 id 错误",
177
+ 306003: "fail to get message 获取消息错误(可重试)",
178
+ 306004: "no permission to delete message 没有撤回此消息的权限",
179
+ 306005: "retract message error 消息撤回失败(可重试)",
180
+ 306006: "fail to get channel 获取子频道失败(可重试)",
181
+ 501001: "参数校验失败",
182
+ 501002: "创建子频道公告失败(可重试)",
183
+ 501003: "删除子频道公告失败(可重试)",
184
+ 501004: "获取频道信息失败(可重试)",
185
+ 501005: "MessageID 错误",
186
+ 501006: "创建频道全局公告失败(可重试)",
187
+ 501007: "删除频道全局公告失败(可重试)",
188
+ 501008: "MessageID 不存在",
189
+ 501009: "MessageID 解析失败",
190
+ 501010: "此条消息非子频道内消息",
191
+ 501011: "创建精华消息失败(可重试)",
192
+ 501012: "删除精华消息失败(可重试)",
193
+ 501013: "精华消息超过最大数量",
194
+ 501014: "安全打击",
195
+ 501015: "此消息不允许设置",
196
+ 501016: "频道公告子频道推荐超过最大数量",
197
+ 501017: "非频道主或管理员",
198
+ 501018: "推荐子频道 ID 无效",
199
+ 501019: "公告类型错误",
200
+ 501020: "创建推荐子频道类型频道公告失败",
201
+ 502001: "频道 id 无效",
202
+ 502002: "频道 id 为空",
203
+ 502003: "用户 id 无效",
204
+ 502004: "用户 id 为空",
205
+ 502005: "timestamp 不合法",
206
+ 502006: "timestamp 无效",
207
+ 502007: "参数转换错误",
208
+ 502008: "rpc 调用失败",
209
+ 502009: "安全打击",
210
+ 502010: "请求头错误",
211
+ 503001: "频道 id 无效",
212
+ 503002: "频道 id 为空",
213
+ 503003: "获取子频道信息失败",
214
+ 503004: "超出发布帖子的频次限制",
215
+ 503005: "帖子标题为空",
216
+ 503006: "帖子内容为空",
217
+ 503007: "帖子 ID 为空",
218
+ 503008: "获取 X-Uin 失败",
219
+ 503009: "帖子 ID 无效或不合法",
220
+ 503010: "通过 Uin 获取 TinyID 失败",
221
+ 503011: "帖子 ID 里面的时间戳无效或不合法",
222
+ 503012: "帖子不存在或已删除",
223
+ 503013: "服务器内部错误",
224
+ 503014: "帖子 JSON 内容解析失败",
225
+ 503015: "帖子内容转换失败",
226
+ 503016: "链接数量超过限制",
227
+ 503017: "字数超过限制",
228
+ 503018: "图片数量超过限制",
229
+ 503019: "视频数量超过限制",
230
+ 503020: "标题长度超过限制",
231
+ 504001: "请求参数无效错误",
232
+ 504002: "获取 HTTP 头失败",
233
+ 504003: "获取 BOT UIN 错误",
234
+ 504004: "获取消息频率设置信息错误",
235
+ 610001: "获取频道 ID 失败",
236
+ 610002: "获取 HTTP 头失败",
237
+ 610003: "获取机器人号码失败",
238
+ 610004: "获取机器人角色失败",
239
+ 610005: "获取机器人角色内部错误",
240
+ 610006: "拉取机器人权限列表失败",
241
+ 610007: "机器人不在频道内",
242
+ 610008: "无效参数",
243
+ 610009: "获取 API 接口详情失败",
244
+ 610010: "API 接口已授权",
245
+ 610011: "获取机器人信息失败",
246
+ 610012: "限频失败",
247
+ 610013: "已限频",
248
+ 610014: "api 授权链接发送失败",
249
+ 620001: "表情表态无效参数",
250
+ 620002: "已经达到表情反应的类型数量上限",
251
+ 620003: "已经设置过该表情表态",
252
+ 620004: "没有设置过该表情表态",
253
+ 620005: "没有权限设置表情表态",
254
+ 620006: "操作限频",
255
+ 620007: "表情表态操作失败,请重试",
256
+ 630001: "互动回调数据更新无效参数",
257
+ 630002: "互动回调数据更新获取 AppID 失败",
258
+ 630003: "互动回调数据 AppID 不匹配",
259
+ 630004: "互动回调数据更新内部存储错误",
260
+ 630005: "互动回调数据更新内部存储读取错误",
261
+ 630006: "互动回调数据更新读取请求 AppID 失败",
262
+ 630007: "互动回调数据太大",
263
+ 1100100: "安全打击:消息被限频",
264
+ 1100101: "安全打击:内容涉及敏感,请返回修改",
265
+ 1100102: "安全打击:抱歉,暂未获得新功能体验资格",
266
+ 1100103: "安全打击",
267
+ 1100104: "安全打击:该群已失效或当前群已不存在",
268
+ 1100300: "系统内部错误",
269
+ 1100301: "调用方不是群成员",
270
+ 1100302: "获取指定频道名称失败",
271
+ 1100303: "主页频道非管理员不允许发消息",
272
+ 1100304: "@次数鉴权失败",
273
+ 1100305: "TinyId 转换 Uin 失败",
274
+ 1100306: "非私有频道成员",
275
+ 1100307: "非白名单应用子频道",
276
+ 1100308: "触发频道内限频",
277
+ 1100499: "其他错误",
278
+ 3300006: "安全打击"
279
+ };
280
+ const rangeDefs = [
281
+ {
282
+ min: 301e3,
283
+ max: 301099,
284
+ message: "子频道权限错误"
285
+ },
286
+ {
287
+ min: 501e3,
288
+ max: 501999,
289
+ message: "公告错误"
290
+ },
291
+ {
292
+ min: 502e3,
293
+ max: 502099,
294
+ message: "禁言相关错误"
295
+ },
296
+ {
297
+ min: 504e3,
298
+ max: 504999,
299
+ message: "消息频率相关错误"
300
+ },
301
+ {
302
+ min: 61e4,
303
+ max: 619999,
304
+ message: "频道权限错误"
305
+ },
306
+ {
307
+ min: 620001,
308
+ max: 629999,
309
+ message: "表情表态错误"
310
+ },
311
+ {
312
+ min: 630001,
313
+ max: 639999,
314
+ message: "互动回调数据更新错误"
315
+ },
316
+ {
317
+ min: 1e6,
318
+ max: 2999999,
319
+ message: "发消息错误"
320
+ },
321
+ {
322
+ min: 3e6,
323
+ max: 3999999,
324
+ message: "编辑消息错误"
325
+ }
326
+ ];
327
+ /**
328
+ * 根据 code 查找对应的中文错误描述
329
+ * @param code - 业务错误码
330
+ * @returns 中文错误描述,未找到则返回 undefined
331
+ */
332
+ const getErrorMessage = (code) => {
333
+ const exact = errorCodeMap[code];
334
+ if (exact) return exact;
335
+ for (const range of rangeDefs) if (code >= range.min && code <= range.max) return `${range.message} (code: ${code})`;
336
+ };
337
+ /**
338
+ * 格式化 OpenAPI 错误为可读字符串
339
+ * @param status - HTTP 状态码
340
+ * @param code - 业务错误码
341
+ * @param message - 原始消息
342
+ * @returns 格式化后的错误字符串
343
+ */
344
+ const formatOpenAPIError = (status, code, message) => {
345
+ const parts = [];
346
+ const statusText = httpStatusMap[status];
347
+ if (statusText) parts.push(`[HTTP ${status}] ${statusText}`);
348
+ else parts.push(`[HTTP ${status}]`);
349
+ if (code !== void 0) {
350
+ const codeText = getErrorMessage(code);
351
+ if (codeText) parts.push(`[Code ${code}] ${codeText}`);
352
+ else parts.push(`[Code ${code}]`);
353
+ }
354
+ if (message) parts.push(message);
355
+ return parts.join(" | ");
356
+ };
357
+
358
+ //#endregion
359
+ //#region src/core/api/http.ts
360
+ /**
361
+ * 包装 axios 错误为可读多行 message
362
+ * 如果响应中包含 QQ 官方错误码(code),会自动映射为中文描述
363
+ */
364
+ const formatError = (path, options, err) => {
365
+ if (axios.isAxiosError(err)) {
366
+ const response = err.response;
367
+ const status = response?.status ?? 0;
368
+ const data = response?.data;
369
+ const code = typeof data?.code === "number" ? data.code : void 0;
370
+ const msg = typeof data?.message === "string" ? data.message : void 0;
371
+ const lines = [];
372
+ lines.push("[axios] 请求失败");
373
+ lines.push(`请求路径: ${path}`);
374
+ lines.push(`请求数据: ${lodash.truncate(JSON.stringify(redactRequestData(options)), { length: 500 })}`);
375
+ if (!response) {
376
+ const reason = [err.code, err.message].filter(Boolean).join(" | ");
377
+ if (reason) lines.push(`请求错误: ${reason}`);
378
+ }
379
+ if (code !== void 0 || status > 0) lines.push(`错误详情: ${formatOpenAPIError(status, code, msg)}`);
380
+ lines.push(`响应数据: ${JSON.stringify(data)}`);
381
+ return new Error(lines.join("\n"));
382
+ }
383
+ if (err instanceof Error) return err;
384
+ return new Error(typeof err === "string" ? err : JSON.stringify(err));
385
+ };
386
+ /**
387
+ * 请求失败日志脱敏,避免富媒体 file_data/base64 被完整 stringify。
388
+ * @param value 请求数据。
389
+ * @returns 脱敏后的请求数据。
390
+ */
391
+ const redactRequestData = (value) => {
392
+ if (!value || typeof value !== "object") return value;
393
+ if (Array.isArray(value)) return value.map((item) => redactRequestData(item));
394
+ const source = value;
395
+ const result = {};
396
+ for (const [key, item] of Object.entries(source)) if (key === "file_data" && typeof item === "string") result[key] = `<redacted ${item.length} chars>`;
397
+ else result[key] = redactRequestData(item);
398
+ return result;
399
+ };
400
+ /**
401
+ * Http 基础类,子模块继承此类共享 axios 实例与错误格式化
402
+ */
403
+ var Http = class {
404
+ axios;
405
+ constructor(axios) {
406
+ this.axios = axios;
407
+ }
408
+ async get(path) {
409
+ try {
410
+ const { data } = await this.axios.get(path);
411
+ return data;
412
+ } catch (err) {
413
+ throw formatError(path, void 0, err);
414
+ }
415
+ }
416
+ async post(path, body = {}, headers, timeout) {
417
+ try {
418
+ const { data } = await this.axios.post(path, body, {
419
+ headers,
420
+ timeout
421
+ });
422
+ return data;
423
+ } catch (err) {
424
+ throw formatError(path, body, err);
425
+ }
426
+ }
427
+ async put(path, body = {}, headers, timeout) {
428
+ try {
429
+ const { data } = await this.axios.put(path, body, {
430
+ headers,
431
+ timeout
432
+ });
433
+ return data;
434
+ } catch (err) {
435
+ throw formatError(path, body, err);
436
+ }
437
+ }
438
+ async delete(path) {
439
+ try {
440
+ const { data } = await this.axios.delete(path);
441
+ return data;
442
+ } catch (err) {
443
+ throw formatError(path, void 0, err);
444
+ }
445
+ }
446
+ };
447
+
448
+ //#endregion
449
+ //#region src/core/api/messages.ts
450
+ /**
451
+ * 消息发送 + 撤回
452
+ */
453
+ var MessagesApi = class extends Http {
454
+ /** 单聊消息 */
455
+ sendFriendMsg(openid, body) {
456
+ return this.post(`/v2/users/${openid}/messages`, body);
457
+ }
458
+ /** 群聊消息 */
459
+ sendGroupMsg(groupOpenid, body) {
460
+ return this.post(`/v2/groups/${groupOpenid}/messages`, body);
461
+ }
462
+ /** 文字子频道消息 */
463
+ sendChannelMsg(channelId, body) {
464
+ const headers = body instanceof FormData ? body.getHeaders() : void 0;
465
+ return this.post(`/channels/${channelId}/messages`, body, headers);
466
+ }
467
+ async sendDmsMsg(a, b, c) {
468
+ if (typeof b === "string") {
469
+ const dms = await this.post("/users/@me/dms", {
470
+ recipient_id: a,
471
+ source_guild_id: b
472
+ });
473
+ const headers = c instanceof FormData ? c.getHeaders() : void 0;
474
+ return this.post(`/dms/${dms.guild_id}/messages`, c, headers);
475
+ }
476
+ const headers = b instanceof FormData ? b.getHeaders() : void 0;
477
+ return this.post(`/dms/${a}/messages`, b, headers);
478
+ }
479
+ async recall(type, peer, messageId, hidetip = false) {
480
+ const path = type === "user" || type === "group" ? `/v2/${type}s/${peer}/messages/${messageId}` : `/${type}/${peer}/messages/${messageId}?hidetip=${hidetip}`;
481
+ await this.delete(path);
482
+ return true;
483
+ }
484
+ };
485
+
486
+ //#endregion
487
+ //#region src/core/api/upload-cache.ts
488
+ /** 单进程内最多缓存的 file_info 数量,避免长期运行时内存无限增长。 */
489
+ const MAX_CACHE_SIZE = 500;
490
+ /** 比 QQ 返回的 ttl 提前失效,避免临界点使用到刚过期的 file_info。 */
491
+ const TTL_SAFETY_MARGIN_SECONDS = 60;
492
+ /** QQ 返回极短 ttl 时仍保留一个最小可用窗口。 */
493
+ const MIN_EFFECTIVE_TTL_SECONDS = 10;
494
+ const cache = /* @__PURE__ */ new Map();
495
+ /**
496
+ * 构建 file_info 缓存键。
497
+ *
498
+ * 虽然 QQ 文档说同一类会话内 file_info 可复用,但这里仍按 peer 隔离,
499
+ * 以避开未文档化的服务端限制。
500
+ *
501
+ * @param scene 上传场景。
502
+ * @param peer openid 或 group_openid。
503
+ * @param type 富媒体类型。
504
+ * @param contentHash 文件内容 hash。
505
+ * @returns 缓存键。
506
+ */
507
+ const cacheKey = (scene, peer, type, contentHash) => [
508
+ scene,
509
+ peer,
510
+ type,
511
+ contentHash
512
+ ].join("");
513
+ /**
514
+ * 惰性清理过期缓存。
515
+ * @param now 当前时间戳。
516
+ */
517
+ const cleanupExpired = (now = Date.now()) => {
518
+ for (const [key, entry] of cache) if (entry.expiresAt <= now) cache.delete(key);
519
+ };
520
+ /**
521
+ * 缓存满时淘汰最旧的一批条目。
522
+ */
523
+ const evictOldest = () => {
524
+ if (cache.size < MAX_CACHE_SIZE) return;
525
+ cleanupExpired();
526
+ if (cache.size < MAX_CACHE_SIZE) return;
527
+ const entries = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
528
+ const removeCount = Math.max(1, Math.ceil(MAX_CACHE_SIZE / 2));
529
+ for (const [key] of entries.slice(0, removeCount)) cache.delete(key);
530
+ };
531
+ /**
532
+ * 读取可复用的上传结果。
533
+ *
534
+ * @param scene 上传场景。
535
+ * @param peer openid 或 group_openid。
536
+ * @param type 富媒体类型。
537
+ * @param contentHash 文件内容 hash。
538
+ * @returns 未命中或过期时返回 null。
539
+ */
540
+ const getCachedUpload = (scene, peer, type, contentHash) => {
541
+ const key = cacheKey(scene, peer, type, contentHash);
542
+ const entry = cache.get(key);
543
+ if (!entry) return null;
544
+ if (entry.expiresAt <= Date.now()) {
545
+ cache.delete(key);
546
+ return null;
547
+ }
548
+ return { ...entry.response };
549
+ };
550
+ /**
551
+ * 写入上传结果缓存。
552
+ *
553
+ * @param scene 上传场景。
554
+ * @param peer openid 或 group_openid。
555
+ * @param type 富媒体类型。
556
+ * @param contentHash 文件内容 hash。
557
+ * @param response 上传接口返回。
558
+ */
559
+ const setCachedUpload = (scene, peer, type, contentHash, response) => {
560
+ if (!response.file_info) return;
561
+ evictOldest();
562
+ const now = Date.now();
563
+ const ttl = Number(response.ttl);
564
+ const effectiveTtl = ttl > 0 ? Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : Number.POSITIVE_INFINITY;
565
+ cache.set(cacheKey(scene, peer, type, contentHash), {
566
+ response: { ...response },
567
+ expiresAt: ttl > 0 ? now + effectiveTtl : Number.POSITIVE_INFINITY,
568
+ createdAt: now
569
+ });
570
+ };
571
+
572
+ //#endregion
573
+ //#region src/core/api/media.ts
574
+ const FILE_TYPE = {
575
+ image: 1,
576
+ video: 2,
577
+ record: 3,
578
+ file: 4
579
+ };
580
+ /** 各类资源的 QQ 上传上限。 */
581
+ const MAX_UPLOAD_SIZE = {
582
+ image: 30 * 1024 * 1024,
583
+ video: 100 * 1024 * 1024,
584
+ record: 20 * 1024 * 1024,
585
+ file: 100 * 1024 * 1024
586
+ };
587
+ /** 上传错误中展示的资源类型名称。 */
588
+ const MEDIA_TYPE_LABEL = {
589
+ image: "图片",
590
+ video: "视频",
591
+ record: "语音",
592
+ file: "文件"
593
+ };
594
+ /** fallback 上传超过该大小时走 QQ 分片上传。 */
595
+ const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
596
+ /** QQ upload_prepare 需要的前 10,002,432 字节 MD5。 */
597
+ const MD5_10M_SIZE = 10002432;
598
+ /** 分片上传时没有业务文件名的默认值。 */
599
+ const DEFAULT_FILENAME$1 = {
600
+ image: "image.jpg",
601
+ video: "video.mp4",
602
+ record: "record.mp3",
603
+ file: "file.bin"
604
+ };
605
+ /** 富媒体上传比普通接口慢,尤其本地视频会走 file_data。 */
606
+ const MEDIA_UPLOAD_TIMEOUT = 12e4;
607
+ /** URL 回源下载兜底超时。 */
608
+ const MEDIA_DOWNLOAD_TIMEOUT = 12e4;
609
+ /** 每个分片 PUT 到 QQ 临时地址的超时时间。 */
610
+ const PART_UPLOAD_TIMEOUT = 3e5;
611
+ /** 每个分片 PUT 失败后的重试次数。 */
612
+ const PART_UPLOAD_RETRIES = 2;
613
+ /** upload_part_finish 普通失败重试次数。 */
614
+ const PART_FINISH_RETRIES = 2;
615
+ /** upload_part_finish 普通失败重试基础间隔。 */
616
+ const PART_FINISH_BASE_DELAY = 1e3;
617
+ /** upload_part_finish 命中这些业务码时,需要按服务端给出的窗口持续重试。 */
618
+ const PART_FINISH_RETRYABLE_CODES = /* @__PURE__ */ new Set([40093001]);
619
+ /** 服务端未返回 retry_timeout 时,持续重试的默认时间。 */
620
+ const PART_FINISH_DEFAULT_RETRY_TIMEOUT = 120 * 1e3;
621
+ /** 持续重试窗口上限,避免服务端返回异常值导致任务长时间占用。 */
622
+ const PART_FINISH_MAX_RETRY_TIMEOUT = 600 * 1e3;
623
+ /** 持续重试时的固定间隔。 */
624
+ const PART_FINISH_RETRY_INTERVAL = 1e3;
625
+ /** 分片完成接口失败重试次数。 */
626
+ const COMPLETE_UPLOAD_RETRIES = 2;
627
+ /** 分片完成接口失败重试基础间隔。 */
628
+ const COMPLETE_UPLOAD_BASE_DELAY = 2e3;
629
+ /** upload_prepare 返回该业务码时表示大文件上传额度受限。 */
630
+ const UPLOAD_PREPARE_DAILY_LIMIT_CODE = 40093002;
631
+ /** QQ 没返回建议并发数时的默认并发。 */
632
+ const DEFAULT_PART_CONCURRENCY = 1;
633
+ /** 本地限制的最大分片并发,避免服务端返回过大值。 */
634
+ const MAX_PART_CONCURRENCY = 10;
635
+ /** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
636
+ const HTTP_URL_RE$1 = /^https?:\/\//i;
637
+ /** Karin 常见 base64 图片/媒体前缀。 */
638
+ const BASE64_PREFIX_RE = /^base64:\/\//i;
639
+ /** 标准 data URL 的 base64 前缀。 */
640
+ const DATA_URL_BASE64_RE = /^data:[^;,]+;base64,/i;
641
+ /**
642
+ * 判断字符串是否明显是本地文件路径。
643
+ * 不明显的字符串按裸 base64 兼容处理,避免破坏旧调用方式。
644
+ */
645
+ const isLocalPathSource = (source) => {
646
+ return source.startsWith("file://") || /^[a-zA-Z]:[\\/]/.test(source) || source.startsWith("/") || source.startsWith("\\") || source.startsWith("./") || source.startsWith("../") || source.startsWith(".\\") || source.startsWith("..\\");
647
+ };
648
+ /**
649
+ * 将 file:// 或普通路径统一为本地文件系统路径。
650
+ * @param source 本地路径或 file:// URL。
651
+ * @returns 可直接传给 fs 的本地路径。
652
+ */
653
+ const normalizeLocalPath = (source) => {
654
+ return source.startsWith("file://") ? fileURLToPath$1(source) : source;
655
+ };
656
+ /**
657
+ * 去掉 Karin/标准 data URL 的 base64 前缀。
658
+ * @param source base64 字符串。
659
+ * @returns QQ 上传接口需要的纯 base64 数据。
660
+ */
661
+ const stripBase64Prefix = (source) => {
662
+ return source.replace(DATA_URL_BASE64_RE, "").replace(BASE64_PREFIX_RE, "");
663
+ };
664
+ /**
665
+ * 将文件大小格式化为便于阅读的字符串。
666
+ * @param bytes 文件字节数。
667
+ * @returns 格式化后的文件大小。
668
+ */
669
+ const formatBytes = (bytes) => {
670
+ if (bytes < 1024) return `${bytes}B`;
671
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
672
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
673
+ };
674
+ /**
675
+ * 规范化上传文件名,避免把路径当成文件名交给 QQ。
676
+ * @param type 富媒体类型。
677
+ * @param name 候选文件名。
678
+ * @returns 安全的基础文件名。
679
+ */
680
+ const normalizeUploadFileName = (type, name) => {
681
+ const value = name?.trim();
682
+ if (!value) return DEFAULT_FILENAME$1[type];
683
+ return path.basename(value) || DEFAULT_FILENAME$1[type];
684
+ };
685
+ /**
686
+ * 检查 fallback 资源大小是否超过 QQ 对应类型上限。
687
+ * @param type 富媒体类型。
688
+ * @param size 文件大小。
689
+ */
690
+ const assertUploadSize = (type, size) => {
691
+ const limit = MAX_UPLOAD_SIZE[type];
692
+ if (size <= limit) return;
693
+ throw new Error(`${MEDIA_TYPE_LABEL[type]}过大:${formatBytes(size)},QQ 上传上限为 ${formatBytes(limit)}`);
694
+ };
695
+ /**
696
+ * 读取本地媒体文件并转为 QQ 上传接口需要的 base64。
697
+ * @param source 本地路径或 file:// URL。
698
+ * @returns 文件内容的 base64 字符串。
699
+ */
700
+ const readLocalFileAsBase64 = async (source) => {
701
+ return readFile(normalizeLocalPath(source), "base64");
702
+ };
703
+ /**
704
+ * 下载远程媒体并返回 Buffer。
705
+ * @param url 远程媒体 URL。
706
+ * @returns 文件内容。
707
+ */
708
+ const downloadRemoteAsBuffer = async (url) => {
709
+ const { data } = await axios.get(url, {
710
+ responseType: "arraybuffer",
711
+ timeout: MEDIA_DOWNLOAD_TIMEOUT,
712
+ maxBodyLength: Infinity,
713
+ maxContentLength: Infinity
714
+ });
715
+ return Buffer.from(data);
716
+ };
717
+ /**
718
+ * 下载远程媒体并转为 QQ 上传接口需要的 base64。
719
+ * @param url 远程媒体 URL。
720
+ * @returns 文件内容的 base64 字符串。
721
+ */
722
+ const downloadRemoteAsBase64 = async (url) => {
723
+ return (await downloadRemoteAsBuffer(url)).toString("base64");
724
+ };
725
+ /**
726
+ * 将 Karin 的 file 字段归一为 QQ 上传接口支持的 url / file_data。
727
+ * @param source HTTP URL、本地路径、data URL、base64:// 或裸 base64。
728
+ * @returns 可合并进上传请求体的来源字段。
729
+ */
730
+ const buildUploadSource = async (source) => {
731
+ const value = source.trim();
732
+ if (HTTP_URL_RE$1.test(value)) return { url: value };
733
+ if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
734
+ if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
735
+ return { file_data: value };
736
+ };
737
+ /**
738
+ * 将 fallback 来源归一为可判断大小、可分片读取的形式。
739
+ *
740
+ * @param type 富媒体类型。
741
+ * @param source Karin 消息段里的 file 字段。
742
+ * @param fileName 可选文件名。
743
+ * @returns 可用于普通上传或分片上传的来源。
744
+ */
745
+ const buildFallbackSource = async (type, source, fileName) => {
746
+ const value = source.trim();
747
+ const normalizedName = normalizeUploadFileName(type, fileName);
748
+ if (HTTP_URL_RE$1.test(value)) {
749
+ const buffer = await downloadRemoteAsBuffer(value);
750
+ return {
751
+ kind: "buffer",
752
+ buffer,
753
+ size: buffer.length,
754
+ fileName: normalizedName
755
+ };
756
+ }
757
+ if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) {
758
+ const buffer = Buffer.from(stripBase64Prefix(value), "base64");
759
+ return {
760
+ kind: "buffer",
761
+ buffer,
762
+ size: buffer.length,
763
+ fileName: normalizedName
764
+ };
765
+ }
766
+ if (isLocalPathSource(value)) {
767
+ const file = normalizeLocalPath(value);
768
+ const info = await stat(file);
769
+ const name = normalizeUploadFileName(type, fileName || path.basename(file));
770
+ return {
771
+ kind: "local",
772
+ file,
773
+ size: info.size,
774
+ fileName: name
775
+ };
776
+ }
777
+ const buffer = Buffer.from(value, "base64");
778
+ return {
779
+ kind: "buffer",
780
+ buffer,
781
+ size: buffer.length,
782
+ fileName: normalizedName
783
+ };
784
+ };
785
+ /**
786
+ * 读取 fallback 来源的小文件内容并转成 base64。
787
+ * @param source fallback 来源。
788
+ * @returns base64 字符串。
789
+ */
790
+ const readFallbackSourceAsBase64 = async (source) => {
791
+ if (source.kind === "buffer") return source.buffer.toString("base64");
792
+ return readFile(source.file, "base64");
793
+ };
794
+ /**
795
+ * 计算 QQ upload_prepare 所需的 hash。
796
+ *
797
+ * @param source fallback 来源。
798
+ * @returns 完整 MD5、完整 SHA1 与前 10MB MD5。
799
+ */
800
+ const computeUploadHashes = async (source) => {
801
+ if (source.kind === "buffer") {
802
+ const md5 = crypto.createHash("md5").update(source.buffer).digest("hex");
803
+ return {
804
+ md5,
805
+ sha1: crypto.createHash("sha1").update(source.buffer).digest("hex"),
806
+ md510m: source.size > MD5_10M_SIZE ? crypto.createHash("md5").update(source.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") : md5
807
+ };
808
+ }
809
+ return new Promise((resolve, reject) => {
810
+ const md5 = crypto.createHash("md5");
811
+ const sha1 = crypto.createHash("sha1");
812
+ const md510m = crypto.createHash("md5");
813
+ const needsMd510m = source.size > MD5_10M_SIZE;
814
+ let consumed = 0;
815
+ const stream = createReadStream(source.file);
816
+ stream.on("data", (chunk) => {
817
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
818
+ md5.update(buffer);
819
+ sha1.update(buffer);
820
+ if (needsMd510m) {
821
+ const remaining = MD5_10M_SIZE - consumed;
822
+ if (remaining > 0) md510m.update(remaining >= buffer.length ? buffer : buffer.subarray(0, remaining));
823
+ }
824
+ consumed += buffer.length;
825
+ });
826
+ stream.on("end", () => {
827
+ const md5Hex = md5.digest("hex");
828
+ resolve({
829
+ md5: md5Hex,
830
+ sha1: sha1.digest("hex"),
831
+ md510m: needsMd510m ? md510m.digest("hex") : md5Hex
832
+ });
833
+ });
834
+ stream.on("error", reject);
835
+ });
836
+ };
837
+ /**
838
+ * 从 fallback 来源读取指定分片。
839
+ * @param source fallback 来源。
840
+ * @param handle 本地文件句柄,buffer 来源不需要。
841
+ * @param offset 分片起始位置。
842
+ * @param length 分片长度。
843
+ * @returns 分片内容。
844
+ */
845
+ const readPart = async (source, handle, offset, length) => {
846
+ if (source.kind === "buffer") return source.buffer.subarray(offset, offset + length);
847
+ if (!handle) throw new Error("读取分片失败:文件句柄不存在");
848
+ const buffer = Buffer.alloc(length);
849
+ const { bytesRead } = await handle.read(buffer, 0, length, offset);
850
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
851
+ };
852
+ /**
853
+ * 带有限重试地把单个分片 PUT 到 QQ 返回的临时地址。
854
+ *
855
+ * @param url QQ 返回的分片上传地址。
856
+ * @param data 分片内容。
857
+ * @param partIndex 分片序号。
858
+ * @param totalParts 总分片数。
859
+ */
860
+ const putPart = async (url, data, partIndex, totalParts) => {
861
+ let lastError;
862
+ for (let attempt = 0; attempt <= PART_UPLOAD_RETRIES; attempt++) try {
863
+ await axios.put(url, data, {
864
+ headers: {
865
+ "Content-Type": "application/octet-stream",
866
+ "Content-Length": String(data.length)
867
+ },
868
+ timeout: PART_UPLOAD_TIMEOUT,
869
+ maxBodyLength: Infinity,
870
+ maxContentLength: Infinity
871
+ });
872
+ return;
873
+ } catch (err) {
874
+ lastError = err;
875
+ if (attempt < PART_UPLOAD_RETRIES) await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
876
+ }
877
+ throw new Error(`上传分片失败:${partIndex}/${totalParts},${lastError instanceof Error ? lastError.message : String(lastError)}`);
878
+ };
879
+ /**
880
+ * 使用固定并发执行异步任务。
881
+ * @param tasks 待执行任务。
882
+ * @param concurrency 最大并发。
883
+ */
884
+ const runWithConcurrency = async (tasks, concurrency) => {
885
+ let index = 0;
886
+ const workerCount = Math.max(1, Math.min(concurrency, tasks.length));
887
+ const workers = Array.from({ length: workerCount }, async () => {
888
+ while (index < tasks.length) {
889
+ const current = index;
890
+ index += 1;
891
+ await tasks[current]();
892
+ }
893
+ });
894
+ await Promise.all(workers);
895
+ };
896
+ /**
897
+ * 等待指定时间。
898
+ * @param ms 毫秒数。
899
+ */
900
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
901
+ /**
902
+ * 从 axios 原始错误或已格式化错误文本里提取 QQ 业务错误码。
903
+ * @param err 待检查的错误。
904
+ * @returns 业务错误码。
905
+ */
906
+ const openApiCode = (err) => {
907
+ if (axios.isAxiosError(err)) {
908
+ const data = err.response?.data;
909
+ const code = typeof data?.code === "number" ? data.code : typeof data?.err_code === "number" ? data.err_code : void 0;
910
+ if (code !== void 0) return code;
911
+ }
912
+ if (err instanceof Error) {
913
+ const match = err.message.match(/\[Code\s+(\d+)\]/);
914
+ if (match) return Number(match[1]);
915
+ }
916
+ };
917
+ /**
918
+ * 判断 upload_part_finish 是否命中需要持续重试的服务端状态。
919
+ * @param err 请求错误。
920
+ * @returns 是否需要持续重试。
921
+ */
922
+ const shouldRetryPartFinishPersistently = (err) => {
923
+ const code = openApiCode(err);
924
+ return code !== void 0 && PART_FINISH_RETRYABLE_CODES.has(code);
925
+ };
926
+ /**
927
+ * 将未知错误重新抛出为 Error。
928
+ * @param err 原始错误。
929
+ */
930
+ const throwError = (err) => {
931
+ if (err instanceof Error) throw err;
932
+ throw new Error(typeof err === "string" ? err : JSON.stringify(err));
933
+ };
934
+ /**
935
+ * 富媒体上传
936
+ */
937
+ var MediaApi = class extends Http {
938
+ /**
939
+ * @param scene user(单聊) / group(群聊)
940
+ * @param peer openid 或 group_openid
941
+ * @param type 文件类型
942
+ * @param source http url、本地路径或 base64 字符串
943
+ * @param srvSendMsg 上传后是否由服务端直接发送
944
+ * @param fileName 文件消息的展示文件名
945
+ */
946
+ async upload(scene, peer, type, source, srvSendMsg = false, fileName) {
947
+ const sourceBody = await buildUploadSource(source);
948
+ const body = {
949
+ file_type: FILE_TYPE[type],
950
+ srv_send_msg: srvSendMsg
951
+ };
952
+ Object.assign(body, sourceBody);
953
+ if (type === "file" && fileName) body.file_name = fileName;
954
+ const path = `/v2/${scene}s/${peer}/files`;
955
+ try {
956
+ return await this.post(path, body, void 0, MEDIA_UPLOAD_TIMEOUT);
957
+ } catch (err) {
958
+ if (!sourceBody.url) throw err;
959
+ const fallbackBody = {
960
+ ...body,
961
+ file_data: await downloadRemoteAsBase64(sourceBody.url)
962
+ };
963
+ delete fallbackBody.url;
964
+ return this.post(path, fallbackBody, void 0, MEDIA_UPLOAD_TIMEOUT);
965
+ }
966
+ }
967
+ /**
968
+ * 上传没有公网 URL 处理器可用时的 fallback 富媒体资源。
969
+ *
970
+ * 较大的 fallback 资源会优先使用 QQ 分片上传,提高直接发送成功率。
971
+ * `srvSendMsg` 为 true 时保留普通上传路径,避免分片完成接口与服务端直接发送语义冲突。
972
+ *
973
+ * @param scene user(单聊) / group(群聊)
974
+ * @param peer openid 或 group_openid
975
+ * @param type 文件类型
976
+ * @param source 本地路径、http url、data URL、base64:// 或裸 base64
977
+ * @param srvSendMsg 上传后是否由服务端直接发送
978
+ * @param fileName 文件消息的展示文件名
979
+ * @returns 上传结果,包含后续发送消息所需的 file_info
980
+ */
981
+ async uploadFallback(scene, peer, type, source, srvSendMsg = false, fileName) {
982
+ const fallbackSource = await buildFallbackSource(type, source, fileName);
983
+ assertUploadSize(type, fallbackSource.size);
984
+ const hashes = !srvSendMsg ? await computeUploadHashes(fallbackSource) : void 0;
985
+ if (hashes) {
986
+ const cached = getCachedUpload(scene, peer, type, hashes.md5);
987
+ if (cached) return cached;
988
+ }
989
+ if (!srvSendMsg && fallbackSource.size >= LARGE_UPLOAD_THRESHOLD) {
990
+ const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
991
+ if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
992
+ return response;
993
+ }
994
+ const body = {
995
+ file_type: FILE_TYPE[type],
996
+ srv_send_msg: srvSendMsg,
997
+ file_data: await readFallbackSourceAsBase64(fallbackSource)
998
+ };
999
+ if (type === "file") body.file_name = fallbackSource.fileName;
1000
+ const response = await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1001
+ if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
1002
+ return response;
1003
+ }
1004
+ /**
1005
+ * 确认单个分片已完成上传。
1006
+ *
1007
+ * QQ 有时会在分片刚 PUT 完时短暂返回 40093001,此时需要在服务端给出的
1008
+ * retry_timeout 窗口内持续重试,而不是直接判定整个大文件上传失败。
1009
+ *
1010
+ * @param scene user(单聊) / group(群聊)
1011
+ * @param peer openid 或 group_openid
1012
+ * @param body upload_part_finish 请求体
1013
+ * @param retryTimeout 服务端返回的持续重试秒数
1014
+ */
1015
+ async finishUploadPart(scene, peer, body, retryTimeout) {
1016
+ let lastError;
1017
+ for (let attempt = 0; attempt <= PART_FINISH_RETRIES; attempt++) try {
1018
+ await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1019
+ return;
1020
+ } catch (err) {
1021
+ if (shouldRetryPartFinishPersistently(err)) {
1022
+ await this.retryUploadPartFinish(scene, peer, body, retryTimeout);
1023
+ return;
1024
+ }
1025
+ lastError = err;
1026
+ if (attempt < PART_FINISH_RETRIES) await sleep(PART_FINISH_BASE_DELAY * 2 ** attempt);
1027
+ }
1028
+ throwError(lastError);
1029
+ }
1030
+ /**
1031
+ * 持续重试 upload_part_finish 的短暂服务端状态。
1032
+ *
1033
+ * @param scene user(单聊) / group(群聊)
1034
+ * @param peer openid 或 group_openid
1035
+ * @param body upload_part_finish 请求体
1036
+ * @param retryTimeout 服务端返回的持续重试秒数
1037
+ */
1038
+ async retryUploadPartFinish(scene, peer, body, retryTimeout) {
1039
+ const retryTimeoutMs = retryTimeout ? Math.min(Number(retryTimeout) * 1e3, PART_FINISH_MAX_RETRY_TIMEOUT) : PART_FINISH_DEFAULT_RETRY_TIMEOUT;
1040
+ const deadline = Date.now() + retryTimeoutMs;
1041
+ let attempts = 0;
1042
+ let lastError;
1043
+ while (Date.now() < deadline) try {
1044
+ await this.post(`/v2/${scene}s/${peer}/upload_part_finish`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1045
+ return;
1046
+ } catch (err) {
1047
+ lastError = err;
1048
+ if (!shouldRetryPartFinishPersistently(err)) throwError(err);
1049
+ attempts += 1;
1050
+ await sleep(Math.min(PART_FINISH_RETRY_INTERVAL, Math.max(0, deadline - Date.now())));
1051
+ }
1052
+ throw new Error(`upload_part_finish 持续重试超时:${Math.round(retryTimeoutMs / 1e3)}s,已重试 ${attempts} 次,最后错误:${lastError instanceof Error ? lastError.message : String(lastError)}`);
1053
+ }
1054
+ /**
1055
+ * 完成 QQ 分片上传,并对短暂失败做有限重试。
1056
+ *
1057
+ * @param scene user(单聊) / group(群聊)
1058
+ * @param peer openid 或 group_openid
1059
+ * @param uploadId upload_prepare 返回的上传任务 ID
1060
+ * @returns 上传结果
1061
+ */
1062
+ async completeChunkedUpload(scene, peer, uploadId) {
1063
+ let lastError;
1064
+ const body = { upload_id: uploadId };
1065
+ for (let attempt = 0; attempt <= COMPLETE_UPLOAD_RETRIES; attempt++) try {
1066
+ return await this.post(`/v2/${scene}s/${peer}/files`, body, void 0, MEDIA_UPLOAD_TIMEOUT);
1067
+ } catch (err) {
1068
+ lastError = err;
1069
+ if (attempt < COMPLETE_UPLOAD_RETRIES) await sleep(COMPLETE_UPLOAD_BASE_DELAY * 2 ** attempt);
1070
+ }
1071
+ return throwError(lastError);
1072
+ }
1073
+ /**
1074
+ * 使用 QQ 官方分片协议上传 fallback 大资源。
1075
+ *
1076
+ * 流程为:准备上传 -> PUT 每个分片 -> 确认每个分片 -> 完成上传。
1077
+ *
1078
+ * @param scene user(单聊) / group(群聊)
1079
+ * @param peer openid 或 group_openid
1080
+ * @param type 文件类型
1081
+ * @param source 已归一化的 fallback 来源
1082
+ * @param preparedHashes 预计算的文件 hash
1083
+ * @returns 上传结果,包含后续发送消息所需的 file_info
1084
+ */
1085
+ async uploadChunked(scene, peer, type, source, preparedHashes) {
1086
+ const hashes = preparedHashes || await computeUploadHashes(source);
1087
+ const prepareBody = {
1088
+ file_type: FILE_TYPE[type],
1089
+ file_name: source.fileName,
1090
+ file_size: source.size,
1091
+ md5: hashes.md5,
1092
+ sha1: hashes.sha1
1093
+ };
1094
+ prepareBody["md5_10m"] = hashes.md510m;
1095
+ const prepare = await this.post(`/v2/${scene}s/${peer}/upload_prepare`, prepareBody, void 0, MEDIA_UPLOAD_TIMEOUT).catch((err) => {
1096
+ if (openApiCode(err) === UPLOAD_PREPARE_DAILY_LIMIT_CODE) throw new Error(`QQ 大文件上传额度已达上限,${MEDIA_TYPE_LABEL[type]}暂时无法通过 fallback 直传:${source.fileName} (${formatBytes(source.size)})`);
1097
+ return throwError(err);
1098
+ });
1099
+ if (!prepare.parts.length) throw new Error("分片上传准备失败:QQ 未返回分片信息");
1100
+ const concurrency = Math.min(prepare.concurrency || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
1101
+ const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
1102
+ try {
1103
+ await runWithConcurrency(prepare.parts.map((part) => async () => {
1104
+ const offset = (part.index - 1) * prepare.block_size;
1105
+ const data = await readPart(source, handle, offset, Math.min(prepare.block_size, source.size - offset));
1106
+ const md5 = crypto.createHash("md5").update(data).digest("hex");
1107
+ await putPart(part.presigned_url, data, part.index, prepare.parts.length);
1108
+ await this.finishUploadPart(scene, peer, {
1109
+ upload_id: prepare.upload_id,
1110
+ part_index: part.index,
1111
+ block_size: data.length,
1112
+ md5
1113
+ }, prepare.retry_timeout);
1114
+ }), concurrency);
1115
+ } finally {
1116
+ await handle?.close().catch(() => void 0);
1117
+ }
1118
+ return this.completeChunkedUpload(scene, peer, prepare.upload_id);
1119
+ }
1120
+ };
1121
+
1122
+ //#endregion
1123
+ //#region src/core/api/interaction.ts
1124
+ /** QQ 客户端回调按钮需要快速 ACK,超时必须小于平台等待窗口。 */
1125
+ const INTERACTION_ACK_TIMEOUT = 2800;
1126
+ /**
1127
+ * INTERACTION_CREATE 回应
1128
+ */
1129
+ var InteractionApi = class extends Http {
1130
+ /**
1131
+ * @param interactionId INTERACTION_CREATE 事件中的 id
1132
+ * @param code 0 成功 / 1 操作失败 / 2 操作频繁 / 3 重复操作 / 4 无权限 / 5 仅管理员
1133
+ */
1134
+ async ack(interactionId, code = 0) {
1135
+ await this.put(`/interactions/${interactionId}`, { code }, void 0, INTERACTION_ACK_TIMEOUT);
1136
+ }
1137
+ };
1138
+
1139
+ //#endregion
1140
+ //#region src/core/api/meta.ts
1141
+ /**
1142
+ * 元信息接口:@me / dms / gateway
1143
+ */
1144
+ var MetaApi = class extends Http {
1145
+ /** 当前机器人详情 */
1146
+ getMe() {
1147
+ return this.get("/users/@me");
1148
+ }
1149
+ /** 创建频道私信会话 */
1150
+ createDms(recipientId, srcGuildId) {
1151
+ return this.post("/users/@me/dms", {
1152
+ recipient_id: recipientId,
1153
+ source_guild_id: srcGuildId
1154
+ });
1155
+ }
1156
+ /** 获取通用 WSS 地址 */
1157
+ getGateway() {
1158
+ return this.get("/gateway");
1159
+ }
1160
+ };
1161
+
1162
+ //#endregion
1163
+ //#region src/core/api/builders.ts
1164
+ /**
1165
+ * 构造 QQ 单聊 / 群聊请求体
1166
+ */
1167
+ const buildQQMsg = {
1168
+ text(content) {
1169
+ return {
1170
+ msg_type: 0,
1171
+ content
1172
+ };
1173
+ },
1174
+ markdown(markdown, keyboard) {
1175
+ return {
1176
+ msg_type: 2,
1177
+ markdown,
1178
+ keyboard
1179
+ };
1180
+ },
1181
+ ark(ark) {
1182
+ return {
1183
+ msg_type: 3,
1184
+ ark
1185
+ };
1186
+ },
1187
+ /** 构造 msg_type=7 富媒体消息体 */
1188
+ media(fileInfo) {
1189
+ return {
1190
+ msg_type: 7,
1191
+ media: { file_info: fileInfo }
1192
+ };
1193
+ }
1194
+ };
1195
+ /**
1196
+ * 构造频道请求体
1197
+ */
1198
+ const buildGuildMsg = {
1199
+ text(content, image) {
1200
+ return {
1201
+ type: "text",
1202
+ content,
1203
+ image
1204
+ };
1205
+ },
1206
+ image(image) {
1207
+ return {
1208
+ type: "image",
1209
+ image
1210
+ };
1211
+ },
1212
+ embed(embed) {
1213
+ return {
1214
+ type: "embed",
1215
+ embed
1216
+ };
1217
+ },
1218
+ ark(ark) {
1219
+ return {
1220
+ type: "ark",
1221
+ ark
1222
+ };
1223
+ },
1224
+ markdown(markdown, keyboard) {
1225
+ return {
1226
+ type: "markdown",
1227
+ markdown,
1228
+ keyboard
1229
+ };
1230
+ }
1231
+ };
1232
+
1233
+ //#endregion
1234
+ //#region src/core/api/index.ts
1235
+ /**
1236
+ * QQBot 官方 API 门面
1237
+ *
1238
+ * 子模块按职能拆分:
1239
+ * - messages:发送 / 撤回
1240
+ * - media:富媒体上传
1241
+ * - interaction:按钮回调 ack
1242
+ * - meta:@me / dms / gateway
1243
+ *
1244
+ * 请求体构造器 {@link buildQQMsg} / {@link buildGuildMsg} 独立导出,避免与 HTTP 调用耦合
1245
+ */
1246
+ var QQBotApi = class {
1247
+ axios;
1248
+ messages;
1249
+ media;
1250
+ interaction;
1251
+ meta;
1252
+ /** 请求体构造器(静态便利) */
1253
+ qq = buildQQMsg;
1254
+ guild = buildGuildMsg;
1255
+ constructor(axios) {
1256
+ this.axios = axios;
1257
+ this.messages = new MessagesApi(axios);
1258
+ this.media = new MediaApi(axios);
1259
+ this.interaction = new InteractionApi(axios);
1260
+ this.meta = new MetaApi(axios);
1261
+ }
1262
+ };
1263
+
1264
+ //#endregion
1265
+ //#region src/utils/user-agent.ts
1266
+ const USER_AGENT_SUFFIX = "Karin";
1267
+ /**
1268
+ * 构建 QQBot HTTP / WebSocket 使用的 User-Agent。
1269
+ */
1270
+ const getUserAgent = () => {
1271
+ return `QQBotAdapter/${String(pkg().version || "unknown")} (Node/${process.versions.node}; ${os.platform()}; ${USER_AGENT_SUFFIX}/${process.env.KARIN_VERSION || "unknown"})`;
1272
+ };
1273
+
1274
+ //#endregion
1275
+ //#region src/core/internal/axios.ts
1276
+ const tokens = /* @__PURE__ */ new Map();
1277
+ /**
1278
+ * 拉取 access_token 并安排下一次刷新
1279
+ * - 提前 50s 刷新,防止边界过期
1280
+ * - 同一 appId 旧定时器会被清理,避免并发刷新链
1281
+ */
1282
+ const getAccessToken = async (url, appId, secret) => {
1283
+ let res;
1284
+ try {
1285
+ res = await axios.post(url, {
1286
+ appId: String(appId),
1287
+ clientSecret: secret
1288
+ }, { headers: { "User-Agent": getUserAgent() } });
1289
+ } catch (err) {
1290
+ if (axios.isAxiosError(err)) {
1291
+ const response = err.response;
1292
+ const status = response?.status ?? 0;
1293
+ const data = response?.data;
1294
+ log("error", `[${appId}] 获取 access_token 失败 | ${formatOpenAPIError(status, typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0)}`);
1295
+ } else log("error", `[${appId}] 获取 access_token 失败:`, err);
1296
+ throw err;
1297
+ }
1298
+ const accessToken = res.data?.access_token;
1299
+ const expiresIn = Number(res.data?.expires_in);
1300
+ if (!accessToken || !expiresIn || Number.isNaN(expiresIn)) throw new Error(`获取 access_token 失败: ${JSON.stringify(res.data)}`);
1301
+ const prev = tokens.get(appId);
1302
+ if (prev?.refreshTimer) clearTimeout(prev.refreshTimer);
1303
+ const state = { accessToken };
1304
+ tokens.set(appId, state);
1305
+ const delay = Math.max(5e3, expiresIn * 1e3 - 5e4);
1306
+ state.refreshTimer = setTimeout(() => {
1307
+ getAccessToken(url, appId, secret).catch(() => {});
1308
+ }, delay);
1309
+ return {
1310
+ accessToken,
1311
+ expiresIn
1312
+ };
1313
+ };
1314
+ /**
1315
+ * 读取已缓存的 access_token
1316
+ */
1317
+ const getBotAccessToken = (appId) => {
1318
+ return tokens.get(appId)?.accessToken;
1319
+ };
1320
+ /**
1321
+ * 停止某个 bot 的 token 刷新(销毁 bot 时调用)
1322
+ */
1323
+ const stopTokenRefresh = (appId) => {
1324
+ const state = tokens.get(appId);
1325
+ if (!state) return;
1326
+ if (state.refreshTimer) clearTimeout(state.refreshTimer);
1327
+ tokens.delete(appId);
1328
+ };
1329
+ /**
1330
+ * 创建 axios 实例,自动从 tokens map 取最新 access_token
1331
+ */
1332
+ const createAxiosInstance = (baseURL, appId) => {
1333
+ const instance = axios.create({
1334
+ baseURL,
1335
+ headers: {
1336
+ "Content-Type": "application/json",
1337
+ "User-Agent": getUserAgent()
1338
+ },
1339
+ timeout: 5500
1340
+ });
1341
+ instance.interceptors.request.use((config) => {
1342
+ const token = tokens.get(appId)?.accessToken;
1343
+ if (token) config.headers.Authorization = `QQBot ${token}`;
1344
+ return config;
1345
+ });
1346
+ return instance;
1347
+ };
1348
+
1349
+ //#endregion
1350
+ //#region src/core/adapter/button-enter.ts
1351
+ /** QQ 私聊直发文本链标签。 */
1352
+ const COMMAND_ENTER_TEXT_CHAIN_REGEXP = /<\s*qqbot-cmd-enter\b/i;
1353
+ /**
1354
+ * 判断按钮是否是 `enter: true` 指令按钮。
1355
+ *
1356
+ * 链接按钮和回调按钮有自己的平台行为,只有普通指令按钮才需要提示
1357
+ * `enter: true` 在非私聊场景不生效。
1358
+ *
1359
+ * @param button Karin 按钮配置。
1360
+ * @returns 是否为直发指令按钮。
1361
+ */
1362
+ const isCommandEnterButton = (button) => {
1363
+ if (button.link || button.callback) return false;
1364
+ return (button.type ?? 2) === 2 && button.enter === true;
1365
+ };
1366
+ /**
1367
+ * 判断 markdown 内容是否手写了 QQ 私聊直发文本链。
1368
+ *
1369
+ * @param markdown markdown 内容。
1370
+ * @returns 是否包含 `<qqbot-cmd-enter ...>`。
1371
+ */
1372
+ const hasCommandEnterTextChain = (markdown) => {
1373
+ return COMMAND_ENTER_TEXT_CHAIN_REGEXP.test(markdown);
1374
+ };
1375
+ /**
1376
+ * 收集 button/keyboard 中所有 `enter: true` 指令按钮。
1377
+ *
1378
+ * @param buttons 单行按钮元素列表。
1379
+ * @param keyboards 多行 keyboard 元素列表。
1380
+ * @returns 需要特殊处理的直发指令按钮。
1381
+ */
1382
+ const collectCommandEnterButtons = (buttons, keyboards) => {
1383
+ const result = [];
1384
+ buttons.forEach((element) => {
1385
+ element.data.forEach((button) => {
1386
+ if (isCommandEnterButton(button)) result.push(button);
1387
+ });
1388
+ });
1389
+ keyboards.forEach((element) => {
1390
+ element.rows.forEach((row) => {
1391
+ row.forEach((button) => {
1392
+ if (isCommandEnterButton(button)) result.push(button);
1393
+ });
1394
+ });
1395
+ });
1396
+ return result;
1397
+ };
1398
+ /**
1399
+ * 归一 QQ keyboard 按钮字段。
1400
+ *
1401
+ * @param button node-karin 转换后的 QQBot 按钮。
1402
+ * @param id 当前 keyboard 内唯一按钮序号。
1403
+ * @returns 可直接放入 QQ 官方 keyboard 的按钮对象。
1404
+ */
1405
+ const normalizeQQBotButton = (button, id) => {
1406
+ return {
1407
+ ...button,
1408
+ id: String(id)
1409
+ };
1410
+ };
1411
+ /**
1412
+ * 生成直发按钮不支持提示中的按钮名称列表。
1413
+ *
1414
+ * @param buttons 直发指令按钮列表。
1415
+ * @returns 便于写入日志的按钮名称。
1416
+ */
1417
+ const formatCommandEnterButtonNames = (buttons) => {
1418
+ return buttons.map((button) => button.text || button.data || "未命名按钮").join("、");
1419
+ };
1420
+
1421
+ //#endregion
1422
+ //#region src/core/adapter/grouping.ts
1423
+ /**
1424
+ * 生成 QQ 被动回复使用的 msg_seq 种子。
1425
+ *
1426
+ * QQ 官方使用 `msg_id + msg_seq` 判重。这里和 openclaw qqbot 适配器一样限制在
1427
+ * 16-bit 范围内,后续每发一条再递增取模。
1428
+ *
1429
+ * @returns 0..65535 范围内的初始 msg_seq。
1430
+ */
1431
+ const createMsgSeqSeed = () => {
1432
+ return (Date.now() % 1e8 ^ Math.floor(Math.random() * 65536)) % 65536;
1433
+ };
1434
+ /**
1435
+ * 创建空容器
1436
+ */
1437
+ const createGrouping = () => ({
1438
+ text: [],
1439
+ qqImages: [],
1440
+ guildImageUrls: [],
1441
+ guildImageFiles: [],
1442
+ buttons: [],
1443
+ keyboards: [],
1444
+ markdowns: [],
1445
+ media: [],
1446
+ reply: { messageId: "" },
1447
+ pasmsg: {
1448
+ type: "msg",
1449
+ id: "",
1450
+ seq: createMsgSeqSeed()
1451
+ },
1452
+ faces: []
1453
+ });
1454
+ /**
1455
+ * 将 karin elements 归类到容器
1456
+ */
1457
+ const groupElements = (scene, elements) => {
1458
+ const g = createGrouping();
1459
+ const isQQ = scene === "group" || scene === "friend";
1460
+ for (const v of elements) switch (v.type) {
1461
+ case "text":
1462
+ g.text.push(v.text);
1463
+ break;
1464
+ case "image":
1465
+ if (isQQ) g.qqImages.push(v.file);
1466
+ else v.file.startsWith("http") ? g.guildImageUrls.push(v.file) : g.guildImageFiles.push(v.file);
1467
+ break;
1468
+ case "at":
1469
+ if (scene === "friend") break;
1470
+ if (v.targetId === "all") g.text.push("<qqbot-at-everyone />");
1471
+ else g.text.push(`<qqbot-at-user id="${v.targetId}" />`);
1472
+ break;
1473
+ case "face":
1474
+ if (!isQQ) {
1475
+ g.text.push(`<emoji:${v.id}>`);
1476
+ g.faces.push(Number(v.id));
1477
+ }
1478
+ break;
1479
+ case "reply":
1480
+ g.reply.messageId = v.messageId;
1481
+ break;
1482
+ case "pasmsg":
1483
+ if (v.source === "event") g.pasmsg.type = "event";
1484
+ g.pasmsg.id = v.id;
1485
+ break;
1486
+ case "button":
1487
+ g.buttons.push(v);
1488
+ break;
1489
+ case "keyboard":
1490
+ g.keyboards.push(v);
1491
+ break;
1492
+ case "markdown":
1493
+ g.markdowns.push(v);
1494
+ break;
1495
+ case "video":
1496
+ g.media.push({
1497
+ kind: "video",
1498
+ source: v.file,
1499
+ name: v.name
1500
+ });
1501
+ break;
1502
+ case "record":
1503
+ g.media.push({
1504
+ kind: "record",
1505
+ source: v.file,
1506
+ name: v.name
1507
+ });
1508
+ break;
1509
+ case "file":
1510
+ g.media.push({
1511
+ kind: "file",
1512
+ source: v.file,
1513
+ name: v.name
1514
+ });
1515
+ break;
1516
+ default: break;
1517
+ }
1518
+ return g;
1519
+ };
1520
+
1521
+ //#endregion
1522
+ //#region src/core/adapter/media-source.ts
1523
+ /** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
1524
+ const HTTP_URL_RE = /^https?:\/\//i;
1525
+ /** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
1526
+ const DEFAULT_FILENAME = {
1527
+ image: "image.jpg",
1528
+ video: "video.mp4",
1529
+ record: "record.mp3",
1530
+ file: "file.bin"
1531
+ };
1532
+ /**
1533
+ * 当前 Karin 运行时是否注册了 fileToUrl 处理器。
1534
+ * @returns true 表示可以优先通过 fileToUrl 产出公网 URL。
1535
+ */
1536
+ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
1537
+ /**
1538
+ * 判断一个来源是否已经是公网 HTTP(S) 地址。
1539
+ * @param source Karin 消息段里的 file 字段。
1540
+ * @returns 是否为 HTTP(S) URL。
1541
+ */
1542
+ const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
1543
+ /**
1544
+ * 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
1545
+ *
1546
+ * QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
1547
+ * fileToUrl 作为主路径,QQ 上传只做兜底。
1548
+ *
1549
+ * @param ctx 适配器实例,用于记录降级日志。
1550
+ * @param type 富媒体类型。
1551
+ * @param source Karin 消息段里的 file 字段。
1552
+ * @param filename 可选文件名,主要用于 file 消息。
1553
+ * @returns 解析后的首选来源和来源类型。
1554
+ */
1555
+ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
1556
+ if (isHttpMediaSource(source)) return {
1557
+ source,
1558
+ via: "public-url"
1559
+ };
1560
+ if (!hasFileToUrlHandler()) return {
1561
+ source,
1562
+ via: "fallback"
1563
+ };
1564
+ try {
1565
+ return {
1566
+ source: (await fileToUrl(type, source, filename || DEFAULT_FILENAME[type])).url,
1567
+ via: "file-to-url"
1568
+ };
1569
+ } catch (err) {
1570
+ ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
1571
+ return {
1572
+ source,
1573
+ via: "fallback"
1574
+ };
1575
+ }
1576
+ };
1577
+
1578
+ //#endregion
1579
+ //#region src/core/adapter/message-id-map.ts
1580
+ /** QQ 消息 ID 与 REFIDX 映射的短期缓存时长,和本地消息缓存窗口保持一致。 */
1581
+ const MESSAGE_ID_LINK_CACHE_TTL = 1440 * 60 * 1e3;
1582
+ /** QQ 消息 ID 映射表清理间隔。 */
1583
+ const MESSAGE_ID_LINK_CLEANUP_INTERVAL = 60 * 1e3;
1584
+ /** 按适配器实例隔离的消息 ID 映射状态。 */
1585
+ const states = /* @__PURE__ */ new WeakMap();
1586
+ /**
1587
+ * 获取适配器实例对应的消息 ID 映射状态。
1588
+ *
1589
+ * @param ctx 当前 QQBot 适配器实例。
1590
+ * @returns 该实例的映射状态。
1591
+ */
1592
+ const getState = (ctx) => {
1593
+ const existing = states.get(ctx);
1594
+ if (existing) return existing;
1595
+ const created = {
1596
+ apiMessageIdCache: /* @__PURE__ */ new Map(),
1597
+ referenceMessageIdCache: /* @__PURE__ */ new Map(),
1598
+ ownMessageIdCache: /* @__PURE__ */ new Map(),
1599
+ lastCleanup: 0
1600
+ };
1601
+ states.set(ctx, created);
1602
+ return created;
1603
+ };
1604
+ /**
1605
+ * 构造 REFIDX/API ID 映射的会话级 key。
1606
+ *
1607
+ * @param contact 消息所在会话。
1608
+ * @param messageId 消息 ID。
1609
+ * @returns 可用于 Map 的稳定 key。
1610
+ */
1611
+ const messageIdKey = (contact, messageId) => {
1612
+ return [
1613
+ contact.scene,
1614
+ contact.peer,
1615
+ contact.subPeer || "",
1616
+ messageId
1617
+ ].join("");
1618
+ };
1619
+ /**
1620
+ * 清理过期的消息 ID 映射。
1621
+ *
1622
+ * @param ctx 当前 QQBot 适配器实例。
1623
+ */
1624
+ const cleanup = (ctx) => {
1625
+ const state = getState(ctx);
1626
+ const now = Date.now();
1627
+ if (now - state.lastCleanup < MESSAGE_ID_LINK_CLEANUP_INTERVAL) return;
1628
+ state.lastCleanup = now;
1629
+ for (const [key, entry] of state.apiMessageIdCache) if (entry.expiresAt <= now) state.apiMessageIdCache.delete(key);
1630
+ for (const [key, entry] of state.referenceMessageIdCache) if (entry.expiresAt <= now) state.referenceMessageIdCache.delete(key);
1631
+ for (const [key, entry] of state.ownMessageIdCache) if (entry.expiresAt <= now) state.ownMessageIdCache.delete(key);
1632
+ };
1633
+ /**
1634
+ * 记住机器人自己发送的 QQ API 消息 ID。
1635
+ *
1636
+ * 单聊场景只允许撤回机器人自己发送的消息。收到用户消息时的 `e.messageId`
1637
+ * 也是 API 消息 ID,但不能用于单聊撤回,因此需要独立标记来源。
1638
+ *
1639
+ * @param ctx 当前 QQBot 适配器实例。
1640
+ * @param contact 消息所在会话。
1641
+ * @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
1642
+ */
1643
+ const rememberOwnMessageId = (ctx, contact, apiMessageId) => {
1644
+ if (!apiMessageId) return;
1645
+ cleanup(ctx);
1646
+ getState(ctx).ownMessageIdCache.set(messageIdKey(contact, apiMessageId), { expiresAt: Date.now() + MESSAGE_ID_LINK_CACHE_TTL });
1647
+ };
1648
+ /**
1649
+ * 判断给定 QQ API 消息 ID 是否由机器人自己发送。
1650
+ *
1651
+ * @param ctx 当前 QQBot 适配器实例。
1652
+ * @param contact 消息所在会话。
1653
+ * @param apiMessageId QQ API 消息 ID。
1654
+ * @returns true 表示这条消息是当前适配器实例发送的。
1655
+ */
1656
+ const isOwnMessageId = (ctx, contact, apiMessageId) => {
1657
+ cleanup(ctx);
1658
+ const state = getState(ctx);
1659
+ const key = messageIdKey(contact, apiMessageId);
1660
+ const cached = state.ownMessageIdCache.get(key);
1661
+ if (!cached) return false;
1662
+ if (cached.expiresAt > Date.now()) return true;
1663
+ state.ownMessageIdCache.delete(key);
1664
+ return false;
1665
+ };
1666
+ /**
1667
+ * 记住 QQ 引用索引对应的 API 消息 ID。
1668
+ *
1669
+ * QQ 群聊/单聊的 `message_reference.message_id` 使用 REFIDX,但撤回等 API 路径使用
1670
+ * ROBOT1.0 开头的消息 ID。映射按适配器实例和会话隔离,避免不同群里的 REFIDX 冲突。
1671
+ *
1672
+ * @param ctx 当前 QQBot 适配器实例。
1673
+ * @param contact 消息所在会话。
1674
+ * @param referenceMessageId QQ 引用索引,通常为 REFIDX_...。
1675
+ * @param apiMessageId QQ API 消息 ID,通常为 ROBOT1.0_...。
1676
+ */
1677
+ const rememberApiMessageId = (ctx, contact, referenceMessageId, apiMessageId) => {
1678
+ if (!referenceMessageId || !apiMessageId) return;
1679
+ if (referenceMessageId === apiMessageId) return;
1680
+ cleanup(ctx);
1681
+ const state = getState(ctx);
1682
+ const expiresAt = Date.now() + MESSAGE_ID_LINK_CACHE_TTL;
1683
+ state.apiMessageIdCache.set(messageIdKey(contact, referenceMessageId), {
1684
+ apiMessageId,
1685
+ expiresAt
1686
+ });
1687
+ state.referenceMessageIdCache.set(messageIdKey(contact, apiMessageId), {
1688
+ referenceMessageId,
1689
+ expiresAt
1690
+ });
1691
+ };
1692
+ /**
1693
+ * 将 QQ API 消息 ID 解析为客户端可见引用使用的 REFIDX。
1694
+ *
1695
+ * @param ctx 当前 QQBot 适配器实例。
1696
+ * @param contact 消息所在会话。
1697
+ * @param messageId Karin 侧传入的消息 ID,可能是 ROBOT1.0 或 REFIDX。
1698
+ * @returns 可用于 `message_reference.message_id` 的 ID;没有映射时返回原值。
1699
+ */
1700
+ const resolveReferenceMessageId = (ctx, contact, messageId) => {
1701
+ cleanup(ctx);
1702
+ const state = getState(ctx);
1703
+ const key = messageIdKey(contact, messageId);
1704
+ const cached = state.referenceMessageIdCache.get(key);
1705
+ if (!cached) return messageId;
1706
+ if (cached.expiresAt > Date.now()) return cached.referenceMessageId;
1707
+ state.referenceMessageIdCache.delete(key);
1708
+ return messageId;
1709
+ };
1710
+ /**
1711
+ * 将 Karin 侧传入的消息 ID 解析为 QQ API 可用的消息 ID。
1712
+ *
1713
+ * @param ctx 当前 QQBot 适配器实例。
1714
+ * @param contact 消息所在会话。
1715
+ * @param messageId Karin 侧传入的消息 ID,可能是 REFIDX。
1716
+ * @returns QQ API 路径可用的消息 ID;没有映射时返回原值。
1717
+ */
1718
+ const resolveApiMessageId = (ctx, contact, messageId) => {
1719
+ cleanup(ctx);
1720
+ const state = getState(ctx);
1721
+ const key = messageIdKey(contact, messageId);
1722
+ const cached = state.apiMessageIdCache.get(key);
1723
+ if (!cached) return messageId;
1724
+ if (cached.expiresAt > Date.now()) return cached.apiMessageId;
1725
+ state.apiMessageIdCache.delete(key);
1726
+ return messageId;
1727
+ };
1728
+
1729
+ //#endregion
1730
+ //#region src/core/adapter/text-to-md.ts
1731
+ const MAX_BUTTONS = 5;
1732
+ /**
1733
+ * 提取文本中的 URL,将其替换为 `[按钮 N]` 占位文本并返回 button 列表
1734
+ *
1735
+ * @param text 文本
1736
+ * @param friendly 是否为单聊(true 时清除残余的 at 标签)
1737
+ */
1738
+ const extractUrlButtons = (text, friendly = false) => {
1739
+ let str = text.replace(/@everyone/g, "everyone").replace(/\n/g, "\r");
1740
+ if (friendly) str = str.replace(/<qqbot-at-user id="[^"]+"\s*\/?>/g, "").replace(/<@!\d+>/g, "");
1741
+ const urls = handleUrl(str);
1742
+ if (urls.length === 0) return {
1743
+ text: str,
1744
+ buttons: []
1745
+ };
1746
+ const buttons = [];
1747
+ urls.forEach((url, i) => {
1748
+ str = str.replace(new RegExp(url, "g"), `[请点击 按钮${i} 查看]`);
1749
+ buttons.push(segment.button({
1750
+ text: `${i}. ${url}`,
1751
+ link: url
1752
+ }));
1753
+ });
1754
+ return {
1755
+ text: str,
1756
+ buttons: buttons.slice(0, MAX_BUTTONS)
1757
+ };
1758
+ };
1759
+ /**
1760
+ * 将图片列表合并到一段 markdown 文本内(每张图占一行)
1761
+ */
1762
+ const imagesToMarkdown = async (urls) => {
1763
+ return Promise.all(urls.map(async (file) => {
1764
+ if (file.startsWith("http")) {
1765
+ const { width, height } = await getImageSize(file);
1766
+ return `![karin #${width}px #${height}px](${file})`;
1767
+ }
1768
+ const { url, width, height } = await fileToUrl("image", file, "image.jpg");
1769
+ return `![karin #${width}px #${height}px](${url})`;
1770
+ }));
1771
+ };
1772
+
1773
+ //#endregion
1774
+ //#region src/core/adapter/pipeline-qq.ts
1775
+ /** QQ 官方限制:单聊中同一 `msg_id` 最多发送四次被动回复。 */
1776
+ const C2C_PASSIVE_REPLY_LIMIT = 4;
1777
+ /** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
1778
+ const KEYBOARD_MAX_ROWS$1 = 5;
1779
+ const KEYBOARD_MAX_BUTTONS_PER_ROW$1 = 5;
1780
+ /** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
1781
+ const BUTTON_ONLY_MARKDOWN$1 = "​";
1782
+ /** 群聊 event_id 白名单 */
1783
+ const GROUP_EVENT_WHITELIST = /* @__PURE__ */ new Set([
1784
+ "INTERACTION_CREATE",
1785
+ "GROUP_ADD_ROBOT",
1786
+ "GROUP_MSG_RECEIVE"
1787
+ ]);
1788
+ /** 单聊 event_id 白名单 */
1789
+ const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
1790
+ "INTERACTION_CREATE",
1791
+ "C2C_MSG_RECEIVE",
1792
+ "FRIEND_ADD"
1793
+ ]);
1794
+ /**
1795
+ * 处理 QQ 场景(单聊 + 群聊)的发送
1796
+ */
1797
+ const sendQQ = async (ctx, contact, elements) => {
1798
+ const target = contact.scene === "friend" ? "user" : "group";
1799
+ const grouping = groupElements(contact.scene, elements);
1800
+ if (ctx.cfg.keyboard.enable && grouping.text.length) {
1801
+ const { text, buttons } = extractUrlButtons(grouping.text.join(""), contact.scene === "friend");
1802
+ grouping.text = [text];
1803
+ grouping.buttons.push(...buttons);
1804
+ }
1805
+ /**
1806
+ * 默认走 markdown 通道:
1807
+ * text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
1808
+ * 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 单独补发。所有富媒体
1809
+ * 都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
1810
+ * 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
1811
+ */
1812
+ return sendQQMarkdown(ctx, contact, grouping, target);
1813
+ };
1814
+ /**
1815
+ * Markdown 通道:合成一条 msg_type=2 + 可选 keyboard
1816
+ */
1817
+ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
1818
+ const items = [];
1819
+ const lines = [];
1820
+ if (grouping.text.length) lines.push(grouping.text.join(""));
1821
+ const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages);
1822
+ if (grouping.markdowns.length) grouping.markdowns.forEach((m) => lines.push(m.markdown));
1823
+ if (contact.scene === "group") {
1824
+ warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
1825
+ warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
1826
+ }
1827
+ if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(ctx, contact, grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
1828
+ else {
1829
+ const keyboard = buildKeyboard$1(grouping);
1830
+ const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
1831
+ items.push(ctx.super.qq.markdown({ content }, keyboard));
1832
+ }
1833
+ for (const file of fallbackImages) {
1834
+ const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
1835
+ items.push(ctx.super.qq.media(res.file_info));
1836
+ }
1837
+ for (const m of grouping.media) {
1838
+ const source = await resolvePreferredMediaSource(ctx, m.kind, m.source, m.name);
1839
+ const res = source.via === "fallback" ? await ctx.super.media.uploadFallback(target, contact.peer, m.kind, source.source, false, m.name) : await ctx.super.media.upload(target, contact.peer, m.kind, source.source, false, m.name);
1840
+ items.push(ctx.super.qq.media(res.file_info));
1841
+ }
1842
+ if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
1843
+ return flushQQ(ctx, contact, grouping, items);
1844
+ };
1845
+ /**
1846
+ * 将能转成公网 URL 的图片追加到 markdown 行,无法转换的图片返回给富媒体 fallback。
1847
+ *
1848
+ * @param ctx 适配器实例,用于输出降级日志。
1849
+ * @param lines markdown 行数组。
1850
+ * @param files QQ 图片消息段的 file 字段。
1851
+ * @returns 需要改走 msg_type=7 富媒体发送的图片列表。
1852
+ */
1853
+ const appendMarkdownImages = async (ctx, lines, files) => {
1854
+ const fallback = [];
1855
+ for (const file of files) {
1856
+ const resolved = await resolvePreferredMediaSource(ctx, "image", file);
1857
+ if (resolved.via === "fallback") {
1858
+ fallback.push(file);
1859
+ continue;
1860
+ }
1861
+ await appendImageUrlLine(lines, resolved.source);
1862
+ }
1863
+ return fallback;
1864
+ };
1865
+ /**
1866
+ * 生成单张 markdown 图片行。
1867
+ * 尺寸获取失败时使用保守默认尺寸,保证 fileToUrl 成功的图片仍走 markdown。
1868
+ *
1869
+ * @param lines markdown 行数组。
1870
+ * @param url 图片 URL。
1871
+ */
1872
+ const appendImageUrlLine = async (lines, url) => {
1873
+ try {
1874
+ const [line] = await imagesToMarkdown([url]);
1875
+ lines.push(line);
1876
+ } catch {
1877
+ lines.push(`![karin #300px #300px](${url})`);
1878
+ }
1879
+ };
1880
+ /**
1881
+ * 判断显式引用回复是否应降级为普通文本消息。
1882
+ *
1883
+ * QQ 的 `message_reference` 对普通文本最稳定;markdown 带引用在客户端渲染上存在兼容问题。
1884
+ * 因此只有纯文本显式引用回复会走 msg_type=0,图片、按钮、keyboard、markdown 段仍走原通道。
1885
+ *
1886
+ * @param grouping 已归类的消息段。
1887
+ * @returns 是否使用普通文本发送主消息。
1888
+ */
1889
+ const shouldUseTextForReference = (ctx, contact, grouping) => {
1890
+ return isQQReferenceMessageId(resolveReferenceMessageId(ctx, contact, grouping.reply.messageId)) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
1891
+ };
1892
+ /**
1893
+ * 判断给定 ID 是否是 QQ 客户端引用使用的 REFIDX。
1894
+ *
1895
+ * `ROBOT1.0_...` 这类官方消息 ID 只能作为被动发送的 `msg_id`,不能放进
1896
+ * `message_reference.message_id`。
1897
+ *
1898
+ * @param messageId 待判断的消息 ID。
1899
+ * @returns 是否为 QQ 引用索引。
1900
+ */
1901
+ const isQQReferenceMessageId = (messageId) => messageId.startsWith("REFIDX_");
1902
+ /**
1903
+ * 构造 keyboard 字段(buttons + keyboards 合并)
1904
+ */
1905
+ const buildKeyboard$1 = (grouping) => {
1906
+ const rows = [];
1907
+ grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
1908
+ grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
1909
+ const normalizedRows = [];
1910
+ let id = 0;
1911
+ for (const row of rows.slice(0, KEYBOARD_MAX_ROWS$1)) {
1912
+ const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW$1).map((button) => normalizeQQBotButton(button, id++));
1913
+ if (buttons.length) normalizedRows.push({ buttons });
1914
+ }
1915
+ if (!normalizedRows.length) return void 0;
1916
+ return { content: { rows: normalizedRows } };
1917
+ };
1918
+ /**
1919
+ * 群聊不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
1920
+ *
1921
+ * @param ctx 适配器实例,用于输出日志。
1922
+ * @param buttons 本次消息中的直发指令按钮。
1923
+ */
1924
+ const warnUnsupportedCommandEnterButtons$1 = (ctx, buttons) => {
1925
+ if (!buttons.length) return;
1926
+ const names = formatCommandEnterButtonNames(buttons);
1927
+ ctx.logger("debug", `群聊不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
1928
+ };
1929
+ /**
1930
+ * 群聊不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
1931
+ *
1932
+ * @param ctx 适配器实例,用于输出日志。
1933
+ * @param markdowns 本次消息中的 markdown 内容。
1934
+ */
1935
+ const warnUnsupportedCommandEnterMarkdowns$1 = (ctx, markdowns) => {
1936
+ if (!markdowns.some(hasCommandEnterTextChain)) return;
1937
+ ctx.logger("debug", "群聊不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送");
1938
+ };
1939
+ /**
1940
+ * 逐条发送 + 被动消息附加 + 引用回复
1941
+ */
1942
+ const flushQQ = async (ctx, contact, grouping, items) => {
1943
+ const result = ctx.initSendMsgResults();
1944
+ const passiveSource = resolvePassiveQQ(grouping);
1945
+ const passive = buildPassiveQQ(ctx, contact.scene, grouping);
1946
+ const send = contact.scene === "friend" ? (peer, item) => ctx.super.messages.sendFriendMsg(peer, item) : (peer, item) => ctx.super.messages.sendGroupMsg(peer, item);
1947
+ /**
1948
+ * 仅单聊 `msg_id` 回复受四次上限约束。`event_id` 事件回复和群聊不适用该限制。
1949
+ * 这是 QQ 官方 2026-01-10 更新后的限制,超出部分不请求平台接口。
1950
+ */
1951
+ const maxItems = contact.scene === "friend" && passiveSource?.type === "msg" && passiveSource.id ? C2C_PASSIVE_REPLY_LIMIT : Infinity;
1952
+ if (items.length > maxItems) ctx.logger("warn", `单聊被动回复最多 ${C2C_PASSIVE_REPLY_LIMIT} 条,已跳过 ${items.length - maxItems} 条`);
1953
+ let referenceHandled = false;
1954
+ for (const item of items.slice(0, maxItems)) {
1955
+ passive(item);
1956
+ if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(ctx, contact, item, grouping);
1957
+ const res = await sendQQWithEventFallback(ctx, contact, send, item);
1958
+ rememberOwnMessageId(ctx, contact, res.id);
1959
+ if (res.ext_info?.ref_idx) rememberApiMessageId(ctx, contact, res.ext_info.ref_idx, res.id);
1960
+ result.rawData.push(res);
1961
+ }
1962
+ return ctx.handleResponse(result);
1963
+ };
1964
+ /**
1965
+ * 发送 QQ 消息,并在平台拒绝 `event_id` 时降级为普通消息重试。
1966
+ *
1967
+ * QQ 文档说明 `INTERACTION_CREATE.d.id` 可用于被动消息发送,但实测群聊接口会出现
1968
+ * 40034025。为了避免按钮回调业务回复把 Karin 命令链路打断,这里只对
1969
+ * `event_id` 参数无效做一次无 `event_id` 重试。
1970
+ *
1971
+ * @param ctx 适配器实例,用于输出降级日志。
1972
+ * @param contact 消息目标。
1973
+ * @param send 当前场景的发送函数。
1974
+ * @param item 即将发送的 QQ 消息体。
1975
+ * @returns QQ 消息发送响应。
1976
+ */
1977
+ const sendQQWithEventFallback = async (ctx, contact, send, item) => {
1978
+ try {
1979
+ return await send(contact.peer, item);
1980
+ } catch (err) {
1981
+ if (!item.event_id || !isInvalidEventIdError(err)) throw err;
1982
+ ctx.logger("warn", `event_id 被 QQ 拒绝,已改用普通消息重试: ${item.event_id}`);
1983
+ const retryItem = { ...item };
1984
+ delete retryItem.event_id;
1985
+ delete retryItem.msg_seq;
1986
+ return send(contact.peer, retryItem);
1987
+ }
1988
+ };
1989
+ /**
1990
+ * 判断发送失败是否来自 QQ 对 `event_id` 的参数校验。
1991
+ *
1992
+ * 当前错误由 Http 层格式化为普通 Error,因此这里基于官方错误码和中文错误信息兜底识别。
1993
+ *
1994
+ * @param err 捕获到的发送异常。
1995
+ * @returns 是否可以通过移除 `event_id` 重试。
1996
+ */
1997
+ const isInvalidEventIdError = (err) => {
1998
+ const message = err instanceof Error ? err.message : String(err);
1999
+ return message.includes("40034025") || message.includes("请求参数event_id无效");
2000
+ };
2001
+ /**
2002
+ * 解析 QQ 场景应使用的被动回复来源。
2003
+ *
2004
+ * `msg_id/event_id` 是 QQBot 被动发送凭证,不等于客户端展示引用使用的
2005
+ * `message_reference.message_id`。因此这里只读取事件入口追加的 pasmsg,显式
2006
+ * `segment.reply` 交给 `attachVisibleReferenceQQ` 处理。
2007
+ *
2008
+ * @param grouping 已归类的消息段。
2009
+ * @returns 可附加到发送体的被动回复来源;没有可用来源时返回 undefined。
2010
+ */
2011
+ const resolvePassiveQQ = (grouping) => {
2012
+ if (grouping.pasmsg.id) return grouping.pasmsg;
2013
+ };
2014
+ /**
2015
+ * 附加 QQ 客户端可见的引用对象。
2016
+ *
2017
+ * 群聊/单聊里真正发给 QQ 的值必须是 `message_scene.ext` 的 `msg_idx=REFIDX_xxx`。
2018
+ * 如果插件传入的是 Karin 暴露的官方 API 消息 ID,这里会先按同会话映射转换。
2019
+ *
2020
+ * @param item 即将发送的 QQ 消息体。
2021
+ * @param grouping 已归类的消息段。
2022
+ * @returns 是否已经消费一次显式引用。
2023
+ */
2024
+ const attachVisibleReferenceQQ = (ctx, contact, item, grouping) => {
2025
+ const messageId = resolveReferenceMessageId(ctx, contact, grouping.reply.messageId);
2026
+ if (!isQQReferenceMessageId(messageId)) return false;
2027
+ item.message_reference = { message_id: messageId };
2028
+ return true;
2029
+ };
2030
+ /**
2031
+ * 递增并返回下一条 QQ 被动回复序号。
2032
+ *
2033
+ * @param grouping 已归类的消息段。
2034
+ * @returns 0..65535 范围内的 msg_seq。
2035
+ */
2036
+ const nextPassiveMsgSeq = (grouping) => {
2037
+ grouping.pasmsg.seq = (grouping.pasmsg.seq + 1) % 65536;
2038
+ return grouping.pasmsg.seq;
2039
+ };
2040
+ /**
2041
+ * 构造被动消息附加器(含白名单校验)
2042
+ */
2043
+ const buildPassiveQQ = (ctx, scene, grouping) => {
2044
+ const source = resolvePassiveQQ(grouping);
2045
+ if (!source?.id) return (_item) => void 0;
2046
+ const whitelist = scene === "friend" ? FRIEND_EVENT_WHITELIST : GROUP_EVENT_WHITELIST;
2047
+ if (source.type === "event") {
2048
+ const eventName = source.id.split(":")[0];
2049
+ if (eventName && !whitelist.has(eventName)) {
2050
+ ctx.logger("warn", `跳过无效 event_id: ${source.id}`);
2051
+ return (_item) => void 0;
2052
+ }
2053
+ return (item) => {
2054
+ item.msg_seq = nextPassiveMsgSeq(grouping);
2055
+ item.event_id = source.id;
2056
+ };
2057
+ }
2058
+ return (item) => {
2059
+ item.msg_seq = nextPassiveMsgSeq(grouping);
2060
+ item.msg_id = source.id;
2061
+ };
2062
+ };
2063
+
2064
+ //#endregion
2065
+ //#region src/core/adapter/pipeline-guild.ts
2066
+ /** QQ keyboard 限制:最多 5 行,每行最多 5 个按钮。 */
2067
+ const KEYBOARD_MAX_ROWS = 5;
2068
+ const KEYBOARD_MAX_BUTTONS_PER_ROW = 5;
2069
+ /** 只有按钮没有文本时仍需提供非空 markdown 内容。 */
2070
+ const BUTTON_ONLY_MARKDOWN = "​";
2071
+ /**
2072
+ * 处理频道场景(子频道 + 私信)的发送
2073
+ *
2074
+ * 永远走 markdown 通道(msg_type=2),文本 / at / 图片均通过 markdown
2075
+ * 渲染。Markdown / Keyboard 已全量开放,老的 type=text/image 通道不再使用。
2076
+ */
2077
+ const sendGuild = async (ctx, contact, elements) => {
2078
+ const grouping = groupElements(contact.scene, elements);
2079
+ if (ctx.cfg.keyboard.enable && grouping.text.length) {
2080
+ const { text, buttons } = extractUrlButtons(grouping.text.join(""), false);
2081
+ grouping.text = [text];
2082
+ grouping.buttons.push(...buttons);
2083
+ }
2084
+ return sendGuildMarkdown(ctx, contact, grouping);
2085
+ };
2086
+ /**
2087
+ * Markdown 通道
2088
+ */
2089
+ const sendGuildMarkdown = async (ctx, contact, grouping) => {
2090
+ const items = [];
2091
+ const lines = [];
2092
+ if (grouping.text.length) lines.push(grouping.text.join(""));
2093
+ const allImages = [...grouping.guildImageUrls];
2094
+ for (const file of grouping.guildImageFiles) {
2095
+ const { url } = await fileToUrl("image", file, "image.jpg");
2096
+ allImages.push(url);
2097
+ }
2098
+ if (allImages.length) {
2099
+ const mdImages = await imagesToMarkdown(allImages);
2100
+ lines.push(...mdImages);
2101
+ }
2102
+ grouping.markdowns.forEach((m) => lines.push(m.markdown));
2103
+ warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
2104
+ warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
2105
+ if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
2106
+ const keyboard = buildKeyboard(grouping);
2107
+ const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
2108
+ items.push(ctx.super.guild.markdown({ content }, keyboard));
2109
+ }
2110
+ if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
2111
+ return flushGuild(ctx, contact, grouping, items);
2112
+ };
2113
+ const buildKeyboard = (grouping) => {
2114
+ const rows = [];
2115
+ grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
2116
+ grouping.keyboards.forEach((k) => rows.push(...karinToQQBot(k)));
2117
+ const normalizedRows = [];
2118
+ let id = 0;
2119
+ for (const row of rows.slice(0, KEYBOARD_MAX_ROWS)) {
2120
+ const buttons = row.buttons.slice(0, KEYBOARD_MAX_BUTTONS_PER_ROW).map((button) => normalizeQQBotButton(button, id++));
2121
+ if (buttons.length) normalizedRows.push({ buttons });
2122
+ }
2123
+ if (!normalizedRows.length) return void 0;
2124
+ return { content: { rows: normalizedRows } };
2125
+ };
2126
+ /**
2127
+ * 频道消息不支持 `enter: true` 直发按钮,只输出提示,不拦截 keyboard 发送。
2128
+ *
2129
+ * @param ctx 适配器实例,用于输出日志。
2130
+ * @param scene 频道消息场景。
2131
+ * @param buttons 本次消息中的直发指令按钮。
2132
+ */
2133
+ const warnUnsupportedCommandEnterButtons = (ctx, scene, buttons) => {
2134
+ if (!buttons.length) return;
2135
+ const names = formatCommandEnterButtonNames(buttons);
2136
+ const name = scene === "guild" ? "频道" : "频道私信";
2137
+ ctx.logger("debug", `${name}不支持 enter: true 直接发送,按钮仍会按原样发送: ${names}`);
2138
+ };
2139
+ /**
2140
+ * 频道消息不支持 `<qqbot-cmd-enter>` 文本链,只输出提示,不拦截 markdown 发送。
2141
+ *
2142
+ * @param ctx 适配器实例,用于输出日志。
2143
+ * @param scene 频道消息场景。
2144
+ * @param markdowns 本次消息中的 markdown 内容。
2145
+ */
2146
+ const warnUnsupportedCommandEnterMarkdowns = (ctx, scene, markdowns) => {
2147
+ if (!markdowns.some(hasCommandEnterTextChain)) return;
2148
+ const name = scene === "guild" ? "频道" : "频道私信";
2149
+ ctx.logger("debug", `${name}不支持 <qqbot-cmd-enter> 直接发送,markdown 仍会按原样发送`);
2150
+ };
2151
+ const flushGuild = async (ctx, contact, grouping, items) => {
2152
+ const result = ctx.initSendMsgResults();
2153
+ const passive = buildPassiveGuild(grouping);
2154
+ const send = contact.scene === "guild" ? (peer, subPeer, item) => ctx.super.messages.sendChannelMsg(subPeer, item) : (peer, _subPeer, item) => ctx.super.messages.sendDmsMsg(peer, item);
2155
+ for (const item of items) {
2156
+ passive(item);
2157
+ const res = await send(contact.peer, contact.subPeer, item);
2158
+ result.rawData.push(res);
2159
+ }
2160
+ return ctx.handleResponse(result);
2161
+ };
2162
+ /**
2163
+ * 解析频道/私信场景的被动回复来源。
2164
+ *
2165
+ * openclaw 的 qqbot 适配器对 channel/dm 也只传 `msg_id`。这里让显式
2166
+ * `segment.reply` 优先,其次使用事件入口追加的 pasmsg。
2167
+ *
2168
+ * @param grouping 已归类的消息段。
2169
+ * @returns 被动回复来源;没有可用来源时返回 undefined。
2170
+ */
2171
+ const resolvePassiveGuild = (grouping) => {
2172
+ if (grouping.reply.messageId) return {
2173
+ type: "msg",
2174
+ id: grouping.reply.messageId
2175
+ };
2176
+ if (grouping.pasmsg.id) return grouping.pasmsg;
2177
+ };
2178
+ /**
2179
+ * 构造频道/私信被动消息附加器。
2180
+ *
2181
+ * @param grouping 已归类的消息段。
2182
+ * @returns 可直接修改发送体或 FormData 的附加函数。
2183
+ */
2184
+ const buildPassiveGuild = (grouping) => {
2185
+ const source = resolvePassiveGuild(grouping);
2186
+ if (!source?.id) return (_item) => void 0;
2187
+ if (source.type === "event") return (item) => {
2188
+ if (item instanceof FormData) item.append("event_id", source.id);
2189
+ else item.event_id = source.id;
2190
+ };
2191
+ return (item) => {
2192
+ if (item instanceof FormData) item.append("msg_id", source.id);
2193
+ else item.msg_id = source.id;
2194
+ };
2195
+ };
2196
+
2197
+ //#endregion
2198
+ //#region src/core/adapter/self-message-cache.ts
2199
+ /**
2200
+ * 只有两个缓存开关同时开启时才需要等待发送结果并缓存自己消息。
2201
+ */
2202
+ const shouldCacheSelfMessage = (ctx) => {
2203
+ return ctx.cfg.messageCache.enable && ctx.cfg.messageCache.self;
2204
+ };
2205
+ /**
2206
+ * 预处理自己消息元素。
2207
+ *
2208
+ * 静态资源只在这里转一次 HTTP URL,之后发送 pipeline 和缓存都复用该 URL。
2209
+ */
2210
+ const prepareSelfMessageCache = async (ctx, elements) => {
2211
+ const sendElements = [];
2212
+ const cacheElements = [];
2213
+ for (const element of elements) {
2214
+ const prepared = await prepareSelfCacheElement(ctx, element);
2215
+ sendElements.push(prepared.send);
2216
+ if (prepared.cache) cacheElements.push(prepared.cache);
2217
+ }
2218
+ return {
2219
+ sendElements,
2220
+ cacheElements
2221
+ };
2222
+ };
2223
+ /**
2224
+ * 发送成功后缓存机器人自己的消息,供 bot.getMsg(messageId) 查询。
2225
+ */
2226
+ const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
2227
+ if (!shouldCacheSelfMessage(ctx)) return;
2228
+ if (!cacheElements.length) return;
2229
+ const responses = (Array.isArray(result.rawData) ? result.rawData : [result.rawData]).filter((item) => item?.id);
2230
+ if (!responses.length) return;
2231
+ const sender = selfSender(ctx, contact);
2232
+ const seen = /* @__PURE__ */ new Set();
2233
+ for (const response of responses) {
2234
+ const apiMessageId = String(response.id);
2235
+ const referenceMessageId = typeof response.ext_info?.ref_idx === "string" ? response.ext_info.ref_idx : "";
2236
+ const messageId = apiMessageId;
2237
+ const aliases = [referenceMessageId].filter((id) => id && id !== messageId);
2238
+ if (!messageId || seen.has(messageId)) continue;
2239
+ seen.add(messageId);
2240
+ if (referenceMessageId) rememberApiMessageId(ctx, contact, referenceMessageId, apiMessageId);
2241
+ ctx.messageStore.save(String(ctx.cfg.appId), {
2242
+ messageId,
2243
+ messageSeq: 0,
2244
+ time: responseTime(response.timestamp),
2245
+ contact,
2246
+ sender,
2247
+ elements: cacheElements
2248
+ }, aliases).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
2249
+ }
2250
+ };
2251
+ /**
2252
+ * 转换单个自己消息元素为缓存安全形态。
2253
+ */
2254
+ const prepareSelfCacheElement = async (ctx, element) => {
2255
+ switch (element.type) {
2256
+ case "text":
2257
+ case "at":
2258
+ case "reply":
2259
+ case "face":
2260
+ case "markdown": {
2261
+ const cached = { ...element };
2262
+ return {
2263
+ send: cached,
2264
+ cache: cached
2265
+ };
2266
+ }
2267
+ case "image":
2268
+ case "video":
2269
+ case "record":
2270
+ case "file": return prepareStaticElement(ctx, element);
2271
+ default: return {
2272
+ send: element,
2273
+ cache: null
2274
+ };
2275
+ }
2276
+ };
2277
+ /**
2278
+ * 静态资源统一转 HTTP URL 后再缓存;转换失败时跳过该元素。
2279
+ */
2280
+ const prepareStaticElement = async (ctx, element) => {
2281
+ if (element.file.startsWith("http")) {
2282
+ const cached = { ...element };
2283
+ return {
2284
+ send: cached,
2285
+ cache: cached
2286
+ };
2287
+ }
2288
+ try {
2289
+ switch (element.type) {
2290
+ case "image": {
2291
+ const res = await fileToUrl("image", element.file, element.name || "image.jpg");
2292
+ const cached = {
2293
+ ...element,
2294
+ file: res.url,
2295
+ width: res.width,
2296
+ height: res.height
2297
+ };
2298
+ return {
2299
+ send: cached,
2300
+ cache: cached
2301
+ };
2302
+ }
2303
+ case "video": {
2304
+ const res = await fileToUrl("video", element.file, element.name || "video.mp4");
2305
+ const cached = {
2306
+ ...element,
2307
+ file: res.url
2308
+ };
2309
+ return {
2310
+ send: cached,
2311
+ cache: cached
2312
+ };
2313
+ }
2314
+ case "record": {
2315
+ const res = await fileToUrl("record", element.file, element.name || "record.mp3");
2316
+ const cached = {
2317
+ ...element,
2318
+ file: res.url
2319
+ };
2320
+ return {
2321
+ send: cached,
2322
+ cache: cached
2323
+ };
2324
+ }
2325
+ case "file": {
2326
+ const res = await fileToUrl("file", element.file, element.name || "file.bin");
2327
+ const cached = {
2328
+ ...element,
2329
+ file: res.url
2330
+ };
2331
+ return {
2332
+ send: cached,
2333
+ cache: cached
2334
+ };
2335
+ }
2336
+ }
2337
+ } catch (err) {
2338
+ const message = err instanceof Error ? err.message : String(err);
2339
+ ctx.logger("debug", `[getMsg] 跳过自己消息资源缓存: ${element.type} ${message}`);
2340
+ return {
2341
+ send: element,
2342
+ cache: null
2343
+ };
2344
+ }
2345
+ };
2346
+ /**
2347
+ * 构造机器人自己的 sender。
2348
+ */
2349
+ const selfSender = (ctx, contact) => {
2350
+ const userId = ctx.selfSubId("id") || ctx.selfId;
2351
+ const name = ctx.selfName || ctx.cfg.name || "";
2352
+ if (contact.scene === "friend" || contact.scene === "direct") return karin$1.friendSender(userId, name);
2353
+ return karin$1.groupSender(userId, "member", name);
2354
+ };
2355
+ /**
2356
+ * QQ / 频道发送接口 timestamp 格式不同,统一转成毫秒时间戳。
2357
+ */
2358
+ const responseTime = (timestamp) => {
2359
+ if (typeof timestamp === "string") {
2360
+ const time = new Date(timestamp).getTime();
2361
+ return Number.isFinite(time) ? time : Date.now();
2362
+ }
2363
+ if (typeof timestamp === "number") return timestamp < 0xe8d4a51000 ? timestamp * 1e3 : timestamp;
2364
+ return Date.now();
2365
+ };
2366
+
2367
+ //#endregion
2368
+ //#region src/core/adapter/base.ts
2369
+ const adapterConfigStore = /* @__PURE__ */ new WeakMap();
2370
+ /**
2371
+ * QQ Official Bot 适配器
2372
+ *
2373
+ * 全场景统一走 msg_type=2 Markdown 通道(详见 pipeline-qq / pipeline-guild)。
2374
+ * 视频 / 语音 / 文件由 pipeline 内部以 msg_type=7 紧随主消息补发。
2375
+ */
2376
+ var AdapterQQBot = class extends AdapterBase {
2377
+ /** 与官方 API 交互 */
2378
+ super;
2379
+ /** openId / member_openid -> 昵称 缓存 */
2380
+ nicknameCache = /* @__PURE__ */ new Map();
2381
+ constructor(cfg, api) {
2382
+ super();
2383
+ adapterConfigStore.set(this, cfg);
2384
+ this.super = api;
2385
+ this.adapter.name = "QQ Official Bot";
2386
+ this.adapter.protocol = "qqbot";
2387
+ this.adapter.platform = "qq";
2388
+ this.adapter.standard = "other";
2389
+ }
2390
+ /** 当前 bot 配置。真实配置存放在 WeakMap,避免被系统接口序列化整个适配器时带出。 */
2391
+ get cfg() {
2392
+ const cfg = adapterConfigStore.get(this);
2393
+ if (!cfg) throw new Error("QQBot adapter config missing");
2394
+ return cfg;
2395
+ }
2396
+ /** 接收消息的一天热缓存 + SQLite 缓存,用于实现 Karin 标准 `getMsg`。 */
2397
+ get messageStore() {
2398
+ return getMessageStore();
2399
+ }
2400
+ logger(level, ...args) {
2401
+ logger.bot(level, this.selfId, ...args);
2402
+ }
2403
+ /**
2404
+ * 主消息回复入口(karin 调用)
2405
+ */
2406
+ async srcReply(e, elements) {
2407
+ const extra = await buttonHandle(e.msg, { e });
2408
+ return this.sendMsg(e.contact, [...elements, ...extra]);
2409
+ }
2410
+ /**
2411
+ * 发送消息
2412
+ */
2413
+ async sendMsg(contact, elements, _retryCount) {
2414
+ if (contact.scene === "direct" || contact.scene === "guild") {
2415
+ if (!shouldCacheSelfMessage(this)) return sendGuild(this, contact, elements);
2416
+ const prepared = await prepareSelfMessageCache(this, elements);
2417
+ const result = await sendGuild(this, contact, prepared.sendElements);
2418
+ cacheSelfMessage(this, contact, prepared.cacheElements, result);
2419
+ return result;
2420
+ }
2421
+ if (contact.scene === "group" || contact.scene === "friend") {
2422
+ if (!shouldCacheSelfMessage(this)) return sendQQ(this, contact, elements);
2423
+ const prepared = await prepareSelfMessageCache(this, elements);
2424
+ const result = await sendQQ(this, contact, prepared.sendElements);
2425
+ cacheSelfMessage(this, contact, prepared.cacheElements, result);
2426
+ return result;
2427
+ }
2428
+ throw new Error(`不支持的消息场景: ${contact.scene}`);
2429
+ }
2430
+ /**
2431
+ * 撤回消息
2432
+ *
2433
+ * 群聊中机器人被群主设为管理员后,也可撤回成员消息;平台仍限制消息发送后两分钟内。
2434
+ */
2435
+ async recallMsg(contact, messageId) {
2436
+ const apiMessageId = resolveApiMessageId(this, contact, messageId);
2437
+ try {
2438
+ if (contact.scene === "friend") {
2439
+ if (!isOwnMessageId(this, contact, apiMessageId)) {
2440
+ this.logger("warn", `[recallMsg] QQ 单聊只能撤回机器人自己发送的消息,已跳过撤回: ${messageId}`);
2441
+ return;
2442
+ }
2443
+ await this.super.messages.recall("user", contact.peer, apiMessageId);
2444
+ } else if (contact.scene === "group") await this.super.messages.recall("group", contact.peer, apiMessageId);
2445
+ else if (contact.scene === "direct") await this.super.messages.recall("dms", contact.peer, apiMessageId);
2446
+ else if (contact.scene === "guild") await this.super.messages.recall("channels", contact.peer, apiMessageId);
2447
+ } catch (err) {
2448
+ logger.error("撤回消息失败:", err);
2449
+ }
2450
+ }
2451
+ /**
2452
+ * 构造空发送结果,pipeline 内填充 rawData
2453
+ */
2454
+ initSendMsgResults() {
2455
+ return {
2456
+ messageId: "",
2457
+ message_id: "",
2458
+ time: 0,
2459
+ messageTime: 0,
2460
+ rawData: []
2461
+ };
2462
+ }
2463
+ /**
2464
+ * 将 rawData[0].id / timestamp 同步到顶层字段
2465
+ */
2466
+ handleResponse(data) {
2467
+ const first = data.rawData[0];
2468
+ if (!first) return data;
2469
+ const { id, timestamp } = first;
2470
+ data.messageId = id;
2471
+ data.message_id = id;
2472
+ data.time = typeof timestamp === "string" ? new Date(timestamp).getTime() : timestamp;
2473
+ data.messageTime = data.time;
2474
+ return data;
2475
+ }
2476
+ /**
2477
+ * 发送长消息
2478
+ */
2479
+ async sendLongMsg(_contact, _resId) {
2480
+ this.logger("warn", "[sendLongMsg] QQ Official Bot API 暂不支持长消息发送");
2481
+ return this.initSendMsgResults();
2482
+ }
2483
+ /**
2484
+ * 发送合并转发消息
2485
+ *
2486
+ * 将 NodeElement 拆解为文本 + 图片等元素,拼接成 markdown 发送。
2487
+ * 节点之间仅用换行隔开,不添加任何前缀提示文本。
2488
+ */
2489
+ async sendForwardMsg(contact, elements, _options) {
2490
+ const parts = [];
2491
+ const mediaElements = [];
2492
+ for (const node of elements) {
2493
+ if (node.subType === "messageID") continue;
2494
+ const lines = [];
2495
+ for (const el of node.message) {
2496
+ if (typeof el === "string") {
2497
+ lines.push(el);
2498
+ continue;
2499
+ }
2500
+ switch (el.type) {
2501
+ case "text":
2502
+ lines.push(el.text);
2503
+ break;
2504
+ case "image":
2505
+ if (el.file.startsWith("http")) try {
2506
+ const { width, height } = await getImageSize(el.file);
2507
+ lines.push(`![${el.name || "图片"} #${width}px #${height}px](${el.file})`);
2508
+ } catch {
2509
+ lines.push(`![${el.name || "图片"} #300px #300px](${el.file})`);
2510
+ }
2511
+ else try {
2512
+ const { url, width, height } = await fileToUrl("image", el.file, el.name || "image.jpg");
2513
+ lines.push(`![${el.name || "图片"} #${width}px #${height}px](${url})`);
2514
+ } catch {
2515
+ lines.push(`![${el.name || "图片"} #300px #300px]()`);
2516
+ }
2517
+ break;
2518
+ case "at":
2519
+ if (contact.scene === "friend" || contact.scene === "direct") break;
2520
+ if (el.targetId === "all") lines.push("<qqbot-at-everyone />");
2521
+ else lines.push(`<qqbot-at-user id="${el.targetId}" />`);
2522
+ break;
2523
+ case "markdown":
2524
+ lines.push(el.markdown);
2525
+ break;
2526
+ case "video":
2527
+ case "record":
2528
+ case "file":
2529
+ mediaElements.push(el);
2530
+ break;
2531
+ case "face":
2532
+ case "reply":
2533
+ case "basketball":
2534
+ case "dice":
2535
+ case "rps": break;
2536
+ default: break;
2537
+ }
2538
+ }
2539
+ const block = lines.join("\n");
2540
+ if (block) parts.push(block);
2541
+ }
2542
+ const content = parts.join("\r\r\n\n");
2543
+ const result = await this.sendMsg(contact, [segment.markdown(content)]);
2544
+ if (mediaElements.length) await this.sendMsg(contact, mediaElements);
2545
+ return {
2546
+ messageId: result.messageId,
2547
+ forwardId: ""
2548
+ };
2549
+ }
2550
+ /**
2551
+ * 获取头像 url
2552
+ */
2553
+ async getAvatarUrl(_userId, _size = 0) {
2554
+ return `https://thirdqq.qlogo.cn/qqapp/${this.cfg.appId}/${_userId}/${_size}`;
2555
+ }
2556
+ /**
2557
+ * 获取群头像 url
2558
+ */
2559
+ async getGroupAvatarUrl(_groupId, _size = 0, _history) {
2560
+ return `https://p.qlogo.cn/gh/${_groupId}/${_groupId}/${_size || 100}`;
2561
+ }
2562
+ /**
2563
+ * 按消息 ID 获取已接收的消息。
2564
+ *
2565
+ * QQ 官方 Bot API 没有历史消息查询接口,因此仅从本地缓存读取。
2566
+ * 消息在接收时已转换为 Karin elements,最长保留一天;引用消息的 `REFIDX`
2567
+ * 索引同样可作为 `messageId` 查询。
2568
+ *
2569
+ * @param input OneBot v11 标准单参 messageId,或用于限定查询范围的 Contact。
2570
+ * @param messageId 当 input 是 Contact 时传入的目标消息 ID。
2571
+ * @returns 命中的消息;未命中时返回空消息结构。
2572
+ */
2573
+ async getMsg(input, messageId) {
2574
+ /** OneBot v11 标准单参形式:bot.getMsg(messageId)。 */
2575
+ const targetId = typeof input === "string" ? input : messageId || "";
2576
+ const contact = typeof input === "string" ? void 0 : input;
2577
+ if (!this.cfg.messageCache.enable) {
2578
+ this.logger("debug", `[getMsg] 消息缓存已关闭: ${targetId || "(empty)"}`);
2579
+ return {
2580
+ time: 0,
2581
+ messageId: targetId,
2582
+ messageSeq: 0,
2583
+ contact: contact || {
2584
+ scene: "group",
2585
+ peer: "",
2586
+ subPeer: "",
2587
+ name: ""
2588
+ },
2589
+ sender: {
2590
+ userId: "",
2591
+ nick: "",
2592
+ name: "",
2593
+ role: "member"
2594
+ },
2595
+ elements: []
2596
+ };
2597
+ }
2598
+ const cached = await this.messageStore.get(String(this.cfg.appId), targetId, contact);
2599
+ if (cached) return cached;
2600
+ this.logger("debug", `[getMsg] 本地一天消息缓存未命中: ${targetId || "(empty)"}`);
2601
+ return {
2602
+ time: 0,
2603
+ messageId: targetId,
2604
+ messageSeq: 0,
2605
+ contact: contact || {
2606
+ scene: "group",
2607
+ peer: "",
2608
+ subPeer: "",
2609
+ name: ""
2610
+ },
2611
+ sender: {
2612
+ userId: "",
2613
+ nick: "",
2614
+ name: "",
2615
+ role: "member"
2616
+ },
2617
+ elements: []
2618
+ };
2619
+ }
2620
+ /** @internal */
2621
+ async getHistoryMsg(contact, start, count = 1) {
2622
+ if (typeof start === "number") {
2623
+ this.logger("warn", "[getHistoryMsg] QQ Official Bot 仅支持通过 message_id 获取历史消息,不支持 message_seq");
2624
+ return [];
2625
+ }
2626
+ if (!this.cfg.messageCache.enable) {
2627
+ this.logger("warn", "[getHistoryMsg] 历史消息缓存为空,请打开“启用数据库缓存消息”开关后再记录历史消息");
2628
+ return [];
2629
+ }
2630
+ const history = await this.messageStore.getHistory(String(this.cfg.appId), contact, start, count);
2631
+ if (!history.length) this.logger("debug", `[getHistoryMsg] 本地一天历史消息缓存未命中: ${start || "(empty)"}`);
2632
+ return history;
2633
+ }
2634
+ /**
2635
+ * 获取合并转发消息
2636
+ */
2637
+ async getForwardMsg(_resId) {
2638
+ this.logger("warn", "[getForwardMsg] QQ Official Bot API 暂不支持获取合并转发消息");
2639
+ return [];
2640
+ }
2641
+ /**
2642
+ * 获取精华消息
2643
+ */
2644
+ async getGroupHighlights(_groupId, _page, _pageSize) {
2645
+ this.logger("warn", "[getGroupHighlights] QQ Official Bot API 暂不支持获取精华消息");
2646
+ return [];
2647
+ }
2648
+ /**
2649
+ * 构造资源 ID
2650
+ */
2651
+ async createResId(_contact, _elements) {
2652
+ this.logger("warn", "[createResId] QQ Official Bot API 暂不支持构造资源 ID");
2653
+ return "";
2654
+ }
2655
+ /**
2656
+ * 设置、取消群精华消息
2657
+ */
2658
+ async setGroupHighlights(_groupId, _messageId, _create) {
2659
+ this.logger("warn", "[setGroupHighlights] QQ Official Bot API 暂不支持设置精华消息");
2660
+ }
2661
+ /**
2662
+ * 发送好友赞
2663
+ */
2664
+ async sendLike(_targetId, _count) {
2665
+ this.logger("warn", "[sendLike] QQ Official Bot API 暂不支持发送好友赞");
2666
+ }
2667
+ /**
2668
+ * 群踢人
2669
+ */
2670
+ async groupKickMember(_groupId, _targetId, _rejectAddRequest, _kickReason) {
2671
+ this.logger("warn", "[groupKickMember] QQ Official Bot API 暂不支持群踢人");
2672
+ }
2673
+ /**
2674
+ * 禁言群成员
2675
+ */
2676
+ async setGroupMute(_groupId, _targetId, _duration) {
2677
+ this.logger("warn", "[setGroupMute] QQ Official Bot API 暂不支持禁言群成员");
2678
+ }
2679
+ /**
2680
+ * 群全员禁言
2681
+ */
2682
+ async setGroupAllMute(_groupId, _isBan) {
2683
+ this.logger("warn", "[setGroupAllMute] QQ Official Bot API 暂不支持群全员禁言");
2684
+ }
2685
+ /**
2686
+ * 设置群管理员
2687
+ */
2688
+ async setGroupAdmin(_groupId, _targetId, _isAdmin) {
2689
+ this.logger("warn", "[setGroupAdmin] QQ Official Bot API 暂不支持设置群管理员");
2690
+ }
2691
+ /**
2692
+ * 设置群名片
2693
+ */
2694
+ async setGroupMemberCard(_groupId, _targetId, _card) {
2695
+ this.logger("warn", "[setGroupMemberCard] QQ Official Bot API 暂不支持设置群名片");
2696
+ }
2697
+ /**
2698
+ * 设置群名
2699
+ */
2700
+ async setGroupName(_groupId, _groupName) {
2701
+ this.logger("warn", "[setGroupName] QQ Official Bot API 暂不支持设置群名");
2702
+ }
2703
+ /**
2704
+ * 退出群组
2705
+ */
2706
+ async setGroupQuit(_groupId, _isDismiss) {
2707
+ this.logger("warn", "[setGroupQuit] QQ Official Bot API 暂不支持退出群组");
2708
+ }
2709
+ /**
2710
+ * 设置群专属头衔
2711
+ */
2712
+ async setGroupMemberTitle(_groupId, _targetId, _title) {
2713
+ this.logger("warn", "[setGroupMemberTitle] QQ Official Bot API 暂不支持设置群专属头衔");
2714
+ }
2715
+ /**
2716
+ * 获取陌生人信息
2717
+ */
2718
+ async getStrangerInfo(_targetId) {
2719
+ this.logger("warn", "[getStrangerInfo] QQ Official Bot API 暂不支持获取陌生人信息");
2720
+ return {
2721
+ userId: _targetId,
2722
+ nick: ""
2723
+ };
2724
+ }
2725
+ /**
2726
+ * 获取好友列表
2727
+ */
2728
+ async getFriendList(_refresh) {
2729
+ this.logger("warn", "[getFriendList] QQ Official Bot API 暂不支持获取好友列表");
2730
+ return [];
2731
+ }
2732
+ /**
2733
+ * 获取群信息
2734
+ */
2735
+ async getGroupInfo(_groupId, _noCache) {
2736
+ this.logger("warn", "[getGroupInfo] QQ Official Bot API 暂不支持获取群信息");
2737
+ return {
2738
+ groupId: _groupId,
2739
+ groupName: "",
2740
+ owner: "",
2741
+ groupRemark: "",
2742
+ admins: [],
2743
+ maxMemberCount: 0,
2744
+ memberCount: 0,
2745
+ groupDesc: "",
2746
+ avatar: ""
2747
+ };
2748
+ }
2749
+ /**
2750
+ * 获取群列表
2751
+ */
2752
+ async getGroupList(_refresh) {
2753
+ this.logger("warn", "[getGroupList] QQ Official Bot API 暂不支持获取群列表");
2754
+ return [];
2755
+ }
2756
+ /**
2757
+ * 获取群成员信息
2758
+ */
2759
+ async getGroupMemberInfo(_groupId, _targetId, _refresh) {
2760
+ this.logger("warn", "[getGroupMemberInfo] QQ Official Bot API 暂不支持获取群成员信息");
2761
+ return {
2762
+ userId: _targetId,
2763
+ role: "member"
2764
+ };
2765
+ }
2766
+ /**
2767
+ * 获取群成员列表
2768
+ */
2769
+ async getGroupMemberList(_groupId, _refresh) {
2770
+ this.logger("warn", "[getGroupMemberList] QQ Official Bot API 暂不支持获取群成员列表");
2771
+ return [];
2772
+ }
2773
+ /**
2774
+ * 获取群荣誉信息
2775
+ */
2776
+ async getGroupHonor(_groupId) {
2777
+ this.logger("warn", "[getGroupHonor] QQ Official Bot API 暂不支持获取群荣誉信息");
2778
+ return [];
2779
+ }
2780
+ /**
2781
+ * 设置好友请求结果
2782
+ */
2783
+ async setFriendApplyResult(_requestId, _isApprove, _remark) {
2784
+ this.logger("warn", "[setFriendApplyResult] QQ Official Bot API 暂不支持设置好友请求结果");
2785
+ }
2786
+ /**
2787
+ * 设置申请加入群请求结果
2788
+ */
2789
+ async setGroupApplyResult(_requestId, _isApprove, _denyReason) {
2790
+ this.logger("warn", "[setGroupApplyResult] QQ Official Bot API 暂不支持设置群申请结果");
2791
+ }
2792
+ /**
2793
+ * 设置邀请加入群请求结果
2794
+ */
2795
+ async setInvitedJoinGroupResult(_requestId, _isApprove) {
2796
+ this.logger("warn", "[setInvitedJoinGroupResult] QQ Official Bot API 暂不支持设置邀请入群结果");
2797
+ }
2798
+ /**
2799
+ * 设置消息表情回应
2800
+ */
2801
+ async setMsgReaction(_contact, _messageId, _faceId, _isSet) {
2802
+ this.logger("warn", "[setMsgReaction] QQ Official Bot API 暂不支持设置消息表情回应");
2803
+ }
2804
+ /**
2805
+ * 上传群文件、私聊文件
2806
+ */
2807
+ async uploadFile(_contact, _file, _name, _folder) {
2808
+ this.logger("warn", "[uploadFile] QQ Official Bot API 暂不支持上传文件");
2809
+ }
2810
+ /**
2811
+ * 下载文件到协议端本地
2812
+ */
2813
+ async downloadFile(_options) {
2814
+ this.logger("warn", "[downloadFile] QQ Official Bot API 暂不支持下载文件到本地");
2815
+ return { filePath: "" };
2816
+ }
2817
+ /**
2818
+ * 创建群文件夹
2819
+ */
2820
+ async createGroupFolder(_groupId, _name) {
2821
+ this.logger("warn", "[createGroupFolder] QQ Official Bot API 暂不支持创建群文件夹");
2822
+ return {
2823
+ id: "",
2824
+ usedSpace: "0"
2825
+ };
2826
+ }
2827
+ /**
2828
+ * 重命名群文件夹
2829
+ */
2830
+ async renameGroupFolder(_groupId, _folderId, _name) {
2831
+ this.logger("warn", "[renameGroupFolder] QQ Official Bot API 暂不支持重命名群文件夹");
2832
+ return false;
2833
+ }
2834
+ /**
2835
+ * 删除群文件夹
2836
+ */
2837
+ async delGroupFolder(_groupId, _folderId) {
2838
+ this.logger("warn", "[delGroupFolder] QQ Official Bot API 暂不支持删除群文件夹");
2839
+ return false;
2840
+ }
2841
+ /**
2842
+ * 上传群文件
2843
+ */
2844
+ async uploadGroupFile(_groupId, _file, _name) {
2845
+ this.logger("warn", "[uploadGroupFile] QQ Official Bot API 暂不支持上传群文件");
2846
+ return false;
2847
+ }
2848
+ /**
2849
+ * 获取文件 url
2850
+ */
2851
+ async getFileUrl(_contact, _fileId) {
2852
+ this.logger("warn", "[getFileUrl] QQ Official Bot API 暂不支持获取文件 url");
2853
+ return "";
2854
+ }
2855
+ /**
2856
+ * 删除群文件
2857
+ */
2858
+ async delGroupFile(_groupId, _fileId, _busId) {
2859
+ this.logger("warn", "[delGroupFile] QQ Official Bot API 暂不支持删除群文件");
2860
+ return false;
2861
+ }
2862
+ /**
2863
+ * 获取群文件系统信息
2864
+ */
2865
+ async getGroupFileSystemInfo(_groupId) {
2866
+ this.logger("warn", "[getGroupFileSystemInfo] QQ Official Bot API 暂不支持获取群文件系统信息");
2867
+ return {
2868
+ fileCount: 0,
2869
+ limitCount: 0,
2870
+ usedSpace: 0,
2871
+ totalSpace: 0
2872
+ };
2873
+ }
2874
+ /**
2875
+ * 获取群文件夹下文件列表
2876
+ */
2877
+ async getGroupFileList(_groupId, _folderId) {
2878
+ this.logger("warn", "[getGroupFileList] QQ Official Bot API 暂不支持获取群文件列表");
2879
+ return {
2880
+ files: [],
2881
+ folders: []
2882
+ };
2883
+ }
2884
+ /**
2885
+ * 设置群备注
2886
+ */
2887
+ async setGroupRemark(_groupId, _remark) {
2888
+ this.logger("warn", "[setGroupRemark] QQ Official Bot API 暂不支持设置群备注");
2889
+ return false;
2890
+ }
2891
+ /**
2892
+ * 获取陌生群信息
2893
+ */
2894
+ async getNotJoinedGroupInfo(_groupId) {
2895
+ this.logger("warn", "[getNotJoinedGroupInfo] QQ Official Bot API 暂不支持获取陌生群信息");
2896
+ return {
2897
+ groupId: _groupId,
2898
+ groupName: "",
2899
+ owner: "",
2900
+ groupRemark: "",
2901
+ admins: [],
2902
+ maxMemberCount: 0,
2903
+ memberCount: 0,
2904
+ groupDesc: "",
2905
+ avatar: ""
2906
+ };
2907
+ }
2908
+ /**
2909
+ * 获取艾特全体成员剩余次数
2910
+ */
2911
+ async getAtAllCount(_groupId) {
2912
+ this.logger("warn", "[getAtAllCount] QQ Official Bot API 暂不支持获取艾特全体成员剩余次数");
2913
+ return {
2914
+ accessAtAll: false,
2915
+ groupRemainCount: 0,
2916
+ userRremainCount: 0
2917
+ };
2918
+ }
2919
+ /**
2920
+ * 获取群被禁言用户列表
2921
+ */
2922
+ async getGroupMuteList(_groupId) {
2923
+ this.logger("warn", "[getGroupMuteList] QQ Official Bot API 暂不支持获取群被禁言用户列表");
2924
+ return [];
2925
+ }
2926
+ /**
2927
+ * 戳一戳用户
2928
+ */
2929
+ async pokeUser(_contact, _targetId, _count) {
2930
+ this.logger("warn", "[pokeUser] QQ Official Bot API 暂不支持戳一戳用户");
2931
+ return false;
2932
+ }
2933
+ /**
2934
+ * 获取 Cookies
2935
+ */
2936
+ async getCookies(_domain) {
2937
+ this.logger("warn", "[getCookies] QQ Official Bot API 暂不支持获取 Cookies");
2938
+ return { cookie: "" };
2939
+ }
2940
+ /**
2941
+ * 获取 QQ 相关接口凭证
2942
+ */
2943
+ async getCredentials(_domain) {
2944
+ this.logger("warn", "[getCredentials] QQ Official Bot API 暂不支持获取 Credentials");
2945
+ return {
2946
+ cookies: "",
2947
+ csrf_token: 0
2948
+ };
2949
+ }
2950
+ /**
2951
+ * 获取 CSRF Token
2952
+ */
2953
+ async getCSRFToken() {
2954
+ this.logger("warn", "[getCSRFToken] QQ Official Bot API 暂不支持获取 CSRF Token");
2955
+ return { token: 0 };
2956
+ }
2957
+ /**
2958
+ * 设置头像
2959
+ */
2960
+ async setAvatar(_file) {
2961
+ this.logger("warn", "[setAvatar] QQ Official Bot API 暂不支持设置头像");
2962
+ }
2963
+ /**
2964
+ * 获取 rkey
2965
+ */
2966
+ async getRkey() {
2967
+ this.logger("warn", "[getRkey] QQ Official Bot API 暂不支持获取 rkey");
2968
+ return [];
2969
+ }
2970
+ /**
2971
+ * 获取群 Ai 语音可用声色列表
2972
+ */
2973
+ async getAiCharacters() {
2974
+ this.logger("warn", "[getAiCharacters] QQ Official Bot API 暂不支持获取群 Ai 语音可用声色列表");
2975
+ return [];
2976
+ }
2977
+ /**
2978
+ * 设置群 Ai 语音声色
2979
+ */
2980
+ async sendAiCharacter(_groupId, _characterId, _text) {
2981
+ this.logger("warn", "[sendAiCharacter] QQ Official Bot API 暂不支持设置群 Ai 语音声色");
2982
+ return { messageId: "" };
2983
+ }
2984
+ };
2985
+
2986
+ //#endregion
2987
+ //#region src/core/event/conver.ts
2988
+ /** 反转义 HTML 实体 */
2989
+ const unescape = (text) => text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
2990
+ /** 从 QQ `message_scene.ext` 中提取消息索引。 */
2991
+ const getMessageSceneIndex = (scene, key) => {
2992
+ const prefix = `${key}=`;
2993
+ return scene?.ext?.find((item) => item.startsWith(prefix))?.slice(prefix.length) || "";
2994
+ };
2995
+ /** 原生 Markdown 不能从 QQ 回显中完全区分,只识别适配器发送后可稳定还原的常用语法。 */
2996
+ const isNativeMarkdown = (text) => {
2997
+ return /(^|\n)\s{0,3}(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|~~|`{1,3}|\[[^\]]+\]\([^\s)]+\)|(^|\n)\s*\|.+\|\s*\n\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?/m.test(text);
2998
+ };
2999
+ /** 将非图片 Markdown 片段恢复为 text 或 markdown 元素。 */
3000
+ const pushMarkdownText = (text, elements) => {
3001
+ if (!text) return;
3002
+ elements.push(isNativeMarkdown(text) ? segment.markdown(text) : segment.text(text));
3003
+ };
3004
+ /**
3005
+ * 反向解析适配器发送的 QQ Markdown。
3006
+ *
3007
+ * 发送侧会将 Karin 图片编码为 `![名称 #宽px #高px](URL)`,并以换行拼接文本、
3008
+ * 图片和原生 Markdown;这里按原顺序恢复为 Karin `text`、`image`、`markdown`。
3009
+ */
3010
+ const pushQQMarkdown = (text, elements) => {
3011
+ const imageReg = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
3012
+ let lastIndex = 0;
3013
+ let matched = false;
3014
+ let match;
3015
+ while (match = imageReg.exec(text)) {
3016
+ matched = true;
3017
+ if (match.index > lastIndex) pushMarkdownText(text.slice(lastIndex, match.index), elements);
3018
+ const alt = match[1];
3019
+ const dimension = alt.match(/\s+#(\d+)px\s+#(\d+)px\s*$/);
3020
+ const name = alt.replace(/\s+#\d+px\s+#\d+px\s*$/, "") || "图片";
3021
+ elements.push(segment.image(match[2], {
3022
+ name,
3023
+ width: dimension ? Number(dimension[1]) : void 0,
3024
+ height: dimension ? Number(dimension[2]) : void 0
3025
+ }));
3026
+ lastIndex = imageReg.lastIndex;
3027
+ }
3028
+ if (matched && lastIndex < text.length) pushMarkdownText(text.slice(lastIndex), elements);
3029
+ return matched;
3030
+ };
3031
+ /**
3032
+ * 只拆 QQ 协议标签,保留普通文本中的 HTML/Markdown 片段。
3033
+ *
3034
+ * 普通文本或 Markdown 里可能包含 `<br>`、`<table>` 等内容;如果用 `[^<]+`
3035
+ * 直接切分,会把这些内容误当成协议标签边界,导致一条文本被拆成多段甚至丢失 `<`。
3036
+ */
3037
+ const tokenizeContent = (content = "") => {
3038
+ const tokens = [];
3039
+ const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[A-Za-z0-9]+>/g;
3040
+ let lastIndex = 0;
3041
+ let match;
3042
+ while (match = knownTag.exec(content)) {
3043
+ if (match.index > lastIndex) tokens.push(content.slice(lastIndex, match.index));
3044
+ tokens.push(match[0]);
3045
+ lastIndex = knownTag.lastIndex;
3046
+ }
3047
+ if (lastIndex < content.length) tokens.push(content.slice(lastIndex));
3048
+ return tokens;
3049
+ };
3050
+ /**
3051
+ * 判断 QQ 回显内容是否应恢复成 Karin markdown 段。
3052
+ *
3053
+ * QQBot 自己收到自己发出的 markdown 时可能是 `message_type=103`;收到其他
3054
+ * QQBot 发出的 markdown 时,官方报文仍可能给 `message_type=0`,只能结合
3055
+ * `author.bot` 与内容语法做保守识别。
3056
+ */
3057
+ const shouldUseMarkdown = (messageType, fromBot, text) => {
3058
+ return (messageType === 103 || fromBot) && isNativeMarkdown(text);
3059
+ };
3060
+ /**
3061
+ * 从 QQ 表情标签的 base64 ext 中提取可读文本。
3062
+ *
3063
+ * faceType=4 的表情包套图没有图片附件,只能通过 ext 中的 JSON 文本展示。
3064
+ *
3065
+ * @param ext QQ 表情标签里的 base64 字段。
3066
+ * @returns ext JSON 中的 text;解析失败时返回空字符串。
3067
+ */
3068
+ const getFaceExtText = (ext) => {
3069
+ if (!ext) return "";
3070
+ try {
3071
+ const decoded = Buffer.from(ext, "base64").toString("utf-8");
3072
+ const payload = JSON.parse(decoded);
3073
+ return typeof payload.text === "string" ? payload.text : "";
3074
+ } catch {
3075
+ return "";
3076
+ }
3077
+ };
3078
+ /**
3079
+ * QQ Bot 报文 → karin elements
3080
+ *
3081
+ * @param appid 应用 ID(与机器人 sub_id 同一值会被替换为 appid)
3082
+ * @param ev 事件
3083
+ * @param selfSubId 机器人 user id(非 appid)
3084
+ * @param options
3085
+ */
3086
+ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
3087
+ const elements = [];
3088
+ const data = ev.d;
3089
+ const messageType = "message_type" in data ? data.message_type : void 0;
3090
+ const fromBot = !!data.author?.bot;
3091
+ /** mentions 索引 id -> displayName */
3092
+ const mentionMap = /* @__PURE__ */ new Map();
3093
+ const selfMentionIds = new Set([appid, selfSubId].filter(Boolean));
3094
+ if (ev.t === "MESSAGE_CREATE" || ev.t === "AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
3095
+ mentionMap.set(v.id, v.username);
3096
+ if (v.id === selfSubId) selfMentionIds.add(v.id);
3097
+ });
3098
+ else if (ev.t === "GROUP_MESSAGE_CREATE" || ev.t === "GROUP_AT_MESSAGE_CREATE") ev.d.mentions?.forEach((v) => {
3099
+ mentionMap.set(v.id, v.username);
3100
+ mentionMap.set(v.member_openid, v.username);
3101
+ if (v.is_you) {
3102
+ selfMentionIds.add(v.id);
3103
+ selfMentionIds.add(v.member_openid);
3104
+ }
3105
+ });
3106
+ const isSelfAt = (element) => element.type === "at" && selfMentionIds.has(element.targetId);
3107
+ const hasSelfAt = () => elements.some(isSelfAt);
3108
+ const pushAt = (id, name = "") => {
3109
+ const targetId = selfMentionIds.has(id) ? appid : id;
3110
+ if (targetId === appid && hasSelfAt()) {
3111
+ const prev = elements.at(-1);
3112
+ const beforePrev = elements.at(-2);
3113
+ if (prev?.type === "text" && !prev.text.trim() && beforePrev && isSelfAt(beforePrev)) elements.pop();
3114
+ return;
3115
+ }
3116
+ elements.push(segment.at(targetId, name));
3117
+ };
3118
+ /** 将 QQ 附件转换为 Karin 元素。 */
3119
+ const attachments = data.attachments || [];
3120
+ let attachmentIndex = 0;
3121
+ const pushAttachment = (v) => {
3122
+ const url = v.url.startsWith("http") ? v.url : `https://${v.url}`;
3123
+ if (v.content_type.startsWith("image/")) elements.push(segment.image(url, {
3124
+ subType: v.content_type.split("/")[1],
3125
+ name: v.filename,
3126
+ width: v.width,
3127
+ height: v.height
3128
+ }));
3129
+ else if (v.content_type === "video/mp4") elements.push(segment.video(url));
3130
+ else if (v.content_type === "voice") elements.push(segment.record(v.voice_wav_url || url));
3131
+ else if (v.content_type === "file") elements.push(segment.file(url, {
3132
+ name: v.filename,
3133
+ size: v.size
3134
+ }));
3135
+ };
3136
+ /** content 解析 */
3137
+ const tokens = tokenizeContent(data?.content);
3138
+ const cfg = getConfig(appid);
3139
+ for (const tok of tokens) {
3140
+ if (tok.startsWith("<faceType=")) {
3141
+ const m = tok.match(/faceType=(\d+),faceId="([^"]*)",ext="([^"]*)"/);
3142
+ const faceType = Number(m?.[1]);
3143
+ const faceIdText = m?.[2] || "";
3144
+ const faceId = Number(faceIdText);
3145
+ const attachment = attachments[attachmentIndex];
3146
+ if (!faceIdText) {
3147
+ if (attachment?.content_type.startsWith("image/")) {
3148
+ pushAttachment(attachment);
3149
+ attachmentIndex++;
3150
+ } else {
3151
+ const text = getFaceExtText(m?.[3] || "");
3152
+ if (text) elements.push(segment.text(text));
3153
+ }
3154
+ continue;
3155
+ }
3156
+ /**
3157
+ * QQ 将用户收藏表情图片编码为 `faceId=0` + 图片附件。附件在 content 中
3158
+ * 没有独立标签,必须在这个占位符位置消费,才能保留原始元素顺序。
3159
+ */
3160
+ if (faceId === 0 && (messageType === 103 || faceType === 6) && attachment?.content_type.startsWith("image/")) {
3161
+ pushAttachment(attachment);
3162
+ attachmentIndex++;
3163
+ } else if (faceType === 6 && faceId === 0) {} else if (m) elements.push(segment.face(faceId, faceType === 3));
3164
+ continue;
3165
+ }
3166
+ if (tok.startsWith("<@")) {
3167
+ const id = tok.replace(/^<@!?|>$/g, "");
3168
+ pushAt(id, mentionMap.get(id) || "");
3169
+ continue;
3170
+ }
3171
+ if (tok.startsWith("<qqbot-at-user")) {
3172
+ const id = tok.match(/id="([^"]+)"/)?.[1] || "";
3173
+ pushAt(id, mentionMap.get(id) || "");
3174
+ continue;
3175
+ }
3176
+ if (tok.startsWith("<qqbot-at-everyone")) {
3177
+ elements.push(segment.at("all"));
3178
+ continue;
3179
+ }
3180
+ let text = unescape(tok);
3181
+ if (cfg?.regex) for (const r of cfg.regex) {
3182
+ const reg = r.reg instanceof RegExp ? r.reg : new RegExp(r.reg);
3183
+ text = text.replace(reg, r.rep);
3184
+ }
3185
+ if (!text) continue;
3186
+ if (messageType === 103 && pushQQMarkdown(text, elements)) continue;
3187
+ if (shouldUseMarkdown(messageType, fromBot, text)) {
3188
+ elements.push(segment.markdown(text));
3189
+ continue;
3190
+ }
3191
+ elements.push(segment.text(text));
3192
+ }
3193
+ /** content 中未出现占位符的普通附件,按 QQ 报文顺序追加。 */
3194
+ for (; attachmentIndex < attachments.length; attachmentIndex++) pushAttachment(attachments[attachmentIndex]);
3195
+ /** 群消息是否需要在头部补 self at */
3196
+ if (ev.t === "GROUP_AT_MESSAGE_CREATE" || ev.t === "GROUP_MESSAGE_CREATE") {
3197
+ const youMention = (ev.d.mentions || []).find((m) => m.is_you);
3198
+ if (options.forceAtSelf || !!youMention) {
3199
+ if (!elements.some((e) => e.type === "at" && (e.targetId === appid || e.targetId === selfSubId))) elements.unshift(segment.at(appid, youMention?.username || ""));
3200
+ }
3201
+ }
3202
+ return elements;
3203
+ };
3204
+ /**
3205
+ * 将 QQ 引用上下文转换为 Karin 元素。
3206
+ *
3207
+ * QQ 不会提供引用消息的正式消息 ID;调用方应以 `msg_idx` 作为缓存键。
3208
+ */
3209
+ const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
3210
+ return convertToKarin(appid, {
3211
+ ...ev,
3212
+ d: {
3213
+ ...ev.d,
3214
+ content: reference.content,
3215
+ attachments: reference.attachments,
3216
+ mentions: void 0,
3217
+ message_scene: void 0,
3218
+ msg_elements: void 0,
3219
+ message_type: reference.message_type
3220
+ }
3221
+ }, selfSubId);
3222
+ };
3223
+
3224
+ //#endregion
3225
+ //#region src/core/event/message.ts
3226
+ /**
3227
+ * 写入 getMsg 热缓存并投递 SQLite 后台队列。
3228
+ *
3229
+ * 不等待数据库落库,避免缓存特性拖慢 Karin 消息事件下发。
3230
+ *
3231
+ * @param client 当前 QQBot 适配器实例。
3232
+ * @param message 需要缓存的消息快照。
3233
+ * @param aliases 可用于查询同一消息的 QQ 侧索引,例如 msg_idx。
3234
+ */
3235
+ const cacheMessage = (client, message, aliases = []) => {
3236
+ if (!client.cfg.messageCache.enable) return;
3237
+ client.messageStore.save(String(client.cfg.appId), {
3238
+ ...message,
3239
+ messageSeq: 0
3240
+ }, aliases).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
3241
+ };
3242
+ /**
3243
+ * 写入引用消息上下文缓存。
3244
+ *
3245
+ * 引用上下文没有正式消息 ID 时,以 QQ 的 `ref_msg_idx` 建立只写入一次的缓存。
3246
+ *
3247
+ * @param client 当前 QQBot 适配器实例。
3248
+ * @param message 以 ref_msg_idx 作为 messageId 的引用消息快照。
3249
+ */
3250
+ const cacheReference = (client, message) => {
3251
+ if (!client.cfg.messageCache.enable) return;
3252
+ client.messageStore.saveReferenceIfAbsent(String(client.cfg.appId), {
3253
+ ...message,
3254
+ messageSeq: 0
3255
+ }).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
3256
+ };
3257
+ /**
3258
+ * 群消息(@机器人 / 普通消息合并入口)
3259
+ */
3260
+ const onGroupMsg = (client, ev, opts) => {
3261
+ const selfId = client.selfId;
3262
+ const userId = ev.d.author.member_openid || ev.d.author.id;
3263
+ const username = ev.d.author.username || "";
3264
+ if (userId && username) client.nicknameCache.set(userId, username);
3265
+ ev.d.mentions?.forEach((m) => {
3266
+ if (m.member_openid && m.username) client.nicknameCache.set(m.member_openid, m.username);
3267
+ });
3268
+ const contact = karin$1.contactGroup(ev.d.group_openid || ev.d.group_id);
3269
+ const sender = karin$1.groupSender(userId, ev.d.author.member_role || "unknown", username);
3270
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"), { forceAtSelf: opts.forceAtSelf });
3271
+ const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
3272
+ const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
3273
+ const karinMessageId = ev.d.id;
3274
+ if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
3275
+ if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
3276
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3277
+ cacheMessage(client, {
3278
+ messageId: karinMessageId,
3279
+ time,
3280
+ contact,
3281
+ sender,
3282
+ elements
3283
+ }, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
3284
+ const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
3285
+ if (reference && referenceIndex) cacheReference(client, {
3286
+ messageId: referenceIndex,
3287
+ time,
3288
+ contact,
3289
+ sender: karin$1.groupSender("", "member", reference.author?.username || ""),
3290
+ elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
3291
+ });
3292
+ const e = createGroupMessage({
3293
+ bot: client,
3294
+ elements,
3295
+ eventId: ev.id,
3296
+ messageId: karinMessageId,
3297
+ messageSeq: 0,
3298
+ rawEvent: ev,
3299
+ time,
3300
+ contact,
3301
+ sender,
3302
+ srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
3303
+ });
3304
+ return e;
3305
+ };
3306
+ /**
3307
+ * 单聊消息
3308
+ */
3309
+ const onFriendMsg = (client, ev) => {
3310
+ const selfId = client.selfId;
3311
+ const userId = ev.d.author.user_openid || ev.d.author.id;
3312
+ const username = ev.d.author.username || "";
3313
+ if (userId && username) client.nicknameCache.set(userId, username);
3314
+ const contact = karin$1.contactFriend(userId);
3315
+ const sender = karin$1.friendSender(userId, username);
3316
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
3317
+ const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
3318
+ const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
3319
+ const karinMessageId = ev.d.id;
3320
+ if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
3321
+ if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
3322
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3323
+ cacheMessage(client, {
3324
+ messageId: karinMessageId,
3325
+ time,
3326
+ contact,
3327
+ sender,
3328
+ elements
3329
+ }, [ev.d.id, messageIndex].filter((id) => !!id && id !== karinMessageId && id !== referenceIndex));
3330
+ const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
3331
+ if (reference && referenceIndex) cacheReference(client, {
3332
+ messageId: referenceIndex,
3333
+ time,
3334
+ contact,
3335
+ sender: karin$1.friendSender("", reference.author?.username || ""),
3336
+ elements: convertReferenceToKarin(selfId, ev, client.selfSubId("id"), reference)
3337
+ });
3338
+ const e = createFriendMessage({
3339
+ bot: client,
3340
+ elements,
3341
+ eventId: ev.id,
3342
+ messageId: karinMessageId,
3343
+ messageSeq: 0,
3344
+ rawEvent: ev,
3345
+ time,
3346
+ contact,
3347
+ sender,
3348
+ srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
3349
+ });
3350
+ return e;
3351
+ };
3352
+ /**
3353
+ * 频道消息
3354
+ */
3355
+ const onChannelMsg = (client, ev) => {
3356
+ const selfId = client.selfId;
3357
+ const userId = ev.d.author.id;
3358
+ const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
3359
+ const sender = karin$1.groupSender(userId, "unknown", ev.d.author.username || "");
3360
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
3361
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3362
+ cacheMessage(client, {
3363
+ messageId: ev.d.id,
3364
+ time,
3365
+ contact,
3366
+ sender,
3367
+ elements
3368
+ });
3369
+ const e = createGuildMessage({
3370
+ bot: client,
3371
+ elements,
3372
+ eventId: ev.id,
3373
+ messageId: ev.d.id,
3374
+ messageSeq: 0,
3375
+ rawEvent: ev,
3376
+ time,
3377
+ contact,
3378
+ sender,
3379
+ srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
3380
+ });
3381
+ return e;
3382
+ };
3383
+ /**
3384
+ * 频道私信
3385
+ */
3386
+ const onDirectMsg = (client, ev) => {
3387
+ const selfId = client.selfId;
3388
+ const userId = ev.d.author.id;
3389
+ const contact = karin$1.contact("direct", ev.d.guild_id, ev.d.channel_id);
3390
+ const sender = karin$1.friendSender(userId, ev.d.author.username || "");
3391
+ const elements = convertToKarin(selfId, ev, client.selfSubId("id"));
3392
+ const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
3393
+ cacheMessage(client, {
3394
+ messageId: ev.d.id,
3395
+ time,
3396
+ contact,
3397
+ sender,
3398
+ elements
3399
+ });
3400
+ const e = createDirectMessage({
3401
+ bot: client,
3402
+ elements,
3403
+ eventId: ev.id,
3404
+ messageId: ev.d.id,
3405
+ messageSeq: 0,
3406
+ rawEvent: ev,
3407
+ time,
3408
+ contact,
3409
+ sender,
3410
+ srcGuildId: ev.d.src_guild_id,
3411
+ srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
3412
+ });
3413
+ return e;
3414
+ };
3415
+
3416
+ //#endregion
3417
+ //#region src/core/event/notice.ts
3418
+ /**
3419
+ * 机器人加入群聊事件
3420
+ * @param client 机器人实例
3421
+ * @param event 事件
3422
+ */
3423
+ const onGroupAddRobot = (client, event) => {
3424
+ const selfId = client.selfId;
3425
+ const userId = event.d.op_member_openid;
3426
+ const groupId = event.d.group_openid;
3427
+ const contact = karin$1.contactGroup(groupId);
3428
+ const sender = karin$1.groupSender(selfId, "member");
3429
+ const eventId = event.id;
3430
+ const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
3431
+ createGroupMemberAddNotice({
3432
+ contact,
3433
+ eventId,
3434
+ sender,
3435
+ srcReply,
3436
+ bot: client,
3437
+ rawEvent: event,
3438
+ time: event.d.timestamp,
3439
+ content: {
3440
+ operatorId: userId,
3441
+ targetId: selfId,
3442
+ type: "invite"
3443
+ }
3444
+ });
3445
+ };
3446
+ /**
3447
+ * 机器人退出群聊事件
3448
+ * @param client 机器人实例
3449
+ * @param event 事件
3450
+ */
3451
+ const onGroupDelRobot = (client, event) => {
3452
+ const selfId = client.selfId;
3453
+ const userId = event.d.op_member_openid;
3454
+ const groupId = event.d.group_openid;
3455
+ const contact = karin$1.contactGroup(groupId);
3456
+ const sender = karin$1.groupSender(selfId, "unknown");
3457
+ const eventId = event.id;
3458
+ const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
3459
+ createGroupMemberDelNotice({
3460
+ contact,
3461
+ eventId,
3462
+ sender,
3463
+ srcReply,
3464
+ bot: client,
3465
+ rawEvent: event,
3466
+ time: event.d.timestamp,
3467
+ content: {
3468
+ operatorId: userId,
3469
+ targetId: selfId,
3470
+ type: "kick"
3471
+ }
3472
+ });
3473
+ };
3474
+ /**
3475
+ * 群成员加入事件。
3476
+ *
3477
+ * QQ 官方仅下发加入成员,未提供操作人和加入方式;Karin 的通知模型要求
3478
+ * `type`,因此以 `approve` 表示已加入,并将 `operatorId` 留空。
3479
+ * 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
3480
+ *
3481
+ * @param client 机器人实例
3482
+ * @param event 官方群成员加入事件
3483
+ */
3484
+ const onGroupMemberAdd = (client, event) => {
3485
+ const memberId = event.d.member_openid;
3486
+ const contact = karin$1.contactGroup(event.d.group_openid);
3487
+ const sender = karin$1.groupSender(memberId, "member");
3488
+ createGroupMemberAddNotice({
3489
+ contact,
3490
+ eventId: event.id,
3491
+ sender,
3492
+ srcReply: (elements) => client.sendMsg(contact, elements),
3493
+ bot: client,
3494
+ rawEvent: event,
3495
+ time: event.d.timestamp,
3496
+ content: {
3497
+ operatorId: "",
3498
+ targetId: memberId,
3499
+ type: "approve"
3500
+ }
3501
+ });
3502
+ };
3503
+ /**
3504
+ * 群成员退出事件。
3505
+ *
3506
+ * QQ 官方仅下发退出成员,未提供操作人和退出原因;Karin 的通知模型要求
3507
+ * `type`,因此以 `leave` 表示成员已离开,并将 `operatorId` 留空。
3508
+ * 该事件不能作为 QQ 被动消息的 `event_id`,回复会按普通消息发送。
3509
+ *
3510
+ * @param client 机器人实例
3511
+ * @param event 官方群成员退出事件
3512
+ */
3513
+ const onGroupMemberRemove = (client, event) => {
3514
+ const memberId = event.d.member_openid;
3515
+ const contact = karin$1.contactGroup(event.d.group_openid);
3516
+ const sender = karin$1.groupSender(memberId, "unknown");
3517
+ createGroupMemberDelNotice({
3518
+ contact,
3519
+ eventId: event.id,
3520
+ sender,
3521
+ srcReply: (elements) => client.sendMsg(contact, elements),
3522
+ bot: client,
3523
+ rawEvent: event,
3524
+ time: event.d.timestamp,
3525
+ content: {
3526
+ operatorId: "",
3527
+ targetId: memberId,
3528
+ type: "leave"
3529
+ }
3530
+ });
3531
+ };
3532
+ /**
3533
+ * 群聊接受机器人主动消息
3534
+ * @param client 机器人实例
3535
+ * @param event 事件
3536
+ */
3537
+ const onGroupMsgReceive = (client, event) => {
3538
+ const groupId = event.d.group_openid;
3539
+ const userId = event.d.op_member_openid;
3540
+ client.logger("mark", `群聊 ${groupId} 已允许机器人主动发送消息 操作人: ${userId}`);
3541
+ };
3542
+ /**
3543
+ * 群聊拒绝机器人主动消息
3544
+ * @param client 机器人实例
3545
+ * @param event 事件
3546
+ */
3547
+ const onGroupMsgReject = (client, event) => {
3548
+ const groupId = event.d.group_openid;
3549
+ const userId = event.d.op_member_openid;
3550
+ client.logger("mark", `群聊 ${groupId} 已拒绝机器人主动发送消息 操作人: ${userId}`);
3551
+ };
3552
+ /**
3553
+ * 好友添加事件
3554
+ * @param client 机器人实例
3555
+ * @param event 事件
3556
+ */
3557
+ const onFriendAdd = (client, event) => {
3558
+ const selfId = client.selfId;
3559
+ const userId = event.d.openid;
3560
+ const contact = karin$1.contactFriend(userId);
3561
+ const sender = karin$1.friendSender(userId, "");
3562
+ const eventId = event.id;
3563
+ const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
3564
+ createFriendIncreaseNotice({
3565
+ contact,
3566
+ eventId,
3567
+ sender,
3568
+ srcReply,
3569
+ bot: client,
3570
+ rawEvent: event,
3571
+ time: event.d.timestamp,
3572
+ content: { targetId: selfId }
3573
+ });
3574
+ };
3575
+ /**
3576
+ * 好友删除事件
3577
+ * @param client 机器人实例
3578
+ * @param event 事件
3579
+ */
3580
+ const onFriendDel = (client, event) => {
3581
+ const selfId = client.selfId;
3582
+ const userId = event.d.openid;
3583
+ const contact = karin$1.contactFriend(userId);
3584
+ const sender = karin$1.friendSender(userId, "");
3585
+ const eventId = event.id;
3586
+ const srcReply = (elements) => client.sendMsg(contact, [...elements, segment.pasmsg(eventId, "event")]);
3587
+ createFriendDecreaseNotice({
3588
+ contact,
3589
+ eventId,
3590
+ sender,
3591
+ srcReply,
3592
+ bot: client,
3593
+ rawEvent: event,
3594
+ time: event.d.timestamp,
3595
+ content: { targetId: selfId }
3596
+ });
3597
+ };
3598
+ /**
3599
+ * 好友接受机器人主动消息
3600
+ * @param client 机器人实例
3601
+ * @param event 事件
3602
+ */
3603
+ const onC2CMsgReceive = (client, event) => {
3604
+ const userId = event.d.openid;
3605
+ client.logger("mark", `好友 ${userId} 已允许机器人主动发送消息`);
3606
+ };
3607
+ /**
3608
+ * 好友拒绝机器人主动消息
3609
+ * @param client 机器人实例
3610
+ * @param event 事件
3611
+ */
3612
+ const onC2CMsgReject = (client, event) => {
3613
+ const userId = event.d.openid;
3614
+ client.logger("mark", `好友 ${userId} 已拒绝机器人主动发送消息`);
3615
+ };
3616
+
3617
+ //#endregion
3618
+ //#region src/core/event/interaction.ts
3619
+ /**
3620
+ * 取得互动事件真正用于 ACK 的平台 ID。
3621
+ *
3622
+ * WebSocket Dispatch 外层也有一个 `id`,形如 `INTERACTION_CREATE:uuid`;按钮 ACK
3623
+ * 接口需要的是事件体内的 `d.id`,也就是不带事件名前缀的 uuid。
3624
+ *
3625
+ * @param ev QQBot 原始互动事件。
3626
+ * @returns 可传给 `/interactions/{interaction_id}` 的 ID。
3627
+ */
3628
+ const getInteractionId = (ev) => ev.d.id || ev.id;
3629
+ /**
3630
+ * 构造按钮回调附加给 Karin 消息的元信息。
3631
+ *
3632
+ * QQBot 不同场景下下发字段差异很大:群聊通常只有 button_data/button_id,
3633
+ * 频道才可能带 user_id/message_id/feature_id。这里只写入实际存在的字段,
3634
+ * 避免插件侧看到一堆语义不明的空值。
3635
+ *
3636
+ * @param ev QQBot 原始互动事件。
3637
+ * @param interactionId 已确认可用于 ACK 的平台互动 ID。
3638
+ * @returns 可放入 segment.json 的精简元信息。
3639
+ */
3640
+ const createInteractionMeta = (ev, interactionId) => {
3641
+ const resolved = ev.d.data?.resolved;
3642
+ const meta = {
3643
+ tag: "qqbot-button-click",
3644
+ interaction_id: interactionId,
3645
+ dispatch_id: ev.id,
3646
+ interaction_type: ev.d.type
3647
+ };
3648
+ const optional = {
3649
+ data_type: ev.d.data?.type,
3650
+ chat_type: ev.d.chat_type,
3651
+ scene: ev.d.scene,
3652
+ button_id: resolved?.button_id,
3653
+ button_data: resolved?.button_data,
3654
+ application_id: ev.d.application_id,
3655
+ group_openid: ev.d.group_openid,
3656
+ group_member_openid: ev.d.group_member_openid,
3657
+ user_openid: ev.d.user_openid,
3658
+ guild_id: ev.d.guild_id,
3659
+ channel_id: ev.d.channel_id,
3660
+ guild_user_id: resolved?.user_id,
3661
+ feature_id: resolved?.feature_id,
3662
+ message_id: resolved?.message_id,
3663
+ require_mention: resolved?.require_mention,
3664
+ group_policy: resolved?.group_policy,
3665
+ mention_patterns: resolved?.mention_patterns,
3666
+ version: ev.d.version
3667
+ };
3668
+ for (const [key, value] of Object.entries(optional)) if (value !== void 0 && value !== "") meta[key] = value;
3669
+ return meta;
3670
+ };
3671
+ /**
3672
+ * INTERACTION_CREATE 处理
3673
+ *
3674
+ * - 先 ack 客户端(必须在 3s 内),失败仅 warn
3675
+ * - 借用普通消息事件投递给业务层,业务层通过 e.rawEvent.t === 'INTERACTION_CREATE' 区分
3676
+ * - elements 只放 button_data 文本,避免 at/json 段污染 e.msg 导致 command 无法精确匹配
3677
+ */
3678
+ const onInteraction = (client, ev) => {
3679
+ const interactionId = getInteractionId(ev);
3680
+ const ackStart = Date.now();
3681
+ client.logger("debug", `[INTERACTION] ack 准备: ${interactionId} dispatch=${ev.id}`);
3682
+ client.super.interaction.ack(interactionId, 0).then(() => {
3683
+ client.logger("debug", `[INTERACTION] ack 成功: ${interactionId} (${Date.now() - ackStart}ms)`);
3684
+ }).catch((err) => {
3685
+ client.logger("warn", "[INTERACTION] ack 失败:", err);
3686
+ }).finally(() => {
3687
+ emitInteractionMessage(client, ev, interactionId);
3688
+ });
3689
+ };
3690
+ /**
3691
+ * 将按钮互动事件转换为 Karin 消息事件。
3692
+ *
3693
+ * ACK 请求先发出并结束后再投递业务层,避免业务回复请求抢在 ACK 前占用回调窗口。
3694
+ *
3695
+ * @param client 当前 QQBot 适配器实例。
3696
+ * @param ev QQBot 原始互动事件。
3697
+ * @param interactionId 已确认可用于 ACK 的平台互动 ID。
3698
+ */
3699
+ const emitInteractionMessage = (client, ev, interactionId) => {
3700
+ const buttonData = ev.d.data?.resolved?.button_data || "";
3701
+ const rawEvent = {
3702
+ ...ev,
3703
+ buttonMeta: createInteractionMeta(ev, interactionId)
3704
+ };
3705
+ const time = Number(ev.d.timestamp ? new Date(ev.d.timestamp).getTime() : Date.now()) || Date.now();
3706
+ if (ev.d.chat_type === 1 && ev.d.group_openid) {
3707
+ const userId = ev.d.group_member_openid || "";
3708
+ const contact = karin$1.contactGroup(ev.d.group_openid);
3709
+ const sender = karin$1.groupSender(userId, "unknown");
3710
+ const e = createGroupMessage({
3711
+ bot: client,
3712
+ elements: [segment.text(buttonData)],
3713
+ eventId: ev.id,
3714
+ messageId: interactionId,
3715
+ messageSeq: 0,
3716
+ rawEvent,
3717
+ time,
3718
+ contact,
3719
+ sender,
3720
+ srcReply: (els) => client.srcReply(e, [...els, segment.pasmsg(ev.id, "event")])
3721
+ });
3722
+ return;
3723
+ }
3724
+ if (ev.d.chat_type === 2 && ev.d.user_openid) {
3725
+ const userId = ev.d.user_openid;
3726
+ const contact = karin$1.contactFriend(userId);
3727
+ const sender = karin$1.friendSender(userId, "");
3728
+ const e = createFriendMessage({
3729
+ bot: client,
3730
+ elements: [segment.text(buttonData)],
3731
+ eventId: ev.id,
3732
+ messageId: interactionId,
3733
+ messageSeq: 0,
3734
+ rawEvent,
3735
+ time,
3736
+ contact,
3737
+ sender,
3738
+ srcReply: (els) => client.srcReply(e, [...els, segment.pasmsg(ev.id, "event")])
3739
+ });
3740
+ return;
3741
+ }
3742
+ if (ev.d.chat_type === 0 && ev.d.guild_id && ev.d.channel_id) {
3743
+ const userId = ev.d.data?.resolved?.user_id || "";
3744
+ const contact = karin$1.contact("guild", ev.d.guild_id, ev.d.channel_id);
3745
+ const sender = karin$1.groupSender(userId, "unknown");
3746
+ const e = createGuildMessage({
3747
+ bot: client,
3748
+ elements: [segment.text(buttonData)],
3749
+ eventId: ev.id,
3750
+ messageId: interactionId,
3751
+ messageSeq: 0,
3752
+ rawEvent,
3753
+ time,
3754
+ contact,
3755
+ sender,
3756
+ srcReply: (els) => client.srcReply(e, [...els, segment.pasmsg(ev.id, "event")])
3757
+ });
3758
+ return;
3759
+ }
3760
+ client.logger("debug", `[INTERACTION] 未识别的 chat_type=${ev.d.chat_type}`);
3761
+ };
3762
+
3763
+ //#endregion
3764
+ //#region src/core/event/dispatcher.ts
3765
+ /** 事件名 → 中文标签 */
3766
+ const eventLabel = {
3767
+ ["READY"]: "就绪",
3768
+ ["RESUMED"]: "连接恢复",
3769
+ ["GROUP_AT_MESSAGE_CREATE"]: "群聊@消息",
3770
+ ["GROUP_MESSAGE_CREATE"]: "群聊消息",
3771
+ ["C2C_MESSAGE_CREATE"]: "好友消息",
3772
+ ["MESSAGE_CREATE"]: "频道消息",
3773
+ ["AT_MESSAGE_CREATE"]: "频道@消息",
3774
+ ["DIRECT_MESSAGE_CREATE"]: "频道私信",
3775
+ ["GROUP_ADD_ROBOT"]: "机器人入群",
3776
+ ["GROUP_DEL_ROBOT"]: "机器人退群",
3777
+ ["GROUP_MEMBER_ADD"]: "群成员加入",
3778
+ ["GROUP_MEMBER_REMOVE"]: "群成员退出",
3779
+ ["GROUP_MSG_RECEIVE"]: "群聊开启通知",
3780
+ ["GROUP_MSG_REJECT"]: "群聊关闭通知",
3781
+ ["FRIEND_ADD"]: "添加好友",
3782
+ ["FRIEND_DEL"]: "删除好友",
3783
+ ["C2C_MSG_RECEIVE"]: "单聊开启通知",
3784
+ ["C2C_MSG_REJECT"]: "单聊关闭通知",
3785
+ ["INTERACTION_CREATE"]: "互动事件",
3786
+ ["MESSAGE_AUDIT_PASS"]: "消息审核通过",
3787
+ ["MESSAGE_AUDIT_REJECT"]: "消息审核不通过"
3788
+ };
3789
+ /**
3790
+ * 事件分发
3791
+ */
3792
+ const dispatch$1 = (client, ev) => {
3793
+ const label = eventLabel[ev.t] || ev.t;
3794
+ client.logger("debug", `[事件][${label}]`);
3795
+ log("debug", `${client.selfId}: dispatcher routing t=${ev.t} -> ${label}`);
3796
+ switch (ev.t) {
3797
+ case "READY": return client.logger("info", `WebSocket 就绪: ${ev.d?.user?.username || ""}`);
3798
+ case "RESUMED": return client.logger("info", "WebSocket 连接已恢复");
3799
+ case "GROUP_AT_MESSAGE_CREATE":
3800
+ onGroupMsg(client, ev, { forceAtSelf: true });
3801
+ return;
3802
+ case "GROUP_MESSAGE_CREATE":
3803
+ onGroupMsg(client, ev, { forceAtSelf: false });
3804
+ return;
3805
+ case "C2C_MESSAGE_CREATE":
3806
+ onFriendMsg(client, ev);
3807
+ return;
3808
+ case "MESSAGE_CREATE":
3809
+ case "AT_MESSAGE_CREATE":
3810
+ onChannelMsg(client, ev);
3811
+ return;
3812
+ case "DIRECT_MESSAGE_CREATE":
3813
+ onDirectMsg(client, ev);
3814
+ return;
3815
+ case "GROUP_ADD_ROBOT":
3816
+ onGroupAddRobot(client, ev);
3817
+ return;
3818
+ case "GROUP_DEL_ROBOT":
3819
+ onGroupDelRobot(client, ev);
3820
+ return;
3821
+ case "GROUP_MEMBER_ADD":
3822
+ onGroupMemberAdd(client, ev);
3823
+ return;
3824
+ case "GROUP_MEMBER_REMOVE":
3825
+ onGroupMemberRemove(client, ev);
3826
+ return;
3827
+ case "GROUP_MSG_RECEIVE":
3828
+ onGroupMsgReceive(client, ev);
3829
+ return;
3830
+ case "GROUP_MSG_REJECT":
3831
+ onGroupMsgReject(client, ev);
3832
+ return;
3833
+ case "FRIEND_ADD":
3834
+ onFriendAdd(client, ev);
3835
+ return;
3836
+ case "FRIEND_DEL":
3837
+ onFriendDel(client, ev);
3838
+ return;
3839
+ case "C2C_MSG_RECEIVE":
3840
+ onC2CMsgReceive(client, ev);
3841
+ return;
3842
+ case "C2C_MSG_REJECT":
3843
+ onC2CMsgReject(client, ev);
3844
+ return;
3845
+ case "INTERACTION_CREATE":
3846
+ onInteraction(client, ev);
3847
+ return;
3848
+ case "MESSAGE_AUDIT_PASS": return client.logger("debug", `消息审核通过: audit_id=${ev.d.audit_id}`);
3849
+ case "MESSAGE_AUDIT_REJECT": return client.logger("debug", `消息审核不通过: audit_id=${ev.d.audit_id}`);
3850
+ default: client.logger("debug", `[QQ Official Bot] 未处理事件: ${ev.t}`);
3851
+ }
3852
+ };
3853
+
3854
+ //#endregion
3855
+ //#region src/connection/transport.ts
3856
+ /**
3857
+ * 全局事件总线
3858
+ * 每个 appId 一个 channel,业务层(dispatcher)通过 on(appId, handler) 订阅
3859
+ */
3860
+ const bus = new EventEmitter();
3861
+ bus.setMaxListeners(0);
3862
+ /**
3863
+ * 推送一条事件到业务层
3864
+ */
3865
+ const dispatch = (appId, ev) => {
3866
+ const n = bus.listenerCount(appId);
3867
+ log("debug", `${appId}: bus.dispatch t=${ev.t} listeners=${n}`);
3868
+ if (n === 0) log("warn", `${appId}: 收到事件 ${ev.t} 但没有监听器(dispatcher 未注册或已被移除)`);
3869
+ bus.emit(appId, ev);
3870
+ };
3871
+ /**
3872
+ * 注销某个 appId 的所有监听器
3873
+ * 重连 / 销毁 bot 时调用
3874
+ */
3875
+ const offAll = (appId) => {
3876
+ bus.removeAllListeners(appId);
3877
+ };
3878
+
3879
+ //#endregion
3880
+ //#region src/connection/ws/error.ts
3881
+ /** WebSocket 错误码精确映射 */
3882
+ const wsErrorMap = {
3883
+ 4001: {
3884
+ message: "无效的 opcode",
3885
+ canResume: false,
3886
+ canIdentify: false
3887
+ },
3888
+ 4002: {
3889
+ message: "无效的 payload",
3890
+ canResume: false,
3891
+ canIdentify: false
3892
+ },
3893
+ 4004: {
3894
+ message: "鉴权失败或 access_token 无效",
3895
+ canResume: false,
3896
+ canIdentify: true
3897
+ },
3898
+ 4007: {
3899
+ message: "seq 错误",
3900
+ canResume: false,
3901
+ canIdentify: true
3902
+ },
3903
+ 4006: {
3904
+ message: "无效的 session id,无法继续 resume,请 identify",
3905
+ canResume: false,
3906
+ canIdentify: true
3907
+ },
3908
+ 4008: {
3909
+ message: "发送 payload 过快,请重新连接,并遵守连接后返回的频控信息",
3910
+ canResume: true,
3911
+ canIdentify: true
3912
+ },
3913
+ 4009: {
3914
+ message: "连接过期,请重连并执行 resume 进行重新连接",
3915
+ canResume: true,
3916
+ canIdentify: true
3917
+ },
3918
+ 4010: {
3919
+ message: "无效的 shard",
3920
+ canResume: false,
3921
+ canIdentify: false
3922
+ },
3923
+ 4011: {
3924
+ message: "连接需要处理的 guild 过多,请进行合理的分片",
3925
+ canResume: false,
3926
+ canIdentify: false
3927
+ },
3928
+ 4012: {
3929
+ message: "无效的 version",
3930
+ canResume: false,
3931
+ canIdentify: false
3932
+ },
3933
+ 4013: {
3934
+ message: "无效的 intent",
3935
+ canResume: false,
3936
+ canIdentify: false
3937
+ },
3938
+ 4014: {
3939
+ message: "intent 无权限",
3940
+ canResume: false,
3941
+ canIdentify: false
3942
+ },
3943
+ 4914: {
3944
+ message: "机器人已下架,只允许连接沙箱环境,请断开连接,检验当前连接环境",
3945
+ canResume: false,
3946
+ canIdentify: false
3947
+ },
3948
+ 4915: {
3949
+ message: "机器人已封禁,不允许连接,请断开连接,申请解封后再连接",
3950
+ canResume: false,
3951
+ canIdentify: false
3952
+ }
3953
+ };
3954
+ /**
3955
+ * 获取 WebSocket 错误码信息
3956
+ * @param code - WebSocket close code
3957
+ * @returns 错误信息,未找到则返回 undefined
3958
+ */
3959
+ const getWSErrorInfo = (code) => {
3960
+ const exact = wsErrorMap[code];
3961
+ if (exact) return exact;
3962
+ if (code >= 4900 && code <= 4913) return {
3963
+ message: "内部错误,请重连",
3964
+ canResume: false,
3965
+ canIdentify: true
3966
+ };
3967
+ };
3968
+ /**
3969
+ * 格式化 WebSocket 关闭信息为可读字符串
3970
+ * @param code - WebSocket close code
3971
+ * @param reason - 关闭原因(二进制 payload)
3972
+ * @returns 格式化后的错误字符串
3973
+ */
3974
+ const formatWSClose = (code, reason) => {
3975
+ const info = getWSErrorInfo(code);
3976
+ const parts = [];
3977
+ if (info) {
3978
+ parts.push(`[${code}] ${info.message}`);
3979
+ if (info.canResume && info.canIdentify) parts.push("建议: 可尝试 Resume 或重新 Identify");
3980
+ else if (info.canResume) parts.push("建议: 可尝试 Resume");
3981
+ else if (info.canIdentify) parts.push("建议: 可尝试重新 Identify");
3982
+ else parts.push("建议: 不可重试,请检查配置或联系官方");
3983
+ } else {
3984
+ parts.push(`[${code}] 未知错误码`);
3985
+ if (code === 1e3) parts.push("正常关闭");
3986
+ else if (code === 1001) parts.push("终端离开");
3987
+ else if (code === 1006) parts.push("异常关闭(连接异常断开)");
3988
+ else if (code === 1011) parts.push("服务器遇到异常");
3989
+ }
3990
+ if (reason) parts.push(`reason: ${reason}`);
3991
+ return parts.join(" | ");
3992
+ };
3993
+
3994
+ //#endregion
3995
+ //#region src/connection/ws/client.ts
3996
+ /**
3997
+ * 单连接生命周期
3998
+ */
3999
+ var WSClient = class {
4000
+ opts;
4001
+ socket;
4002
+ heartbeatTimer;
4003
+ heartbeatInterval = 45e3;
4004
+ sessionId = "";
4005
+ seq = 0;
4006
+ closed = false;
4007
+ closedReason = "manual";
4008
+ openedAt = 0;
4009
+ lastAuthAction;
4010
+ constructor(opts) {
4011
+ this.opts = opts;
4012
+ if (opts.resume) {
4013
+ this.sessionId = opts.resume.sessionId;
4014
+ this.seq = opts.resume.seq;
4015
+ }
4016
+ }
4017
+ /** 连接的快照,用于上层 Resume */
4018
+ snapshot() {
4019
+ return {
4020
+ sessionId: this.sessionId,
4021
+ seq: this.seq
4022
+ };
4023
+ }
4024
+ start() {
4025
+ const ws = new WebSocket(this.opts.gatewayUrl, { headers: { "User-Agent": this.opts.userAgent } });
4026
+ this.socket = ws;
4027
+ ws.on("open", () => {
4028
+ this.openedAt = Date.now();
4029
+ log("debug", `${this.opts.appId}: WebSocket opened (${this.opts.gatewayUrl})`);
4030
+ });
4031
+ ws.on("close", (code, reason) => {
4032
+ const remoteReason = reason?.toString() || "";
4033
+ const closeInfo = formatWSClose(code, remoteReason);
4034
+ const info = getWSErrorInfo(code);
4035
+ log("error", `${this.opts.appId}: WebSocket 关闭 | ${closeInfo}`);
4036
+ if (this.closed) return;
4037
+ this.close("closed", {
4038
+ code,
4039
+ remoteReason: remoteReason || void 0,
4040
+ durationMs: this.durationMs(),
4041
+ canResume: info?.canResume,
4042
+ canIdentify: info?.canIdentify
4043
+ });
4044
+ });
4045
+ ws.on("error", (err) => {
4046
+ log("error", `${this.opts.appId}: WebSocket error:`, err);
4047
+ if (this.closed) return;
4048
+ this.close("error", { durationMs: this.durationMs() });
4049
+ });
4050
+ ws.on("unexpected-response", (_req, res) => {
4051
+ log("error", `${this.opts.appId}: WebSocket unexpected-response status=${res.statusCode} ${res.statusMessage}`);
4052
+ });
4053
+ ws.on("message", (raw) => this.onMessage(raw));
4054
+ }
4055
+ /**
4056
+ * 主动关闭
4057
+ */
4058
+ stop() {
4059
+ this.close("manual");
4060
+ }
4061
+ close(reason, event = {}) {
4062
+ if (this.closed) return;
4063
+ this.closed = true;
4064
+ this.closedReason = reason;
4065
+ if (this.heartbeatTimer) {
4066
+ clearInterval(this.heartbeatTimer);
4067
+ this.heartbeatTimer = void 0;
4068
+ }
4069
+ try {
4070
+ this.socket?.removeAllListeners();
4071
+ this.socket?.close();
4072
+ } catch {}
4073
+ this.opts.onClose({
4074
+ reason,
4075
+ ...event
4076
+ });
4077
+ }
4078
+ durationMs() {
4079
+ return this.openedAt ? Date.now() - this.openedAt : void 0;
4080
+ }
4081
+ send(payload) {
4082
+ try {
4083
+ this.socket?.send(JSON.stringify(payload));
4084
+ } catch (err) {
4085
+ log("error", `${this.opts.appId}: send failed`, err);
4086
+ }
4087
+ }
4088
+ startHeartbeat() {
4089
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
4090
+ this.heartbeatTimer = setInterval(() => {
4091
+ if (this.closed) return;
4092
+ this.send({
4093
+ op: 1,
4094
+ d: this.seq || null
4095
+ });
4096
+ }, this.heartbeatInterval);
4097
+ }
4098
+ identify() {
4099
+ this.lastAuthAction = "identify";
4100
+ this.send({
4101
+ op: 2,
4102
+ d: {
4103
+ token: `QQBot ${this.opts.getAccessToken()}`,
4104
+ intents: this.opts.intents,
4105
+ shard: [0, 1],
4106
+ properties: {
4107
+ $os: os.type(),
4108
+ $browser: "@karinjs/adapter-qqbot",
4109
+ $device: "@karinjs/adapter-qqbot"
4110
+ }
4111
+ }
4112
+ });
4113
+ log("debug", `${this.opts.appId}: Identify sent, intents=${this.opts.intents}`);
4114
+ }
4115
+ resume() {
4116
+ this.lastAuthAction = "resume";
4117
+ this.send({
4118
+ op: 6,
4119
+ d: {
4120
+ token: `QQBot ${this.opts.getAccessToken()}`,
4121
+ session_id: this.sessionId,
4122
+ seq: this.seq
4123
+ }
4124
+ });
4125
+ log("debug", `${this.opts.appId}: Resume sent, session=${this.sessionId}, seq=${this.seq}`);
4126
+ }
4127
+ onMessage(raw) {
4128
+ const text = raw.toString();
4129
+ let msg;
4130
+ try {
4131
+ msg = JSON.parse(text);
4132
+ } catch {
4133
+ log("error", `${this.opts.appId}: non-JSON message: ${text}`);
4134
+ return;
4135
+ }
4136
+ log("debug", `${this.opts.appId}: << op=${msg.op} t=${msg.t ?? "-"} s=${msg.s ?? "-"} body=${text.length > 500 ? text.slice(0, 500) + "..." : text}`);
4137
+ switch (msg.op) {
4138
+ case 10:
4139
+ this.heartbeatInterval = msg.d?.heartbeat_interval || 45e3;
4140
+ log("debug", `${this.opts.appId}: Hello received, heartbeat_interval=${this.heartbeatInterval}ms, session=${this.sessionId || "(none)"} seq=${this.seq}`);
4141
+ this.startHeartbeat();
4142
+ if (this.sessionId && this.seq) this.resume();
4143
+ else this.identify();
4144
+ break;
4145
+ case 11:
4146
+ log("debug", `${this.opts.appId}: HeartbeatACK`);
4147
+ break;
4148
+ case 0:
4149
+ if (typeof msg.s === "number") this.seq = msg.s;
4150
+ if (msg.t === "READY") {
4151
+ this.lastAuthAction = void 0;
4152
+ this.sessionId = msg.d?.session_id || "";
4153
+ log("debug", `${this.opts.appId}: READY received, session_id=${this.sessionId}`);
4154
+ }
4155
+ try {
4156
+ this.opts.onEvent(msg);
4157
+ } catch (err) {
4158
+ log("error", `${this.opts.appId}: onEvent handler threw:`, err);
4159
+ }
4160
+ break;
4161
+ case 7:
4162
+ log("debug", `${this.opts.appId}: server asked reconnect (op:7)`);
4163
+ this.close("reconnect");
4164
+ break;
4165
+ case 9: {
4166
+ const canResume = msg.d === true;
4167
+ if (this.lastAuthAction === "identify") {
4168
+ log("error", `${this.opts.appId}: identify rejected (op:9), will try fallback intents, canResume=${canResume}`);
4169
+ this.close("auth_fail", {
4170
+ durationMs: this.durationMs(),
4171
+ invalidSessionCanResume: canResume
4172
+ });
4173
+ } else if (canResume && this.sessionId) {
4174
+ log("debug", `${this.opts.appId}: invalid session, server allows resume`);
4175
+ this.close("reconnect", {
4176
+ durationMs: this.durationMs(),
4177
+ invalidSessionCanResume: true
4178
+ });
4179
+ } else {
4180
+ log("debug", `${this.opts.appId}: invalid session, will retry with fresh identify, canResume=${canResume}`);
4181
+ this.sessionId = "";
4182
+ this.seq = 0;
4183
+ this.close("session_lost", {
4184
+ durationMs: this.durationMs(),
4185
+ invalidSessionCanResume: canResume
4186
+ });
4187
+ }
4188
+ break;
4189
+ }
4190
+ default: log("debug", `${this.opts.appId}: unhandled opcode ${msg.op}`);
4191
+ }
4192
+ }
4193
+ };
4194
+
4195
+ //#endregion
4196
+ //#region src/connection/ws/intents.ts
4197
+ /**
4198
+ * intent bit -> 名称(中英双语)
4199
+ */
4200
+ const intentBitMap = {
4201
+ [1]: "GUILDS (频道生命周期)",
4202
+ [2]: "GUILD_MEMBERS (频道成员变更)",
4203
+ [512]: "GUILD_MESSAGES (频道消息-私域)",
4204
+ [4096]: "DIRECT_MESSAGE (频道私信)",
4205
+ [1 << 24]: "群成员进退群事件",
4206
+ [1 << 25]: "GROUP_AND_C2C_EVENT (群聊/单聊消息)",
4207
+ [1 << 26]: "INTERACTION (按钮交互回调)",
4208
+ [1 << 30]: "PUBLIC_GUILD_MESSAGES (频道消息-公域)"
4209
+ };
4210
+ /** 优先级从高到低,回退时从最前面剥离 */
4211
+ const priorityIntents = [
4212
+ 2,
4213
+ 512,
4214
+ 1 << 30,
4215
+ 4096,
4216
+ 1 << 24,
4217
+ 1 << 26,
4218
+ 1 << 25,
4219
+ 1
4220
+ ];
4221
+ /**
4222
+ * 计算最大 intents(全部启用)
4223
+ */
4224
+ const computeMax = () => priorityIntents.reduce((acc, bit) => acc | bit, 0);
4225
+ /**
4226
+ * 在当前 intents 基础上去掉最高优先级的一位,得到回退值
4227
+ * 返回 0 表示已无可用 intent
4228
+ */
4229
+ const computeFallback = (current) => {
4230
+ for (const bit of priorityIntents) if (current & bit) return current & ~bit;
4231
+ return 0;
4232
+ };
4233
+ /**
4234
+ * 将 intents 数值解析为可读名称列表
4235
+ */
4236
+ const formatIntentNames = (intents, prefix = "") => {
4237
+ const names = [];
4238
+ for (const [bit, name] of Object.entries(intentBitMap)) if (intents & Number(bit)) names.push(prefix + name);
4239
+ return names.join("\n");
4240
+ };
4241
+
4242
+ //#endregion
4243
+ //#region src/connection/ws/manager.ts
4244
+ const conns = /* @__PURE__ */ new Map();
4245
+ /** /gateway 接口超时(毫秒) */
4246
+ const GATEWAY_FETCH_TIMEOUT_MS = 5e3;
4247
+ const RATE_LIMIT_DELAY_MS = 6e4;
4248
+ const QUICK_DISCONNECT_THRESHOLD_MS = 5e3;
4249
+ const QUICK_DISCONNECT_LIMIT = 3;
4250
+ const QUICK_DISCONNECT_DELAY_MS = 6e4;
4251
+ /**
4252
+ * 从 prodApi / sandboxApi 推导兜底的 WSS 地址
4253
+ * 官方实际长期返回的也是 wss://{host}/websocket/,不依赖 /gateway 也能直连
4254
+ */
4255
+ const buildFallbackWssUrl = (cfg) => {
4256
+ return `${(cfg.sandbox ? cfg.sandboxApi : cfg.prodApi).replace(/^https?:\/\//i, "wss://").replace(/\/+$/, "")}/websocket/`;
4257
+ };
4258
+ /**
4259
+ * 获取网关地址:优先调 /gateway;失败 / 超时 / 限频 → 直接 fallback 到硬编码
4260
+ */
4261
+ const fetchGateway = async (cfg) => {
4262
+ const apiUrl = cfg.sandbox ? cfg.sandboxApi : cfg.prodApi;
4263
+ const fallback = buildFallbackWssUrl(cfg);
4264
+ const accessToken = getBotAccessToken(cfg.appId);
4265
+ if (!accessToken) {
4266
+ log("warn", `${cfg.appId}: 无 access_token,使用硬编码 WSS ${fallback}`);
4267
+ return fallback;
4268
+ }
4269
+ try {
4270
+ const { data } = await axios.get(`${apiUrl}/gateway`, {
4271
+ headers: {
4272
+ Authorization: `QQBot ${accessToken}`,
4273
+ "User-Agent": getUserAgent()
4274
+ },
4275
+ timeout: GATEWAY_FETCH_TIMEOUT_MS
4276
+ });
4277
+ if (data?.url) return data.url;
4278
+ log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码 WSS ${fallback}`);
4279
+ return fallback;
4280
+ } catch (err) {
4281
+ if (axios.isAxiosError(err)) {
4282
+ const response = err.response;
4283
+ const status = response?.status ?? 0;
4284
+ const data = response?.data;
4285
+ const detail = formatOpenAPIError(status, typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
4286
+ log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WSS ${fallback}`);
4287
+ } else {
4288
+ const reason = err?.message || "unknown";
4289
+ log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码 WSS ${fallback}`);
4290
+ }
4291
+ return fallback;
4292
+ }
4293
+ };
4294
+ /** 重连退避(毫秒) */
4295
+ const backoff = (attempt) => Math.min(3e4, 1500 * 2 ** Math.min(attempt, 5)) + random(0, 1500);
4296
+ /**
4297
+ * 打印 intents 探测汇总
4298
+ */
4299
+ const summarize = (appId, finalIntents, success) => {
4300
+ const conn = conns.get(appId);
4301
+ if (!conn || conn.intentHistory.length <= 1) return;
4302
+ const initial = conn.intentHistory[0];
4303
+ const removed = [];
4304
+ for (let i = 1; i < conn.intentHistory.length; i++) {
4305
+ const diff = conn.intentHistory[i - 1] ^ conn.intentHistory[i];
4306
+ for (const [bit, name] of Object.entries(intentBitMap)) if (diff & Number(bit)) removed.push(name.trim());
4307
+ }
4308
+ const removedBlock = removed.length ? " └─ 被移除 / Removed:\n" + removed.map((n) => ` ${n.trim()}`).join("\n") : " └─ 被移除 / Removed: 无";
4309
+ log("debug", [
4310
+ `${appId}: intents ${success ? "探测完成 | Probe Done" : "探测失败 | Probe Failed"}`,
4311
+ ` ├─ 初始 / Initial: ${initial}`,
4312
+ ` ├─ 最终 / Final : ${finalIntents}`,
4313
+ formatIntentNames(finalIntents, " │ "),
4314
+ removedBlock
4315
+ ].join("\n"));
4316
+ conn.intentHistory = [];
4317
+ };
4318
+ /**
4319
+ * 启动连接
4320
+ */
4321
+ const start = async (cfg) => {
4322
+ if (cfg.event.type !== 2) return;
4323
+ if (!cfg.appId) return;
4324
+ if (conns.has(cfg.appId)) stop(cfg.appId);
4325
+ const intents = computeMax();
4326
+ conns.set(cfg.appId, {
4327
+ client: null,
4328
+ cfg,
4329
+ intents,
4330
+ intentHistory: [intents],
4331
+ quickDisconnects: 0,
4332
+ aborted: false
4333
+ });
4334
+ await connect(cfg, intents, 0);
4335
+ };
4336
+ /**
4337
+ * 调度一次重试(统一封装,便于 stop 取消)
4338
+ */
4339
+ const schedule = (appId, delay, fn) => {
4340
+ const conn = conns.get(appId);
4341
+ if (!conn || conn.aborted) return;
4342
+ if (conn.pendingTimer) clearTimeout(conn.pendingTimer);
4343
+ conn.pendingTimer = setTimeout(() => {
4344
+ if (conn.aborted) return;
4345
+ conn.pendingTimer = void 0;
4346
+ fn();
4347
+ }, delay);
4348
+ };
4349
+ /**
4350
+ * 内部连接 + 重试
4351
+ */
4352
+ const connect = async (cfg, intents, attempt, resume) => {
4353
+ let conn = conns.get(cfg.appId);
4354
+ if (!conn || conn.aborted) return;
4355
+ let gatewayUrl = conn.gatewayUrl;
4356
+ if (!gatewayUrl) gatewayUrl = await fetchGateway(cfg);
4357
+ conn = conns.get(cfg.appId);
4358
+ if (!conn || conn.aborted) return;
4359
+ conn.gatewayUrl = gatewayUrl;
4360
+ const client = new WSClient({
4361
+ appId: cfg.appId,
4362
+ gatewayUrl,
4363
+ userAgent: getUserAgent(),
4364
+ getAccessToken: () => getBotAccessToken(cfg.appId) || "",
4365
+ intents,
4366
+ resume,
4367
+ onEvent: (ev) => {
4368
+ if (ev.t === "READY") summarize(cfg.appId, intents, true);
4369
+ dispatch(cfg.appId, ev);
4370
+ },
4371
+ onClose: (event) => onClose(cfg, intents, attempt, event, client)
4372
+ });
4373
+ conn.client = client;
4374
+ conn.cfg = cfg;
4375
+ conn.intents = intents;
4376
+ if (conn.intentHistory[conn.intentHistory.length - 1] !== intents) conn.intentHistory.push(intents);
4377
+ client.start();
4378
+ };
4379
+ /**
4380
+ * 关闭回调处理
4381
+ */
4382
+ const onClose = (cfg, intents, attempt, event, client) => {
4383
+ if (event.reason === "manual") {
4384
+ log("debug", `${cfg.appId}: WebSocket 已主动关闭`);
4385
+ return;
4386
+ }
4387
+ const conn = conns.get(cfg.appId);
4388
+ if (!conn || conn.aborted) return;
4389
+ const code = event.code;
4390
+ const closeLabel = formatCloseLabel(event);
4391
+ if (event.reason === "auth_fail" || code === 4013 || code === 4014) {
4392
+ const fallback = computeFallback(intents);
4393
+ if (fallback && fallback !== intents) {
4394
+ log("warn", `${cfg.appId}: Identify 被拒,尝试降级 intents ${intents} -> ${fallback}`);
4395
+ schedule(cfg.appId, 1e3, () => connect(cfg, fallback, 0));
4396
+ return;
4397
+ }
4398
+ summarize(cfg.appId, 0, false);
4399
+ log("error", `${cfg.appId}: 鉴权失败,所有 intents 均不可用,请检查机器人是否已上线`);
4400
+ conn.aborted = true;
4401
+ conns.delete(cfg.appId);
4402
+ return;
4403
+ }
4404
+ if (code === 4914 || code === 4915) {
4405
+ summarize(cfg.appId, intents, false);
4406
+ log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),机器人状态不允许连接,已停止自动重连`);
4407
+ conn.aborted = true;
4408
+ conns.delete(cfg.appId);
4409
+ return;
4410
+ }
4411
+ if (event.canResume === false && event.canIdentify === false) {
4412
+ summarize(cfg.appId, intents, false);
4413
+ log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),该错误不建议重试,已停止自动重连`);
4414
+ conn.aborted = true;
4415
+ conns.delete(cfg.appId);
4416
+ return;
4417
+ }
4418
+ if (code === 4004) {
4419
+ log("warn", `${cfg.appId}: access_token 已失效,刷新 token 后重连...`);
4420
+ schedule(cfg.appId, 1e3, () => {
4421
+ getAccessToken(cfg.tokenApi, cfg.appId, cfg.secret).then(() => connect(cfg, intents, 0)).catch(() => {
4422
+ log("error", `${cfg.appId}: access_token 刷新失败,${RATE_LIMIT_DELAY_MS}ms 后重试连接...`);
4423
+ schedule(cfg.appId, RATE_LIMIT_DELAY_MS, () => connect(cfg, intents, attempt + 1));
4424
+ });
4425
+ });
4426
+ return;
4427
+ }
4428
+ const snap = client.snapshot();
4429
+ const resume = event.reason === "session_lost" || shouldFreshIdentify(code) ? void 0 : snap.sessionId ? snap : void 0;
4430
+ const delay = reconnectDelay(conn, attempt, event, code === 4008 ? RATE_LIMIT_DELAY_MS : void 0);
4431
+ const nextAttempt = shouldResetAttempt(event) ? 0 : attempt + 1;
4432
+ log("error", `${cfg.appId}: WebSocket 断开 (${closeLabel}),${delay}ms 后${resume ? "Resume" : "Identify"}重连...`);
4433
+ schedule(cfg.appId, delay, () => connect(cfg, intents, nextAttempt, resume));
4434
+ };
4435
+ const shouldFreshIdentify = (code) => {
4436
+ if (code === 4006 || code === 4007 || code === 4009) return true;
4437
+ return typeof code === "number" && code >= 4900 && code <= 4913;
4438
+ };
4439
+ const shouldResetAttempt = (event) => {
4440
+ return typeof event.durationMs === "number" && event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS;
4441
+ };
4442
+ const reconnectDelay = (conn, attempt, event, forcedDelay) => {
4443
+ const stable = shouldResetAttempt(event);
4444
+ let delay = forcedDelay ?? backoff(stable ? 0 : attempt);
4445
+ if (typeof event.durationMs !== "number") return delay;
4446
+ if (event.durationMs >= QUICK_DISCONNECT_THRESHOLD_MS) {
4447
+ conn.quickDisconnects = 0;
4448
+ return delay;
4449
+ }
4450
+ conn.quickDisconnects += 1;
4451
+ if (conn.quickDisconnects >= QUICK_DISCONNECT_LIMIT) {
4452
+ delay = Math.max(delay, QUICK_DISCONNECT_DELAY_MS);
4453
+ log("warn", `${conn.cfg.appId}: WebSocket 连续快速断开 ${conn.quickDisconnects} 次,延长重连间隔到 ${delay}ms`);
4454
+ }
4455
+ return delay;
4456
+ };
4457
+ const formatCloseLabel = (event) => {
4458
+ const parts = [event.reason];
4459
+ if (typeof event.code === "number") parts.push(String(event.code));
4460
+ if (event.remoteReason) parts.push(event.remoteReason);
4461
+ return parts.join(" / ");
4462
+ };
4463
+ /**
4464
+ * 停止连接(同步:标记 abort + 清 pendingTimer + 关 client)
4465
+ */
4466
+ const stop = (appId) => {
4467
+ const conn = conns.get(appId);
4468
+ if (!conn) return false;
4469
+ conn.aborted = true;
4470
+ if (conn.pendingTimer) {
4471
+ clearTimeout(conn.pendingTimer);
4472
+ conn.pendingTimer = void 0;
4473
+ }
4474
+ try {
4475
+ conn.client?.stop();
4476
+ } catch {}
4477
+ conns.delete(appId);
4478
+ return true;
4479
+ };
4480
+
4481
+ //#endregion
4482
+ //#region src/core/index.ts
4483
+ /**
4484
+ * 初始化所有 Bot
4485
+ */
4486
+ const initQQBotAdapter = async () => {
4487
+ const cfg = config();
4488
+ logger.debug(`[QQ Official Bot] 开始初始化 ${cfg.length} 个 Bot...`);
4489
+ for (const bot of cfg) try {
4490
+ await createBot(bot);
4491
+ } catch (err) {
4492
+ logger.debug(`[QQ Official Bot] 初始化 Bot 失败 ${bot.appId || "(无 appId)"}:`, err);
4493
+ }
4494
+ };
4495
+ /**
4496
+ * 初始化状态(防止并发触发重复连接)
4497
+ */
4498
+ const initState = /* @__PURE__ */ new Map();
4499
+ const hashConfig = (bot) => JSON.stringify({
4500
+ appId: bot.appId,
4501
+ secret: bot.secret,
4502
+ tokenApi: bot.tokenApi,
4503
+ prodApi: bot.prodApi,
4504
+ sandboxApi: bot.sandboxApi,
4505
+ sandbox: bot.sandbox,
4506
+ event: bot.event,
4507
+ keyboard: bot.keyboard,
4508
+ messageCache: bot.messageCache
4509
+ });
4510
+ /**
4511
+ * 创建单个 Bot 实例
4512
+ */
4513
+ const createBot = async (bot) => {
4514
+ const appId = String(bot.appId);
4515
+ if (!appId) {
4516
+ logger.warn("[QQ Official Bot] 配置缺少 appId,跳过");
4517
+ return;
4518
+ }
4519
+ if (bot.event.type === 0) {
4520
+ logger.info(`[QQ Official Bot][${appId}] 事件接收方式为"关闭",跳过`);
4521
+ return;
4522
+ }
4523
+ const hash = hashConfig(bot);
4524
+ const existing = initState.get(appId);
4525
+ if (existing) {
4526
+ if (existing.hash === hash) return existing.promise;
4527
+ await existing.promise.catch(() => {});
4528
+ }
4529
+ const promise = (async () => {
4530
+ unregisterBot("selfId", appId);
4531
+ stop(appId);
4532
+ offAll(appId);
4533
+ stopTokenRefresh(appId);
4534
+ logger.info(`[QQ Official Bot][${appId}] 获取 access_token...`);
4535
+ await getAccessToken(bot.tokenApi, appId, bot.secret);
4536
+ const baseUrl = bot.sandbox ? bot.sandboxApi : bot.prodApi;
4537
+ const api = new QQBotApi(createAxiosInstance(baseUrl, appId));
4538
+ const me = await api.meta.getMe();
4539
+ const client = new AdapterQQBot(bot, api);
4540
+ client.account.name = me.username;
4541
+ client.account.avatar = me.avatar;
4542
+ client.account.selfId = appId;
4543
+ const qq = new URL(me.share_url).searchParams.get("robot_uin");
4544
+ client.account.subId.id = me.id;
4545
+ client.account.subId.qq = qq;
4546
+ client.account.subId.appid = appId;
4547
+ client.account.subId.union_openid = me.union_openid;
4548
+ bus.on(appId, (ev) => {
4549
+ dispatch$1(client, ev);
4550
+ });
4551
+ client.adapter.address = baseUrl;
4552
+ client.adapter.secret = "*********";
4553
+ client.adapter.version = pkg().version;
4554
+ if (bot.event.type === 2) {
4555
+ client.adapter.index = registerBot("webSocketClient", client);
4556
+ logger.debug(`[QQ Official Bot][${appId}] 已注册为 webSocketClient`);
4557
+ await start(bot);
4558
+ } else if (bot.event.type === 1) {
4559
+ client.adapter.index = registerBot("http", client);
4560
+ logger.debug(`[QQ Official Bot][${appId}] 已注册为 http`);
4561
+ }
4562
+ })();
4563
+ initState.set(appId, {
4564
+ hash,
4565
+ promise
4566
+ });
4567
+ promise.finally(() => {
4568
+ if (initState.get(appId)?.hash === hash) initState.delete(appId);
4569
+ });
4570
+ return promise;
4571
+ };
4572
+ /**
4573
+ * 销毁 bot:注销 + 关 WS + 取消监听 + 停止 token 刷新
4574
+ */
4575
+ const destroyBot = (appId) => {
4576
+ unregisterBot("selfId", appId);
4577
+ stop(appId);
4578
+ offAll(appId);
4579
+ stopTokenRefresh(appId);
4580
+ };
4581
+ bindHandlers(createBot, destroyBot);
4582
+
4583
+ //#endregion
4584
+ //#region src/core/api/sign.ts
4585
+ /**
4586
+ * 从 secret 生成密钥对(不足 32 字节循环填充)
4587
+ */
4588
+ const generateKeyPair = (seed) => {
4589
+ let finalSeed = seed;
4590
+ while (finalSeed.length < 32) finalSeed = finalSeed.repeat(2);
4591
+ finalSeed = finalSeed.slice(0, 32);
4592
+ const seedBuffer = Buffer.from(finalSeed, "utf-8");
4593
+ return nacl.sign.keyPair.fromSeed(seedBuffer);
4594
+ };
4595
+ /**
4596
+ * 用 secret 对 eventTs + plainToken 签名(hex 编码)
4597
+ */
4598
+ const sign = (secret, eventTs, plainToken) => {
4599
+ const { secretKey } = generateKeyPair(secret);
4600
+ const messageBytes = Buffer.from(eventTs + plainToken, "utf-8");
4601
+ const signature = nacl.sign.detached(messageBytes, secretKey);
4602
+ return Buffer.from(signature).toString("hex");
4603
+ };
4604
+ /**
4605
+ * 验证 webhook 推送签名(ed25519)
4606
+ * @param secret 机器人密钥
4607
+ * @param timestamp x-signature-timestamp 头
4608
+ * @param rawBody 原始 body 字符串
4609
+ * @param ed25519Hex x-signature-ed25519 头
4610
+ */
4611
+ const verifySignature = (secret, timestamp, rawBody, ed25519Hex) => {
4612
+ return sign(secret, timestamp, rawBody) === ed25519Hex;
4613
+ };
4614
+
4615
+ //#endregion
4616
+ //#region src/connection/webhook.ts
4617
+ /**
4618
+ * 解析请求:读 rawBody + JSON + 字段校验
4619
+ * 失败返回 null,并已写入日志
4620
+ */
4621
+ const parseRequest = async (req) => {
4622
+ const ip = req.socket.remoteAddress;
4623
+ const rawBody = await new Promise((resolve) => {
4624
+ const chunks = [];
4625
+ req.on("data", (chunk) => chunks.push(chunk));
4626
+ req.on("end", () => resolve(chunks.join("")));
4627
+ });
4628
+ const appid = req.headers["x-bot-appid"];
4629
+ if (!appid) {
4630
+ fakeEvent(`未找到 x-bot-appid: ${ip} body: ${rawBody}`);
4631
+ return null;
4632
+ }
4633
+ if (req.headers["user-agent"] !== "QQBot-Callback") {
4634
+ fakeEvent(`User-Agent 非 QQBot-Callback: ${ip} body: ${rawBody}`);
4635
+ return null;
4636
+ }
4637
+ let body;
4638
+ try {
4639
+ body = JSON.parse(rawBody) || {};
4640
+ } catch {
4641
+ fakeEvent(`非法 JSON: ${ip} body: ${rawBody}`);
4642
+ return null;
4643
+ }
4644
+ if (typeof body.op !== "number") {
4645
+ fakeEvent(`缺少 op 字段: ${ip} body: ${rawBody}`);
4646
+ return null;
4647
+ }
4648
+ return {
4649
+ appid,
4650
+ rawBody,
4651
+ body
4652
+ };
4653
+ };
4654
+ /**
4655
+ * QQ 官方 webhook 路由
4656
+ * POST /qqbot/webhook
4657
+ */
4658
+ const webhookRouting = async (req, res) => {
4659
+ const parsed = await parseRequest(req);
4660
+ if (!parsed) {
4661
+ res.status(200).end();
4662
+ return;
4663
+ }
4664
+ const cfg = getConfig(parsed.appid);
4665
+ if (!cfg) {
4666
+ log("error", `[webhook][${parsed.appid}] 配置文件中未找到对应 appId`);
4667
+ res.status(200).end();
4668
+ return;
4669
+ }
4670
+ if (cfg.event.type !== 1) {
4671
+ log("error", `[webhook][${parsed.appid}] webhook 未启用`);
4672
+ res.status(200).end();
4673
+ return;
4674
+ }
4675
+ if (parsed.body.op === 13) {
4676
+ const eventTs = parsed.body?.d?.event_ts;
4677
+ const plainToken = parsed.body?.d?.plain_token;
4678
+ if (!eventTs || !plainToken) {
4679
+ log("error", `[webhook][${parsed.appid}] 缺少 event_ts/plain_token: ${parsed.rawBody}`);
4680
+ res.status(200).end();
4681
+ return;
4682
+ }
4683
+ const signature = sign(cfg.secret, eventTs, plainToken);
4684
+ log("mark", `[webhook][${parsed.appid}] sign=${signature}`);
4685
+ res.setHeader("Content-Type", "application/json");
4686
+ res.status(200).end(JSON.stringify({
4687
+ plain_token: plainToken,
4688
+ signature
4689
+ }));
4690
+ return;
4691
+ }
4692
+ const ed25519 = req.headers["x-signature-ed25519"];
4693
+ const timestamp = req.headers["x-signature-timestamp"];
4694
+ if (!verifySignature(cfg.secret, timestamp, parsed.rawBody, ed25519)) {
4695
+ fakeEvent(`签名验证失败 appid=${parsed.appid} headerSig=${ed25519} body=${parsed.rawBody}`);
4696
+ res.status(200).end();
4697
+ return;
4698
+ }
4699
+ res.status(200).end();
4700
+ dispatch(parsed.appid, parsed.body);
4701
+ };
4702
+
4703
+ //#endregion
4704
+ //#region src/connection/routing.ts
4705
+ /**
4706
+ * 初始化路由
4707
+ * 创建 /qqbot/webhook 路由用于接收QQ机器人事件
4708
+ */
4709
+ const createRouting = () => {
4710
+ const router = express.Router();
4711
+ router.use("/webhook", webhookRouting);
4712
+ app.use("/qqbot", router);
4713
+ };
4714
+
4715
+ //#endregion
4716
+ //#region src/index.ts
4717
+ logger.info(`${logger.violet(`[插件:${pkg().version}]`)} ${logger.green(basename)} 初始化完成~`);
99
4718
  createRouting();
100
- initQQBotAdapter();
4719
+ const printOnboardBanner = () => {
4720
+ console.log();
4721
+ console.log(" ==========================================");
4722
+ console.log(" 未检测到 QQBot 配置");
4723
+ console.log(" 请使用任意可用 bot(如 OneBot / Console 适配器)");
4724
+ console.log(" 对机器人发送指令: #QQ登录");
4725
+ console.log(" 扫码授权完成后会自动写入配置并完成初始化");
4726
+ console.log();
4727
+ console.log(" ⚠ 注意:扫码授权会刷新该机器人的 secret,旧 secret 立即失效");
4728
+ console.log(" ==========================================");
4729
+ console.log();
4730
+ };
4731
+ const bootstrap = async () => {
4732
+ if (needQrOnboard()) {
4733
+ printOnboardBanner();
4734
+ return;
4735
+ }
4736
+ try {
4737
+ await initQQBotAdapter();
4738
+ } catch (err) {
4739
+ log("error", "初始化适配器失败:", err);
4740
+ }
4741
+ };
4742
+ bootstrap();
4743
+
4744
+ //#endregion
4745
+ export { };