# 3. 10 分钟做出第一个插件

> **导读** 本章从零开始，带你写出并运行一个真实的最小插件。它只包含一个命令组件，结构简单到只有两个文件，但已经覆盖了插件系统最关键的完整链路：目录结构、manifest、插件类、组件注册、命令路由。跑通这一章，你对插件系统的理解就不再停留在概念层面了。

前两章一直在铺垫。到了这里，终于可以做一件真正有成就感的事：**亲手做出一个能被 Neo-MoFox 发现、加载，并且能响应命令的插件。**

本章不追求"完整工程结构"，也不引入配置、服务拆分、事件处理等内容。目标很单纯：

> **先做出一个最小可运行插件。**

只要这个目标先完成，后面再谈结构、抽象、复用，读起来才会踏实。

### 3.1 这一章我们要做什么

我们会做一个非常小的插件：`echo_demo`。

它只提供一个命令组件 `echo`，支持两个子命令：

- `/echo ping`
- `/echo say hello`

如果你输入：

```text
/echo ping
```

它应该返回：

```text
pong
```

如果你输入：

```text
/echo say hello
```

它应该返回：

```text
echo: hello
```

如果你想测试带空格的文本，也可以这样：

```text
/echo say "hello world"
```

这里之所以把带空格的文本放进引号里，是因为当前命令组件底层会按命令参数拆分文本，而引号可以帮我们把它当成一个整体参数保留下来。

### 3.2 先把第一个插件做小，不要做全

很多人刚开始写插件时，会下意识地做一件适得其反的事：

- 先拆文件。
- 再加配置。
- 再补日志。
- 再做数据结构。
- 再考虑之后怎么扩展。

然后第一个小时过去了，插件还没被系统真正加载过一次。

所以这一章反过来做：**先用最小结构把插件跑起来。** 我们会暂时把命令类和插件类都放在同一个 `plugin.py` 里。这不一定是将来最优雅的组织方式，但它是最适合入门的方式——可以最快看清完整链路是怎么通的。

等后面章节再逐步拆分和演化。

### 3.3 创建插件目录

先在项目根目录下的 `plugins` 目录里创建一个新文件夹：

```text
plugins/
└── echo_demo/
    ├── manifest.json
    └── plugin.py
```

这就是我们这个最小插件的全部结构。

你会发现，它真的很小。小到有点让人不放心。但这正是好事——说明我们正在关注真正关键的部分，而不是一开始就被工程外壳分散注意力。

### 3.4 编写 manifest.json

先写插件清单：

```json
{
	"name": "echo_demo",
    "display_name": "Echo Demo",
	"version": "1.0.0",
	"description": "一个用于演示插件加载与命令执行的最小回显插件",
    "icon": "icon.png",
	"author": "MoFox Community",
	"dependencies": {
		"plugins": [],
		"components": []
	},
	"include": [
		{
			"component_type": "command",
			"component_name": "echo",
			"dependencies": [],
			"enabled": true
		}
	],
	"entry_point": "plugin.py",
	"min_core_version": "1.0.0"
}
```

先别急着背字段。现在只需抓住几个最重要的点：

- `name` 是插件的唯一标识。
- `entry_point` 告诉系统从哪个文件导入插件。
- `dependencies` 先留空，表示这个最小插件不依赖其他插件或组件。
- `min_core_version` 在当前阶段建议你**始终显式填写**，不要偷懒省略。

> **提示**
>
> 这里我们仍然把组件写进了 `include`，是为了让插件的结构描述更完整，也方便后续依赖设计保持一致。但就当前实现而言，真正决定运行时注册哪些组件的，仍然是插件类里的 `get_components()`。

### 3.5 编写 plugin.py

接下来写插件的主体代码：

