neobot-modloader 1.0.0__py3-none-any.whl
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.
- neobot_modloader/__init__.py +139 -0
- neobot_modloader/agent.py +12 -0
- neobot_modloader/agent_intent.py +89 -0
- neobot_modloader/bot.py +42 -0
- neobot_modloader/command_dsl.py +373 -0
- neobot_modloader/config_store.py +82 -0
- neobot_modloader/config_validation.py +95 -0
- neobot_modloader/context.py +531 -0
- neobot_modloader/database.py +418 -0
- neobot_modloader/dependencies.py +186 -0
- neobot_modloader/dependency.py +184 -0
- neobot_modloader/generation.py +55 -0
- neobot_modloader/hooks.py +431 -0
- neobot_modloader/host.py +550 -0
- neobot_modloader/installer.py +752 -0
- neobot_modloader/loader.py +343 -0
- neobot_modloader/loading/__init__.py +2 -0
- neobot_modloader/loading/importer.py +66 -0
- neobot_modloader/loading/manifest.py +104 -0
- neobot_modloader/loading/models.py +136 -0
- neobot_modloader/loading/ordering.py +337 -0
- neobot_modloader/management.py +263 -0
- neobot_modloader/manager.py +851 -0
- neobot_modloader/message.py +205 -0
- neobot_modloader/plugin.py +548 -0
- neobot_modloader/plugins/__init__.py +2 -0
- neobot_modloader/plugins/agents.py +328 -0
- neobot_modloader/plugins/dispatch.py +171 -0
- neobot_modloader/plugins/injection.py +413 -0
- neobot_modloader/plugins/markdown_skills.py +275 -0
- neobot_modloader/plugins/registration.py +136 -0
- neobot_modloader/plugins/tools.py +868 -0
- neobot_modloader/py.typed +0 -0
- neobot_modloader/reply.py +52 -0
- neobot_modloader/runtime.py +2382 -0
- neobot_modloader/state.py +126 -0
- neobot_modloader/users.py +207 -0
- neobot_modloader/version.py +89 -0
- neobot_modloader-1.0.0.dist-info/METADATA +883 -0
- neobot_modloader-1.0.0.dist-info/RECORD +41 -0
- neobot_modloader-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from neobot_modloader.agent import AgentRequest
|
|
4
|
+
from neobot_modloader.bot import Bot
|
|
5
|
+
from neobot_modloader.database import (
|
|
6
|
+
Migration,
|
|
7
|
+
PluginDatabase,
|
|
8
|
+
PluginDatabaseClosedError,
|
|
9
|
+
PluginDatabaseError,
|
|
10
|
+
PluginDatabaseNotReadyError,
|
|
11
|
+
PluginMigrationConflictError,
|
|
12
|
+
PluginMigrationError,
|
|
13
|
+
)
|
|
14
|
+
from neobot_modloader.config_store import (
|
|
15
|
+
CONFIG_FILENAME as PLUGIN_CONFIG_FILENAME,
|
|
16
|
+
PluginConfigStore,
|
|
17
|
+
merge_plugin_config,
|
|
18
|
+
)
|
|
19
|
+
from neobot_modloader.dependency import (
|
|
20
|
+
PluginDependency,
|
|
21
|
+
PluginDependencyError,
|
|
22
|
+
format_dependency,
|
|
23
|
+
parse_dependencies,
|
|
24
|
+
parse_dependency,
|
|
25
|
+
version_satisfies,
|
|
26
|
+
)
|
|
27
|
+
from neobot_modloader.dependencies import PythonDependencyInstaller
|
|
28
|
+
from neobot_modloader.generation import RuntimeGeneration
|
|
29
|
+
from neobot_modloader.hooks import PluginHookBus
|
|
30
|
+
from neobot_modloader.host import DefaultServiceRegistry, PluginHostFacade
|
|
31
|
+
from neobot_modloader.installer import (
|
|
32
|
+
PROXY_MODES,
|
|
33
|
+
PluginInstallError,
|
|
34
|
+
PluginInstallResult,
|
|
35
|
+
PluginInstaller,
|
|
36
|
+
PluginUpdateCheck,
|
|
37
|
+
ProxySettings,
|
|
38
|
+
RepoSpec,
|
|
39
|
+
compare_versions,
|
|
40
|
+
)
|
|
41
|
+
from neobot_modloader.loader import DiscoveredPlugin, FilesystemPluginLoader
|
|
42
|
+
from neobot_modloader.loading.models import OFFICIAL_SOURCE, THIRD_PARTY_SOURCE
|
|
43
|
+
from neobot_modloader.management import PluginControlFacade, PluginOperationResult, PluginSnapshot
|
|
44
|
+
from neobot_modloader.state import PluginStateEntry, PluginStateStore
|
|
45
|
+
from neobot_modloader.manager import DefaultPluginManager
|
|
46
|
+
from neobot_modloader.message import AtSegment, ImageSegment, Message, MessageChain, MessageSegment, at, image, text
|
|
47
|
+
from neobot_modloader.plugin import Plugin
|
|
48
|
+
from neobot_modloader.reply import Reply
|
|
49
|
+
from neobot_modloader.runtime import PluginRuntime
|
|
50
|
+
from neobot_modloader.context import RuntimePluginContext
|
|
51
|
+
from neobot_modloader.users import UserDirectory, UserProfile
|
|
52
|
+
from neobot_contracts.ports.screenshot import (
|
|
53
|
+
FontFace,
|
|
54
|
+
FontFormat,
|
|
55
|
+
FontLoadError,
|
|
56
|
+
ImageFormat,
|
|
57
|
+
InvalidScreenshotOptions,
|
|
58
|
+
RenderOptions,
|
|
59
|
+
ScreenshotError,
|
|
60
|
+
ScreenshotMode,
|
|
61
|
+
ScreenshotOptions,
|
|
62
|
+
ScreenshotPort,
|
|
63
|
+
ScreenshotResult,
|
|
64
|
+
ScreenshotTargetNotFound,
|
|
65
|
+
ScreenshotTimeout,
|
|
66
|
+
ScreenshotUnavailable,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"AgentRequest",
|
|
71
|
+
"AtSegment",
|
|
72
|
+
"Bot",
|
|
73
|
+
"DefaultPluginManager",
|
|
74
|
+
"DefaultServiceRegistry",
|
|
75
|
+
"DiscoveredPlugin",
|
|
76
|
+
"FilesystemPluginLoader",
|
|
77
|
+
"OFFICIAL_SOURCE",
|
|
78
|
+
"PLUGIN_CONFIG_FILENAME",
|
|
79
|
+
"PROXY_MODES",
|
|
80
|
+
"PluginInstallError",
|
|
81
|
+
"PluginInstallResult",
|
|
82
|
+
"PluginInstaller",
|
|
83
|
+
"PluginStateEntry",
|
|
84
|
+
"PluginStateStore",
|
|
85
|
+
"PluginUpdateCheck",
|
|
86
|
+
"ProxySettings",
|
|
87
|
+
"RepoSpec",
|
|
88
|
+
"THIRD_PARTY_SOURCE",
|
|
89
|
+
"compare_versions",
|
|
90
|
+
"ImageSegment",
|
|
91
|
+
"Message",
|
|
92
|
+
"MessageChain",
|
|
93
|
+
"MessageSegment",
|
|
94
|
+
"PluginDependency",
|
|
95
|
+
"PluginDependencyError",
|
|
96
|
+
"format_dependency",
|
|
97
|
+
"parse_dependencies",
|
|
98
|
+
"parse_dependency",
|
|
99
|
+
"version_satisfies",
|
|
100
|
+
"Migration",
|
|
101
|
+
"Plugin",
|
|
102
|
+
"PluginConfigStore",
|
|
103
|
+
"PluginControlFacade",
|
|
104
|
+
"PluginDatabase",
|
|
105
|
+
"PluginDatabaseClosedError",
|
|
106
|
+
"PluginDatabaseError",
|
|
107
|
+
"PluginDatabaseNotReadyError",
|
|
108
|
+
"PluginHookBus",
|
|
109
|
+
"PluginHostFacade",
|
|
110
|
+
"PluginMigrationConflictError",
|
|
111
|
+
"PluginMigrationError",
|
|
112
|
+
"PluginOperationResult",
|
|
113
|
+
"PluginRuntime",
|
|
114
|
+
"PluginSnapshot",
|
|
115
|
+
"PythonDependencyInstaller",
|
|
116
|
+
"Reply",
|
|
117
|
+
"RuntimeGeneration",
|
|
118
|
+
"RuntimePluginContext",
|
|
119
|
+
"UserDirectory",
|
|
120
|
+
"UserProfile",
|
|
121
|
+
"at",
|
|
122
|
+
"image",
|
|
123
|
+
"merge_plugin_config",
|
|
124
|
+
"text",
|
|
125
|
+
"FontFace",
|
|
126
|
+
"FontFormat",
|
|
127
|
+
"FontLoadError",
|
|
128
|
+
"ImageFormat",
|
|
129
|
+
"InvalidScreenshotOptions",
|
|
130
|
+
"RenderOptions",
|
|
131
|
+
"ScreenshotError",
|
|
132
|
+
"ScreenshotMode",
|
|
133
|
+
"ScreenshotOptions",
|
|
134
|
+
"ScreenshotPort",
|
|
135
|
+
"ScreenshotResult",
|
|
136
|
+
"ScreenshotTargetNotFound",
|
|
137
|
+
"ScreenshotTimeout",
|
|
138
|
+
"ScreenshotUnavailable",
|
|
139
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""插件消息处理器的「交主管线」意图通道(spec(5) §1.4 R13/R14 的实现基础)。
|
|
2
|
+
|
|
3
|
+
背景:`ctx.block_ai_reply()` 只能**拦住** AI 回复,插件消息处理器没有任何
|
|
4
|
+
把控制权**交回**主回复管线(并携带提示词 / 声明预激活技能)的手段。本模块补上
|
|
5
|
+
这条通用能力(不是给小游戏开特例,第三方插件同样可用):
|
|
6
|
+
|
|
7
|
+
- 插件在消息处理器里调用 `ctx.agent_reply(background, preactivate=[...])`;
|
|
8
|
+
- 该调用经 `request_agent_reply` 落到**本次事件的 EventContext**上(一次性、
|
|
9
|
+
随事件对象传递、消费即清除),事件管线再走**既有**的「命令结果触发一次回复」
|
|
10
|
+
通路,因此不新造管线;
|
|
11
|
+
- `preactivate` 里的技能名会在本轮回复构建工具表时被按需激活,使这些
|
|
12
|
+
`{plugin}__{tool}` 在该轮模型调用里即可直接调用。
|
|
13
|
+
|
|
14
|
+
实现说明:处理器拿到的 `ctx` 是插件运行时上下文(每个插件一个、跨事件复用),
|
|
15
|
+
它不知道当前事件,所以这里用 **ContextVar** 在 `PluginHookBus.dispatch` 期间
|
|
16
|
+
绑定当前 EventContext;dispatch 结束后在 finally 里复位。ContextVar 是
|
|
17
|
+
任务/上下文作用域的,不会跨请求串台,也不留进程级可变状态。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from contextvars import ContextVar
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
#: 当前正在分发的事件上下文(由 PluginHookBus.dispatch 绑定;无事件时为 None)
|
|
27
|
+
_CURRENT_EVENT_CONTEXT: ContextVar[Any | None] = ContextVar(
|
|
28
|
+
"neobot_plugin_current_event_context", default=None
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bind_event_context(ctx: Any) -> Any:
|
|
33
|
+
"""绑定当前事件上下文,返回用于复位的 token。"""
|
|
34
|
+
return _CURRENT_EVENT_CONTEXT.set(ctx)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def reset_event_context(token: Any) -> None:
|
|
38
|
+
"""复位事件上下文绑定(必须在 finally 中调用)。"""
|
|
39
|
+
try:
|
|
40
|
+
_CURRENT_EVENT_CONTEXT.reset(token)
|
|
41
|
+
except (ValueError, LookupError): # pragma: no cover - 跨上下文复位时忽略
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def current_event_context() -> Any | None:
|
|
46
|
+
return _CURRENT_EVENT_CONTEXT.get()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_preactivate(preactivate: Any) -> tuple[str, ...]:
|
|
50
|
+
"""规范化预激活技能名(去空、去重、保序)。"""
|
|
51
|
+
if preactivate is None:
|
|
52
|
+
return ()
|
|
53
|
+
if isinstance(preactivate, str) or not isinstance(preactivate, Sequence):
|
|
54
|
+
values = [preactivate]
|
|
55
|
+
else:
|
|
56
|
+
values = list(preactivate)
|
|
57
|
+
seen: list[str] = []
|
|
58
|
+
for item in values:
|
|
59
|
+
name = str(item or "").strip()
|
|
60
|
+
if name and name not in seen:
|
|
61
|
+
seen.append(name)
|
|
62
|
+
return tuple(seen)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def request_agent_reply(background: Any = "", *, preactivate: Any = ()) -> bool:
|
|
66
|
+
"""请求「把本轮交回主回复管线」。
|
|
67
|
+
|
|
68
|
+
返回是否登记成功;**永不抛异常**(消息路径上的任何失败都只是「这次不生效」)。
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
ctx = _CURRENT_EVENT_CONTEXT.get()
|
|
72
|
+
if ctx is None:
|
|
73
|
+
return False
|
|
74
|
+
recorder = getattr(ctx, "agent_reply", None)
|
|
75
|
+
if not callable(recorder):
|
|
76
|
+
return False
|
|
77
|
+
recorder(str(background or ""), preactivate=normalize_preactivate(preactivate))
|
|
78
|
+
return True
|
|
79
|
+
except Exception:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"bind_event_context",
|
|
85
|
+
"current_event_context",
|
|
86
|
+
"normalize_preactivate",
|
|
87
|
+
"request_agent_reply",
|
|
88
|
+
"reset_event_context",
|
|
89
|
+
]
|
neobot_modloader/bot.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from neobot_contracts.models import ConversationRef
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Bot:
|
|
9
|
+
"""适配器包装类,对外提供稳定的消息发送 API。
|
|
10
|
+
|
|
11
|
+
暴露 ``send``、``send_private`` 与 ``send_group``,对插件和依赖注入
|
|
12
|
+
解析出的处理器隐藏适配器内部实现。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, adapter: Any) -> None:
|
|
16
|
+
self._adapter = adapter
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def self_id(self) -> Any:
|
|
20
|
+
"""从适配器获取机器人自身的 ID。"""
|
|
21
|
+
return getattr(self._adapter, "self_id", None)
|
|
22
|
+
|
|
23
|
+
async def send(self, conversation: ConversationRef, message: Any) -> Any:
|
|
24
|
+
"""将 *message* 发送到 *conversation*。
|
|
25
|
+
|
|
26
|
+
委托给 ``adapter.send(conversation, message)``。
|
|
27
|
+
"""
|
|
28
|
+
return await self._adapter.send(conversation, message)
|
|
29
|
+
|
|
30
|
+
async def send_private(self, user_id: int, message: Any) -> Any:
|
|
31
|
+
"""将 *message* 发送到由 *user_id* 标识的私聊。
|
|
32
|
+
|
|
33
|
+
委托给 ``adapter.send_private_msg(user_id, message)``。
|
|
34
|
+
"""
|
|
35
|
+
return await self._adapter.send_private_msg(user_id, message)
|
|
36
|
+
|
|
37
|
+
async def send_group(self, group_id: int, message: Any) -> Any:
|
|
38
|
+
"""将 *message* 发送到由 *group_id* 标识的群聊。
|
|
39
|
+
|
|
40
|
+
委托给 ``adapter.send_group_msg(group_id, message)``。
|
|
41
|
+
"""
|
|
42
|
+
return await self._adapter.send_group_msg(group_id, message)
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from neobot_modloader.message import AtSegment, ImageSegment, Message, MessageSegment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PatternError(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PatternMatchError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class PatternElement:
|
|
21
|
+
kind: str
|
|
22
|
+
value: str
|
|
23
|
+
name: str | None = None
|
|
24
|
+
value_type: str = "str"
|
|
25
|
+
optional: bool = False
|
|
26
|
+
list_value: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class PatternMatch:
|
|
31
|
+
matched: bool
|
|
32
|
+
values: dict[str, Any]
|
|
33
|
+
error: str | None = None
|
|
34
|
+
command_matched: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MessagePattern:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
pattern: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
command: bool = False,
|
|
43
|
+
aliases: tuple[str, ...] = (),
|
|
44
|
+
prefix: str = "/",
|
|
45
|
+
) -> None:
|
|
46
|
+
self.pattern = (pattern or "").strip()
|
|
47
|
+
self.command = command
|
|
48
|
+
self.aliases = tuple(aliases)
|
|
49
|
+
self.elements = _parse_pattern(self.pattern)
|
|
50
|
+
self.prefix = _validate_command_prefix(prefix) if command else prefix
|
|
51
|
+
if self.command:
|
|
52
|
+
if not self.elements or self.elements[0].kind != "literal":
|
|
53
|
+
raise PatternError("command pattern must start with a command name")
|
|
54
|
+
self.command_name = self.elements[0].value.removeprefix(self.prefix)
|
|
55
|
+
self.body = self.elements[1:]
|
|
56
|
+
else:
|
|
57
|
+
self.command_name = ""
|
|
58
|
+
self.body = self.elements
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def capture_names(self) -> tuple[str, ...]:
|
|
62
|
+
return tuple(
|
|
63
|
+
element.name
|
|
64
|
+
for element in self.elements
|
|
65
|
+
if element.kind == "param" and element.name is not None
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def usage(self) -> str:
|
|
70
|
+
return (
|
|
71
|
+
f"{self.prefix}{self.pattern}"
|
|
72
|
+
if self.command and not self.pattern.startswith(self.prefix)
|
|
73
|
+
else self.pattern
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def match(self, message: Message) -> PatternMatch:
|
|
77
|
+
tokens = _message_tokens(message)
|
|
78
|
+
if self.command:
|
|
79
|
+
command_index = _find_command(tokens, self.prefix)
|
|
80
|
+
if command_index is None:
|
|
81
|
+
return PatternMatch(False, {}, command_matched=False)
|
|
82
|
+
raw = tokens[command_index]
|
|
83
|
+
assert isinstance(raw, str)
|
|
84
|
+
command_name = raw.removeprefix(self.prefix)
|
|
85
|
+
allowed = {
|
|
86
|
+
self.command_name.lower(),
|
|
87
|
+
*(alias.removeprefix(self.prefix).lower() for alias in self.aliases),
|
|
88
|
+
}
|
|
89
|
+
if command_name.lower() not in allowed:
|
|
90
|
+
return PatternMatch(False, {}, command_matched=False)
|
|
91
|
+
try:
|
|
92
|
+
values = _match_elements(self.body, tokens[command_index + 1 :])
|
|
93
|
+
except PatternMatchError as exc:
|
|
94
|
+
return PatternMatch(False, {}, str(exc), command_matched=True)
|
|
95
|
+
return PatternMatch(True, values, command_matched=True)
|
|
96
|
+
|
|
97
|
+
if not self.body:
|
|
98
|
+
return PatternMatch(True, {}, command_matched=False)
|
|
99
|
+
try:
|
|
100
|
+
values = _match_elements(self.body, tokens)
|
|
101
|
+
except PatternMatchError as exc:
|
|
102
|
+
return PatternMatch(False, {}, str(exc), command_matched=False)
|
|
103
|
+
return PatternMatch(True, values, command_matched=False)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RegexPattern:
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
pattern: str | re.Pattern[str],
|
|
110
|
+
*,
|
|
111
|
+
flags: int = 0,
|
|
112
|
+
) -> None:
|
|
113
|
+
if not isinstance(flags, int):
|
|
114
|
+
raise PatternError("flags must be an integer")
|
|
115
|
+
if isinstance(pattern, str):
|
|
116
|
+
try:
|
|
117
|
+
compiled = re.compile(pattern, flags)
|
|
118
|
+
except (re.error, ValueError, OverflowError) as exc:
|
|
119
|
+
raise PatternError(f"invalid regex pattern: {exc}") from exc
|
|
120
|
+
elif isinstance(pattern, re.Pattern):
|
|
121
|
+
if flags:
|
|
122
|
+
raise PatternError(
|
|
123
|
+
"flags cannot be applied to an already compiled pattern"
|
|
124
|
+
)
|
|
125
|
+
compiled = pattern
|
|
126
|
+
else:
|
|
127
|
+
raise PatternError(
|
|
128
|
+
"regex pattern must be a string or a compiled pattern"
|
|
129
|
+
)
|
|
130
|
+
if not isinstance(compiled.pattern, str):
|
|
131
|
+
raise PatternError("regex pattern must be a string pattern")
|
|
132
|
+
if not compiled.pattern.strip():
|
|
133
|
+
raise PatternError("regex pattern cannot be empty")
|
|
134
|
+
self.pattern = compiled
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def capture_names(self) -> tuple[str, ...]:
|
|
138
|
+
return tuple(sorted(self.pattern.groupindex))
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def usage(self) -> str:
|
|
142
|
+
return self.pattern.pattern
|
|
143
|
+
|
|
144
|
+
def match(self, message: Message) -> PatternMatch:
|
|
145
|
+
match = self.pattern.search(message.text)
|
|
146
|
+
if match is None:
|
|
147
|
+
return PatternMatch(False, {})
|
|
148
|
+
return PatternMatch(
|
|
149
|
+
True, {name: match.group(name) for name in self.capture_names}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_pattern(pattern: str) -> list[PatternElement]:
|
|
154
|
+
if not pattern:
|
|
155
|
+
return []
|
|
156
|
+
try:
|
|
157
|
+
tokens = shlex.split(pattern, posix=True)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
raise PatternError(str(exc)) from exc
|
|
160
|
+
|
|
161
|
+
elements: list[PatternElement] = []
|
|
162
|
+
capture_names: set[str] = set()
|
|
163
|
+
for token in tokens:
|
|
164
|
+
if token.startswith("<") and token.endswith(">"):
|
|
165
|
+
element = _parse_param(token[1:-1], optional=False)
|
|
166
|
+
elif token.startswith("[") and token.endswith("]"):
|
|
167
|
+
element = _parse_param(token[1:-1], optional=True)
|
|
168
|
+
else:
|
|
169
|
+
element = PatternElement(kind="literal", value=token)
|
|
170
|
+
if element.name is not None:
|
|
171
|
+
if element.name in capture_names:
|
|
172
|
+
raise PatternError(f"duplicate parameter name: {element.name}")
|
|
173
|
+
capture_names.add(element.name)
|
|
174
|
+
elements.append(element)
|
|
175
|
+
return elements
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_param(raw: str, *, optional: bool) -> PatternElement:
|
|
179
|
+
if ":" in raw:
|
|
180
|
+
name, value_type = raw.split(":", 1)
|
|
181
|
+
else:
|
|
182
|
+
name, value_type = raw, "str"
|
|
183
|
+
name = name.strip()
|
|
184
|
+
value_type = value_type.strip()
|
|
185
|
+
if not name:
|
|
186
|
+
raise PatternError("parameter name cannot be empty")
|
|
187
|
+
list_value = value_type.startswith("list[") and value_type.endswith("]")
|
|
188
|
+
if list_value:
|
|
189
|
+
value_type = value_type[5:-1].strip()
|
|
190
|
+
if value_type not in {"str", "int", "float", "bool", "rest", "image", "at"}:
|
|
191
|
+
raise PatternError(f"unsupported parameter type: {value_type}")
|
|
192
|
+
return PatternElement(
|
|
193
|
+
kind="param",
|
|
194
|
+
value=raw,
|
|
195
|
+
name=name,
|
|
196
|
+
value_type=value_type,
|
|
197
|
+
optional=optional,
|
|
198
|
+
list_value=list_value,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _message_tokens(message: Message) -> list[str | MessageSegment]:
|
|
203
|
+
tokens: list[str | MessageSegment] = []
|
|
204
|
+
for segment in message.segments:
|
|
205
|
+
if segment.type == "text":
|
|
206
|
+
tokens.extend(_split_text(segment.text))
|
|
207
|
+
else:
|
|
208
|
+
tokens.append(segment)
|
|
209
|
+
return tokens
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _split_text(value: str) -> list[str]:
|
|
213
|
+
try:
|
|
214
|
+
return shlex.split(value, posix=True)
|
|
215
|
+
except ValueError:
|
|
216
|
+
return value.split()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _find_command(
|
|
220
|
+
tokens: list[str | MessageSegment], prefix: str = "/"
|
|
221
|
+
) -> int | None:
|
|
222
|
+
for index, token in enumerate(tokens):
|
|
223
|
+
if isinstance(token, str) and token.startswith(prefix) and len(token) > len(prefix):
|
|
224
|
+
return index
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _validate_command_prefix(prefix: str) -> str:
|
|
229
|
+
if not isinstance(prefix, str):
|
|
230
|
+
raise PatternError("command prefix must be a string")
|
|
231
|
+
if any(char.isspace() for char in prefix):
|
|
232
|
+
raise PatternError("command prefix cannot contain whitespace")
|
|
233
|
+
return prefix
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _match_elements(
|
|
237
|
+
elements: list[PatternElement], tokens: list[str | MessageSegment]
|
|
238
|
+
) -> dict[str, Any]:
|
|
239
|
+
values: dict[str, Any] = {}
|
|
240
|
+
index = 0
|
|
241
|
+
for element in elements:
|
|
242
|
+
if element.kind == "literal":
|
|
243
|
+
index = _match_literal(element.value, tokens, index)
|
|
244
|
+
continue
|
|
245
|
+
assert element.name is not None
|
|
246
|
+
value, index = _capture(element, tokens, index)
|
|
247
|
+
values[element.name] = value
|
|
248
|
+
return values
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _match_literal(literal: str, tokens: list[str | MessageSegment], start: int) -> int:
|
|
252
|
+
for index in range(start, len(tokens)):
|
|
253
|
+
token = tokens[index]
|
|
254
|
+
if isinstance(token, str) and token.lower() == literal.lower():
|
|
255
|
+
return index + 1
|
|
256
|
+
if not isinstance(token, str):
|
|
257
|
+
continue
|
|
258
|
+
break
|
|
259
|
+
raise PatternMatchError(f"expected {literal!r}")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _capture(
|
|
263
|
+
element: PatternElement,
|
|
264
|
+
tokens: list[str | MessageSegment],
|
|
265
|
+
start: int,
|
|
266
|
+
) -> tuple[Any, int]:
|
|
267
|
+
if element.value_type == "image":
|
|
268
|
+
return _capture_image(element, tokens, start)
|
|
269
|
+
if element.value_type == "at":
|
|
270
|
+
return _capture_at(element, tokens, start)
|
|
271
|
+
if element.value_type == "rest":
|
|
272
|
+
rest = " ".join(token for token in tokens[start:] if isinstance(token, str))
|
|
273
|
+
if not rest and not element.optional:
|
|
274
|
+
raise PatternMatchError(f"missing required parameter {element.name}")
|
|
275
|
+
return (rest or None if element.optional else rest), len(tokens)
|
|
276
|
+
|
|
277
|
+
index = _next_text_token(tokens, start)
|
|
278
|
+
if index is None:
|
|
279
|
+
if element.optional:
|
|
280
|
+
return None, start
|
|
281
|
+
raise PatternMatchError(f"missing required parameter {element.name}")
|
|
282
|
+
token = tokens[index]
|
|
283
|
+
assert isinstance(token, str)
|
|
284
|
+
return _coerce_text(token, element), index + 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _capture_image(
|
|
288
|
+
element: PatternElement,
|
|
289
|
+
tokens: list[str | MessageSegment],
|
|
290
|
+
start: int,
|
|
291
|
+
) -> tuple[Any, int]:
|
|
292
|
+
images: list[ImageSegment] = []
|
|
293
|
+
first_index: int | None = None
|
|
294
|
+
last_index = start
|
|
295
|
+
for index in range(start, len(tokens)):
|
|
296
|
+
token = tokens[index]
|
|
297
|
+
if isinstance(token, ImageSegment):
|
|
298
|
+
if first_index is None:
|
|
299
|
+
first_index = index
|
|
300
|
+
images.append(token)
|
|
301
|
+
last_index = index + 1
|
|
302
|
+
if not element.list_value:
|
|
303
|
+
break
|
|
304
|
+
if element.list_value:
|
|
305
|
+
if not images and not element.optional:
|
|
306
|
+
raise PatternMatchError(f"missing required image parameter {element.name}")
|
|
307
|
+
return images, last_index if images else start
|
|
308
|
+
if first_index is None:
|
|
309
|
+
if element.optional:
|
|
310
|
+
return None, start
|
|
311
|
+
raise PatternMatchError(f"missing required image parameter {element.name}")
|
|
312
|
+
return images[0], last_index
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _capture_at(
|
|
316
|
+
element: PatternElement,
|
|
317
|
+
tokens: list[str | MessageSegment],
|
|
318
|
+
start: int,
|
|
319
|
+
) -> tuple[Any, int]:
|
|
320
|
+
ats: list[AtSegment] = []
|
|
321
|
+
first_index: int | None = None
|
|
322
|
+
last_index = start
|
|
323
|
+
for index in range(start, len(tokens)):
|
|
324
|
+
token = tokens[index]
|
|
325
|
+
if isinstance(token, AtSegment):
|
|
326
|
+
if first_index is None:
|
|
327
|
+
first_index = index
|
|
328
|
+
ats.append(token)
|
|
329
|
+
last_index = index + 1
|
|
330
|
+
if not element.list_value:
|
|
331
|
+
break
|
|
332
|
+
elif not isinstance(token, str):
|
|
333
|
+
break
|
|
334
|
+
if element.list_value:
|
|
335
|
+
if not ats and not element.optional:
|
|
336
|
+
raise PatternMatchError(f"missing required at parameter {element.name}")
|
|
337
|
+
return ats, last_index if ats else start
|
|
338
|
+
if first_index is None:
|
|
339
|
+
if element.optional:
|
|
340
|
+
return None, start
|
|
341
|
+
raise PatternMatchError(f"missing required at parameter {element.name}")
|
|
342
|
+
return ats[0], last_index
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _next_text_token(tokens: list[str | MessageSegment], start: int) -> int | None:
|
|
346
|
+
for index in range(start, len(tokens)):
|
|
347
|
+
if isinstance(tokens[index], str):
|
|
348
|
+
return index
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _coerce_text(value: str, element: PatternElement) -> Any:
|
|
353
|
+
try:
|
|
354
|
+
if element.value_type == "str":
|
|
355
|
+
return value
|
|
356
|
+
if element.value_type == "int":
|
|
357
|
+
return int(value)
|
|
358
|
+
if element.value_type == "float":
|
|
359
|
+
return float(value)
|
|
360
|
+
if element.value_type == "bool":
|
|
361
|
+
return _coerce_bool(value)
|
|
362
|
+
except ValueError as exc:
|
|
363
|
+
raise PatternMatchError(f"invalid value for {element.name}: {value!r}") from exc
|
|
364
|
+
raise PatternMatchError(f"unsupported parameter type: {element.value_type}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _coerce_bool(value: str) -> bool:
|
|
368
|
+
normalized = value.strip().lower()
|
|
369
|
+
if normalized in {"1", "true", "yes", "y", "on"}:
|
|
370
|
+
return True
|
|
371
|
+
if normalized in {"0", "false", "no", "n", "off"}:
|
|
372
|
+
return False
|
|
373
|
+
raise ValueError(value)
|