From d031814bc518ee8df5caacc2e391d6da0291ba1c Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 13 Mar 2026 06:53:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E6=B7=BB=E5=8A=A0=E6=96=B0?= =?UTF-8?q?=E7=9A=84=20V4=20=E7=A4=BA=E4=BE=8B=E6=8F=92=E4=BB=B6=E5=8F=8A?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=BB=A5=E5=8F=8D=E6=98=A0=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + CLAUDE.md | 1 + test_plugin/new/commands/__init__.py | 1 + test_plugin/new/commands/hello.py | 83 +++++++++++++++ test_plugin/new/plugin.yaml | 13 +++ test_plugin/new/requirements.txt | 1 + tests_v4/test_new_plugin_integration.py | 75 ++++++++++++++ tests_v4/test_runtime.py | 132 +++++++++++++++--------- 8 files changed, 261 insertions(+), 46 deletions(-) create mode 100644 test_plugin/new/commands/__init__.py create mode 100644 test_plugin/new/commands/hello.py create mode 100644 test_plugin/new/plugin.yaml create mode 100644 test_plugin/new/requirements.txt create mode 100644 tests_v4/test_new_plugin_integration.py diff --git a/AGENTS.md b/AGENTS.md index a02c7846f..d60327b7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index a02c7846f..d60327b7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. # 开发命令 diff --git a/test_plugin/new/commands/__init__.py b/test_plugin/new/commands/__init__.py new file mode 100644 index 000000000..9ff2fb6cd --- /dev/null +++ b/test_plugin/new/commands/__init__.py @@ -0,0 +1 @@ +"""V4 sample plugin commands package.""" diff --git a/test_plugin/new/commands/hello.py b/test_plugin/new/commands/hello.py new file mode 100644 index 000000000..0ddb3d56e --- /dev/null +++ b/test_plugin/new/commands/hello.py @@ -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, + } diff --git a/test_plugin/new/plugin.yaml b/test_plugin/new/plugin.yaml new file mode 100644 index 000000000..7980b6f72 --- /dev/null +++ b/test_plugin/new/plugin.yaml @@ -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: 发送问候消息 diff --git a/test_plugin/new/requirements.txt b/test_plugin/new/requirements.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/test_plugin/new/requirements.txt @@ -0,0 +1 @@ + diff --git a/tests_v4/test_new_plugin_integration.py b/tests_v4/test_new_plugin_integration.py new file mode 100644 index 000000000..ed2aa9877 --- /dev/null +++ b/tests_v4/test_new_plugin_integration.py @@ -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) diff --git a/tests_v4/test_runtime.py b/tests_v4/test_runtime.py index a69fdd17d..44d80cbbf 100644 --- a/tests_v4/test_runtime.py +++ b/tests_v4/test_runtime.py @@ -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"