@karinjs/adapter-qqbot 3.3.0 → 3.4.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/apps/qqbot-file-to-url.d.ts +78 -1
- package/dist/apps/qqbot-file-to-url.js +22 -26
- package/dist/base-Dw5yfdft.d.ts +2688 -0
- package/dist/{common-DwVsAvIo.js → common-D4MDcmOI.js} +3 -1
- package/dist/index.d.ts +2 -2590
- package/dist/index.js +308 -112
- package/dist/{utils-u17tvC2U.js → utils-YkZrLNvO.js} +1 -1
- package/dist/web.config.js +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { l as basename, n as config, r as getConfig, s as pkg, t as bindHandlers } from "./config-BgCU6apn.js";
|
|
2
2
|
import { t as log } from "./logger-sza6kuy3.js";
|
|
3
|
-
import { a as DATA_URL_BASE64_RE, i as BASE64_PREFIX_RE, n as getImageSize, o as HTTP_URL_RE, r as random, s as
|
|
4
|
-
import "./utils-
|
|
3
|
+
import { a as DATA_URL_BASE64_RE, c as QQ_NUMBER_RE, i as BASE64_PREFIX_RE, n as getImageSize, o as HTTP_URL_RE, r as random, s as QQBOT_MEDIA_RE, t as fakeEvent } from "./common-D4MDcmOI.js";
|
|
4
|
+
import "./utils-YkZrLNvO.js";
|
|
5
5
|
import { t as getMessageStore } from "./message-CydR-wrX.js";
|
|
6
6
|
import karin, { AdapterBase, app, buttonHandle, common, createDirectMessage, createFriendDecreaseNotice, createFriendIncreaseNotice, createFriendMessage, createGroupApplyRequest, createGroupInviteRequest, createGroupMemberAddNotice, createGroupMemberDelNotice, createGroupMessage, createGuildMessage, fileToUrl, fileToUrlHandlerKey, getMimeType, handler, karin as karin$1, karinToQQBot, logger, registerBot, segment, senderGroup, unregisterBot } from "node-karin";
|
|
7
7
|
import { URL as URL$1 } from "url";
|
|
@@ -32,13 +32,6 @@ const FILE_TYPE = {
|
|
|
32
32
|
record: 3,
|
|
33
33
|
file: 4
|
|
34
34
|
};
|
|
35
|
-
/** 各类资源的 QQ 上传上限。 */
|
|
36
|
-
const MAX_UPLOAD_SIZE = {
|
|
37
|
-
image: 30 * 1024 * 1024,
|
|
38
|
-
video: 100 * 1024 * 1024,
|
|
39
|
-
record: 20 * 1024 * 1024,
|
|
40
|
-
file: 100 * 1024 * 1024
|
|
41
|
-
};
|
|
42
35
|
/** 上传错误中展示的资源类型名称。 */
|
|
43
36
|
const MEDIA_TYPE_LABEL = {
|
|
44
37
|
image: "图片",
|
|
@@ -63,7 +56,7 @@ const DEFAULT_MIME = {
|
|
|
63
56
|
video: "video/mp4",
|
|
64
57
|
record: "audio/mpeg"
|
|
65
58
|
};
|
|
66
|
-
/**
|
|
59
|
+
/** 二进制直传超过该大小时改用分片;这不是平台硬限制。 */
|
|
67
60
|
const LARGE_UPLOAD_THRESHOLD = 5 * 1024 * 1024;
|
|
68
61
|
/** COS 用于覆盖下载响应类型的 query 参数名。 */
|
|
69
62
|
const RESPONSE_TYPE_PARAM = "response-content-type";
|
|
@@ -937,24 +930,6 @@ const normalizeUploadFileName = (type, name) => {
|
|
|
937
930
|
return path.basename(value) || DEFAULT_FILENAME[type];
|
|
938
931
|
};
|
|
939
932
|
/**
|
|
940
|
-
* 检查 fallback 资源大小是否超过 QQ 对应类型上限。
|
|
941
|
-
* @param type 富媒体类型。
|
|
942
|
-
* @param size 文件大小。
|
|
943
|
-
*/
|
|
944
|
-
const assertUploadSize = (type, size) => {
|
|
945
|
-
const limit = MAX_UPLOAD_SIZE[type];
|
|
946
|
-
if (size <= limit) return;
|
|
947
|
-
throw new Error(`${MEDIA_TYPE_LABEL[type]}过大:${formatBytes(size)},QQ 上传上限为 ${formatBytes(limit)}`);
|
|
948
|
-
};
|
|
949
|
-
/**
|
|
950
|
-
* 读取本地媒体文件并转为 QQ 上传接口需要的 base64。
|
|
951
|
-
* @param source 本地路径或 file:// URL。
|
|
952
|
-
* @returns 文件内容的 base64 字符串。
|
|
953
|
-
*/
|
|
954
|
-
const readLocalFileAsBase64 = async (source) => {
|
|
955
|
-
return readFile(normalizeLocalPath(source), "base64");
|
|
956
|
-
};
|
|
957
|
-
/**
|
|
958
933
|
* 下载远程媒体并返回 Buffer。
|
|
959
934
|
* @param url 远程媒体 URL。
|
|
960
935
|
* @returns 文件内容。
|
|
@@ -977,18 +952,6 @@ const downloadRemoteAsBase64 = async (url) => {
|
|
|
977
952
|
return (await downloadRemoteAsBuffer(url)).toString("base64");
|
|
978
953
|
};
|
|
979
954
|
/**
|
|
980
|
-
* 将 Karin 的 file 字段归一为 QQ 上传接口支持的 url / file_data。
|
|
981
|
-
* @param source HTTP URL、本地路径、data URL、base64:// 或裸 base64。
|
|
982
|
-
* @returns 可合并进上传请求体的来源字段。
|
|
983
|
-
*/
|
|
984
|
-
const buildUploadSource = async (source) => {
|
|
985
|
-
const value = source.trim();
|
|
986
|
-
if (HTTP_URL_RE.test(value)) return { url: value };
|
|
987
|
-
if (DATA_URL_BASE64_RE.test(value) || BASE64_PREFIX_RE.test(value)) return { file_data: stripBase64Prefix(value) };
|
|
988
|
-
if (isLocalPathSource(value)) return { file_data: await readLocalFileAsBase64(value) };
|
|
989
|
-
return { file_data: value };
|
|
990
|
-
};
|
|
991
|
-
/**
|
|
992
955
|
* 将 fallback 来源归一为可判断大小、可分片读取的形式。
|
|
993
956
|
*
|
|
994
957
|
* @param type 富媒体类型。
|
|
@@ -1198,24 +1161,25 @@ var MediaApi = class extends Http {
|
|
|
1198
1161
|
* @param fileName 文件消息的展示文件名
|
|
1199
1162
|
*/
|
|
1200
1163
|
async upload(scene, peer, type, source, srvSendMsg = false, fileName) {
|
|
1201
|
-
const
|
|
1164
|
+
const value = source.trim();
|
|
1165
|
+
/** 本地 / Base64 属于二进制直传路径,统一交给按体积选择直传或分片的实现。 */
|
|
1166
|
+
if (!HTTP_URL_RE.test(value)) return this.uploadFallback(scene, peer, type, value, srvSendMsg, fileName);
|
|
1202
1167
|
const body = {
|
|
1203
1168
|
file_type: FILE_TYPE[type],
|
|
1204
|
-
srv_send_msg: srvSendMsg
|
|
1169
|
+
srv_send_msg: srvSendMsg,
|
|
1170
|
+
url: value
|
|
1205
1171
|
};
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
const path = `/v2/${scene}s/${peer}/files`;
|
|
1172
|
+
if (fileName) body.file_name = fileName;
|
|
1173
|
+
const requestPath = `/v2/${scene}s/${peer}/files`;
|
|
1209
1174
|
try {
|
|
1210
|
-
return await this.post(
|
|
1175
|
+
return await this.post(requestPath, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
|
|
1211
1176
|
} catch (err) {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
return this.post(path, fallbackBody, { timeout: MEDIA_UPLOAD_TIMEOUT });
|
|
1177
|
+
/**
|
|
1178
|
+
* 文档明确提示 URL 上传可能因服务端网络失败;下载后走同一套
|
|
1179
|
+
* 二进制直传 / 分片兜底,避免再次把公网 URL 交给平台。
|
|
1180
|
+
*/
|
|
1181
|
+
const base64 = await downloadRemoteAsBase64(value);
|
|
1182
|
+
return this.uploadFallback(scene, peer, type, `base64://${base64}`, srvSendMsg, fileName);
|
|
1219
1183
|
}
|
|
1220
1184
|
}
|
|
1221
1185
|
/**
|
|
@@ -1230,27 +1194,32 @@ var MediaApi = class extends Http {
|
|
|
1230
1194
|
* @param source 本地路径、http url、data URL、base64:// 或裸 base64
|
|
1231
1195
|
* @param srvSendMsg 上传后是否由服务端直接发送
|
|
1232
1196
|
* @param fileName 文件消息的展示文件名
|
|
1197
|
+
* @param forceChunk 是否强制走分片上传,不按 5MB 阈值判断
|
|
1233
1198
|
* @returns 上传结果,包含后续发送消息所需的 file_info
|
|
1234
1199
|
*/
|
|
1235
|
-
async uploadFallback(scene, peer, type, source, srvSendMsg = false, fileName) {
|
|
1200
|
+
async uploadFallback(scene, peer, type, source, srvSendMsg = false, fileName, forceChunk = false) {
|
|
1201
|
+
/**
|
|
1202
|
+
* 不在适配器侧按固定体积拦截;平台硬限制可能调整,由上传接口返回错误。
|
|
1203
|
+
* 这里的 5MB 只用于选择二进制直传还是分片传输。
|
|
1204
|
+
*/
|
|
1236
1205
|
const fallbackSource = await buildFallbackSource(type, source, fileName);
|
|
1237
|
-
|
|
1238
|
-
const hashes = !srvSendMsg ? await computeUploadHashes(fallbackSource) : void 0;
|
|
1239
|
-
if (hashes) {
|
|
1206
|
+
const needsChunk = forceChunk || fallbackSource.size > 5242880;
|
|
1207
|
+
const hashes = !srvSendMsg || needsChunk ? await computeUploadHashes(fallbackSource) : void 0;
|
|
1208
|
+
if (!srvSendMsg && hashes) {
|
|
1240
1209
|
const cached = await getCachedUpload(scene, peer, type, hashes.md5);
|
|
1241
1210
|
if (cached) return cached;
|
|
1242
1211
|
}
|
|
1243
|
-
if (
|
|
1244
|
-
const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes);
|
|
1245
|
-
if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1212
|
+
if (needsChunk) {
|
|
1213
|
+
const response = await this.uploadChunked(scene, peer, type, fallbackSource, hashes, srvSendMsg);
|
|
1214
|
+
if (!srvSendMsg && hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1246
1215
|
return response;
|
|
1247
1216
|
}
|
|
1248
1217
|
const body = {
|
|
1249
1218
|
file_type: FILE_TYPE[type],
|
|
1250
1219
|
srv_send_msg: srvSendMsg,
|
|
1220
|
+
file_name: fallbackSource.fileName,
|
|
1251
1221
|
file_data: await readFallbackSourceAsBase64(fallbackSource)
|
|
1252
1222
|
};
|
|
1253
|
-
if (type === "file") body.file_name = fallbackSource.fileName;
|
|
1254
1223
|
const response = await this.post(`/v2/${scene}s/${peer}/files`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
|
|
1255
1224
|
if (hashes) await setCachedUpload(scene, peer, type, hashes.md5, response);
|
|
1256
1225
|
return response;
|
|
@@ -1276,8 +1245,8 @@ var MediaApi = class extends Http {
|
|
|
1276
1245
|
*/
|
|
1277
1246
|
async uploadForUrl(scene, peer, type, source, fileName) {
|
|
1278
1247
|
if (type === "file") throw new Error(`QQ 不为${MEDIA_TYPE_LABEL.file}类型返回下载直链,无法当图床使用`);
|
|
1248
|
+
/** 同样不在本地拦截体积;平台限制由 upload_prepare / files 接口返回。 */
|
|
1279
1249
|
const prepared = await buildFallbackSource(type, source, fileName);
|
|
1280
|
-
assertUploadSize(type, prepared.size);
|
|
1281
1250
|
const hashes = await computeUploadHashes(prepared);
|
|
1282
1251
|
const reused = await getCachedUpload(scene, peer, type, hashes.md5);
|
|
1283
1252
|
const response = reused?.raw_url ? reused : await this.uploadChunked(scene, peer, type, prepared, hashes);
|
|
@@ -1346,9 +1315,10 @@ var MediaApi = class extends Http {
|
|
|
1346
1315
|
* @param uploadId upload_prepare 返回的上传任务 ID
|
|
1347
1316
|
* @returns 上传结果
|
|
1348
1317
|
*/
|
|
1349
|
-
async completeChunkedUpload(scene, peer, uploadId) {
|
|
1318
|
+
async completeChunkedUpload(scene, peer, uploadId, srvSendMsg = false) {
|
|
1350
1319
|
let lastError;
|
|
1351
1320
|
const body = { upload_id: uploadId };
|
|
1321
|
+
if (srvSendMsg) body.srv_send_msg = true;
|
|
1352
1322
|
for (let attempt = 0; attempt <= 2; attempt++) try {
|
|
1353
1323
|
return await this.post(`/v2/${scene}s/${peer}/files`, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
|
|
1354
1324
|
} catch (err) {
|
|
@@ -1369,7 +1339,7 @@ var MediaApi = class extends Http {
|
|
|
1369
1339
|
* @param preparedHashes 预计算的文件 hash
|
|
1370
1340
|
* @returns 上传结果,包含后续发送消息所需的 file_info
|
|
1371
1341
|
*/
|
|
1372
|
-
async uploadChunked(scene, peer, type, source, preparedHashes) {
|
|
1342
|
+
async uploadChunked(scene, peer, type, source, preparedHashes, srvSendMsg = false) {
|
|
1373
1343
|
const hashes = preparedHashes || await computeUploadHashes(source);
|
|
1374
1344
|
const prepareBody = {
|
|
1375
1345
|
file_type: FILE_TYPE[type],
|
|
@@ -1404,7 +1374,154 @@ var MediaApi = class extends Http {
|
|
|
1404
1374
|
} finally {
|
|
1405
1375
|
await handle?.close().catch(() => void 0);
|
|
1406
1376
|
}
|
|
1407
|
-
return this.completeChunkedUpload(scene, peer, prepare.upload_id);
|
|
1377
|
+
return this.completeChunkedUpload(scene, peer, prepare.upload_id, srvSendMsg);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region src/core/api/media-images.ts
|
|
1383
|
+
/** 素材图片缓存提前失效时间,避免临界点继续引用刚过期的 URL。 */
|
|
1384
|
+
const MEDIA_IMAGE_TTL_SAFETY_MARGIN_SECONDS = 60;
|
|
1385
|
+
/**
|
|
1386
|
+
* 根据文件头识别官方支持的图片类型。
|
|
1387
|
+
*
|
|
1388
|
+
* 素材接口要求文件名扩展名与 Content-Type 一致,因此不能只依赖调用方传入的
|
|
1389
|
+
* 文件名;Buffer 内容才是最终可信来源。
|
|
1390
|
+
*
|
|
1391
|
+
* @param buffer 图片内容。
|
|
1392
|
+
* @returns 图片 MIME 与标准扩展名。
|
|
1393
|
+
*/
|
|
1394
|
+
const detectMediaImageType = (buffer) => {
|
|
1395
|
+
if (buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) return {
|
|
1396
|
+
mime: "image/png",
|
|
1397
|
+
ext: "png"
|
|
1398
|
+
};
|
|
1399
|
+
if (buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) return {
|
|
1400
|
+
mime: "image/jpeg",
|
|
1401
|
+
ext: "jpg"
|
|
1402
|
+
};
|
|
1403
|
+
const header = buffer.subarray(0, 6).toString("ascii");
|
|
1404
|
+
if (header === "GIF87a" || header === "GIF89a") return {
|
|
1405
|
+
mime: "image/gif",
|
|
1406
|
+
ext: "gif"
|
|
1407
|
+
};
|
|
1408
|
+
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return {
|
|
1409
|
+
mime: "image/webp",
|
|
1410
|
+
ext: "webp"
|
|
1411
|
+
};
|
|
1412
|
+
throw new Error("素材图片格式不支持:仅支持 png / jpeg / gif / webp");
|
|
1413
|
+
};
|
|
1414
|
+
/**
|
|
1415
|
+
* 让文件名扩展名与真实图片格式一致。
|
|
1416
|
+
*
|
|
1417
|
+
* @param fileName 调用方传入的文件名。
|
|
1418
|
+
* @param type 识别出的图片类型。
|
|
1419
|
+
* @returns 可直接交给 QQ 的文件名。
|
|
1420
|
+
*/
|
|
1421
|
+
const normalizeMediaImageName = (fileName, type) => {
|
|
1422
|
+
const fallback = `image.${type.ext}`;
|
|
1423
|
+
const base = path.basename(fileName?.trim() || fallback);
|
|
1424
|
+
const currentExt = path.extname(base).slice(1).toLowerCase();
|
|
1425
|
+
if (currentExt === type.ext || type.ext === "jpg" && currentExt === "jpeg") return base;
|
|
1426
|
+
return `${(currentExt ? base.slice(0, -(currentExt.length + 1)) : base) || "image"}.${type.ext}`;
|
|
1427
|
+
};
|
|
1428
|
+
/**
|
|
1429
|
+
* 读取素材图片内容。支持 Buffer、URL、本地路径、data URL 与 base64://。
|
|
1430
|
+
*
|
|
1431
|
+
* @param source 图片来源。
|
|
1432
|
+
* @returns 图片二进制。
|
|
1433
|
+
*/
|
|
1434
|
+
const readMediaImageBuffer = async (source) => {
|
|
1435
|
+
if (Buffer.isBuffer(source)) return source;
|
|
1436
|
+
if (source instanceof Uint8Array) return Buffer.from(source);
|
|
1437
|
+
const normalized = source.trim().replace(/^data:([^;,]+);base64,/i, "base64://");
|
|
1438
|
+
const compact = normalized.replace(/\s+/g, "");
|
|
1439
|
+
/** Karin 消息段可能直接给裸 base64,不一定是 `base64://` 前缀。 */
|
|
1440
|
+
if (compact.length >= 8 && compact.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(compact)) {
|
|
1441
|
+
const decoded = Buffer.from(compact, "base64");
|
|
1442
|
+
try {
|
|
1443
|
+
detectMediaImageType(decoded);
|
|
1444
|
+
return decoded;
|
|
1445
|
+
} catch {}
|
|
1446
|
+
}
|
|
1447
|
+
const data = await common.buffer(normalized);
|
|
1448
|
+
return Buffer.from(data);
|
|
1449
|
+
};
|
|
1450
|
+
/**
|
|
1451
|
+
* Markdown 素材图片管理。
|
|
1452
|
+
*
|
|
1453
|
+
* 官方接口文档 v1.42.0 新增 `/v2/media-images` 系列接口,用于把本地图片
|
|
1454
|
+
* 上传为可长期复用的 `qqbot-media://` Markdown 引用。
|
|
1455
|
+
*/
|
|
1456
|
+
var MediaImagesApi = class extends Http {
|
|
1457
|
+
/** 当前 bot 进程内按内容 MD5 复用的素材缓存。 */
|
|
1458
|
+
cache = /* @__PURE__ */ new Map();
|
|
1459
|
+
/** 申请上传图片,获取 upload_id 与预签名 PUT 地址。 */
|
|
1460
|
+
prepare(body) {
|
|
1461
|
+
return this.post("/v2/media-images/prepare", body);
|
|
1462
|
+
}
|
|
1463
|
+
/** 完成图片上传,获取可写入 Markdown 的 `qqbot-media://` 引用。 */
|
|
1464
|
+
complete(body) {
|
|
1465
|
+
return this.post("/v2/media-images/complete", body);
|
|
1466
|
+
}
|
|
1467
|
+
/** 查询当前机器人的有效素材图片。 */
|
|
1468
|
+
list(params = {}) {
|
|
1469
|
+
return this.get("/v2/media-images", { params: params.cursor ? { cursor: params.cursor } : void 0 });
|
|
1470
|
+
}
|
|
1471
|
+
/** 删除素材图片并释放数量配额。 */
|
|
1472
|
+
delete(imageId) {
|
|
1473
|
+
this.cache.clear();
|
|
1474
|
+
return super.delete(`/v2/media-images/${encodeURIComponent(imageId)}/delete`);
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* 上传图片并返回可写入 Markdown 的 `qqbot-media://` 引用。
|
|
1478
|
+
*
|
|
1479
|
+
* 自动完成文件头识别、文件名规范化、MD5/SHA1 计算、预签名 PUT 与 complete。
|
|
1480
|
+
*
|
|
1481
|
+
* @param source 图片来源:Buffer、URL、本地路径、data URL 或 base64://。
|
|
1482
|
+
* @param fileName 可选文件名;未提供时按真实图片格式生成。
|
|
1483
|
+
* @returns complete 接口响应。
|
|
1484
|
+
*/
|
|
1485
|
+
async upload(source, fileName) {
|
|
1486
|
+
/**
|
|
1487
|
+
* 不在适配器侧限制图片体积;平台当前素材接口限制为 10MB,但限制策略
|
|
1488
|
+
* 可能调整,统一由 prepare / complete 接口返回的错误决定。
|
|
1489
|
+
*/
|
|
1490
|
+
const buffer = await readMediaImageBuffer(source);
|
|
1491
|
+
if (!buffer.length) throw new Error("素材图片内容为空");
|
|
1492
|
+
const type = detectMediaImageType(buffer);
|
|
1493
|
+
const normalizedName = normalizeMediaImageName(fileName, type);
|
|
1494
|
+
const md5 = crypto.createHash("md5").update(buffer).digest("hex");
|
|
1495
|
+
const cached = this.cache.get(md5);
|
|
1496
|
+
if (cached && (cached.expiresAt === 0 || cached.expiresAt > Date.now())) return cached.response;
|
|
1497
|
+
const sha1 = crypto.createHash("sha1").update(buffer).digest("hex");
|
|
1498
|
+
const prepared = await this.prepare({
|
|
1499
|
+
file_name: normalizedName,
|
|
1500
|
+
file_size: buffer.length,
|
|
1501
|
+
content_type: type.mime,
|
|
1502
|
+
md5,
|
|
1503
|
+
sha1
|
|
1504
|
+
});
|
|
1505
|
+
await axios.put(prepared.presigned_url, buffer, {
|
|
1506
|
+
headers: {
|
|
1507
|
+
"Content-Type": type.mime,
|
|
1508
|
+
"Content-Length": String(buffer.length)
|
|
1509
|
+
},
|
|
1510
|
+
timeout: 12e4,
|
|
1511
|
+
maxBodyLength: Infinity,
|
|
1512
|
+
maxContentLength: Infinity
|
|
1513
|
+
});
|
|
1514
|
+
const response = await this.complete({
|
|
1515
|
+
upload_id: prepared.upload_id,
|
|
1516
|
+
md5
|
|
1517
|
+
});
|
|
1518
|
+
const ttl = Number(response.ttl);
|
|
1519
|
+
const expiresAt = ttl > 0 ? Date.now() + Math.max(ttl - MEDIA_IMAGE_TTL_SAFETY_MARGIN_SECONDS, 10) * 1e3 : 0;
|
|
1520
|
+
this.cache.set(md5, {
|
|
1521
|
+
response,
|
|
1522
|
+
expiresAt
|
|
1523
|
+
});
|
|
1524
|
+
return response;
|
|
1408
1525
|
}
|
|
1409
1526
|
};
|
|
1410
1527
|
|
|
@@ -1452,8 +1569,11 @@ var MetaApi = class extends Http {
|
|
|
1452
1569
|
*
|
|
1453
1570
|
* @param body 分享链接参数,均为可选
|
|
1454
1571
|
*/
|
|
1455
|
-
generateUrlLink(body = {}) {
|
|
1456
|
-
|
|
1572
|
+
async generateUrlLink(body = {}) {
|
|
1573
|
+
const response = await this.post("/v2/generate_url_link", body);
|
|
1574
|
+
/** 兼容旧版接口返回;新文档的正式结构是 `{ data: { url } }`。 */
|
|
1575
|
+
if (!("data" in response) && response.url_link) return { data: { url: response.url_link } };
|
|
1576
|
+
return response;
|
|
1457
1577
|
}
|
|
1458
1578
|
};
|
|
1459
1579
|
|
|
@@ -1974,6 +2094,7 @@ const buildGuildMsg = {
|
|
|
1974
2094
|
* 子模块按职能拆分:
|
|
1975
2095
|
* - messages:发送 / 撤回
|
|
1976
2096
|
* - media:富媒体上传
|
|
2097
|
+
* - mediaImages:Markdown 素材图片管理
|
|
1977
2098
|
* - interaction:按钮回调 ack
|
|
1978
2099
|
* - meta:@me / dms / gateway / 分享链接
|
|
1979
2100
|
* - groups:群基础信息 / 机器人群内状态 / 群成员列表与详情 / 禁言 / 批量移除 / 群黑名单 / 入群申请(白名单接口)
|
|
@@ -2003,6 +2124,7 @@ var QQBotApi = class {
|
|
|
2003
2124
|
request;
|
|
2004
2125
|
messages;
|
|
2005
2126
|
media;
|
|
2127
|
+
mediaImages;
|
|
2006
2128
|
interaction;
|
|
2007
2129
|
meta;
|
|
2008
2130
|
groups;
|
|
@@ -2018,6 +2140,7 @@ var QQBotApi = class {
|
|
|
2018
2140
|
this.request = new RequestApi(axios);
|
|
2019
2141
|
this.messages = new MessagesApi(axios);
|
|
2020
2142
|
this.media = new MediaApi(axios);
|
|
2143
|
+
this.mediaImages = new MediaImagesApi(axios);
|
|
2021
2144
|
this.interaction = new InteractionApi(axios);
|
|
2022
2145
|
this.meta = new MetaApi(axios);
|
|
2023
2146
|
this.groups = new GroupsApi(axios);
|
|
@@ -2063,6 +2186,16 @@ const getAccessToken = async (appId, secret) => {
|
|
|
2063
2186
|
} else log("error", `[${appId}] 获取 access_token 失败:`, err);
|
|
2064
2187
|
throw err;
|
|
2065
2188
|
}
|
|
2189
|
+
/**
|
|
2190
|
+
* access_token 接口的业务错误即使失败也会返回 HTTP 200,必须优先检查
|
|
2191
|
+
* 响应体中的 code,不能只依赖 axios 的 HTTP 状态。
|
|
2192
|
+
*/
|
|
2193
|
+
const code = res.data?.code;
|
|
2194
|
+
if (typeof code === "number" && code !== 0) {
|
|
2195
|
+
const detail = formatOpenAPIError(200, code, res.data?.message);
|
|
2196
|
+
log("error", `[${appId}] 获取 access_token 失败 | ${detail}`);
|
|
2197
|
+
throw new Error(`获取 access_token 失败 | ${detail}`);
|
|
2198
|
+
}
|
|
2066
2199
|
const accessToken = res.data?.access_token;
|
|
2067
2200
|
const expiresIn = Number(res.data?.expires_in);
|
|
2068
2201
|
if (!accessToken || !expiresIn || Number.isNaN(expiresIn)) throw new Error(`获取 access_token 失败: ${JSON.stringify(res.data)}`);
|
|
@@ -2345,10 +2478,11 @@ const hasFileToUrlHandler = () => handler.has(fileToUrlHandlerKey);
|
|
|
2345
2478
|
*/
|
|
2346
2479
|
const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
|
|
2347
2480
|
/**
|
|
2348
|
-
*
|
|
2481
|
+
* 解析本地 / Base64 富媒体来源。
|
|
2349
2482
|
*
|
|
2350
|
-
*
|
|
2351
|
-
* fileToUrl
|
|
2483
|
+
* 优先级为:用户自己的 `fileToUrl` 处理器 -> Markdown 图片素材管理 ->
|
|
2484
|
+
* 适配器内置 fileToUrl 图床 -> QQ 富媒体上传兜底。内置图床只在前两步都失败后
|
|
2485
|
+
* 才被允许接手,避免临时直链抢占 `qqbot-media://`;视频 / 语音仍可由内置图床取直链。
|
|
2352
2486
|
*
|
|
2353
2487
|
* @param ctx 适配器实例,用于记录降级日志并标明执行发送的 bot。
|
|
2354
2488
|
* @param type 富媒体类型。
|
|
@@ -2360,51 +2494,76 @@ const isHttpMediaSource = (source) => HTTP_URL_RE.test(source);
|
|
|
2360
2494
|
*/
|
|
2361
2495
|
const resolvePreferredMediaSource = async (ctx, type, source, filename, origin, purpose = "media") => {
|
|
2362
2496
|
const normalized = normalizeMediaSource(source);
|
|
2363
|
-
if (isHttpMediaSource(normalized)) return {
|
|
2497
|
+
if (isHttpMediaSource(normalized) || QQBOT_MEDIA_RE.test(normalized)) return {
|
|
2364
2498
|
source: normalized,
|
|
2365
2499
|
via: "public-url"
|
|
2366
2500
|
};
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
const extra = {
|
|
2373
|
-
selfId: ctx.selfId,
|
|
2374
|
-
purpose,
|
|
2375
|
-
origin
|
|
2376
|
-
};
|
|
2377
|
-
const result = await fileToUrl(type, normalized, filename || DEFAULT_FILENAME[type], extra);
|
|
2378
|
-
/**
|
|
2379
|
-
* `handler.has` 只判断该 key 下是否注册过处理器,不代表真的有处理器接手:
|
|
2380
|
-
* 所有处理器都调用 next() 放行时,fileToUrl 会返回 undefined。此时不属于
|
|
2381
|
-
* 异常,安静地走 QQ 上传兜底即可,不必输出 warn 噪音。
|
|
2382
|
-
*/
|
|
2383
|
-
if (!result?.url) {
|
|
2384
|
-
ctx.logger("debug", `[sendQQ] 没有 fileToUrl 处理器接手 ${type},改用 QQ 上传兜底`);
|
|
2385
|
-
return {
|
|
2386
|
-
source: normalized,
|
|
2387
|
-
via: "fallback"
|
|
2388
|
-
};
|
|
2389
|
-
}
|
|
2390
|
-
/** 只有 image 会带宽高,其余类型按声明就没有这两个字段。 */
|
|
2391
|
-
const { width, height } = result;
|
|
2392
|
-
const size = typeof width === "number" && typeof height === "number" ? {
|
|
2393
|
-
width,
|
|
2394
|
-
height
|
|
2501
|
+
/** 将 fileToUrl 结果归一为发送链路需要的来源结构。 */
|
|
2502
|
+
const toPreferred = (result) => {
|
|
2503
|
+
const size = typeof result.width === "number" && typeof result.height === "number" ? {
|
|
2504
|
+
width: result.width,
|
|
2505
|
+
height: result.height
|
|
2395
2506
|
} : void 0;
|
|
2396
2507
|
return {
|
|
2397
2508
|
source: result.url,
|
|
2398
2509
|
via: "file-to-url",
|
|
2399
2510
|
size
|
|
2400
2511
|
};
|
|
2401
|
-
}
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2512
|
+
};
|
|
2513
|
+
/**
|
|
2514
|
+
* 调用 fileToUrl 链。
|
|
2515
|
+
*
|
|
2516
|
+
* 第一次传 `allowBuiltinFileToUrl: false`,内置处理器会主动放行;素材管理
|
|
2517
|
+
* 失败后再传 true,允许内置图床接手。用户自己的处理器在两轮中都可以响应。
|
|
2518
|
+
*/
|
|
2519
|
+
const tryFileToUrl = async (allowBuiltinFileToUrl) => {
|
|
2520
|
+
if (!hasFileToUrlHandler()) return {};
|
|
2521
|
+
try {
|
|
2522
|
+
const extra = {
|
|
2523
|
+
selfId: ctx.selfId,
|
|
2524
|
+
purpose,
|
|
2525
|
+
origin,
|
|
2526
|
+
allowBuiltinFileToUrl
|
|
2527
|
+
};
|
|
2528
|
+
const result = await fileToUrl(type, normalized, filename || DEFAULT_FILENAME[type], extra);
|
|
2529
|
+
return { result: result?.url ? result : void 0 };
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
return { error: err };
|
|
2532
|
+
}
|
|
2533
|
+
};
|
|
2534
|
+
/**
|
|
2535
|
+
* 图片要进入 Markdown 时,如果用户图床不可用,优先使用官方素材管理。
|
|
2536
|
+
* 该接口返回的 `qqbot-media://` 可以直接写进 Markdown,且不依赖公网 URL。
|
|
2537
|
+
*/
|
|
2538
|
+
const tryQQBotMedia = async () => {
|
|
2539
|
+
if (type !== "image" || purpose !== "markdown") return void 0;
|
|
2540
|
+
try {
|
|
2541
|
+
const size = await getImageSize(normalized).catch(() => void 0);
|
|
2542
|
+
const uploaded = await ctx.super.mediaImages.upload(normalized, filename);
|
|
2543
|
+
ctx.logger("debug", `[sendQQ] 素材管理上传成功,ttl=${uploaded.ttl}s: ${uploaded.url}`);
|
|
2544
|
+
return {
|
|
2545
|
+
source: uploaded.url,
|
|
2546
|
+
via: "qqbot-media",
|
|
2547
|
+
size
|
|
2548
|
+
};
|
|
2549
|
+
} catch (err) {
|
|
2550
|
+
ctx.logger("debug", `[sendQQ] ${type} 素材管理上传失败,继续尝试内置图床:`, err);
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
};
|
|
2554
|
+
const userStage = await tryFileToUrl(false);
|
|
2555
|
+
if (userStage.result) return toPreferred(userStage.result);
|
|
2556
|
+
const material = await tryQQBotMedia();
|
|
2557
|
+
if (material) return material;
|
|
2558
|
+
const builtinStage = await tryFileToUrl(true);
|
|
2559
|
+
if (builtinStage.result) return toPreferred(builtinStage.result);
|
|
2560
|
+
const failure = builtinStage.error || userStage.error;
|
|
2561
|
+
if (failure) ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, failure);
|
|
2562
|
+
else ctx.logger("debug", `[sendQQ] 没有 fileToUrl 处理器接手 ${type},改用 QQ 上传兜底`);
|
|
2563
|
+
return {
|
|
2564
|
+
source: normalized,
|
|
2565
|
+
via: "fallback"
|
|
2566
|
+
};
|
|
2408
2567
|
};
|
|
2409
2568
|
|
|
2410
2569
|
//#endregion
|
|
@@ -2419,6 +2578,8 @@ const MARKDOWN_IMAGE_RE = /!\[([^\]]*)]\(\s*(<[^>]+>|[^\s)]+)(?:\s+["'][^"']*["'
|
|
|
2419
2578
|
* 保留。
|
|
2420
2579
|
*/
|
|
2421
2580
|
const EXPLICIT_IMAGE_SIZE_RE = /#(\d+)px\s+#(\d+)px/;
|
|
2581
|
+
/** 已经是可直接写入 Markdown 的图片地址。 */
|
|
2582
|
+
const isDirectImageSource = (source) => /^https?:\/\//i.test(source) || QQBOT_MEDIA_RE.test(source);
|
|
2422
2583
|
/**
|
|
2423
2584
|
* 调用 fileToUrl 并确认真的拿到了 URL。
|
|
2424
2585
|
*
|
|
@@ -2451,6 +2612,7 @@ const buildMarkdownImageLine = async (url, alt = "", size) => {
|
|
|
2451
2612
|
const explicit = alt.match(EXPLICIT_IMAGE_SIZE_RE);
|
|
2452
2613
|
if (explicit) return `![${alt.replace(EXPLICIT_IMAGE_SIZE_RE, "").trim()} #${explicit[1]}px #${explicit[2]}px](${url})`;
|
|
2453
2614
|
const desc = alt.trim() || "karin";
|
|
2615
|
+
/** QQ 客户端对缺失尺寸的图片会使用很小的默认值,探测失败也必须写入兜底尺寸。 */
|
|
2454
2616
|
const resolved = size ?? await getImageSize(url).catch(() => ({
|
|
2455
2617
|
width: 300,
|
|
2456
2618
|
height: 300
|
|
@@ -2484,7 +2646,7 @@ const buildPendingMarkdownImageLine = (source, alt = "", size) => {
|
|
|
2484
2646
|
*/
|
|
2485
2647
|
const imagesToMarkdown = async (urls, extra) => {
|
|
2486
2648
|
return Promise.all(urls.map(async (file) => {
|
|
2487
|
-
if (file
|
|
2649
|
+
if (isDirectImageSource(file)) return buildMarkdownImageLine(file);
|
|
2488
2650
|
const { url, width, height } = await imageToUrl(file, extra);
|
|
2489
2651
|
return buildMarkdownImageLine(url, "", {
|
|
2490
2652
|
width,
|
|
@@ -2500,7 +2662,7 @@ const imagesToMarkdown = async (urls, extra) => {
|
|
|
2500
2662
|
* @returns markdown 图片行。
|
|
2501
2663
|
*/
|
|
2502
2664
|
const markdownPartToLine = async (part, extra) => {
|
|
2503
|
-
if (part.source
|
|
2665
|
+
if (isDirectImageSource(part.source)) return buildMarkdownImageLine(part.source, part.alt);
|
|
2504
2666
|
const { url, width, height } = await imageToUrl(part.source, extra);
|
|
2505
2667
|
return buildMarkdownImageLine(url, part.alt, {
|
|
2506
2668
|
width,
|
|
@@ -2595,7 +2757,8 @@ const sendQQMarkdown = async (ctx, contact, grouping, target) => {
|
|
|
2595
2757
|
}
|
|
2596
2758
|
const mediaItems = [];
|
|
2597
2759
|
for (const file of fallbackImages) {
|
|
2598
|
-
|
|
2760
|
+
/** 图片兜底固定走分片上传,避免普通二进制直传在本地/Base64 场景下不稳定。 */
|
|
2761
|
+
const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false, void 0, true);
|
|
2599
2762
|
mediaItems.push(ctx.super.qq.media(res.file_info));
|
|
2600
2763
|
}
|
|
2601
2764
|
for (const m of grouping.media) {
|
|
@@ -3457,6 +3620,34 @@ const getAdapterConfig = (bot) => {
|
|
|
3457
3620
|
return cfg;
|
|
3458
3621
|
};
|
|
3459
3622
|
|
|
3623
|
+
//#endregion
|
|
3624
|
+
//#region src/core/adapter/self-avatar.ts
|
|
3625
|
+
/**
|
|
3626
|
+
* 机器人自身头像的在途请求存储。
|
|
3627
|
+
*
|
|
3628
|
+
* 机器人没有可拼进 qlogo CDN 的自身 openid(selfId 就是 appId),只能走官方
|
|
3629
|
+
* `GET /users/@me` 取 avatar。在途请求挂在 WeakMap 而不是实例字段上,保持
|
|
3630
|
+
* AdapterQQBot 只包含 AdapterType 约束的成员;bot 重建后旧实例随之释放。
|
|
3631
|
+
*/
|
|
3632
|
+
const pending = /* @__PURE__ */ new WeakMap();
|
|
3633
|
+
/**
|
|
3634
|
+
* 获取机器人自身的真实头像,并发调用只请求一次,失败后释放以便下次重试。
|
|
3635
|
+
* @param bot 适配器实例。
|
|
3636
|
+
* @returns `/users/@me` 的 avatar,拉取失败时退回 qlogo 拼接模式(默认头像)。
|
|
3637
|
+
*/
|
|
3638
|
+
const selfAvatarUrl = (bot) => {
|
|
3639
|
+
const existed = pending.get(bot);
|
|
3640
|
+
if (existed) return existed;
|
|
3641
|
+
const fallback = `https://thirdqq.qlogo.cn/qqapp/${bot.cfg.appId}/${bot.selfId}/0`;
|
|
3642
|
+
const task = bot.super.meta.getMe().then((me) => me.avatar || fallback).catch((err) => {
|
|
3643
|
+
bot.logger("warn", `[getAvatarUrl] 获取机器人自身头像失败,退回默认拼接: ${err instanceof Error ? err.message : err}`);
|
|
3644
|
+
pending.delete(bot);
|
|
3645
|
+
return fallback;
|
|
3646
|
+
});
|
|
3647
|
+
pending.set(bot, task);
|
|
3648
|
+
return task;
|
|
3649
|
+
};
|
|
3650
|
+
|
|
3460
3651
|
//#endregion
|
|
3461
3652
|
//#region src/core/adapter/base.ts
|
|
3462
3653
|
/**
|
|
@@ -3683,8 +3874,13 @@ var AdapterQQBot = class extends AdapterBase {
|
|
|
3683
3874
|
}
|
|
3684
3875
|
/**
|
|
3685
3876
|
* 获取头像 url
|
|
3877
|
+
*
|
|
3878
|
+
* 第三方头像 CDN 的模式是 `qqapp/{appId}/{openid}/{size}`,只有拼用户 openid 才有效;
|
|
3879
|
+
* 机器人自身没有可用的 openid(selfId 就是 appId,拼出 appId/appid 只会得到默认头像),
|
|
3880
|
+
* 因此目标是自己时改走官方 `GET /users/@me` 返回的 avatar 字段,取值与缓存见 self-avatar。
|
|
3686
3881
|
*/
|
|
3687
3882
|
async getAvatarUrl(_userId, _size = 0) {
|
|
3883
|
+
if (_userId && (_userId === this.selfId || _userId === this.selfSubId("id"))) return selfAvatarUrl(this);
|
|
3688
3884
|
return `https://thirdqq.qlogo.cn/qqapp/${this.cfg.appId}/${_userId}/${_size}`;
|
|
3689
3885
|
}
|
|
3690
3886
|
/**
|
|
@@ -4322,7 +4518,7 @@ const pushMarkdownText = (text, elements) => {
|
|
|
4322
4518
|
* 图片和原生 Markdown;这里按原顺序恢复为 Karin `text`、`image`、`markdown`。
|
|
4323
4519
|
*/
|
|
4324
4520
|
const pushQQMarkdown = (text, elements) => {
|
|
4325
|
-
const imageReg = /!\[([^\]]*)\]\((https
|
|
4521
|
+
const imageReg = /!\[([^\]]*)\]\(((?:https?:\/\/|qqbot-media:\/\/)[^\s)]+)\)/g;
|
|
4326
4522
|
let lastIndex = 0;
|
|
4327
4523
|
let matched = false;
|
|
4328
4524
|
let match;
|
|
@@ -4350,7 +4546,7 @@ const pushQQMarkdown = (text, elements) => {
|
|
|
4350
4546
|
*/
|
|
4351
4547
|
const tokenizeContent = (content = "") => {
|
|
4352
4548
|
const tokens = [];
|
|
4353
|
-
const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[
|
|
4549
|
+
const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[^>]+>/g;
|
|
4354
4550
|
let lastIndex = 0;
|
|
4355
4551
|
let match;
|
|
4356
4552
|
while (match = knownTag.exec(content)) {
|
|
@@ -6110,4 +6306,4 @@ const bootstrap = async () => {
|
|
|
6110
6306
|
bootstrap();
|
|
6111
6307
|
|
|
6112
6308
|
//#endregion
|
|
6113
|
-
export {
|
|
6309
|
+
export { QQBotApi };
|
package/dist/web.config.js
CHANGED