mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat: 更新文档和测试,增强旧版插件兼容性及能力装饰器的命名约束
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
- 2026-03-13: `runtime.loader` must preserve declared legacy handler order. Falling back to `dir(instance)` or sorting the merged discoverable names reorders compat handlers alphabetically, which changes which legacy command/hook appears first to the supervisor and breaks old-plugin expectations.
|
||||
- 2026-03-13: `runtime.loader.import_string()` cannot trust `sys.modules` when plugins reuse generic top-level package names like `commands.*`. Before importing a plugin module, compare the cached root package against the current plugin directory and evict conflicting root/submodules, or later plugins will accidentally reuse an earlier plugin's package tree.
|
||||
- 2026-03-13: Keep `astrbot_sdk.protocol` root focused on native v4 protocol models and parsers. Legacy JSON-RPC helpers remain supported, but they should be imported from `astrbot_sdk.protocol.legacy_adapter` explicitly instead of being re-exported from the package root.
|
||||
- 2026-03-13: `Peer` must treat transport EOF/connection loss as a first-class failure path, not only explicit protocol parse errors. If the transport closes unexpectedly and `Peer` does not proactively fail `_pending_results` / `_pending_streams`, supervisor-side calls into workers can hang forever even though the worker session already noticed the disconnect.
|
||||
- 2026-03-13: The repository root `test_plugin/` is no longer a single runnable plugin fixture. The maintained compat sample now lives under `test_plugin/old/`; tests or scripts that still copy/load `test_plugin/` directly will mis-detect it as an incomplete legacy plugin and fail.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
- 2026-03-13: `runtime.loader` must preserve declared legacy handler order. Falling back to `dir(instance)` or sorting the merged discoverable names reorders compat handlers alphabetically, which changes which legacy command/hook appears first to the supervisor and breaks old-plugin expectations.
|
||||
- 2026-03-13: `runtime.loader.import_string()` cannot trust `sys.modules` when plugins reuse generic top-level package names like `commands.*`. Before importing a plugin module, compare the cached root package against the current plugin directory and evict conflicting root/submodules, or later plugins will accidentally reuse an earlier plugin's package tree.
|
||||
- 2026-03-13: Keep `astrbot_sdk.protocol` root focused on native v4 protocol models and parsers. Legacy JSON-RPC helpers remain supported, but they should be imported from `astrbot_sdk.protocol.legacy_adapter` explicitly instead of being re-exported from the package root.
|
||||
- 2026-03-13: `Peer` must treat transport EOF/connection loss as a first-class failure path, not only explicit protocol parse errors. If the transport closes unexpectedly and `Peer` does not proactively fail `_pending_results` / `_pending_streams`, supervisor-side calls into workers can hang forever even though the worker session already noticed the disconnect.
|
||||
- 2026-03-13: The repository root `test_plugin/` is no longer a single runnable plugin fixture. The maintained compat sample now lives under `test_plugin/old/`; tests or scripts that still copy/load `test_plugin/` directly will mis-detect it as an incomplete legacy plugin and fail.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from .protocol.descriptors import (
|
||||
EventTrigger,
|
||||
MessageTrigger,
|
||||
Permissions,
|
||||
RESERVED_CAPABILITY_PREFIXES,
|
||||
ScheduleTrigger,
|
||||
)
|
||||
|
||||
@@ -131,6 +132,8 @@ def provide_capability(
|
||||
"""声明插件对外暴露的 capability。"""
|
||||
|
||||
def decorator(func: HandlerCallable) -> HandlerCallable:
|
||||
if name.startswith(RESERVED_CAPABILITY_PREFIXES):
|
||||
raise ValueError(f"保留 capability 命名空间不能用于插件导出:{name}")
|
||||
descriptor = CapabilityDescriptor(
|
||||
name=name,
|
||||
description=description,
|
||||
|
||||
@@ -4,8 +4,10 @@ Tests for decorators.py - Handler decorator infrastructure.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot_sdk.decorators import (
|
||||
get_capability_meta,
|
||||
HANDLER_META_ATTR,
|
||||
HandlerMeta,
|
||||
get_handler_meta,
|
||||
@@ -13,6 +15,7 @@ from astrbot_sdk.decorators import (
|
||||
on_event,
|
||||
on_message,
|
||||
on_schedule,
|
||||
provide_capability,
|
||||
require_admin,
|
||||
)
|
||||
from astrbot_sdk.protocol.descriptors import (
|
||||
@@ -260,6 +263,38 @@ class TestRequireAdminDecorator:
|
||||
meta = get_handler_meta(handler)
|
||||
assert meta.permissions.require_admin is True
|
||||
|
||||
|
||||
class TestProvideCapabilityDecorator:
|
||||
"""Tests for @provide_capability decorator."""
|
||||
|
||||
def test_sets_capability_meta(self):
|
||||
"""@provide_capability should attach capability descriptor metadata."""
|
||||
|
||||
@provide_capability(
|
||||
"demo.echo",
|
||||
description="Echo text",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object"},
|
||||
)
|
||||
async def echo(payload):
|
||||
return payload
|
||||
|
||||
meta = get_capability_meta(echo)
|
||||
assert meta is not None
|
||||
assert meta.descriptor.name == "demo.echo"
|
||||
|
||||
def test_rejects_reserved_namespaces(self):
|
||||
"""@provide_capability should reject framework-reserved prefixes."""
|
||||
for name in ("handler.echo", "system.echo", "internal.echo"):
|
||||
with pytest.raises(ValueError, match=name):
|
||||
|
||||
@provide_capability(
|
||||
name,
|
||||
description="reserved",
|
||||
)
|
||||
async def reserved(payload):
|
||||
return payload
|
||||
|
||||
def test_can_combine_with_other_decorators(self):
|
||||
"""@require_admin can be combined with other decorators."""
|
||||
|
||||
|
||||
@@ -470,7 +470,7 @@ class TestRealTestPlugin:
|
||||
def test_load_test_plugin(self):
|
||||
"""测试加载项目中的 test_plugin。"""
|
||||
project_root = Path(__file__).parent.parent
|
||||
test_plugin_dir = project_root / "test_plugin"
|
||||
test_plugin_dir = project_root / "test_plugin" / "old"
|
||||
|
||||
if not test_plugin_dir.exists():
|
||||
pytest.skip("test_plugin directory not found")
|
||||
|
||||
@@ -19,7 +19,24 @@ from astrbot_sdk.runtime.transport import (
|
||||
WebSocketServerTransport,
|
||||
)
|
||||
|
||||
from tests_v4.helpers import make_transport_pair
|
||||
from tests_v4.helpers import MemoryTransport, make_transport_pair
|
||||
|
||||
|
||||
class LinkedMemoryTransport(MemoryTransport):
|
||||
async def stop(self) -> None:
|
||||
if self._closed.is_set():
|
||||
return
|
||||
self._closed.set()
|
||||
if self.partner is not None and not self.partner._closed.is_set():
|
||||
self.partner._closed.set()
|
||||
|
||||
|
||||
def make_linked_transport_pair() -> tuple[LinkedMemoryTransport, LinkedMemoryTransport]:
|
||||
left = LinkedMemoryTransport()
|
||||
right = LinkedMemoryTransport()
|
||||
left.partner = right
|
||||
right.partner = left
|
||||
return left, right
|
||||
|
||||
|
||||
class PeerRuntimeTest(unittest.IsolatedAsyncioTestCase):
|
||||
@@ -332,6 +349,52 @@ class PeerRuntimeTest(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
await self.left.stop()
|
||||
|
||||
async def test_unexpected_transport_close_fails_pending_invoke(self) -> None:
|
||||
left, right = make_linked_transport_pair()
|
||||
started = asyncio.Event()
|
||||
|
||||
async def hanging_invoke(_message, _token):
|
||||
started.set()
|
||||
await asyncio.Future()
|
||||
|
||||
core = Peer(
|
||||
transport=left,
|
||||
peer_info=PeerInfo(name="core", role="core", version="v4"),
|
||||
)
|
||||
core.set_initialize_handler(
|
||||
lambda _message: asyncio.sleep(
|
||||
0,
|
||||
result=InitializeOutput(
|
||||
peer=PeerInfo(name="core", role="core", version="v4"),
|
||||
capabilities=[],
|
||||
metadata={},
|
||||
),
|
||||
)
|
||||
)
|
||||
core.set_invoke_handler(hanging_invoke)
|
||||
plugin = Peer(
|
||||
transport=right,
|
||||
peer_info=PeerInfo(name="plugin", role="plugin", version="v4"),
|
||||
)
|
||||
|
||||
await core.start()
|
||||
await plugin.start()
|
||||
await plugin.initialize([])
|
||||
|
||||
task = asyncio.create_task(
|
||||
plugin.invoke("llm.chat", {"prompt": "close-me"}, request_id="req-close")
|
||||
)
|
||||
await started.wait()
|
||||
await left.stop()
|
||||
|
||||
with self.assertRaises(AstrBotError) as raised:
|
||||
await task
|
||||
self.assertEqual(raised.exception.code, "network_error")
|
||||
self.assertTrue(raised.exception.retryable)
|
||||
|
||||
await asyncio.wait_for(plugin.wait_closed(), timeout=1.0)
|
||||
await asyncio.wait_for(core.wait_closed(), timeout=1.0)
|
||||
|
||||
|
||||
class CapabilityRouterContractTest(unittest.TestCase):
|
||||
def test_capability_names_must_match_namespace_method_format(self) -> None:
|
||||
|
||||
@@ -126,7 +126,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
plugins_root = Path(temp_dir) / "plugins"
|
||||
plugin_root = plugins_root / "compat_plugin"
|
||||
shutil.copytree(Path.cwd() / "test_plugin", plugin_root)
|
||||
shutil.copytree(Path.cwd() / "test_plugin" / "old", plugin_root)
|
||||
|
||||
runtime = SupervisorRuntime(
|
||||
transport=self.right,
|
||||
|
||||
Reference in New Issue
Block a user