@karinjs/adapter-qqbot 2.1.0 → 3.0.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/README.md +10 -0
- package/dist/apps/cleanup.d.ts +6 -2
- package/dist/apps/cleanup.js +5 -10
- package/dist/apps/login.js +2 -2
- package/dist/config-BUuU6FnQ.js +293 -0
- package/dist/index.d.ts +426 -217
- package/dist/index.js +470 -288
- package/dist/message-BTGL3TSn.js +1843 -0
- package/dist/{onboard-DPWgX39H.js → onboard-vIEoe9dB.js} +1 -1
- package/dist/utils-N9PNiHjE.js +4 -0
- package/dist/web.config.d.ts +24 -19
- package/dist/web.config.js +262 -91
- package/package.json +8 -6
- package/dist/config-DKa7596f.js +0 -170
- package/dist/message-O73AeL_P.js +0 -1739
- package/dist/utils-BPbnNAix.js +0 -4
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { c as
|
|
1
|
+
import { c as joinHttpPath, d as normalizeWsGatewayUrl, f as pkg, h as basename, l as normalizeHttpUrl, m as log, n as config, r as getConfig, s as buildFallbackWsUrlFromApi, t as bindHandlers, u as normalizeProxyConfig } from "./config-BUuU6FnQ.js";
|
|
2
2
|
import { i as random, n as getImageSize, r as handleUrl, t as fakeEvent } from "./common-by1GpV0b.js";
|
|
3
|
-
import "./utils-
|
|
4
|
-
import { t as getMessageStore } from "./message-
|
|
5
|
-
import { t as needQrOnboard } from "./onboard-
|
|
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";
|
|
3
|
+
import "./utils-N9PNiHjE.js";
|
|
4
|
+
import { t as getMessageStore } from "./message-BTGL3TSn.js";
|
|
5
|
+
import { t as needQrOnboard } from "./onboard-vIEoe9dB.js";
|
|
6
|
+
import { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
|
|
7
7
|
import { URL } from "url";
|
|
8
8
|
import lodash from "node-karin/lodash";
|
|
9
9
|
import { createReadStream } from "node:fs";
|
|
@@ -22,7 +22,7 @@ import nacl from "tweetnacl";
|
|
|
22
22
|
//#region src/core/api/error.ts
|
|
23
23
|
/**
|
|
24
24
|
* OpenAPI 错误码映射表
|
|
25
|
-
* 来源: api-
|
|
25
|
+
* 来源: api-docs/openapi/error/error.md
|
|
26
26
|
*
|
|
27
27
|
* 规则:
|
|
28
28
|
* - 区间码 (如 301000~301099) 用范围匹配
|
|
@@ -41,7 +41,7 @@ const httpStatusMap = {
|
|
|
41
41
|
500: "处理失败",
|
|
42
42
|
504: "处理失败"
|
|
43
43
|
};
|
|
44
|
-
/** 业务错误码(
|
|
44
|
+
/** 业务错误码(err_code)映射 */
|
|
45
45
|
const errorCodeMap = {
|
|
46
46
|
10001: "UnknownAccount 账号异常",
|
|
47
47
|
10003: "UnknownChannel 子频道异常",
|
|
@@ -359,15 +359,16 @@ const formatOpenAPIError = (status, code, message) => {
|
|
|
359
359
|
//#region src/core/api/http.ts
|
|
360
360
|
/**
|
|
361
361
|
* 包装 axios 错误为可读多行 message
|
|
362
|
-
* 如果响应中包含 QQ 官方错误码(
|
|
362
|
+
* 如果响应中包含 QQ 官方错误码(err_code),会自动映射为中文描述
|
|
363
363
|
*/
|
|
364
364
|
const formatError = (path, options, err) => {
|
|
365
365
|
if (axios.isAxiosError(err)) {
|
|
366
366
|
const response = err.response;
|
|
367
367
|
const status = response?.status ?? 0;
|
|
368
368
|
const data = response?.data;
|
|
369
|
-
const code = typeof data?.code === "number" ? data.code : void 0;
|
|
369
|
+
const code = typeof data?.err_code === "number" ? data.err_code : typeof data?.code === "number" ? data.code : void 0;
|
|
370
370
|
const msg = typeof data?.message === "string" ? data.message : void 0;
|
|
371
|
+
const traceId = typeof data?.trace_id === "string" ? data.trace_id : void 0;
|
|
371
372
|
const lines = [];
|
|
372
373
|
lines.push("[axios] 请求失败");
|
|
373
374
|
lines.push(`请求路径: ${path}`);
|
|
@@ -377,6 +378,7 @@ const formatError = (path, options, err) => {
|
|
|
377
378
|
if (reason) lines.push(`请求错误: ${reason}`);
|
|
378
379
|
}
|
|
379
380
|
if (code !== void 0 || status > 0) lines.push(`错误详情: ${formatOpenAPIError(status, code, msg)}`);
|
|
381
|
+
if (traceId) lines.push(`TraceID: ${traceId}`);
|
|
380
382
|
lines.push(`响应数据: ${JSON.stringify(data)}`);
|
|
381
383
|
return new Error(lines.join("\n"));
|
|
382
384
|
}
|
|
@@ -447,6 +449,8 @@ var Http = class {
|
|
|
447
449
|
|
|
448
450
|
//#endregion
|
|
449
451
|
//#region src/core/api/messages.ts
|
|
452
|
+
/** 输入中状态默认展示时长(秒),平台 typing 窗口约 60s */
|
|
453
|
+
const INPUT_NOTIFY_DEFAULT_SECOND = 60;
|
|
450
454
|
/**
|
|
451
455
|
* 消息发送 + 撤回
|
|
452
456
|
*/
|
|
@@ -459,6 +463,33 @@ var MessagesApi = class extends Http {
|
|
|
459
463
|
sendGroupMsg(groupOpenid, body) {
|
|
460
464
|
return this.post(`/v2/groups/${groupOpenid}/messages`, body);
|
|
461
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* 单聊输入中状态(msg_type=6,仅单聊)
|
|
468
|
+
* @param openid 用户 openid
|
|
469
|
+
* @param inputSecond 展示时长(秒),默认 60
|
|
470
|
+
* @param passive 被动消息参数(msg_id / event_id / msg_seq),可选
|
|
471
|
+
*/
|
|
472
|
+
sendFriendInputNotify(openid, inputSecond = INPUT_NOTIFY_DEFAULT_SECOND, passive) {
|
|
473
|
+
return this.post(`/v2/users/${openid}/messages`, {
|
|
474
|
+
msg_type: 6,
|
|
475
|
+
input_notify: {
|
|
476
|
+
input_type: 1,
|
|
477
|
+
input_second: inputSecond
|
|
478
|
+
},
|
|
479
|
+
...passive
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* 单聊流式消息(仅单聊)
|
|
484
|
+
*
|
|
485
|
+
* 首帧不带 stream_msg_id,响应的 id 作为后续帧的 stream_msg_id 回传;
|
|
486
|
+
* 同一次流 index 从 0 递增、msg_seq 保持一致;建议帧间隔 300~500ms。
|
|
487
|
+
* @param openid 用户 openid
|
|
488
|
+
* @param body 流式消息帧
|
|
489
|
+
*/
|
|
490
|
+
sendFriendStreamMsg(openid, body) {
|
|
491
|
+
return this.post(`/v2/users/${openid}/stream_messages`, body);
|
|
492
|
+
}
|
|
462
493
|
/** 文字子频道消息 */
|
|
463
494
|
sendChannelMsg(channelId, body) {
|
|
464
495
|
const headers = body instanceof FormData ? body.getHeaders() : void 0;
|
|
@@ -485,13 +516,10 @@ var MessagesApi = class extends Http {
|
|
|
485
516
|
|
|
486
517
|
//#endregion
|
|
487
518
|
//#region src/core/api/upload-cache.ts
|
|
488
|
-
/** 单进程内最多缓存的 file_info 数量,避免长期运行时内存无限增长。 */
|
|
489
|
-
const MAX_CACHE_SIZE = 500;
|
|
490
519
|
/** 比 QQ 返回的 ttl 提前失效,避免临界点使用到刚过期的 file_info。 */
|
|
491
520
|
const TTL_SAFETY_MARGIN_SECONDS = 60;
|
|
492
521
|
/** QQ 返回极短 ttl 时仍保留一个最小可用窗口。 */
|
|
493
522
|
const MIN_EFFECTIVE_TTL_SECONDS = 10;
|
|
494
|
-
const cache = /* @__PURE__ */ new Map();
|
|
495
523
|
/**
|
|
496
524
|
* 构建 file_info 缓存键。
|
|
497
525
|
*
|
|
@@ -511,41 +539,25 @@ const cacheKey = (scene, peer, type, contentHash) => [
|
|
|
511
539
|
contentHash
|
|
512
540
|
].join("");
|
|
513
541
|
/**
|
|
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
542
|
* 读取可复用的上传结果。
|
|
533
543
|
*
|
|
544
|
+
* 缓存持久化在 SQLite(file_info ttl 可达数天),进程重启后同一份媒体
|
|
545
|
+
* 不需要重新上传。缓存层异常一律按未命中处理,不影响上传主链路。
|
|
546
|
+
*
|
|
534
547
|
* @param scene 上传场景。
|
|
535
548
|
* @param peer openid 或 group_openid。
|
|
536
549
|
* @param type 富媒体类型。
|
|
537
550
|
* @param contentHash 文件内容 hash。
|
|
538
551
|
* @returns 未命中或过期时返回 null。
|
|
539
552
|
*/
|
|
540
|
-
const getCachedUpload = (scene, peer, type, contentHash) => {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
553
|
+
const getCachedUpload = async (scene, peer, type, contentHash) => {
|
|
554
|
+
try {
|
|
555
|
+
const raw = await getMessageStore().getUploadCache(cacheKey(scene, peer, type, contentHash));
|
|
556
|
+
if (!raw) return null;
|
|
557
|
+
return JSON.parse(raw);
|
|
558
|
+
} catch {
|
|
546
559
|
return null;
|
|
547
560
|
}
|
|
548
|
-
return { ...entry.response };
|
|
549
561
|
};
|
|
550
562
|
/**
|
|
551
563
|
* 写入上传结果缓存。
|
|
@@ -556,17 +568,13 @@ const getCachedUpload = (scene, peer, type, contentHash) => {
|
|
|
556
568
|
* @param contentHash 文件内容 hash。
|
|
557
569
|
* @param response 上传接口返回。
|
|
558
570
|
*/
|
|
559
|
-
const setCachedUpload = (scene, peer, type, contentHash, response) => {
|
|
571
|
+
const setCachedUpload = async (scene, peer, type, contentHash, response) => {
|
|
560
572
|
if (!response.file_info) return;
|
|
561
|
-
evictOldest();
|
|
562
|
-
const now = Date.now();
|
|
563
573
|
const ttl = Number(response.ttl);
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
createdAt: now
|
|
569
|
-
});
|
|
574
|
+
const expiresAt = ttl > 0 ? Date.now() + Math.max(ttl - TTL_SAFETY_MARGIN_SECONDS, MIN_EFFECTIVE_TTL_SECONDS) * 1e3 : 0;
|
|
575
|
+
try {
|
|
576
|
+
await getMessageStore().setUploadCache(cacheKey(scene, peer, type, contentHash), JSON.stringify(response), expiresAt);
|
|
577
|
+
} catch {}
|
|
570
578
|
};
|
|
571
579
|
|
|
572
580
|
//#endregion
|
|
@@ -633,7 +641,7 @@ const DEFAULT_PART_CONCURRENCY = 1;
|
|
|
633
641
|
/** 本地限制的最大分片并发,避免服务端返回过大值。 */
|
|
634
642
|
const MAX_PART_CONCURRENCY = 10;
|
|
635
643
|
/** HTTP(S) URL 可直接交给 QQ 平台拉取。 */
|
|
636
|
-
const HTTP_URL_RE$
|
|
644
|
+
const HTTP_URL_RE$2 = /^https?:\/\//i;
|
|
637
645
|
/** Karin 常见 base64 图片/媒体前缀。 */
|
|
638
646
|
const BASE64_PREFIX_RE = /^base64:\/\//i;
|
|
639
647
|
/** 标准 data URL 的 base64 前缀。 */
|
|
@@ -729,7 +737,7 @@ const downloadRemoteAsBase64 = async (url) => {
|
|
|
729
737
|
*/
|
|
730
738
|
const buildUploadSource = async (source) => {
|
|
731
739
|
const value = source.trim();
|
|
732
|
-
if (HTTP_URL_RE$
|
|
740
|
+
if (HTTP_URL_RE$2.test(value)) return { url: value };
|
|
733
741
|
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
|
|
734
742
|
if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
|
|
735
743
|
return { file_data: value };
|
|
@@ -745,7 +753,7 @@ const buildUploadSource = async (source) => {
|
|
|
745
753
|
const buildFallbackSource = async (type, source, fileName) => {
|
|
746
754
|
const value = source.trim();
|
|
747
755
|
const normalizedName = normalizeUploadFileName(type, fileName);
|
|
748
|
-
if (HTTP_URL_RE$
|
|
756
|
+
if (HTTP_URL_RE$2.test(value)) {
|
|
749
757
|
const buffer = await downloadRemoteAsBuffer(value);
|
|
750
758
|
return {
|
|
751
759
|
kind: "buffer",
|
|
@@ -983,12 +991,12 @@ var MediaApi = class extends Http {
|
|
|
983
991
|
assertUploadSize(type, fallbackSource.size);
|
|
984
992
|
const hashes = !srvSendMsg ? await computeUploadHashes(fallbackSource) : void 0;
|
|
985
993
|
if (hashes) {
|
|
986
|
-
const cached = getCachedUpload(scene, peer, type, hashes.md5);
|
|
994
|
+
const cached = await getCachedUpload(scene, peer, type, hashes.md5);
|
|
987
995
|
if (cached) return cached;
|
|
988
996
|
}
|
|
989
997
|
if (!srvSendMsg && fallbackSource.size >= LARGE_UPLOAD_THRESHOLD) {
|
|
990
998
|
const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
|
|
991
|
-
if (hashes) setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
999
|
+
if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
992
1000
|
return response;
|
|
993
1001
|
}
|
|
994
1002
|
const body = {
|
|
@@ -998,7 +1006,7 @@ var MediaApi = class extends Http {
|
|
|
998
1006
|
};
|
|
999
1007
|
if (type === "file") body.file_name = fallbackSource.fileName;
|
|
1000
1008
|
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);
|
|
1009
|
+
if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1002
1010
|
return response;
|
|
1003
1011
|
}
|
|
1004
1012
|
/**
|
|
@@ -1100,9 +1108,10 @@ var MediaApi = class extends Http {
|
|
|
1100
1108
|
const concurrency = Math.min(prepare.concurrency || DEFAULT_PART_CONCURRENCY, MAX_PART_CONCURRENCY, prepare.parts.length);
|
|
1101
1109
|
const handle = source.kind === "local" ? await open(source.file, "r") : void 0;
|
|
1102
1110
|
try {
|
|
1103
|
-
|
|
1111
|
+
const tasks = prepare.parts.map((part) => async () => {
|
|
1104
1112
|
const offset = (part.index - 1) * prepare.block_size;
|
|
1105
|
-
const
|
|
1113
|
+
const length = Math.min(prepare.block_size, source.size - offset);
|
|
1114
|
+
const data = await readPart(source, handle, offset, length);
|
|
1106
1115
|
const md5 = crypto.createHash("md5").update(data).digest("hex");
|
|
1107
1116
|
await putPart(part.presigned_url, data, part.index, prepare.parts.length);
|
|
1108
1117
|
await this.finishUploadPart(scene, peer, {
|
|
@@ -1111,7 +1120,8 @@ var MediaApi = class extends Http {
|
|
|
1111
1120
|
block_size: data.length,
|
|
1112
1121
|
md5
|
|
1113
1122
|
}, prepare.retry_timeout);
|
|
1114
|
-
})
|
|
1123
|
+
});
|
|
1124
|
+
await runWithConcurrency(tasks, concurrency);
|
|
1115
1125
|
} finally {
|
|
1116
1126
|
await handle?.close().catch(() => void 0);
|
|
1117
1127
|
}
|
|
@@ -1159,6 +1169,41 @@ var MetaApi = class extends Http {
|
|
|
1159
1169
|
}
|
|
1160
1170
|
};
|
|
1161
1171
|
|
|
1172
|
+
//#endregion
|
|
1173
|
+
//#region src/core/api/groups.ts
|
|
1174
|
+
/**
|
|
1175
|
+
* QQ 群信息接口
|
|
1176
|
+
*
|
|
1177
|
+
* 注意:以下接口均为白名单接口,未开通权限时平台返回错误码 11253,
|
|
1178
|
+
* 需要向平台运营申请后可用。
|
|
1179
|
+
*/
|
|
1180
|
+
var GroupsApi = class extends Http {
|
|
1181
|
+
/**
|
|
1182
|
+
* 获取群基础信息
|
|
1183
|
+
* @param groupOpenid 群 openid
|
|
1184
|
+
*/
|
|
1185
|
+
getGroupInfo(groupOpenid) {
|
|
1186
|
+
return this.get(`/v2/groups/${groupOpenid}/info`);
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* 获取机器人在群内的状态(角色、入群时间、主动消息开关等)
|
|
1190
|
+
* @param groupOpenid 群 openid
|
|
1191
|
+
*/
|
|
1192
|
+
getBotState(groupOpenid) {
|
|
1193
|
+
return this.get(`/v2/groups/${groupOpenid}/bot_state`);
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* 获取群成员详情
|
|
1197
|
+
*
|
|
1198
|
+
* 未公开文档,来自腾讯官方 openclaw 插件(v2.0.0)的接口速查。
|
|
1199
|
+
* @param groupOpenid 群 openid
|
|
1200
|
+
* @param memberOpenid 群成员 openid
|
|
1201
|
+
*/
|
|
1202
|
+
getGroupMember(groupOpenid, memberOpenid) {
|
|
1203
|
+
return this.get(`/v2/groups/${groupOpenid}/members/${memberOpenid}`);
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1162
1207
|
//#endregion
|
|
1163
1208
|
//#region src/core/api/builders.ts
|
|
1164
1209
|
/**
|
|
@@ -1240,6 +1285,7 @@ const buildGuildMsg = {
|
|
|
1240
1285
|
* - media:富媒体上传
|
|
1241
1286
|
* - interaction:按钮回调 ack
|
|
1242
1287
|
* - meta:@me / dms / gateway
|
|
1288
|
+
* - groups:群基础信息 / 机器人群内状态(白名单接口)
|
|
1243
1289
|
*
|
|
1244
1290
|
* 请求体构造器 {@link buildQQMsg} / {@link buildGuildMsg} 独立导出,避免与 HTTP 调用耦合
|
|
1245
1291
|
*/
|
|
@@ -1249,6 +1295,7 @@ var QQBotApi = class {
|
|
|
1249
1295
|
media;
|
|
1250
1296
|
interaction;
|
|
1251
1297
|
meta;
|
|
1298
|
+
groups;
|
|
1252
1299
|
/** 请求体构造器(静态便利) */
|
|
1253
1300
|
qq = buildQQMsg;
|
|
1254
1301
|
guild = buildGuildMsg;
|
|
@@ -1258,6 +1305,7 @@ var QQBotApi = class {
|
|
|
1258
1305
|
this.media = new MediaApi(axios);
|
|
1259
1306
|
this.interaction = new InteractionApi(axios);
|
|
1260
1307
|
this.meta = new MetaApi(axios);
|
|
1308
|
+
this.groups = new GroupsApi(axios);
|
|
1261
1309
|
}
|
|
1262
1310
|
};
|
|
1263
1311
|
|
|
@@ -1280,9 +1328,10 @@ const tokens = /* @__PURE__ */ new Map();
|
|
|
1280
1328
|
* - 同一 appId 旧定时器会被清理,避免并发刷新链
|
|
1281
1329
|
*/
|
|
1282
1330
|
const getAccessToken = async (url, appId, secret) => {
|
|
1331
|
+
const tokenUrl = normalizeHttpUrl(url);
|
|
1283
1332
|
let res;
|
|
1284
1333
|
try {
|
|
1285
|
-
res = await axios.post(
|
|
1334
|
+
res = await axios.post(tokenUrl, {
|
|
1286
1335
|
appId: String(appId),
|
|
1287
1336
|
clientSecret: secret
|
|
1288
1337
|
}, { headers: { "User-Agent": getUserAgent() } });
|
|
@@ -1304,7 +1353,7 @@ const getAccessToken = async (url, appId, secret) => {
|
|
|
1304
1353
|
tokens.set(appId, state);
|
|
1305
1354
|
const delay = Math.max(5e3, expiresIn * 1e3 - 5e4);
|
|
1306
1355
|
state.refreshTimer = setTimeout(() => {
|
|
1307
|
-
getAccessToken(
|
|
1356
|
+
getAccessToken(tokenUrl, appId, secret).catch(() => {});
|
|
1308
1357
|
}, delay);
|
|
1309
1358
|
return {
|
|
1310
1359
|
accessToken,
|
|
@@ -1331,7 +1380,7 @@ const stopTokenRefresh = (appId) => {
|
|
|
1331
1380
|
*/
|
|
1332
1381
|
const createAxiosInstance = (baseURL, appId) => {
|
|
1333
1382
|
const instance = axios.create({
|
|
1334
|
-
baseURL,
|
|
1383
|
+
baseURL: normalizeHttpUrl(baseURL),
|
|
1335
1384
|
headers: {
|
|
1336
1385
|
"Content-Type": "application/json",
|
|
1337
1386
|
"User-Agent": getUserAgent()
|
|
@@ -1521,7 +1570,7 @@ const groupElements = (scene, elements) => {
|
|
|
1521
1570
|
//#endregion
|
|
1522
1571
|
//#region src/core/adapter/media-source.ts
|
|
1523
1572
|
/** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
|
|
1524
|
-
const HTTP_URL_RE = /^https?:\/\//i;
|
|
1573
|
+
const HTTP_URL_RE$1 = /^https?:\/\//i;
|
|
1525
1574
|
/** fileToUrl 需要文件名来推断上传类型;没有业务文件名时使用这些默认值。 */
|
|
1526
1575
|
const DEFAULT_FILENAME = {
|
|
1527
1576
|
image: "image.jpg",
|
|
@@ -1539,7 +1588,7 @@ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
|
|
|
1539
1588
|
* @param source Karin 消息段里的 file 字段。
|
|
1540
1589
|
* @returns 是否为 HTTP(S) URL。
|
|
1541
1590
|
*/
|
|
1542
|
-
const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
|
|
1591
|
+
const isHttpMediaSource = (source) => HTTP_URL_RE$1.test(source);
|
|
1543
1592
|
/**
|
|
1544
1593
|
* 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
|
|
1545
1594
|
*
|
|
@@ -1575,160 +1624,10 @@ const resolvePreferredMediaSource = async (ctx, type, source, filename) => {
|
|
|
1575
1624
|
}
|
|
1576
1625
|
};
|
|
1577
1626
|
|
|
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
1627
|
//#endregion
|
|
1730
1628
|
//#region src/core/adapter/text-to-md.ts
|
|
1731
1629
|
const MAX_BUTTONS = 5;
|
|
1630
|
+
const MARKDOWN_IMAGE_RE = /!\[[^\]]*]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'])?\s*\)/g;
|
|
1732
1631
|
/**
|
|
1733
1632
|
* 提取文本中的 URL,将其替换为 `[按钮 N]` 占位文本并返回 button 列表
|
|
1734
1633
|
*
|
|
@@ -1769,6 +1668,40 @@ const imagesToMarkdown = async (urls) => {
|
|
|
1769
1668
|
return ``;
|
|
1770
1669
|
}));
|
|
1771
1670
|
};
|
|
1671
|
+
/**
|
|
1672
|
+
* 将 markdown 文本拆为普通文本片段与图片来源。
|
|
1673
|
+
*/
|
|
1674
|
+
const splitMarkdownImages = (markdown) => {
|
|
1675
|
+
const parts = [];
|
|
1676
|
+
let lastIndex = 0;
|
|
1677
|
+
for (const match of markdown.matchAll(MARKDOWN_IMAGE_RE)) {
|
|
1678
|
+
const index = match.index ?? 0;
|
|
1679
|
+
if (index > lastIndex) {
|
|
1680
|
+
const text = markdown.slice(lastIndex, index);
|
|
1681
|
+
if (text) parts.push({
|
|
1682
|
+
type: "text",
|
|
1683
|
+
value: text
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
const source = (match[1] || "").trim().replace(/^<|>$/g, "");
|
|
1687
|
+
if (source) parts.push({
|
|
1688
|
+
type: "image",
|
|
1689
|
+
source
|
|
1690
|
+
});
|
|
1691
|
+
lastIndex = index + match[0].length;
|
|
1692
|
+
}
|
|
1693
|
+
if (lastIndex < markdown.length) {
|
|
1694
|
+
const text = markdown.slice(lastIndex);
|
|
1695
|
+
if (text) parts.push({
|
|
1696
|
+
type: "text",
|
|
1697
|
+
value: text
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
return parts.length ? parts : [{
|
|
1701
|
+
type: "text",
|
|
1702
|
+
value: markdown
|
|
1703
|
+
}];
|
|
1704
|
+
};
|
|
1772
1705
|
|
|
1773
1706
|
//#endregion
|
|
1774
1707
|
//#region src/core/adapter/pipeline-qq.ts
|
|
@@ -1797,19 +1730,22 @@ const FRIEND_EVENT_WHITELIST = /* @__PURE__ */ new Set([
|
|
|
1797
1730
|
const sendQQ = async (ctx, contact, elements) => {
|
|
1798
1731
|
const target = contact.scene === "friend" ? "user" : "group";
|
|
1799
1732
|
const grouping = groupElements(contact.scene, elements);
|
|
1800
|
-
|
|
1733
|
+
await resolveOutgoingReferenceQQ(ctx, contact, grouping);
|
|
1734
|
+
if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
1801
1735
|
const { text, buttons } = extractUrlButtons(grouping.text.join(""), contact.scene === "friend");
|
|
1802
1736
|
grouping.text = [text];
|
|
1803
1737
|
grouping.buttons.push(...buttons);
|
|
1804
1738
|
}
|
|
1805
1739
|
/**
|
|
1806
|
-
*
|
|
1740
|
+
* 自动 Markdown 通道:
|
|
1807
1741
|
* text / at / 可转换为公网 URL 的图片通过 markdown content 渲染;无法转换的图片
|
|
1808
1742
|
* 与视频 / 语音 / 文件由 sendQQMarkdown 内部以 msg_type=7 单独补发。所有富媒体
|
|
1809
1743
|
* 都会优先通过 fileToUrl 取得公网地址,QQ 上传只做兜底。只有显式引用的
|
|
1810
1744
|
* 纯文本消息会降级为 msg_type=0,以保证 QQ 客户端能稳定显示引用气泡。
|
|
1745
|
+
* 显式 segment.markdown 不受该自动通道开关影响。
|
|
1811
1746
|
*/
|
|
1812
|
-
return sendQQMarkdown(ctx, contact, grouping, target);
|
|
1747
|
+
if (ctx.cfg.markdown.enable) return sendQQMarkdown(ctx, contact, grouping, target);
|
|
1748
|
+
return sendQQClassic(ctx, contact, grouping, target);
|
|
1813
1749
|
};
|
|
1814
1750
|
/**
|
|
1815
1751
|
* Markdown 通道:合成一条 msg_type=2 + 可选 keyboard
|
|
@@ -1819,12 +1755,13 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
|
|
|
1819
1755
|
const lines = [];
|
|
1820
1756
|
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
1821
1757
|
const fallbackImages = await appendMarkdownImages(ctx, lines, grouping.qqImages);
|
|
1822
|
-
|
|
1758
|
+
const markdownFallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
|
|
1759
|
+
fallbackImages.push(...markdownFallbackImages);
|
|
1823
1760
|
if (contact.scene === "group") {
|
|
1824
1761
|
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
1825
1762
|
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
1826
1763
|
}
|
|
1827
|
-
if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(
|
|
1764
|
+
if (lines.length || grouping.buttons.length || grouping.keyboards.length) if (shouldUseTextForReference(grouping)) items.push(ctx.super.qq.text(lines.join("\n")));
|
|
1828
1765
|
else {
|
|
1829
1766
|
const keyboard = buildKeyboard$1(grouping);
|
|
1830
1767
|
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
@@ -1843,6 +1780,47 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
|
|
|
1843
1780
|
return flushQQ(ctx, contact, grouping, items);
|
|
1844
1781
|
};
|
|
1845
1782
|
/**
|
|
1783
|
+
* 经典通道:文本走 msg_type=0,图片 / 富媒体走 msg_type=7。
|
|
1784
|
+
*/
|
|
1785
|
+
const sendQQClassic = async (ctx, contact, grouping, target) => {
|
|
1786
|
+
const items = [];
|
|
1787
|
+
const lines = [];
|
|
1788
|
+
const images = [...grouping.qqImages];
|
|
1789
|
+
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
1790
|
+
if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
|
|
1791
|
+
const content = lines.join("\n").trim();
|
|
1792
|
+
if (content) items.push(ctx.super.qq.text(content));
|
|
1793
|
+
for (const image of images) items.push(await buildQQMediaItem(ctx, target, contact.peer, "image", image));
|
|
1794
|
+
for (const m of grouping.media) items.push(await buildQQMediaItem(ctx, target, contact.peer, m.kind, m.source, m.name));
|
|
1795
|
+
await appendExplicitMarkdownItems(ctx, contact, grouping, target, items);
|
|
1796
|
+
if (!items.length) items.push(ctx.super.qq.text("不支持发送的消息类型"));
|
|
1797
|
+
return flushQQ(ctx, contact, grouping, items);
|
|
1798
|
+
};
|
|
1799
|
+
/**
|
|
1800
|
+
* 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
|
|
1801
|
+
*/
|
|
1802
|
+
const appendExplicitMarkdownItems = async (ctx, contact, grouping, target, items) => {
|
|
1803
|
+
if (!grouping.markdowns.length) return;
|
|
1804
|
+
const lines = [];
|
|
1805
|
+
const fallbackImages = await appendExplicitMarkdown(ctx, lines, grouping);
|
|
1806
|
+
if (contact.scene === "group") {
|
|
1807
|
+
warnUnsupportedCommandEnterButtons$1(ctx, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
1808
|
+
warnUnsupportedCommandEnterMarkdowns$1(ctx, grouping.markdowns.map((m) => m.markdown));
|
|
1809
|
+
}
|
|
1810
|
+
const keyboard = buildKeyboard$1(grouping);
|
|
1811
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN$1;
|
|
1812
|
+
items.push(ctx.super.qq.markdown({ content }, keyboard));
|
|
1813
|
+
for (const file of fallbackImages) {
|
|
1814
|
+
const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
|
|
1815
|
+
items.push(ctx.super.qq.media(res.file_info));
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
const buildQQMediaItem = async (ctx, target, peer, type, source, name) => {
|
|
1819
|
+
const resolved = await resolvePreferredMediaSource(ctx, type, source, name);
|
|
1820
|
+
const res = resolved.via === "fallback" ? await ctx.super.media.uploadFallback(target, peer, type, resolved.source, false, name) : await ctx.super.media.upload(target, peer, type, resolved.source, false, name);
|
|
1821
|
+
return ctx.super.qq.media(res.file_info);
|
|
1822
|
+
};
|
|
1823
|
+
/**
|
|
1846
1824
|
* 将能转成公网 URL 的图片追加到 markdown 行,无法转换的图片返回给富媒体 fallback。
|
|
1847
1825
|
*
|
|
1848
1826
|
* @param ctx 适配器实例,用于输出降级日志。
|
|
@@ -1863,6 +1841,26 @@ const appendMarkdownImages = async (ctx, lines, files) => {
|
|
|
1863
1841
|
return fallback;
|
|
1864
1842
|
};
|
|
1865
1843
|
/**
|
|
1844
|
+
* 处理显式 segment.markdown 中的图片:可转公网 URL 的继续嵌入,无法转的改走富媒体。
|
|
1845
|
+
*/
|
|
1846
|
+
const appendExplicitMarkdown = async (ctx, lines, grouping) => {
|
|
1847
|
+
const fallback = [];
|
|
1848
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) {
|
|
1849
|
+
if (part.type === "text") {
|
|
1850
|
+
const text = part.value.trim();
|
|
1851
|
+
if (text) lines.push(text);
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
const resolved = await resolvePreferredMediaSource(ctx, "image", part.source);
|
|
1855
|
+
if (resolved.via === "fallback") {
|
|
1856
|
+
fallback.push(part.source);
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
await appendImageUrlLine(lines, resolved.source);
|
|
1860
|
+
}
|
|
1861
|
+
return fallback;
|
|
1862
|
+
};
|
|
1863
|
+
/**
|
|
1866
1864
|
* 生成单张 markdown 图片行。
|
|
1867
1865
|
* 尺寸获取失败时使用保守默认尺寸,保证 fileToUrl 成功的图片仍走 markdown。
|
|
1868
1866
|
*
|
|
@@ -1886,8 +1884,8 @@ const appendImageUrlLine = async (lines, url) => {
|
|
|
1886
1884
|
* @param grouping 已归类的消息段。
|
|
1887
1885
|
* @returns 是否使用普通文本发送主消息。
|
|
1888
1886
|
*/
|
|
1889
|
-
const shouldUseTextForReference = (
|
|
1890
|
-
return isQQReferenceMessageId(
|
|
1887
|
+
const shouldUseTextForReference = (grouping) => {
|
|
1888
|
+
return isQQReferenceMessageId(grouping.reply.messageId) && !grouping.qqImages.length && !grouping.buttons.length && !grouping.keyboards.length && !grouping.markdowns.length;
|
|
1891
1889
|
};
|
|
1892
1890
|
/**
|
|
1893
1891
|
* 判断给定 ID 是否是 QQ 客户端引用使用的 REFIDX。
|
|
@@ -1900,6 +1898,23 @@ const shouldUseTextForReference = (ctx, contact, grouping) => {
|
|
|
1900
1898
|
*/
|
|
1901
1899
|
const isQQReferenceMessageId = (messageId) => messageId.startsWith("REFIDX_");
|
|
1902
1900
|
/**
|
|
1901
|
+
* 发送前把显式引用的 API 消息 ID 解析为 REFIDX。
|
|
1902
|
+
*
|
|
1903
|
+
* 映射只存在于 SQLite 消息缓存(ID 映射行不受缓存开关影响,始终落库)。
|
|
1904
|
+
* 解析结果直接写回 grouping,后续同步路径(降级判断、message_reference
|
|
1905
|
+
* 附加)无需再查库。
|
|
1906
|
+
*
|
|
1907
|
+
* @param ctx 适配器实例。
|
|
1908
|
+
* @param contact 消息目标会话。
|
|
1909
|
+
* @param grouping 已归类的消息段。
|
|
1910
|
+
*/
|
|
1911
|
+
const resolveOutgoingReferenceQQ = async (ctx, contact, grouping) => {
|
|
1912
|
+
const original = grouping.reply.messageId;
|
|
1913
|
+
if (!original || isQQReferenceMessageId(original)) return;
|
|
1914
|
+
const fromStore = await ctx.messageStore.resolveRefIdx(String(ctx.cfg.appId), contact, original).catch(() => null);
|
|
1915
|
+
if (fromStore) grouping.reply.messageId = fromStore;
|
|
1916
|
+
};
|
|
1917
|
+
/**
|
|
1903
1918
|
* 构造 keyboard 字段(buttons + keyboards 合并)
|
|
1904
1919
|
*/
|
|
1905
1920
|
const buildKeyboard$1 = (grouping) => {
|
|
@@ -1953,10 +1968,8 @@ const flushQQ = async (ctx, contact, grouping, items) => {
|
|
|
1953
1968
|
let referenceHandled = false;
|
|
1954
1969
|
for (const item of items.slice(0, maxItems)) {
|
|
1955
1970
|
passive(item);
|
|
1956
|
-
if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(
|
|
1971
|
+
if (!referenceHandled) referenceHandled = attachVisibleReferenceQQ(item, grouping);
|
|
1957
1972
|
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
1973
|
result.rawData.push(res);
|
|
1961
1974
|
}
|
|
1962
1975
|
return ctx.handleResponse(result);
|
|
@@ -2015,14 +2028,14 @@ const resolvePassiveQQ = (grouping) => {
|
|
|
2015
2028
|
* 附加 QQ 客户端可见的引用对象。
|
|
2016
2029
|
*
|
|
2017
2030
|
* 群聊/单聊里真正发给 QQ 的值必须是 `message_scene.ext` 的 `msg_idx=REFIDX_xxx`。
|
|
2018
|
-
*
|
|
2031
|
+
* API 消息 ID 到 REFIDX 的转换已由 `resolveOutgoingReferenceQQ` 在发送前完成。
|
|
2019
2032
|
*
|
|
2020
2033
|
* @param item 即将发送的 QQ 消息体。
|
|
2021
2034
|
* @param grouping 已归类的消息段。
|
|
2022
2035
|
* @returns 是否已经消费一次显式引用。
|
|
2023
2036
|
*/
|
|
2024
|
-
const attachVisibleReferenceQQ = (
|
|
2025
|
-
const messageId =
|
|
2037
|
+
const attachVisibleReferenceQQ = (item, grouping) => {
|
|
2038
|
+
const messageId = grouping.reply.messageId;
|
|
2026
2039
|
if (!isQQReferenceMessageId(messageId)) return false;
|
|
2027
2040
|
item.message_reference = { message_id: messageId };
|
|
2028
2041
|
return true;
|
|
@@ -2071,17 +2084,18 @@ const BUTTON_ONLY_MARKDOWN = "";
|
|
|
2071
2084
|
/**
|
|
2072
2085
|
* 处理频道场景(子频道 + 私信)的发送
|
|
2073
2086
|
*
|
|
2074
|
-
*
|
|
2075
|
-
*
|
|
2087
|
+
* 默认把普通文本 / at / 图片通过 markdown 渲染。关闭自动 Markdown 通道后,
|
|
2088
|
+
* 普通文本和图片改走经典发送;显式 segment.markdown 仍按调用方指定走 Markdown。
|
|
2076
2089
|
*/
|
|
2077
2090
|
const sendGuild = async (ctx, contact, elements) => {
|
|
2078
2091
|
const grouping = groupElements(contact.scene, elements);
|
|
2079
|
-
if (ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
2092
|
+
if (ctx.cfg.markdown.enable && ctx.cfg.keyboard.enable && grouping.text.length) {
|
|
2080
2093
|
const { text, buttons } = extractUrlButtons(grouping.text.join(""), false);
|
|
2081
2094
|
grouping.text = [text];
|
|
2082
2095
|
grouping.buttons.push(...buttons);
|
|
2083
2096
|
}
|
|
2084
|
-
return sendGuildMarkdown(ctx, contact, grouping);
|
|
2097
|
+
if (ctx.cfg.markdown.enable) return sendGuildMarkdown(ctx, contact, grouping);
|
|
2098
|
+
return sendGuildClassic(ctx, contact, grouping);
|
|
2085
2099
|
};
|
|
2086
2100
|
/**
|
|
2087
2101
|
* Markdown 通道
|
|
@@ -2099,7 +2113,13 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
|
|
|
2099
2113
|
const mdImages = await imagesToMarkdown(allImages);
|
|
2100
2114
|
lines.push(...mdImages);
|
|
2101
2115
|
}
|
|
2102
|
-
grouping.markdowns
|
|
2116
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
|
|
2117
|
+
const text = part.value.trim();
|
|
2118
|
+
if (text) lines.push(text);
|
|
2119
|
+
} else {
|
|
2120
|
+
const [mdImage] = await imagesToMarkdown([part.source]);
|
|
2121
|
+
lines.push(mdImage);
|
|
2122
|
+
}
|
|
2103
2123
|
warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2104
2124
|
warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
|
|
2105
2125
|
if (lines.length || grouping.buttons.length || grouping.keyboards.length) {
|
|
@@ -2110,6 +2130,55 @@ const sendGuildMarkdown = async (ctx, contact, grouping) => {
|
|
|
2110
2130
|
if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
|
|
2111
2131
|
return flushGuild(ctx, contact, grouping, items);
|
|
2112
2132
|
};
|
|
2133
|
+
/**
|
|
2134
|
+
* 经典通道:文本走 content,图片按文档走 image / file_image 单独发送。
|
|
2135
|
+
*/
|
|
2136
|
+
const sendGuildClassic = async (ctx, contact, grouping) => {
|
|
2137
|
+
const items = [];
|
|
2138
|
+
const lines = [];
|
|
2139
|
+
const images = [...grouping.guildImageUrls, ...grouping.guildImageFiles];
|
|
2140
|
+
if (grouping.text.length) lines.push(grouping.text.join(""));
|
|
2141
|
+
if (!grouping.markdowns.length && (grouping.buttons.length || grouping.keyboards.length)) ctx.logger("warn", "Markdown 通道已关闭,button / keyboard 无法随普通文本发送,已跳过");
|
|
2142
|
+
const content = lines.join("\n").trim();
|
|
2143
|
+
if (content) items.push(ctx.super.guild.text(content));
|
|
2144
|
+
for (const image of images) items.push(await buildGuildImageItem(image));
|
|
2145
|
+
await appendExplicitGuildMarkdownItems(ctx, contact, grouping, items);
|
|
2146
|
+
if (!items.length) items.push(ctx.super.guild.text("不支持发送的消息类型"));
|
|
2147
|
+
return flushGuild(ctx, contact, grouping, items);
|
|
2148
|
+
};
|
|
2149
|
+
/**
|
|
2150
|
+
* 显式 `segment.markdown` 是调用方指定的发送类型,不受 Markdown 自动通道开关影响。
|
|
2151
|
+
*/
|
|
2152
|
+
const appendExplicitGuildMarkdownItems = async (ctx, contact, grouping, items) => {
|
|
2153
|
+
if (!grouping.markdowns.length) return;
|
|
2154
|
+
const lines = [];
|
|
2155
|
+
for (const markdown of grouping.markdowns) for (const part of splitMarkdownImages(markdown.markdown)) if (part.type === "text") {
|
|
2156
|
+
const text = part.value.trim();
|
|
2157
|
+
if (text) lines.push(text);
|
|
2158
|
+
} else {
|
|
2159
|
+
const [mdImage] = await imagesToMarkdown([part.source]);
|
|
2160
|
+
lines.push(mdImage);
|
|
2161
|
+
}
|
|
2162
|
+
warnUnsupportedCommandEnterButtons(ctx, contact.scene, collectCommandEnterButtons(grouping.buttons, grouping.keyboards));
|
|
2163
|
+
warnUnsupportedCommandEnterMarkdowns(ctx, contact.scene, grouping.markdowns.map((m) => m.markdown));
|
|
2164
|
+
const keyboard = buildKeyboard(grouping);
|
|
2165
|
+
const content = lines.length ? lines.join("\n") : BUTTON_ONLY_MARKDOWN;
|
|
2166
|
+
items.push(ctx.super.guild.markdown({ content }, keyboard));
|
|
2167
|
+
};
|
|
2168
|
+
const buildGuildImageItem = async (source) => {
|
|
2169
|
+
if (/^https?:\/\//i.test(source)) return {
|
|
2170
|
+
type: "image",
|
|
2171
|
+
image: source
|
|
2172
|
+
};
|
|
2173
|
+
const form = new FormData();
|
|
2174
|
+
form.append("file_image", await common.buffer(source, { http: false }), { filename: resolveImageFilename(source) });
|
|
2175
|
+
return form;
|
|
2176
|
+
};
|
|
2177
|
+
const resolveImageFilename = (source) => {
|
|
2178
|
+
const file = source.split(/[?#]/)[0] || "";
|
|
2179
|
+
const name = path.basename(file);
|
|
2180
|
+
return name.includes(".") ? name : "image.jpg";
|
|
2181
|
+
};
|
|
2113
2182
|
const buildKeyboard = (grouping) => {
|
|
2114
2183
|
const rows = [];
|
|
2115
2184
|
grouping.buttons.forEach((b) => rows.push(...karinToQQBot(b)));
|
|
@@ -2221,31 +2290,38 @@ const prepareSelfMessageCache = async (ctx, elements) => {
|
|
|
2221
2290
|
};
|
|
2222
2291
|
};
|
|
2223
2292
|
/**
|
|
2224
|
-
*
|
|
2293
|
+
* 发送成功后记录机器人自己的消息。
|
|
2294
|
+
*
|
|
2295
|
+
* 无论缓存开关如何,都会落库最小 ID 映射行(msg_id/ref_idx/is_self)——
|
|
2296
|
+
* 单聊撤回判定和引用解析没有任何内存映射,完全依赖数据库。
|
|
2297
|
+
* 只有 self 缓存开启时才附带消息正文,供 bot.getMsg(messageId) 查询。
|
|
2225
2298
|
*/
|
|
2226
2299
|
const cacheSelfMessage = (ctx, contact, cacheElements, result) => {
|
|
2227
|
-
if (!shouldCacheSelfMessage(ctx)) return;
|
|
2228
|
-
if (!cacheElements.length) return;
|
|
2229
2300
|
const responses = (Array.isArray(result.rawData) ? result.rawData : [result.rawData]).filter((item) => item?.id);
|
|
2230
2301
|
if (!responses.length) return;
|
|
2302
|
+
const withContent = shouldCacheSelfMessage(ctx) && cacheElements.length > 0;
|
|
2303
|
+
const elements = withContent ? cacheElements : [];
|
|
2304
|
+
const level = withContent ? ctx.cfg.messageCache.level : "minimal";
|
|
2231
2305
|
const sender = selfSender(ctx, contact);
|
|
2232
2306
|
const seen = /* @__PURE__ */ new Set();
|
|
2233
2307
|
for (const response of responses) {
|
|
2234
2308
|
const apiMessageId = String(response.id);
|
|
2235
2309
|
const referenceMessageId = typeof response.ext_info?.ref_idx === "string" ? response.ext_info.ref_idx : "";
|
|
2236
2310
|
const messageId = apiMessageId;
|
|
2237
|
-
const aliases = [referenceMessageId].filter((id) => id && id !== messageId);
|
|
2238
2311
|
if (!messageId || seen.has(messageId)) continue;
|
|
2239
2312
|
seen.add(messageId);
|
|
2240
|
-
if (referenceMessageId) rememberApiMessageId(ctx, contact, referenceMessageId, apiMessageId);
|
|
2241
2313
|
ctx.messageStore.save(String(ctx.cfg.appId), {
|
|
2242
2314
|
messageId,
|
|
2243
2315
|
messageSeq: 0,
|
|
2244
2316
|
time: responseTime(response.timestamp),
|
|
2245
2317
|
contact,
|
|
2246
2318
|
sender,
|
|
2247
|
-
elements
|
|
2248
|
-
},
|
|
2319
|
+
elements
|
|
2320
|
+
}, {
|
|
2321
|
+
refIdx: referenceMessageId && referenceMessageId !== messageId ? referenceMessageId : void 0,
|
|
2322
|
+
isSelf: true,
|
|
2323
|
+
level
|
|
2324
|
+
}).catch((err) => ctx.logger("warn", `[getMsg] 写入自己消息缓存失败: ${messageId}`, err));
|
|
2249
2325
|
}
|
|
2250
2326
|
};
|
|
2251
2327
|
/**
|
|
@@ -2367,6 +2443,8 @@ const responseTime = (timestamp) => {
|
|
|
2367
2443
|
//#endregion
|
|
2368
2444
|
//#region src/core/adapter/base.ts
|
|
2369
2445
|
const adapterConfigStore = /* @__PURE__ */ new WeakMap();
|
|
2446
|
+
/** HTTP(S) URL 可直接交给 QQ 平台拉取上传 */
|
|
2447
|
+
const HTTP_URL_RE = /^https?:\/\//i;
|
|
2370
2448
|
/**
|
|
2371
2449
|
* QQ Official Bot 适配器
|
|
2372
2450
|
*
|
|
@@ -2411,18 +2489,17 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2411
2489
|
* 发送消息
|
|
2412
2490
|
*/
|
|
2413
2491
|
async sendMsg(contact, elements, _retryCount) {
|
|
2492
|
+
/** 发送成功后始终记录消息 ID 映射;正文是否入库由 cacheSelfMessage 内部按开关决定。 */
|
|
2414
2493
|
if (contact.scene === "direct" || contact.scene === "guild") {
|
|
2415
|
-
|
|
2416
|
-
const
|
|
2417
|
-
|
|
2418
|
-
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2494
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
|
|
2495
|
+
const result = await sendGuild(this, contact, prepared?.sendElements ?? elements);
|
|
2496
|
+
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
2419
2497
|
return result;
|
|
2420
2498
|
}
|
|
2421
2499
|
if (contact.scene === "group" || contact.scene === "friend") {
|
|
2422
|
-
|
|
2423
|
-
const
|
|
2424
|
-
|
|
2425
|
-
cacheSelfMessage(this, contact, prepared.cacheElements, result);
|
|
2500
|
+
const prepared = shouldCacheSelfMessage(this) ? await prepareSelfMessageCache(this, elements) : void 0;
|
|
2501
|
+
const result = await sendQQ(this, contact, prepared?.sendElements ?? elements);
|
|
2502
|
+
cacheSelfMessage(this, contact, prepared?.cacheElements ?? [], result);
|
|
2426
2503
|
return result;
|
|
2427
2504
|
}
|
|
2428
2505
|
throw new Error(`不支持的消息场景: ${contact.scene}`);
|
|
@@ -2431,12 +2508,18 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2431
2508
|
* 撤回消息
|
|
2432
2509
|
*
|
|
2433
2510
|
* 群聊中机器人被群主设为管理员后,也可撤回成员消息;平台仍限制消息发送后两分钟内。
|
|
2511
|
+
* REFIDX 与 API 消息 ID 的映射、单聊"是否自己发送"的判定都直接查询
|
|
2512
|
+
* SQLite 消息缓存(ID 映射行不受缓存开关影响,始终落库)。
|
|
2434
2513
|
*/
|
|
2435
2514
|
async recallMsg(contact, messageId) {
|
|
2436
|
-
|
|
2515
|
+
let apiMessageId = messageId;
|
|
2516
|
+
if (apiMessageId.startsWith("REFIDX_")) {
|
|
2517
|
+
const resolved = await this.messageStore.resolveApiMessageId(String(this.cfg.appId), contact, apiMessageId).catch(() => null);
|
|
2518
|
+
if (resolved) apiMessageId = resolved;
|
|
2519
|
+
}
|
|
2437
2520
|
try {
|
|
2438
2521
|
if (contact.scene === "friend") {
|
|
2439
|
-
if (!
|
|
2522
|
+
if (!await this.messageStore.isSelfMessage(String(this.cfg.appId), contact, apiMessageId).catch(() => false)) {
|
|
2440
2523
|
this.logger("warn", `[recallMsg] QQ 单聊只能撤回机器人自己发送的消息,已跳过撤回: ${messageId}`);
|
|
2441
2524
|
return;
|
|
2442
2525
|
}
|
|
@@ -2731,10 +2814,12 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2731
2814
|
}
|
|
2732
2815
|
/**
|
|
2733
2816
|
* 获取群信息
|
|
2817
|
+
*
|
|
2818
|
+
* 走官方 `GET /v2/groups/{group_openid}/info`(白名单接口)。
|
|
2819
|
+
* 未开通权限(11253)或查询失败时降级为空结构并告警。
|
|
2734
2820
|
*/
|
|
2735
2821
|
async getGroupInfo(_groupId, _noCache) {
|
|
2736
|
-
|
|
2737
|
-
return {
|
|
2822
|
+
const empty = {
|
|
2738
2823
|
groupId: _groupId,
|
|
2739
2824
|
groupName: "",
|
|
2740
2825
|
owner: "",
|
|
@@ -2745,6 +2830,19 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2745
2830
|
groupDesc: "",
|
|
2746
2831
|
avatar: ""
|
|
2747
2832
|
};
|
|
2833
|
+
try {
|
|
2834
|
+
const info = await this.super.groups.getGroupInfo(_groupId);
|
|
2835
|
+
return {
|
|
2836
|
+
...empty,
|
|
2837
|
+
groupName: info.group_name || "",
|
|
2838
|
+
memberCount: info.group_member_num ?? 0,
|
|
2839
|
+
groupDesc: info.group_finger_memo || "",
|
|
2840
|
+
avatar: await this.getGroupAvatarUrl(_groupId)
|
|
2841
|
+
};
|
|
2842
|
+
} catch (err) {
|
|
2843
|
+
this.logger("warn", `[getGroupInfo] 获取群信息失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
|
|
2844
|
+
return empty;
|
|
2845
|
+
}
|
|
2748
2846
|
}
|
|
2749
2847
|
/**
|
|
2750
2848
|
* 获取群列表
|
|
@@ -2755,13 +2853,43 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2755
2853
|
}
|
|
2756
2854
|
/**
|
|
2757
2855
|
* 获取群成员信息
|
|
2856
|
+
*
|
|
2857
|
+
* - 查询机器人自身(targetId 为 selfId)走官方 `GET /v2/groups/{group_openid}/bot_state`(白名单)。
|
|
2858
|
+
* - 查询其他成员走 `GET /v2/groups/{group_openid}/members/{member_openid}`,
|
|
2859
|
+
* 该接口未公开文档,来自腾讯官方 openclaw 插件 v2.0.0,返回结构以平台实际为准。
|
|
2860
|
+
* 接口不可用(如白名单未开通返回 11253)时降级为占位结构并告警。
|
|
2758
2861
|
*/
|
|
2759
2862
|
async getGroupMemberInfo(_groupId, _targetId, _refresh) {
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
userId
|
|
2763
|
-
role
|
|
2764
|
-
|
|
2863
|
+
/** 按 karin 接口约定构造返回值,sender 由 getter 即时构建 */
|
|
2864
|
+
const build = (userId, role, nick, joinTime) => ({
|
|
2865
|
+
userId,
|
|
2866
|
+
role,
|
|
2867
|
+
nick,
|
|
2868
|
+
joinTime,
|
|
2869
|
+
get sender() {
|
|
2870
|
+
return senderGroup({
|
|
2871
|
+
userId,
|
|
2872
|
+
nick: nick || "",
|
|
2873
|
+
name: nick || "",
|
|
2874
|
+
role
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
});
|
|
2878
|
+
if (_targetId === this.selfId) try {
|
|
2879
|
+
const state = await this.super.groups.getBotState(_groupId);
|
|
2880
|
+
return build(state.member_openid || _targetId, state.member_role || "member", void 0, state.joined_at ? new Date(state.joined_at).getTime() : void 0);
|
|
2881
|
+
} catch (err) {
|
|
2882
|
+
this.logger("warn", `[getGroupMemberInfo] 获取机器人群内状态失败(该接口为白名单接口,未开通权限会返回 11253): ${err instanceof Error ? err.message : err}`);
|
|
2883
|
+
return build(_targetId, "member");
|
|
2884
|
+
}
|
|
2885
|
+
try {
|
|
2886
|
+
const member = await this.super.groups.getGroupMember(_groupId, _targetId);
|
|
2887
|
+
if (member.nick) this.nicknameCache.set(_targetId, member.nick);
|
|
2888
|
+
return build(member.member_openid || _targetId, member.member_role || "member", member.nick, member.joined_at ? new Date(member.joined_at).getTime() : void 0);
|
|
2889
|
+
} catch (err) {
|
|
2890
|
+
this.logger("warn", `[getGroupMemberInfo] 获取群成员详情失败(该接口未公开文档,可能不可用): ${err instanceof Error ? err.message : err}`);
|
|
2891
|
+
return build(_targetId, "member");
|
|
2892
|
+
}
|
|
2765
2893
|
}
|
|
2766
2894
|
/**
|
|
2767
2895
|
* 获取群成员列表
|
|
@@ -2803,9 +2931,19 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2803
2931
|
}
|
|
2804
2932
|
/**
|
|
2805
2933
|
* 上传群文件、私聊文件
|
|
2934
|
+
*
|
|
2935
|
+
* QQ 官方 Bot API 没有群文件系统,上传走 `POST /v2/{users|groups}/{openid}/files`
|
|
2936
|
+
* (file_type=4 + srv_send_msg=true),由服务端直接发送文件消息。
|
|
2937
|
+
* 频道 / 频道私信场景官方无文件接口,暂不支持。
|
|
2806
2938
|
*/
|
|
2807
2939
|
async uploadFile(_contact, _file, _name, _folder) {
|
|
2808
|
-
|
|
2940
|
+
if (_contact.scene !== "friend" && _contact.scene !== "group") {
|
|
2941
|
+
this.logger("warn", "[uploadFile] 频道场景 QQ Official Bot API 暂不支持上传文件");
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2944
|
+
const scene = _contact.scene === "friend" ? "user" : "group";
|
|
2945
|
+
if (HTTP_URL_RE.test(_file)) await this.super.media.upload(scene, _contact.peer, "file", _file, true, _name);
|
|
2946
|
+
else await this.super.media.uploadFallback(scene, _contact.peer, "file", _file, true, _name);
|
|
2809
2947
|
}
|
|
2810
2948
|
/**
|
|
2811
2949
|
* 下载文件到协议端本地
|
|
@@ -2840,10 +2978,18 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
2840
2978
|
}
|
|
2841
2979
|
/**
|
|
2842
2980
|
* 上传群文件
|
|
2981
|
+
*
|
|
2982
|
+
* 实现同 {@link uploadFile}:官方无群文件系统,走 files 接口由服务端直发文件消息。
|
|
2843
2983
|
*/
|
|
2844
2984
|
async uploadGroupFile(_groupId, _file, _name) {
|
|
2845
|
-
|
|
2846
|
-
|
|
2985
|
+
try {
|
|
2986
|
+
if (HTTP_URL_RE.test(_file)) await this.super.media.upload("group", _groupId, "file", _file, true, _name);
|
|
2987
|
+
else await this.super.media.uploadFallback("group", _groupId, "file", _file, true, _name);
|
|
2988
|
+
return true;
|
|
2989
|
+
} catch (err) {
|
|
2990
|
+
this.logger("warn", `[uploadGroupFile] 上传群文件失败: ${err instanceof Error ? err.message : err}`);
|
|
2991
|
+
return false;
|
|
2992
|
+
}
|
|
2847
2993
|
}
|
|
2848
2994
|
/**
|
|
2849
2995
|
* 获取文件 url
|
|
@@ -3207,7 +3353,7 @@ const convertToKarin = (appid, ev, selfSubId, options = {}) => {
|
|
|
3207
3353
|
* QQ 不会提供引用消息的正式消息 ID;调用方应以 `msg_idx` 作为缓存键。
|
|
3208
3354
|
*/
|
|
3209
3355
|
const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
|
|
3210
|
-
|
|
3356
|
+
const referenceEvent = {
|
|
3211
3357
|
...ev,
|
|
3212
3358
|
d: {
|
|
3213
3359
|
...ev.d,
|
|
@@ -3218,26 +3364,33 @@ const convertReferenceToKarin = (appid, ev, selfSubId, reference) => {
|
|
|
3218
3364
|
msg_elements: void 0,
|
|
3219
3365
|
message_type: reference.message_type
|
|
3220
3366
|
}
|
|
3221
|
-
}
|
|
3367
|
+
};
|
|
3368
|
+
return convertToKarin(appid, referenceEvent, selfSubId);
|
|
3222
3369
|
};
|
|
3223
3370
|
|
|
3224
3371
|
//#endregion
|
|
3225
3372
|
//#region src/core/event/message.ts
|
|
3226
3373
|
/**
|
|
3227
|
-
*
|
|
3374
|
+
* 把消息投递 SQLite 后台写入队列。
|
|
3228
3375
|
*
|
|
3229
3376
|
* 不等待数据库落库,避免缓存特性拖慢 Karin 消息事件下发。
|
|
3230
3377
|
*
|
|
3378
|
+
* 关闭消息缓存时仍按 minimal 级落库最小 ID 映射行(msg_id/ref_idx)——
|
|
3379
|
+
* 撤回和引用解析没有任何内存映射,完全依赖数据库。
|
|
3380
|
+
*
|
|
3231
3381
|
* @param client 当前 QQBot 适配器实例。
|
|
3232
3382
|
* @param message 需要缓存的消息快照。
|
|
3233
|
-
* @param
|
|
3383
|
+
* @param refIdx QQ `msg_idx` 引用索引,作为该消息的第二查询索引。
|
|
3234
3384
|
*/
|
|
3235
|
-
const cacheMessage = (client, message,
|
|
3236
|
-
|
|
3385
|
+
const cacheMessage = (client, message, refIdx) => {
|
|
3386
|
+
const { enable, level } = client.cfg.messageCache;
|
|
3237
3387
|
client.messageStore.save(String(client.cfg.appId), {
|
|
3238
3388
|
...message,
|
|
3239
3389
|
messageSeq: 0
|
|
3240
|
-
},
|
|
3390
|
+
}, {
|
|
3391
|
+
refIdx,
|
|
3392
|
+
level: enable ? level : "minimal"
|
|
3393
|
+
}).catch((err) => log("warn", `[getMsg] 写入消息缓存失败: ${message.messageId}`, err));
|
|
3241
3394
|
};
|
|
3242
3395
|
/**
|
|
3243
3396
|
* 写入引用消息上下文缓存。
|
|
@@ -3252,7 +3405,26 @@ const cacheReference = (client, message) => {
|
|
|
3252
3405
|
client.messageStore.saveReferenceIfAbsent(String(client.cfg.appId), {
|
|
3253
3406
|
...message,
|
|
3254
3407
|
messageSeq: 0
|
|
3255
|
-
}).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
|
|
3408
|
+
}, client.cfg.messageCache.level).catch((err) => log("warn", `[getMsg] 写入引用上下文失败: ${message.messageId}`, err));
|
|
3409
|
+
};
|
|
3410
|
+
/**
|
|
3411
|
+
* 将 Karin 快速回复自动生成的当前消息引用 ID 转成 QQ 客户端可见引用 ID。
|
|
3412
|
+
*
|
|
3413
|
+
* Karin 核心只能根据 `e.message_id` 生成 `segment.reply(e.message_id)`;而 QQ 群/单聊
|
|
3414
|
+
* 的可见引用需要 `message_scene.ext` 里的 `msg_idx=REFIDX_xxx`。这里仅转换
|
|
3415
|
+
* `reply.messageId === ev.d.id` 的那一个自动引用段,保留 `ev.d.id` 作为 Karin 标准
|
|
3416
|
+
* messageId,避免影响 getMsg / recallMsg / 被动回复 msg_id 等主链路。
|
|
3417
|
+
*/
|
|
3418
|
+
const normalizeQuickReplyReference = (elements, sourceMessageId, referenceMessageId) => {
|
|
3419
|
+
if (!sourceMessageId || !referenceMessageId) return elements;
|
|
3420
|
+
let normalized = false;
|
|
3421
|
+
return elements.map((element) => {
|
|
3422
|
+
if (!normalized && element.type === "reply" && element.messageId === sourceMessageId) {
|
|
3423
|
+
normalized = true;
|
|
3424
|
+
return segment.reply(referenceMessageId);
|
|
3425
|
+
}
|
|
3426
|
+
return element;
|
|
3427
|
+
});
|
|
3256
3428
|
};
|
|
3257
3429
|
/**
|
|
3258
3430
|
* 群消息(@机器人 / 普通消息合并入口)
|
|
@@ -3271,7 +3443,6 @@ const onGroupMsg = (client, ev, opts) => {
|
|
|
3271
3443
|
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3272
3444
|
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3273
3445
|
const karinMessageId = ev.d.id;
|
|
3274
|
-
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3275
3446
|
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3276
3447
|
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3277
3448
|
cacheMessage(client, {
|
|
@@ -3280,7 +3451,7 @@ const onGroupMsg = (client, ev, opts) => {
|
|
|
3280
3451
|
contact,
|
|
3281
3452
|
sender,
|
|
3282
3453
|
elements
|
|
3283
|
-
},
|
|
3454
|
+
}, messageIndex && messageIndex !== referenceIndex ? messageIndex : void 0);
|
|
3284
3455
|
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3285
3456
|
if (reference && referenceIndex) cacheReference(client, {
|
|
3286
3457
|
messageId: referenceIndex,
|
|
@@ -3299,7 +3470,7 @@ const onGroupMsg = (client, ev, opts) => {
|
|
|
3299
3470
|
time,
|
|
3300
3471
|
contact,
|
|
3301
3472
|
sender,
|
|
3302
|
-
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
3473
|
+
srcReply: (elements) => client.srcReply(e, [...normalizeQuickReplyReference(elements, karinMessageId, messageIndex), segment.pasmsg(ev.d.id)])
|
|
3303
3474
|
});
|
|
3304
3475
|
return e;
|
|
3305
3476
|
};
|
|
@@ -3317,7 +3488,6 @@ const onFriendMsg = (client, ev) => {
|
|
|
3317
3488
|
const messageIndex = getMessageSceneIndex(ev.d.message_scene, "msg_idx");
|
|
3318
3489
|
const referenceIndex = getMessageSceneIndex(ev.d.message_scene, "ref_msg_idx");
|
|
3319
3490
|
const karinMessageId = ev.d.id;
|
|
3320
|
-
if (messageIndex) rememberApiMessageId(client, contact, messageIndex, ev.d.id);
|
|
3321
3491
|
if (referenceIndex) elements.unshift(segment.reply(referenceIndex));
|
|
3322
3492
|
const time = Number(new Date(ev.d.timestamp).getTime() || ev.d.timestamp);
|
|
3323
3493
|
cacheMessage(client, {
|
|
@@ -3326,7 +3496,7 @@ const onFriendMsg = (client, ev) => {
|
|
|
3326
3496
|
contact,
|
|
3327
3497
|
sender,
|
|
3328
3498
|
elements
|
|
3329
|
-
},
|
|
3499
|
+
}, messageIndex && messageIndex !== referenceIndex ? messageIndex : void 0);
|
|
3330
3500
|
const reference = ev.d.msg_elements?.find((item) => item.msg_idx === referenceIndex);
|
|
3331
3501
|
if (reference && referenceIndex) cacheReference(client, {
|
|
3332
3502
|
messageId: referenceIndex,
|
|
@@ -3345,7 +3515,7 @@ const onFriendMsg = (client, ev) => {
|
|
|
3345
3515
|
time,
|
|
3346
3516
|
contact,
|
|
3347
3517
|
sender,
|
|
3348
|
-
srcReply: (elements) => client.srcReply(e, [...elements, segment.pasmsg(ev.d.id)])
|
|
3518
|
+
srcReply: (elements) => client.srcReply(e, [...normalizeQuickReplyReference(elements, karinMessageId, messageIndex), segment.pasmsg(ev.d.id)])
|
|
3349
3519
|
});
|
|
3350
3520
|
return e;
|
|
3351
3521
|
};
|
|
@@ -4249,25 +4419,34 @@ const QUICK_DISCONNECT_THRESHOLD_MS = 5e3;
|
|
|
4249
4419
|
const QUICK_DISCONNECT_LIMIT = 3;
|
|
4250
4420
|
const QUICK_DISCONNECT_DELAY_MS = 6e4;
|
|
4251
4421
|
/**
|
|
4252
|
-
* 从 prodApi / sandboxApi 推导兜底的
|
|
4422
|
+
* 从 proxy.prodApi / proxy.sandboxApi 推导兜底的 WS 地址
|
|
4253
4423
|
* 官方实际长期返回的也是 wss://{host}/websocket/,不依赖 /gateway 也能直连
|
|
4254
4424
|
*/
|
|
4255
|
-
const
|
|
4256
|
-
return
|
|
4425
|
+
const buildFallbackWsUrl = (cfg) => {
|
|
4426
|
+
return buildFallbackWsUrlFromApi(cfg.sandbox ? cfg.proxy.sandboxApi : cfg.proxy.prodApi);
|
|
4257
4427
|
};
|
|
4258
4428
|
/**
|
|
4259
4429
|
* 获取网关地址:优先调 /gateway;失败 / 超时 / 限频 → 直接 fallback 到硬编码
|
|
4260
4430
|
*/
|
|
4261
4431
|
const fetchGateway = async (cfg) => {
|
|
4262
|
-
const apiUrl = cfg.sandbox ? cfg.sandboxApi : cfg.prodApi;
|
|
4263
|
-
const fallback =
|
|
4432
|
+
const apiUrl = normalizeHttpUrl(cfg.sandbox ? cfg.proxy.sandboxApi : cfg.proxy.prodApi);
|
|
4433
|
+
const fallback = buildFallbackWsUrl(cfg);
|
|
4434
|
+
const proxyWsValue = cfg.sandbox ? cfg.proxy.sandboxWs : cfg.proxy.prodWs;
|
|
4435
|
+
if (proxyWsValue.trim()) try {
|
|
4436
|
+
const proxyWsUrl = normalizeWsGatewayUrl(proxyWsValue);
|
|
4437
|
+
log("debug", `${cfg.appId}: 使用自定义 WS ${proxyWsUrl}`);
|
|
4438
|
+
return proxyWsUrl;
|
|
4439
|
+
} catch (err) {
|
|
4440
|
+
const reason = err?.message || "unknown";
|
|
4441
|
+
log("warn", `${cfg.appId}: 自定义 WS 地址无效 (${reason}),尝试获取 /gateway`);
|
|
4442
|
+
}
|
|
4264
4443
|
const accessToken = getBotAccessToken(cfg.appId);
|
|
4265
4444
|
if (!accessToken) {
|
|
4266
|
-
log("warn", `${cfg.appId}: 无 access_token,使用硬编码
|
|
4445
|
+
log("warn", `${cfg.appId}: 无 access_token,使用硬编码 WS ${fallback}`);
|
|
4267
4446
|
return fallback;
|
|
4268
4447
|
}
|
|
4269
4448
|
try {
|
|
4270
|
-
const { data } = await axios.get(
|
|
4449
|
+
const { data } = await axios.get(joinHttpPath(apiUrl, "gateway"), {
|
|
4271
4450
|
headers: {
|
|
4272
4451
|
Authorization: `QQBot ${accessToken}`,
|
|
4273
4452
|
"User-Agent": getUserAgent()
|
|
@@ -4275,18 +4454,18 @@ const fetchGateway = async (cfg) => {
|
|
|
4275
4454
|
timeout: GATEWAY_FETCH_TIMEOUT_MS
|
|
4276
4455
|
});
|
|
4277
4456
|
if (data?.url) return data.url;
|
|
4278
|
-
log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码
|
|
4457
|
+
log("warn", `${cfg.appId}: /gateway 返回为空,使用硬编码 WS ${fallback}`);
|
|
4279
4458
|
return fallback;
|
|
4280
4459
|
} catch (err) {
|
|
4281
4460
|
if (axios.isAxiosError(err)) {
|
|
4282
4461
|
const response = err.response;
|
|
4283
4462
|
const status = response?.status ?? 0;
|
|
4284
4463
|
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},使用硬编码
|
|
4464
|
+
const detail = formatOpenAPIError(status, typeof data?.err_code === "number" ? data.err_code : typeof data?.code === "number" ? data.code : void 0, typeof data?.message === "string" ? data.message : void 0);
|
|
4465
|
+
log("warn", `${cfg.appId}: /gateway 调用失败 | ${detail},使用硬编码 WS ${fallback}`);
|
|
4287
4466
|
} else {
|
|
4288
4467
|
const reason = err?.message || "unknown";
|
|
4289
|
-
log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码
|
|
4468
|
+
log("warn", `${cfg.appId}: /gateway 调用失败 (${reason}),使用硬编码 WS ${fallback}`);
|
|
4290
4469
|
}
|
|
4291
4470
|
return fallback;
|
|
4292
4471
|
}
|
|
@@ -4418,7 +4597,7 @@ const onClose = (cfg, intents, attempt, event, client) => {
|
|
|
4418
4597
|
if (code === 4004) {
|
|
4419
4598
|
log("warn", `${cfg.appId}: access_token 已失效,刷新 token 后重连...`);
|
|
4420
4599
|
schedule(cfg.appId, 1e3, () => {
|
|
4421
|
-
getAccessToken(cfg.tokenApi, cfg.appId, cfg.secret).then(() => connect(cfg, intents, 0)).catch(() => {
|
|
4600
|
+
getAccessToken(cfg.proxy.tokenApi, cfg.appId, cfg.secret).then(() => connect(cfg, intents, 0)).catch(() => {
|
|
4422
4601
|
log("error", `${cfg.appId}: access_token 刷新失败,${RATE_LIMIT_DELAY_MS}ms 后重试连接...`);
|
|
4423
4602
|
schedule(cfg.appId, RATE_LIMIT_DELAY_MS, () => connect(cfg, intents, attempt + 1));
|
|
4424
4603
|
});
|
|
@@ -4499,18 +4678,21 @@ const initState = /* @__PURE__ */ new Map();
|
|
|
4499
4678
|
const hashConfig = (bot) => JSON.stringify({
|
|
4500
4679
|
appId: bot.appId,
|
|
4501
4680
|
secret: bot.secret,
|
|
4502
|
-
|
|
4503
|
-
prodApi: bot.prodApi,
|
|
4504
|
-
sandboxApi: bot.sandboxApi,
|
|
4681
|
+
proxy: bot.proxy,
|
|
4505
4682
|
sandbox: bot.sandbox,
|
|
4506
4683
|
event: bot.event,
|
|
4507
4684
|
keyboard: bot.keyboard,
|
|
4685
|
+
markdown: bot.markdown,
|
|
4508
4686
|
messageCache: bot.messageCache
|
|
4509
4687
|
});
|
|
4510
4688
|
/**
|
|
4511
4689
|
* 创建单个 Bot 实例
|
|
4512
4690
|
*/
|
|
4513
|
-
const createBot = async (
|
|
4691
|
+
const createBot = async (input) => {
|
|
4692
|
+
const bot = {
|
|
4693
|
+
...input,
|
|
4694
|
+
proxy: normalizeProxyConfig(input.proxy)
|
|
4695
|
+
};
|
|
4514
4696
|
const appId = String(bot.appId);
|
|
4515
4697
|
if (!appId) {
|
|
4516
4698
|
logger.warn("[QQ Official Bot] 配置缺少 appId,跳过");
|
|
@@ -4532,8 +4714,8 @@ const createBot = async (bot) => {
|
|
|
4532
4714
|
offAll(appId);
|
|
4533
4715
|
stopTokenRefresh(appId);
|
|
4534
4716
|
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;
|
|
4717
|
+
await getAccessToken(bot.proxy.tokenApi, appId, bot.secret);
|
|
4718
|
+
const baseUrl = bot.sandbox ? bot.proxy.sandboxApi : bot.proxy.prodApi;
|
|
4537
4719
|
const api = new QQBotApi(createAxiosInstance(baseUrl, appId));
|
|
4538
4720
|
const me = await api.meta.getMe();
|
|
4539
4721
|
const client = new AdapterQQBot(bot, api);
|