diff --git a/README.md b/README.md index e69de29bb..4b745f97c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,221 @@ +# AstrBot SDK + +面向 AstrBot 插件作者的 v4 SDK。它提供三件核心能力: + +- 用 `Star`、`Context`、`MessageEvent` 编写插件 +- 用 `astrbot-sdk dev --local` / `--watch` 做本地调试 +- 用 `astrbot_sdk.testing` 写不依赖真实 Core 的插件测试 + +## 5 分钟跑通第一个插件 + +### 1. 创建插件骨架 + +```bash +astrbot-sdk init my_plugin +cd my_plugin +``` + +生成后的目录结构: + +```text +my_plugin/ +├── README.md +├── plugin.yaml +├── requirements.txt +├── main.py +└── tests + └── test_plugin.py +``` + +### 2. 校验插件 + +```bash +astrbot-sdk validate --plugin-dir . +``` + +### 3. 本地运行一次 + +```bash +astrbot-sdk dev --local --plugin-dir . --event-text hello +``` + +### 4. 开启热重载 + +```bash +astrbot-sdk dev --local --watch --plugin-dir . --event-text hello +``` + +保存 `main.py` 后,本地 harness 会自动重载并重新派发这条消息。 + +## 最小插件示例 + +```python +from astrbot_sdk import Context, MessageEvent, Star, on_command + + +class MyPlugin(Star): + @on_command("hello", description="发送问候") + async def hello(self, event: MessageEvent, ctx: Context) -> None: + await event.reply("Hello, World!") +``` + +对应 `plugin.yaml`: + +```yaml +name: my_plugin +display_name: My Plugin +desc: 一个最小可运行的 AstrBot SDK 插件 +author: your-name +version: 0.1.0 +runtime: + python: "3.12" +components: + - class: main:MyPlugin +``` + +## 插件作者最常用的 API + +### 事件和上下文 + +- `MessageEvent.text`: 当前消息文本 +- `MessageEvent.reply(text)`: 回复文本 +- `Context.plugin_id`: 当前插件 ID +- `Context.logger`: 已绑定插件 ID 的日志器 + +### 平台能力 + +- `ctx.platform.send(session, text)` +- `ctx.platform.send_image(session, image_url)` +- `ctx.platform.send_chain(session, chain)` +- `ctx.platform.get_members(session)` + +### LLM + +- `ctx.llm.chat(prompt)` +- `ctx.llm.chat_raw(prompt, ...)` +- `ctx.llm.stream_chat(prompt, ...)` + +### 存储 + +- `ctx.db.get/set/delete/list` +- `ctx.memory.save/get/delete/search` + +### 插件元数据和 HTTP + +- `ctx.metadata.get_current_plugin()` +- `ctx.metadata.get_plugin_config()` +- `ctx.http.register_api(...)` +- `ctx.http.unregister_api(...)` +- `ctx.http.list_apis()` + +## 本地调试 + +### 单次派发 + +```bash +astrbot-sdk dev --local --plugin-dir . --event-text hello +``` + +### 交互模式 + +```bash +astrbot-sdk dev --local --plugin-dir . --interactive +``` + +交互模式支持: + +- `/session ` +- `/user ` +- `/platform ` +- `/group ` +- `/private` +- `/event ` +- `/exit` + +### 热重载 + +```bash +astrbot-sdk dev --local --watch --plugin-dir . --interactive +``` + +适合边改边测。代码变更后会自动重建插件运行时。 + +## 测试插件 + +最小测试示例: + +```python +import pytest + +from astrbot_sdk.testing import MockContext, MockMessageEvent +from main import MyPlugin + + +@pytest.mark.asyncio +async def test_hello_handler(): + plugin = MyPlugin() + ctx = MockContext(plugin_id="my_plugin") + event = MockMessageEvent(text="/hello", context=ctx) + + await plugin.hello(event, ctx) + + assert event.replies == ["Hello, World!"] + ctx.platform.assert_sent("Hello, World!") +``` + +运行: + +```bash +python -m pytest tests/test_plugin.py -v +``` + +## 示例插件 + +面向插件作者的最小示例在: + +- [examples/hello_plugin/README.md](examples/hello_plugin/README.md) + +仓库里的 `test_plugin/new` 是运行时/集成测试夹具,不是作者入门模板。 + +## 常见问题 + +### 1. `validate` 通过了,但 `dev` 还是失败 + +通常是组件导入或实例化阶段异常。现在错误信息会包含: + +- 插件名 +- `plugin.yaml` 路径 +- `components[i].class` +- 原始异常原因 + +优先检查: + +- `plugin.yaml` 的 `components` +- 导入路径是否真实存在 +- 组件类是否继承 `astrbot_sdk.Star` +- `__init__()` 是否做了会抛异常的工作 + +### 2. handler 参数为什么无法注入 + +默认支持: + +- 按类型注入:`MessageEvent`、`Context` +- 按名字注入:`event`、`ctx`、`context` +- 命令参数字典中的同名字段 + +如果参数不在这几类里,请自己从 `event` 或 `ctx` 取。 + +### 3. capability schema 校验失败怎么看 + +错误会明确指出: + +- 哪个 capability +- 输入还是输出校验失败 +- 具体字段路径 +- 期望类型和实际类型 + +## 下一步 + +1. 先跑通 [examples/hello_plugin/README.md](examples/hello_plugin/README.md) +2. 再看 `src-new/astrbot_sdk/testing.py` 里的 `PluginHarness` / `MockContext` +3. 需要更复杂的 capability、HTTP、metadata 示例时,再参考 `test_plugin/new` diff --git a/examples/hello_plugin/README.md b/examples/hello_plugin/README.md new file mode 100644 index 000000000..b460f56fb --- /dev/null +++ b/examples/hello_plugin/README.md @@ -0,0 +1,43 @@ +# Hello Plugin + +这是给 AstrBot SDK 插件作者准备的最小示例。 + +## 目录结构 + +```text +hello_plugin/ +├── plugin.yaml +├── requirements.txt +├── main.py +└── tests + └── test_plugin.py +``` + +## 能学到什么 + +- 如何定义一个 `Star` 插件 +- 如何注册命令 handler +- 如何使用 `MessageEvent.reply()` +- 如何从 `Context` 里读取当前插件元数据 +- 如何用 `MockContext` / `MockMessageEvent` 写插件测试 + +## 运行 + +在仓库根目录执行: + +```bash +astrbot-sdk validate --plugin-dir examples/hello_plugin +astrbot-sdk dev --local --plugin-dir examples/hello_plugin --event-text hello +astrbot-sdk dev --local --watch --plugin-dir examples/hello_plugin --event-text hello +``` + +## 测试 + +```bash +python -m pytest examples/hello_plugin/tests/test_plugin.py -v +``` + +## 代码说明 + +- `hello`: 最小命令,收到 `hello` 时回复 `Hello, World!` +- `about`: 读取 `ctx.metadata.get_current_plugin()`,演示 capability 客户端的基础用法 diff --git a/examples/hello_plugin/main.py b/examples/hello_plugin/main.py new file mode 100644 index 000000000..08e673416 --- /dev/null +++ b/examples/hello_plugin/main.py @@ -0,0 +1,13 @@ +from astrbot_sdk import Context, MessageEvent, Star, on_command + + +class HelloPlugin(Star): + @on_command("hello", description="发送最小问候") + async def hello(self, event: MessageEvent, ctx: Context) -> None: + await event.reply("Hello, World!") + + @on_command("about", description="返回当前插件信息") + async def about(self, event: MessageEvent, ctx: Context) -> None: + plugin = await ctx.metadata.get_current_plugin() + display_name = plugin.display_name if plugin is not None else ctx.plugin_id + await event.reply(f"我是 {display_name}") diff --git a/examples/hello_plugin/plugin.yaml b/examples/hello_plugin/plugin.yaml new file mode 100644 index 000000000..558e12cfb --- /dev/null +++ b/examples/hello_plugin/plugin.yaml @@ -0,0 +1,9 @@ +name: hello_plugin +display_name: Hello Plugin +desc: 一个适合插件作者入门的最小示例插件 +author: your-name +version: 0.1.0 +runtime: + python: "3.12" +components: + - class: main:HelloPlugin diff --git a/examples/hello_plugin/requirements.txt b/examples/hello_plugin/requirements.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/examples/hello_plugin/requirements.txt @@ -0,0 +1 @@ + diff --git a/examples/hello_plugin/tests/test_plugin.py b/examples/hello_plugin/tests/test_plugin.py new file mode 100644 index 000000000..289c531e3 --- /dev/null +++ b/examples/hello_plugin/tests/test_plugin.py @@ -0,0 +1,27 @@ +import pytest + +from astrbot_sdk.testing import MockContext, MockMessageEvent +from main import HelloPlugin + + +@pytest.mark.asyncio +async def test_hello_handler() -> None: + plugin = HelloPlugin() + ctx = MockContext(plugin_id="hello_plugin") + event = MockMessageEvent(text="/hello", context=ctx) + + await plugin.hello(event, ctx) + + assert event.replies == ["Hello, World!"] + ctx.platform.assert_sent("Hello, World!") + + +@pytest.mark.asyncio +async def test_about_handler() -> None: + plugin = HelloPlugin() + ctx = MockContext(plugin_id="hello_plugin") + event = MockMessageEvent(text="/about", context=ctx) + + await plugin.about(event, ctx) + + assert any("hello_plugin" in reply for reply in event.replies) diff --git a/src-new/astrbot_sdk/cli.py b/src-new/astrbot_sdk/cli.py index 58df39f51..90191f424 100644 --- a/src-new/astrbot_sdk/cli.py +++ b/src-new/astrbot_sdk/cli.py @@ -599,6 +599,41 @@ def _render_init_main_py(*, plugin_name: str) -> str: ) +def _render_init_readme(*, plugin_name: str) -> str: + return dedent( + f"""\ + # {plugin_name} + + 一个最小可运行的 AstrBot SDK v4 插件。 + + ## 目录结构 + + ``` + . + ├── plugin.yaml + ├── requirements.txt + ├── main.py + └── tests + └── test_plugin.py + ``` + + ## 本地开发 + + ```bash + astrbot-sdk validate --plugin-dir . + astrbot-sdk dev --local --plugin-dir . --event-text hello + astrbot-sdk dev --local --watch --plugin-dir . --event-text hello + ``` + + ## 运行测试 + + ```bash + python -m pytest tests/test_plugin.py -v + ``` + """ + ) + + def _render_init_test_py(*, plugin_name: str) -> str: class_name = _class_name_for_plugin(plugin_name) return dedent( @@ -701,6 +736,10 @@ def _init_plugin(name: str) -> None: _render_init_main_py(plugin_name=plugin_name), encoding="utf-8", ) + (target_dir / "README.md").write_text( + _render_init_readme(plugin_name=plugin_name), + encoding="utf-8", + ) (target_dir / "tests" / "test_plugin.py").write_text( _render_init_test_py(plugin_name=plugin_name), encoding="utf-8", diff --git a/test_plugin/new/README.md b/test_plugin/new/README.md new file mode 100644 index 000000000..ca30bcb43 --- /dev/null +++ b/test_plugin/new/README.md @@ -0,0 +1,14 @@ +# test_plugin/new + +这个目录是运行时与集成测试夹具,不是给插件作者直接照抄的入门模板。 + +它的目标是覆盖更多 SDK surface,例如: + +- 生命周期 +- LLM / DB / Memory / Platform / HTTP / Metadata client +- 自定义 capability +- schedule / event / message / command handler + +如果你是在找最小可学习示例,请改看: + +- `examples/hello_plugin/` diff --git a/tests_v4/test_testing_module.py b/tests_v4/test_testing_module.py index af8757279..d09dfb8e8 100644 --- a/tests_v4/test_testing_module.py +++ b/tests_v4/test_testing_module.py @@ -55,6 +55,20 @@ def test_dev_help_lists_watch_option() -> None: assert "--watch" in process.stdout +def test_init_plugin_template_includes_readme(tmp_path: Path, monkeypatch) -> None: + from astrbot_sdk.cli import _init_plugin + + target = tmp_path / "demo_plugin" + monkeypatch.chdir(tmp_path) + + _init_plugin(target.name) + + assert (target / "README.md").exists() + assert "astrbot-sdk dev --local --watch" in ( + target / "README.md" + ).read_text(encoding="utf-8") + + @pytest.mark.asyncio async def test_plugin_harness_dispatches_sample_plugin() -> None: from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness @@ -85,6 +99,21 @@ async def test_plugin_harness_supports_metadata_and_http_commands() -> None: ) +@pytest.mark.asyncio +async def test_example_hello_plugin_dispatches_commands() -> None: + from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness + + plugin_dir = _repo_root() / "examples" / "hello_plugin" + + async with PluginHarness(LocalRuntimeConfig(plugin_dir=plugin_dir)) as harness: + hello_records = await harness.dispatch_text("hello") + about_records = await harness.dispatch_text("about") + + assert any(record.text == "Hello, World!" for record in hello_records) + # about 命令返回 display_name "Hello Plugin",不是 name "hello_plugin" + assert any("Hello Plugin" in (record.text or "") for record in about_records) + + @pytest.mark.asyncio async def test_plugin_harness_reports_component_load_errors(tmp_path: Path) -> None: from astrbot_sdk.testing import LocalRuntimeConfig, PluginHarness, _PluginLoadError