@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/DSH-GO-VERIFICATION.md +57 -0
  2. package/ICON.png +0 -0
  3. package/LICENSE +674 -0
  4. package/README.md +251 -2
  5. package/cordis.patch.yml +8 -0
  6. package/install.ps1 +0 -0
  7. package/install.sh +209 -0
  8. package/lib/client.js +1511 -0
  9. package/lib/host/aliashint.js +70 -0
  10. package/lib/host/attachments.js +25 -0
  11. package/lib/host/command.js +34 -0
  12. package/lib/host/consts.js +16 -0
  13. package/lib/host/dataguide.js +35 -0
  14. package/lib/host/imagebridge.js +41 -0
  15. package/lib/host/native-result.js +91 -0
  16. package/lib/host/native.js +191 -0
  17. package/lib/host/proxy.js +59 -0
  18. package/lib/host/results.js +67 -0
  19. package/lib/host/service.js +263 -0
  20. package/lib/host/tool.js +185 -0
  21. package/lib/host/troubleshoot.js +31 -0
  22. package/lib/host/worldbook.js +80 -0
  23. package/lib/index.js +74 -0
  24. package/locale/en.json +6 -0
  25. package/locale/zh.json +6 -0
  26. package/package.json +136 -4
  27. package/service/ICON.png +0 -0
  28. package/service/ai/AI_DATA.md +221 -0
  29. package/service/knowledge/additional/manual.jsonl +5 -0
  30. package/service/knowledge/wiki/baike.jsonl +1231 -0
  31. package/service/knowledge/wiki/chengjiu.jsonl +36 -0
  32. package/service/knowledge/wiki/gonglve.jsonl +383 -0
  33. package/service/knowledge/wiki/huodong.jsonl +427 -0
  34. package/service/knowledge/wiki/renwu.jsonl +412 -0
  35. package/service/knowledge/wiki/tujian.jsonl +2889 -0
  36. package/service/knowledge/wiki/zixun.jsonl +47 -0
  37. package/service/plugins/__init__.py +0 -0
  38. package/service/pyproject.toml +40 -0
  39. package/service/rover/__init__.py +0 -0
  40. package/service/rover/ai_core/__init__.py +0 -0
  41. package/service/rover/ai_core/models.py +50 -0
  42. package/service/rover/ai_core/rag/__init__.py +5 -0
  43. package/service/rover/ai_core/rag/knowledge.py +23 -0
  44. package/service/rover/ai_core/register.py +86 -0
  45. package/service/rover/ai_core/resource.py +6 -0
  46. package/service/rover/ai_core/skills/__init__.py +0 -0
  47. package/service/rover/ai_core/skills/operations.py +8 -0
  48. package/service/rover/api.py +720 -0
  49. package/service/rover/api_render.py +161 -0
  50. package/service/rover/app_life.py +154 -0
  51. package/service/rover/aps.py +47 -0
  52. package/service/rover/avatar.py +85 -0
  53. package/service/rover/bot.py +133 -0
  54. package/service/rover/builtin.py +152 -0
  55. package/service/rover/cards/__init__.py +26 -0
  56. package/service/rover/cards/_shared.py +129 -0
  57. package/service/rover/cards/char_list.py +258 -0
  58. package/service/rover/cards/char_panel.py +1353 -0
  59. package/service/rover/cards/echo_list.py +206 -0
  60. package/service/rover/cards/matrix_roles.py +25 -0
  61. package/service/rover/cards/rank_boards.py +714 -0
  62. package/service/rover/cards/rank_total.py +664 -0
  63. package/service/rover/compat.py +215 -0
  64. package/service/rover/config.py +163 -0
  65. package/service/rover/custom_panel.py +220 -0
  66. package/service/rover/data_store.py +31 -0
  67. package/service/rover/gss.py +19 -0
  68. package/service/rover/handler.py +191 -0
  69. package/service/rover/help/__init__.py +0 -0
  70. package/service/rover/help/draw_new_plugin_help.py +332 -0
  71. package/service/rover/help/model.py +16 -0
  72. package/service/rover/help/utils.py +7 -0
  73. package/service/rover/hooks.py +420 -0
  74. package/service/rover/html_actions.py +236 -0
  75. package/service/rover/i18n.py +19 -0
  76. package/service/rover/knowledge.py +235 -0
  77. package/service/rover/logger.py +102 -0
  78. package/service/rover/main.py +33 -0
  79. package/service/rover/media.py +222 -0
  80. package/service/rover/message_models.py +23 -0
  81. package/service/rover/models.py +92 -0
  82. package/service/rover/native_media.py +64 -0
  83. package/service/rover/ocr.py +79 -0
  84. package/service/rover/pool.py +43 -0
  85. package/service/rover/restart.py +36 -0
  86. package/service/rover/segment.py +143 -0
  87. package/service/rover/server.py +131 -0
  88. package/service/rover/status/__init__.py +0 -0
  89. package/service/rover/status/models.py +10 -0
  90. package/service/rover/status/plugin_status.py +17 -0
  91. package/service/rover/subscribe.py +148 -0
  92. package/service/rover/sv.py +403 -0
  93. package/service/rover/templates/char_list.html +182 -0
  94. package/service/rover/templates/char_panel.html +465 -0
  95. package/service/rover/templates/echo_list.html +221 -0
  96. package/service/rover/templates/rank_all_total.html +193 -0
  97. package/service/rover/templates/rank_board.html +107 -0
  98. package/service/rover/templates/rank_level_total.html +57 -0
  99. package/service/rover/templates/rank_matrix_total.html +66 -0
  100. package/service/rover/templates/rank_phantom_total.html +180 -0
  101. package/service/rover/templates/rank_slash_total.html +38 -0
  102. package/service/rover/trigger.py +98 -0
  103. package/service/rover/utils/__init__.py +0 -0
  104. package/service/rover/utils/boardcast/__init__.py +0 -0
  105. package/service/rover/utils/boardcast/models.py +15 -0
  106. package/service/rover/utils/cookie_manager/__init__.py +0 -0
  107. package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
  108. package/service/rover/utils/database/__init__.py +0 -0
  109. package/service/rover/utils/database/base_models.py +1771 -0
  110. package/service/rover/utils/database/models.py +80 -0
  111. package/service/rover/utils/database/startup.py +60 -0
  112. package/service/rover/utils/default_bg/bg.jpg +0 -0
  113. package/service/rover/utils/download_resource/__init__.py +0 -0
  114. package/service/rover/utils/download_resource/download_core.py +164 -0
  115. package/service/rover/utils/download_resource/download_file.py +46 -0
  116. package/service/rover/utils/fonts/__init__.py +0 -0
  117. package/service/rover/utils/fonts/fonts.py +33 -0
  118. package/service/rover/utils/image/__init__.py +0 -0
  119. package/service/rover/utils/image/convert.py +51 -0
  120. package/service/rover/utils/image/image_tools.py +30 -0
  121. package/service/rover/utils/image/utils.py +70 -0
  122. package/service/rover/utils/plugins_config/__init__.py +0 -0
  123. package/service/rover/utils/plugins_config/gs_config.py +506 -0
  124. package/service/rover/utils/plugins_config/models.py +182 -0
  125. package/service/rover/web_app.py +3 -0
  126. package/service/rover/webconsole/__init__.py +0 -0
  127. package/service/rover/webconsole/mount_app.py +26 -0
  128. package/service/tools/migrate.py +351 -0
  129. package/service/tools/regression.py +87 -0
  130. package/service/tools/smoke.py +63 -0
  131. package/service/tools/update_plugins.py +212 -0
  132. package/service/tools/verify_actions.py +104 -0
  133. package/service/tools/verify_card.py +82 -0
  134. package/service/tools/wwdata.py +665 -0
  135. package/tests/compat.test.js +181 -0
  136. package/tests/imagebridge.test.js +58 -0
  137. package/tests/media.test.js +68 -0
  138. package/tests/native.test.js +127 -0
  139. package/tests/runtime.test.js +69 -0
  140. package/tests/service.test.js +48 -0
