koishi-plugin-my-pig-group-friends 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,15 +2,33 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/koishi-plugin-my-pig-group-friends?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-my-pig-group-friends)
4
4
 
5
- 猪醒 - 虚拟旅行打卡插件,让你的猪猪群友环游世界。
5
+ **猪醒** - 虚拟旅行打卡插件,让你的猪猪群友环游世界。
6
6
 
7
- ## 功能特点
7
+ > 你群里有没有那种作息混乱、日夜颠倒的猪猪朋友?现在,每当他们在奇怪的时间冒泡,就送他们去世界各地"旅行"吧!
8
8
 
9
- - **虚拟旅行打卡**:随机(或通过 LLM)生成全球旅行地点,生成精美毛玻璃效果足迹卡片
10
- - **作息异常检测(实验性)**:自动检测用户每日首条消息,根据当地日出时间判断作息是否异常并触发旅行
11
- - **高质量背景**:集成 Unsplash API 获取目的地真实风景图
12
- - **AI 生图支持**:支持通过 media-luna 插件生成小猪在当地旅行的 AI 插画
13
- - **存储集成**:深度集成 chatluna-storage-service 进行图片管理
9
+ ## 功能亮点
10
+
11
+ - **虚拟旅行打卡** - 生成精美毛玻璃效果足迹卡片,记录猪猪的环球之旅
12
+ - **智能日出选点** - LLM 会根据当前时间,选择正在迎接日出的地区作为目的地(毕竟是"猪醒"嘛)
13
+ - **高质量风景图** - 集成 Unsplash / Pexels API,获取目的地真实风景照片
14
+ - **AI 生图支持** - 可选生成小猪在当地旅行的 AI 插画
15
+ - **作息异常检测(实验性)** - 自动检测用户每日首条消息时间,判断作息是否异常
16
+
17
+ ## 效果预览
18
+
19
+ 当触发旅行时,会生成类似这样的卡片:
20
+
21
+ ```
22
+ ┌─────────────────────────────┐
23
+ │ [目的地风景照片背景] │
24
+ │ │
25
+ │ 🐷 xxx 猪醒! │
26
+ │ │
27
+ │ 去了 冰岛蓝湖温泉,冰岛! │
28
+ │ │
29
+ │ 2024-01-15 06:32 │
30
+ └─────────────────────────────┘
31
+ ```
14
32
 
15
33
  ## 安装
16
34
 
@@ -22,40 +40,86 @@ npm install koishi-plugin-my-pig-group-friends
22
40
 
23
41
  ### 必需
24
42
  - `database` - 存储用户状态和旅行日志
25
- - `cron` - 定时清理和报表生成
26
- - `puppeteer` - 生成精美卡片
43
+ - `cron` - 定时清理任务
44
+ - `puppeteer` - 渲染精美卡片
27
45
 
28
46
  ### 可选
29
- - `chatluna` - 用于 LLM 动态生成旅行地点
30
- - `chatluna_storage` - 用于高效管理和分发生成的图片卡片
31
- - `media-luna` - 用于 AI 绘图功能
47
+ - `chatluna` - LLM 动态生成旅行地点(推荐)
48
+ - `chatluna_storage` - 高效管理图片缓存
49
+ - `media-luna` - AI 绘图功能
32
50
 
33
51
  ## 使用方法
34
52
 
35
53
  ### 指令
36
54
 
37
- - `pig [user]` - 触发一次虚拟旅行。如果不指定用户,则对自己生效。
38
- - `猪醒 [user]` - `pig` 指令的别名。
55
+ | 指令 | 说明 |
56
+ |------|------|
57
+ | `pig` | 送自己去旅行 |
58
+ | `pig @某人` | 送指定用户去旅行 |
59
+ | `猪醒` | `pig` 的别名 |
60
+
61
+ ### 示例
62
+
63
+ ```
64
+ > 猪醒 @张三
65
+ 🐷 张三 猪醒!去了 挪威罗弗敦群岛,挪威!
66
+ [精美卡片图片]
67
+ ```
39
68
 
40
- ## 配置项
69
+ ## 配置说明
41
70
 
42
71
  ### 基础设置
43
- - `outputMode`: 输出模式(image/text)。
44
- - `travelMessageTemplate`: 旅行消息模板。
72
+
73
+ | 配置项 | 默认值 | 说明 |
74
+ |--------|--------|------|
75
+ | `outputMode` | `image` | 输出模式:`image` 生成卡片,`text` 纯文本 |
76
+ | `travelMessageTemplate` | `去了 {landmark},{country}!` | 旅行消息模板 |
45
77
 
46
78
  ### 地点与图片
47
- - `llmLocationEnabled`: 是否启用 LLM 动态生成地点。
48
- - `llmLocationModel`: 生成地点的 LLM 模型(推荐使用轻量快速模型)。
49
- - `unsplashAccessKey`: Unsplash API 访问密钥。
79
+
80
+ | 配置项 | 默认值 | 说明 |
81
+ |--------|--------|------|
82
+ | `llmLocationEnabled` | `false` | 启用 LLM 动态生成地点 |
83
+ | `llmLocationModel` | - | LLM 模型(推荐 gemini-flash 等快速模型) |
84
+ | `llmLocationCustomContext` | - | 自定义偏好(如:北欧风格、赛博朋克建筑等) |
85
+ | `imageSearchPrompt` | `{landmark} {country} landscape` | 图片搜索关键词模板 |
86
+ | `unsplashAccessKey` | - | Unsplash API 密钥 |
87
+ | `pexelsApiKey` | - | Pexels API 密钥(备用图源) |
50
88
 
51
89
  ### AI 生图(可选)
