聊天机器人开发(三)-指令和事件
指令系统
在了解了控制台的基本用法后,我们终于可以开始介绍如何与机器人对话了!让我们从上一节中看到的例子开始:
这里的输出与两个插件有关:
一个 Koishi 机器人的绝大部分功能都是通过指令提供给用户的。当你安装了更多的插件后,你也就有了更多的指令可供使用。
查看帮助
help 指令后还可以添加一个参数,用于查看特定指令的帮助信息:
那么细心的小伙伴可能会发现,既然 help 本身也是一个指令,那我能不能用来查看 help 自己的帮助信息呢?答案是肯定的:
参数和选项
在上面的用法中,我们接触到了两个新的概念:参数 (Argument) 和 选项 (Option)。
参数分为必选参数和可选参数,分别用尖括号 <> 和方括号 [] 表示。一个指令可以有任意多个参数,它们的顺序是固定的,用户必须按照指令定义的顺序来输入参数。
必选参数一定出现在可选参数之前。如果用户输入的参数数量不足必选参数的个数,那么插件通常会给出错误提示;如果用户输入了额外的参数,那么会被忽略。
例如,help 指令共有一个参数,它是可选参数,表示要查看的指令名;echo 指令也有一个参数,它是必选参数,表示要发送的消息。让我们看看如果不填必选参数会怎么样:
选项同样可以控制指令的行为。它通常以 - 或 – 开头,后面不带空格地跟着一个固定的单词,称为选项名称。
选项之间没有顺序要求,但通常建议将选项放在参数之前。让我们试试看:
在上面的例子中,我们使用了 -E 选项,成功改变了输出的内容。关于这具体是怎么做到的,我们会在后续的章节中进行介绍。
参数除了可以分为必选和可选外,还可以分为定长和变长。定长参数的中不能出现空白字符,而变长参数则可以。变长参数通过参数名前后的 … 来指示,例如 echo 指令的参数就是一个变长参数。如果要为定长参数传入带有空白字符的内容,可以使用引号将其包裹起来,例如:
此外,部分选项也可以接受参数。例如,当你安装了翻译插件,你将会获得如下的帮助信息:
在这个例子中,-s 和 -t 都是带有参数的选项。我们使用 -t ja 来指定目标语言为日语,源语言仍然采用了默认行为。
指令开发
一个 Koishi 机器人的绝大部分功能都是通过指令提供给用户的。
Koishi 的指令系统能够高效地处理大量消息的并发调用,
同时还提供了快捷方式、调用前缀、权限管理、速率限制、本地化等大量功能。
因此,只需掌握指令开发并编写少量代码就可以轻松应对各类用户需求。
编写下面的代码,你就实现了一个简单的 echo 指令:
1 | ctx.command('echo <message>') |
让我们回头看看这段代码是如何工作的:
.command() 方法定义了名为 echo 的指令,其有一个必选参数为 message
.action() 方法定义了指令触发时的回调函数,第一个参数是一个 Argv 对象,第二个参数是输入的 message
这种链式的结构能够让我们非常方便地定义和扩展指令。
稍后我们将看到这两个函数的更多用法,以及更多指令相关的函数。
定义参数
正如你在上面所见的那样,使用 ctx.command(decl) 方法可以定义一个指令,其中 decl 是一个字符串,包含了 指令名 和 参数列表。
指令名可以包含数字、字母、短横线甚至中文,但不应该包含空白字符、小数点 . 或斜杠 /;小数点和斜杠的用途参见 注册子指令 章节
一个指令可以含有任意个参数,其中 必选参数 用尖括号包裹,可选参数 用方括号包裹;这些参数将作为 action 回调函数除 Argv 以外的的后续参数传入
例如,下面的程序定义了一个拥有三个参数的指令,第一个为必选参数,后面两个为可选参数,它们将分别作为 action 回调函数的第 2~4 个参数:
1 | ctx.command('test <arg1> [arg2] [arg3]') |
变长参数
有时我们需要传入未知数量的参数,这时我们可以使用 变长参数,它可以通过在括号中前置 … 来实现。
在下面的例子中,无论传入了多少个参数,都会被放入 rest 数组进行处理:
1 | ctx.command('test <arg1> [...rest]') |
文本参数
通常来说传入的信息被解析成指令调用后,会被空格分割成若干个参数。
但如果你想输入的就是含有空格的内容,可以通过在括号中后置 :text 来声明一个 文本参数。
在下面的例子中,即使 test 后面的内容中含有空格,也会被整体传入 message 中:
1 | ctx.command('test <message:text>') |
指令别名
你可以为一条指令添加别名:
1 | ctx.command('echo <message>').alias('say') |
这样一来,无论是 echo 还是 say 都能触发这条指令了。
你还可以为别名添加参数或选项:
1 | ctx.command('market <area> <item>').alias('市场', { args: ['China'] }) |
此时调用 市场 时将等价于调用 market China。如果你传入了更多的参数,那么它们将被添加到 China 之后。
编写帮助和添加使用说明
之前已经介绍了 ctx.command() 和 cmd.option() 这两个方法,它们都能传入一个 desc 参数。
你可以在这个参数的结尾补上对于指令或参数的说明文字,就像这样:
1 | ctx.command('echo <message:text> 输出收到的信息') |
当然,我们还可以加入具体的用法和使用示例,进一步丰富这则使用说明:
1 | ctx.command('echo <message:text>', '输出收到的信息') |
这时再调用 echo -h,你便会发现使用说明中已经添加了你刚刚的补充文本
事件系统
在上一节中我们了解了指令开发,现在让我们回到更加基础的事件系统。
事件系统在 Koishi 中扮演着底层的角色,它不仅包含由聊天平台触发的会话事件,还包含了监听运行状态的生命周期事件和提供扩展性的自定义事件。
基本用法
让我们先从一个基本示例开始:
1 | ctx.on('message', (session) => { |
上述代码片段实现了一个简单的功能:当任何用户发送「天王盖地虎」时,机器人将发送「宝塔镇河妖」。
如你所见,ctx.on() 方法监听了一个事件。传入的第一个参数 message 是事件的名称,而第二个参数则是事件的回调函数。每一次 message 事件被触发 (即收到消息) 时都会调用该函数。
回调函数接受一个参数 session,称为会话对象。
在这个例子中,我们通过它访问事件相关的数据 (使用 session.content 获取消息的内容),
并调用其上的 API 作为对此事件的响应 (使用 session.send() 在当前频道内发送消息)。
事件与会话构成了最基础的交互模型。这种模型不仅能够处理消息,还能够处理其他类型的事件。我们再给出一个例子:
1 | // 当有好友请求时,接受请求并发送欢迎消息 |
像这样由聊天平台推送的事件,我们称之为 会话事件。
除此以外,Koishi 还有着其他类型的事件,例如由 Koishi 自身生成的 生命周期事件,又或者是由插件提供的 自定义事件 等等。
这些事件的监听方式与会话事件基本一致,只不过它们的回调函数接受的参数不同。
例如下面的代码实现了当 Bot 上线时自动给自己发送一条消息的功能:
1 | // bot-status-updated 不是会话事件 |
在后续的章节中,我们也将介绍更多的事件和会话的使用方法。
监听事件
在上面的例子中,我们已经了解到事件系统的基本用法:使用 ctx.on() 注册监听器。
它的写法与 Node.js 自带的 EventEmitter 类似:第一个参数表示要监听的事件名称,第二个参数表示事件的回调函数。
同时,我们也提供了类似的函数 ctx.once(),用于注册一个只触发一次的监听器;以及 ctx.off(),用于取消一个已注册的监听器。
这套事件系统与 EventEmitter 的一个不同点在于,无论是 ctx.on() 还是 ctx.once() 都会返回一个 dispose 函数,
调用这个函数即可取消注册监听器。因此你其实不必使用 ctx.once() 和 ctx.off()。下面给一个只触发一次的监听器的例子:
1 | declare module 'koishi' { |
事件的命名
无论是会话事件,生命周期事件还是插件自定义的事件,Koishi 的事件名都遵循着一些既定的规范。遵守规范能够让开发者获得一致的体验,提高开发和调试的效率。它们包括:
举个例子,koishi-plugin-dialogue 扩展了多达 20 个自定义事件。为了防止命名冲突,所有的事件都以 dialogue/ 开头,并且在特定操作前触发的事件都包含了 before- 前缀,例如:
前置事件
前面介绍了,Koishi 有不少监听器满足 before-xxx 的形式。
对于这类监听器的注册,我们也提供了一个语法糖,那就是 ctx.before(‘xxx’, callback)。这种写法也支持命名空间的情况:
1 | // @errors: 2304 |
默认情况下,事件的多个回调函数的执行顺序取决于它们添加的顺序。先注册的回调函数会先被执行。
如果你希望提高某个回调函数的优先级,可以给 ctx.on() 传入第三个参数 prepend,
设置为 true 即表示添加到事件执行队列的开头而非结尾,相当于 emitter.prependListener()。
对于 ctx.before(),情况则正好相反。默认的行为的先注册的回调函数后执行,
同时 ctx.before() 的第三个参数 append 则表示添加到事件执行队列的末尾而非开头。
触发事件
如果你开发的插件希望允许其他插件扩展,那么触发事件就是最简单的方式。
触发事件的基本用法也都与 EventEmitter 类似,第一个参数是事件名称,之后的参数对应回调函数的参数。下面是一个例子:
1 | declare module 'koishi' { |
触发方式
Koishi 的事件系统与 EventEmitter 的另一个区别在于,触发一个事件可以有着多种形式,目前支持 4 个不同的方法,足以适应绝大多数需求。
过滤触发上下文
如果你的自定义事件与某个特定会话相关 (并不需要是会话事件),你可以在触发事件的时候传入一个额外的一参数 session,以实现对触发上下文的过滤:
1 | declare module 'koishi' { |
更一般地,即使是不使用会话的事件也能主动选择触发的上下文,其语法完全一致:
1 | const thisArg = { [Context.filter]: callback } |
触发事件时传入的一参数如果是对象,则会作为事件回调函数的 this。
并且如果这个对象有 Context.filter 属性,那么这个属性将被用于过滤触发上下文。
对应的值是一个函数,传入一个上下文对象,返回一个 boolean 表示是否应该在该上下文上触发该事件。
而上面介绍的会话事件只是一种特殊情况而已。
自定义事件
在本节的最后,我们来聊聊插件扩展的事件系统。
如果你是插件的开发者,想要自定义一些事件,那么只需要在你的插件中添加下面的代码:
1 | declare module 'koishi' { |
如果你监听的事件由其他插件扩展而来,那么你同样需要通过一行额外的代码来导入相应的类型:
1 | // 从 @koishijs/plugin-adapter-kook 导入事件类型 |