mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
feat(plugin): 添加新的 V4 示例插件及集成测试,更新文档以反映插件结构变化
This commit is contained in:
@@ -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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
1
test_plugin/new/commands/__init__.py
Normal file
1
test_plugin/new/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""V4 sample plugin commands package."""
|
||||
83
test_plugin/new/commands/hello.py
Normal file
83
test_plugin/new/commands/hello.py
Normal 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,
|
||||
}
|
||||
13
test_plugin/new/plugin.yaml
Normal file
13
test_plugin/new/plugin.yaml
Normal 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: 发送问候消息
|
||||
1
test_plugin/new/requirements.txt
Normal file
1
test_plugin/new/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
75
tests_v4/test_new_plugin_integration.py
Normal file
75
tests_v4/test_new_plugin_integration.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user