Skip to content

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",
	"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

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

python
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,而是:

text
/echo ...

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

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

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

  • echo 是主命令。
  • pingsay 是它下面的子命令。

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

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.jsonplugin.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 到底做了什么,插件类为什么要那样写,组件签名是怎么生成的,系统在加载成功之后又做了哪些动作。

贡献者

The avatar of contributor named as minecraft1024a minecraft1024a
The avatar of contributor named as Windpicker-owo Windpicker-owo

页面历史

Released under the GPL-3.0 License.

新对话
MoFox 助手

下午好。

今天想做点什么?

聊天内容可能会被记录以用于改进服务及其质量,并会遵循我们的隐私政策进行处理。