koishi-plugin-cfmrmod 1.0.2 → 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,6 +1,12 @@
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
12
  const fetch = require('node-fetch');
@@ -130,6 +136,14 @@ function formatNumber(num) {
130
136
  return `${(n / 1e3).toFixed(1).replace('.0', '')}k`;
131
137
  return String(n);
132
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
+ }
133
147
  function parseCompactNumber(text) {
134
148
  if (!text)
135
149
  return null;
@@ -157,30 +171,45 @@ function fixUrl(url, base = '') {
157
171
  async function loadImageSafe(url, timeout = 15000) {
158
172
  if (!url)
159
173
  return null;
160
- const tryUrls = [url];
161
- if (url.includes('.webp')) {
162
- tryUrls.push(url.replace('.webp', '.png'));
163
- if (!url.includes('format='))
164
- tryUrls.push(`${url}${url.includes('?') ? '&' : '?'}format=png`);
165
- }
166
- let lastErr;
167
- for (const u of tryUrls) {
168
- try {
169
- return await loadImage(u);
170
- }
171
- catch (e) {
172
- lastErr = e;
173
- }
174
- }
174
+ // 1. 尝试直接加载 (保留 User-Agent 以防万一,同时检查 res.ok)
175
175
  try {
176
- 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}`);
177
184
  const buf = await res.buffer();
178
185
  return await loadImage(buf);
179
186
  }
180
- catch (e) {
181
- 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;
182
212
  }
183
- throw lastErr;
184
213
  }
185
214
  // 简单的 Markdown 转 HTML 配置
186
215
  marked.setOptions({ breaks: true, gfm: true });
@@ -788,7 +817,7 @@ async function drawProjectCard(data) {
788
817
  ctx.fillStyle = COLORS.textSec;
789
818
  ctx.font = `12px "${font}"`;
790
819
  ctx.textAlign = 'center';
791
- 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);
792
821
  footerY += 18;
793
822
  // 3. 绘制要求的作者署名
794
823
  ctx.fillText('Plugin By Mai_xiyu', width / 2, footerY);
@@ -1182,6 +1211,454 @@ async function drawProjectCardCF(data) {
1182
1211
  }
1183
1212
  return [canvas.toBuffer('image/png')];
1184
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
+ }
1185
1662
  // ================= API 交互 =================
1186
1663
  async function fetchModrinthDetail(id, timeout) {
1187
1664
  var _a, _b, _c, _d, _e, _f;
@@ -1241,7 +1718,7 @@ async function fetchModrinthDetail(id, timeout) {
1241
1718
  id: project.id,
1242
1719
  name: project.title,
1243
1720
  author,
1244
- 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),
1245
1722
  summary: project.description,
1246
1723
  body,
1247
1724
  bodyIsHtml,
package/dist/index.js CHANGED
@@ -38,14 +38,30 @@ 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';
42
- exports.inject = ['skia'];
43
+ exports.inject = ['skia', 'database'];
43
44
  exports.Config = koishi_1.Schema.object({
44
45
  prefixes: koishi_1.Schema.object({
45
46
  cf: koishi_1.Schema.string().default('cf'),
46
47
  mr: koishi_1.Schema.string().default('mr'),
47
48
  cnmc: koishi_1.Schema.string().default('cnmc'),
48
49
  }).description('指令前缀设置'),
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 搜索与图片卡片'),
@@ -62,4 +78,6 @@ function apply(ctx, config) {
62
78
  cfmr.apply(ctx, { ...((config === null || config === void 0 ? void 0 : config.cfmr) || {}), ...shared });
63
79
  if (mcmod.apply)
64
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) || {} });
65
83
  }
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.2",
3
+ "version": "1.0.3",
4
4
  "description": "Koishi 插件:搜索 CurseForge/Modrinth/MCMod 并渲染图片卡片",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -55,7 +55,7 @@
55
55
  "zh": "从 CurseForge/Modrinth/MCMod 搜索模组/整合包/光影等内容,并生成图片卡片。"
56
56
  },
57
57
  "service": {
58
- "required": ["skia"]
58
+ "required": ["skia", "database"]
59
59
  }
60
60
  }
61
61
  }