nonebot-plugin-rollpig-plus 0.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. nonebot_plugin_rollpig_plus/__init__.py +1726 -0
  2. nonebot_plugin_rollpig_plus/catalog_renderer.py +529 -0
  3. nonebot_plugin_rollpig_plus/config.py +105 -0
  4. nonebot_plugin_rollpig_plus/data_manager.py +910 -0
  5. nonebot_plugin_rollpig_plus/perf_logging.py +32 -0
  6. nonebot_plugin_rollpig_plus/render_budget.py +55 -0
  7. nonebot_plugin_rollpig_plus/resource/catalog_anchor.html +8 -0
  8. nonebot_plugin_rollpig_plus/resource/catalog_base.png +0 -0
  9. nonebot_plugin_rollpig_plus/resource/catalog_template.html +207 -0
  10. nonebot_plugin_rollpig_plus/resource/image/abstract-pig.png +0 -0
  11. nonebot_plugin_rollpig_plus/resource/image/ai-pig.png +0 -0
  12. nonebot_plugin_rollpig_plus/resource/image/alien-pig.png +0 -0
  13. nonebot_plugin_rollpig_plus/resource/image/android-pig.png +0 -0
  14. nonebot_plugin_rollpig_plus/resource/image/antibacterial-pig.png +0 -0
  15. nonebot_plugin_rollpig_plus/resource/image/apple-of-eye-pig.png +0 -0
  16. nonebot_plugin_rollpig_plus/resource/image/apple-pig.png +0 -0
  17. nonebot_plugin_rollpig_plus/resource/image/arknights-pig.png +0 -0
  18. nonebot_plugin_rollpig_plus/resource/image/astro-pig.png +0 -0
  19. nonebot_plugin_rollpig_plus/resource/image/bacon.png +0 -0
  20. nonebot_plugin_rollpig_plus/resource/image/balloon-pig.png +0 -0
  21. nonebot_plugin_rollpig_plus/resource/image/bamboo-pig.png +0 -0
  22. nonebot_plugin_rollpig_plus/resource/image/bandage-pig.png +0 -0
  23. nonebot_plugin_rollpig_plus/resource/image/big-lazy-pig.png +0 -0
  24. nonebot_plugin_rollpig_plus/resource/image/big-stomach-pig.png +0 -0
  25. nonebot_plugin_rollpig_plus/resource/image/black-pig.png +0 -0
  26. nonebot_plugin_rollpig_plus/resource/image/black-white-pig.png +0 -0
  27. nonebot_plugin_rollpig_plus/resource/image/bone-soup-pig.png +0 -0
  28. nonebot_plugin_rollpig_plus/resource/image/buddha-pig.png +0 -0
  29. nonebot_plugin_rollpig_plus/resource/image/burger-pig.png +0 -0
  30. nonebot_plugin_rollpig_plus/resource/image/canned-pig.png +0 -0
  31. nonebot_plugin_rollpig_plus/resource/image/carrot-ghost-pig.png +0 -0
  32. nonebot_plugin_rollpig_plus/resource/image/chained_crown_pig.png +0 -0
  33. nonebot_plugin_rollpig_plus/resource/image/chainsaw-pig.png +0 -0
  34. nonebot_plugin_rollpig_plus/resource/image/char-siu.png +0 -0
  35. nonebot_plugin_rollpig_plus/resource/image/charge-pig.png +0 -0
  36. nonebot_plugin_rollpig_plus/resource/image/chef-pig.png +0 -0
  37. nonebot_plugin_rollpig_plus/resource/image/chocolate-pig.png +0 -0
  38. nonebot_plugin_rollpig_plus/resource/image/civil-eng-pig.png +0 -0
  39. nonebot_plugin_rollpig_plus/resource/image/clean-pig.png +0 -0
  40. nonebot_plugin_rollpig_plus/resource/image/cloud-pig.png +0 -0
  41. nonebot_plugin_rollpig_plus/resource/image/cocktail-pig.png +0 -0
  42. nonebot_plugin_rollpig_plus/resource/image/coder-pig.png +0 -0
  43. nonebot_plugin_rollpig_plus/resource/image/computer-pig.png +0 -0
  44. nonebot_plugin_rollpig_plus/resource/image/crystal-pig.png +0 -0
  45. nonebot_plugin_rollpig_plus/resource/image/curator-pig.png +0 -0
  46. nonebot_plugin_rollpig_plus/resource/image/cyberpunk-pig.png +0 -0
  47. nonebot_plugin_rollpig_plus/resource/image/delivery-pig.png +0 -0
  48. nonebot_plugin_rollpig_plus/resource/image/demon-pig.png +0 -0
  49. nonebot_plugin_rollpig_plus/resource/image/dirty-pig.png +0 -0
  50. nonebot_plugin_rollpig_plus/resource/image/doctor-pig.png +0 -0
  51. nonebot_plugin_rollpig_plus/resource/image/doll-pig.png +0 -0
  52. nonebot_plugin_rollpig_plus/resource/image/doro-pig.png +0 -0
  53. nonebot_plugin_rollpig_plus/resource/image/drunk-pig.png +0 -0
  54. nonebot_plugin_rollpig_plus/resource/image/dumb-pig.png +0 -0
  55. nonebot_plugin_rollpig_plus/resource/image/dumpling-pig.png +0 -0
  56. nonebot_plugin_rollpig_plus/resource/image/early-class-pig.png +0 -0
  57. nonebot_plugin_rollpig_plus/resource/image/earthy-pig.png +0 -0
  58. nonebot_plugin_rollpig_plus/resource/image/eaten.png +0 -0
  59. nonebot_plugin_rollpig_plus/resource/image/elephant-pig.png +0 -0
  60. nonebot_plugin_rollpig_plus/resource/image/error-404-pig.png +0 -0
  61. nonebot_plugin_rollpig_plus/resource/image/everest-pig.png +0 -0
  62. nonebot_plugin_rollpig_plus/resource/image/explosive-pig.png +0 -0
  63. nonebot_plugin_rollpig_plus/resource/image/fine-chaff-boar.png +0 -0
  64. nonebot_plugin_rollpig_plus/resource/image/frozen-pig.png +0 -0
  65. nonebot_plugin_rollpig_plus/resource/image/goblin-pig.png +0 -0
  66. nonebot_plugin_rollpig_plus/resource/image/halloween-pig.png +0 -0
  67. nonebot_plugin_rollpig_plus/resource/image/heaven-pig.png +0 -0
  68. nonebot_plugin_rollpig_plus/resource/image/homebody-pig.png +0 -0
  69. nonebot_plugin_rollpig_plus/resource/image/hotdog-pig.png +0 -0
  70. nonebot_plugin_rollpig_plus/resource/image/human.png +0 -0
  71. nonebot_plugin_rollpig_plus/resource/image/invisible_pig.png +0 -0
  72. nonebot_plugin_rollpig_plus/resource/image/jewelry-pig.png +0 -0
  73. nonebot_plugin_rollpig_plus/resource/image/jiahao-pig.png +0 -0
  74. nonebot_plugin_rollpig_plus/resource/image/juliet-pig.png +0 -0
  75. nonebot_plugin_rollpig_plus/resource/image/jurassic-pig.png +0 -0
  76. nonebot_plugin_rollpig_plus/resource/image/laborer-pig.png +0 -0
  77. nonebot_plugin_rollpig_plus/resource/image/landmine-pig.png +0 -0
  78. nonebot_plugin_rollpig_plus/resource/image/lao_zhu_li.png +0 -0
  79. nonebot_plugin_rollpig_plus/resource/image/lard-pig.png +0 -0
  80. nonebot_plugin_rollpig_plus/resource/image/leek-pig.png +0 -0
  81. nonebot_plugin_rollpig_plus/resource/image/lemon-pig.png +0 -0
  82. nonebot_plugin_rollpig_plus/resource/image/little-red-pig.png +0 -0
  83. nonebot_plugin_rollpig_plus/resource/image/lone-pig.png +0 -0
  84. nonebot_plugin_rollpig_plus/resource/image/magic-pig-cat.png +0 -0
  85. nonebot_plugin_rollpig_plus/resource/image/magic-pig.png +0 -0
  86. nonebot_plugin_rollpig_plus/resource/image/mc_porkchop.png +0 -0
  87. nonebot_plugin_rollpig_plus/resource/image/mcdonalds-pig.png +0 -0
  88. nonebot_plugin_rollpig_plus/resource/image/mechanical-pig.png +0 -0
  89. nonebot_plugin_rollpig_plus/resource/image/melting-pig.png +0 -0
  90. nonebot_plugin_rollpig_plus/resource/image/miku-pig.png +0 -0
  91. nonebot_plugin_rollpig_plus/resource/image/mini-pig.png +0 -0
  92. nonebot_plugin_rollpig_plus/resource/image/muscle-pig.png +0 -0
  93. nonebot_plugin_rollpig_plus/resource/image/ninja-pig.png +0 -0
  94. nonebot_plugin_rollpig_plus/resource/image/oreo-pig.png +0 -0
  95. nonebot_plugin_rollpig_plus/resource/image/oriental-pearl-pig.png +0 -0
  96. nonebot_plugin_rollpig_plus/resource/image/palico-pig.png +0 -0
  97. nonebot_plugin_rollpig_plus/resource/image/pearl-pig.png +0 -0
  98. nonebot_plugin_rollpig_plus/resource/image/pig-ball.png +0 -0
  99. nonebot_plugin_rollpig_plus/resource/image/pig-bun.png +0 -0
  100. nonebot_plugin_rollpig_plus/resource/image/pig-cat.png +0 -0
  101. nonebot_plugin_rollpig_plus/resource/image/pig-coin.png +0 -0
  102. nonebot_plugin_rollpig_plus/resource/image/pig-die.png +0 -0
  103. nonebot_plugin_rollpig_plus/resource/image/pig-human.png +0 -0
  104. nonebot_plugin_rollpig_plus/resource/image/pig-king.png +0 -0
  105. nonebot_plugin_rollpig_plus/resource/image/pig-skin-milk.png +0 -0
  106. nonebot_plugin_rollpig_plus/resource/image/pig-souffle.png +0 -0
  107. nonebot_plugin_rollpig_plus/resource/image/pig-stamp.png +0 -0
  108. nonebot_plugin_rollpig_plus/resource/image/pig-turtle.png +0 -0
  109. nonebot_plugin_rollpig_plus/resource/image/pig.png +0 -0
  110. nonebot_plugin_rollpig_plus/resource/image/pig_god.png +0 -0
  111. nonebot_plugin_rollpig_plus/resource/image/piggsium.png +0 -0
  112. nonebot_plugin_rollpig_plus/resource/image/piggy-bank.png +0 -0
  113. nonebot_plugin_rollpig_plus/resource/image/pigskin-pig.png +0 -0
  114. nonebot_plugin_rollpig_plus/resource/image/pigsleep.png +0 -0
  115. nonebot_plugin_rollpig_plus/resource/image/pigtok.png +0 -0
  116. nonebot_plugin_rollpig_plus/resource/image/pork-floss.png +0 -0
  117. nonebot_plugin_rollpig_plus/resource/image/pork-skewer.png +0 -0
  118. nonebot_plugin_rollpig_plus/resource/image/prison-break-pig.png +0 -0
  119. nonebot_plugin_rollpig_plus/resource/image/protagonist-pig.png +0 -0
  120. nonebot_plugin_rollpig_plus/resource/image/rail-pig.png +0 -0
  121. nonebot_plugin_rollpig_plus/resource/image/rainbow-pig.png +0 -0
  122. nonebot_plugin_rollpig_plus/resource/image/repeater-pig.png +0 -0
  123. nonebot_plugin_rollpig_plus/resource/image/roasted-pig.png +0 -0
  124. nonebot_plugin_rollpig_plus/resource/image/salmon-sushi-pig.png +0 -0
  125. nonebot_plugin_rollpig_plus/resource/image/shop-pig.png +0 -0
  126. nonebot_plugin_rollpig_plus/resource/image/shrimp-sushi-pig.png +0 -0
  127. nonebot_plugin_rollpig_plus/resource/image/skeleton-pig.png +0 -0
  128. nonebot_plugin_rollpig_plus/resource/image/sleepy-pig.png +0 -0
  129. nonebot_plugin_rollpig_plus/resource/image/snow-pig.png +0 -0
  130. nonebot_plugin_rollpig_plus/resource/image/sold-out.png +0 -0
  131. nonebot_plugin_rollpig_plus/resource/image/soul-pig.png +0 -0
  132. nonebot_plugin_rollpig_plus/resource/image/spider-pig.png +0 -0
  133. nonebot_plugin_rollpig_plus/resource/image/spring-festival-pig.png +0 -0
  134. nonebot_plugin_rollpig_plus/resource/image/stacking-pig.png +0 -0
  135. nonebot_plugin_rollpig_plus/resource/image/stem-pig.png +0 -0
  136. nonebot_plugin_rollpig_plus/resource/image/strawberry-cake-pig.png +0 -0
  137. nonebot_plugin_rollpig_plus/resource/image/streamer-pig.png +0 -0
  138. nonebot_plugin_rollpig_plus/resource/image/stressed-pig.png +0 -0
  139. nonebot_plugin_rollpig_plus/resource/image/stuck-pig.png +0 -0
  140. nonebot_plugin_rollpig_plus/resource/image/study-pig.png +0 -0
  141. nonebot_plugin_rollpig_plus/resource/image/suckling-pig.png +0 -0
  142. nonebot_plugin_rollpig_plus/resource/image/sushi-platter-pig.png +0 -0
  143. nonebot_plugin_rollpig_plus/resource/image/taffy-pig.png +0 -0
  144. nonebot_plugin_rollpig_plus/resource/image/tamagoyaki-pig.png +0 -0
  145. nonebot_plugin_rollpig_plus/resource/image/tangyuan_pig.png +0 -0
  146. nonebot_plugin_rollpig_plus/resource/image/tank_pig.png +0 -0
  147. nonebot_plugin_rollpig_plus/resource/image/taxi-pig.png +0 -0
  148. nonebot_plugin_rollpig_plus/resource/image/teammate-pig.png +0 -0
  149. nonebot_plugin_rollpig_plus/resource/image/tiramisu-pig.png +0 -0
  150. nonebot_plugin_rollpig_plus/resource/image/unchained-pig-king.png +0 -0
  151. nonebot_plugin_rollpig_plus/resource/image/upper-class-pig.png +0 -0
  152. nonebot_plugin_rollpig_plus/resource/image/vangogh_pig.png +0 -0
  153. nonebot_plugin_rollpig_plus/resource/image/watermelon-pig.png +0 -0
  154. nonebot_plugin_rollpig_plus/resource/image/wechat-pig.png +0 -0
  155. nonebot_plugin_rollpig_plus/resource/image/wild-boar.png +0 -0
  156. nonebot_plugin_rollpig_plus/resource/image/world-ruler-pig.png +0 -0
  157. nonebot_plugin_rollpig_plus/resource/image/zhuge-liang.png +0 -0
  158. nonebot_plugin_rollpig_plus/resource/image/zombie-pig.png +0 -0
  159. nonebot_plugin_rollpig_plus/resource/pig.json +896 -0
  160. nonebot_plugin_rollpig_plus/resource/pig_rules.json +33 -0
  161. nonebot_plugin_rollpig_plus/resource/template.html +61 -0
  162. nonebot_plugin_rollpig_plus/resource_manager.py +735 -0
  163. nonebot_plugin_rollpig_plus/roast_manager.py +228 -0
  164. nonebot_plugin_rollpig_plus/runtime.py +121 -0
  165. nonebot_plugin_rollpig_plus/store/__init__.py +3 -0
  166. nonebot_plugin_rollpig_plus/store/base.py +107 -0
  167. nonebot_plugin_rollpig_plus/store/cloud.py +337 -0
  168. nonebot_plugin_rollpig_plus/store/factory.py +23 -0
  169. nonebot_plugin_rollpig_plus/store/local_json.py +113 -0
  170. nonebot_plugin_rollpig_plus/store/models.py +64 -0
  171. nonebot_plugin_rollpig_plus/summary_service.py +96 -0
  172. nonebot_plugin_rollpig_plus/texts.py +487 -0
  173. nonebot_plugin_rollpig_plus-0.7.3.dist-info/METADATA +319 -0
  174. nonebot_plugin_rollpig_plus-0.7.3.dist-info/RECORD +176 -0
  175. nonebot_plugin_rollpig_plus-0.7.3.dist-info/WHEEL +4 -0
  176. nonebot_plugin_rollpig_plus-0.7.3.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,1726 @@
