@forgetmelody/koishi-plugin-best-cave 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/index.js +94 -21
  2. package/package.json +1 -1
  3. package/readme.md +12 -0
package/lib/index.js CHANGED
@@ -304,7 +304,6 @@ var import_koishi2 = require("koishi");
304
304
  var import_koishi = require("koishi");
305
305
  var path2 = __toESM(require("path"));
306
306
  var crypto = __toESM(require("crypto"));
307
- var import_jimp = __toESM(require("jimp"));
308
307
  var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
309
308
  function resolveCaveHeaderFooter(cave, config, prefix) {
310
309
  const data = {
@@ -424,10 +423,87 @@ async function buildCaveMessage(cave, config, fileManager, logger2, platform, pr
424
423
  __name(buildCaveMessage, "buildCaveMessage");
425
424
  var qqImageCache = /* @__PURE__ */ new Map();
426
425
  var qqImageTypes = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
426
+ function detectImageType(buffer) {
427
+ if (buffer.length >= 24 && buffer[0] === 137 && buffer.toString("ascii", 1, 4) === "PNG") {
428
+ return { ext: ".png", mime: "image/png" };
429
+ }
430
+ if (buffer.length >= 10 && buffer.toString("ascii", 0, 3) === "GIF") {
431
+ return { ext: ".gif", mime: "image/gif" };
432
+ }
433
+ if (buffer.length >= 3 && buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) {
434
+ return { ext: ".jpg", mime: "image/jpeg" };
435
+ }
436
+ if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
437
+ return { ext: ".webp", mime: "image/webp" };
438
+ }
439
+ return null;
440
+ }
441
+ __name(detectImageType, "detectImageType");
442
+ function readUInt24LE(buffer, offset) {
443
+ return buffer[offset] | buffer[offset + 1] << 8 | buffer[offset + 2] << 16;
444
+ }
445
+ __name(readUInt24LE, "readUInt24LE");
446
+ function getJpegSize(buffer) {
447
+ let offset = 2;
448
+ while (offset + 9 < buffer.length) {
449
+ if (buffer[offset] !== 255) {
450
+ offset++;
451
+ continue;
452
+ }
453
+ const marker = buffer[offset + 1];
454
+ if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
455
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
456
+ }
457
+ const length = buffer.readUInt16BE(offset + 2);
458
+ if (length < 2) break;
459
+ offset += 2 + length;
460
+ }
461
+ return null;
462
+ }
463
+ __name(getJpegSize, "getJpegSize");
464
+ function getWebpSize(buffer) {
465
+ const chunk = buffer.toString("ascii", 12, 16);
466
+ if (chunk === "VP8X") {
467
+ return { width: 1 + readUInt24LE(buffer, 24), height: 1 + readUInt24LE(buffer, 27) };
468
+ }
469
+ if (chunk === "VP8 ") {
470
+ return { width: buffer.readUInt16LE(26) & 16383, height: buffer.readUInt16LE(28) & 16383 };
471
+ }
472
+ if (chunk === "VP8L") {
473
+ const b0 = buffer[21], b1 = buffer[22], b2 = buffer[23], b3 = buffer[24];
474
+ return { width: 1 + (b0 | (b1 & 63) << 8), height: 1 + (b1 >> 6 | b2 << 2 | (b3 & 15) << 10) };
475
+ }
476
+ return null;
477
+ }
478
+ __name(getWebpSize, "getWebpSize");
479
+ function getImageSize(buffer) {
480
+ const type = detectImageType(buffer);
481
+ if (!type) return null;
482
+ if (type.ext === ".png") return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
483
+ if (type.ext === ".gif") return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
484
+ if (type.ext === ".jpg") return getJpegSize(buffer);
485
+ if (type.ext === ".webp") return getWebpSize(buffer);
486
+ return null;
487
+ }
488
+ __name(getImageSize, "getImageSize");
489
+ async function getImageSizeFromUrl(session, url) {
490
+ if (!url.startsWith("http")) return null;
491
+ try {
492
+ const data = await session.bot.ctx.http.get(url, { responseType: "arraybuffer", timeout: 15e3 });
493
+ return getImageSize(Buffer.from(data));
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+ __name(getImageSizeFromUrl, "getImageSizeFromUrl");
427
499
  async function uploadQQImage(session, buffer, fileName) {
428
- const contentType = qqImageTypes[path2.extname(fileName).toLowerCase()];
429
- if (!contentType || buffer.length > 10 * 1024 * 1024) return null;
430
- const cached = qqImageCache.get(`${session.bot.selfId}:${fileName}`);
500
+ if (buffer.length > 10 * 1024 * 1024) return null;
501
+ const detected = detectImageType(buffer);
502
+ const contentType = detected?.mime ?? qqImageTypes[path2.extname(fileName).toLowerCase()];
503
+ if (!contentType) return null;
504
+ const uploadName = detected ? `${path2.basename(fileName, path2.extname(fileName))}${detected.ext}` : fileName;
505
+ const cacheKey = `${session.bot.selfId}:${fileName}`;
506
+ const cached = qqImageCache.get(cacheKey);
431
507
  if (cached) return cached;
432
508
  const bot = session.bot;
433
509
  const request = bot.internal.http();
@@ -435,7 +511,7 @@ async function uploadQQImage(session, buffer, fileName) {
435
511
  const sha1 = crypto.createHash("sha1").update(buffer).digest("hex");
436
512
  const prepared = await request("/v2/media-images/prepare", {
437
513
  method: "POST",
438
- data: { file_name: fileName, file_size: buffer.length, content_type: contentType, md5, sha1 }
514
+ data: { file_name: uploadName, file_size: buffer.length, content_type: contentType, md5, sha1 }
439
515
  });
440
516
  await bot.ctx.http.put(prepared.presigned_url, buffer, { headers: { "Content-Type": contentType } });
441
517
  const completed = await request("/v2/media-images/complete", {
@@ -443,19 +519,10 @@ async function uploadQQImage(session, buffer, fileName) {
443
519
  data: { upload_id: prepared.upload_id, md5 }
444
520
  });
445
521
  if (!completed?.url) return null;
446
- qqImageCache.set(`${session.bot.selfId}:${fileName}`, completed.url);
522
+ qqImageCache.set(cacheKey, completed.url);
447
523
  return completed.url;
448
524
  }
449
525
  __name(uploadQQImage, "uploadQQImage");
450
- async function getImageSize(buffer) {
451
- try {
452
- const image = await import_jimp.default.read(buffer);
453
- return { width: image.bitmap.width, height: image.bitmap.height };
454
- } catch {
455
- return null;
456
- }
457
- }
458
- __name(getImageSize, "getImageSize");
459
526
  async function buildQQCaveMarkdown(cave, config, fileManager, logger2, session, prefix) {
460
527
  const { header, footer } = resolveCaveHeaderFooter(cave, config, prefix);
461
528
  const lines = [];
@@ -479,7 +546,8 @@ async function buildQQCaveMarkdown(cave, config, fileManager, logger2, session,
479
546
  }
480
547
  let url = null;
481
548
  if (config.enableS3 && config.publicUrl) {
482
- url = new URL(fileName, config.publicUrl).href;
549
+ const base = config.publicUrl.endsWith("/") ? config.publicUrl : `${config.publicUrl}/`;
550
+ url = new URL(fileName, base).href;
483
551
  } else if (buffer) {
484
552
  try {
485
553
  url = await uploadQQImage(session, buffer, fileName);
@@ -491,8 +559,13 @@ async function buildQQCaveMarkdown(cave, config, fileManager, logger2, session,
491
559
  lines.push("[图片]");
492
560
  continue;
493
561
  }
494
- const size = buffer ? await getImageSize(buffer) : null;
495
- lines.push(size ? `![img #${size.width}px #${size.height}px](${url})` : `![img](${url})`);
562
+ const size = buffer ? getImageSize(buffer) : await getImageSizeFromUrl(session, url);
563
+ if (!size) {
564
+ logger2.warn(`无法获取图片 ${fileName} 的尺寸,QQ 端将显示为 [图片]`);
565
+ lines.push("[图片]");
566
+ continue;
567
+ }
568
+ lines.push(`![img #${size.width}px #${size.height}px](${url})`);
496
569
  } else if (el.type === "video") {
497
570
  lines.push("[视频]");
498
571
  } else if (el.type === "audio") {
@@ -988,7 +1061,7 @@ ${pendingCaves.map((c) => c.id).join("|")}`;
988
1061
  };
989
1062
 
990
1063
  // src/HashManager.ts
991
- var import_jimp2 = __toESM(require("jimp"));
1064
+ var import_jimp = __toESM(require("jimp"));
992
1065
  var crypto2 = __toESM(require("crypto"));
993
1066
  var HashManager = class {
994
1067
  /**
@@ -1278,8 +1351,8 @@ ${imageResult.reportLines.join("\n")}`;
1278
1351
  * @returns 64位十六进制 pHash 字符串。
1279
1352
  */
1280
1353
  async generatePHash(imageBuffer) {
1281
- const image = await import_jimp2.default.read(imageBuffer);
1282
- image.resize(32, 32, import_jimp2.default.RESIZE_BILINEAR).greyscale();
1354
+ const image = await import_jimp.default.read(imageBuffer);
1355
+ image.resize(32, 32, import_jimp.default.RESIZE_BILINEAR).greyscale();
1283
1356
  const matrix = Array.from({ length: 32 }, () => new Array(32).fill(0));
1284
1357
  image.scan(0, 0, 32, 32, (x, y, idx) => {
1285
1358
  matrix[y][x] = image.bitmap.data[idx];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@forgetmelody/koishi-plugin-best-cave",
3
3
  "description": "功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。",
4
- "version": "1.0.0",
4
+ "version": "1.0.1",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -113,6 +113,18 @@
113
113
  | `accessKeyId` | `string` | | **(S3 必填)** S3 访问密钥 ID。 |
114
114
  | `secretAccessKey` | `string` | | **(S3 必填)** S3 秘密访问密钥。 |
115
115
 
116
+ ## 🤖 QQ 官方机器人
117
+
118
+ 插件会自动识别 `qq` 平台的群聊消息,将回声洞改为 **Markdown 消息**(`msg_type: 2`)发送:
119
+
120
+ - **所有内容同一条消息**:页眉页脚、文字、@ 用户以及该回声洞的全部图片都内联在同一段 Markdown 中(`msg_type: 2` 请求的 `markdown` 与 `keyboard` 字段并列),不会再拆成多条。
121
+ - **图片带真实宽高**:图片按 `![img #宽px #高px](url)` 输出,并直接从文件头解析 PNG / JPEG / GIF / WebP 尺寸,移动端不会显示为 `[图片]` 占位。
122
+ - **S3 图片**:配置了 `enableS3` 和 `publicUrl` 时直接使用公网 URL;未配置 `publicUrl` 时自动上传到 QQ 素材库(单张 ≤10MB,支持 png/jpeg/gif/webp)。若 S3 读取失败但公网可访问,会改从 `publicUrl` 读取尺寸。
123
+ - **按钮**:消息底部附带「再来一个cave」按钮,点击后随机再抽一条,无需申请按钮模板 ID。
124
+ - **不支持内联的元素**:视频、音频、文件、合并转发会显示为 `[视频]`、`[音频]`、`[文件]`、`[合并转发]` 占位文本;多条删除在 QQ 下会逐条发送。
125
+
126
+ 非 QQ 平台仍使用原有消息格式。
127
+
116
128
  ## ⚠️ 注意事项
117
129
 
118
130
  1. **媒体发送方式**:插件会按以下优先级决定如何发送图片、视频等媒体文件: