koishi-plugin-cfmrmod 1.0.1 → 1.0.3

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.
@@ -1,13 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Config = exports.name = void 0;
4
+ exports.drawProjectCard = drawProjectCard;
5
+ exports.drawProjectCardCF = drawProjectCardCF;
6
+ exports.drawProjectCardMRNotify = drawProjectCardMRNotify;
7
+ exports.drawProjectCardCFNotify = drawProjectCardCFNotify;
8
+ exports.fetchModrinthDetail = fetchModrinthDetail;
9
+ exports.fetchCurseForgeDetail = fetchCurseForgeDetail;
4
10
  exports.apply = apply;
5
11
  const { Schema, h } = require('koishi');
6
- // 【修复】这里添加了 Path2D 的引入
7
- const { createCanvas, loadImage, Path2D, GlobalFonts } = require('@napi-rs/canvas');
8
12
  const fetch = require('node-fetch');
9
13
  const cheerio = require('cheerio');
10
14
  const { marked } = require('marked');
15
+ let createCanvas;
16
+ let loadImage;
17
+ let Path2DRef;
18
+ let registerFont;
19
+ async function toImageSrc(input) {
20
+ const value = (input && typeof input.then === 'function') ? await input : input;
21
+ if (!value)
22
+ return '';
23
+ if (typeof value === 'string')
24
+ return value;
25
+ const buf = Buffer.isBuffer(value) ? value : (value instanceof Uint8Array ? Buffer.from(value) : null);
26
+ if (buf)
27
+ return `data:image/png;base64,${buf.toString('base64')}`;
28
+ return String(value);
29
+ }
11
30
  const CF_LOADER_MAP = {
12
31
  1: 'Forge',
13
32
  2: 'Cauldron',
@@ -117,6 +136,14 @@ function formatNumber(num) {
117
136
  return `${(n / 1e3).toFixed(1).replace('.0', '')}k`;
118
137
  return String(n);
119
138
  }
139
+ function formatFileSize(bytes) {
140
+ const n = Number(bytes) || 0;
141
+ if (n >= 1024 * 1024)
142
+ return `${(n / (1024 * 1024)).toFixed(2)} MiB`;
143
+ if (n >= 1024)
144
+ return `${(n / 1024).toFixed(2)} KiB`;
145
+ return `${n} B`;
146
+ }
120
147
  function parseCompactNumber(text) {
121
148
  if (!text)
122
149
  return null;
@@ -144,30 +171,45 @@ function fixUrl(url, base = '') {
144
171
  async function loadImageSafe(url, timeout = 15000) {
145
172
  if (!url)
146
173
  return null;
147
- const tryUrls = [url];
148
- if (url.includes('.webp')) {
149
- tryUrls.push(url.replace('.webp', '.png'));
150
- if (!url.includes('format='))
151
- tryUrls.push(`${url}${url.includes('?') ? '&' : '?'}format=png`);
152
- }
153
- let lastErr;
154
- for (const u of tryUrls) {
155
- try {
156
- return await loadImage(u);
157
- }
158
- catch (e) {
159
- lastErr = e;
160
- }
161
- }
174
+ // 1. 尝试直接加载 (保留 User-Agent 以防万一,同时检查 res.ok)
162
175
  try {
163
- const res = await fetchWithTimeout(tryUrls[0], {}, timeout);
176
+ const res = await fetchWithTimeout(url, {
177
+ headers: {
178
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
179
+ }
180
+ }, timeout);
181
+ // 如果返回 404/403 等非 2xx 状态,直接抛出异常进入 catch
182
+ if (!res.ok)
183
+ throw new Error(`HTTP ${res.status}`);
164
184
  const buf = await res.buffer();
165
185
  return await loadImage(buf);
166
186
  }
167
- catch (e) {
168
- lastErr = e;
187
+ catch (bufferErr) {
188
+ // 2. 加载失败 (如下载成功但 skia 无法解码 WebP),尝试备用链接
189
+ const tryUrls = []; // 清空之前的无效尝试
190
+ if (url.includes('.webp')) {
191
+ // 【关键修复】使用 wsrv.nl 代理将 WebP 转换为 PNG
192
+ tryUrls.push(`https://wsrv.nl/?url=${encodeURIComponent(url)}&output=png`);
193
+ }
194
+ else {
195
+ // 如果不是 webp 结尾,也尝试一下原链接 (应对网络波动)
196
+ tryUrls.push(url);
197
+ }
198
+ let lastErr = bufferErr;
199
+ for (const u of tryUrls) {
200
+ try {
201
+ // 备用链接也应当加 UA
202
+ // 注意:loadImage(string) 内部通常会自动处理 fetch,但为了稳妥这里利用 skia 的远程加载能力
203
+ // 或者也可以复用 fetchWithTimeout 下载 buffer,这里简化直接调 loadImage
204
+ return await loadImage(u);
205
+ }
206
+ catch (e) {
207
+ lastErr = e;
208
+ }
209
+ }
210
+ // 如果都失败了,抛出异常(外部会捕获并绘制灰底)
211
+ throw lastErr;
169
212
  }
170
- throw lastErr;
171
213
  }
172
214
  // 简单的 Markdown 转 HTML 配置
173
215
  marked.setOptions({ breaks: true, gfm: true });
@@ -560,6 +602,8 @@ async function drawProjectCard(data) {
560
602
  // Stats & Tags Row
561
603
  // Downloads Icon
562
604
  const drawIcon = (path, x, y) => {
605
+ if (!Path2DRef)
606
+ return;
563
607
  ctx.save();
564
608
  ctx.translate(x, y);
565
609
  ctx.scale(0.8, 0.8);
@@ -567,8 +611,7 @@ async function drawProjectCard(data) {
567
611
  ctx.lineWidth = 2;
568
612
  ctx.lineCap = 'round';
569
613
  ctx.lineJoin = 'round';
570
- // Path2D 需要引入
571
- const p = new Path2D(path);
614
+ const p = new Path2DRef(path);
572
615
  ctx.stroke(p);
573
616
  ctx.restore();
574
617
  };
@@ -774,7 +817,7 @@ async function drawProjectCard(data) {
774
817
  ctx.fillStyle = COLORS.textSec;
775
818
  ctx.font = `12px "${font}"`;
776
819
  ctx.textAlign = 'center';
777
- ctx.fillText('Generated by Koishi | Powered by Modrinth & CurseForge', width / 2, footerY);
820
+ ctx.fillText('Generated by Koishi | Powered by Modrinth & CurseForge | Plugin By Mai_xiyu', width / 2, footerY);
778
821
  footerY += 18;
779
822
  // 3. 绘制要求的作者署名
780
823
  ctx.fillText('Plugin By Mai_xiyu', width / 2, footerY);
@@ -1168,6 +1211,454 @@ async function drawProjectCardCF(data) {
1168
1211
  }
1169
1212
  return [canvas.toBuffer('image/png')];
1170
1213
  }
1214
+ // ================= 更新通知卡片(Modrinth) =================
1215
+ // ================= 更新通知卡片(Modrinth 还原版) =================
1216
+ async function drawProjectCardMRNotify(data, latest) {
1217
+ const width = 1000;
1218
+ const margin = 30; // 稍微增大边距
1219
+ const gap = 30;
1220
+ const font = GLOBAL_FONT_FAMILY;
1221
+ // Modrinth Dark Theme Colors (参考截图取色)
1222
+ const C_BG = '#131516'; // 整体深色背景 (接近黑色)
1223
+ const C_CARD = '#1a1c1d'; // 左侧内容背景 (稍微亮一点)
1224
+ const C_TEXT_MAIN = '#ffffff'; // 主标题白色
1225
+ const C_TEXT_SEC = '#9ca5b5'; // 次要文本 (Label 颜色)
1226
+ const C_DIVIDER = '#252729'; // 分割线
1227
+ const C_GREEN = '#1bd96a'; // Release Green
1228
+ const C_CHIP_BG = '#2c2d30'; // Version ID 背景
1229
+ const C_LINK = '#3d83f7'; // 链接色(备用)
1230
+ const iconSize = 80;
1231
+ const rightW = 300; // 侧边栏宽度
1232
+ const cardW = width - margin * 2;
1233
+ const leftW = cardW - rightW - gap;
1234
+ // 1. 高度计算
1235
+ const dummyC = createCanvas(100, 100);
1236
+ const dummy = dummyC.getContext('2d');
1237
+ // Header 高度
1238
+ const headerTextW = cardW - iconSize - 24;
1239
+ dummy.font = `800 32px "${font}"`;
1240
+ const titleH = wrapText(dummy, data.name || '', 0, 0, headerTextW, 40, 2, false);
1241
+ const headerH = Math.max(iconSize, titleH + 30) + 20;
1242
+ // Changelog 高度
1243
+ const changelogText = ((latest === null || latest === void 0 ? void 0 : latest.changelog) || '').trim() || 'No changelog provided.';
1244
+ dummy.font = `15px "${font}"`;
1245
+ const changelogBodyH = wrapText(dummy, changelogText, 0, 0, leftW - 48, 26, 30, false);
1246
+ const mainContentH = 60 + changelogBodyH + 40;
1247
+ // 侧边栏高度计算 (Metadata)
1248
+ // 固定项目:Release(50) + Version(50) + Loaders(50) + GameVer(50) + Env(70) + DLs(50) + Date(50) + Pub(70) + ID(60)
1249
+ // 估算大约 550px,如果内容多会自动延伸
1250
+ let sidebarH = 600;
1251
+ const bodyH = Math.max(sidebarH, mainContentH);
1252
+ const totalH = margin + headerH + 20 + bodyH + margin + 30;
1253
+ // 2. 绘制
1254
+ const canvas = createCanvas(width, totalH);
1255
+ const ctx = canvas.getContext('2d');
1256
+ // 背景
1257
+ ctx.fillStyle = C_BG;
1258
+ ctx.fillRect(0, 0, width, totalH);
1259
+ // === Header ===
1260
+ let cy = margin;
1261
+ if (data.icon) {
1262
+ try {
1263
+ const icon = await loadImageSafe(data.icon);
1264
+ ctx.save();
1265
+ roundRect(ctx, margin, cy, iconSize, iconSize, 16);
1266
+ ctx.clip();
1267
+ ctx.drawImage(icon, margin, cy, iconSize, iconSize);
1268
+ ctx.restore();
1269
+ }
1270
+ catch (e) {
1271
+ ctx.fillStyle = C_CARD;
1272
+ roundRect(ctx, margin, cy, iconSize, iconSize, 16);
1273
+ ctx.fill();
1274
+ }
1275
+ }
1276
+ const tx = margin + iconSize + 24;
1277
+ let ty = cy + 5;
1278
+ ctx.fillStyle = C_TEXT_MAIN;
1279
+ ctx.font = `800 32px "${font}"`;
1280
+ ty = wrapText(ctx, data.name || '', tx, ty, headerTextW, 40, 2, true) + 8;
1281
+ ctx.fillStyle = C_TEXT_SEC;
1282
+ ctx.font = `16px "${font}"`;
1283
+ // Header 下方显示简单的 By 信息
1284
+ ctx.fillText(`By ${data.author || 'Unknown'}`, tx, ty);
1285
+ // 分割线
1286
+ cy += headerH + 10;
1287
+ ctx.strokeStyle = C_DIVIDER;
1288
+ ctx.lineWidth = 1;
1289
+ ctx.beginPath();
1290
+ ctx.moveTo(margin, cy);
1291
+ ctx.lineTo(width - margin, cy);
1292
+ ctx.stroke();
1293
+ // === Content Area ===
1294
+ cy += 30;
1295
+ const leftX = margin;
1296
+ const rightX = margin + leftW + gap;
1297
+ // --- Left: Changelog ---
1298
+ ctx.fillStyle = C_CARD;
1299
+ roundRect(ctx, leftX, cy, leftW, bodyH, 12);
1300
+ ctx.fill();
1301
+ ctx.fillStyle = C_TEXT_MAIN;
1302
+ ctx.font = `700 22px "${font}"`;
1303
+ ctx.fillText('Changelog', leftX + 24, cy + 40);
1304
+ ctx.strokeStyle = C_DIVIDER;
1305
+ ctx.beginPath();
1306
+ ctx.moveTo(leftX + 24, cy + 60);
1307
+ ctx.lineTo(leftX + leftW - 24, cy + 60);
1308
+ ctx.stroke();
1309
+ ctx.fillStyle = '#b4b4b4'; // Changelog 文本稍微亮一点的灰
1310
+ ctx.font = `15px "${font}"`;
1311
+ wrapText(ctx, changelogText, leftX + 24, cy + 90, leftW - 48, 26, 30, true);
1312
+ // --- Right: Metadata Sidebar (还原截图风格) ---
1313
+ let ry = cy;
1314
+ // Title
1315
+ ctx.fillStyle = C_TEXT_MAIN;
1316
+ ctx.font = `800 22px "${font}"`;
1317
+ ctx.fillText('Metadata', rightX, ry + 10);
1318
+ ry += 40;
1319
+ // Helper: Draw Section Label
1320
+ const drawLabel = (text) => {
1321
+ ctx.fillStyle = C_TEXT_SEC;
1322
+ ctx.font = `700 14px "${font}"`;
1323
+ ctx.fillText(text, rightX, ry);
1324
+ ry += 24;
1325
+ };
1326
+ // Helper: Draw Value Text
1327
+ const drawValue = (text, color = C_TEXT_MAIN, isBold = false) => {
1328
+ ctx.fillStyle = color;
1329
+ ctx.font = `${isBold ? '700' : '500'} 16px "${font}"`;
1330
+ const h = wrapText(ctx, text, rightX, ry, rightW, 22, 2, true);
1331
+ ry = h + 16; // gap
1332
+ };
1333
+ // 1. Release channel
1334
+ drawLabel('Release channel');
1335
+ // Dot
1336
+ const channelType = (latest === null || latest === void 0 ? void 0 : latest.versionType) === 'beta' ? 'Beta' : ((latest === null || latest === void 0 ? void 0 : latest.versionType) === 'alpha' ? 'Alpha' : 'Release');
1337
+ const channelColor = (latest === null || latest === void 0 ? void 0 : latest.versionType) === 'beta' ? '#4695ee' : ((latest === null || latest === void 0 ? void 0 : latest.versionType) === 'alpha' ? '#f04747' : C_GREEN);
1338
+ ctx.beginPath();
1339
+ ctx.arc(rightX + 6, ry - 6, 5, 0, Math.PI * 2);
1340
+ ctx.fillStyle = channelColor;
1341
+ ctx.fill();
1342
+ ctx.fillStyle = channelColor;
1343
+ ctx.font = `700 16px "${font}"`;
1344
+ ctx.fillText(channelType, rightX + 18, ry);
1345
+ ry += 40;
1346
+ // 2. Version number
1347
+ drawLabel('Version number');
1348
+ drawValue((latest === null || latest === void 0 ? void 0 : latest.version) || (latest === null || latest === void 0 ? void 0 : latest.versionId) || '--');
1349
+ // 3. Loaders (With Icons)
1350
+ drawLabel('Loaders');
1351
+ const loaders = (latest === null || latest === void 0 ? void 0 : latest.loaders) || data.loaders || [];
1352
+ let lx = rightX;
1353
+ // 简易 Tag 图标 Path
1354
+ const tagPath = "M15.5 2H10.5C10.2 2 10 2.1 9.8 2.3L2.3 9.8C1.9 10.2 1.9 10.8 2.3 11.2L6.8 15.7C7.2 16.1 7.8 16.1 8.2 15.7L15.7 8.2C15.9 8 16 7.8 16 7.5V2.5C16 2.2 15.8 2 15.5 2ZM13.5 5C13.2 5 13 4.8 13 4.5C13 4.2 13.2 4 13.5 4C13.8 4 14 4.2 14 4.5C14 4.8 13.8 5 13.5 5Z";
1355
+ if (loaders.length === 0) {
1356
+ drawValue('--');
1357
+ }
1358
+ else {
1359
+ // 模拟 Flex 布局
1360
+ let loaderText = '';
1361
+ loaders.forEach((l, i) => {
1362
+ // 图标
1363
+ ctx.save();
1364
+ ctx.translate(lx, ry - 14);
1365
+ ctx.scale(0.9, 0.9);
1366
+ if (Path2DRef) {
1367
+ const p = new Path2DRef(tagPath);
1368
+ ctx.fillStyle = C_TEXT_SEC;
1369
+ ctx.fill(p);
1370
+ }
1371
+ else {
1372
+ // Fallback circle
1373
+ ctx.beginPath();
1374
+ ctx.arc(8, 8, 4, 0, Math.PI * 2);
1375
+ ctx.fill();
1376
+ }
1377
+ ctx.restore();
1378
+ // 文本
1379
+ ctx.fillStyle = '#b4b4b4';
1380
+ ctx.font = `500 15px "${font}"`;
1381
+ const text = l.charAt(0).toUpperCase() + l.slice(1);
1382
+ ctx.fillText(text, lx + 20, ry);
1383
+ const itemW = 20 + ctx.measureText(text).width + 15;
1384
+ lx += itemW;
1385
+ // 简单的换行处理
1386
+ if (lx > rightX + rightW - 50) {
1387
+ lx = rightX;
1388
+ ry += 24;
1389
+ }
1390
+ });
1391
+ ry += 40;
1392
+ }
1393
+ // 4. Game versions
1394
+ drawLabel('Game versions');
1395
+ const gv = ((latest === null || latest === void 0 ? void 0 : latest.gameVersions) || data.gameVersions || []).slice(0, 4).join(', ');
1396
+ drawValue(gv || 'All');
1397
+ // 5. Environment
1398
+ drawLabel('Environment');
1399
+ let envText = 'Client and server';
1400
+ if (data.clientSide === 'unsupported')
1401
+ envText = 'Server only';
1402
+ else if (data.serverSide === 'unsupported')
1403
+ envText = 'Client only';
1404
+ else if (data.clientSide === 'required' && data.serverSide === 'required')
1405
+ envText = 'Client and server, required on both';
1406
+ drawValue(envText);
1407
+ // 6. Downloads
1408
+ drawLabel('Downloads');
1409
+ drawValue(formatNumber((latest === null || latest === void 0 ? void 0 : latest.downloads) || 0));
1410
+ // 7. Publication date
1411
+ drawLabel('Publication date');
1412
+ const dateStr = (latest === null || latest === void 0 ? void 0 : latest.datePublished)
1413
+ ? new Date(latest.datePublished).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short' })
1414
+ : '--';
1415
+ drawValue(dateStr);
1416
+ // 8. Publisher (Avatar + Name)
1417
+ drawLabel('Publisher');
1418
+ const authorName = data.author || 'Unknown';
1419
+ const memberRole = 'Member'; // 默认写 Member,API 没返回具体 Role
1420
+ // Avatar
1421
+ const avatarR = 20;
1422
+ ctx.save();
1423
+ ctx.beginPath();
1424
+ ctx.arc(rightX + avatarR, ry + avatarR - 10, avatarR, 0, Math.PI * 2);
1425
+ ctx.fillStyle = C_CHIP_BG;
1426
+ ctx.fill();
1427
+ ctx.clip();
1428
+ if (data.authorIcon) {
1429
+ try {
1430
+ const authorImg = await loadImageSafe(data.authorIcon);
1431
+ ctx.drawImage(authorImg, rightX, ry - 10, avatarR * 2, avatarR * 2);
1432
+ }
1433
+ catch (e) { }
1434
+ }
1435
+ else {
1436
+ // Draw Initials
1437
+ ctx.fillStyle = C_TEXT_SEC;
1438
+ ctx.textAlign = 'center';
1439
+ ctx.textBaseline = 'middle';
1440
+ ctx.font = `bold 16px "${font}"`;
1441
+ ctx.fillText(authorName.charAt(0).toUpperCase(), rightX + avatarR, ry + avatarR - 10);
1442
+ }
1443
+ ctx.restore();
1444
+ // Name & Role
1445
+ ctx.textAlign = 'left';
1446
+ ctx.textBaseline = 'top';
1447
+ ctx.fillStyle = C_TEXT_MAIN;
1448
+ ctx.font = `700 15px "${font}"`;
1449
+ ctx.fillText(authorName, rightX + 50, ry - 5);
1450
+ ctx.fillStyle = C_TEXT_SEC;
1451
+ ctx.font = `13px "${font}"`;
1452
+ ctx.fillText(memberRole, rightX + 50, ry + 15);
1453
+ ry += 60;
1454
+ // 9. Version ID (Chip)
1455
+ drawLabel('Version ID');
1456
+ const verId = (latest === null || latest === void 0 ? void 0 : latest.versionId) || '-------';
1457
+ const chipPadding = 10;
1458
+ ctx.font = `14px "${font}"`; // Monospace ideally, but sans is fine
1459
+ const idW = ctx.measureText(verId).width + 30; // + space for icon
1460
+ ctx.fillStyle = C_CHIP_BG;
1461
+ roundRect(ctx, rightX, ry - 5, idW + chipPadding * 2, 32, 8);
1462
+ ctx.fill();
1463
+ ctx.fillStyle = '#b4b4b4';
1464
+ ctx.fillText(verId, rightX + chipPadding, ry + 5);
1465
+ // Copy Icon (simulated)
1466
+ const iconX = rightX + chipPadding + ctx.measureText(verId).width + 10;
1467
+ ctx.strokeStyle = '#b4b4b4';
1468
+ ctx.lineWidth = 1.5;
1469
+ ctx.strokeRect(iconX, ry + 4, 10, 12);
1470
+ ctx.fillStyle = '#b4b4b4';
1471
+ ctx.fillRect(iconX + 3, ry + 2, 8, 2); // top bit
1472
+ ry += 50;
1473
+ // Footer
1474
+ ctx.fillStyle = C_TEXT_SEC;
1475
+ ctx.font = `12px "${font}"`;
1476
+ ctx.textAlign = 'center';
1477
+ ctx.fillText('Powered by Modrinth | Generated by Koishi | Plugin By Mai_xiyu', width / 2, totalH - 15);
1478
+ return [canvas.toBuffer('image/png')];
1479
+ }
1480
+ // ================= 更新通知卡片(CurseForge) =================
1481
+ async function drawProjectCardCFNotify(data, latest) {
1482
+ const width = 1000;
1483
+ const margin = 24;
1484
+ const gap = 24;
1485
+ const font = GLOBAL_FONT_FAMILY;
1486
+ // CF Colors
1487
+ const C_BG = '#141414';
1488
+ const C_PANEL = '#1d1d1d';
1489
+ const C_TEXT = '#dee2e6';
1490
+ const C_TEXT_SEC = '#adb5bd';
1491
+ const C_ACCENT = '#f16436';
1492
+ const C_DIVIDER = '#2d2d2d';
1493
+ const iconSize = 80;
1494
+ const rightW = 320; // 加宽右侧
1495
+ const cardW = width - margin * 2;
1496
+ const leftW = cardW - rightW - gap;
1497
+ const dummyC = createCanvas(100, 100);
1498
+ const dummy = dummyC.getContext('2d');
1499
+ // --- 辅助:映射 Release Type ---
1500
+ const getReleaseTypeStr = (type) => {
1501
+ if (type === 1)
1502
+ return 'Release';
1503
+ if (type === 2)
1504
+ return 'Beta';
1505
+ if (type === 3)
1506
+ return 'Alpha';
1507
+ return String(type || 'Unknown');
1508
+ };
1509
+ const releaseTypeStr = getReleaseTypeStr(latest === null || latest === void 0 ? void 0 : latest.releaseType);
1510
+ // --- 1. 高度计算 ---
1511
+ const headerTextW = cardW - iconSize - 24;
1512
+ // Header: 标题自适应
1513
+ let titleFontSize = 32;
1514
+ dummy.font = `bold ${titleFontSize}px "${font}"`;
1515
+ while (dummy.measureText(data.name || '').width > headerTextW && titleFontSize > 22) {
1516
+ titleFontSize -= 2;
1517
+ dummy.font = `bold ${titleFontSize}px "${font}"`;
1518
+ }
1519
+ const titleH = wrapText(dummy, data.name || '', 0, 0, headerTextW, titleFontSize * 1.3, 2, false);
1520
+ dummy.font = `16px "${font}"`;
1521
+ const summaryH = wrapText(dummy, (data.summary || '').slice(0, 200), 0, 0, headerTextW, 24, 3, false);
1522
+ const headerH = Math.max(iconSize, titleH + summaryH + 15) + 20;
1523
+ // Sidebar Metadata
1524
+ const metaLines = [
1525
+ { l: 'New Version', v: (latest === null || latest === void 0 ? void 0 : latest.version) || '--', hl: true }, // Highlight
1526
+ { l: 'Downloads', v: formatNumber(latest === null || latest === void 0 ? void 0 : latest.downloads) },
1527
+ { l: 'Game Ver', v: ((latest === null || latest === void 0 ? void 0 : latest.gameVersions) || data.gameVersions || []).slice(0, 5).join(', ') || '--' },
1528
+ { l: 'Loaders', v: ((latest === null || latest === void 0 ? void 0 : latest.loaders) || data.loaders || []).slice(0, 4).join(', ') || '--' },
1529
+ { l: 'Updated', v: (latest === null || latest === void 0 ? void 0 : latest.datePublished) ? new Date(latest.datePublished).toLocaleDateString() : '--' },
1530
+ { l: 'Release Type', v: releaseTypeStr },
1531
+ { l: 'File', v: (latest === null || latest === void 0 ? void 0 : latest.fileName) || '--' },
1532
+ { l: 'Size', v: formatFileSize(latest === null || latest === void 0 ? void 0 : latest.fileSize) },
1533
+ { l: 'Author', v: data.author || '--' },
1534
+ ];
1535
+ // 计算 Sidebar 高度 (支持换行)
1536
+ let metaH = 60; // Title padding
1537
+ dummy.font = `15px "${font}"`;
1538
+ metaLines.forEach(item => {
1539
+ metaH += 20; // Label
1540
+ const valLines = wrapText(dummy, item.v, 0, 0, rightW - 40, 24, 5, false) / 24;
1541
+ metaH += valLines * 24 + 10; // Value + padding
1542
+ metaH += 10; // Divider
1543
+ });
1544
+ // Changelog Height
1545
+ const changelogText = ((latest === null || latest === void 0 ? void 0 : latest.changelog) || '').trim() || 'No changelog provided.';
1546
+ dummy.font = `15px "${font}"`;
1547
+ const changelogBodyH = wrapText(dummy, changelogText, 0, 0, leftW - 48, 26, 60, false);
1548
+ const contentH = 60 + changelogBodyH + 40;
1549
+ const bodyH = Math.max(metaH, contentH, 300);
1550
+ const totalH = margin + headerH + 20 + bodyH + margin + 30;
1551
+ // --- 2. 绘制 ---
1552
+ const canvas = createCanvas(width, totalH);
1553
+ const ctx = canvas.getContext('2d');
1554
+ ctx.fillStyle = C_BG;
1555
+ ctx.fillRect(0, 0, width, totalH);
1556
+ // === Header ===
1557
+ let cy = margin;
1558
+ if (data.icon) {
1559
+ try {
1560
+ const icon = await loadImageSafe(data.icon);
1561
+ ctx.save();
1562
+ ctx.shadowColor = 'rgba(0,0,0,0.5)';
1563
+ ctx.shadowBlur = 10;
1564
+ roundRect(ctx, margin, cy, iconSize, iconSize, 8);
1565
+ ctx.fill();
1566
+ ctx.shadowColor = 'transparent';
1567
+ roundRect(ctx, margin, cy, iconSize, iconSize, 8);
1568
+ ctx.clip();
1569
+ ctx.drawImage(icon, margin, cy, iconSize, iconSize);
1570
+ ctx.restore();
1571
+ }
1572
+ catch (e) {
1573
+ ctx.fillStyle = '#333';
1574
+ roundRect(ctx, margin, cy, iconSize, iconSize, 8);
1575
+ ctx.fill();
1576
+ }
1577
+ }
1578
+ const tx = margin + iconSize + 24;
1579
+ let ty = cy + 5;
1580
+ ctx.fillStyle = C_TEXT;
1581
+ ctx.font = `bold ${titleFontSize}px "${font}"`;
1582
+ ty = wrapText(ctx, data.name || '', tx, ty, headerTextW, titleFontSize * 1.3, 2, true) + 8;
1583
+ ctx.fillStyle = C_TEXT_SEC;
1584
+ ctx.font = `16px "${font}"`;
1585
+ wrapText(ctx, `By ${data.author || 'Unknown'}`, tx, ty, headerTextW, 24, 1, true);
1586
+ // Orange Tab Indicator
1587
+ cy += headerH + 10;
1588
+ ctx.fillStyle = C_ACCENT;
1589
+ ctx.fillRect(margin, cy, 100, 4); // 稍微长一点的指示条
1590
+ ctx.fillStyle = C_DIVIDER;
1591
+ ctx.fillRect(margin + 100, cy, width - margin * 2 - 100, 4);
1592
+ // === Body ===
1593
+ cy += 24;
1594
+ const leftX = margin;
1595
+ const rightX = margin + leftW + gap;
1596
+ // -- 左侧:Changelog --
1597
+ ctx.fillStyle = C_PANEL;
1598
+ roundRect(ctx, leftX, cy, leftW, bodyH, 8);
1599
+ ctx.fill();
1600
+ ctx.fillStyle = C_TEXT;
1601
+ ctx.font = `bold 22px "${font}"`;
1602
+ ctx.fillText('What\'s New', leftX + 24, cy + 40);
1603
+ ctx.fillStyle = C_DIVIDER;
1604
+ ctx.fillRect(leftX + 24, cy + 60, leftW - 48, 2);
1605
+ ctx.fillStyle = '#ced4da';
1606
+ ctx.font = `15px "${font}"`;
1607
+ wrapText(ctx, changelogText, leftX + 24, cy + 85, leftW - 48, 26, 60, true);
1608
+ // -- 右侧:Sidebar --
1609
+ ctx.fillStyle = C_PANEL;
1610
+ roundRect(ctx, rightX, cy, rightW, bodyH, 8);
1611
+ ctx.fill();
1612
+ let ry = cy + 40;
1613
+ const maxRy = cy + bodyH - 20;
1614
+ ctx.fillStyle = C_TEXT;
1615
+ ctx.font = `bold 18px "${font}"`;
1616
+ ctx.fillText('File Details', rightX + 20, ry);
1617
+ ry += 20;
1618
+ metaLines.forEach(item => {
1619
+ if (ry + 40 > maxRy) {
1620
+ ctx.fillStyle = C_TEXT_SEC;
1621
+ ctx.font = `12px "${font}"`;
1622
+ ctx.fillText('...', rightX + 20, maxRy - 6);
1623
+ return;
1624
+ }
1625
+ ry += 15;
1626
+ // Label
1627
+ ctx.fillStyle = C_TEXT_SEC;
1628
+ ctx.font = `13px "${font}"`;
1629
+ ctx.fillText(item.l, rightX + 20, ry);
1630
+ // Value (支持换行)
1631
+ ry += 22;
1632
+ ctx.font = `15px "${font}"`;
1633
+ // 特殊颜色处理
1634
+ if (item.hl)
1635
+ ctx.fillStyle = C_ACCENT;
1636
+ else if (item.l === 'Release Type') {
1637
+ if (item.v === 'Release')
1638
+ ctx.fillStyle = '#1bd96a'; // Green
1639
+ else if (item.v === 'Beta')
1640
+ ctx.fillStyle = '#a020f0'; // Purple
1641
+ else
1642
+ ctx.fillStyle = C_TEXT;
1643
+ }
1644
+ else {
1645
+ ctx.fillStyle = C_TEXT;
1646
+ }
1647
+ // 自动换行绘制
1648
+ const nextY = wrapText(ctx, item.v, rightX + 20, ry, rightW - 40, 24, 5, true);
1649
+ ry = nextY + 10;
1650
+ if (ry + 6 > maxRy)
1651
+ return;
1652
+ ctx.fillStyle = 'rgba(255,255,255,0.05)';
1653
+ ctx.fillRect(rightX + 20, ry, rightW - 40, 1);
1654
+ });
1655
+ // Footer
1656
+ ctx.fillStyle = C_TEXT_SEC;
1657
+ ctx.font = `12px "${font}"`;
1658
+ ctx.textAlign = 'center';
1659
+ ctx.fillText('Powered by CurseForge | Generated by Koishi | Plugin By Mai_xiyu', width / 2, totalH - 15);
1660
+ return [canvas.toBuffer('image/png')];
1661
+ }
1171
1662
  // ================= API 交互 =================
1172
1663
  async function fetchModrinthDetail(id, timeout) {
1173
1664
  var _a, _b, _c, _d, _e, _f;
@@ -1227,7 +1718,7 @@ async function fetchModrinthDetail(id, timeout) {
1227
1718
  id: project.id,
1228
1719
  name: project.title,
1229
1720
  author,
1230
- icon: (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.icon) || project.icon_url,
1721
+ icon: project.icon_url || (pageInfo === null || pageInfo === void 0 ? void 0 : pageInfo.icon),
1231
1722
  summary: project.description,
1232
1723
  body,
1233
1724
  bodyIsHtml,
@@ -1488,18 +1979,25 @@ async function searchCurseForge(query, type, apiKey, timeout, gameId = 432) {
1488
1979
  function apply(ctx, config) {
1489
1980
  var _a, _b;
1490
1981
  const logger = ctx.logger('mc-search');
1491
- if (config.fontPath) {
1492
- try {
1493
- const ok = GlobalFonts.registerFromPath(config.fontPath, 'KoishiFont');
1494
- if (ok)
1495
- GLOBAL_FONT_FAMILY = 'KoishiFont';
1496
- else
1497
- logger.warn('字体加载失败: registerFromPath 返回 false');
1498
- }
1499
- catch (e) {
1500
- logger.warn('字体加载失败: ' + e.message);
1501
- }
1982
+ const skia = ctx.skia;
1983
+ if (!(skia === null || skia === void 0 ? void 0 : skia.Canvas) || !(skia === null || skia === void 0 ? void 0 : skia.loadImage)) {
1984
+ throw new Error('缺少 skia 服务,请先启用 @ltxhhz/koishi-plugin-skia-canvas');
1502
1985
  }
1986
+ createCanvas = (w, h) => {
1987
+ const c = new skia.Canvas(w || 0, h || 0);
1988
+ if (!c || typeof c.getContext !== 'function') {
1989
+ throw new Error('skia 服务异常:Canvas 无效,请确认使用 @ltxhhz/koishi-plugin-skia-canvas');
1990
+ }
1991
+ return c;
1992
+ };
1993
+ loadImage = skia.loadImage;
1994
+ registerFont = (path, options) => {
1995
+ var _a;
1996
+ if ((_a = skia.FontLibrary) === null || _a === void 0 ? void 0 : _a.use)
1997
+ skia.FontLibrary.use(path, options === null || options === void 0 ? void 0 : options.family);
1998
+ };
1999
+ Path2DRef = skia.Path2D || globalThis.Path2D;
2000
+ // 取消自定义字体配置,使用 skia 默认字体
1503
2001
  const states = new Map();
1504
2002
  const normalizeMessageIds = (res) => {
1505
2003
  if (!res)
@@ -1564,7 +2062,7 @@ function apply(ctx, config) {
1564
2062
  maxCanvasHeight: config.maxCanvasHeight || 8000
1565
2063
  });
1566
2064
  for (const buf of imgBufs) {
1567
- await session.send(h.image(buf, 'image/png'));
2065
+ await session.send(h.image(await toImageSrc(buf)));
1568
2066
  }
1569
2067
  if (config.sendLink)
1570
2068
  await session.send(`链接: ${detailData.url}`);
@@ -1625,7 +2123,7 @@ function apply(ctx, config) {
1625
2123
  maxCanvasHeight: config.maxCanvasHeight || 8000
1626
2124
  });
1627
2125
  for (const buf of imgBufs) {
1628
- await session.send(h.image(buf, 'image/png'));
2126
+ await session.send(h.image(await toImageSrc(buf)));
1629
2127
  }
1630
2128
  if (config.sendLink)
1631
2129
  await session.send(`链接: ${detailData.url}`);
package/dist/index.js CHANGED
@@ -33,19 +33,35 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.Config = exports.name = void 0;
36
+ exports.Config = exports.inject = exports.name = void 0;
37
37
  exports.apply = apply;
38
38
  const koishi_1 = require("koishi");
39
39
  const cfmr = __importStar(require("./plugins/cfmr"));
40
40
  const mcmod = __importStar(require("./plugins/mcmod"));
41
+ const notify = __importStar(require("./notify"));
41
42
  exports.name = 'minecraft-search';
43
+ exports.inject = ['skia', 'database'];
42
44
  exports.Config = koishi_1.Schema.object({
43
45
  prefixes: koishi_1.Schema.object({
44
46
  cf: koishi_1.Schema.string().default('cf'),
45
47
  mr: koishi_1.Schema.string().default('mr'),
46
48
  cnmc: koishi_1.Schema.string().default('cnmc'),
47
49
  }).description('指令前缀设置'),
48
- fontPath: koishi_1.Schema.string().role('path').description('中文字体路径 (建议使用含中文和Emoji的字体)'),
50
+ notify: koishi_1.Schema.object({
51
+ enabled: koishi_1.Schema.boolean().default(false).description('是否开启模组更新通知'),
52
+ interval: koishi_1.Schema.number().default(30 * 60 * 1000).description('轮询间隔(ms),默认 30 分钟'),
53
+ adminAuthority: koishi_1.Schema.number().default(3).description('机器人管理员权限等级(默认 3)'),
54
+ stateFile: koishi_1.Schema.string().default('data/cfmrmod_notify_state.json').description('状态存储 JSON 路径(数据库不可用时使用)'),
55
+ groups: koishi_1.Schema.array(koishi_1.Schema.object({
56
+ channelId: koishi_1.Schema.string().description('群号/频道 ID'),
57
+ enabled: koishi_1.Schema.boolean().default(true).description('是否启用本群通知'),
58
+ subs: koishi_1.Schema.array(koishi_1.Schema.object({
59
+ platform: koishi_1.Schema.union(['mr', 'cf']).description('平台:mr/cf'),
60
+ projectId: koishi_1.Schema.string().description('项目 ID'),
61
+ interval: koishi_1.Schema.number().default(30 * 60 * 1000).description('单独轮询间隔(ms),默认 30 分钟,<= 0 禁用该订阅'),
62
+ })).role('table').default([]).description('订阅列表'),
63
+ })).role('table').default([]).description('通知群与订阅列表'),
64
+ }).description('—— 更新通知 ——'),
49
65
  timeouts: koishi_1.Schema.number().default(60000).description('搜索会话超时时间(ms)'),
50
66
  debug: koishi_1.Schema.boolean().default(false).description('开启调试日志'),
51
67
  cfmr: cfmr.Config.description('CurseForge/Modrinth 搜索与图片卡片'),
@@ -55,7 +71,6 @@ function apply(ctx, config) {
55
71
  const prefixes = (config === null || config === void 0 ? void 0 : config.prefixes) || {};
56
72
  const shared = {
57
73
  prefixes,
58
- fontPath: config === null || config === void 0 ? void 0 : config.fontPath,
59
74
  timeouts: config === null || config === void 0 ? void 0 : config.timeouts,
60
75
  debug: config === null || config === void 0 ? void 0 : config.debug,
61
76
  };
@@ -63,4 +78,6 @@ function apply(ctx, config) {
63
78
  cfmr.apply(ctx, { ...((config === null || config === void 0 ? void 0 : config.cfmr) || {}), ...shared });
64
79
  if (mcmod.apply)
65
80
  mcmod.apply(ctx, { ...((config === null || config === void 0 ? void 0 : config.mcmod) || {}), ...shared });
81
+ if (notify.apply)
82
+ notify.apply(ctx, (config === null || config === void 0 ? void 0 : config.notify) || {}, { cfmr: (config === null || config === void 0 ? void 0 : config.cfmr) || {} });
66
83
  }
@@ -6,8 +6,21 @@ const fetch = require('node-fetch');
6
6
  const cheerio = require('cheerio');
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
- const { createCanvas, loadImage, registerFont, GlobalFonts } = require('@napi-rs/canvas');
10
9
  const { h, Schema } = require('koishi');
10
+ let createCanvas;
11
+ let loadImage;
12
+ let registerFont;
13
+ async function toImageSrc(input) {
14
+ const value = (input && typeof input.then === 'function') ? await input : input;
15
+ if (!value)
16
+ return '';
17
+ if (typeof value === 'string')
18
+ return value;
19
+ const buf = Buffer.isBuffer(value) ? value : (value instanceof Uint8Array ? Buffer.from(value) : null);
20
+ if (buf)
21
+ return `data:image/png;base64,${buf.toString('base64')}`;
22
+ return String(value);
23
+ }
11
24
  // Cookie 管理器
12
25
  let cookieManager = null;
13
26
  try {
@@ -139,13 +152,14 @@ function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines = 1000, draw =
139
152
  return currentY + lineHeight;
140
153
  }
141
154
  // ================= 字体注册 =================
142
- function initFont(preferredPath, logger) {
155
+ function initFont(preferredPath, logger, registerFontFn) {
143
156
  const fontName = 'MCModFont';
144
157
  const tryRegister = (filePath, source) => {
145
158
  if (!fs.existsSync(filePath))
146
159
  return false;
147
160
  try {
148
- if (GlobalFonts.registerFromPath(filePath, fontName)) {
161
+ if (registerFontFn) {
162
+ registerFontFn(filePath, { family: fontName });
149
163
  GLOBAL_FONT_FAMILY = fontName;
150
164
  logger.info(`[Font] 成功加载${source}: ${filePath}`);
151
165
  return true;
@@ -2433,7 +2447,24 @@ exports.Config = Schema.object({
2433
2447
  function apply(ctx, config) {
2434
2448
  var _a;
2435
2449
  const logger = ctx.logger('mcmod');
2436
- if (!initFont(config.fontPath, logger)) { }
2450
+ const skia = ctx.skia;
2451
+ if (!(skia === null || skia === void 0 ? void 0 : skia.Canvas) || !(skia === null || skia === void 0 ? void 0 : skia.loadImage)) {
2452
+ throw new Error('缺少 skia 服务,请先启用 @ltxhhz/koishi-plugin-skia-canvas');
2453
+ }
2454
+ createCanvas = (w, h) => {
2455
+ const c = new skia.Canvas(w || 0, h || 0);
2456
+ if (!c || typeof c.getContext !== 'function') {
2457
+ throw new Error('skia 服务异常:Canvas 无效,请确认使用 @ltxhhz/koishi-plugin-skia-canvas');
2458
+ }
2459
+ return c;
2460
+ };
2461
+ loadImage = skia.loadImage;
2462
+ registerFont = (path, options) => {
2463
+ var _a;
2464
+ if ((_a = skia.FontLibrary) === null || _a === void 0 ? void 0 : _a.use)
2465
+ skia.FontLibrary.use(path, options === null || options === void 0 ? void 0 : options.family);
2466
+ };
2467
+ // 取消自定义字体配置,使用 skia 默认字体
2437
2468
  // 初始化 Cookie
2438
2469
  if (config.cookie) {
2439
2470
  globalCookie = config.cookie;
@@ -2563,7 +2594,7 @@ function apply(ctx, config) {
2563
2594
  img = await drawTutorialCard(item.link);
2564
2595
  else
2565
2596
  img = await createInfoCard(item.link, type);
2566
- await session.send(h.image(img, 'image/png'));
2597
+ await session.send(h.image(await toImageSrc(img)));
2567
2598
  if (config.sendLink)
2568
2599
  await session.send(`链接: ${item.link}`);
2569
2600
  return;
@@ -2612,7 +2643,7 @@ function apply(ctx, config) {
2612
2643
  const item = results[0];
2613
2644
  await ensureValidCookie();
2614
2645
  const img = await drawModCard(item.link);
2615
- await session.send(h.image(img, 'image/png'));
2646
+ await session.send(h.image(await toImageSrc(img)));
2616
2647
  if (config.sendLink)
2617
2648
  await session.send(`链接: ${item.link}`);
2618
2649
  return;
@@ -2716,7 +2747,7 @@ function apply(ctx, config) {
2716
2747
  img = await drawTutorialCard(item.link);
2717
2748
  else
2718
2749
  img = await createInfoCard(item.link, currentState.type);
2719
- await session.send(h.image(img, 'image/png'));
2750
+ await session.send(h.image(await toImageSrc(img)));
2720
2751
  if (config.sendLink)
2721
2752
  await session.send(`链接: ${item.link}`);
2722
2753
  }
package/dist/notify.js ADDED
@@ -0,0 +1,529 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.apply = apply;
7
+ const koishi_1 = require("koishi");
8
+ const fs_1 = require("fs");
9
+ const path_1 = __importDefault(require("path"));
10
+ const cfmr_1 = require("./cfmr");
11
+ const fetch = require('node-fetch');
12
+ const MR_BASE = 'https://api.modrinth.com/v2';
13
+ const CF_MIRROR_BASE = 'https://api.curse.tools/v1/cf';
14
+ function normalizePlatform(platform) {
15
+ if (platform === 'mr' || platform === 'cf')
16
+ return platform;
17
+ return null;
18
+ }
19
+ async function toImageSrc(input) {
20
+ const value = (input && typeof input.then === 'function') ? await input : input;
21
+ if (!value)
22
+ return '';
23
+ if (typeof value === 'string')
24
+ return value;
25
+ const buf = Buffer.isBuffer(value) ? value : (value instanceof Uint8Array ? Buffer.from(value) : null);
26
+ if (buf)
27
+ return `data:image/png;base64,${buf.toString('base64')}`;
28
+ return String(value);
29
+ }
30
+ async function fetchJson(url, timeout = 15000) {
31
+ const controller = new AbortController();
32
+ const id = setTimeout(() => controller.abort(), timeout);
33
+ try {
34
+ const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json' }, signal: controller.signal });
35
+ if (!res.ok)
36
+ throw new Error(`HTTP ${res.status}`);
37
+ return await res.json();
38
+ }
39
+ finally {
40
+ clearTimeout(id);
41
+ }
42
+ }
43
+ function apply(ctx, config, options) {
44
+ const logger = ctx.logger('cfmr-notify');
45
+ ctx.model.extend('cfmrmod_notify_sub', {
46
+ id: 'unsigned',
47
+ channelId: 'string',
48
+ platform: 'string',
49
+ projectId: 'string',
50
+ lastVersion: 'string',
51
+ lastNotifiedAt: 'timestamp',
52
+ }, { primary: 'id', autoInc: true });
53
+ const getRoleLevel = (session) => {
54
+ var _a, _b, _c, _d, _e;
55
+ const roles = new Set();
56
+ const list = (_a = session === null || session === void 0 ? void 0 : session.member) === null || _a === void 0 ? void 0 : _a.roles;
57
+ if (Array.isArray(list)) {
58
+ list.forEach((r) => {
59
+ if (typeof r === 'string')
60
+ roles.add(r);
61
+ else if (r && typeof r === 'object') {
62
+ if (typeof r.id === 'string')
63
+ roles.add(r.id);
64
+ if (typeof r.name === 'string')
65
+ roles.add(r.name);
66
+ }
67
+ });
68
+ }
69
+ const role = (_b = session === null || session === void 0 ? void 0 : session.member) === null || _b === void 0 ? void 0 : _b.role;
70
+ if (typeof role === 'string')
71
+ roles.add(role);
72
+ const onebotRole = (_d = (_c = session === null || session === void 0 ? void 0 : session.event) === null || _c === void 0 ? void 0 : _c.sender) === null || _d === void 0 ? void 0 : _d.role;
73
+ if (typeof onebotRole === 'string')
74
+ roles.add(onebotRole);
75
+ const eventMember = (_e = session === null || session === void 0 ? void 0 : session.event) === null || _e === void 0 ? void 0 : _e.member;
76
+ if ((eventMember === null || eventMember === void 0 ? void 0 : eventMember.role) && typeof eventMember.role === 'string')
77
+ roles.add(eventMember.role);
78
+ if (Array.isArray(eventMember === null || eventMember === void 0 ? void 0 : eventMember.roles)) {
79
+ eventMember.roles.forEach((r) => {
80
+ if (typeof r === 'string')
81
+ roles.add(r);
82
+ else if (r && typeof r === 'object') {
83
+ if (typeof r.id === 'string')
84
+ roles.add(r.id);
85
+ if (typeof r.name === 'string')
86
+ roles.add(r.name);
87
+ }
88
+ });
89
+ }
90
+ if (roles.has('owner'))
91
+ return 3;
92
+ if (roles.has('admin'))
93
+ return 2;
94
+ if (roles.has('member'))
95
+ return 1;
96
+ return 0;
97
+ };
98
+ const isOwner = (session) => getRoleLevel(session) >= 3;
99
+ const isAdmin = (session) => getRoleLevel(session) >= 2;
100
+ const getRoleLevelAsync = async (session) => {
101
+ let level = getRoleLevel(session);
102
+ if (level > 0)
103
+ return level;
104
+ const bot = session === null || session === void 0 ? void 0 : session.bot;
105
+ if ((bot === null || bot === void 0 ? void 0 : bot.getGuildMember) && (session === null || session === void 0 ? void 0 : session.guildId) && (session === null || session === void 0 ? void 0 : session.userId)) {
106
+ try {
107
+ const member = await bot.getGuildMember(session.guildId, session.userId);
108
+ const roles = new Set();
109
+ if ((member === null || member === void 0 ? void 0 : member.role) && typeof member.role === 'string')
110
+ roles.add(member.role);
111
+ if (Array.isArray(member === null || member === void 0 ? void 0 : member.roles)) {
112
+ member.roles.forEach((r) => {
113
+ if (typeof r === 'string')
114
+ roles.add(r);
115
+ else if (r && typeof r === 'object') {
116
+ if (typeof r.id === 'string')
117
+ roles.add(r.id);
118
+ if (typeof r.name === 'string')
119
+ roles.add(r.name);
120
+ }
121
+ });
122
+ }
123
+ if (roles.has('owner'))
124
+ level = 3;
125
+ else if (roles.has('admin'))
126
+ level = 2;
127
+ else if (roles.has('member'))
128
+ level = 1;
129
+ }
130
+ catch { }
131
+ }
132
+ return level;
133
+ };
134
+ const requireManage = async (session, channelId) => {
135
+ var _a, _b, _c, _d, _e;
136
+ const level = Number((_a = config.adminAuthority) !== null && _a !== void 0 ? _a : 3);
137
+ if (level <= 1)
138
+ return true;
139
+ if (channelId && channelId !== session.channelId)
140
+ return false;
141
+ const roleLevel = await getRoleLevelAsync(session);
142
+ if (level <= 2)
143
+ return roleLevel >= 2;
144
+ const ok = roleLevel >= 3;
145
+ if (!ok) {
146
+ logger.info(`权限不足调试:level=${level}, role=${(_b = session === null || session === void 0 ? void 0 : session.member) === null || _b === void 0 ? void 0 : _b.role}, roles=${JSON.stringify((_c = session === null || session === void 0 ? void 0 : session.member) === null || _c === void 0 ? void 0 : _c.roles)}, onebotRole=${(_e = (_d = session === null || session === void 0 ? void 0 : session.event) === null || _d === void 0 ? void 0 : _d.sender) === null || _e === void 0 ? void 0 : _e.role}`);
147
+ }
148
+ return ok;
149
+ };
150
+ const parseChannelId = (channelId) => {
151
+ const idx = channelId.indexOf(':');
152
+ if (idx <= 0)
153
+ return null;
154
+ return { platform: channelId.slice(0, idx), id: channelId.slice(idx + 1) };
155
+ };
156
+ const sendToChannel = async (channelId, content) => {
157
+ const parsed = parseChannelId(channelId);
158
+ if (parsed) {
159
+ const bot = ctx.bots.find(b => b.platform === parsed.platform);
160
+ if (bot) {
161
+ await bot.sendMessage(parsed.id, content);
162
+ return true;
163
+ }
164
+ }
165
+ if (ctx.bots.length === 1) {
166
+ await ctx.bots[0].sendMessage(channelId, content);
167
+ return true;
168
+ }
169
+ if (ctx.bots.length) {
170
+ for (const bot of ctx.bots) {
171
+ try {
172
+ await bot.sendMessage(channelId, content);
173
+ return true;
174
+ }
175
+ catch { }
176
+ }
177
+ }
178
+ logger.warn(`无法发送到频道 ${channelId},请使用 platform:channelId 格式。`);
179
+ return false;
180
+ };
181
+ const lastCheckMap = new Map();
182
+ const stateCache = new Map();
183
+ let stateLoaded = false;
184
+ let saving = false;
185
+ let dbWarned = false;
186
+ const getStateKey = (channelId, platform, projectId) => {
187
+ return `${channelId}|${platform}|${projectId}`;
188
+ };
189
+ const resolveStateFile = () => {
190
+ const p = String(config.stateFile || 'data/cfmrmod_notify_state.json');
191
+ return path_1.default.isAbsolute(p) ? p : path_1.default.resolve(process.cwd(), p);
192
+ };
193
+ const loadStateFromFile = async () => {
194
+ if (stateLoaded)
195
+ return;
196
+ stateLoaded = true;
197
+ try {
198
+ const filePath = resolveStateFile();
199
+ const content = await fs_1.promises.readFile(filePath, 'utf8');
200
+ const json = JSON.parse(content);
201
+ if (json && typeof json === 'object') {
202
+ Object.keys(json).forEach(key => {
203
+ const val = json[key];
204
+ if (val && typeof val === 'object')
205
+ stateCache.set(key, { lastVersion: val.lastVersion });
206
+ });
207
+ }
208
+ }
209
+ catch { }
210
+ };
211
+ const saveStateToFile = async () => {
212
+ if (saving)
213
+ return;
214
+ saving = true;
215
+ try {
216
+ const filePath = resolveStateFile();
217
+ await fs_1.promises.mkdir(path_1.default.dirname(filePath), { recursive: true });
218
+ const obj = {};
219
+ for (const [key, val] of stateCache.entries())
220
+ obj[key] = { lastVersion: val.lastVersion };
221
+ await fs_1.promises.writeFile(filePath, JSON.stringify(obj, null, 2), 'utf8');
222
+ }
223
+ catch (e) {
224
+ logger.warn(`状态文件写入失败:${e.message}`);
225
+ }
226
+ finally {
227
+ saving = false;
228
+ }
229
+ };
230
+ const getState = async (channelId, platform, projectId) => {
231
+ await loadStateFromFile();
232
+ const key = getStateKey(channelId, platform, projectId);
233
+ return stateCache.get(key) || null;
234
+ };
235
+ const createState = async (channelId, platform, projectId, lastVersion) => {
236
+ await loadStateFromFile();
237
+ const key = getStateKey(channelId, platform, projectId);
238
+ stateCache.set(key, { lastVersion });
239
+ await saveStateToFile();
240
+ };
241
+ const updateState = async (channelId, platform, projectId, lastVersion) => {
242
+ await loadStateFromFile();
243
+ const key = getStateKey(channelId, platform, projectId);
244
+ stateCache.set(key, { lastVersion });
245
+ await saveStateToFile();
246
+ };
247
+ const getConfigGroups = () => Array.isArray(config.groups) ? config.groups : [];
248
+ const getConfigSubs = (channelId) => {
249
+ const groups = getConfigGroups();
250
+ const subs = [];
251
+ for (const group of groups) {
252
+ if (!(group === null || group === void 0 ? void 0 : group.channelId))
253
+ continue;
254
+ if (channelId && group.channelId !== channelId)
255
+ continue;
256
+ if (group.enabled === false)
257
+ continue;
258
+ const list = Array.isArray(group.subs) ? group.subs : [];
259
+ for (const sub of list) {
260
+ const platformKey = normalizePlatform(sub === null || sub === void 0 ? void 0 : sub.platform);
261
+ const projectId = String((sub === null || sub === void 0 ? void 0 : sub.projectId) || '').trim();
262
+ if (!platformKey || !projectId)
263
+ continue;
264
+ const rawInterval = Number(sub === null || sub === void 0 ? void 0 : sub.interval);
265
+ if (Number.isFinite(rawInterval) && rawInterval <= 0)
266
+ continue;
267
+ const interval = Math.max(60 * 1000, rawInterval || Number(config.interval) || 30 * 60 * 1000);
268
+ subs.push({ channelId: String(group.channelId), platform: platformKey, projectId, interval });
269
+ }
270
+ }
271
+ return subs;
272
+ };
273
+ const getConfigSubsOrdered = (channelId) => {
274
+ const groups = getConfigGroups();
275
+ const subs = [];
276
+ for (const group of groups) {
277
+ if (!(group === null || group === void 0 ? void 0 : group.channelId))
278
+ continue;
279
+ if (channelId && group.channelId !== channelId)
280
+ continue;
281
+ if (group.enabled === false)
282
+ continue;
283
+ const list = Array.isArray(group.subs) ? group.subs : [];
284
+ for (const sub of list) {
285
+ const platformKey = normalizePlatform(sub === null || sub === void 0 ? void 0 : sub.platform);
286
+ const projectId = String((sub === null || sub === void 0 ? void 0 : sub.projectId) || '').trim();
287
+ if (!platformKey || !projectId)
288
+ continue;
289
+ const rawInterval = Number(sub === null || sub === void 0 ? void 0 : sub.interval);
290
+ if (Number.isFinite(rawInterval) && rawInterval <= 0)
291
+ continue;
292
+ const interval = Math.max(60 * 1000, rawInterval || Number(config.interval) || 30 * 60 * 1000);
293
+ subs.push({ channelId: String(group.channelId), platform: platformKey, projectId, interval });
294
+ }
295
+ }
296
+ return subs;
297
+ };
298
+ async function getLatestModrinth(projectId, timeout) {
299
+ const versions = await fetchJson(`${MR_BASE}/project/${projectId}/version`, timeout);
300
+ const latest = Array.isArray(versions) ? versions[0] : null;
301
+ if (!latest)
302
+ return null;
303
+ const file = Array.isArray(latest.files) && latest.files.length ? latest.files[0] : null;
304
+ return {
305
+ versionId: latest.id,
306
+ version: latest.version_number || latest.name || latest.id,
307
+ changelog: latest.changelog || '',
308
+ downloads: latest.downloads,
309
+ datePublished: latest.date_published,
310
+ versionType: latest.version_type,
311
+ loaders: Array.isArray(latest.loaders) ? latest.loaders.map(String) : [],
312
+ gameVersions: Array.isArray(latest.game_versions) ? latest.game_versions.map(String) : [],
313
+ fileName: (file === null || file === void 0 ? void 0 : file.filename) || '',
314
+ fileSize: (file === null || file === void 0 ? void 0 : file.size) || 0,
315
+ };
316
+ }
317
+ async function getLatestCurseForge(projectId, timeout) {
318
+ var _a;
319
+ const files = await fetchJson(`${CF_MIRROR_BASE}/mods/${projectId}/files?index=0&pageSize=1`, timeout);
320
+ const latest = (_a = files === null || files === void 0 ? void 0 : files.data) === null || _a === void 0 ? void 0 : _a[0];
321
+ if (!latest)
322
+ return null;
323
+ return {
324
+ versionId: String(latest.id),
325
+ version: latest.displayName || latest.fileName || String(latest.id),
326
+ changelog: latest.changelog || '',
327
+ downloads: latest.downloadCount,
328
+ datePublished: latest.fileDate || null,
329
+ releaseType: latest.releaseType,
330
+ loaders: Array.isArray(latest.gameVersions) ? latest.gameVersions.filter((v) => /forge|fabric|quilt|neoforge/i.test(String(v))) : [],
331
+ gameVersions: Array.isArray(latest.gameVersions) ? latest.gameVersions.filter((v) => /\d/.test(String(v))) : [],
332
+ fileName: latest.fileName || '',
333
+ fileSize: latest.fileLength || 0,
334
+ };
335
+ }
336
+ async function sendUpdate(channelId, platform, projectId, latest) {
337
+ var _a, _b, _c;
338
+ try {
339
+ let detailData;
340
+ if (platform === 'mr')
341
+ detailData = await (0, cfmr_1.fetchModrinthDetail)(projectId, ((_a = options === null || options === void 0 ? void 0 : options.cfmr) === null || _a === void 0 ? void 0 : _a.requestTimeout) || 15000);
342
+ else
343
+ detailData = await (0, cfmr_1.fetchCurseForgeDetail)(projectId, (_b = options === null || options === void 0 ? void 0 : options.cfmr) === null || _b === void 0 ? void 0 : _b.curseforgeApiKey, ((_c = options === null || options === void 0 ? void 0 : options.cfmr) === null || _c === void 0 ? void 0 : _c.requestTimeout) || 15000, null);
344
+ detailData.type = 'mod';
345
+ const imgBufs = detailData.source === 'CurseForge'
346
+ ? await (0, cfmr_1.drawProjectCardCFNotify)({ ...detailData }, latest)
347
+ : await (0, cfmr_1.drawProjectCardMRNotify)({ ...detailData }, latest);
348
+ for (const buf of imgBufs) {
349
+ const src = await toImageSrc(buf);
350
+ await sendToChannel(channelId, koishi_1.h.image(src));
351
+ }
352
+ // 仅发送卡片,不发送文字
353
+ }
354
+ catch (e) {
355
+ logger.warn(`发送通知失败(${platform}:${projectId}): ${e.message}`);
356
+ }
357
+ }
358
+ async function checkOnce(channelId, force = false) {
359
+ var _a;
360
+ if (!config.enabled)
361
+ return;
362
+ const subs = getConfigSubs(channelId);
363
+ const stats = { checked: 0, updated: 0, noChange: 0, skipped: 0, failed: 0 };
364
+ for (const sub of subs) {
365
+ try {
366
+ const key = `${sub.channelId}|${sub.platform}|${sub.projectId}`;
367
+ const lastCheck = lastCheckMap.get(key) || 0;
368
+ if (!force && Date.now() - lastCheck < sub.interval) {
369
+ stats.skipped += 1;
370
+ continue;
371
+ }
372
+ lastCheckMap.set(key, Date.now());
373
+ stats.checked += 1;
374
+ const timeout = ((_a = options === null || options === void 0 ? void 0 : options.cfmr) === null || _a === void 0 ? void 0 : _a.requestTimeout) || 15000;
375
+ const latest = sub.platform === 'mr'
376
+ ? await getLatestModrinth(sub.projectId, timeout)
377
+ : await getLatestCurseForge(sub.projectId, timeout);
378
+ if (!latest) {
379
+ stats.failed += 1;
380
+ continue;
381
+ }
382
+ const state = await getState(sub.channelId, sub.platform, sub.projectId);
383
+ if (!state) {
384
+ await createState(sub.channelId, sub.platform, sub.projectId, latest.version || '');
385
+ stats.noChange += 1;
386
+ continue;
387
+ }
388
+ if (!state.lastVersion) {
389
+ await updateState(sub.channelId, sub.platform, sub.projectId, latest.version);
390
+ stats.noChange += 1;
391
+ continue;
392
+ }
393
+ if (latest.version === state.lastVersion) {
394
+ stats.noChange += 1;
395
+ continue;
396
+ }
397
+ await sendUpdate(sub.channelId, sub.platform, sub.projectId, latest);
398
+ await updateState(sub.channelId, sub.platform, sub.projectId, latest.version);
399
+ stats.updated += 1;
400
+ }
401
+ catch (e) {
402
+ logger.warn(`检查失败(${sub.platform}:${sub.projectId}): ${e.message}`);
403
+ stats.failed += 1;
404
+ }
405
+ }
406
+ return stats;
407
+ }
408
+ const checkOne = async (sub, forceSendAll) => {
409
+ var _a;
410
+ const timeout = ((_a = options === null || options === void 0 ? void 0 : options.cfmr) === null || _a === void 0 ? void 0 : _a.requestTimeout) || 15000;
411
+ const latest = sub.platform === 'mr'
412
+ ? await getLatestModrinth(sub.projectId, timeout)
413
+ : await getLatestCurseForge(sub.projectId, timeout);
414
+ if (!latest)
415
+ return { sent: false, updated: false };
416
+ const state = await getState(sub.channelId, sub.platform, sub.projectId);
417
+ if (!state) {
418
+ await createState(sub.channelId, sub.platform, sub.projectId, latest.version || '');
419
+ if (forceSendAll) {
420
+ await sendUpdate(sub.channelId, sub.platform, sub.projectId, latest);
421
+ await updateState(sub.channelId, sub.platform, sub.projectId, latest.version || '');
422
+ return { sent: true, updated: true };
423
+ }
424
+ return { sent: false, updated: false };
425
+ }
426
+ if (forceSendAll) {
427
+ await sendUpdate(sub.channelId, sub.platform, sub.projectId, latest);
428
+ await updateState(sub.channelId, sub.platform, sub.projectId, latest.version || '');
429
+ return { sent: true, updated: true };
430
+ }
431
+ if (!state.lastVersion) {
432
+ await updateState(sub.channelId, sub.platform, sub.projectId, latest.version || '');
433
+ return { sent: false, updated: false };
434
+ }
435
+ if (latest.version === state.lastVersion)
436
+ return { sent: false, updated: false };
437
+ await sendUpdate(sub.channelId, sub.platform, sub.projectId, latest);
438
+ await updateState(sub.channelId, sub.platform, sub.projectId, latest.version || '');
439
+ return { sent: true, updated: true };
440
+ };
441
+ if (config.enabled) {
442
+ const tick = Math.max(60 * 1000, Number(config.interval) || 30 * 60 * 1000);
443
+ ctx.setInterval(() => checkOnce().catch(() => null), tick);
444
+ }
445
+ ctx.command('notify.add <platform> <projectId> [channelId]', '添加更新订阅')
446
+ .action(async ({ session }, platform, projectId, channelId) => {
447
+ if (!platform || !projectId)
448
+ return '参数不足。';
449
+ const targetChannel = channelId || session.channelId;
450
+ if (!targetChannel)
451
+ return '只能在群聊使用或指定 channelId。';
452
+ if (!await requireManage(session, channelId))
453
+ return '权限不足。';
454
+ return '请在配置页面的 notify.groups 中编辑订阅列表。';
455
+ });
456
+ ctx.command('notify.remove <platform> <projectId> [channelId]', '删除更新订阅')
457
+ .action(async ({ session }, platform, projectId, channelId) => {
458
+ if (!platform || !projectId)
459
+ return '参数不足。';
460
+ const targetChannel = channelId || session.channelId;
461
+ if (!targetChannel)
462
+ return '只能在群聊使用或指定 channelId。';
463
+ if (!await requireManage(session, channelId))
464
+ return '权限不足。';
465
+ return '请在配置页面的 notify.groups 中编辑订阅列表。';
466
+ });
467
+ ctx.command('notify.list [channelId]', '列出订阅')
468
+ .action(async ({ session }, channelId) => {
469
+ const targetChannel = channelId || session.channelId;
470
+ if (!targetChannel)
471
+ return '只能在群聊使用或指定 channelId。';
472
+ if (!await requireManage(session, channelId))
473
+ return '权限不足。';
474
+ const subs = getConfigSubs(targetChannel);
475
+ if (!subs.length)
476
+ return '暂无订阅。';
477
+ return subs.map(s => `- ${s.platform}:${s.projectId} (${Math.round(s.interval / 60000)} 分钟)`).join('\n');
478
+ });
479
+ ctx.command('notify.enable <onoff> [channelId]', '启用/禁用本群通知')
480
+ .action(async ({ session }, onoff, channelId) => {
481
+ const targetChannel = channelId || session.channelId;
482
+ if (!targetChannel)
483
+ return '只能在群聊使用或指定 channelId。';
484
+ if (!await requireManage(session, channelId))
485
+ return '权限不足。';
486
+ return '请在配置页面的 notify.groups 中编辑 enabled。';
487
+ });
488
+ ctx.command('notify.check [arg]', '手动检查更新')
489
+ .option('broadcast', '-b 直接发送最新版卡片(忽略是否更新)')
490
+ .action(async ({ session, options }, arg) => {
491
+ const targetChannel = session.channelId;
492
+ if (!targetChannel)
493
+ return '只能在群聊使用或指定 channelId。';
494
+ if (!await requireManage(session))
495
+ return '权限不足。';
496
+ const list = getConfigSubsOrdered(targetChannel);
497
+ if (!list.length)
498
+ return '暂无订阅。';
499
+ let targets = list;
500
+ if (arg) {
501
+ const idx = Number(arg);
502
+ if (Number.isFinite(idx) && idx > 0) {
503
+ const sub = list[idx - 1];
504
+ if (!sub)
505
+ return '未找到对应序号的订阅。';
506
+ targets = [sub];
507
+ }
508
+ else {
509
+ const sub = list.find(s => s.projectId === String(arg));
510
+ if (!sub)
511
+ return '未找到对应项目 ID 的订阅。';
512
+ targets = [sub];
513
+ }
514
+ }
515
+ let sent = 0;
516
+ for (const sub of targets) {
517
+ try {
518
+ const res = await checkOne(sub, !!(options === null || options === void 0 ? void 0 : options.broadcast));
519
+ if (res.sent)
520
+ sent += 1;
521
+ }
522
+ catch (e) {
523
+ logger.warn(`检查失败(${sub.platform}:${sub.projectId}): ${e.message}`);
524
+ }
525
+ }
526
+ if (!sent)
527
+ return '暂无更新。';
528
+ });
529
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-cfmrmod",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Koishi 插件:搜索 CurseForge/Modrinth/MCMod 并渲染图片卡片",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -39,10 +39,10 @@
39
39
  "pub": "npm run build && npm publish --access public"
40
40
  },
41
41
  "peerDependencies": {
42
- "koishi": "^4.0.0"
42
+ "koishi": "^4.0.0",
43
+ "@ltxhhz/koishi-plugin-skia-canvas": "^0.0.10"
43
44
  },
44
45
  "dependencies": {
45
- "@napi-rs/canvas": "^0.1.55",
46
46
  "cheerio": "^1.0.0-rc.12",
47
47
  "marked": "^9.1.5",
48
48
  "node-fetch": "^2.6.12"
@@ -53,6 +53,9 @@
53
53
  "koishi": {
54
54
  "description": {
55
55
  "zh": "从 CurseForge/Modrinth/MCMod 搜索模组/整合包/光影等内容,并生成图片卡片。"
56
+ },
57
+ "service": {
58
+ "required": ["skia", "database"]
56
59
  }
57
60
  }
58
61
  }