```python
from __future__ import annotations

from src.app.plugin_system.base import (
    BaseCommand,
    BasePlugin,
    cmd_route,
    register_plugin,
)
from src.app.plugin_system.api.send_api import send_text

class EchoCommand(BaseCommand):
    """最小回显命令。"""

    command_name = "echo"
    command_description = "一个用于演示插件系统的最小回显命令"
    command_prefix = "/"

    @cmd_route("ping")
    async def handle_ping(self) -> tuple[bool, str]:
        """检查命令是否已经正常工作。"""
        await send_text("pong", stream_id=self.stream_id)
        return True, "pong"

    @cmd_route("say")
    async def handle_say(self, text: str) -> tuple[bool, str]:
        """回显一段文本。"""
        await send_text(f"echo: {text}", stream_id=self.stream_id)
        return True, f"echo: {text}"


@register_plugin
class EchoDemoPlugin(BasePlugin):
    """最小回显插件。"""

    plugin_name = "echo_demo"
    plugin_description = "一个用于演示插件加载与命令执行的最小插件"
    plugin_version = "1.0.0"

    def get_components(self) -> list[type]:
        """返回当前插件包含的组件。"""
        return [EchoCommand]
```

这段代码虽然短，但已经把一个最小插件的关键骨架完整写出来了。

我们逐段来看它在做什么。

这里顺便强调一个约定：**面向插件作者的代码，后续应优先从 `src.app.plugin_system` 这一层导入。**

原因很简单。插件系统对外最好有一层相对稳定的高层入口，这样插件就不必直接绑定到底层实现细节。对于社区插件来说，这一点尤其重要。你可以把 `src.app.plugin_system` 理解成插件开发时更推荐依赖的一层“公开接口面”，而不是一路往更底层的模块里钻。

### 3.6 这段代码里最重要的几个点

#### **1. EchoCommand 继承自 BaseCommand**

这说明它是一个命令组件，而不是 Service、Tool 或别的类型。

在当前系统里，组件类型不是靠文件名猜出来的，而是靠基类识别出来的。所以你继承谁，系统就会把你当成哪一类组件去注册。

#### **2. command_name = "echo"**

这决定了用户最终触发它时使用的主命令名。

也就是说，读者未来在平台里输入的不是类名 `EchoCommand`，而是：

```text
/echo ...
```

#### **3. @cmd_route("ping") 和 @cmd_route("say")**

这两个装饰器定义了命令组件内部的子路由。

所以整个命令关系可以这样理解：

- `echo` 是主命令。
- `ping` 和 `say` 是它下面的子命令。

也正因为这样，最终可用命令才会长成：

```text
/echo ping
/echo say hello
```

#### **4. @register_plugin**

这是很关键的一步。

系统在导入 `plugin.py` 时，并不会自动神奇地知道哪个类才是插件类。`@register_plugin` 的作用，就是在模块导入阶段把这个插件类注册进全局插件注册表，让后续加载流程能根据插件名把它取回来。

如果你忘了这一步，manifest 明明能被读到，但插件类本身仍然可能找不到。

#### **5. get_components() 返回 [EchoCommand]**

这一步是在告诉系统：

> 这个插件真正要注册的组件，就是这个命令组件。

注意，这里返回的是**类本身**，不是实例。

这很重要，因为真正的组件实例化，是运行时管理器在后面合适的阶段做的，而不是你在插件定义阶段手动 new 出来。

### 3.7 为什么这一章不先写配置

如果你已经想问："一个正经插件难道不该有配置吗？"

答案是：**以后会，但现在不急。**

因为这一章真正要验证的，是下面这条最短路径：

```text
manifest -> 插件类 -> 组件注册 -> 命令可见 -> 命令可执行
```

一旦这条最短路径先跑通，后面加配置会更踏实。反过来，如果一开始就把配置也加进去，很容易在"配置有没有生成出来""路径是不是对的""字段有没有同步"这些问题上兜圈子，却还没真正理解插件的最小骨架。

所以不是配置不重要，而是：**它不属于第一个 10 分钟必须解决的问题。**

### 3.8 启动项目，先验证插件有没有被加载

现在可以启动项目了：

```bash
uv run main.py
```

