feat(plugin): 添加新的 V4 示例插件及集成测试,更新文档以反映插件结构变化

This commit is contained in:
whatevertogo
2026-03-13 06:53:31 +08:00
parent 3ba9b24522
commit d031814bc5
8 changed files with 261 additions and 46 deletions

View File

@@ -17,6 +17,7 @@
- 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.
- 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart.
# 开发命令

View File

@@ -17,6 +17,7 @@
- 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.
- 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart.
# 开发命令

View File

@@ -0,0 +1 @@
"""V4 sample plugin commands package."""

View File

@@ -0,0 +1,83 @@
"""V4 sample plugin used by integration tests."""
from __future__ import annotations
from astrbot_sdk import (
Context,
MessageEvent,
Star,
on_command,
on_message,
provide_capability,
)
from astrbot_sdk.context import CancelToken
class HelloPlugin(Star):
"""Small but representative v4 plugin fixture."""
@on_command("hello", aliases=["hi"], description="发送问候消息")
async def hello(self, event: MessageEvent, ctx: Context) -> None:
reply = await ctx.llm.chat(event.text)
await event.reply(reply)
chunks: list[str] = []
async for chunk in ctx.llm.stream_chat("stream"):
chunks.append(chunk)
await event.reply("".join(chunks))
@on_command("remember", description="保存一条记忆并回读")
async def remember(self, event: MessageEvent, ctx: Context) -> None:
await ctx.memory.save(
"demo:last_message",
{"user_id": event.user_id or "", "text": event.text},
)
remembered = await ctx.memory.get("demo:last_message") or {}
await ctx.db.set("demo:last_session", event.session_id)
keys = await ctx.db.list("demo:")
await event.reply(
f"Memory saved for {remembered.get('user_id', 'unknown')} with {len(keys)} keys"
)
@on_message(regex=r"^ping$")
async def ping(self, event: MessageEvent) -> None:
await event.reply("pong")
@on_command("announce", description="发送一条富消息链")
async def announce(self, event: MessageEvent, ctx: Context) -> None:
await ctx.platform.send_chain(
event.target or event.session_id,
[
{"type": "Plain", "text": "Demo "},
{"type": "Image", "file": "https://example.com/demo.png"},
],
)
@provide_capability(
"demo.echo",
description="回显输入文本",
input_schema={
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
output_schema={
"type": "object",
"properties": {
"echo": {"type": "string"},
"plugin_id": {"type": "string"},
},
"required": ["echo", "plugin_id"],
},
)
async def echo_capability(
self,
payload: dict[str, object],
ctx: Context,
cancel_token: CancelToken,
) -> dict[str, str]:
cancel_token.raise_if_cancelled()
return {
"echo": str(payload.get("text", "")),
"plugin_id": ctx.plugin_id,
}

View File

@@ -0,0 +1,13 @@
_schema_version: 2
name: astrbot_plugin_v4demo
display_name: V4 Demo 插件
desc: 一个覆盖 v4 原生命令、消息处理和 capability 的示例插件
author: Soulter
version: 0.1.0
runtime:
python: "3.12"
components:
- class: commands.hello:HelloPlugin
type: command
name: hello
description: 发送问候消息

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,75 @@
"""真实 v4 示例插件集成测试。"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from astrbot_sdk.protocol.descriptors import CommandTrigger, MessageTrigger
from astrbot_sdk.runtime.loader import load_plugin, load_plugin_spec
class TestRealNewTestPlugin:
"""验证仓库中的真实 v4 示例插件目录。"""
def test_load_new_plugin(self):
project_root = Path(__file__).resolve().parent.parent
test_plugin_dir = project_root / "test_plugin" / "new"
if not test_plugin_dir.exists():
pytest.skip("test_plugin/new directory not found")
spec = load_plugin_spec(test_plugin_dir)
paths_to_add = []
if str(test_plugin_dir) not in sys.path:
sys.path.insert(0, str(test_plugin_dir))
paths_to_add.append(str(test_plugin_dir))
src_new = project_root / "src-new"
if str(src_new) not in sys.path:
sys.path.insert(0, str(src_new))
paths_to_add.append(str(src_new))
try:
loaded = load_plugin(spec)
assert loaded.plugin.name == "astrbot_plugin_v4demo"
assert len(loaded.instances) == 1
command_triggers = [
handler.descriptor.trigger
for handler in loaded.handlers
if isinstance(handler.descriptor.trigger, CommandTrigger)
]
message_triggers = [
handler.descriptor.trigger
for handler in loaded.handlers
if isinstance(handler.descriptor.trigger, MessageTrigger)
]
assert {trigger.command for trigger in command_triggers} == {
"announce",
"hello",
"remember",
}
hello_trigger = next(
trigger for trigger in command_triggers if trigger.command == "hello"
)
assert "hi" in hello_trigger.aliases
assert len(message_triggers) == 1
assert message_triggers[0].regex == r"^ping$"
capability_names = [item.descriptor.name for item in loaded.capabilities]
assert capability_names == ["demo.echo"]
finally:
for path in paths_to_add:
if path in sys.path:
sys.path.remove(path)
for module_name in list(sys.modules):
if module_name == "commands" or module_name.startswith("commands."):
sys.modules.pop(module_name, None)

View File

@@ -2,9 +2,7 @@ from __future__ import annotations
import asyncio
import shutil
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
@@ -15,49 +13,8 @@ from astrbot_sdk.runtime.peer import Peer
from tests_v4.helpers import FakeEnvManager, make_transport_pair
def write_new_plugin(plugin_root: Path) -> None:
(plugin_root / "commands").mkdir(parents=True, exist_ok=True)
(plugin_root / "commands" / "__init__.py").write_text("", encoding="utf-8")
(plugin_root / "requirements.txt").write_text("", encoding="utf-8")
(plugin_root / "plugin.yaml").write_text(
textwrap.dedent(
f"""\
_schema_version: 2
name: v4_plugin
display_name: V4 Plugin
desc: test
author: tester
version: 0.1.0
runtime:
python: "{sys.version_info.major}.{sys.version_info.minor}"
components:
- class: commands.sample:MyPlugin
type: command
name: hello
description: hello
"""
),
encoding="utf-8",
)
(plugin_root / "commands" / "sample.py").write_text(
textwrap.dedent(
"""\
from astrbot_sdk import Context, MessageEvent, Star, on_command
class MyPlugin(Star):
@on_command("hello")
async def hello(self, event: MessageEvent, ctx: Context):
reply = await ctx.llm.chat(event.text)
await event.reply(reply)
chunks = []
async for chunk in ctx.llm.stream_chat("stream"):
chunks.append(chunk)
await event.reply("".join(chunks))
"""
),
encoding="utf-8",
)
def sample_plugin_dir(name: str) -> Path:
return Path(__file__).resolve().parents[1] / "test_plugin" / name
class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
@@ -86,7 +43,7 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "v4_plugin"
write_new_plugin(plugin_root)
shutil.copytree(sample_plugin_dir("new"), plugin_root)
runtime = SupervisorRuntime(
transport=self.right,
@@ -122,6 +79,89 @@ class RuntimeIntegrationTest(unittest.IsolatedAsyncioTestCase):
finally:
await runtime.stop()
async def test_supervisor_exposes_real_v4_plugin_capability(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "v4_plugin"
shutil.copytree(sample_plugin_dir("new"), plugin_root)
runtime = SupervisorRuntime(
transport=self.right,
plugins_dir=plugins_root,
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
capability_names = {
descriptor.name
for descriptor in self.core.remote_provided_capabilities
}
self.assertIn("demo.echo", capability_names)
result = await self.core.invoke(
"demo.echo",
{"text": "capability"},
request_id="call-v4-capability",
)
self.assertEqual(
result,
{
"echo": "capability",
"plugin_id": "astrbot_plugin_v4demo",
},
)
finally:
await runtime.stop()
async def test_supervisor_runs_v4_plugin_chain_send(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"
plugin_root = plugins_root / "v4_plugin"
shutil.copytree(sample_plugin_dir("new"), plugin_root)
runtime = SupervisorRuntime(
transport=self.right,
plugins_dir=plugins_root,
env_manager=FakeEnvManager(),
)
try:
await runtime.start()
await self.core.wait_until_remote_initialized()
handler_id = next(
handler.id
for handler in self.core.remote_handlers
if getattr(handler.trigger, "command", None) == "announce"
)
await self.core.invoke(
"handler.invoke",
{
"handler_id": handler_id,
"event": {
"text": "announce",
"session_id": "session-chain",
"user_id": "user-1",
"platform": "test",
},
},
request_id="call-v4-chain",
)
chain_message = runtime.capability_router.sent_messages[-1]
self.assertEqual(chain_message["session"], "session-chain")
self.assertEqual(
chain_message["target"]["conversation_id"],
"session-chain",
)
self.assertEqual(chain_message["chain"][0]["text"], "Demo ")
self.assertEqual(
chain_message["chain"][1]["file"],
"https://example.com/demo.png",
)
finally:
await runtime.stop()
async def test_supervisor_runs_compat_plugin(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
plugins_root = Path(temp_dir) / "plugins"