@@ -0,0 +1,235 @@
1
+ """游戏常识知识库: service/knowledge/ 下 jsonl 的加载、词法检索与世界书触发查询。
2
+
3
+ 纯 stdlib、不依赖 rover 其它模块, wwdata 脚本可直接 import 复用。
4
+ 存储粒度是"小节": {id: "<entry>#<n>", entry, title: "条目/小节", tags, source, triggers, content}
5
+ 检索按小节返回; kb_get 传小节 id 取小节、传条目 id 取整条拼装。
6
+ """
7
+
8
+ import json
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ SERVICE = Path(__file__).resolve().parents[1]
14
+ KB_DIR = SERVICE / "knowledge"
15
+ CHAR_ALIAS = SERVICE / "data" / "XutheringWavesUID" / "alias" / "char_alias.json"
16
+
17
+ _CJK = re.compile(r"[一-鿿]")
18
+ TRIGGER_MIN_LEN = 2
19
+ # 过泛词不做自动注入触发(检索不受影响)
20
+ BROAD_TRIGGERS = {"声骸", "资源", "补给", "头像", "武器", "角色", "攻略", "活动", "任务", "成就", "套装"}
21
+ # 只有这些层参与自动注入; event/article/story 只可检索
22
+ INJECT_TIERS = {"core", "guide"}
23
+ LOOKUP_MAX_HITS = 2
24
+ SNIPPET_LEN = 160
25
+ INJECT_CONTENT_LEN = 600
26
+
27
+ # tags → 触发优先级; 数值大者先注入
28
+ _PRIORITY_TAGS = [
29
+ (10, ("共鸣者", "武器", "声骸", "合鸣效果")),
30
+ (8, ("角色攻略", "游戏机制", "名词注释")),
31
+ (7, ("游戏百科", "教程百科", "玩法攻略")),
32
+ ]
33
+
34
+ _state: Optional[Dict[str, Any]] = None
35
+
36
+
37
+ def _canonical_chars() -> Set[str]:
38
+ try:
39
+ return set(json.loads(CHAR_ALIAS.read_text("utf-8")).keys())
40
+ except (OSError, ValueError):
41
+ return set()
42
+
43
+
44
+ def _priority(rec: dict) -> int:
45
+ tags = set(rec.get("tags") or [])
46
+ for score, keys in _PRIORITY_TAGS:
47
+ if tags & set(keys):
48
+ return score
49
+ return 5
50
+
51
+
52
+ def _entry_title(sec: dict) -> str:
53
+ return str(sec.get("entry_title") or "").strip() or str(sec.get("title") or "").rsplit("/", 1)[0]
54
+
55
+
56
+ def _load() -> Dict[str, Any]:
57
+ global _state
58
+ if _state is not None:
59
+ return _state
60
+ sections: Dict[str, dict] = {}
61
+ entry_secs: Dict[str, List[str]] = {}
62
+ for path in sorted(KB_DIR.rglob("*.jsonl")):
63
+ source_kind = path.parent.name if path.parent != KB_DIR else path.stem
64
+ for line in path.read_text("utf-8").splitlines():
65
+ try:
66
+ rec = json.loads(line)
67
+ except ValueError:
68
+ continue
69
+ if not rec.get("id") or not rec.get("content"):
70
+ continue
71
+ rec["_kind"] = source_kind
72
+ sections[rec["id"]] = rec
73
+ entry_secs.setdefault(rec.get("entry") or rec["id"], []).append(rec["id"])
74
+
75
+ chars = _canonical_chars()
76
+ # (触发词, 条目 id, 注入的小节 id, 优先级); 每条目同一词只登记最先声明它的小节
77
+ triggers: List[Tuple[str, str, str, int]] = []
78
+ for entry_id, sec_ids in entry_secs.items():
79
+ first = sections[sec_ids[0]]
80
+ if first.get("tier") and first["tier"] not in INJECT_TIERS:
81
+ continue
82
+ prio = int(first.get("priority") or _priority(first))
83
+ title = _entry_title(first)
84
+ word_sec: Dict[str, str] = {}
85
+ for sid in sec_ids:
86
+ for word in sections[sid].get("triggers") or []:
87
+ word_sec.setdefault(str(word).strip(), sid)
88
+ if title:
89
+ word_sec.setdefault(title, sec_ids[0])
90
+ # 标题里含角色正式名时(如"【WIKI】椿角色攻略"), 允许按角色名触发
91
+ for char_name in chars:
92
+ if char_name and char_name in title:
93
+ word_sec.setdefault(char_name, sec_ids[0])
94
+ for word, sid in word_sec.items():
95
+ if not word or not _CJK.search(word) or word in BROAD_TRIGGERS:
96
+ continue
97
+ if len(word) < TRIGGER_MIN_LEN and word not in chars:
98
+ continue
99
+ triggers.append((word, entry_id, sid, prio))
100
+ triggers.sort(key=lambda t: (-len(t[0]), -t[3]))
101
+ _state = {"sections": sections, "entries": entry_secs, "triggers": triggers}
102
+ return _state
103
+
104
+
105
+ def reload() -> int:
106
+ global _state
107
+ _state = None
108
+ return len(_load()["sections"])
109
+
110
+
111
+ def get(kid: str) -> Optional[dict]:
112
+ """小节 id → 小节记录; 条目 id → 全部小节拼装的整条。"""
113
+ state = _load()
114
+ if kid in state["sections"]:
115
+ return {k: v for k, v in state["sections"][kid].items() if not k.startswith("_")}
116
+ sec_ids = state["entries"].get(kid)
117
+ if not sec_ids:
118
+ return None
119
+ first = state["sections"][sec_ids[0]]
120
+ parts = []
121
+ for sid in sec_ids:
122
+ sec = state["sections"][sid]
123
+ sec_name = str(sec.get("section_title") or "").strip() or str(sec.get("title") or "").rsplit("/", 1)[-1]
124
+ parts.append(f"## {sec_name}\n{sec['content']}")
125
+ return {
126
+ "id": kid,
127
+ "title": _entry_title(first),
128
+ "tags": first.get("tags") or [],
129
+ "source": first.get("source"),
130
+ "sections": [
131
+ {"id": sid, "title": state["sections"][sid].get("title")} for sid in sec_ids
132
+ ],
133
+ "content": "\n\n".join(parts),
134
+ }
135
+
136
+
137
+ def lookup(text: str, exclude: Optional[Set[str]] = None,
138
+ limit: int = LOOKUP_MAX_HITS) -> List[Dict[str, Any]]:
139
+ """世界书触发: 在一段话里扫触发词, 长词优先, 注入声明该触发词的小节。
140
+
141
+ exclude 是条目 id 集合; 已排除条目的同名条目一并跳过(多源同名词条只注入一次)。
142
+ """
143
+ text = (text or "").strip()
144
+ if not text:
145
+ return []
146
+ exclude = exclude or set()
147
+ state = _load()
148
+ excluded_titles = set()
149
+ for entry_id in exclude:
150
+ sec_ids = state["entries"].get(entry_id)
151
+ if sec_ids:
152
+ excluded_titles.add(_entry_title(state["sections"][sec_ids[0]]))
153
+ hits: List[Dict[str, Any]] = []
154
+ seen_entries: Set[str] = set()
155
+ taken_words: List[str] = []
156
+ for word, entry_id, sec_id, _prio in state["triggers"]:
157
+ if entry_id in exclude or entry_id in seen_entries or word not in text:
158
+ continue
159
+ sec = state["sections"][sec_id]
160
+ if _entry_title(sec) in excluded_titles:
161
+ continue
162
+ if any(word in longer for longer in taken_words):
163
+ continue
164
+ content = sec["content"]
165
+ if len(content) > INJECT_CONTENT_LEN:
166
+ content = content[:INJECT_CONTENT_LEN] + f"…(截断, 整条 kb_get('{entry_id}'))"
167
+ hits.append({"id": sec["id"], "entry": entry_id, "title": sec.get("title"),
168
+ "trigger": word, "tags": sec.get("tags") or [], "content": content})
169
+ seen_entries.add(entry_id)
170
+ taken_words.append(word)
171
+ if len(hits) >= limit:
172
+ break
173
+ return hits
174
+
175
+
176
+ def _terms(q: str) -> List[str]:
177
+ parts = [p for p in re.split(r"[\s,,、/]+", (q or "").strip()) if p]
178
+ return parts or ([q.strip()] if q.strip() else [])
179
+
180
+
181
+ def search(q: str, source: Optional[str] = None, tags: Optional[List[str]] = None,
182
+ limit: int = 5, full: bool = False) -> List[Dict[str, Any]]:
183
+ """按小节词法检索: title/triggers/tags 命中权重高于 content, 多词全含加分。"""
184
+ terms = _terms(q)
185
+ if not terms:
186
+ return []
187
+ state = _load()
188
+ want_tags = set(tags or [])
189
+ scored: List[Tuple[float, dict]] = []
190
+ for rec in state["sections"].values():
191
+ if source and rec.get("_kind") != source:
192
+ continue
193
+ if want_tags and not (want_tags & set(rec.get("tags") or [])):
194
+ continue
195
+ title = str(rec.get("title") or "")
196
+ trig = " ".join(rec.get("triggers") or [])
197
+ tag_text = " ".join(rec.get("tags") or [])
198
+ content = rec["content"]
199
+ score = 0.0
200
+ matched = 0
201
+ for term in terms:
202
+ hit = False
203
+ if term == _entry_title(rec):
204
+ score += 100; hit = True
205
+ elif term in title:
206
+ score += 30; hit = True
207
+ if term in trig:
208
+ score += 25; hit = True
209
+ if term in tag_text:
210
+ score += 15; hit = True
211
+ occurrences = content.count(term)
212
+ if occurrences:
213
+ score += 5 * min(occurrences, 3); hit = True
214
+ if hit:
215
+ matched += 1
216
+ if not matched:
217
+ continue
218
+ if matched == len(terms) and len(terms) > 1:
219
+ score *= 1.5
220
+ scored.append((score, rec))
221
+ scored.sort(key=lambda x: -x[0])
222
+ out = []
223
+ for score, rec in scored[:limit]:
224
+ item = {"id": rec["id"], "entry": rec.get("entry"), "title": rec.get("title"),
225
+ "source": rec.get("source"), "kind": rec.get("_kind"),
226
+ "tags": rec.get("tags") or [], "score": round(score, 1)}
227
+ if full:
228
+ item["content"] = rec["content"]
229
+ else:
230
+ pos = min((rec["content"].find(t) for t in terms
231
+ if rec["content"].find(t) >= 0), default=0)
232
+ start = max(0, pos - 40)
233
+ item["snippet"] = rec["content"][start:start + SNIPPET_LEN].replace("\n", " ")
234
+ out.append(item)
235
+ return out
@@ -0,0 +1,102 @@
1
+ """日志。标准 logging + success/trace 两个额外级别。"""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from typing import Any
7
+
8
+ SUCCESS = 25
9
+ TRACE = 5
10
+ logging.addLevelName(SUCCESS, "SUCCESS")
11
+ logging.addLevelName(TRACE, "TRACE")
12
+
13
+
14
+ class RoverLogger(logging.Logger):
15
+ def success(self, msg: Any, *args: Any, **kwargs: Any) -> None:
16
+ if self.isEnabledFor(SUCCESS):
17
+ self._log(SUCCESS, msg, args, **kwargs)
18
+
19
+ def trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
20
+ if self.isEnabledFor(TRACE):
21
+ self._log(TRACE, msg, args, **kwargs)
22
+
23
+
24
+ logging.setLoggerClass(RoverLogger)
25
+
26
+ _COLORS = {
27
+ "TRACE": "\033[36m",
28
+ "DEBUG": "\033[36m",
29
+ "INFO": "\033[0m",
30
+ "SUCCESS": "\033[32m",
31
+ "WARNING": "\033[33m",
32
+ "ERROR": "\033[31m",
33
+ "CRITICAL": "\033[35m",
34
+ }
35
+
36
+
37
+ class _Formatter(logging.Formatter):
38
+ def format(self, record: logging.LogRecord) -> str:
39
+ color = _COLORS.get(record.levelname, "")
40
+ plain = record.levelname
41
+ record.levelname = f"{color}{plain:<8}\033[0m"
42
+ try:
43
+ return super().format(record)
44
+ finally:
45
+ # 同一条记录还要交给文件处理器, 不能把颜色码留在上面
46
+ record.levelname = plain
47
+
48
+
49
+ # 落盘日志的容量上限: 单个文件 2MB, 连备份最多 4 个文件
50
+ FILE_MAX_BYTES = 2 * 1024 * 1024
51
+ FILE_BACKUPS = 3
52
+
53
+
54
+ def _level() -> str:
55
+ try:
56
+ from rover.config import core_config
57
+
58
+ value = core_config.get_config("log_level")
59
+ except Exception:
60
+ value = ""
61
+ return str(os.getenv("ROVER_LOG_LEVEL") or value or "INFO").upper()
62
+
63
+
64
+ def _file_handler() -> logging.Handler:
65
+ from logging.handlers import RotatingFileHandler
66
+
67
+ from rover.data_store import get_res_path
68
+
69
+ return RotatingFileHandler(
70
+ get_res_path("logs") / "service.log",
71
+ maxBytes=FILE_MAX_BYTES,
72
+ backupCount=FILE_BACKUPS,
73
+ encoding="utf-8",
74
+ )
75
+
76
+
77
+ def _build() -> RoverLogger:
78
+ lg = logging.getLogger("rover")
79
+ if not isinstance(lg, RoverLogger):
80
+ # 本模块之前已有人建过同名 logger, 换成带 success/trace 的实现
81
+ lg.__class__ = RoverLogger
82
+ if lg.handlers:
83
+ return lg # type: ignore[return-value]
84
+
85
+ lg.setLevel(_level())
86
+ fmt = "%(asctime)s | %(levelname)s | %(message)s"
87
+
88
+ stdout = logging.StreamHandler(sys.stdout)
89
+ stdout.setFormatter(_Formatter(fmt, "%m-%d %H:%M:%S"))
90
+ lg.addHandler(stdout)
91
+ try:
92
+ handler = _file_handler()
93
+ # 文件里不要 ANSI 颜色码
94
+ handler.setFormatter(logging.Formatter(fmt, "%m-%d %H:%M:%S"))
95
+ lg.addHandler(handler)
96
+ except OSError:
97
+ pass
98
+ lg.propagate = False
99
+ return lg # type: ignore[return-value]
100
+
101
+
102
+ logger: RoverLogger = _build()
@@ -0,0 +1,33 @@
1
+ """服务入口: python -m rover.main [--host 127.0.0.1] [--port 9777]"""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description="鸣潮服务")
13
+ parser.add_argument("--host", default=os.getenv("ROVER_HOST", "127.0.0.1"))
14
+ parser.add_argument("--port", type=int, default=int(os.getenv("ROVER_PORT", "9777")))
15
+ args = parser.parse_args()
16
+
17
+ from rover.config import core_config
18
+
19
+ if str(core_config.get_config("PORT")) != str(args.port):
20
+ core_config.set_config("PORT", str(args.port))
21
+ if core_config.get_config("HOST") != args.host:
22
+ core_config.set_config("HOST", args.host)
23
+
24
+ import uvicorn
25
+
26
+ import rover.api # noqa: F401 注册路由
27
+ from rover.app_life import app
28
+
29
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning", access_log=False)
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,222 @@
1
+ """媒体落盘与 HTML 载体。渲染产物存文件、对外给 /api/media/<id> 链接。"""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from stat import S_ISREG
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ from rover.data_store import get_res_path
12
+ from rover.logger import logger
13
+
14
+ MEDIA_DIR: Path = get_res_path() / "media"
15
+
16
+ # 刚落盘的产物在此秒数内不参与超限淘汰, 避免删掉前端正在加载的卡片
17
+ GRACE_SECONDS = 300
18
+ # 超限淘汰的目标水位
19
+ TARGET_RATIO = 0.8
20
+ # 累计写入量达到此值才做一次目录扫描清理
21
+ CHECK_BYTES = 32 * 1024 * 1024
22
+ # 访问时刷新 mtime 的最小间隔
23
+ TOUCH_INTERVAL = 60
24
+
25
+ _written_bytes = 0
26
+
27
+
28
+ def _max_bytes() -> int:
29
+ from rover.config import core_config
30
+
31
+ try:
32
+ return int(core_config.get_config("media_max_mb") or 0) * 1024 * 1024
33
+ except Exception:
34
+ return 512 * 1024 * 1024
35
+
36
+
37
+ class HtmlBytes(bytes):
38
+ """HTML 渲染产物。是 bytes 子类, 因此能穿透插件内所有 `if img_bytes:` 判断。"""
39
+
40
+ html: str
41
+ template_name: str
42
+
43
+ def __new__(cls, html: str, template_name: str = ""):
44
+ obj = super().__new__(cls, html.encode("utf-8"))
45
+ obj.html = html
46
+ obj.template_name = template_name
47
+ return obj
48
+
49
+
50
+ def save_media(data: bytes, ext: str = "jpg") -> str:
51
+ MEDIA_DIR.mkdir(parents=True, exist_ok=True)
52
+ digest = hashlib.sha1(data).hexdigest()[:20]
53
+ name = f"{digest}.{ext}"
54
+ path = MEDIA_DIR / name
55
+ if path.exists():
56
+ _touch(path)
57
+ else:
58
+ path.write_bytes(data)
59
+ _on_written(len(data))
60
+ return name
61
+
62
+
63
+ async def render_png(name: str) -> Optional[Path]:
64
+ """把存好的 HTML 卡片用无头浏览器拍成 PNG, 按原始设计宽度出图, 结果缓存复用。"""
65
+ source = media_path(name)
66
+ if source is None or source.suffix != ".html":
67
+ return None
68
+ target = source.with_suffix(".png")
69
+ if target.is_file() and target.stat().st_mtime >= source.stat().st_mtime:
70
+ _touch(target)
71
+ return target
72
+
73
+ from playwright.async_api import async_playwright
74
+
75
+ async with async_playwright() as driver:
76
+ browser = await driver.chromium.launch(args=["--disable-gpu", "--disable-dev-shm-usage"])
77
+ try:
78
+ page = await browser.new_page(viewport={"width": 1600, "height": 1200})
79
+ await page.goto(source.as_uri(), wait_until="load")
80
+ # 桥接脚本没收到宿主宽度时不缩放, 量到的就是设计尺寸
81
+ await page.wait_for_timeout(600)
82
+ size = await page.evaluate(
83
+ "() => { const el = document.querySelector('.container') || document.body;"
84
+ " return { w: Math.ceil(el.scrollWidth), h: Math.ceil(el.scrollHeight) }; }"
85
+ )
86
+ width = max(1, min(int(size["w"]), 4000))
87
+ height = max(1, min(int(size["h"]), 30000))
88
+ await page.set_viewport_size({"width": width, "height": min(height, 2000)})
89
+ await page.wait_for_timeout(400)
90
+ data = await page.screenshot(
91
+ full_page=True, clip={"x": 0, "y": 0, "width": width, "height": height}
92
+ )
93
+ finally:
94
+ await browser.close()
95
+
96
+ target.write_bytes(data)
97
+ _on_written(len(data))
98
+ logger.info(f"[媒体] 卡片截图 {name} → {width}x{height} {len(data) // 1024}KB")
99
+ return target
100
+
101
+
102
+ def media_url(name: str) -> str:
103
+ return f"/api/media/{name}"
104
+
105
+
106
+ def media_path(name: str) -> Optional[Path]:
107
+ if "/" in name or "\\" in name or name.startswith("."):
108
+ return None
109
+ path = MEDIA_DIR / name
110
+ if not path.is_file():
111
+ return None
112
+ _touch(path)
113
+ return path
114
+
115
+
116
+ def _touch(path: Path) -> None:
117
+ """刷新 mtime, 使淘汰顺序按最近使用时间。"""
118
+ try:
119
+ if time.time() - path.stat().st_mtime > TOUCH_INTERVAL:
120
+ os.utime(path, None)
121
+ except OSError:
122
+ pass
123
+
124
+
125
+ def _on_written(size: int) -> None:
126
+ global _written_bytes
127
+ _written_bytes += size
128
+ if _written_bytes < CHECK_BYTES:
129
+ return
130
+ _written_bytes = 0
131
+ try:
132
+ cleanup_media()
133
+ except Exception as e:
134
+ logger.warning(f"[媒体清理] 写入触发的清理失败: {e}")
135
+
136
+
137
+ def _scan() -> Tuple[List[Tuple[float, int, Path]], int]:
138
+ entries: List[Tuple[float, int, Path]] = []
139
+ total = 0
140
+ try:
141
+ it = list(MEDIA_DIR.iterdir())
142
+ except OSError:
143
+ return entries, total
144
+ for path in it:
145
+ try:
146
+ st = path.stat()
147
+ except OSError:
148
+ continue
149
+ if not S_ISREG(st.st_mode):
150
+ continue
151
+ entries.append((st.st_mtime, st.st_size, path))
152
+ total += st.st_size
153
+ return entries, total
154
+
155
+
156
+ def media_usage() -> Dict[str, float]:
157
+ if not MEDIA_DIR.is_dir():
158
+ return {"files": 0, "bytes": 0, "mb": 0.0}
159
+ files = 0
160
+ total = 0
161
+ for path in MEDIA_DIR.iterdir():
162
+ if path.is_file():
163
+ files += 1
164
+ total += path.stat().st_size
165
+ return {"files": files, "bytes": total, "mb": total / 1024 / 1024}
166
+
167
+
168
+ def cleanup_media() -> Dict[str, int]:
169
+ if not MEDIA_DIR.is_dir():
170
+ return {"removed": 0, "freed": 0, "remain": 0}
171
+ """超容量上限时按最久未使用淘汰到目标水位; 不按时间删, 历史卡片还引用着。"""
172
+ now = time.time()
173
+ limit = _max_bytes()
174
+ entries, total = _scan()
175
+ removed = 0
176
+ freed = 0
177
+ alive: List[Tuple[float, int, Path]] = list(entries)
178
+
179
+ if limit > 0 and total > limit:
180
+ target = int(limit * TARGET_RATIO)
181
+ alive.sort(key=lambda item: item[0])
182
+ for mtime, size, path in alive:
183
+ if total <= target:
184
+ break
185
+ if now - mtime < GRACE_SECONDS:
186
+ continue
187
+ try:
188
+ path.unlink()
189
+ except OSError:
190
+ continue
191
+ removed += 1
192
+ freed += size
193
+ total -= size
194
+
195
+ if removed:
196
+ logger.info(
197
+ f"[媒体清理] 删除 {removed} 个, 释放 {freed / 1048576:.1f}MB, "
198
+ f"剩余 {total / 1048576:.1f}MB / 上限 {limit / 1048576:.0f}MB"
199
+ )
200
+ return {
201
+ "removed": removed,
202
+ "freed_bytes": freed,
203
+ "remaining_bytes": total,
204
+ "limit_bytes": limit,
205
+ }
206
+
207
+
208
+ async def _scheduled_cleanup() -> None:
209
+ await asyncio.to_thread(cleanup_media)
210
+
211
+
212
+ def register_cleanup_job() -> None:
213
+ """注册周期清理, 由 app_life 在调度器启动前调用。"""
214
+ from rover.aps import scheduler
215
+
216
+ scheduler.add_job(
217
+ _scheduled_cleanup,
218
+ "interval",
219
+ minutes=30,
220
+ id="media_cleanup",
221
+ replace_existing=True,
222
+ )
@@ -0,0 +1,23 @@
1
+ from typing import List, Union, Literal, TypeVar, Optional
2
+
3
+ from msgspec import Struct
4
+
5
+
6
+ class Button(Struct):
7
+ text: str
8
+ data: str # 具体数据
9
+ pressed_text: Optional[str] = None # 按下之后显示的值
10
+ style: Literal[0, 1] = 1 # 0灰色线框,1蓝色线框
11
+ action: Literal[-1, 0, 1, 2] = -1 # 0跳转按钮,1回调按钮,2命令按钮, 【-1自适应】
12
+ permisson: Literal[0, 1, 2, 3] = 2 # 0指定用户,1管理者,2所有人可按,3指定身份组
13
+ specify_role_ids: List[str] = [] # 仅限频道可用
14
+ specify_user_ids: List[str] = [] # 指定用户
15
+ unsupport_tips: str = "您的客户端暂不支持该功能, 请升级后适配..."
16
+ prefix: str = "" # 命令前缀, 使用时请直接继承该类重写该值, 避免重复定义
17
+ _edited: bool = False # 是否编辑过
18
+
19
+
20
+ T = TypeVar("T", bound=Button)
21
+
22
+ ButtonType = Union[List[str], List[T], List[List[str]], List[List[T]]]
23
+ ButtonList = Union[List[T], List[List[T]]]