@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,161 @@
1
+ """消息段 → 前端 JSON。图片/HTML 落盘走 /api/media, 避免把 MB 级 base64 塞进响应。"""
2
+
3
+ import base64
4
+ import html as html_lib
5
+ import re
6
+ from typing import Any, Dict, List
7
+
8
+ import msgspec
9
+
10
+ from rover.logger import logger
11
+ from rover.media import media_url, save_media
12
+ from rover.models import Message
13
+
14
+ _IMAGE_MAGIC = {
15
+ b"\xff\xd8\xff": "jpg",
16
+ b"\x89PNG": "png",
17
+ b"GIF8": "gif",
18
+ b"RIFF": "webp",
19
+ }
20
+
21
+
22
+ def _guess_ext(data: bytes) -> str:
23
+ for magic, ext in _IMAGE_MAGIC.items():
24
+ if data.startswith(magic):
25
+ return ext
26
+ return "jpg"
27
+
28
+
29
+ _DROP_TAGS = re.compile(r"<(script|style)\b.*?</\1>", re.S | re.I)
30
+ # 头像格上挂着角色名, 摊平成文字时按原位置补进去
31
+ _NAMED_TAG = re.compile(r'<[^>]*data-ww-name="([^"]*)"[^>]*>')
32
+ _TAGS = re.compile(r"<[^>]+>")
33
+ _BLANK = re.compile(r"[ \t ]+")
34
+ # 卡片里的数值也交给模型: 太长会挤占上下文, 截断即可
35
+ DIGEST_LIMIT = 1600
36
+
37
+
38
+ def html_digest(html: str) -> str:
39
+ """把卡片里的可见文字抽出来, 让模型也能看到面板上的具体数值。"""
40
+ text = _DROP_TAGS.sub(" ", html)
41
+ text = _NAMED_TAG.sub(lambda m: m.group(0) + m.group(1) + " ", text)
42
+ text = _TAGS.sub(" ", text)
43
+ text = _BLANK.sub(" ", html_lib.unescape(text))
44
+ words: List[str] = []
45
+ for line in text.splitlines():
46
+ line = line.strip()
47
+ if line:
48
+ words.append(line)
49
+ joined = " ".join(words)
50
+ return joined[:DIGEST_LIMIT] + " …" if len(joined) > DIGEST_LIMIT else joined
51
+
52
+
53
+ async def _image_segment(data: Any) -> Dict[str, Any]:
54
+ if isinstance(data, str):
55
+ if data.startswith("link://"):
56
+ return {"type": "image", "url": data[len("link://") :]}
57
+ if data.startswith("base64://"):
58
+ raw = base64.b64decode(data[len("base64://") :])
59
+ else:
60
+ return {"type": "image", "url": data}
61
+ elif isinstance(data, (bytes, bytearray, memoryview)):
62
+ raw = bytes(data)
63
+ else:
64
+ return {"type": "text", "text": str(data)}
65
+ name = save_media(raw, _guess_ext(raw))
66
+ seg: Dict[str, Any] = {"type": "image", "url": media_url(name), "bytes": len(raw)}
67
+ from rover.config import core_config
68
+
69
+ if core_config.get_config("image_ocr") is not False:
70
+ from rover.ocr import image_digest
71
+
72
+ digest = await image_digest(raw)
73
+ if digest:
74
+ seg["digest"] = digest
75
+ return seg
76
+
77
+
78
+ async def render_segments(segments: List[Message]) -> List[Dict[str, Any]]:
79
+ out: List[Dict[str, Any]] = []
80
+ for seg in segments:
81
+ stype = seg.type or "text"
82
+ data = seg.data
83
+ try:
84
+ if stype == "text":
85
+ text = "" if data is None else str(data)
86
+ if text.strip():
87
+ out.append({"type": "text", "text": text})
88
+ elif stype == "image":
89
+ out.append(await _image_segment(data))
90
+ elif stype == "html":
91
+ html = data if isinstance(data, str) else str(data)
92
+ name = save_media(html.encode("utf-8"), "html")
93
+ out.append(
94
+ {
95
+ "type": "html",
96
+ "url": media_url(name),
97
+ "bytes": len(html),
98
+ "digest": html_digest(html),
99
+ }
100
+ )
101
+ elif stype == "node":
102
+ items = data if isinstance(data, list) else []
103
+ children = await render_segments(
104
+ [i if isinstance(i, Message) else Message(**i) for i in items]
105
+ )
106
+ out.append({"type": "node", "items": children})
107
+ elif stype == "file":
108
+ raw = str(data or "")
109
+ file_name, _, payload = raw.partition("|")
110
+ if payload.startswith("link://"):
111
+ out.append(
112
+ {"type": "file", "name": file_name, "url": payload[len("link://") :]}
113
+ )
114
+ else:
115
+ ext = file_name.rsplit(".", 1)[-1] if "." in file_name else "bin"
116
+ name = save_media(base64.b64decode(payload), ext)
117
+ out.append({"type": "file", "name": file_name, "url": media_url(name)})
118
+ elif stype == "buttons":
119
+ out.append({"type": "buttons", "buttons": msgspec.to_builtins(data)})
120
+ elif stype == "at":
121
+ out.append({"type": "at", "target": str(data)})
122
+ else:
123
+ out.append({"type": "text", "text": f"[{stype}]"})
124
+ except Exception as e:
125
+ logger.warning(f"消息段渲染失败 type={stype}: {e}")
126
+ out.append({"type": "text", "text": f"[{stype} 渲染失败]"})
127
+ return out
128
+
129
+
130
+ def summarize(rendered: List[Dict[str, Any]]) -> str:
131
+ """给模型看的纯文本摘要(图片/HTML 只留占位, 不塞内容)。"""
132
+ parts: List[str] = []
133
+ for seg in rendered:
134
+ stype = seg.get("type")
135
+ if stype == "text":
136
+ parts.append(seg.get("text", ""))
137
+ elif stype == "image":
138
+ digest = seg.get("digest") or ""
139
+ parts.append(
140
+ f"[图片已在卡片中展示]\n图片文字(OCR): {digest}" if digest
141
+ else "[图片已在卡片中展示]"
142
+ )
143
+ elif stype == "html":
144
+ digest = seg.get("digest") or ""
145
+ parts.append(
146
+ f"[图文卡片已在界面中展示]\n卡片内容: {digest}" if digest
147
+ else "[图文卡片已在界面中展示]"
148
+ )
149
+ elif stype == "node":
150
+ parts.append(summarize(seg.get("items", [])))
151
+ elif stype == "file":
152
+ parts.append(f"[文件 {seg.get('name')}]")
153
+ elif stype == "buttons":
154
+ labels = []
155
+ for row in seg.get("buttons") or []:
156
+ for btn in row if isinstance(row, list) else [row]:
157
+ if isinstance(btn, dict) and btn.get("text"):
158
+ labels.append(str(btn["text"]))
159
+ if labels:
160
+ parts.append("可用快捷指令: " + " / ".join(labels))
161
+ return "\n".join(p for p in parts if p)
@@ -0,0 +1,154 @@
1
+ """服务生命周期。启动顺序: 建库 → 载插件 → 建表迁移 → 启动钩子与调度器。"""
2
+
3
+ import asyncio
4
+ import importlib
5
+ import pkgutil
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+
12
+ from rover.logger import logger
13
+
14
+ PLUGINS_DIR = Path(__file__).resolve().parents[1] / "plugins"
15
+
16
+ # 目录名 → 内层包名(上游插件都是同名双层目录)
17
+ PLUGIN_PACKAGES = ["XutheringWavesUID", "RoverSign", "ScoreEcho"]
18
+
19
+ # 面向原框架自带模型的模块, dsh 有自己的模型, 不加载
20
+ SKIP_MODULES = {"wutheringwaves_ai_rag"}
21
+
22
+ # 运行时的硬性前提: 出图走本地 HTML, 没有远端渲染器。这两项在设置页里也不展示
23
+ CONFIG_FORCED = {
24
+ "XutheringWavesUID": {
25
+ "UseHtmlRender": True,
26
+ "RemoteRenderEnable": False,
27
+ # 推送不绑 dsh 会话, 会落到全局收件箱并出现在每张卡片上, 本地关掉
28
+ "WavesAnnOpen": False,
29
+ },
30
+ }
31
+
32
+ # 初值: 只在插件第一次装载时写一次, 之后以用户在设置页里的选择为准
33
+ CONFIG_SEED = {
34
+ "XutheringWavesUID": {
35
+ "RankActiveFilterGroup": False,
36
+ "ActiveUserDays": 0,
37
+ },
38
+ "RoverSign": {
39
+ "UserWavesSignin": True,
40
+ "UserPGRSignin": True,
41
+ "UserBBSSchedSignin": True,
42
+ "SigninMaster": True,
43
+ "SignActiveUserOnly": False,
44
+ "SigninMasterSkipInactive": False,
45
+ "ActiveUserDays": 0,
46
+ },
47
+ }
48
+
49
+ _plugins_loaded = False
50
+ plugin_load_errors = []
51
+
52
+
53
+ def _inner_package(name: str) -> str:
54
+ return f"plugins.{name}.{name}"
55
+
56
+
57
+ def load_plugins() -> None:
58
+ """按上游的双层包结构加载, 加载前后各施加一次本地改造。"""
59
+ global _plugins_loaded
60
+ if _plugins_loaded:
61
+ return
62
+ _plugins_loaded = True
63
+
64
+ from rover import compat, hooks
65
+
66
+ compat.install([str(PLUGINS_DIR)])
67
+ # 帮助表在插件 import 时就定型, 扩展模块清单得先按目录里实际有哪些插件对齐
68
+ hooks.align_help_modules(
69
+ {p.name for p in PLUGINS_DIR.iterdir() if p.is_dir() and not p.name.startswith(("_", "."))}
70
+ )
71
+
72
+ missing = compat.missing_imports(PLUGINS_DIR)
73
+ if missing:
74
+ logger.warning(f"[加载] 运行时缺少上游用到的模块: {missing}")
75
+
76
+ for name in PLUGIN_PACKAGES:
77
+ package_name = _inner_package(name)
78
+ try:
79
+ package = importlib.import_module(package_name)
80
+ except Exception as e:
81
+ plugin_load_errors.append(name)
82
+ logger.error(f"[加载] 插件 {name} 载入失败: {e}")
83
+ logger.exception(e)
84
+ continue
85
+
86
+ hooks.patch_modules(name, package_name)
87
+
88
+ loaded = 0
89
+ for module in pkgutil.iter_modules(package.__path__):
90
+ if not module.ispkg or module.name.startswith("_") or module.name == "utils":
91
+ continue
92
+ if module.name in SKIP_MODULES:
93
+ continue
94
+ try:
95
+ importlib.import_module(f"{package_name}.{module.name}")
96
+ loaded += 1
97
+ except Exception as e:
98
+ plugin_load_errors.append(f"{name}.{module.name}")
99
+ logger.error(f"[加载] 模块 {name}.{module.name} 载入失败: {e}")
100
+ logger.exception(e)
101
+ logger.success(f"[加载] {name} 已载入 {loaded} 个功能模块")
102
+
103
+ importlib.import_module("rover.builtin")
104
+ hooks.apply_defaults(CONFIG_FORCED)
105
+ hooks.seed_defaults(CONFIG_SEED)
106
+ hooks.align_local_urls()
107
+ hooks.drop_commands()
108
+
109
+
110
+ @asynccontextmanager
111
+ async def lifespan(app: FastAPI):
112
+ from rover.aps import start_scheduler, stop_scheduler
113
+ from rover.media import register_cleanup_job
114
+ from rover.server import core_shutdown_execute, core_start_before_execute, core_start_execute
115
+ from rover.utils.database.base_models import init_database
116
+ from rover.utils.database.startup import init_all
117
+
118
+ await init_database()
119
+ load_plugins()
120
+ await init_all()
121
+ await core_start_before_execute()
122
+ app.state.resources_ready = False
123
+ app.state.resource_error = None
124
+
125
+ async def prepare_resources():
126
+ try:
127
+ await core_start_execute()
128
+ app.state.resources_ready = True
129
+ except Exception as error:
130
+ app.state.resource_error = str(error)
131
+ logger.exception(error)
132
+
133
+ resource_task = asyncio.create_task(prepare_resources())
134
+ register_cleanup_job()
135
+ await start_scheduler()
136
+ logger.success("[服务] 启动完成")
137
+ try:
138
+ yield
139
+ finally:
140
+ resource_task.cancel()
141
+ await asyncio.gather(resource_task, return_exceptions=True)
142
+ await stop_scheduler()
143
+ await core_shutdown_execute()
144
+ logger.info("[服务] 已停止")
145
+
146
+
147
+ app = FastAPI(title="鸣潮服务", lifespan=lifespan)
148
+ app.add_middleware(
149
+ CORSMiddleware,
150
+ allow_origins=["*"],
151
+ allow_credentials=False,
152
+ allow_methods=["*"],
153
+ allow_headers=["*"],
154
+ )
@@ -0,0 +1,47 @@
1
+ """定时任务调度器。插件通过 `@scheduler.scheduled_job` / `scheduler.add_job` 注册。"""
2
+
3
+ from apscheduler.executors.pool import ThreadPoolExecutor
4
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler
5
+
6
+ from rover.logger import logger
7
+
8
+ # default 执行器保持 AsyncIOExecutor(协程任务必须在主事件循环跑),
9
+ # 阻塞型任务显式 add_job(..., executor="threadpool")
10
+ executor = ThreadPoolExecutor(10)
11
+ def _misfire_grace_time() -> int:
12
+ from rover.config import core_config
13
+
14
+ try:
15
+ return int(core_config.get_config("misfire_grace_time") or 90)
16
+ except Exception:
17
+ return 90
18
+
19
+
20
+ job_defaults = {"misfire_grace_time": _misfire_grace_time(), "coalesce": True}
21
+ options = {
22
+ "executors": {"threadpool": executor},
23
+ "job_defaults": job_defaults,
24
+ "timezone": "Asia/Shanghai",
25
+ }
26
+
27
+
28
+ class _Scheduler(AsyncIOScheduler):
29
+ def scheduled_job(self, *args, **kwargs):
30
+ kwargs.pop("replace_existing", None)
31
+ return super().scheduled_job(*args, **kwargs)
32
+
33
+
34
+ scheduler = _Scheduler()
35
+ scheduler.configure(**options)
36
+
37
+
38
+ async def start_scheduler() -> None:
39
+ if not scheduler.running:
40
+ scheduler.start()
41
+ logger.info("[调度器] 启动完成")
42
+
43
+
44
+ async def stop_scheduler() -> None:
45
+ if scheduler.running:
46
+ scheduler.shutdown(wait=False)
47
+ logger.info("[调度器] 已关闭定时任务")
@@ -0,0 +1,85 @@
1
+ """头像配置解析。
2
+
3
+ 配置项 avatar 支持三种写法: 本地图片路径 / http(s) 链接 / 纯数字 QQ 号,
4
+ 统一解析成能直接放进 <img src> 的地址; 置空则不覆盖, 沿用插件自己取的头像。
5
+ """
6
+
7
+ import base64
8
+ from pathlib import Path
9
+ from typing import Dict, Optional, Set, Tuple
10
+
11
+ from rover.logger import logger
12
+
13
+ QQ_AVATAR_URL = "https://q1.qlogo.cn/g?b=qq&nk={}&s=640"
14
+
15
+ # 按文件头认图片类型, 不信扩展名
16
+ MAGICS = (
17
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
18
+ (b"\xff\xd8\xff", "image/jpeg"),
19
+ (b"GIF8", "image/gif"),
20
+ (b"BM", "image/bmp"),
21
+ (b"\x00\x00\x01\x00", "image/x-icon"),
22
+ )
23
+
24
+ # 本地文件的 data:URI 缓存: 路径 → (修改时间, 大小, data:URI)
25
+ _file_cache: Dict[str, Tuple[float, int, str]] = {}
26
+ # 同一个坏配置只提示一次
27
+ _warned: Set[str] = set()
28
+
29
+
30
+ def _warn(raw: str, reason: str) -> None:
31
+ if raw in _warned:
32
+ return
33
+ _warned.add(raw)
34
+ logger.warning(f"[头像] 配置无效, 回退默认头像: {raw} ({reason})")
35
+
36
+
37
+ def _mime(data: bytes) -> Optional[str]:
38
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
39
+ return "image/webp"
40
+ for magic, mime in MAGICS:
41
+ if data.startswith(magic):
42
+ return mime
43
+ if b"<svg" in data[:512].lower():
44
+ return "image/svg+xml"
45
+ return None
46
+
47
+
48
+ def _data_uri(raw: str) -> str:
49
+ path = Path(raw).expanduser()
50
+ try:
51
+ stat = path.stat()
52
+ except OSError as e:
53
+ _warn(raw, f"读不到文件: {e}")
54
+ return ""
55
+ key = str(path)
56
+ cached = _file_cache.get(key)
57
+ if cached and cached[0] == stat.st_mtime and cached[1] == stat.st_size:
58
+ return cached[2]
59
+ try:
60
+ data = path.read_bytes()
61
+ except OSError as e:
62
+ _warn(raw, f"读文件失败: {e}")
63
+ return ""
64
+ mime = _mime(data)
65
+ if mime is None:
66
+ _warn(raw, "不是图片文件")
67
+ return ""
68
+ uri = f"data:{mime};base64,{base64.b64encode(data).decode()}"
69
+ _file_cache[key] = (stat.st_mtime, stat.st_size, uri)
70
+ _warned.discard(raw)
71
+ return uri
72
+
73
+
74
+ def get_avatar_url() -> str:
75
+ """返回配置的头像地址; 未配置或配置无效时返回空串。"""
76
+ from rover.config import core_config
77
+
78
+ raw = str(core_config.get_config("avatar") or "").strip()
79
+ if not raw:
80
+ return ""
81
+ if raw.startswith(("http://", "https://", "data:")):
82
+ return raw
83
+ if raw.isascii() and raw.isdigit():
84
+ return QQ_AVATAR_URL.format(raw)
85
+ return _data_uri(raw)
@@ -0,0 +1,133 @@
1
+ """会话对象。插件调用 bot.send 产出的消息段被收集起来, 由上层一次性返回前端。"""
2
+
3
+ from typing import Any, Dict, List, Optional, Union
4
+
5
+ import msgspec
6
+
7
+ from rover.logger import logger
8
+ from rover.message_models import Button
9
+ from rover.models import Event, Message
10
+ from rover.segment import MessageSegment, convert_message
11
+
12
+ msgjson = msgspec.json
13
+
14
+
15
+ class _AsyncLogger:
16
+ """插件里以 `await bot.logger.info(...)` 形式使用。"""
17
+
18
+ def __init__(self, prefix: str = ""):
19
+ self.prefix = prefix
20
+
21
+ async def _log(self, level: str, message: Any, *args: Any) -> None:
22
+ getattr(logger, level)(f"{self.prefix}{message}", *args)
23
+
24
+ async def info(self, message: Any, *args: Any) -> None:
25
+ await self._log("info", message, *args)
26
+
27
+ async def debug(self, message: Any, *args: Any) -> None:
28
+ await self._log("debug", message, *args)
29
+
30
+ async def trace(self, message: Any, *args: Any) -> None:
31
+ await self._log("trace", message, *args)
32
+
33
+ async def success(self, message: Any, *args: Any) -> None:
34
+ await self._log("success", message, *args)
35
+
36
+ async def warning(self, message: Any, *args: Any) -> None:
37
+ await self._log("warning", message, *args)
38
+
39
+ async def error(self, message: Any, *args: Any) -> None:
40
+ await self._log("error", message, *args)
41
+
42
+ async def exception(self, message: Any, *args: Any) -> None:
43
+ logger.exception(message)
44
+
45
+
46
+ def _log_dropped_push(segments, target_type, target_id) -> None:
47
+ """主动推送在本地没有去处: 只有一个使用者, 也没有群会话。记一行日志就够了。"""
48
+ from rover.logger import logger
49
+
50
+ text = " ".join(str(s.data) for s in segments if getattr(s, "type", "") == "text")
51
+ logger.info(f"[推送] 丢弃一条 {target_type}/{target_id or '-'} 的主动推送: {text[:80]}")
52
+
53
+
54
+ class Bot:
55
+ def __init__(self, ev: Event, sink: Optional[List[Message]] = None):
56
+ self.ev = ev
57
+ self.bot_id = ev.bot_id
58
+ self.bot_self_id = ev.bot_self_id
59
+ self.logger = _AsyncLogger()
60
+ self.sink: List[Message] = sink if sink is not None else []
61
+
62
+ async def send(
63
+ self,
64
+ message: Union[Message, List[Message], List[str], str, bytes, None],
65
+ at_sender: bool = False,
66
+ extra_metadata: Optional[Dict] = None,
67
+ wait_recall: bool = False,
68
+ ) -> None:
69
+ if message is None:
70
+ return None
71
+ segments = await convert_message(message, self.bot_id, self.bot_self_id)
72
+ self.sink.extend(segments)
73
+ return None
74
+
75
+ async def send_option(
76
+ self,
77
+ reply: Union[Message, List[Message], List[str], str, bytes, None],
78
+ option_list: Optional[Union[List[Button], List[List[Button]]]] = None,
79
+ unsuported_platform: bool = False,
80
+ is_mutiply: bool = False,
81
+ timeout: int = 60,
82
+ ) -> None:
83
+ await self.send(reply)
84
+ if option_list:
85
+ self.sink.append(MessageSegment.buttons(option_list))
86
+ return None
87
+
88
+ async def target_send(
89
+ self,
90
+ message: Union[Message, List[Message], List[str], str, bytes],
91
+ target_type: str,
92
+ target_id: Optional[str],
93
+ bot_id: str = "",
94
+ bot_self_id: str = "",
95
+ msg_id: str = "",
96
+ at_sender: bool = False,
97
+ sender_id: str = "",
98
+ group_id: Optional[str] = None,
99
+ **kwargs: Any,
100
+ ) -> None:
101
+ segments = await convert_message(message, bot_id or self.bot_id, bot_self_id)
102
+ _log_dropped_push(segments, target_type, target_id)
103
+ return None
104
+
105
+
106
+ class PushBot:
107
+ """给定时任务/订阅推送用的发送端, 语义与 Bot.target_send 一致。"""
108
+
109
+ def __init__(self, bot_id: str = "dsh", bot_self_id: str = "dsh"):
110
+ self.bot_id = bot_id
111
+ self.bot_self_id = bot_self_id
112
+ self.logger = _AsyncLogger()
113
+
114
+ async def target_send(
115
+ self,
116
+ message: Union[Message, List[Message], List[str], str, bytes],
117
+ target_type: str,
118
+ target_id: Optional[str],
119
+ bot_id: str = "",
120
+ bot_self_id: str = "",
121
+ msg_id: str = "",
122
+ at_sender: bool = False,
123
+ sender_id: str = "",
124
+ group_id: Optional[str] = None,
125
+ **kwargs: Any,
126
+ ) -> None:
127
+ segments = await convert_message(message, bot_id or self.bot_id, bot_self_id)
128
+ _log_dropped_push(segments, target_type, target_id)
129
+ return None
130
+
131
+ async def send(self, message: Any, *args: Any, **kwargs: Any) -> None:
132
+ await self.target_send(message, "group", "dsh")
133
+ return None