3. 10 分钟做出第一个插件
导读 本章从零开始,带你写出并运行一个真实的最小插件。它只包含一个命令组件,结构简单到只有两个文件,但已经覆盖了插件系统最关键的完整链路:目录结构、manifest、插件类、组件注册、命令路由。跑通这一章,你对插件系统的理解就不再停留在概念层面了。
前两章一直在铺垫。到了这里,终于可以做一件真正有成就感的事:亲手做出一个能被 Neo-MoFox 发现、加载,并且能响应命令的插件。
本章不追求"完整工程结构",也不引入配置、服务拆分、事件处理等内容。目标很单纯:
先做出一个最小可运行插件。
只要这个目标先完成,后面再谈结构、抽象、复用,读起来才会踏实。
3.1 这一章我们要做什么
我们会做一个非常小的插件:echo_demo。
它只提供一个命令组件 echo,支持两个子命令:
/echo ping/echo say hello
如果你输入:
/echo ping它应该返回:
pong如果你输入:
/echo say hello它应该返回:
echo: hello如果你想测试带空格的文本,也可以这样:
/echo say "hello world"这里之所以把带空格的文本放进引号里,是因为当前命令组件底层会按命令参数拆分文本,而引号可以帮我们把它当成一个整体参数保留下来。
3.2 先把第一个插件做小,不要做全
很多人刚开始写插件时,会下意识地做一件适得其反的事:
- 先拆文件。
- 再加配置。
- 再补日志。
- 再做数据结构。
- 再考虑之后怎么扩展。
然后第一个小时过去了,插件还没被系统真正加载过一次。
所以这一章反过来做:先用最小结构把插件跑起来。 我们会暂时把命令类和插件类都放在同一个 plugin.py 里。这不一定是将来最优雅的组织方式,但它是最适合入门的方式——可以最快看清完整链路是怎么通的。
等后面章节再逐步拆分和演化。
3.3 创建插件目录
先在项目根目录下的 plugins 目录里创建一个新文件夹:
plugins/
└── echo_demo/
├── manifest.json
└── plugin.py这就是我们这个最小插件的全部结构。
你会发现,它真的很小。小到有点让人不放心。但这正是好事——说明我们正在关注真正关键的部分,而不是一开始就被工程外壳分散注意力。
3.4 编写 manifest.json
先写插件清单:
{
"name": "echo_demo",
"version": "1.0.0",
"description": "一个用于演示插件加载与命令执行的最小回显插件",
"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
接下来写插件的主体代码:
from __future__ import annotations
from src.app.plugin_system.base import (
BaseCommand,
BasePlugin,
cmd_route,
register_plugin,
)
class EchoCommand(BaseCommand):
"""最小回显命令。"""
command_name = "echo"
command_description = "一个用于演示插件系统的最小回显命令"
command_prefix = "/"
@cmd_route("ping")
async def handle_ping(self) -> tuple[bool, str]:
"""检查命令是否已经正常工作。"""
return True, "pong"
@cmd_route("say")
async def handle_say(self, text: str) -> tuple[bool, str]:
"""回显一段文本。"""
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,而是:
/echo ...3. @cmd_route("ping") 和 @cmd_route("say")
这两个装饰器定义了命令组件内部的子路由。
所以整个命令关系可以这样理解:
echo是主命令。ping和say是它下面的子命令。
也正因为这样,最终可用命令才会长成:
/echo ping
/echo say hello4. @register_plugin
这是很关键的一步。
系统在导入 plugin.py 时,并不会自动神奇地知道哪个类才是插件类。@register_plugin 的作用,就是在模块导入阶段把这个插件类注册进全局插件注册表,让后续加载流程能根据插件名把它取回来。
如果你忘了这一步,manifest 明明能被读到,但插件类本身仍然可能找不到。
5. get_components() 返回 [EchoCommand]
这一步是在告诉系统:
这个插件真正要注册的组件,就是这个命令组件。
注意,这里返回的是类本身,不是实例。
这很重要,因为真正的组件实例化,是运行时管理器在后面合适的阶段做的,而不是你在插件定义阶段手动 new 出来。
3.7 为什么这一章不先写配置
如果你已经想问:"一个正经插件难道不该有配置吗?"
答案是:以后会,但现在不急。
因为这一章真正要验证的,是下面这条最短路径:
manifest -> 插件类 -> 组件注册 -> 命令可见 -> 命令可执行一旦这条最短路径先跑通,后面加配置会更踏实。反过来,如果一开始就把配置也加进去,很容易在"配置有没有生成出来""路径是不是对的""字段有没有同步"这些问题上兜圈子,却还没真正理解插件的最小骨架。
所以不是配置不重要,而是:它不属于第一个 10 分钟必须解决的问题。
3.8 启动项目,先验证插件有没有被加载
现在可以启动项目了:
uv run main.py这一轮先不要急着立刻测命令输出。先看第一层验证:插件是否被系统发现并加载。
你至少应该关注下面这些现象:
- 系统启动时没有因为
echo_demo报 manifest 解析错误。 - 没有因为
plugin.py导入失败而中断加载。 - 没有出现“插件类未注册”之类的错误。
- 启动日志里能看到
echo_demo被成功加载。
如果这一步没有通过,后面所有“命令为什么没反应”的讨论都还太早。先保证插件已经进入系统视野,才有资格继续往下查。
3.9 再验证命令是否已经注册
插件加载成功,只说明插件类被认出来了;还不代表命令组件一定已经按预期注册进系统。
所以第二层验证,是确认 EchoCommand 已经成为系统中的可用命令组件。
如果你已经接入了实际聊天平台,那么最直观的验证方式就是直接发送:
/echo ping预期结果:
pong然后再试:
/echo say hello预期结果:
echo: hello如果你要测试带空格的文本,可以再试一次:
/echo say "hello world"预期结果:
echo: hello world3.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()
确认你返回的是:
return [EchoCommand]而不是:
return [EchoCommand()]如果忘了把命令组件写进去。
5. 最后再看命令本身
确认你发送的是:
/echo ping而不是:
/ping这里最容易混淆的地方在于:echo 是命令组件名,ping 是它内部的子路由。
3.12 到这里你已经完成了一件重要的事
不要小看这个最小插件。
从代码量看,它确实不大;但从学习路径看,它已经让你亲手走通了插件系统里最关键的第一段:
- 创建了插件目录。
- 编写了 manifest。
- 定义了插件类。
- 声明了一个命令组件。
- 让系统成功发现并加载了它。
- 让它对外暴露了一个真正可调用的命令入口。
这一步之后,你对插件系统的理解就已经和“只看过仓库结构”完全不同了。
你不只是知道它"看起来是怎么组织的",而是已经知道:一个最小插件到底怎样才能被系统接受。
下一章我们会回过头来,把这个最小插件再拆开看一遍:manifest 到底做了什么,插件类为什么要那样写,组件签名是怎么生成的,系统在加载成功之后又做了哪些动作。

