@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DSH-GO-VERIFICATION.md +57 -0
- package/ICON.png +0 -0
- package/LICENSE +674 -0
- package/README.md +251 -2
- package/cordis.patch.yml +8 -0
- package/install.ps1 +0 -0
- package/install.sh +209 -0
- package/lib/client.js +1511 -0
- package/lib/host/aliashint.js +70 -0
- package/lib/host/attachments.js +25 -0
- package/lib/host/command.js +34 -0
- package/lib/host/consts.js +16 -0
- package/lib/host/dataguide.js +35 -0
- package/lib/host/imagebridge.js +41 -0
- package/lib/host/native-result.js +91 -0
- package/lib/host/native.js +191 -0
- package/lib/host/proxy.js +59 -0
- package/lib/host/results.js +67 -0
- package/lib/host/service.js +263 -0
- package/lib/host/tool.js +185 -0
- package/lib/host/troubleshoot.js +31 -0
- package/lib/host/worldbook.js +80 -0
- package/lib/index.js +74 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +136 -4
- package/service/ICON.png +0 -0
- package/service/ai/AI_DATA.md +221 -0
- package/service/knowledge/additional/manual.jsonl +5 -0
- package/service/knowledge/wiki/baike.jsonl +1231 -0
- package/service/knowledge/wiki/chengjiu.jsonl +36 -0
- package/service/knowledge/wiki/gonglve.jsonl +383 -0
- package/service/knowledge/wiki/huodong.jsonl +427 -0
- package/service/knowledge/wiki/renwu.jsonl +412 -0
- package/service/knowledge/wiki/tujian.jsonl +2889 -0
- package/service/knowledge/wiki/zixun.jsonl +47 -0
- package/service/plugins/__init__.py +0 -0
- package/service/pyproject.toml +40 -0
- package/service/rover/__init__.py +0 -0
- package/service/rover/ai_core/__init__.py +0 -0
- package/service/rover/ai_core/models.py +50 -0
- package/service/rover/ai_core/rag/__init__.py +5 -0
- package/service/rover/ai_core/rag/knowledge.py +23 -0
- package/service/rover/ai_core/register.py +86 -0
- package/service/rover/ai_core/resource.py +6 -0
- package/service/rover/ai_core/skills/__init__.py +0 -0
- package/service/rover/ai_core/skills/operations.py +8 -0
- package/service/rover/api.py +720 -0
- package/service/rover/api_render.py +161 -0
- package/service/rover/app_life.py +154 -0
- package/service/rover/aps.py +47 -0
- package/service/rover/avatar.py +85 -0
- package/service/rover/bot.py +133 -0
- package/service/rover/builtin.py +152 -0
- package/service/rover/cards/__init__.py +26 -0
- package/service/rover/cards/_shared.py +129 -0
- package/service/rover/cards/char_list.py +258 -0
- package/service/rover/cards/char_panel.py +1353 -0
- package/service/rover/cards/echo_list.py +206 -0
- package/service/rover/cards/matrix_roles.py +25 -0
- package/service/rover/cards/rank_boards.py +714 -0
- package/service/rover/cards/rank_total.py +664 -0
- package/service/rover/compat.py +215 -0
- package/service/rover/config.py +163 -0
- package/service/rover/custom_panel.py +220 -0
- package/service/rover/data_store.py +31 -0
- package/service/rover/gss.py +19 -0
- package/service/rover/handler.py +191 -0
- package/service/rover/help/__init__.py +0 -0
- package/service/rover/help/draw_new_plugin_help.py +332 -0
- package/service/rover/help/model.py +16 -0
- package/service/rover/help/utils.py +7 -0
- package/service/rover/hooks.py +420 -0
- package/service/rover/html_actions.py +236 -0
- package/service/rover/i18n.py +19 -0
- package/service/rover/knowledge.py +235 -0
- package/service/rover/logger.py +102 -0
- package/service/rover/main.py +33 -0
- package/service/rover/media.py +222 -0
- package/service/rover/message_models.py +23 -0
- package/service/rover/models.py +92 -0
- package/service/rover/native_media.py +64 -0
- package/service/rover/ocr.py +79 -0
- package/service/rover/pool.py +43 -0
- package/service/rover/restart.py +36 -0
- package/service/rover/segment.py +143 -0
- package/service/rover/server.py +131 -0
- package/service/rover/status/__init__.py +0 -0
- package/service/rover/status/models.py +10 -0
- package/service/rover/status/plugin_status.py +17 -0
- package/service/rover/subscribe.py +148 -0
- package/service/rover/sv.py +403 -0
- package/service/rover/templates/char_list.html +182 -0
- package/service/rover/templates/char_panel.html +465 -0
- package/service/rover/templates/echo_list.html +221 -0
- package/service/rover/templates/rank_all_total.html +193 -0
- package/service/rover/templates/rank_board.html +107 -0
- package/service/rover/templates/rank_level_total.html +57 -0
- package/service/rover/templates/rank_matrix_total.html +66 -0
- package/service/rover/templates/rank_phantom_total.html +180 -0
- package/service/rover/templates/rank_slash_total.html +38 -0
- package/service/rover/trigger.py +98 -0
- package/service/rover/utils/__init__.py +0 -0
- package/service/rover/utils/boardcast/__init__.py +0 -0
- package/service/rover/utils/boardcast/models.py +15 -0
- package/service/rover/utils/cookie_manager/__init__.py +0 -0
- package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
- package/service/rover/utils/database/__init__.py +0 -0
- package/service/rover/utils/database/base_models.py +1771 -0
- package/service/rover/utils/database/models.py +80 -0
- package/service/rover/utils/database/startup.py +60 -0
- package/service/rover/utils/default_bg/bg.jpg +0 -0
- package/service/rover/utils/download_resource/__init__.py +0 -0
- package/service/rover/utils/download_resource/download_core.py +164 -0
- package/service/rover/utils/download_resource/download_file.py +46 -0
- package/service/rover/utils/fonts/__init__.py +0 -0
- package/service/rover/utils/fonts/fonts.py +33 -0
- package/service/rover/utils/image/__init__.py +0 -0
- package/service/rover/utils/image/convert.py +51 -0
- package/service/rover/utils/image/image_tools.py +30 -0
- package/service/rover/utils/image/utils.py +70 -0
- package/service/rover/utils/plugins_config/__init__.py +0 -0
- package/service/rover/utils/plugins_config/gs_config.py +506 -0
- package/service/rover/utils/plugins_config/models.py +182 -0
- package/service/rover/web_app.py +3 -0
- package/service/rover/webconsole/__init__.py +0 -0
- package/service/rover/webconsole/mount_app.py +26 -0
- package/service/tools/migrate.py +351 -0
- package/service/tools/regression.py +87 -0
- package/service/tools/smoke.py +63 -0
- package/service/tools/update_plugins.py +212 -0
- package/service/tools/verify_actions.py +104 -0
- package/service/tools/verify_card.py +82 -0
- package/service/tools/wwdata.py +665 -0
- package/tests/compat.test.js +181 -0
- package/tests/imagebridge.test.js +58 -0
- package/tests/media.test.js +68 -0
- package/tests/native.test.js +127 -0
- package/tests/runtime.test.js +69 -0
- 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
|