@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,92 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any, Dict, List, Literal, Optional, Tuple
|
|
3
|
+
|
|
4
|
+
from msgspec import Struct
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Message(Struct):
|
|
8
|
+
type: Optional[str] = None
|
|
9
|
+
data: Optional[Any] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MessageReceive(Struct):
|
|
13
|
+
bot_id: str = "Bot"
|
|
14
|
+
bot_self_id: str = ""
|
|
15
|
+
msg_id: str = ""
|
|
16
|
+
user_type: Literal["group", "direct", "channel", "sub_channel"] = "group"
|
|
17
|
+
group_id: Optional[str] = None
|
|
18
|
+
user_id: str = ""
|
|
19
|
+
sender: Dict[str, Any] = {}
|
|
20
|
+
user_pm: int = 6
|
|
21
|
+
content: List[Message] = []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Event(MessageReceive):
|
|
25
|
+
WS_BOT_ID: Optional[str] = None
|
|
26
|
+
task_id: str = ""
|
|
27
|
+
task_event: Optional[asyncio.Event] = None
|
|
28
|
+
real_bot_id: str = ""
|
|
29
|
+
raw_text: str = ""
|
|
30
|
+
command: str = ""
|
|
31
|
+
text: str = ""
|
|
32
|
+
image: Optional[str] = None
|
|
33
|
+
image_list: List[Any] = []
|
|
34
|
+
image_id: Optional[str] = None
|
|
35
|
+
image_id_list: List[str] = []
|
|
36
|
+
audio_id: Optional[str] = None
|
|
37
|
+
audio_id_list: List[str] = []
|
|
38
|
+
at: Optional[str] = None
|
|
39
|
+
at_list: List[Any] = []
|
|
40
|
+
is_tome: bool = False
|
|
41
|
+
reply: Optional[str] = None
|
|
42
|
+
reply_id: Optional[str] = None
|
|
43
|
+
node: Optional[List[Message]] = None
|
|
44
|
+
file_name: Optional[str] = None
|
|
45
|
+
file: Optional[str] = None
|
|
46
|
+
file_type: Optional[Literal["url", "base64"]] = None
|
|
47
|
+
regex_group: Tuple[str, ...] = ()
|
|
48
|
+
regex_dict: Dict[str, str] = {}
|
|
49
|
+
meta_event_type: Optional[str] = None
|
|
50
|
+
meta_event_data: Dict[str, Any] = {}
|
|
51
|
+
|
|
52
|
+
def __hash__(self) -> int:
|
|
53
|
+
return hash(
|
|
54
|
+
(
|
|
55
|
+
self.WS_BOT_ID,
|
|
56
|
+
self.bot_id,
|
|
57
|
+
self.bot_self_id,
|
|
58
|
+
self.user_id,
|
|
59
|
+
self.group_id,
|
|
60
|
+
self.user_type,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __eq__(self, other: object) -> bool:
|
|
65
|
+
if not isinstance(other, Event):
|
|
66
|
+
return NotImplemented
|
|
67
|
+
return (
|
|
68
|
+
self.WS_BOT_ID,
|
|
69
|
+
self.bot_id,
|
|
70
|
+
self.bot_self_id,
|
|
71
|
+
self.user_id,
|
|
72
|
+
self.group_id,
|
|
73
|
+
self.user_type,
|
|
74
|
+
) == (
|
|
75
|
+
other.WS_BOT_ID,
|
|
76
|
+
other.bot_id,
|
|
77
|
+
other.bot_self_id,
|
|
78
|
+
other.user_id,
|
|
79
|
+
other.group_id,
|
|
80
|
+
other.user_type,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def session_id(self) -> str:
|
|
85
|
+
ws_bid = self.WS_BOT_ID or self.real_bot_id or self.bot_id or "0"
|
|
86
|
+
bid = self.bot_id if self.bot_id else "0"
|
|
87
|
+
bot_self_id = self.bot_self_id if self.bot_self_id else "0"
|
|
88
|
+
if self.user_type != "direct":
|
|
89
|
+
gid = self.group_id if self.group_id else "0"
|
|
90
|
+
return f"{ws_bid}:{bid}:{bot_self_id}:group:{gid}"
|
|
91
|
+
uid = self.user_id if self.user_id else "0"
|
|
92
|
+
return f"{ws_bid}:{bid}:{bot_self_id}:private:{uid}"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Bounded native image variants for dsh-go's authenticated asset route."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import io
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from rover.logger import logger
|
|
11
|
+
from rover.media import MEDIA_DIR, media_path, render_png
|
|
12
|
+
|
|
13
|
+
_NATIVE_LOCK = asyncio.Lock()
|
|
14
|
+
# The bridge accepts <= 1 MiB per image. Leave room for future format overhead.
|
|
15
|
+
_MAX_BYTES = 950_000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _image_variant(source: Path) -> Path:
|
|
19
|
+
target = source.with_name(source.stem + ".native.jpg")
|
|
20
|
+
if target.is_file() and target.stat().st_mtime >= source.stat().st_mtime:
|
|
21
|
+
return target
|
|
22
|
+
with Image.open(source) as raw:
|
|
23
|
+
image = raw.convert("RGB")
|
|
24
|
+
image.thumbnail((1600, 4096))
|
|
25
|
+
if image.width * image.height > 4_000_000:
|
|
26
|
+
scale = (4_000_000 / (image.width * image.height)) ** .5
|
|
27
|
+
image = image.resize((max(1, int(image.width * scale)), max(1, int(image.height * scale))))
|
|
28
|
+
for _ in range(6):
|
|
29
|
+
for quality in (88, 76, 62, 48):
|
|
30
|
+
buf = io.BytesIO()
|
|
31
|
+
image.save(buf, format="JPEG", quality=quality, optimize=True)
|
|
32
|
+
data = buf.getvalue()
|
|
33
|
+
if len(data) <= _MAX_BYTES:
|
|
34
|
+
target.write_bytes(data)
|
|
35
|
+
return target
|
|
36
|
+
image = image.resize((max(1, int(image.width * .8)), max(1, int(image.height * .8))))
|
|
37
|
+
raise ValueError("图片过大,请使用文字摘要")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def native_segments(segments: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
41
|
+
"""Keep the original segments; add images only for local generated media."""
|
|
42
|
+
out = []
|
|
43
|
+
for segment in segments:
|
|
44
|
+
seg = dict(segment)
|
|
45
|
+
if seg.get("type") == "node":
|
|
46
|
+
seg["items"] = await native_segments(seg.get("items") or [])
|
|
47
|
+
elif seg.get("type") in ("html", "image"):
|
|
48
|
+
url = str(seg.get("url") or "")
|
|
49
|
+
parsed = urlparse(url)
|
|
50
|
+
# Never turn model/user-controlled external URLs into server fetches.
|
|
51
|
+
if not parsed.scheme and not parsed.netloc and parsed.path.startswith("/api/media/"):
|
|
52
|
+
name = parsed.path.removeprefix("/api/media/")
|
|
53
|
+
if "/" not in name and "\\" not in name:
|
|
54
|
+
try:
|
|
55
|
+
async with _NATIVE_LOCK:
|
|
56
|
+
source = await render_png(name) if seg["type"] == "html" else media_path(name)
|
|
57
|
+
if source is not None:
|
|
58
|
+
target = await asyncio.to_thread(_image_variant, source)
|
|
59
|
+
seg["native_image"] = target.name
|
|
60
|
+
except Exception as error:
|
|
61
|
+
logger.warning(f"[原生卡片] 图片生成失败: {error}")
|
|
62
|
+
seg["native_error"] = "图片暂不可用,请查看文字摘要"
|
|
63
|
+
out.append(seg)
|
|
64
|
+
return out
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""图片卡片的本地 OCR 摘要: 评分/面板等 PIL 出图对纯文本模型不可见, 抽出文字随 summary 给模型。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from rover.logger import logger
|
|
8
|
+
|
|
9
|
+
MIN_BYTES = 20 * 1024
|
|
10
|
+
MAX_SIDE = 2000
|
|
11
|
+
DIGEST_LIMIT = 800
|
|
12
|
+
_CACHE_KEEP = 64
|
|
13
|
+
|
|
14
|
+
_engine: Any = None
|
|
15
|
+
_engine_failed = False
|
|
16
|
+
_cache: Dict[str, str] = {}
|
|
17
|
+
_lock = asyncio.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_engine():
|
|
21
|
+
global _engine, _engine_failed
|
|
22
|
+
if _engine is not None or _engine_failed:
|
|
23
|
+
return _engine
|
|
24
|
+
try:
|
|
25
|
+
from rapidocr import RapidOCR
|
|
26
|
+
|
|
27
|
+
# Headless OpenCV avoids X11/Qt dependencies on phone hosts. Bound ONNX
|
|
28
|
+
# worker pools so one screenshot cannot occupy every CPU core.
|
|
29
|
+
_engine = RapidOCR(params={
|
|
30
|
+
"EngineConfig.onnxruntime.intra_op_num_threads": 1,
|
|
31
|
+
"EngineConfig.onnxruntime.inter_op_num_threads": 1,
|
|
32
|
+
"Global.log_level": "warning",
|
|
33
|
+
})
|
|
34
|
+
except Exception as e: # noqa: BLE001
|
|
35
|
+
_engine_failed = True
|
|
36
|
+
logger.warning(f"[OCR] rapidocr 不可用, 图片文字摘要关闭: {e}")
|
|
37
|
+
return _engine
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run(raw: bytes) -> str:
|
|
41
|
+
import cv2
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
engine = _get_engine()
|
|
45
|
+
if engine is None:
|
|
46
|
+
return ""
|
|
47
|
+
img = cv2.imdecode(np.frombuffer(raw, dtype=np.uint8), cv2.IMREAD_COLOR)
|
|
48
|
+
if img is None:
|
|
49
|
+
return ""
|
|
50
|
+
h, w = img.shape[:2]
|
|
51
|
+
scale = MAX_SIDE / max(h, w)
|
|
52
|
+
if scale < 1:
|
|
53
|
+
img = cv2.resize(img, (int(w * scale), int(h * scale)))
|
|
54
|
+
result = engine(img)
|
|
55
|
+
lines = [str(text).strip() for text in result.txts or () if text]
|
|
56
|
+
text = " ".join(x for x in lines if x)
|
|
57
|
+
return text[:DIGEST_LIMIT] + " …" if len(text) > DIGEST_LIMIT else text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def image_digest(raw: Optional[bytes]) -> str:
|
|
61
|
+
"""一张卡片图 → 单行文字摘要; 太小的图(头像等)与识别失败返回空串。"""
|
|
62
|
+
if not raw or len(raw) < MIN_BYTES:
|
|
63
|
+
return ""
|
|
64
|
+
key = hashlib.sha1(raw).hexdigest()
|
|
65
|
+
if key in _cache:
|
|
66
|
+
return _cache[key]
|
|
67
|
+
# 串行跑: OCR 吃 CPU, 并发多张反而互相拖慢
|
|
68
|
+
async with _lock:
|
|
69
|
+
if key in _cache:
|
|
70
|
+
return _cache[key]
|
|
71
|
+
try:
|
|
72
|
+
text = await asyncio.to_thread(_run, raw)
|
|
73
|
+
except Exception as e: # noqa: BLE001
|
|
74
|
+
logger.warning(f"[OCR] 识别失败: {e}")
|
|
75
|
+
text = ""
|
|
76
|
+
if len(_cache) >= _CACHE_KEEP:
|
|
77
|
+
_cache.pop(next(iter(_cache)))
|
|
78
|
+
_cache[key] = text
|
|
79
|
+
return text
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""线程池。to_thread 是装饰器:被装饰函数为协程函数时在线程内新建事件循环执行。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from typing import Any, Awaitable, Callable, Coroutine, ParamSpec, TypeVar, cast, overload
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
P = ParamSpec("P")
|
|
11
|
+
|
|
12
|
+
_executor = ThreadPoolExecutor(max_workers=10)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@overload
|
|
16
|
+
def to_thread(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, Awaitable[T]]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@overload
|
|
20
|
+
def to_thread(func: Callable[P, T]) -> Callable[P, Awaitable[T]]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def to_thread(func: Callable[..., Any]) -> Callable[..., Awaitable[Any]]:
|
|
24
|
+
@functools.wraps(func)
|
|
25
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
26
|
+
loop = asyncio.get_running_loop()
|
|
27
|
+
|
|
28
|
+
def sync_worker():
|
|
29
|
+
if inspect.iscoroutinefunction(func):
|
|
30
|
+
new_loop = asyncio.new_event_loop()
|
|
31
|
+
try:
|
|
32
|
+
asyncio.set_event_loop(new_loop)
|
|
33
|
+
return new_loop.run_until_complete(func(*args, **kwargs))
|
|
34
|
+
finally:
|
|
35
|
+
new_loop.close()
|
|
36
|
+
return func(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
return await loop.run_in_executor(_executor, sync_worker)
|
|
39
|
+
|
|
40
|
+
return cast(Callable[..., Awaitable[Any]], wrapper)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
run_in_thread_pool = to_thread
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""进程重启。退出码 75 交给宿主(dsh)重新拉起, 不要 execv。
|
|
2
|
+
|
|
3
|
+
服务是 Node spawn 的 `python -m rover.main`。execv 用 sys.argv 会变成
|
|
4
|
+
`python rover/main.py`, sys.path 不对, 进程起不来。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from rover.logger import logger
|
|
12
|
+
|
|
13
|
+
# 与 lib/host/service.js 约定: 75 = 主动重启, 宿主马上再拉起
|
|
14
|
+
RESTART_EXIT = 75
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def restart_service(event: Optional[Any] = None, is_send: bool = True) -> None:
|
|
18
|
+
from rover.server import core_shutdown_execute
|
|
19
|
+
|
|
20
|
+
logger.info(f"[重启] 准备重启当前进程 pid={os.getpid()}")
|
|
21
|
+
if is_send and event is not None:
|
|
22
|
+
logger.info("[重启] 重启由会话触发,重启后无法回执")
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
await core_shutdown_execute()
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logger.warning(f"[重启] 关闭钩子执行异常: {e}")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
sys.stdout.flush()
|
|
31
|
+
sys.stderr.flush()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
logger.info(f"[重启] 退出 {RESTART_EXIT}, 由宿主重新拉起")
|
|
36
|
+
os._exit(RESTART_EXIT)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""消息段构造与归一化。语义对齐上游插件的既有预期, 尤其 List[str] → node。"""
|
|
2
|
+
|
|
3
|
+
from base64 import b64encode
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Union
|
|
7
|
+
|
|
8
|
+
import msgspec
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from rover.media import HtmlBytes
|
|
12
|
+
from rover.message_models import Button
|
|
13
|
+
from rover.models import Message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def process_buttons(
|
|
17
|
+
buttons: Union[List[Button], List[List[Button]]],
|
|
18
|
+
) -> List[List[Button]]:
|
|
19
|
+
if not buttons:
|
|
20
|
+
return []
|
|
21
|
+
if isinstance(buttons[0], list):
|
|
22
|
+
return buttons # type: ignore[return-value]
|
|
23
|
+
rows: List[List[Button]] = []
|
|
24
|
+
row: List[Button] = []
|
|
25
|
+
for btn in buttons: # type: ignore[assignment]
|
|
26
|
+
row.append(btn) # type: ignore[arg-type]
|
|
27
|
+
if len(row) >= 3:
|
|
28
|
+
rows.append(row)
|
|
29
|
+
row = []
|
|
30
|
+
if row:
|
|
31
|
+
rows.append(row)
|
|
32
|
+
return rows
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MessageSegment:
|
|
36
|
+
def __add__(self, other):
|
|
37
|
+
return [self, other]
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def image(img: Union[str, Image.Image, bytes, Path]) -> Message:
|
|
41
|
+
if isinstance(img, HtmlBytes):
|
|
42
|
+
return Message(type="html", data=img.html)
|
|
43
|
+
if isinstance(img, Image.Image):
|
|
44
|
+
img = img.convert("RGB")
|
|
45
|
+
result_buffer = BytesIO()
|
|
46
|
+
img.save(result_buffer, format="PNG", subsampling=0)
|
|
47
|
+
img = result_buffer.getvalue()
|
|
48
|
+
elif isinstance(img, bytes):
|
|
49
|
+
pass
|
|
50
|
+
elif isinstance(img, Path):
|
|
51
|
+
img = img.read_bytes()
|
|
52
|
+
elif isinstance(img, (bytearray, memoryview)):
|
|
53
|
+
img = bytes(img)
|
|
54
|
+
else:
|
|
55
|
+
if img.startswith("http"):
|
|
56
|
+
return Message(type="image", data=f"link://{img}")
|
|
57
|
+
if img.startswith("base64://"):
|
|
58
|
+
return Message(type="image", data=img)
|
|
59
|
+
with open(img, "rb") as fp:
|
|
60
|
+
img = fp.read()
|
|
61
|
+
|
|
62
|
+
return Message(type="image", data=f"base64://{b64encode(img).decode()}")
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def text(content: str) -> Message:
|
|
66
|
+
return Message(type="text", data=content)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def buttons(
|
|
70
|
+
buttons: Optional[Union[List[Button], List[List[Button]]]] = None,
|
|
71
|
+
) -> Message:
|
|
72
|
+
if buttons:
|
|
73
|
+
buttons = process_buttons(buttons)
|
|
74
|
+
return Message(type="buttons", data=msgspec.to_builtins(buttons))
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def at(user: str) -> Message:
|
|
78
|
+
return Message(type="at", data=user)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def node(
|
|
82
|
+
content_list: Union[List[Message], List[str], List[bytes]],
|
|
83
|
+
) -> Message:
|
|
84
|
+
msg_list: List[Message] = []
|
|
85
|
+
for msg in content_list:
|
|
86
|
+
if isinstance(msg, Message):
|
|
87
|
+
msg_list.append(msg)
|
|
88
|
+
elif isinstance(msg, (bytes, bytearray, memoryview)):
|
|
89
|
+
msg_list.append(MessageSegment.image(bytes(msg)))
|
|
90
|
+
else:
|
|
91
|
+
if msg.startswith("base64://"):
|
|
92
|
+
msg_list.append(Message(type="image", data=msg))
|
|
93
|
+
elif msg.startswith("http"):
|
|
94
|
+
msg_list.append(Message(type="image", data=f"link://{msg}"))
|
|
95
|
+
else:
|
|
96
|
+
msg_list.append(MessageSegment.text(msg))
|
|
97
|
+
return Message(type="node", data=msg_list)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def file(content: Union[Path, str, bytes], file_name: str) -> Message:
|
|
101
|
+
if isinstance(content, Path):
|
|
102
|
+
file = content.read_bytes()
|
|
103
|
+
elif isinstance(content, bytes):
|
|
104
|
+
file = content
|
|
105
|
+
elif isinstance(content, (bytearray, memoryview)):
|
|
106
|
+
file = bytes(content)
|
|
107
|
+
else:
|
|
108
|
+
if content.startswith("http"):
|
|
109
|
+
return Message(type="file", data=f"{file_name}|link://{content}")
|
|
110
|
+
with open(content, "rb") as fp:
|
|
111
|
+
file = fp.read()
|
|
112
|
+
return Message(type="file", data=f"{file_name}|{b64encode(file).decode()}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _convert_one(message: Union[Message, str, bytes]) -> List[Message]:
|
|
116
|
+
if isinstance(message, Message):
|
|
117
|
+
if message.type == "node" and isinstance(message.data, list):
|
|
118
|
+
return [MessageSegment.node(message.data)]
|
|
119
|
+
return [message]
|
|
120
|
+
if isinstance(message, HtmlBytes):
|
|
121
|
+
return [Message(type="html", data=message.html)]
|
|
122
|
+
if isinstance(message, str):
|
|
123
|
+
if message.startswith("base64://"):
|
|
124
|
+
return [Message(type="image", data=message)]
|
|
125
|
+
return [Message(type="text", data=message)]
|
|
126
|
+
if isinstance(message, (bytes, bytearray, memoryview)):
|
|
127
|
+
return [MessageSegment.image(bytes(message))]
|
|
128
|
+
return [Message(type="text", data=str(message))]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def convert_message(
|
|
132
|
+
message: Union[Message, List[Message], List[str], str, bytes],
|
|
133
|
+
bot_id: str = "",
|
|
134
|
+
bot_self_id: str = "",
|
|
135
|
+
) -> List[Message]:
|
|
136
|
+
if isinstance(message, list):
|
|
137
|
+
if message and all(isinstance(x, str) for x in message):
|
|
138
|
+
return [MessageSegment.node(message)]
|
|
139
|
+
_message: List[Message] = []
|
|
140
|
+
for i in message:
|
|
141
|
+
_message.extend(await _convert_one(i))
|
|
142
|
+
return _message
|
|
143
|
+
return await _convert_one(message)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""启动/关闭钩子注册与执行。
|
|
2
|
+
|
|
3
|
+
三类钩子:
|
|
4
|
+
- on_core_start_before: 阻塞式,服务开始接受连接前必须跑完(数据库迁移等)
|
|
5
|
+
- on_core_start: 启动后执行
|
|
6
|
+
- on_core_shutdown: 关闭前执行
|
|
7
|
+
|
|
8
|
+
同一 priority 的钩子并发执行,全部完成后再进入下一 priority;单个钩子失败只记日志。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from itertools import groupby
|
|
14
|
+
from typing import Any, Callable, List, Optional, Set, TypeVar, overload
|
|
15
|
+
|
|
16
|
+
from rover.logger import logger
|
|
17
|
+
|
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _DefHook:
|
|
23
|
+
priority: int
|
|
24
|
+
func: Callable = field(compare=False)
|
|
25
|
+
|
|
26
|
+
def __hash__(self) -> int:
|
|
27
|
+
return hash(self.func)
|
|
28
|
+
|
|
29
|
+
def __eq__(self, other: object) -> bool:
|
|
30
|
+
if isinstance(other, _DefHook):
|
|
31
|
+
return self.func is other.func
|
|
32
|
+
return NotImplemented
|
|
33
|
+
|
|
34
|
+
def __lt__(self, other: "_DefHook") -> bool:
|
|
35
|
+
return self.priority < other.priority
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
core_start_def: Set[_DefHook] = set()
|
|
39
|
+
core_start_before_def: Set[_DefHook] = set()
|
|
40
|
+
core_shutdown_def: Set[_DefHook] = set()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@overload
|
|
44
|
+
def on_core_start(func: F, /) -> F: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@overload
|
|
48
|
+
def on_core_start(func: None = None, /, priority: int = 0) -> Callable[[F], F]: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def on_core_start(func: Optional[Callable] = None, /, priority: int = 0):
|
|
52
|
+
def decorator(f: Callable) -> Callable:
|
|
53
|
+
core_start_def.add(_DefHook(priority=priority, func=f))
|
|
54
|
+
return f
|
|
55
|
+
|
|
56
|
+
if func is not None:
|
|
57
|
+
return decorator(func)
|
|
58
|
+
return decorator
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@overload
|
|
62
|
+
def on_core_start_before(func: F, /) -> F: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@overload
|
|
66
|
+
def on_core_start_before(func: None = None, /, priority: int = 0) -> Callable[[F], F]: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def on_core_start_before(func: Optional[Callable] = None, /, priority: int = 0):
|
|
70
|
+
def decorator(f: Callable) -> Callable:
|
|
71
|
+
core_start_before_def.add(_DefHook(priority=priority, func=f))
|
|
72
|
+
return f
|
|
73
|
+
|
|
74
|
+
if func is not None:
|
|
75
|
+
return decorator(func)
|
|
76
|
+
return decorator
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@overload
|
|
80
|
+
def on_core_shutdown(func: F, /) -> F: ...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@overload
|
|
84
|
+
def on_core_shutdown(func: None = None, /, priority: int = 0) -> Callable[[F], F]: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def on_core_shutdown(func: Optional[Callable] = None, /, priority: int = 0):
|
|
88
|
+
def decorator(f: Callable) -> Callable:
|
|
89
|
+
core_shutdown_def.add(_DefHook(priority=priority, func=f))
|
|
90
|
+
return f
|
|
91
|
+
|
|
92
|
+
if func is not None:
|
|
93
|
+
return decorator(func)
|
|
94
|
+
return decorator
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _execute(hooks: Set[_DefHook], tag: str) -> None:
|
|
98
|
+
try:
|
|
99
|
+
sorted_defs: List[_DefHook] = sorted(hooks)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.exception(f"[{tag}] 钩子排序失败: {e}")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
logger.info(f"[{tag}] 执行钩子: {[hook.func.__name__ for hook in sorted_defs]}")
|
|
105
|
+
for _, group in groupby(sorted_defs, key=lambda h: h.priority):
|
|
106
|
+
group_hooks = list(group)
|
|
107
|
+
results = await asyncio.gather(
|
|
108
|
+
*[
|
|
109
|
+
(hook.func() if asyncio.iscoroutinefunction(hook.func) else asyncio.to_thread(hook.func))
|
|
110
|
+
for hook in group_hooks
|
|
111
|
+
],
|
|
112
|
+
return_exceptions=True,
|
|
113
|
+
)
|
|
114
|
+
for hook, result in zip(group_hooks, results):
|
|
115
|
+
if isinstance(result, BaseException):
|
|
116
|
+
logger.error(
|
|
117
|
+
f"[{tag}] 钩子 {hook.func.__name__} 执行失败: {result}",
|
|
118
|
+
exc_info=result,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def core_start_before_execute() -> None:
|
|
123
|
+
await _execute(core_start_before_def, "启动前钩子")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def core_start_execute() -> None:
|
|
127
|
+
await _execute(core_start_def, "启动钩子")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def core_shutdown_execute() -> None:
|
|
131
|
+
await _execute(core_shutdown_def, "关闭钩子")
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""插件状态注册表。"""
|
|
2
|
+
|
|
3
|
+
from typing import Awaitable, Callable, Dict, Union
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from .models import PluginStatus
|
|
8
|
+
|
|
9
|
+
plugins_status: Dict[str, PluginStatus] = {}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_status(
|
|
13
|
+
ICON: Image.Image,
|
|
14
|
+
plugin_name: str,
|
|
15
|
+
plugin_status: Dict[str, Callable[..., Awaitable[Union[str, int, float]]]],
|
|
16
|
+
):
|
|
17
|
+
plugins_status[plugin_name] = {"icon": ICON, "status": plugin_status}
|