1
+ import asyncio
2
+ import random
3
+ import datetime
4
+ import time
5
+ from functools import wraps
6
+ import httpx
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from urllib.parse import quote, urljoin, urlsplit, urlunsplit
10
+
11
+ from nonebot import on_command, require, get_driver, get_bot, get_plugin_config
12
+ from nonebot.adapters.onebot.v11 import Event, MessageSegment, Message, GroupMessageEvent, Bot
13
+ from nonebot.params import CommandArg
14
+ from nonebot.log import logger
15
+ from nonebot.plugin import PluginMetadata
16
+
17
+ # 确保依赖插件先被 NoneBot 注册(必须在本地模块 import 之前)
18
+ # data_manager.py 在模块加载时会调用 store.get_plugin_data_file();
19
+ # 定时任务也需要 apscheduler 提前完成插件注册,避免商店/静态审核误判。
20
+ require("nonebot_plugin_htmlrender")
21
+ require("nonebot_plugin_localstore")
22
+ require("nonebot_plugin_apscheduler")
23
+
24
+ from nonebot_plugin_htmlrender import template_to_pic
25
+ from nonebot_plugin_apscheduler import scheduler
26
+
27
+ # 本地模块(在 require() 之后 import)
28
+ from .config import Config
29
+ from .catalog_renderer import render_catalog_image, shutdown_catalog_renderer
30
+ from .perf_logging import log_perf
31
+ from .render_budget import html_render_budget
32
+ from .roast_manager import roast_manager
33
+ from .resource_manager import pig_resource_manager
34
+ from .runtime import (
35
+ is_daily_summary_enabled,
36
+ is_group_rollpig_enabled,
37
+ rollpig_date_str,
38
+ rollpig_today,
39
+ resolve_roast_charge_max,
40
+ resolve_roast_cooldown_seconds,
41
+ )
42
+ from .store import store
43
+ from .store.cloud import CloudStoreError
44
+ from .store.models import DailyRollResult, DrawState, RoastEvent
45
+ from .summary_service import build_daily_summary
46
+ from .texts import (
47
+ TOMORROW_TEXTS,
48
+ DAILY_ROLL_NEW_PIG_TEXTS,
49
+ DAILY_ROLL_DUPLICATE_LEVEL_UP_TEXTS,
50
+ DAILY_ROLL_DUPLICATE_SAME_LEVEL_TEXTS,
51
+ FOOD_PIG_IDS, HUMAN_PIG_ID, EATEN_PIG_ID, SOLD_PIG_ID,
52
+ FORCE_ROAST_KEYWORDS, SUPER_FORCE_ROAST_KEYWORD,
53
+ TODAY_ROAST_HUMAN_BLOCK_TEXTS, TODAY_ROAST_EATEN_BLOCK_TEXTS, TODAY_ROAST_SOLD_BLOCK_TEXTS, TODAY_ROAST_FOOD_BLOCK_TEXTS,
54
+ TARGET_HUMAN_BLOCK_TEXTS, TARGET_EATEN_BLOCK_TEXTS, TARGET_SOLD_BLOCK_TEXTS, TARGET_FOOD_BLOCK_TEXTS,
55
+ BACKFIRE_HUMAN_TEXTS, BACKFIRE_EATEN_TEXTS, BACKFIRE_SOLD_TEXTS, BACKFIRE_FOOD_TEXTS,
56
+ BACKFIRE_NO_PIG_TEXTS, BACKFIRE_GENERIC_TEXTS,
57
+ ESCAPE_TEXTS,
58
+ SUPER_FORCE_ROAST_PREFIX_TEXTS, FORCE_ROAST_PREFIX_TEXTS,
59
+ FORCE_ROAST_LIMIT_TEXTS,
60
+ ROAST_BOT_TEXTS,
61
+ AUTO_ROLL_ROAST_TEXTS,
62
+ DAILY_SUMMARY_EMPTY_TEXTS, DAILY_SUMMARY_HEADER, DAILY_SUMMARY_FOOTER,
63
+ PROTECTION_BLOCK_TEXTS, PROTECTION_BREAK_TEXTS,
64
+ RANDOM_ROAST_INTRO_TEXTS,
65
+ )
66
+
67
+ # --- 引入 PIL ---
68
+ try:
69
+ from PIL import Image as PILImage
70
+ HAS_PIL = True
71
+ except ImportError:
72
+ HAS_PIL = False
73
+
74
+ # ========================================================
75
+
76
+ __plugin_meta__ = PluginMetadata(
77
+ name="今天是什么小猪(今日小猪)Plus",
78
+ description="基于原版Rollpig的拓展分支,新增拓展烤群友、图鉴成长等多项玩法",
79
+ usage="""
80
+ 🐷 基础指令:
81
+ 今日小猪 / 今天是什么小猪 - 抽取今天的命运之猪
82
+ 随机小猪 - 随机看一张猪图
83
+ 找猪 - 从 PigHub 模糊搜索猪猪图
84
+
85
+ 🔮 趣味指令:
86
+ 明日小猪 - 预测明天的猪猪运势
87
+ 昨日小猪 - 查看昨天抽到了什么
88
+ 今日烤猪 - 把今天的猪做成美食
89
+ 烤群友 - 把群友做成烤猪(目标需已抽猪)
90
+ 烤群友 + 加急生火 - 每日一次强制成功(目标仍需已抽猪)
91
+ 加急生火 + @目标 / 回复目标 - 直达触发一次普通后门烧烤
92
+ 烤群友 + 强行点火 - superuser 专属,无限强制成功
93
+
94
+ 📊 统计指令:
95
+ 我的猪圈 - 查看解锁进度
96
+ 小猪图鉴 - 生成图片版小猪图鉴
97
+ 本周小猪 - 生成本周猪猪总结长图
98
+ """,
99
+ type="application",
100
+ homepage="https://github.com/Felis2026/nonebot-plugin-rollpig-plus",
101
+ supported_adapters={"~onebot.v11"},
102
+ config=Config,
103
+ )
104
+
105
+
106
+ @get_driver().on_shutdown
107
+ async def _shutdown_rollpig_runtime() -> None:
108
+ """释放图鉴页面池与存储后端连接,避免长期运行或重载后残留浏览器/HTTP 资源。"""
109
+ await shutdown_catalog_renderer()
110
+ await store.close()
111
+
112
+ # ================= 资源路径 =================
113
+
114
+ PLUGIN_DIR = Path(__file__).parent
115
+ RES_DIR = PLUGIN_DIR / "resource"
116
+ PIGHUB_IMAGE_BASE_URL = "https://pighub.top/data/"
117
+ PIGHUB_ORIGIN = "https://pighub.top/"
118
+ PIGHUB_API_URLS = (
119
+ "https://pighub.top/api/images?sort=2",
120
+ "https://pighub.top/api/all-images",
121
+ )
122
+ PIGHUB_TTL_SECONDS = 6 * 3600 # PigHub 图库缓存有效期(6小时)
123
+ MAX_EXPERT_LEVEL = 5
124
+ DUPLICATE_PITY_WEIGHT_STEP = 0.5
125
+ DUPLICATE_PITY_WEIGHT_CAP = 4.0
126
+
127
+ pighub_images: list = []
128
+ pighub_last_loaded: float = 0.0
129
+
130
+ # ================= 资源加载 =================
131
+
132
+ PIG_LIST: list[dict] = []
133
+
134
+
135
+ def reload_rollpig_resources() -> None:
136
+ """刷新内存中的小猪资源快照;云端资源坏掉时由资源管理器自动回退到内置资源。"""
137
+ global PIG_LIST
138
+ pig_resource_manager.reload()
139
+ PIG_LIST = pig_resource_manager.pig_list
140
+
141
+
142
+ reload_rollpig_resources()
143
+
144
+ # ================= 工具函数 =================
145
+
146
+ def find_image_file(pig_id: str) -> Path | None:
147
+ return pig_resource_manager.find_image_file(pig_id)
148
+
149
+
150
+ def get_pig_by_id(pig_id: Optional[str]) -> Optional[dict]:
151
+ if not pig_id:
152
+ return None
153
+ for p in PIG_LIST:
154
+ if p["id"] == pig_id:
155
+ return p
156
+ return None
157
+
158
+
159
+ def is_food_pig(pig_data: Optional[dict]) -> bool:
160
+ return bool(pig_data and pig_data.get("id") in get_food_pig_ids())
161
+
162
+
163
+ def get_food_pig_ids() -> list[str]:
164
+ """合并内置熟食列表与云端 pig_rules.json;远端缺失时仍保持旧逻辑。"""
165
+ return list(dict.fromkeys([*FOOD_PIG_IDS, *sorted(pig_resource_manager.food_pig_ids)]))
166
+
167
+
168
+ def get_human_pig_ids() -> list[str]:
169
+ """合并内置人类形态与云端规则,允许后续资源包扩展同类特殊形态。"""
170
+ return list(dict.fromkeys([HUMAN_PIG_ID, *sorted(pig_resource_manager.human_pig_ids)]))
171
+
172
+
173
+ def is_human_pig(pig_data: Optional[dict]) -> bool:
174
+ return bool(pig_data and pig_data.get("id") in get_human_pig_ids())
175
+
176
+
177
+ def get_eaten_pig_ids() -> list[str]:
178
+ """合并内置“吃掉了”形态与云端规则,避免特殊终态被新增资源绕过。"""
179
+ return list(dict.fromkeys([EATEN_PIG_ID, *sorted(pig_resource_manager.eaten_pig_ids)]))
180
+
181
+
182
+ def is_eaten_pig(pig_data: Optional[dict]) -> bool:
183
+ return bool(pig_data and pig_data.get("id") in get_eaten_pig_ids())
184
+
185
+
186
+ def get_sold_pig_ids() -> list[str]:
187
+ """合并内置“卖掉了”形态与云端规则,让售罄类特殊形态走独立拦截文案。"""
188
+ return list(dict.fromkeys([SOLD_PIG_ID, *sorted(pig_resource_manager.sold_pig_ids)]))
189
+
190
+
191
+ def is_sold_pig(pig_data: Optional[dict]) -> bool:
192
+ return bool(pig_data and pig_data.get("id") in get_sold_pig_ids())
193
+
194
+
195
+ def can_backfire_roast(attacker_pig: Optional[dict]) -> bool:
196
+ """判断反噬时攻击者是否还能被做成食物;特殊终态只走文字反噬,不二次加工。"""
197
+ return bool(
198
+ attacker_pig
199
+ and not is_food_pig(attacker_pig)
200
+ and not is_human_pig(attacker_pig)
201
+ and not is_eaten_pig(attacker_pig)
202
+ and not is_sold_pig(attacker_pig)
203
+ )
204
+
205
+
206
+ def get_expert_level(copies: int) -> int:
207
+ """根据累计抽到次数计算专家等级:1 次为 Lv.0,6 次及以上封顶 Lv.5。"""
208
+ return min(max(int(copies) - 1, 0), MAX_EXPERT_LEVEL)
209
+
210
+
211
+ # ================================ P1A抽猪成长反馈 ================================ #
212
+ # 伪保底只影响“今日小猪/自动补抽”在未创建今日记录前的候选权重。
213
+ # 真正的 copies 与 duplicate_streak 更新仍由 store.get_or_create_daily_roll 收口,
214
+ # 这样 cloud 模式可以交给服务端事务兜底,本地模式也能保持同一语义。
215
+
216
+ async def pick_daily_roll_candidate(user_id: str) -> dict:
217
+ """按用户当前图鉴状态选择今日候选猪;连续重复越多,新猪权重越高。"""
218
+ draw_state = await store.get_draw_state(user_id)
219
+ owned_pig_ids = set(draw_state.pig_ids)
220
+ duplicate_streak = max(0, int(draw_state.duplicate_streak or 0))
221
+ new_pig_bonus = min(duplicate_streak * DUPLICATE_PITY_WEIGHT_STEP, DUPLICATE_PITY_WEIGHT_CAP)
222
+
223
+ weights = []
224
+ for pig in PIG_LIST:
225
+ pig_id = str(pig.get("id", ""))
226
+ is_unowned = pig_id and pig_id not in owned_pig_ids
227
+ weights.append(1.0 + new_pig_bonus if is_unowned else 1.0)
228
+
229
+ # random.choices 比手写累计权重更不容易写出边界错误;PIG_LIST 为空时调用方已拦截。
230
+ return random.choices(PIG_LIST, weights=weights, k=1)[0]
231
+
232
+
233
+ def build_roll_growth_text(result: DailyRollResult, pig_data: dict) -> str:
234
+ """生成今日首次抽猪后的成长提示;重复查看当天结果时不刷提示也不刷等级。"""
235
+ if not result.created:
236
+ return ""
237
+
238
+ pig_name = pig_data.get("name", "未知小猪")
239
+ current_level = get_expert_level(result.copies)
240
+ if result.is_new_pig:
241
+ return random.choice(DAILY_ROLL_NEW_PIG_TEXTS).format(pig=pig_name, level=current_level)
242
+
243
+ previous_level = get_expert_level(result.previous_copies)
244
+ if previous_level != current_level:
245
+ return random.choice(DAILY_ROLL_DUPLICATE_LEVEL_UP_TEXTS).format(
246
+ pig=pig_name,
247
+ old_level=previous_level,
248
+ new_level=current_level,
249
+ )
250
+ return random.choice(DAILY_ROLL_DUPLICATE_SAME_LEVEL_TEXTS).format(pig=pig_name, level=current_level)
251
+
252
+
253
+ def build_pigsty_growth_summary(user_name: str, draw_state: DrawState, total_pigs: int) -> str:
254
+ """生成文本版猪圈摘要;图片版图鉴由“小猪图鉴”命令独立提供。"""
255
+ user_count = len(draw_state.pig_ids)
256
+ percent = int((user_count / total_pigs) * 100) if total_pigs > 0 else 0
257
+
258
+ ranked_progress = sorted(
259
+ draw_state.progress.items(),
260
+ key=lambda item: (-item[1].copies, item[1].first_obtained_at or "", item[0]),
261
+ )
262
+ favorite_line = "🐷 本命猪:暂无"
263
+ top_repeat_line = "⭐ 高等级小猪:暂无重复猪,猪圈还很清新"
264
+ max_level = 0
265
+ maxed_count = 0
266
+ if ranked_progress:
267
+ levels = [get_expert_level(progress.copies) for _, progress in ranked_progress]
268
+ max_level = max(levels)
269
+ maxed_count = sum(1 for level in levels if level >= MAX_EXPERT_LEVEL)
270
+
271
+ favorite_id, favorite_progress = ranked_progress[0]
272
+ favorite = get_pig_by_id(favorite_id)
273
+ favorite_name = favorite.get("name", favorite_id) if favorite else favorite_id
274
+ favorite_level = get_expert_level(favorite_progress.copies)
275
+ favorite_line = f"🐷 本命猪:【{favorite_name}】EX Lv.{favorite_level}(累计 {favorite_progress.copies} 次)"
276
+
277
+ repeat_items = [
278
+ (pig_id, progress)
279
+ for pig_id, progress in ranked_progress
280
+ if progress.copies >= 2
281
+ ][:5]
282
+ if repeat_items:
283
+ parts = []
284
+ for pig_id, progress in repeat_items:
285
+ pig = get_pig_by_id(pig_id)
286
+ pig_name = pig.get("name", pig_id) if pig else pig_id
287
+ parts.append(f"【{pig_name}】EX Lv.{get_expert_level(progress.copies)}")
288
+ top_repeat_line = "⭐ 高等级小猪:" + "、".join(parts)
289
+
290
+ if draw_state.duplicate_streak > 0:
291
+ streak_line = f"🔥 连续重复:{draw_state.duplicate_streak} 次(新猪气息正在靠近)"
292
+ else:
293
+ streak_line = "🔥 连续重复:0 次(下一只从平常心开始)"
294
+
295
+ footer_line = "发送「今日小猪」开始收集。" if user_count <= 0 else "发送「小猪图鉴」查看图片版完整图鉴。"
296
+
297
+ return (
298
+ f"【我的猪圈统计】\n"
299
+ f"👑 猪圈主人:{user_name}\n"
300
+ f"📦 已收集:{user_count} / {total_pigs} 只\n"
301
+ f"📈 收藏率:{percent}%\n"
302
+ f"🏅 最高等级:EX Lv. {max_level}|满级 {maxed_count} 只\n"
303
+ f"{favorite_line}\n"
304
+ f"{top_repeat_line}\n"
305
+ f"{streak_line}\n"
306
+ f"━━━━━━━━━━━━━━\n"
307
+ f"{footer_line}"
308
+ )
309
+
310
+
311
+ def is_superuser_user(user_id: str) -> bool:
312
+ superusers = {str(x) for x in getattr(get_driver().config, "superusers", set())}
313
+ if user_id in superusers:
314
+ return True
315
+ return any(s.endswith(f":{user_id}") for s in superusers)
316
+
317
+
318
+ def detect_force_roast_mode(raw_text: str, user_id: str) -> Optional[str]:
319
+ normalized = raw_text.replace("/", "").replace(" ", "").replace(" ", "")
320
+ has_super_cmd = SUPER_FORCE_ROAST_KEYWORD in normalized
321
+ has_force_cmd = any(k in normalized for k in FORCE_ROAST_KEYWORDS)
322
+
323
+ if has_super_cmd:
324
+ return "super" if is_superuser_user(user_id) else "super_denied"
325
+ if has_force_cmd:
326
+ return "normal"
327
+ return None
328
+
329
+
330
+ def pick_backfire_text(attacker_name: str, target_name: str, attacker_pig: Optional[dict]) -> str:
331
+ if not attacker_pig:
332
+ pool = BACKFIRE_NO_PIG_TEXTS
333
+ shape = "未抽形态"
334
+ elif is_human_pig(attacker_pig):
335
+ pool = BACKFIRE_HUMAN_TEXTS
336
+ shape = "人类"
337
+ elif is_eaten_pig(attacker_pig):
338
+ pool = BACKFIRE_EATEN_TEXTS
339
+ shape = "吃掉了"
340
+ elif is_sold_pig(attacker_pig):
341
+ pool = BACKFIRE_SOLD_TEXTS
342
+ shape = "卖掉了"
343
+ elif is_food_pig(attacker_pig):
344
+ pool = BACKFIRE_FOOD_TEXTS
345
+ shape = attacker_pig.get("name", "熟食")
346
+ else:
347
+ pool = BACKFIRE_GENERIC_TEXTS
348
+ shape = attacker_pig.get("name", "未知形态")
349
+
350
+ return random.choice(pool).format(attacker=attacker_name, target=target_name, shape=shape)
351
+
352
+
353
+ # ================================ 反噬第二段主语显式化 ================================ #
354
+ # 反噬图片会保留“两段式”结构:
355
+ # 1. 第一段:反噬前缀文案(说明 A 想烤 B 但翻车)
356
+ # 2. 第二段:烧烤结果文案(说明被做成了什么)
357
+ #
358
+ # 现有第二段有时来自默认模板或 AI 文案,常以“你”开头,放在反噬场景里容易让人一时
359
+ # 读不出来到底是谁被烤了。这里仅在“反噬分支”里把第二段主语锚定为攻击者本人,不影响
360
+ # 正常烧烤、今日烤猪或 AI 生成逻辑。
361
+ def clarify_backfire_roast_text(roast_text: str, attacker_name: str) -> str:
362
+ """将反噬场景的第二段烧烤文案明确指向攻击者本人。"""
363
+ normalized_text = (roast_text or "").strip()
364
+ if not normalized_text:
365
+ return normalized_text
366
+
367
+ if attacker_name and attacker_name in normalized_text:
368
+ return normalized_text
369
+
370
+ attacker_label = f"【{attacker_name or '对方'}】"
371
+ subject_replacements = (
372
+ ("曾经你", f"曾经{attacker_label}"),
373
+ ("如今你", f"如今{attacker_label}"),
374
+ ("生前你", f"生前{attacker_label}"),
375
+ ("原本你", f"原本{attacker_label}"),
376
+ ("原来你", f"原来{attacker_label}"),
377
+ ("你本是一只", f"{attacker_label}本是一只"),
378
+ ("你本是", f"{attacker_label}本是"),
379
+ ("你曾经是", f"{attacker_label}曾经是"),
380
+ ("你曾是", f"{attacker_label}曾是"),
381
+ ("你虽然", f"{attacker_label}虽然"),
382
+ ("你从", f"{attacker_label}从"),
383
+ ("看看你", f"看看{attacker_label}"),
384
+ ("可怜的你", f"可怜的{attacker_label}"),
385
+ ("没想到你", f"没想到{attacker_label}"),
386
+ )
387
+ for old_text, new_text in subject_replacements:
388
+ if old_text in normalized_text:
389
+ return normalized_text.replace(old_text, new_text, 1)
390
+
391
+ if "你" in normalized_text:
392
+ return normalized_text.replace("你", attacker_label, 1)
393
+
394
+ return f"{attacker_label}原本想把别人送上烤架,结果最后被端上桌的却是自己。{normalized_text}"
395
+
396
+
397
+ def pick_escape_text(attacker_name: str, target_name: str, target_pig: Optional[dict]) -> str:
398
+ shape = target_pig.get("name", "未知形态") if target_pig else "未知形态"
399
+ return random.choice(ESCAPE_TEXTS).format(attacker=attacker_name, target=target_name, shape=shape)
400
+
401
+
402
+ def pick_force_prefix_text(target_name: str, is_super_mode: bool) -> str:
403
+ pool = SUPER_FORCE_ROAST_PREFIX_TEXTS if is_super_mode else FORCE_ROAST_PREFIX_TEXTS
404
+ return random.choice(pool).format(target=target_name)
405
+
406
+
407
+ def pick_force_limit_text(operator_name: str, target_name: str) -> str:
408
+ return random.choice(FORCE_ROAST_LIMIT_TEXTS).format(operator=operator_name, target=target_name)
409
+
410
+
411
+ def get_event_group_id(event: Event) -> str:
412
+ return str(event.group_id) if isinstance(event, GroupMessageEvent) else ""
413
+
414
+
415
+ def get_event_user_name(event: Event) -> str:
416
+ sender = getattr(event, "sender", None)
417
+ if sender:
418
+ return getattr(sender, "card", "") or getattr(sender, "nickname", "") or str(getattr(event, "user_id", ""))
419
+ return str(getattr(event, "user_id", ""))
420
+
421
+
422
+ async def get_group_roll_candidates(bot: Bot, group_id: int, exclude_ids: set[str]) -> list[str]:
423
+ """优先按当前群成员范围筛候选;接口异常时回退到群内已登记过的今日形态。"""
424
+ today = rollpig_date_str()
425
+ today_rolls = await store.get_daily_rolls(today)
426
+
427
+ try:
428
+ members = await bot.call_api("get_group_member_list", group_id=group_id)
429
+ member_ids = {
430
+ str(member.get("user_id"))
431
+ for member in members
432
+ if member.get("user_id") is not None
433
+ }
434
+ return [uid for uid in today_rolls if uid in member_ids and uid not in exclude_ids]
435
+ except Exception as e:
436
+ logger.debug(f"获取群成员列表失败: group={group_id} error={e}")
437
+ group_rolls = await store.get_group_rolls(str(group_id), today)
438
+ return [uid for uid in group_rolls if uid not in exclude_ids]
439
+
440
+
441
+ def format_cooldown_message(remaining_seconds: int) -> str:
442
+ remaining = max(0, int(remaining_seconds))
443
+ minutes, seconds = divmod(remaining, 60)
444
+ hours, minutes = divmod(minutes, 60)
445
+ time_str = f"{hours}小时{minutes}分" if hours > 0 else f"{minutes}分{seconds}秒"
446
+ return f"烧烤充能恢复中!还需要 {time_str} 恢复 1 次。"
447
+
448
+
449
+ # ================================ 群开关守卫 ================================ #
450
+ # 这里统一拦截群聊中的 rollpig 指令入口。
451
+ # 一旦宿主项目(如 nekobot_v2)给 runtime 挂上了外部群开关检查器,
452
+ # 未启用的群将直接静默跳过;没有接控制台时则默认放行。
453
+ def guard_group_enabled(matcher):
454
+ def decorator(func):
455
+ @wraps(func)
456
+ async def wrapper(*args, **kwargs):
457
+ event = kwargs.get("event")
458
+ if event is None:
459
+ for arg in args:
460
+ if isinstance(arg, Event):
461
+ event = arg
462
+ break
463
+
464
+ group_id = get_event_group_id(event) if isinstance(event, Event) else ""
465
+ if group_id and not is_group_rollpig_enabled(group_id):
466
+ logger.debug(f"rollpig 群功能未启用,跳过处理: group={group_id}")
467
+ await matcher.finish()
468
+
469
+ return await func(*args, **kwargs)
470
+
471
+ return wrapper
472
+
473
+ return decorator
474
+
475
+
476
+ def guard_store_errors(matcher, message: str = "猪圈云账本暂时离线,请稍后再试。"):
477
+ def decorator(func):
478
+ @wraps(func)
479
+ async def wrapper(*args, **kwargs):
480
+ event = kwargs.get("event")
481
+ if event is None:
482
+ for arg in args:
483
+ if isinstance(arg, Event):
484
+ event = arg
485
+ break
486
+
487
+ try:
488
+ return await func(*args, **kwargs)
489
+ except CloudStoreError as error:
490
+ logger.warning(f"rollpig cloud store unavailable: {error}")
491
+ if event is not None:
492
+ await matcher.finish(MessageSegment.reply(event.message_id) + message)
493
+ await matcher.finish(message)
494
+
495
+ return wrapper
496
+
497
+ return decorator
498
+
499
+
500
+ def normalize_pighub_image_item(item: dict) -> Optional[dict]:
501
+ """把 PigHub 新旧 API 条目归一成命令层使用的 title/thumbnail/filename 格式。"""
502
+ if not isinstance(item, dict):
503
+ return None
504
+
505
+ # PigHub 旧接口使用 thumbnail,新接口使用 image_url;命令层继续读 thumbnail。
506
+ thumbnail = item.get("thumbnail") or item.get("image_url")
507
+ if not isinstance(thumbnail, str) or not thumbnail:
508
+ return None
509
+
510
+ title = item.get("title")
511
+ filename = item.get("filename") or thumbnail.split("/")[-1]
512
+ normalized = dict(item)
513
+ normalized["thumbnail"] = thumbnail
514
+ normalized["title"] = str(title or filename or "未命名小猪")
515
+ normalized["filename"] = str(filename or "")
516
+ return normalized
517
+
518
+
519
+ def parse_pighub_images_payload(data: dict, api_url: str) -> list[dict]:
520
+ """解析 PigHub 新旧 API 返回值,并过滤掉缺少图片地址的异常条目。"""
521
+ if not isinstance(data, dict):
522
+ raise ValueError(f"PigHub 返回结构异常({api_url}):不是 JSON 对象")
523
+
524
+ # 新接口:/api/images?sort=2 -> {"code": 0, "message": "OK", "data": [...]}
525
+ # 旧接口:/api/all-images -> {"images": [...]}
526
+ raw_items = data.get("data") if isinstance(data.get("data"), list) else data.get("images")
527
+ if not isinstance(raw_items, list):
528
+ raise ValueError(f"PigHub 返回结构异常({api_url}):缺少 data/images 列表")
529
+
530
+ valid = []
531
+ for item in raw_items:
532
+ normalized = normalize_pighub_image_item(item)
533
+ if normalized:
534
+ valid.append(normalized)
535
+ if not valid:
536
+ raise ValueError(f"PigHub 返回空图集({api_url})")
537
+ return valid
538
+
539
+
540
+ async def ensure_pighub_images_loaded() -> bool:
541
+ """
542
+ 懒加载 PigHub 图库,带 TTL(默认6小时)自动刷新。
543
+ 刷新失败时回退旧缓存,避免因网络抖动打挂功能。
544
+ """
545
+ global pighub_images, pighub_last_loaded
546
+ now = time.time()
547
+
548
+ # 缓存有效:直接返回
549
+ if pighub_images and (now - pighub_last_loaded) < PIGHUB_TTL_SECONDS:
550
+ return True
551
+
552
+ last_error: Exception | None = None
553
+ try:
554
+ async with httpx.AsyncClient(timeout=10) as client:
555
+ for api_url in PIGHUB_API_URLS:
556
+ try:
557
+ resp = await client.get(api_url)
558
+ resp.raise_for_status()
559
+ pighub_images = parse_pighub_images_payload(resp.json(), api_url)
560
+ pighub_last_loaded = now
561
+ return True
562
+ except Exception as error:
563
+ # PigHub 当前新旧接口有切换历史;单个接口失败时继续尝试下一个。
564
+ last_error = error
565
+ logger.warning(f"PigHub 接口刷新失败,尝试备用接口:url={api_url}, error={error}")
566
+ except Exception as error:
567
+ last_error = error
568
+
569
+ if last_error:
570
+ if pighub_images:
571
+ # 刷新失败但有旧缓存:继续使用,不打挂功能
572
+ logger.warning(f"PigHub 刷新失败,继续使用旧缓存({len(pighub_images)} 张): {last_error}")
573
+ return True
574
+ logger.warning(f"PigHub 连接失败: {last_error}")
575
+ return False
576
+
577
+
578
+ def build_pighub_image_url(pig_item: dict) -> Optional[str]:
579
+ thumbnail = pig_item.get("thumbnail")
580
+ if not isinstance(thumbnail, str) or not thumbnail:
581
+ return None
582
+
583
+ # 新接口返回 /images/xxx 或完整 URL;旧接口可能只有文件名或 /data/xxx。
584
+ if thumbnail.startswith(("http://", "https://")):
585
+ image_url = thumbnail
586
+ elif thumbnail.startswith("/"):
587
+ image_url = urljoin(PIGHUB_ORIGIN, thumbnail)
588
+ else:
589
+ image_url = PIGHUB_IMAGE_BASE_URL + thumbnail.split("/")[-1]
590
+
591
+ # 中文文件名和 emoji 需要百分号编码,避免部分发送端/下载端无法识别 URL。
592
+ parsed = urlsplit(image_url)
593
+ return urlunsplit((parsed.scheme, parsed.netloc, quote(parsed.path, safe="/%"), parsed.query, parsed.fragment))
594
+
595
+
596
+ def match_pighub_keyword(pig_item: dict, keyword: str) -> bool:
597
+ """按 PigHub 前端的搜索习惯,同时匹配标题和文件名。"""
598
+ lowered = keyword.lower()
599
+ return any(
600
+ lowered in str(pig_item.get(field, "")).lower()
601
+ for field in ("title", "filename")
602
+ )
603
+
604
+ # ================= 辅助渲染函数 =================
605
+
606
+ async def send_rendered_pig(matcher, event, pig_data: dict, extra_text: str = ""):
607
+ started_at = time.perf_counter()
608
+ pig_id = pig_data.get("id", "")
609
+ avatar_file = find_image_file(pig_id)
610
+ avatar_uri = avatar_file.as_uri() if avatar_file else ""
611
+ name = pig_data.get("name", "未知小猪")
612
+ desc = pig_data.get("description", "")
613
+ analysis = pig_data.get("analysis", "你今天是只神秘小猪。")
614
+ payload_ready_at = time.perf_counter()
615
+
616
+ pic = None
617
+ try:
618
+ render_started_at = time.perf_counter()
619
+ async with html_render_budget("pig-card"):
620
+ pic = await template_to_pic(
621
+ template_path=RES_DIR,
622
+ template_name="template.html",
623
+ templates={
624
+ "avatar": avatar_uri,
625
+ "name": name,
626
+ "desc": desc,
627
+ "analysis": analysis,
628
+ },
629
+ )
630
+ render_finished_at = time.perf_counter()
631
+ except Exception as e:
632
+ logger.error(f"图片渲染失败: pig_id={pig_id}, error={e}")
633
+ await matcher.finish("图片生成失败。")
634
+ return
635
+
636
+ msg = MessageSegment.reply(event.message_id)
637
+ if extra_text:
638
+ msg += extra_text + "\n"
639
+ msg += MessageSegment.image(pic)
640
+ ready_to_send_at = time.perf_counter()
641
+ log_perf(
642
+ f"rollpig card rendered: pig_id={pig_id} name={name} "
643
+ f"image_found={avatar_file is not None} "
644
+ f"payload={payload_ready_at - started_at:.2f}s "
645
+ f"screenshot={render_finished_at - render_started_at:.2f}s "
646
+ f"message={ready_to_send_at - render_finished_at:.2f}s "
647
+ f"total_before_send={ready_to_send_at - started_at:.2f}s "
648
+ f"bytes={len(pic)} extra={bool(extra_text)}"
649
+ )
650
+ await matcher.finish(msg)
651
+
652
+
653
+ async def sync_rollpig_resources(force: bool = False) -> str:
654
+ """同步公有云端资源与可选私有 overlay;成功后立即刷新内存快照。"""
655
+ public_result, private_result = await pig_resource_manager.sync_all(force=force, wait_if_busy=force)
656
+ if public_result.updated or private_result.updated:
657
+ PIG_LIST[:] = pig_resource_manager.pig_list
658
+
659
+ messages = []
660
+ for result in (public_result, private_result):
661
+ if result.message:
662
+ messages.append(result.message)
663
+ return ";".join(messages) or "小猪资源无需同步"
664
+
665
+
666
+ # ================= 指令处理区域 =================
667
+
668
+ # 0. 小猪资源同步(管理员)
669
+ cmd_sync_resources = on_command("同步小猪资源", aliases={"刷新小猪图鉴"}, block=True)
670
+
671
+
672
+ @cmd_sync_resources.handle()
673
+ async def _(event: Event):
674
+ user_id = str(event.user_id)
675
+ if not is_superuser_user(user_id):
676
+ await cmd_sync_resources.finish(MessageSegment.reply(event.message_id) + "只有超级用户可以同步小猪资源。")
677
+ return
678
+
679
+ try:
680
+ message = await sync_rollpig_resources(force=True)
681
+ except Exception as error:
682
+ logger.error(f"rollpig 小猪资源手动同步失败: {error}")
683
+ await cmd_sync_resources.finish(MessageSegment.reply(event.message_id) + f"小猪资源同步失败:{error}")
684
+ return
685
+
686
+ await cmd_sync_resources.finish(
687
+ MessageSegment.reply(event.message_id)
688
+ + f"{message}\n当前资源版本:{pig_resource_manager.resource_version}|小猪数量:{len(PIG_LIST)}"
689
+ )
690
+
691
+
692
+ # 1. 今日小猪
693
+ cmd_today = on_command("今天是什么小猪", aliases={"今日小猪"}, block=True)
694
+
695
+ @cmd_today.handle()
696
+ @guard_group_enabled(cmd_today)
697
+ @guard_store_errors(cmd_today)
698
+ async def _(event: Event):
699
+ user_id = str(event.user_id)
700
+ group_id = get_event_group_id(event)
701
+ pig_id = await store.get_daily_roll(user_id)
702
+ pig = get_pig_by_id(pig_id)
703
+ extra_text = ""
704
+
705
+ if not pig:
706
+ if not PIG_LIST:
707
+ await cmd_today.finish("猪圈塌房了(数据缺失)")
708
+ return
709
+ proposed_pig = await pick_daily_roll_candidate(user_id)
710
+ roll_result = await store.get_or_create_daily_roll(
711
+ user_id,
712
+ proposed_pig["id"],
713
+ group_id=group_id,
714
+ )
715
+ pig = get_pig_by_id(roll_result.pig_id) or proposed_pig
716
+ extra_text = build_roll_growth_text(roll_result, pig)
717
+ elif group_id:
718
+ await store.mark_group_roll_seen(user_id, pig["id"], group_id)
719
+
720
+ await send_rendered_pig(cmd_today, event, pig, extra_text=extra_text)
721
+
722
+
723
+ # 2. 随机小猪
724
+ cmd_roll = on_command("随机小猪", block=True)
725
+
726
+ @cmd_roll.handle()
727
+ @guard_group_enabled(cmd_roll)
728
+ async def _(bot: Bot, event: Event, args: Message = CommandArg()):
729
+ if not await ensure_pighub_images_loaded():
730
+ await cmd_roll.finish("连不上 PigHub...")
731
+ return
732
+
733
+ text = args.extract_plain_text().strip()
734
+ try:
735
+ count = int(text) if text else 1
736
+ except ValueError:
737
+ count = 1
738
+ count = max(1, min(count, 10))
739
+
740
+ pig = random.choice(pighub_images)
741
+ image_url = build_pighub_image_url(pig)
742
+ if not image_url:
743
+ await cmd_roll.finish("PigHub 返回了异常图片数据,请稍后再试。")
744
+ return
745
+
746
+ if count == 1:
747
+ await cmd_roll.finish(MessageSegment.reply(event.message_id) + MessageSegment.image(image_url))
748
+ return
749
+
750
+ # 私聊不支持合并转发,降级为单张
751
+ if not isinstance(event, GroupMessageEvent):
752
+ await cmd_roll.finish(
753
+ MessageSegment.reply(event.message_id)
754
+ + "私聊暂不支持多张连发,先给你一张:\n"
755
+ + MessageSegment.image(image_url)
756
+ )
757
+ return
758
+
759
+ # 多图去重:用 sample 避免重复(若图库数量不足则取全部)
760
+ pool_size = min(count, len(pighub_images))
761
+ selected = random.sample(pighub_images, pool_size)
762
+
763
+ messages = []
764
+ for pig in selected:
765
+ url = build_pighub_image_url(pig)
766
+ if not url:
767
+ continue
768
+ messages.append({
769
+ "type": "node",
770
+ "data": {
771
+ "name": "随机小猪Bot",
772
+ "uin": event.self_id,
773
+ "content": Message(pig.get("title", "随机小猪")) + MessageSegment.image(url),
774
+ },
775
+ })
776
+
777
+ if not messages:
778
+ await cmd_roll.finish("PigHub 图片数据异常,请稍后再试。")
779
+ return
780
+
781
+ await bot.send_group_forward_msg(group_id=event.group_id, messages=messages)
782
+
783
+
784
+ # 2.5 找猪
785
+ cmd_find = on_command("找猪", aliases={"搜猪"}, block=True)
786
+
787
+ @cmd_find.handle()
788
+ @guard_group_enabled(cmd_find)
789
+ async def _(bot: Bot, event: Event, args: Message = CommandArg()):
790
+ if not await ensure_pighub_images_loaded():
791
+ await cmd_find.finish("连不上 PigHub,请稍后再试。")
792
+ return
793
+
794
+ keyword = args.extract_plain_text().strip()
795
+ if not keyword:
796
+ await cmd_find.finish("请加上关键词,如:/找猪 玩偶")
797
+ return
798
+
799
+ found_pigs = [pig for pig in pighub_images if match_pighub_keyword(pig, keyword)]
800
+ if not found_pigs:
801
+ await cmd_find.finish(f"没找到叫「{keyword}」的猪。")
802
+ return
803
+
804
+ if isinstance(event, GroupMessageEvent):
805
+ messages = []
806
+ count = min(len(found_pigs), 10)
807
+ for i in range(count):
808
+ pig = found_pigs[i]
809
+ image_url = build_pighub_image_url(pig)
810
+ if not image_url:
811
+ continue
812
+ messages.append({
813
+ "type": "node",
814
+ "data": {
815
+ "name": "搜猪小助手",
816
+ "uin": event.self_id,
817
+ "content": Message(pig.get("title", "未命名小猪")) + MessageSegment.image(image_url),
818
+ },
819
+ })
820
+ if not messages:
821
+ await cmd_find.finish("搜索结果数据异常,请稍后再试。")
822
+ return
823
+ await bot.send_group_forward_msg(group_id=event.group_id, messages=messages)
824
+ return
825
+
826
+ # 私聊降级:展示首条匹配
827
+ pig = found_pigs[0]
828
+ image_url = build_pighub_image_url(pig)
829
+ if not image_url:
830
+ await cmd_find.finish("搜索结果数据异常,请稍后再试。")
831
+ return
832
+ msg = Message(pig.get("title", "未命名小猪"))
833
+ msg += MessageSegment.image(image_url)
834
+ if len(found_pigs) > 1:
835
+ msg += Message(f"\n共找到 {len(found_pigs)} 张,私聊仅展示第 1 张。")
836
+ await cmd_find.finish(MessageSegment.reply(event.message_id) + msg)
837
+
838
+
839
+ # 3. 明日小猪
840
+ cmd_tmr = on_command("明日小猪", block=True)
841
+
842
+ @cmd_tmr.handle()
843
+ @guard_group_enabled(cmd_tmr)
844
+ async def _(event: Event):
845
+ await cmd_tmr.finish(MessageSegment.reply(event.message_id) + random.choice(TOMORROW_TEXTS))
846
+
847
+
848
+ # 4. 昨日小猪
849
+ cmd_yest = on_command("昨日小猪", block=True)
850
+
851
+ @cmd_yest.handle()
852
+ @guard_group_enabled(cmd_yest)
853
+ @guard_store_errors(cmd_yest)
854
+ async def _(event: Event):
855
+ user_id = str(event.user_id)
856
+ yesterday = rollpig_date_str(-1)
857
+ pig = get_pig_by_id(await store.get_pig_by_date(user_id, yesterday))
858
+
859
+ if not pig:
860
+ await cmd_yest.finish(MessageSegment.reply(event.message_id) + "你昨天没抽猪。")
861
+ msg = f"你昨天是一只【{pig['name']}】!"
862
+ await send_rendered_pig(cmd_yest, event, pig, extra_text=msg)
863
+
864
+
865
+ # 5. 今日烤猪
866
+ cmd_roast = on_command("今日烤猪", block=True)
867
+
868
+ @cmd_roast.handle()
869
+ @guard_group_enabled(cmd_roast)
870
+ @guard_store_errors(cmd_roast)
871
+ async def _(event: Event):
872
+ user_id = str(event.user_id)
873
+ group_id = get_event_group_id(event)
874
+ attacker_name = get_event_user_name(event)
875
+ original_pig = get_pig_by_id(await store.get_daily_roll(user_id))
876
+
877
+ auto_roll_hint = ""
878
+ if not original_pig:
879
+ if not PIG_LIST:
880
+ await cmd_roast.finish(MessageSegment.reply(event.message_id) + "猪圈埋房了(数据缺失)")
881
+ return
882
+ proposed_pig = await pick_daily_roll_candidate(user_id)
883
+ roll_result = await store.get_or_create_daily_roll(
884
+ user_id,
885
+ proposed_pig["id"],
886
+ group_id=group_id,
887
+ )
888
+ original_pig = get_pig_by_id(roll_result.pig_id) or proposed_pig
889
+ auto_roll_hint = random.choice(AUTO_ROLL_ROAST_TEXTS).format(name=original_pig["name"]) + "\n"
890
+ elif group_id:
891
+ await store.mark_group_roll_seen(user_id, original_pig["id"], group_id)
892
+
893
+ if is_human_pig(original_pig):
894
+ await cmd_roast.finish(
895
+ MessageSegment.reply(event.message_id)
896
+ + random.choice(TODAY_ROAST_HUMAN_BLOCK_TEXTS)
897
+ )
898
+ return
899
+
900
+ if is_eaten_pig(original_pig):
901
+ await cmd_roast.finish(
902
+ MessageSegment.reply(event.message_id)
903
+ + random.choice(TODAY_ROAST_EATEN_BLOCK_TEXTS)
904
+ )
905
+ return
906
+
907
+ if is_sold_pig(original_pig):
908
+ await cmd_roast.finish(
909
+ MessageSegment.reply(event.message_id)
910
+ + random.choice(TODAY_ROAST_SOLD_BLOCK_TEXTS)
911
+ )
912
+ return
913
+
914
+ if is_food_pig(original_pig):
915
+ await cmd_roast.finish(
916
+ MessageSegment.reply(event.message_id)
917
+ + random.choice(TODAY_ROAST_FOOD_BLOCK_TEXTS).format(shape=original_pig.get("name", "熟食"))
918
+ )
919
+ return
920
+
921
+ food_id = random.choice(get_food_pig_ids())
922
+ food_pig_template = get_pig_by_id(food_id)
923
+ if not food_pig_template:
924
+ await cmd_roast.finish("食材配置缺失,请检查 pig.json。")
925
+ return
926
+
927
+ roast_text = await roast_manager.get_roast_text(original_pig, food_pig_template)
928
+ roasted_pig_data = food_pig_template.copy()
929
+ roasted_pig_data["analysis"] = roast_text
930
+
931
+ if group_id:
932
+ await store.append_roast_event(
933
+ RoastEvent(
934
+ event_type="self_roast",
935
+ attacker_id=user_id,
936
+ target_id=user_id,
937
+ attacker_name=attacker_name,
938
+ target_name=attacker_name,
939
+ food=food_pig_template["name"],
940
+ group_id=group_id,
941
+ )
942
+ )
943
+ await send_rendered_pig(cmd_roast, event, roasted_pig_data, extra_text=auto_roll_hint)
944
+
945
+
946
+ # 5.5 烤群友
947
+ # `加急生火` 是日常使用频率最高的后门口令,因此额外开放为直达触发命令。
948
+ # 旧写法 `烤群友 加急生火 @某人` 保持兼容;这里只是让高频输入更顺手。
949
+ cmd_roast_member = on_command("烤群友", aliases={"加急生火"}, block=True)
950
+
951
+ @cmd_roast_member.handle()
952
+ @guard_group_enabled(cmd_roast_member)
953
+ @guard_store_errors(cmd_roast_member)
954
+ async def _(bot: Bot, event: GroupMessageEvent):
955
+ attacker_id = str(event.user_id)
956
+ attacker_name = event.sender.card or event.sender.nickname
957
+ group_id = str(event.group_id)
958
+ force_mode = detect_force_roast_mode(event.get_plaintext(), attacker_id)
959
+ attacker_pig = get_pig_by_id(await store.get_daily_roll(attacker_id))
960
+
961
+ if attacker_pig:
962
+ await store.mark_group_roll_seen(attacker_id, attacker_pig["id"], group_id)
963
+
964
+ if force_mode == "super_denied":
965
+ await cmd_roast_member.finish(
966
+ MessageSegment.reply(event.message_id) + "口令【强行点火】仅 superuser 可用。"
967
+ )
968
+ return
969
+
970
+ # 提取目标 ID 和名字
971
+ target_id = None
972
+ target_name = "群友"
973
+
974
+ if event.reply:
975
+ target_id = str(event.reply.sender.user_id)
976
+ target_name = event.reply.sender.card or event.reply.sender.nickname
977
+ else:
978
+ for seg in event.message:
979
+ if seg.type == "at":
980
+ target_id = str(seg.data["qq"])
981
+ target_name = "对方"
982
+ break
983
+
984
+ # @Bot 时框架会把 at 消费掉,补充判断
985
+ if not target_id and event.to_me:
986
+ target_id = str(event.self_id)
987
+
988
+ # 尝试获取更准确的 target_name
989
+ if target_id:
990
+ try:
991
+ member_info = await bot.get_group_member_info(group_id=event.group_id, user_id=int(target_id))
992
+ target_name = member_info.get("card") or member_info.get("nickname")
993
+ except Exception as e:
994
+ logger.debug(f"获取群成员信息失败: group={event.group_id} user={target_id} error={e}")
995
+
996
+ if not target_id:
997
+ await cmd_roast_member.finish("请 At 或回复你要烤的群友!")
998
+ return
999
+
1000
+ if target_id == attacker_id:
1001
+ await cmd_roast_member.finish("对自己好一点,别自焚。请发送「今日烤猪」。")
1002
+ return
1003
+
1004
+ # 检测目标是否是 Bot 自身 → 特殊反噬,不消耗 CD,纯文本回复
1005
+ if target_id == str(event.self_id):
1006
+ food_id = random.choice(get_food_pig_ids())
1007
+ food_pig = get_pig_by_id(food_id)
1008
+ food_name = food_pig["name"] if food_pig else "美食"
1009
+ bot_text = random.choice(ROAST_BOT_TEXTS).format(attacker=attacker_name, food=food_name)
1010
+ logger.info(f"[烤群友→Bot] 特殊反噬 | 凶手={attacker_name}({attacker_id}) 变成={food_name}")
1011
+ await store.append_roast_event(
1012
+ RoastEvent(
1013
+ event_type="bot_backfire",
1014
+ attacker_id=attacker_id,
1015
+ target_id=target_id,
1016
+ attacker_name=attacker_name,
1017
+ target_name=target_name,
1018
+ food=food_name,
1019
+ group_id=group_id,
1020
+ )
1021
+ )
1022
+ await cmd_roast_member.finish(MessageSegment.reply(event.message_id) + bot_text)
1023
+ return
1024
+ # 读取目标形态(后门模式也不绕过此检查)
1025
+ target_pig = get_pig_by_id(await store.get_daily_roll(target_id))
1026
+ if not target_pig:
1027
+ await cmd_roast_member.finish(
1028
+ MessageSegment.reply(event.message_id) + f"【{target_name}】今天还没抽猪,没法下嘴!"
1029
+ )
1030
+ return
1031
+ await store.mark_group_roll_seen(target_id, target_pig["id"], group_id)
1032
+
1033
+ # 保护检查:被烤最多的用户次日受保护(后门可突破)
1034
+ if await store.is_protected(group_id, target_id):
1035
+ if force_mode in {"normal", "super"}:
1036
+ break_text = random.choice(PROTECTION_BREAK_TEXTS).format(target=target_name)
1037
+ logger.info(f"[烤群友] 保护被突破 | 凶手={attacker_name}({attacker_id}) 目标={target_name}({target_id})")
1038
+ await cmd_roast_member.send(MessageSegment.reply(event.message_id) + break_text)
1039
+ else:
1040
+ prot_text = random.choice(PROTECTION_BLOCK_TEXTS).format(target=target_name)
1041
+ await cmd_roast_member.finish(MessageSegment.reply(event.message_id) + prot_text)
1042
+ return
1043
+
1044
+ if is_human_pig(target_pig):
1045
+ await cmd_roast_member.finish(
1046
+ MessageSegment.reply(event.message_id)
1047
+ + random.choice(TARGET_HUMAN_BLOCK_TEXTS).format(target=target_name)
1048
+ )
1049
+ return
1050
+
1051
+ if is_eaten_pig(target_pig):
1052
+ await cmd_roast_member.finish(
1053
+ MessageSegment.reply(event.message_id)
1054
+ + random.choice(TARGET_EATEN_BLOCK_TEXTS).format(target=target_name)
1055
+ )
1056
+ return
1057
+
1058
+ if is_sold_pig(target_pig):
1059
+ await cmd_roast_member.finish(
1060
+ MessageSegment.reply(event.message_id)
1061
+ + random.choice(TARGET_SOLD_BLOCK_TEXTS).format(target=target_name)
1062
+ )
1063
+ return
1064
+
1065
+ if is_food_pig(target_pig):
1066
+ await cmd_roast_member.finish(
1067
+ MessageSegment.reply(event.message_id)
1068
+ + random.choice(TARGET_FOOD_BLOCK_TEXTS).format(
1069
+ target=target_name, shape=target_pig.get("name", "熟食")
1070
+ )
1071
+ )
1072
+ return
1073
+
1074
+ # 模式化限制/计数
1075
+ if force_mode == "normal":
1076
+ if not await store.consume_force_usage(attacker_id):
1077
+ reject_text = pick_force_limit_text(attacker_name, target_name)
1078
+ await cmd_roast_member.finish(MessageSegment.reply(event.message_id) + reject_text)
1079
+ return
1080
+ elif force_mode is None:
1081
+ cooldown_result = await store.consume_roast_cooldown(
1082
+ attacker_id,
1083
+ cooldown_seconds=resolve_roast_cooldown_seconds(),
1084
+ max_charges=resolve_roast_charge_max(),
1085
+ )
1086
+ if not cooldown_result.allowed:
1087
+ await cmd_roast_member.finish(
1088
+ MessageSegment.reply(event.message_id) + format_cooldown_message(cooldown_result.remaining_seconds)
1089
+ )
1090
+ return
1091
+ # super 模式:无限制,不消耗后门次数,不走 CD
1092
+
1093
+ # --- 后门模式:必定成功 ---
1094
+ if force_mode in {"normal", "super"}:
1095
+ food_id = random.choice(get_food_pig_ids())
1096
+ food_pig_template = get_pig_by_id(food_id)
1097
+ if not food_pig_template:
1098
+ await cmd_roast_member.finish("食材配置缺失,请联系管理员修复 pig.json。")
1099
+ return
1100
+
1101
+ text = await roast_manager.get_roast_text(
1102
+ target_pig, food_pig_template,
1103
+ operator_name=attacker_name, target_name=target_name,
1104
+ )
1105
+ prefix_text = pick_force_prefix_text(target_name, is_super_mode=(force_mode == "super"))
1106
+
1107
+ logger.info(
1108
+ f"[烤群友] 后门成功 | 凶手={attacker_name}({attacker_id}) "
1109
+ f"目标={target_name}({target_id}) 模式={force_mode} 结果={food_pig_template['name']}"
1110
+ )
1111
+ await store.append_roast_event(
1112
+ RoastEvent(
1113
+ event_type="success",
1114
+ attacker_id=attacker_id,
1115
+ target_id=target_id,
1116
+ attacker_name=attacker_name,
1117
+ target_name=target_name,
1118
+ food=food_pig_template["name"],
1119
+ group_id=str(event.group_id),
1120
+ )
1121
+ )
1122
+ roasted_data = food_pig_template.copy()
1123
+ roasted_data["analysis"] = text
1124
+ await send_rendered_pig(cmd_roast_member, event, roasted_data, extra_text=prefix_text)
1125
+ return
1126
+
1127
+ # --- 普通模式概率判定 ---
1128
+ roll = random.randint(1, 100)
1129
+
1130
+ # === 成功 (60%) ===
1131
+ if roll <= 60:
1132
+ food_id = random.choice(get_food_pig_ids())
1133
+ food_pig_template = get_pig_by_id(food_id)
1134
+ if not food_pig_template:
1135
+ await cmd_roast_member.finish("食材配置缺失,请联系管理员修复 pig.json。")
1136
+ return
1137
+
1138
+ text = await roast_manager.get_roast_text(
1139
+ target_pig, food_pig_template,
1140
+ operator_name=attacker_name, target_name=target_name,
1141
+ )
1142
+ logger.info(
1143
+ f"[烤群友] 成功 | 凶手={attacker_name}({attacker_id}) "
1144
+ f"目标={target_name}({target_id}) 结果={food_pig_template['name']}"
1145
+ )
1146
+ await store.append_roast_event(
1147
+ RoastEvent(
1148
+ event_type="success",
1149
+ attacker_id=attacker_id,
1150
+ target_id=target_id,
1151
+ attacker_name=attacker_name,
1152
+ target_name=target_name,
1153
+ food=food_pig_template["name"],
1154
+ group_id=str(event.group_id),
1155
+ )
1156
+ )
1157
+ roasted_data = food_pig_template.copy()
1158
+ roasted_data["analysis"] = text
1159
+ await send_rendered_pig(cmd_roast_member, event, roasted_data)
1160
+
1161
+ # === 逃脱 (30%) ===
1162
+ elif roll <= 90:
1163
+ escape_text = pick_escape_text(attacker_name, target_name, target_pig)
1164
+ logger.info(
1165
+ f"[烤群友] 逃脱 | 凶手={attacker_name}({attacker_id}) 目标={target_name}({target_id})"
1166
+ )
1167
+ await store.append_roast_event(
1168
+ RoastEvent(
1169
+ event_type="escape",
1170
+ attacker_id=attacker_id,
1171
+ target_id=target_id,
1172
+ attacker_name=attacker_name,
1173
+ target_name=target_name,
1174
+ group_id=str(event.group_id),
1175
+ )
1176
+ )
1177
+ await cmd_roast_member.finish(MessageSegment.reply(event.message_id) + escape_text)
1178
+
1179
+ # === 反噬 (10%) ===
1180
+ else:
1181
+ if can_backfire_roast(attacker_pig):
1182
+ food_id = random.choice(get_food_pig_ids())
1183
+ food_pig_template = get_pig_by_id(food_id)
1184
+ if not food_pig_template:
1185
+ await cmd_roast_member.finish("食材配置缺失,请联系管理员修复 pig.json。")
1186
+ return
1187
+
1188
+ text = await roast_manager.get_roast_text(attacker_pig, food_pig_template)
1189
+ text = clarify_backfire_roast_text(text, attacker_name)
1190
+ fail_intro = pick_backfire_text(attacker_name, target_name, attacker_pig)
1191
+ fail_text = fail_intro + "\n\n" + text
1192
+
1193
+ logger.info(
1194
+ f"[烤群友] 反噬 | 凶手={attacker_name}({attacker_id}) "
1195
+ f"目标={target_name}({target_id}) 凶手变成={food_pig_template['name']}"
1196
+ )
1197
+ await store.append_roast_event(
1198
+ RoastEvent(
1199
+ event_type="backfire",
1200
+ attacker_id=attacker_id,
1201
+ target_id=target_id,
1202
+ attacker_name=attacker_name,
1203
+ target_name=target_name,
1204
+ food=food_pig_template["name"],
1205
+ group_id=group_id,
1206
+ )
1207
+ )
1208
+ roasted_data = food_pig_template.copy()
1209
+ roasted_data["analysis"] = fail_text
1210
+ await send_rendered_pig(cmd_roast_member, event, roasted_data)
1211
+ else:
1212
+ fail_text = pick_backfire_text(attacker_name, target_name, attacker_pig)
1213
+ logger.info(
1214
+ f"[烤群友] 反噬(文字) | 凶手={attacker_name}({attacker_id}) "
1215
+ f"目标={target_name}({target_id})"
1216
+ )
1217
+ await store.append_roast_event(
1218
+ RoastEvent(
1219
+ event_type="backfire",
1220
+ attacker_id=attacker_id,
1221
+ target_id=target_id,
1222
+ attacker_name=attacker_name,
1223
+ target_name=target_name,
1224
+ group_id=group_id,
1225
+ )
1226
+ )
1227
+ await cmd_roast_member.finish(MessageSegment.reply(event.message_id) + fail_text)
1228
+
1229
+
1230
+ # 5.6 随机烤群友
1231
+ cmd_random_roast = on_command("随机烤群友", aliases={"随机烤猪", "抽个群友烤了"}, block=True)
1232
+
1233
+ @cmd_random_roast.handle()
1234
+ @guard_group_enabled(cmd_random_roast)
1235
+ @guard_store_errors(cmd_random_roast)
1236
+ async def _(bot: Bot, event: GroupMessageEvent):
1237
+ attacker_id = str(event.user_id)
1238
+ attacker_name = event.sender.card or event.sender.nickname
1239
+ group_id = str(event.group_id)
1240
+ attacker_pig = get_pig_by_id(await store.get_daily_roll(attacker_id))
1241
+
1242
+ if attacker_pig:
1243
+ await store.mark_group_roll_seen(attacker_id, attacker_pig["id"], group_id)
1244
+
1245
+ bot_id = str(event.self_id)
1246
+ candidates = await get_group_roll_candidates(bot, event.group_id, {attacker_id, bot_id})
1247
+
1248
+ if not candidates:
1249
+ await cmd_random_roast.finish(
1250
+ MessageSegment.reply(event.message_id) + "今天还没有别人抽猪,没有可以烤的目标!"
1251
+ )
1252
+ return
1253
+
1254
+ target_id = random.choice(candidates)
1255
+
1256
+ # 获取目标昵称
1257
+ target_name = "群友"
1258
+ try:
1259
+ member_info = await bot.get_group_member_info(group_id=event.group_id, user_id=int(target_id))
1260
+ target_name = member_info.get("card") or member_info.get("nickname") or "群友"
1261
+ except Exception:
1262
+ pass
1263
+
1264
+ # 检查攻击者 CD
1265
+ cooldown_result = await store.consume_roast_cooldown(
1266
+ attacker_id,
1267
+ cooldown_seconds=resolve_roast_cooldown_seconds(),
1268
+ max_charges=resolve_roast_charge_max(),
1269
+ )
1270
+ if not cooldown_result.allowed:
1271
+ await cmd_random_roast.finish(
1272
+ MessageSegment.reply(event.message_id) + format_cooldown_message(cooldown_result.remaining_seconds)
1273
+ )
1274
+ return
1275
+
1276
+ # 读取目标形态
1277
+ target_pig = get_pig_by_id(await store.get_daily_roll(target_id))
1278
+ if not target_pig:
1279
+ await cmd_random_roast.finish(
1280
+ MessageSegment.reply(event.message_id) + f"系统随机选中了【{target_name}】,但对方的猪数据异常。"
1281
+ )
1282
+ return
1283
+ await store.mark_group_roll_seen(target_id, target_pig["id"], group_id)
1284
+
1285
+ # 保护检查
1286
+ if await store.is_protected(group_id, target_id):
1287
+ prot_text = random.choice(PROTECTION_BLOCK_TEXTS).format(target=target_name)
1288
+ await cmd_random_roast.finish(
1289
+ MessageSegment.reply(event.message_id)
1290
+ + f"系统随机选中了【{target_name}】——\n{prot_text}"
1291
+ )
1292
+ return
1293
+
1294
+ # 目标是人类/熟食形态 → 拦截
1295
+ if is_human_pig(target_pig):
1296
+ await cmd_random_roast.finish(
1297
+ MessageSegment.reply(event.message_id)
1298
+ + f"系统随机选中了【{target_name}】,但对方是人类形态,烤架拒绝处理。换一次试试?"
1299
+ )
1300
+ return
1301
+
1302
+ if is_eaten_pig(target_pig):
1303
+ await cmd_random_roast.finish(
1304
+ MessageSegment.reply(event.message_id)
1305
+ + random.choice(TARGET_EATEN_BLOCK_TEXTS).format(target=target_name)
1306
+ )
1307
+ return
1308
+
1309
+ if is_sold_pig(target_pig):
1310
+ await cmd_random_roast.finish(
1311
+ MessageSegment.reply(event.message_id)
1312
+ + random.choice(TARGET_SOLD_BLOCK_TEXTS).format(target=target_name)
1313
+ )
1314
+ return
1315
+
1316
+ if is_food_pig(target_pig):
1317
+ await cmd_random_roast.finish(
1318
+ MessageSegment.reply(event.message_id)
1319
+ + f"系统随机选中了【{target_name}】,但对方已经是【{target_pig.get('name', '熟食')}】了,别鞭尸了。"
1320
+ )
1321
+ return
1322
+
1323
+ # 正常概率判定
1324
+ intro = random.choice(RANDOM_ROAST_INTRO_TEXTS).format(target=target_name) + "\n\n"
1325
+ roll = random.randint(1, 100)
1326
+
1327
+ # 成功 (60%)
1328
+ if roll <= 60:
1329
+ food_id = random.choice(get_food_pig_ids())
1330
+ food_pig_template = get_pig_by_id(food_id)
1331
+ if not food_pig_template:
1332
+ await cmd_random_roast.finish("食材配置缺失,请联系管理员修复 pig.json。")
1333
+ return
1334
+
1335
+ text = await roast_manager.get_roast_text(
1336
+ target_pig, food_pig_template,
1337
+ operator_name=attacker_name, target_name=target_name,
1338
+ )
1339
+ logger.info(
1340
+ f"[随机烤群友] 成功 | 凶手={attacker_name}({attacker_id}) "
1341
+ f"目标={target_name}({target_id}) 结果={food_pig_template['name']}"
1342
+ )
1343
+ await store.append_roast_event(
1344
+ RoastEvent(
1345
+ event_type="success",
1346
+ attacker_id=attacker_id,
1347
+ target_id=target_id,
1348
+ attacker_name=attacker_name,
1349
+ target_name=target_name,
1350
+ food=food_pig_template["name"],
1351
+ group_id=str(event.group_id),
1352
+ )
1353
+ )
1354
+ roasted_data = food_pig_template.copy()
1355
+ roasted_data["analysis"] = text
1356
+ await send_rendered_pig(cmd_random_roast, event, roasted_data, extra_text=intro)
1357
+
1358
+ # 逃脱 (30%)
1359
+ elif roll <= 90:
1360
+ escape_text = pick_escape_text(attacker_name, target_name, target_pig)
1361
+ logger.info(
1362
+ f"[随机烤群友] 逃脱 | 凶手={attacker_name}({attacker_id}) 目标={target_name}({target_id})"
1363
+ )
1364
+ await store.append_roast_event(
1365
+ RoastEvent(
1366
+ event_type="escape",
1367
+ attacker_id=attacker_id,
1368
+ target_id=target_id,
1369
+ attacker_name=attacker_name,
1370
+ target_name=target_name,
1371
+ group_id=str(event.group_id),
1372
+ )
1373
+ )
1374
+ await cmd_random_roast.finish(MessageSegment.reply(event.message_id) + intro + escape_text)
1375
+
1376
+ # 反噬 (10%)
1377
+ else:
1378
+ if can_backfire_roast(attacker_pig):
1379
+ food_id = random.choice(get_food_pig_ids())
1380
+ food_pig_template = get_pig_by_id(food_id)
1381
+ if not food_pig_template:
1382
+ await cmd_random_roast.finish("食材配置缺失。")
1383
+ return
1384
+ text = await roast_manager.get_roast_text(attacker_pig, food_pig_template)
1385
+ text = clarify_backfire_roast_text(text, attacker_name)
1386
+ fail_intro = pick_backfire_text(attacker_name, target_name, attacker_pig)
1387
+ fail_text = fail_intro + "\n\n" + text
1388
+ logger.info(
1389
+ f"[随机烤群友] 反噬 | 凶手={attacker_name}({attacker_id}) "
1390
+ f"目标={target_name}({target_id}) 凶手变成={food_pig_template['name']}"
1391
+ )
1392
+ await store.append_roast_event(
1393
+ RoastEvent(
1394
+ event_type="backfire",
1395
+ attacker_id=attacker_id,
1396
+ target_id=target_id,
1397
+ attacker_name=attacker_name,
1398
+ target_name=target_name,
1399
+ food=food_pig_template["name"],
1400
+ group_id=group_id,
1401
+ )
1402
+ )
1403
+ roasted_data = food_pig_template.copy()
1404
+ roasted_data["analysis"] = fail_text
1405
+ await send_rendered_pig(cmd_random_roast, event, roasted_data, extra_text=intro)
1406
+ else:
1407
+ fail_text = pick_backfire_text(attacker_name, target_name, attacker_pig)
1408
+ logger.info(
1409
+ f"[随机烤群友] 反噬(文字) | 凶手={attacker_name}({attacker_id}) "
1410
+ f"目标={target_name}({target_id})"
1411
+ )
1412
+ await store.append_roast_event(
1413
+ RoastEvent(
1414
+ event_type="backfire",
1415
+ attacker_id=attacker_id,
1416
+ target_id=target_id,
1417
+ attacker_name=attacker_name,
1418
+ target_name=target_name,
1419
+ group_id=group_id,
1420
+ )
1421
+ )
1422
+ await cmd_random_roast.finish(MessageSegment.reply(event.message_id) + intro + fail_text)
1423
+
1424
+
1425
+ # 6. 我的猪圈
1426
+ cmd_sty = on_command("我的猪圈", aliases={"我的小猪"}, block=True)
1427
+
1428
+ @cmd_sty.handle()
1429
+ @guard_group_enabled(cmd_sty)
1430
+ @guard_store_errors(cmd_sty)
1431
+ async def _(event: Event):
1432
+ user_id = str(event.user_id)
1433
+ draw_state = await store.get_draw_state(user_id)
1434
+ total_pigs = len(PIG_LIST)
1435
+ user_count = len(draw_state.pig_ids)
1436
+
1437
+ if total_pigs <= 0:
1438
+ await cmd_sty.finish(MessageSegment.reply(event.message_id) + "猪图鉴为空,请先检查资源文件。")
1439
+ return
1440
+
1441
+ if user_count == 0:
1442
+ await cmd_sty.finish(MessageSegment.reply(event.message_id) + "你的猪圈空空如也!")
1443
+ return
1444
+
1445
+ msg = build_pigsty_growth_summary(
1446
+ event.sender.card or event.sender.nickname,
1447
+ draw_state,
1448
+ total_pigs,
1449
+ )
1450
+ await cmd_sty.finish(MessageSegment.reply(event.message_id) + msg)
1451
+
1452
+
1453
+ # 6.5 图片版小猪图鉴
1454
+ cmd_catalog = on_command("小猪图鉴", aliases={"猪猪图鉴", "完整图鉴"}, block=True)
1455
+
1456
+
1457
+ @cmd_catalog.handle()
1458
+ @guard_group_enabled(cmd_catalog)
1459
+ @guard_store_errors(cmd_catalog)
1460
+ async def _(event: Event, args: Message = CommandArg()):
1461
+ plugin_config = get_plugin_config(Config)
1462
+ if not plugin_config.rollpig_catalog_enabled:
1463
+ await cmd_catalog.finish(MessageSegment.reply(event.message_id) + "图片版小猪图鉴当前未启用。")
1464
+ return
1465
+
1466
+ raw_arg = args.extract_plain_text().strip()
1467
+ page = 1
1468
+ if raw_arg:
1469
+ try:
1470
+ page = max(1, int(raw_arg.split()[0]))
1471
+ except ValueError:
1472
+ await cmd_catalog.finish(MessageSegment.reply(event.message_id) + "页码需要是数字,例如:小猪图鉴 2")
1473
+ return
1474
+
1475
+ user_id = str(event.user_id)
1476
+ if not PIG_LIST:
1477
+ await cmd_catalog.finish(MessageSegment.reply(event.message_id) + "猪图鉴为空,请先检查资源文件。")
1478
+ return
1479
+
1480
+ command_started_at = time.perf_counter()
1481
+ snapshot_started_at = time.perf_counter()
1482
+ snapshot = await store.get_catalog_snapshot(user_id, days=14)
1483
+ snapshot_ready_at = time.perf_counter()
1484
+ if not snapshot.draw_state.pig_ids:
1485
+ await cmd_catalog.finish(MessageSegment.reply(event.message_id) + "你的猪圈空空如也!发送「今日小猪」开始收集。")
1486
+ return
1487
+
1488
+ try:
1489
+ pic = await render_catalog_image(
1490
+ user_name=get_event_user_name(event),
1491
+ snapshot=snapshot,
1492
+ page=page,
1493
+ )
1494
+ render_ready_at = time.perf_counter()
1495
+ except Exception as error:
1496
+ logger.error(f"小猪图鉴渲染失败: user={user_id} page={page} error={error}")
1497
+ await cmd_catalog.finish(MessageSegment.reply(event.message_id) + "小猪图鉴生成失败,请稍后再试。")
1498
+ return
1499
+
1500
+ log_perf(
1501
+ f"rollpig catalog command ready: user={user_id} page={page} "
1502
+ f"snapshot={snapshot_ready_at - snapshot_started_at:.2f}s "
1503
+ f"render={render_ready_at - snapshot_ready_at:.2f}s "
1504
+ f"total_before_send={render_ready_at - command_started_at:.2f}s "
1505
+ f"bytes={len(pic)}"
1506
+ )
1507
+ await cmd_catalog.finish(MessageSegment.reply(event.message_id) + MessageSegment.image(pic))
1508
+
1509
+
1510
+ # 7. 本周小猪
1511
+ cmd_week = on_command("本周小猪", block=True)
1512
+
1513
+ @cmd_week.handle()
1514
+ @guard_group_enabled(cmd_week)
1515
+ @guard_store_errors(cmd_week)
1516
+ async def _(event: Event):
1517
+ if not HAS_PIL:
1518
+ await cmd_week.finish("Bot 未安装 PIL 库。")
1519
+
1520
+ user_id = str(event.user_id)
1521
+ today = rollpig_today()
1522
+
1523
+ images_to_merge = []
1524
+ for i in range(7):
1525
+ d = today - datetime.timedelta(days=(6 - i))
1526
+ pig = get_pig_by_id(await store.get_pig_by_date(user_id, d.isoformat()))
1527
+ if pig:
1528
+ img_file = find_image_file(pig["id"])
1529
+ if img_file:
1530
+ images_to_merge.append(img_file)
1531
+
1532
+ if not images_to_merge:
1533
+ await cmd_week.finish(MessageSegment.reply(event.message_id) + "你这周还没抽过猪呢!")
1534
+ return
1535
+
1536
+ try:
1537
+ item_w, item_h = 150, 150
1538
+ padding = 20
1539
+ total_w = (item_w + padding) * len(images_to_merge) + padding
1540
+ total_h = item_h + 80
1541
+
1542
+ canvas = PILImage.new("RGB", (total_w, total_h), (255, 255, 255))
1543
+ for idx, img_path in enumerate(images_to_merge):
1544
+ with PILImage.open(img_path) as opened:
1545
+ img = opened.convert("RGBA").resize((item_w, item_h))
1546
+ x = padding + idx * (item_w + padding)
1547
+ y = padding
1548
+ canvas.paste(img, (x, y), img)
1549
+
1550
+ from io import BytesIO
1551
+ output = BytesIO()
1552
+ canvas.save(output, format="PNG")
1553
+
1554
+ msg = (
1555
+ MessageSegment.reply(event.message_id)
1556
+ + f"你这周变了 {len(images_to_merge)} 次猪!"
1557
+ + MessageSegment.image(output.getvalue())
1558
+ )
1559
+ except Exception as e:
1560
+ logger.error(f"本周小猪长图生成失败: user={user_id}, error={e}")
1561
+ await cmd_week.finish("生成图片失败。")
1562
+ return
1563
+
1564
+ await cmd_week.finish(msg)
1565
+
1566
+
1567
+ # ================= 定时任务:每日总结 =================
1568
+
1569
+ def get_resource_sync_interval_hours() -> int:
1570
+ """读取资源同步间隔;配置异常时回退 24 小时,避免定时任务注册失败。"""
1571
+ try:
1572
+ config = get_plugin_config(Config)
1573
+ return max(1, int(config.rollpig_resource_sync_interval_hours or 24))
1574
+ except Exception as error:
1575
+ logger.warning(f"rollpig_resource_sync_interval_hours 配置非法,已回退到 24 小时: {error}")
1576
+ return 24
1577
+
1578
+
1579
+ async def run_background_resource_sync(source: str) -> None:
1580
+ """后台同步云端小猪资源;任何异常都只记日志,不能影响主业务。"""
1581
+ try:
1582
+ message = await sync_rollpig_resources(force=False)
1583
+ logger.info(f"[小猪资源同步] {source}: {message}")
1584
+ except Exception as error:
1585
+ logger.warning(f"[小猪资源同步] {source} 失败,继续使用当前资源: {error}")
1586
+
1587
+
1588
+ @get_driver().on_startup
1589
+ async def startup_resource_sync():
1590
+ """启动后异步检查一次资源包;不阻塞 NoneBot 启动和连接。"""
1591
+ config = get_plugin_config(Config)
1592
+ if not config.rollpig_resource_sync_enabled:
1593
+ return
1594
+ asyncio.create_task(run_background_resource_sync("startup"))
1595
+
1596
+
1597
+ @scheduler.scheduled_job("interval", hours=get_resource_sync_interval_hours(), id="rollpig_resource_sync", max_instances=1)
1598
+ async def resource_sync_job():
1599
+ """低频检查云端资源包,减少多实例手动同步新猪素材的运维成本。"""
1600
+ config = get_plugin_config(Config)
1601
+ if not config.rollpig_resource_sync_enabled:
1602
+ return
1603
+ await run_background_resource_sync("interval")
1604
+
1605
+
1606
+ def build_daily_summary_text(summary: dict) -> str:
1607
+ """将按群聚合后的日报结果拼成文案。"""
1608
+ roll_count = summary.get("roll_count", 0)
1609
+ roast_total = summary.get("total", 0)
1610
+
1611
+ # 完全无活动
1612
+ if roll_count == 0 and roast_total == 0:
1613
+ return random.choice(DAILY_SUMMARY_EMPTY_TEXTS)
1614
+
1615
+ lines = [DAILY_SUMMARY_HEADER]
1616
+
1617
+ # 抽猪统计
1618
+ if roll_count > 0:
1619
+ top_pig_id = summary.get("top_pig_id")
1620
+ if top_pig_id:
1621
+ pig_data = get_pig_by_id(top_pig_id)
1622
+ pig_name = pig_data["name"] if pig_data else top_pig_id
1623
+ lines.append(f"\U0001f451 最热门形态:【{pig_name}】(共 {summary.get('top_pig_count', 0)} 人抽到)")
1624
+ human_count = summary.get("human_count", 0)
1625
+ if human_count > 0:
1626
+ lines.append(f"\U0001f9cd 今日人类:{human_count} 位幸运儿逃过了猪化")
1627
+ lines.append("")
1628
+
1629
+ # 烧烤统计
1630
+ if roast_total > 0:
1631
+ lines.append(f"\U0001f525 今日共发生 {roast_total} 场烧烤事件")
1632
+
1633
+ if summary.get("most_active_id"):
1634
+ lines.append(f"\U0001f3c6 烧烤狂人:【{summary['most_active_name']}】(发起 {summary['most_active_count']} 次)")
1635
+
1636
+ if summary.get("most_roasted_id"):
1637
+ lines.append(f"\U0001f356 最惨食材:【{summary['most_roasted_name']}】(被烤 {summary['most_roasted_count']} 次)")
1638
+
1639
+ if summary.get("escape_king_id") and summary["escape_king_count"] > 0:
1640
+ lines.append(f"\U0001f3c3 逃脱大师:【{summary['escape_king_name']}】(成功逃脱 {summary['escape_king_count']} 次)")
1641
+
1642
+ if summary.get("backfire_king_id") and summary["backfire_king_count"] > 0:
1643
+ lines.append(f"\U0001f4a5 反噬之王:【{summary['backfire_king_name']}】(自爆 {summary['backfire_king_count']} 次)")
1644
+
1645
+ # 保护提示
1646
+ if summary.get("most_roasted_id") and summary["most_roasted_count"] >= 2:
1647
+ lines.append(f"\n\U0001f6e1\ufe0f 【{summary['most_roasted_name']}】明天将获得猪圈保护协议,免受一切烧烤!")
1648
+ else:
1649
+ lines.append("\U0001f54a 今天无人烧烤,猪们度过了平静的一天。")
1650
+
1651
+ lines.append("\n" + DAILY_SUMMARY_FOOTER)
1652
+ return "\n".join(lines)
1653
+
1654
+
1655
+ @scheduler.scheduled_job("cron", hour=23, minute=45, id="rollpig_daily_summary")
1656
+ async def daily_summary_job():
1657
+ """每晚 23:45~23:55 推送当日猪圈日报(随机延迟 0~10 分钟防风控)。"""
1658
+ import asyncio
1659
+ config = get_plugin_config(Config)
1660
+ if not config.rollpig_daily_summary_enabled:
1661
+ logger.info("[每日总结] rollpig_daily_summary_enabled=false,跳过定时日报任务")
1662
+ return
1663
+
1664
+ delay = random.randint(0, 600) # 0~10 分钟随机延迟
1665
+ logger.info(f"[每日总结] 定时触发,随机延迟 {delay} 秒后推送")
1666
+ await asyncio.sleep(delay)
1667
+ try:
1668
+ active_groups = await store.get_active_group_ids()
1669
+ if not active_groups:
1670
+ logger.info("[每日总结] 今日无活跃群,跳过推送")
1671
+ return
1672
+
1673
+ # ================================ 控制台开关过滤 ================================ #
1674
+ # 如果宿主项目接入了 admin_console 群开关,这里必须在定时任务层同步收口:
1675
+ # 未启用的群既不推日报,也不写次日保护名单,保证“关闭就是彻底关闭”。
1676
+ enabled_active_groups = [
1677
+ group_id for group_id in sorted(active_groups)
1678
+ if is_group_rollpig_enabled(group_id)
1679
+ ]
1680
+ if not enabled_active_groups:
1681
+ logger.info("[每日总结] 今日没有启用 rollpig 的活跃群,跳过推送")
1682
+ return
1683
+
1684
+ group_summaries = {}
1685
+ protect_date = rollpig_date_str(1)
1686
+ for group_id in enabled_active_groups:
1687
+ summary = await build_daily_summary(store, group_id=group_id)
1688
+ group_summaries[group_id] = summary
1689
+ if summary.get("most_roasted_id") and summary.get("most_roasted_count", 0) >= 2:
1690
+ await store.replace_group_protections(group_id, [summary["most_roasted_id"]], protect_date)
1691
+ else:
1692
+ await store.replace_group_protections(group_id, [], protect_date)
1693
+
1694
+ # 清理旧事件
1695
+ await store.prune_events(days_to_keep=7)
1696
+ await store.prune_history(days_to_keep=14)
1697
+
1698
+ try:
1699
+ bot = get_bot()
1700
+ except ValueError:
1701
+ logger.warning("[每日总结] 无可用 Bot,跳过推送")
1702
+ return
1703
+
1704
+ # ================================ 日报推送开关过滤 ================================ #
1705
+ # “日报推送”是独立于 rollpig 主功能的第二层开关:
1706
+ # 群内玩法可以开启,但日报消息可以单独关闭。
1707
+ summary_push_groups = [
1708
+ group_id for group_id in enabled_active_groups
1709
+ if is_daily_summary_enabled(group_id)
1710
+ ]
1711
+ if not summary_push_groups:
1712
+ logger.info("[每日总结] 已完成保护名单刷新,但没有群开启日报推送")
1713
+ return
1714
+
1715
+ for group_id in summary_push_groups:
1716
+ try:
1717
+ text = build_daily_summary_text(group_summaries[group_id])
1718
+ await bot.send_group_msg(group_id=int(group_id), message=text)
1719
+ except Exception as e:
1720
+ logger.warning(f"[每日总结] 推送失败: group={group_id} error={e}")
1721
+
1722
+ logger.info(f"[每日总结] 推送完成, 共 {len(summary_push_groups)} 个群")
1723
+ except CloudStoreError as e:
1724
+ logger.warning(f"[每日总结] 云端账本暂时不可用,跳过本轮推送: {e}")
1725
+ except Exception as e:
1726
+ logger.error(f"[每日总结] 任务异常: {e}")