52
- - `aigcEnabled`: 是否启用 AI 绘图。
53
- - `aigcChannel`: media-luna 绘图渠道。
54
- - `aigcPrompt`: 绘图提示词模板。
90
+
91
+ | 配置项 | 默认值 | 说明 |
92
+ |--------|--------|------|
93
+ | `aigcEnabled` | `false` | 启用 AI 绘图 |
94
+ | `aigcChannel` | - | media-luna 绘图渠道 |
95
+ | `aigcPrompt` | `一个可爱的卡通小猪正在...` | 绘图提示词模板 |
55
96
 
56
97
  ### 自动检测(实验性)
57
- - `experimentalAutoDetect`: 是否启用自动作息异常检测。
58
- - `abnormalThreshold`: 异常判定阈值(小时)。
98
+
99
+ | 配置项 | 默认值 | 说明 |
100
+ |--------|--------|------|
101
+ | `experimentalAutoDetect` | `false` | 自动检测作息异常 |
102
+ | `abnormalThreshold` | `3` | 异常判定阈值(小时) |
103
+
104
+ ### 存储设置
105
+
106
+ | 配置项 | 默认值 | 说明 |
107
+ |--------|--------|------|
108
+ | `useStorageService` | `true` | 使用存储服务缓存图片 |
109
+ | `storageCacheHours` | `24` | 图片缓存时间(小时) |
110
+ | `logRetentionDays` | `45` | 旅行记录保留天数 |
111
+
112
+ ## 获取 API 密钥
113
+
114
+ ### Unsplash(推荐)
115
+ 1. 访问 [Unsplash Developers](https://unsplash.com/developers)
116
+ 2. 创建应用,获取 Access Key
117
+ 3. 免费额度:50 次/小时
118
+
119
+ ### Pexels(备用)
120
+ 1. 访问 [Pexels API](https://www.pexels.com/api/)
121
+ 2. 注册并获取 API Key
122
+ 3. 免费额度:200 次/小时
59
123
 
60
124
  ## License
61
125
 
package/lib/config.d.ts CHANGED
@@ -21,6 +21,7 @@ export interface Config {
21
21
  backgroundFetchMode: 'auto' | 'always' | 'never';
22
22
  backgroundFetchTimeoutMs: number;
23
23
  logRetentionDays: number;
24
+ monthlySummaryEnabled: boolean;
24
25
  experimentalAutoDetect: boolean;
25
26
  debug: boolean;
26
27
  }
package/lib/database.d.ts CHANGED
@@ -20,7 +20,10 @@ export interface PigTravelLog {
20
20
  platform: string;
21
21
  timestamp: Date;
22
22
  country: string;
23
+ countryZh: string;
23
24
  location: string;
25
+ locationZh: string;
26
+ timezone: string;
24
27
  imagePath: string;
25
28
  isAIGC: boolean;
26
29
  }
package/lib/index.js CHANGED
@@ -45,7 +45,10 @@ function applyDatabase(ctx) {
45
45
  platform: "string",
46
46
  timestamp: "timestamp",
47
47
  country: "string",
48
+ countryZh: "string",
48
49
  location: "string",
50
+ locationZh: "string",
51
+ timezone: "string",
49
52
  imagePath: "string",
50
53
  isAIGC: "boolean"
51
54
  }, { primary: "id", autoInc: true });
@@ -146,8 +149,8 @@ async function generateFootprintCard(ctx, config, data, userInfo, platform, back
146
149
  },
147
150
  redirect: "follow"
148
151
  });