这一轮先不要急着立刻测命令输出。先看第一层验证：**插件是否被系统发现并加载。**

你至少应该关注下面这些现象：

- 系统启动时没有因为 `echo_demo` 报 manifest 解析错误。
- 没有因为 `plugin.py` 导入失败而中断加载。
- 没有出现“插件类未注册”之类的错误。
- 启动日志里能看到 `echo_demo` 被成功加载。

如果这一步没有通过，后面所有“命令为什么没反应”的讨论都还太早。先保证插件已经进入系统视野，才有资格继续往下查。

### 3.9 再验证命令是否已经注册

插件加载成功，只说明插件类被认出来了；还不代表命令组件一定已经按预期注册进系统。

所以第二层验证，是确认 `EchoCommand` 已经成为系统中的可用命令组件。

如果你已经接入了实际聊天平台，那么最直观的验证方式就是直接发送：

```text
/echo ping
```

预期结果：

```text
pong
```

然后再试：

```text
/echo say hello
```

预期结果：

```text
echo: hello
```

如果你要测试带空格的文本，可以再试一次：

```text
/echo say "hello world"
```

预期结果：

```text
echo: hello world
```

### 3.10 如果你暂时还没有接入聊天平台

这也是完全正常的。很多人在开始学插件时，运行环境还没配到能直接收发消息的程度。

这种情况下，你至少仍然可以把验证分成两层理解：

- **插件层面**：它已经被系统发现并成功加载。
- **命令层面**：它已经作为 Command 组件注册进系统。

从学习顺序上说，这两层已经足够让你确认“插件骨架是通的”。

等你后面接入平台，再去看真实消息触发，就不会把“平台接入问题”和“插件定义问题”混在一起了。

这件事比很多人想象中重要。新手最常见的误判，就是把链路末端的问题，当作插件本身写错了的问题来排查。

### 3.11 如果第一遍没跑通，先看哪几处

如果插件没有按预期工作，不要立刻大改代码。按下面这个顺序逐步排查：

#### **1. 先看目录名和文件名**

确认你真的把插件放在了 `plugins/echo_demo/` 下，而且 `manifest.json` 和 `plugin.py` 的名字没有写错。

#### **2. 再看 manifest**

重点检查：

- `name` 是否与插件类的 `plugin_name` 一致。
- `entry_point` 是否确实是 `plugin.py`。
- `min_core_version` 是否显式填写。
- JSON 格式本身是否正确。

#### **3. 再看插件类是否注册**

确认插件类前面确实写了 `@register_plugin`。

这一步非常容易漏，而且一旦漏掉，表面现象常常会让人以为“manifest 没生效”或者“插件目录没被扫描到”，其实问题只是在导入后没有注册插件类。

#### **4. 再看 get_components()**

确认你返回的是：

```python
return [EchoCommand]
```

而不是：

```python
return [EchoCommand()]
```

如果忘了把命令组件写进去。

#### **5. 最后再看命令本身**

确认你发送的是：

```text
/echo ping
```

而不是：

```text
/ping
```

这里最容易混淆的地方在于：`echo` 是命令组件名，`ping` 是它内部的子路由。

### 3.12 到这里你已经完成了一件重要的事

不要小看这个最小插件。

从代码量看，它确实不大；但从学习路径看，它已经让你亲手走通了插件系统里最关键的第一段：

- 创建了插件目录。
- 编写了 manifest。
- 定义了插件类。
- 声明了一个命令组件。
- 让系统成功发现并加载了它。
- 让它对外暴露了一个真正可调用的命令入口。

这一步之后，你对插件系统的理解就已经和“只看过仓库结构”完全不同了。

你不只是知道它"看起来是怎么组织的"，而是已经知道：**一个最小插件到底怎样才能被系统接受。**

下一章我们会回过头来，把这个最小插件再拆开看一遍：manifest 到底做了什么，插件类为什么要那样写，组件签名是怎么生成的，系统在加载成功之后又做了哪些动作。
