@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.
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,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,10 @@
1
+ """插件状态结构。"""
2
+
3
+ from typing import Awaitable, Callable, Dict, TypedDict, Union
4
+
5
+ from PIL import Image
6
+
7
+
8
+ class PluginStatus(TypedDict):
9
+ icon: Image.Image
10
+ status: Dict[str, Callable[..., Awaitable[Union[str, int, float]]]]
@@ -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}