149
- const contentType = response.headers.get("content-type") || "";
150
- const contentLength = response.headers.get("content-length") || "unknown";
152
+ const contentType = response.headers?.["content-type"] || "";
153
+ const contentLength = response.headers?.["content-length"] || "unknown";
151
154
  if (config.debug) {
152
155
  ctx.logger("pig").debug(
153
156
  `Background fetch response: status=${response.status} url=${response.url} content-type=${contentType || "unknown"} content-length=${contentLength}`
@@ -1057,7 +1060,8 @@ async function searchUnsplashPhoto(ctx, accessKey, query, debug = false) {
1057
1060
  {
1058
1061
  params: {
1059
1062
  query,
1060
- per_page: 1,
1063
+ per_page: 10,
1064
+ // Fetch more results for variety
1061
1065
  orientation: "landscape"
1062
1066
  },
1063
1067
  headers: {
@@ -1068,13 +1072,15 @@ async function searchUnsplashPhoto(ctx, accessKey, query, debug = false) {
1068
1072
  }
1069
1073
  );
1070
1074
  if (response.results && response.results.length > 0) {
1071
- let photoUrl = response.results[0].urls.regular;
1075
+ const randomIndex = Math.floor(Math.random() * response.results.length);
1076
+ const selectedPhoto = response.results[randomIndex];
1077
+ let photoUrl = selectedPhoto.urls.regular;
1072
1078
  if (photoUrl.includes("?")) {
1073
1079
  photoUrl += "&fm=webp&q=85";
1074
1080
  } else {
1075
1081
  photoUrl += "?fm=webp&q=85";
1076
1082
  }
1077
- if (debug) ctx.logger("pig").debug(`Unsplash: Found photo URL: ${photoUrl}`);
1083
+ if (debug) ctx.logger("pig").debug(`Unsplash: Selected photo ${randomIndex + 1}/${response.results.length}, URL: ${photoUrl}`);
1078
1084
  return photoUrl;
1079
1085
  }
1080
1086
  if (debug) ctx.logger("pig").debug("Unsplash: No photos found for query");
@@ -1100,7 +1106,8 @@ async function searchPexelsPhoto(ctx, apiKey, query, debug = false) {
1100
1106
  {
1101
1107
  params: {
1102
1108
  query,
1103
- per_page: 1,
1109
+ per_page: 10,
1110
+ // Fetch more results for variety
1104
1111
  orientation: "landscape"
1105
1112
  },
1106
1113
  headers: {
@@ -1110,7 +1117,9 @@ async function searchPexelsPhoto(ctx, apiKey, query, debug = false) {
1110
1117
  }
1111
1118
  );
1112
1119
  if (response.photos && response.photos.length > 0) {
1113
- let photoUrl = response.photos[0].src.large2x || response.photos[0].src.large;
1120
+ const randomIndex = Math.floor(Math.random() * response.photos.length);
1121
+ const selectedPhoto = response.photos[randomIndex];
1122
+ let photoUrl = selectedPhoto.src.large2x || selectedPhoto.src.large;
1114
1123
  if (photoUrl.includes("w=")) {
1115
1124
  photoUrl = photoUrl.replace(/w=\d+/, "w=1080");
1116
1125
  } else if (photoUrl.includes("?")) {
@@ -1118,7 +1127,7 @@ async function searchPexelsPhoto(ctx, apiKey, query, debug = false) {
1118
1127
  } else {
1119
1128
  photoUrl += "?w=1080";
1120
1129
  }
1121
- if (debug) ctx.logger("pig").debug(`Pexels: Found photo URL: ${photoUrl}`);
1130
+ if (debug) ctx.logger("pig").debug(`Pexels: Selected photo ${randomIndex + 1}/${response.photos.length}, URL: ${photoUrl}`);
1122
1131
  return photoUrl;
1123
1132
  }
1124
1133
  if (debug) ctx.logger("pig").debug("Pexels: No photos found for query");
@@ -1397,7 +1406,10 @@ async function triggerTravelSequence(ctx, config, userInfo, platform) {
1397
1406
  platform,
1398
1407
  timestamp: now,
1399
1408
  country: location.country,
1409
+ countryZh: location.countryZh || location.country,
1400
1410
  location: location.landmark,
1411
+ locationZh: location.landmarkZh || location.landmark,
1412
+ timezone: location.timezone || "UTC",
1401
1413
  imagePath: imageUrl || "",
1402
1414
  // 存储 URL 或空
1403
1415
  isAIGC
@@ -1412,6 +1424,630 @@ async function triggerTravelSequence(ctx, config, userInfo, platform) {
1412
1424
  }
1413
1425
  __name(triggerTravelSequence, "triggerTravelSequence");
1414
1426
 
1427
+ // src/services/summary.ts
1428
+ async function getMonthlyLogs(ctx, userId, platform, year, month) {
1429
+ const startDate = new Date(year, month - 1, 1);
1430
+ const endDate = new Date(year, month, 1);
1431
+ const logs = await ctx.database.get("pig_travel_log", {
1432
+ userId,
1433
+ platform,
1434
+ timestamp: {
1435
+ $gte: startDate,
1436
+ $lt: endDate
1437
+ }
1438
+ });
1439
+ logs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1440
+ return logs;
1441
+ }
1442
+ __name(getMonthlyLogs, "getMonthlyLogs");
1443
+ async function getUsersWithLogsInMonth(ctx, year, month) {
1444
+ const startDate = new Date(year, month - 1, 1);
1445
+ const endDate = new Date(year, month, 1);
1446
+ const logs = await ctx.database.get("pig_travel_log", {
1447
+ timestamp: {
1448
+ $gte: startDate,
1449
+ $lt: endDate
1450
+ }
1451
+ });
1452
+ const userMap = /* @__PURE__ */ new Map();
1453
+ for (const log of logs) {
1454
+ const key = `${log.platform}:${log.userId}`;
1455
+ if (!userMap.has(key)) {
1456
+ userMap.set(key, { userId: log.userId, platform: log.platform });
1457
+ }
1458
+ }
1459
+ return Array.from(userMap.values());
1460
+ }
1461
+ __name(getUsersWithLogsInMonth, "getUsersWithLogsInMonth");
1462
+ function formatTimezone(timezone) {
1463
+ if (!timezone || timezone === "UTC") return "UTC";
1464
+ try {
1465
+ const now = /* @__PURE__ */ new Date();
1466
+ const formatter = new Intl.DateTimeFormat("en-US", {
1467
+ timeZone: timezone,
1468
+ timeZoneName: "shortOffset"
1469
+ });
1470
+ const parts = formatter.formatToParts(now);
1471
+ const tzPart = parts.find((p) => p.type === "timeZoneName");
1472
+ if (tzPart) {
1473
+ return tzPart.value.replace("GMT", "UTC");
1474
+ }
1475
+ } catch {
1476
+ }
1477
+ if (timezone.startsWith("UTC")) return timezone;
1478
+ return "UTC";
1479
+ }
1480
+ __name(formatTimezone, "formatTimezone");
1481
+ async function generateMonthlySummaryCard(ctx, config, data) {
1482
+ const { year, month, logs, username, totalTrips, countriesVisited, locationsVisited } = data;
1483
+ let { avatarUrl } = data;
1484
+ if (avatarUrl && avatarUrl.startsWith("http")) {
1485
+ try {
1486
+ if (config.debug) ctx.logger("pig").debug(`Fetching avatar: ${avatarUrl}`);
1487
+ const response = await ctx.http(avatarUrl, {
1488
+ responseType: "arraybuffer",
1489
+ timeout: 5e3,
1490
+ headers: {
1491
+ "User-Agent": "Mozilla/5.0",
1492
+ "Accept": "image/*"
1493
+ }
1494
+ });
1495
+ const buffer = Buffer.from(response.data);
1496
+ const contentType = response.headers?.["content-type"] || "image/jpeg";
1497
+ avatarUrl = `data:${contentType};base64,${buffer.toString("base64")}`;
1498
+ if (config.debug) ctx.logger("pig").debug(`Avatar fetched successfully, size: ${buffer.length}`);
1499
+ } catch (e) {
1500
+ if (config.debug) ctx.logger("pig").warn(`Failed to fetch avatar: ${e}`);
1501
+ avatarUrl = "";
1502
+ }
1503
+ }
1504
+ const monthNames = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
1505
+ const monthName = monthNames[month - 1];
1506
+ const tripsHtml = logs.slice(0, 12).map((log, index) => {
1507
+ const date = new Date(log.timestamp);
1508
+ const dayStr = `${date.getMonth() + 1}/${date.getDate()}`;
1509
+ const tz = formatTimezone(log.timezone);
1510
+ return `
1511
+ <div class="trip-item">
1512
+ <div class="trip-index">${index + 1}</div>
1513
+ <div class="trip-info">
1514
+ <div class="trip-location">${escapeHtml2(log.locationZh || log.location)}</div>
1515
+ <div class="trip-country">${escapeHtml2(log.countryZh || log.country)} · ${dayStr}</div>
1516
+ <div class="trip-location-en">${escapeHtml2(log.location)}</div>
1517
+ </div>
1518
+ <div class="trip-tz">${tz}</div>
1519
+ </div>
1520
+ `;
1521
+ }).join("");
1522
+ const moreTripsHtml = logs.length > 12 ? `<div class="more-trips">... 还有 ${logs.length - 12} 次旅行</div>` : "";
1523
+ const statsHtml = `
1524
+ <div class="stats-grid">
1525
+ <div class="stat-item bg-yellow">
1526
+ <div class="stat-value">${totalTrips}</div>
1527
+ <div class="stat-label">次旅行</div>
1528
+ <div class="deco-dot"></div>
1529
+ </div>
1530
+ <div class="stat-item bg-pink">
1531
+ <div class="stat-value">${countriesVisited.length}</div>
1532
+ <div class="stat-label">个国家</div>
1533
+ <div class="deco-line"></div>
1534
+ </div>
1535
+ <div class="stat-item bg-cyan">
1536
+ <div class="stat-value">${locationsVisited.length}</div>
1537
+ <div class="stat-label">个地点</div>
1538
+ <div class="deco-triangle"></div>
1539
+ </div>
1540
+ </div>
1541
+ `;
1542
+ const html = `
1543
+ <!DOCTYPE html>
1544
+ <html>
1545
+ <head>
1546
+ <meta charset="UTF-8">
1547
+ <style>
1548
+ @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700;900&display=swap');
1549
+
1550
+ * {
1551
+ margin: 0;
1552
+ padding: 0;
1553
+ box-sizing: border-box;
1554
+ }
1555
+
1556
+ body {
1557
+ width: 1080px;
1558
+ min-height: 1920px;
1559
+ font-family: "Noto Sans SC", sans-serif, "Noto Color Emoji";
1560
+ background-color: #F0F0F0;
1561
+ background-image:
1562
+ radial-gradient(#000 10%, transparent 11%),
1563
+ radial-gradient(#000 10%, transparent 11%);
1564
+ background-size: 30px 30px;
1565
+ background-position: 0 0, 15px 15px;
1566
+ background-color: #FFDEE9;
1567
+ padding: 60px;
1568
+ }
1569
+
1570
+ .twemoji {
1571
+ font-family: "Twemoji", "Noto Color Emoji", sans-serif;
1572
+ }
1573
+
1574
+ .container {
1575
+ background: #fff;
1576
+ border: 4px solid #000;
1577
+ box-shadow: 20px 20px 0 #000;
1578
+ padding: 60px;
1579
+ position: relative;
1580
+ overflow: hidden;
1581
+ }
1582
+
1583
+ /* Memphis decorative elements */
1584
+ .deco-shape-1 {
1585
+ position: absolute;
1586
+ top: -20px;
1587
+ right: -20px;
1588
+ width: 150px;
1589
+ height: 150px;
1590
+ background: #FFD700;
1591
+ border: 4px solid #000;
1592
+ border-radius: 50%;
1593
+ z-index: 0;
1594
+ }
1595
+
1596
+ .deco-shape-2 {
1597
+ position: absolute;
1598
+ bottom: 40px;
1599
+ left: -30px;
1600
+ width: 100px;
1601
+ height: 100px;
1602
+ background: #00CED1;
1603
+ border: 4px solid #000;
1604
+ transform: rotate(45deg);
1605
+ z-index: 0;
1606
+ }
1607
+
1608
+ .header {
1609
+ display: flex;
1610
+ align-items: center;
1611
+ gap: 32px;
1612
+ margin-bottom: 48px;
1613
+ position: relative;
1614
+ z-index: 1;
1615
+ background: #fff;
1616
+ border: 4px solid #000;
1617
+ padding: 24px;
1618
+ box-shadow: 8px 8px 0 #000;
1619
+ }
1620
+
1621
+ .avatar-ring {
1622
+ padding: 0;
1623
+ border: 4px solid #000;
1624
+ border-radius: 50%;
1625
+ overflow: hidden;
1626
+ background: #FF69B4;
1627
+ }
1628
+
1629
+ .avatar {
1630
+ width: 120px;
1631
+ height: 120px;
1632
+ object-fit: cover;
1633
+ display: block;
1634
+ }
1635
+
1636
+ .user-info {
1637
+ flex: 1;
1638
+ }
1639
+
1640
+ .username {
1641
+ font-size: 48px;
1642
+ font-weight: 900;
1643
+ color: #000;
1644
+ margin-bottom: 8px;
1645
+ text-transform: uppercase;
1646
+ letter-spacing: -1px;
1647
+ }
1648
+
1649
+ .period {
1650
+ font-size: 28px;
1651
+ color: #000;
1652
+ font-weight: 700;
1653
+ background: #00CED1;
1654
+ display: inline-block;
1655
+ padding: 4px 12px;
1656
+ border: 3px solid #000;
1657
+ box-shadow: 4px 4px 0 #000;
1658
+ }
1659
+
1660
+ .title-section {
1661
+ text-align: center;
1662
+ margin-bottom: 48px;
1663
+ position: relative;
1664
+ z-index: 1;
1665
+ border-bottom: 4px solid #000;
1666
+ padding-bottom: 24px;
1667
+ }
1668
+
1669
+ .title {
1670
+ font-size: 80px;
1671
+ font-weight: 900;
1672
+ color: #000;
1673
+ margin-bottom: 16px;
1674
+ text-shadow: 6px 6px 0 #FF69B4;
1675
+ letter-spacing: 4px;
1676
+ }
1677
+
1678
+ .subtitle {
1679
+ font-size: 32px;
1680
+ color: #000;
1681
+ font-weight: 700;
1682
+ background: #FFD700;
1683
+ display: inline-block;
1684
+ padding: 8px 24px;
1685
+ border: 3px solid #000;
1686
+ box-shadow: 6px 6px 0 #000;
1687
+ transform: rotate(-2deg);
1688
+ }
1689
+
1690
+ .stats-grid {
1691
+ display: flex;
1692
+ justify-content: space-between;
1693
+ margin-bottom: 48px;
1694
+ gap: 24px;
1695
+ position: relative;
1696
+ z-index: 1;
1697
+ }
1698
+
1699
+ .stat-item {
1700
+ text-align: center;
1701
+ flex: 1;
1702
+ padding: 32px 16px;
1703
+ border: 4px solid #000;
1704
+ box-shadow: 12px 12px 0 #000;
1705
+ position: relative;
1706
+ overflow: hidden;
1707
+ }
1708
+
1709
+ .bg-yellow { background: #FFD700; }
1710
+ .bg-pink { background: #FF69B4; }
1711
+ .bg-cyan { background: #00CED1; }
1712
+
1713
+ .stat-value {
1714
+ font-size: 72px;
1715
+ font-weight: 900;
1716
+ color: #000;
1717
+ line-height: 1;
1718
+ position: relative;
1719
+ z-index: 2;
1720
+ }
1721
+
1722
+ .stat-label {
1723
+ font-size: 24px;
1724
+ color: #000;
1725
+ margin-top: 8px;
1726
+ font-weight: 700;
1727
+ text-transform: uppercase;
1728
+ position: relative;
1729
+ z-index: 2;
1730
+ }
1731
+
1732
+ .trips-section {
1733
+ margin-bottom: 32px;
1734
+ position: relative;
1735
+ z-index: 1;
1736
+ }
1737
+
1738
+ .section-title {
1739
+ font-size: 36px;
1740
+ font-weight: 900;
1741
+ color: #000;
1742
+ margin-bottom: 24px;
1743
+ padding: 12px 24px;
1744
+ border: 4px solid #000;
1745
+ display: inline-block;
1746
+ background: #fff;
1747
+ box-shadow: 8px 8px 0 #000;
1748
+ }
1749
+
1750
+ .trips-list {
1751
+ display: flex;
1752
+ flex-direction: column;
1753
+ gap: 20px;
1754
+ }
1755
+
1756
+ .trip-item {
1757
+ display: flex;
1758
+ align-items: center;
1759
+ gap: 20px;
1760
+ padding: 20px 24px;
1761
+ background: #fff;
1762
+ border: 3px solid #000;
1763
+ box-shadow: 8px 8px 0 #000;
1764
+ transition: transform 0.2s;
1765
+ position: relative;
1766
+ overflow: hidden;
1767
+ }
1768
+
1769
+ .trip-item:nth-child(odd) {
1770
+ transform: rotate(0.5deg);
1771
+ }
1772
+
1773
+ .trip-item:nth-child(even) {
1774
+ transform: rotate(-0.5deg);
1775
+ }
1776
+
1777
+ .trip-index {
1778
+ width: 48px;
1779
+ height: 48px;
1780
+ background: #000;
1781
+ color: #fff;
1782
+ border-radius: 0;
1783
+ display: flex;
1784
+ align-items: center;
1785
+ justify-content: center;
1786
+ font-size: 24px;
1787
+ font-weight: 700;
1788
+ flex-shrink: 0;
1789
+ box-shadow: 4px 4px 0 #FF69B4;
1790
+ position: relative;
1791
+ z-index: 2;
1792
+ }
1793
+
1794
+ .trip-info {
1795
+ flex: 1;
1796
+ position: relative;
1797
+ z-index: 2;
1798
+ }
1799
+
1800
+ .trip-location {
1801
+ font-size: 28px;
1802
+ font-weight: 800;
1803
+ color: #000;
1804
+ margin-bottom: 4px;
1805
+ }
1806
+
1807
+ .trip-location-en {
1808
+ position: absolute;
1809
+ right: 120px;
1810
+ bottom: -10px;
1811
+ font-size: 16px;
1812
+ font-weight: 900;
1813
+ color: rgba(0,0,0,0.08);
1814
+ text-transform: uppercase;
1815
+ letter-spacing: 1px;
1816
+ pointer-events: none;
1817
+ white-space: nowrap;
1818
+ font-style: italic;
1819
+ z-index: 1;
1820
+ }
1821
+
1822
+ .trip-country {
1823
+ font-size: 22px;
1824
+ color: #000;
1825
+ font-weight: 500;
1826
+ background: #eee;
1827
+ display: inline-block;
1828
+ padding: 2px 8px;
1829
+ border: 2px solid #000;
1830
+ }
1831
+
1832
+ .trip-tz {
1833
+ font-size: 18px;
1834
+ font-weight: 700;
1835
+ color: #000;
1836
+ background: #FFD700;
1837
+ padding: 6px 12px;
1838
+ border: 2px solid #000;
1839
+ box-shadow: 3px 3px 0 #000;
1840
+ flex-shrink: 0;
1841
+ }
1842
+
1843
+ .more-trips {
1844
+ text-align: center;
1845
+ padding: 20px;
1846
+ color: #000;
1847
+ font-size: 24px;
1848
+ font-weight: 700;
1849
+ background: #fff;
1850
+ border: 3px dashed #000;
1851
+ margin-top: 10px;
1852
+ }
1853
+
1854
+ .footer {
1855
+ display: flex;
1856
+ justify-content: space-between;
1857
+ align-items: center;
1858
+ margin-top: 60px;
1859
+ padding-top: 32px;
1860
+ border-top: 6px solid #000;
1861
+ position: relative;
1862
+ z-index: 1;
1863
+ }
1864
+
1865
+ .brand {
1866
+ display: flex;
1867
+ align-items: center;
1868
+ gap: 12px;
1869
+ }
1870
+
1871
+ .brand-icon {
1872
+ font-size: 40px;
1873
+ background: #FF69B4;
1874
+ border: 3px solid #000;
1875
+ padding: 4px;
1876
+ line-height: 1;
1877
+ box-shadow: 4px 4px 0 #000;
1878
+ }
1879
+
1880
+ .brand-name {
1881
+ font-size: 32px;
1882
+ font-weight: 900;
1883
+ text-transform: uppercase;
1884
+ color: #000;
1885
+ font-style: italic;
1886
+ }
1887
+
1888
+ .generated-at {
1889
+ font-size: 20px;
1890
+ color: #000;
1891
+ font-weight: 600;
1892
+ background: #fff;
1893
+ padding: 4px 12px;
1894
+ border: 2px solid #000;
1895
+ }
1896
+
1897
+ .empty-state {
1898
+ text-align: center;
1899
+ padding: 80px 40px;
1900
+ border: 4px dashed #000;
1901
+ background: #fff;
1902
+ margin: 40px 0;
1903
+ }
1904
+
1905
+ .empty-icon {
1906
+ font-size: 80px;
1907
+ margin-bottom: 24px;
1908
+ }
1909
+
1910
+ .empty-text {
1911
+ font-size: 32px;
1912
+ font-weight: 700;
1913
+ color: #000;
1914
+ }
1915
+ </style>
1916
+ </head>
1917
+ <body>
1918
+ <div class="deco-shape-1"></div>
1919
+ <div class="deco-shape-2"></div>
1920
+
1921
+ <div class="container">
1922
+ <div class="header">
1923
+ <div class="avatar-ring">
1924
+ <img class="avatar" src="${avatarUrl || "https://ui-avatars.com/api/?name=U&background=667eea&color=fff"}" onerror="this.src='https://ui-avatars.com/api/?name=U&background=667eea&color=fff'" />
1925
+ </div>
1926
+ <div class="user-info">
1927
+ <div class="username">${escapeHtml2(username)}</div>
1928
+ <div class="period">${year}年${monthName} 总结</div>
1929
+ </div>
1930
+ </div>
1931
+
1932
+ <div class="title-section">
1933
+ <div class="title">猪猪日报</div>
1934
+ <div class="subtitle">PIG DAILY SUMMARY</div>
1935
+ </div>
1936
+
1937
+ ${totalTrips > 0 ? `
1938
+ ${statsHtml}
1939
+
1940
+ <div class="trips-section">
1941
+ <div class="section-title">本月足迹 TRACKS</div>
1942
+ <div class="trips-list">
1943
+ ${tripsHtml}
1944
+ ${moreTripsHtml}
1945
+ </div>
1946
+ </div>
1947
+ ` : `
1948
+ <div class="empty-state">
1949
+ <div class="empty-icon"><span class="twemoji">🐷</span></div>
1950
+ <div class="empty-text">这个月还没有旅行记录哦~</div>
1951
+ </div>
1952
+ `}
1953
+
1954
+ <div class="footer">
1955
+ <div class="brand">
1956
+ <div class="brand-icon"><span class="twemoji">🐷</span></div>
1957
+ <div class="brand-name">Pig Travel</div>
1958
+ </div>
1959
+ <div class="generated-at">${(/* @__PURE__ */ new Date()).toLocaleDateString("zh-CN")}</div>
1960
+ </div>
1961
+ </div>
1962
+
1963
+ <script>
1964
+ async function waitForImages() {
1965
+ const images = Array.from(document.images);
1966
+ const promises = images.map(img => {
1967
+ if (img.complete) return Promise.resolve();
1968
+ return new Promise(resolve => {
1969
+ img.onload = () => resolve();
1970
+ img.onerror = () => resolve();
1971
+ });
1972
+ });
1973
+ await Promise.all([
1974
+ ...promises,
1975
+ document.fonts.ready
1976
+ ]);
1977
+ await new Promise(r => setTimeout(r, 100));
1978
+ }
1979
+ window.renderReady = waitForImages();
1980
+ </script>
1981
+ </body>
1982
+ </html>
1983
+ `;
1984
+ let page = null;
1985
+ try {
1986
+ page = await ctx.puppeteer.page();
1987
+ if (config.debug) {
1988
+ page.on("console", (msg) => ctx.logger("pig").debug(`[Summary] ${msg.text()}`));
1989
+ }
1990
+ const baseHeight = 1200;
1991
+ const tripHeight = Math.min(logs.length, 12) * 90;
1992
+ const extraHeight = logs.length > 12 ? 60 : 0;
1993
+ const totalHeight = baseHeight + tripHeight + extraHeight + (logs.length === 0 ? 200 : 0);
1994
+ await page.setViewport({ width: 1080, height: Math.max(1920, totalHeight), deviceScaleFactor: 1 });
1995
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
1996
+ await page.evaluate(() => window["renderReady"]);
1997
+ const buffer = await page.screenshot({ type: "png", fullPage: true });
1998
+ const filename = `pig_summary_${data.userId}_${year}_${month}.png`;
1999
+ ctx.logger("pig").info(`月度总结卡片已生成: ${filename}`);
2000
+ return { buffer, filename };
2001
+ } catch (e) {
2002
+ ctx.logger("pig").error("Failed to generate summary card", e);
2003
+ throw e;
2004
+ } finally {
2005
+ if (page) {
2006
+ try {
2007
+ await page.close();
2008
+ } catch (closeError) {
2009
+ if (config.debug) {
2010
+ ctx.logger("pig").warn(`Failed to close puppeteer page: ${closeError}`);
2011
+ }
2012
+ }
2013
+ }
2014
+ }
2015
+ }
2016
+ __name(generateMonthlySummaryCard, "generateMonthlySummaryCard");
2017
+ async function prepareMonthlySummary(ctx, userId, platform, username, avatarUrl, year, month) {
2018
+ const logs = await getMonthlyLogs(ctx, userId, platform, year, month);
2019
+ const countriesSet = /* @__PURE__ */ new Set();
2020
+ const locationsSet = /* @__PURE__ */ new Set();
2021
+ for (const log of logs) {
2022
+ countriesSet.add(log.country);
2023
+ locationsSet.add(log.location);
2024
+ }
2025
+ return {
2026
+ userId,
2027
+ platform,
2028
+ username,
2029
+ avatarUrl,
2030
+ year,
2031
+ month,
2032
+ logs,
2033
+ totalTrips: logs.length,
2034
+ countriesVisited: Array.from(countriesSet),
2035
+ locationsVisited: Array.from(locationsSet)
2036
+ };
2037
+ }
2038
+ __name(prepareMonthlySummary, "prepareMonthlySummary");
2039
+ function escapeHtml2(text) {
2040
+ const map = {
2041
+ "&": "&amp;",
2042
+ "<": "&lt;",
2043
+ ">": "&gt;",
2044
+ '"': "&quot;",
2045
+ "'": "&#039;"
2046
+ };
2047
+ return text.replace(/[&<>"']/g, (m) => map[m]);
2048
+ }
2049
+ __name(escapeHtml2, "escapeHtml");
2050
+
1415
2051
  // src/config.ts
1416
2052
  var import_koishi = require("koishi");
1417
2053
  var Config = import_koishi.Schema.intersect([
@@ -1449,6 +2085,7 @@ var Config = import_koishi.Schema.intersect([
1449
2085
  useStorageService: import_koishi.Schema.boolean().default(true).description("使用 chatluna-storage-service 缓存图片(推荐)"),
1450
2086
  storageCacheHours: import_koishi.Schema.number().default(24).description("图片缓存时间(小时)"),
1451
2087
  logRetentionDays: import_koishi.Schema.number().default(45).description("旅行记录保留天数"),
2088
+ monthlySummaryEnabled: import_koishi.Schema.boolean().default(false).description("每月1日自动生成上月旅行总结"),
1452
2089
  logPath: import_koishi.Schema.string().default("./data/pig/logs").description("本地日志存储路径(仅在不使用存储服务时生效)")
1453
2090
  }).description("存储设置 💾"),
1454
2091
  import_koishi.Schema.object({
@@ -1539,6 +2176,79 @@ function apply(ctx, config) {
1539
2176
  const result = await triggerTravelSequence(ctx, config, userInfo, platform);
1540
2177
  return formatTravelMessage(result, userId, config);
1541
2178
  });
2179
+ ctx.command("pig.summary [year:number] [month:number]", "生成月度旅行总结(调试用)").option("all", "-a 生成所有用户的总结").action(async ({ session, options }, yearArg, monthArg) => {
2180
+ const now = /* @__PURE__ */ new Date();
2181
+ let year = yearArg ?? now.getFullYear();
2182
+ let month = monthArg ?? now.getMonth();
2183
+ if (!monthArg && now.getMonth() === 0) {
2184
+ year = now.getFullYear() - 1;
2185
+ month = 12;
2186
+ } else if (!monthArg) {
2187
+ month = now.getMonth();
2188
+ }
2189
+ if (month < 1 || month > 12) {
2190
+ return "月份必须在 1-12 之间";
2191
+ }
2192
+ await session.send(`正在生成 ${year}年${month}月 的旅行总结...`);
2193
+ try {
2194
+ if (options.all) {
2195
+ const users = await getUsersWithLogsInMonth(ctx, year, month);
2196
+ if (users.length === 0) {
2197
+ return `${year}年${month}月 没有任何旅行记录`;
2198
+ }
2199
+ await session.send(`找到 ${users.length} 位用户有旅行记录,开始生成...`);
2200
+ for (const { userId, platform } of users) {
2201
+ try {
2202
+ const summaryData = await prepareMonthlySummary(
2203
+ ctx,
2204
+ userId,
2205
+ platform,
2206
+ userId,
2207
+ "",
2208
+ year,
2209
+ month
2210
+ );
2211
+ const result = await generateMonthlySummaryCard(ctx, config, summaryData);
2212
+ const base64 = result.buffer.toString("base64");
2213
+ await session.send(`用户 ${userId} 的总结:
2214
+ ${import_koishi2.segment.image(`base64://${base64}`)}`);
2215
+ } catch (e) {
2216
+ ctx.logger("pig").error(`Failed to generate summary for user ${userId}:`, e);
2217
+ await session.send(`生成用户 ${userId} 的总结时出错: ${e}`);
2218
+ }
2219
+ }
2220
+ return `已完成 ${users.length} 位用户的月度总结生成`;
2221
+ } else {
2222
+ const platform = session.platform;
2223
+ const userId = session.userId;
2224
+ let avatarUrl = session.author?.avatar || "";
2225
+ if (!avatarUrl && (platform === "onebot" || platform === "qq" || platform.includes("qq"))) {
2226
+ avatarUrl = `https://q.qlogo.cn/headimg_dl?dst_uin=${userId}&spec=640`;
2227
+ }
2228
+ const userInfo = {
2229
+ userId,
2230
+ username: session.author?.nickname || session.author?.name || session.username || userId,
2231
+ avatarUrl
2232
+ };
2233
+ const summaryData = await prepareMonthlySummary(
2234
+ ctx,
2235
+ userId,
2236
+ platform,
2237
+ userInfo.username,
2238
+ userInfo.avatarUrl,
2239
+ year,
2240
+ month
2241
+ );
2242
+ const result = await generateMonthlySummaryCard(ctx, config, summaryData);
2243
+ const base64 = result.buffer.toString("base64");
2244
+ return `${import_koishi2.segment.at(userId)} ${year}年${month}月 旅行总结
2245
+ ${import_koishi2.segment.image(`base64://${base64}`)}`;
2246
+ }
2247
+ } catch (e) {
2248
+ ctx.logger("pig").error("Failed to generate monthly summary:", e);
2249
+ return `生成月度总结失败: ${e}`;
2250
+ }
2251
+ });
1542
2252
  ctx.middleware(async (session, next) => {
1543
2253
  if (!config.experimentalAutoDetect) return next();
1544
2254
  if (!session.userId || !session.content) return next();
@@ -1589,9 +2299,46 @@ function apply(ctx, config) {
1589
2299
  return next();
1590
2300
  });
1591
2301
  ctx.cron("0 0 1 * *", async () => {
2302
+ if (!config.monthlySummaryEnabled) {
2303
+ ctx.logger("pig").debug("Monthly summary is disabled, skipping...");
2304
+ return;
2305
+ }
1592
2306
  const now = /* @__PURE__ */ new Date();
1593
- const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
1594
- ctx.logger("pig").info("Generating monthly travel handbook...");
2307
+ let year = now.getFullYear();
2308
+ let month = now.getMonth();
2309
+ if (month === 0) {
2310
+ year -= 1;
2311
+ month = 12;
2312
+ }
2313
+ ctx.logger("pig").info(`Generating monthly travel handbook for ${year}/${month}...`);
2314
+ try {
2315
+ const users = await getUsersWithLogsInMonth(ctx, year, month);
2316
+ if (users.length === 0) {
2317
+ ctx.logger("pig").info(`No travel logs found for ${year}/${month}`);
2318
+ return;
2319
+ }
2320
+ ctx.logger("pig").info(`Found ${users.length} users with travel logs for ${year}/${month}`);
2321
+ for (const { userId, platform } of users) {
2322
+ try {
2323
+ const summaryData = await prepareMonthlySummary(
2324
+ ctx,
2325
+ userId,
2326
+ platform,
2327
+ userId,
2328
+ "",
2329
+ year,
2330
+ month
2331
+ );
2332
+ await generateMonthlySummaryCard(ctx, config, summaryData);
2333
+ ctx.logger("pig").info(`Generated summary for user ${userId}`);
2334
+ } catch (e) {
2335
+ ctx.logger("pig").error(`Failed to generate summary for user ${userId}:`, e);
2336
+ }
2337
+ }
2338
+ ctx.logger("pig").info(`Monthly handbook generation completed for ${year}/${month}`);
2339
+ } catch (e) {
2340
+ ctx.logger("pig").error("Failed to generate monthly travel handbook:", e);
2341
+ }
1595
2342
  });
1596
2343
  ctx.cron("0 3 * * *", async () => {
1597
2344
  const cutoffDate = /* @__PURE__ */ new Date();
@@ -1,6 +1,7 @@
1
1
  import { Context } from 'koishi';
2
2
  /**
3
- * Search for a photo on Pexels and return the large-sized URL
3
+ * Search for a photo on Pexels and return a randomly selected large-sized URL
4
+ * Fetches multiple results and randomly selects one to avoid repetitive images
4
5
  * @returns The photo URL or null if not found/error
5
6
  */
6
7
  export declare function searchPexelsPhoto(ctx: Context, apiKey: string, query: string, debug?: boolean): Promise<string | null>;
@@ -0,0 +1,38 @@
1
+ import { Context } from 'koishi';
2
+ import { Config } from '../config';
3
+ import { PigTravelLog } from '../database';
4
+ export interface MonthlySummaryData {
5
+ userId: string;
6
+ platform: string;
7
+ username: string;
8
+ avatarUrl: string;
9
+ year: number;
10
+ month: number;
11
+ logs: PigTravelLog[];
12
+ totalTrips: number;
13
+ countriesVisited: string[];
14
+ locationsVisited: string[];
15
+ }
16
+ export interface SummaryCardResult {
17
+ buffer: Buffer;
18
+ filename: string;
19
+ }
20
+ /**
21
+ * 获取指定用户指定月份的旅行记录
22
+ */
23
+ export declare function getMonthlyLogs(ctx: Context, userId: string, platform: string, year: number, month: number): Promise<PigTravelLog[]>;
24
+ /**
25
+ * 获取所有有旅行记录的用户(指定月份)
26
+ */
27
+ export declare function getUsersWithLogsInMonth(ctx: Context, year: number, month: number): Promise<{
28
+ userId: string;
29
+ platform: string;
30
+ }[]>;
31
+ /**
32
+ * 生成月度总结卡片
33
+ */
34
+ export declare function generateMonthlySummaryCard(ctx: Context, config: Config, data: MonthlySummaryData): Promise<SummaryCardResult>;
35
+ /**
36
+ * 准备月度总结数据
37
+ */
38
+ export declare function prepareMonthlySummary(ctx: Context, userId: string, platform: string, username: string, avatarUrl: string, year: number, month: number): Promise<MonthlySummaryData>;
@@ -1,6 +1,7 @@
1
1
  import { Context } from 'koishi';
2
2
  /**
3
- * Search for a photo on Unsplash and return the regular-sized URL
3
+ * Search for a photo on Unsplash and return a randomly selected regular-sized URL
4
+ * Fetches multiple results and randomly selects one to avoid repetitive images
4
5
  * @returns The photo URL or null if not found/error
5
6
  */
6
7
  export declare function searchUnsplashPhoto(ctx: Context, accessKey: string, query: string, debug?: boolean): Promise<string | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-my-pig-group-friends",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "main": "lib/index.js",
5
5
  "types": "lib/index.d.ts",
6
6
  "files": [