@karinjs/adapter-qqbot 3.3.1 → 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/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 QQ_NUMBER_RE, t as fakeEvent } from "./common-DwVsAvIo.js";
4
- import "./utils-u17tvC2U.js";
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
- /** fallback 上传超过该大小时走 QQ 分片上传。 */
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 sourceBody = await buildUploadSource(source);
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
- Object.assign(body, sourceBody);
1207
- if (type === "file" && fileName) body.file_name = fileName;
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(path, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
1175
+ return await this.post(requestPath, body, { timeout: MEDIA_UPLOAD_TIMEOUT });
1211
1176
  } catch (err) {
1212
- if (!sourceBody.url) throw err;
1213
- const fallbackBody = {
1214
- ...body,
1215
- file_data: await downloadRemoteAsBase64(sourceBody.url)
1216
- };
1217
- delete fallbackBody.url;
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
- assertUploadSize(type, fallbackSource.size);
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 (!srvSendMsg && fallbackSource.size >= 5242880) {
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
- return this.post("/v2/generate_url_link", body);
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
- * 优先把本地/base64 富媒体转换为公网 URL;没有处理器或转换失败时返回原始来源。
2481
+ * 解析本地 / Base64 富媒体来源。
2349
2482
  *
2350
- * QQ 官方的普通 file_data 上传对视频等资源更容易失败,因此 adapter 层始终把
2351
- * fileToUrl 作为主路径,QQ 上传只做兜底。
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
- if (!hasFileToUrlHandler()) return {
2368
- source: normalized,
2369
- via: "fallback"
2370
- };
2371
- try {
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
- } catch (err) {
2402
- ctx.logger("warn", `[sendQQ] ${type} 转 URL 失败,改用 QQ 上传兜底:`, err);
2403
- return {
2404
- source: normalized,
2405
- via: "fallback"
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.startsWith("http")) return buildMarkdownImageLine(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.startsWith("http")) return buildMarkdownImageLine(part.source, part.alt);
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
- const res = await ctx.super.media.uploadFallback(target, contact.peer, "image", file, false);
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) {
@@ -4355,7 +4518,7 @@ const pushMarkdownText = (text, elements) => {
4355
4518
  * 图片和原生 Markdown;这里按原顺序恢复为 Karin `text`、`image`、`markdown`。
4356
4519
  */
4357
4520
  const pushQQMarkdown = (text, elements) => {
4358
- const imageReg = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
4521
+ const imageReg = /!\[([^\]]*)\]\(((?:https?:\/\/|qqbot-media:\/\/)[^\s)]+)\)/g;
4359
4522
  let lastIndex = 0;
4360
4523
  let matched = false;
4361
4524
  let match;
@@ -4383,7 +4546,7 @@ const pushQQMarkdown = (text, elements) => {
4383
4546
  */
4384
4547
  const tokenizeContent = (content = "") => {
4385
4548
  const tokens = [];
4386
- const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[A-Za-z0-9]+>/g;
4549
+ const knownTag = /<faceType=\d+,faceId="[^"]*",ext="[^"]*">|<qqbot-at-user id="[^"]+"\s*\/?>|<qqbot-at-everyone\s*\/?>|<@!?[^>]+>/g;
4387
4550
  let lastIndex = 0;
4388
4551
  let match;
4389
4552
  while (match = knownTag.exec(content)) {
@@ -6143,4 +6306,4 @@ const bootstrap = async () => {
6143
6306
  bootstrap();
6144
6307
 
6145
6308
  //#endregion
6146
- export { Http, QQBotApi, RequestApi };
6309
+ export { QQBotApi };
@@ -1,5 +1,5 @@
1
1
  import "./config-BgCU6apn.js";
2
2
  import "./logger-sza6kuy3.js";
3
- import "./common-DwVsAvIo.js";
3
+ import "./common-D4MDcmOI.js";
4
4
 
5
5
  export { };
@@ -1,5 +1,5 @@
1
1
  import { a as readConfigFile, n as config, o as writeConfig, s as pkg } from "./config-BgCU6apn.js";
2
- import "./utils-u17tvC2U.js";
2
+ import "./utils-YkZrLNvO.js";
3
3
  import karin, { components, defineConfig } from "node-karin";
4
4
 
5
5
  //#region src/web.config.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karinjs/adapter-qqbot",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
4
4
  "description": "karin adapter for QQ Official Bot (2.0)",
5
5
  "homepage": "https://github.com/KarinJS/karin-plugin-adapter-qqbot",
6
6
  "bugs": {