指令系统

在了解了控制台的基本用法后,我们终于可以开始介绍如何与机器人对话了!让我们从上一节中看到的例子开始:

这里的输出与两个插件有关:

help 指令由 help 插件提供,它可以显示指令列表或具体指令的帮助信息
echo 指令由 echo 插件提供,它可以将用户的输入原样返回

一个 Koishi 机器人的绝大部分功能都是通过指令提供给用户的。当你安装了更多的插件后,你也就有了更多的指令可供使用。

查看帮助

help 指令后还可以添加一个参数,用于查看特定指令的帮助信息:

那么细心的小伙伴可能会发现,既然 help 本身也是一个指令,那我能不能用来查看 help 自己的帮助信息呢?答案是肯定的:

参数和选项

在上面的用法中,我们接触到了两个新的概念:参数 (Argument) 和 选项 (Option)。

参数分为必选参数和可选参数,分别用尖括号 <> 和方括号 [] 表示。一个指令可以有任意多个参数,它们的顺序是固定的,用户必须按照指令定义的顺序来输入参数。
必选参数一定出现在可选参数之前。如果用户输入的参数数量不足必选参数的个数,那么插件通常会给出错误提示;如果用户输入了额外的参数,那么会被忽略。

例如,help 指令共有一个参数,它是可选参数,表示要查看的指令名;echo 指令也有一个参数,它是必选参数,表示要发送的消息。让我们看看如果不填必选参数会怎么样:

选项同样可以控制指令的行为。它通常以 - 或 – 开头,后面不带空格地跟着一个固定的单词,称为选项名称。
选项之间没有顺序要求,但通常建议将选项放在参数之前。让我们试试看:

在上面的例子中,我们使用了 -E 选项,成功改变了输出的内容。关于这具体是怎么做到的,我们会在后续的章节中进行介绍。

参数除了可以分为必选和可选外,还可以分为定长和变长。定长参数的中不能出现空白字符,而变长参数则可以。变长参数通过参数名前后的 … 来指示,例如 echo 指令的参数就是一个变长参数。如果要为定长参数传入带有空白字符的内容,可以使用引号将其包裹起来,例如:

此外,部分选项也可以接受参数。例如,当你安装了翻译插件,你将会获得如下的帮助信息:

在这个例子中,-s 和 -t 都是带有参数的选项。我们使用 -t ja 来指定目标语言为日语,源语言仍然采用了默认行为。

指令开发

一个 Koishi 机器人的绝大部分功能都是通过指令提供给用户的。
Koishi 的指令系统能够高效地处理大量消息的并发调用,
同时还提供了快捷方式、调用前缀、权限管理、速率限制、本地化等大量功能。
因此,只需掌握指令开发并编写少量代码就可以轻松应对各类用户需求。

编写下面的代码,你就实现了一个简单的 echo 指令:

1
2
ctx.command('echo <message>')
.action((_, message) => message)

让我们回头看看这段代码是如何工作的:
.command() 方法定义了名为 echo 的指令,其有一个必选参数为 message
.action() 方法定义了指令触发时的回调函数,第一个参数是一个 Argv 对象,第二个参数是输入的 message
这种链式的结构能够让我们非常方便地定义和扩展指令。
稍后我们将看到这两个函数的更多用法,以及更多指令相关的函数。

定义参数

正如你在上面所见的那样,使用 ctx.command(decl) 方法可以定义一个指令,其中 decl 是一个字符串,包含了 指令名 和 参数列表。

指令名可以包含数字、字母、短横线甚至中文,但不应该包含空白字符、小数点 . 或斜杠 /;小数点和斜杠的用途参见 注册子指令 章节
一个指令可以含有任意个参数,其中 必选参数 用尖括号包裹,可选参数 用方括号包裹;这些参数将作为 action 回调函数除 Argv 以外的的后续参数传入
例如,下面的程序定义了一个拥有三个参数的指令,第一个为必选参数,后面两个为可选参数,它们将分别作为 action 回调函数的第 2~4 个参数:

1
2
ctx.command('test <arg1> [arg2] [arg3]')
.action((_, arg1, arg2, arg3) => { /* do something */ })

变长参数

有时我们需要传入未知数量的参数,这时我们可以使用 变长参数,它可以通过在括号中前置 … 来实现。
在下面的例子中,无论传入了多少个参数,都会被放入 rest 数组进行处理:

1
2
ctx.command('test <arg1> [...rest]')
.action((_, arg1, ...rest) => { /* do something */ })

文本参数

通常来说传入的信息被解析成指令调用后,会被空格分割成若干个参数。
但如果你想输入的就是含有空格的内容,可以通过在括号中后置 :text 来声明一个 文本参数。
在下面的例子中,即使 test 后面的内容中含有空格,也会被整体传入 message 中:

1
2
ctx.command('test <message:text>')
.action((_, message) => { /* do something */ })

指令别名

你可以为一条指令添加别名:

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
2
ctx.command('echo <message:text> 输出收到的信息')
.option('timeout', '-t <seconds> 设定延迟发送的时间')

当然,我们还可以加入具体的用法和使用示例,进一步丰富这则使用说明:

1
2
3
4
ctx.command('echo <message:text>', '输出收到的信息')
.option('timeout', '-t <seconds> 设定延迟发送的时间')
.usage('注意:参数请写在最前面,不然会被当成 message 的一部分!')
.example('echo -t 300 Hello World 五分钟后发送 Hello World')

这时再调用 echo -h,你便会发现使用说明中已经添加了你刚刚的补充文本

事件系统

在上一节中我们了解了指令开发,现在让我们回到更加基础的事件系统。
事件系统在 Koishi 中扮演着底层的角色,它不仅包含由聊天平台触发的会话事件,还包含了监听运行状态的生命周期事件和提供扩展性的自定义事件。

基本用法

让我们先从一个基本示例开始:

1
2
3
4
5
ctx.on('message', (session) => {
if (session.content === '天王盖地虎') {
session.send('宝塔镇河妖')
}
})

上述代码片段实现了一个简单的功能:当任何用户发送「天王盖地虎」时,机器人将发送「宝塔镇河妖」。
如你所见,ctx.on() 方法监听了一个事件。传入的第一个参数 message 是事件的名称,而第二个参数则是事件的回调函数。每一次 message 事件被触发 (即收到消息) 时都会调用该函数。

回调函数接受一个参数 session,称为会话对象。
在这个例子中,我们通过它访问事件相关的数据 (使用 session.content 获取消息的内容),
并调用其上的 API 作为对此事件的响应 (使用 session.send() 在当前频道内发送消息)。

事件与会话构成了最基础的交互模型。这种模型不仅能够处理消息,还能够处理其他类型的事件。我们再给出一个例子:

1
2
3
4
5
6
// 当有好友请求时,接受请求并发送欢迎消息
ctx.on('friend-request', async (session) => {
// session.bot 是当前会话绑定的机器人实例
await session.bot.handleFriendRequest(session.messageId, true)
await session.bot.sendPrivateMessage(session.userId, '很高兴认识你!')
})

像这样由聊天平台推送的事件,我们称之为 会话事件。
除此以外,Koishi 还有着其他类型的事件,例如由 Koishi 自身生成的 生命周期事件,又或者是由插件提供的 自定义事件 等等。
这些事件的监听方式与会话事件基本一致,只不过它们的回调函数接受的参数不同。
例如下面的代码实现了当 Bot 上线时自动给自己发送一条消息的功能:

1
2
3
4
5
6
7
8
// bot-status-updated 不是会话事件
// 所以回调函数接受的参数不是 session 而是 bot
ctx.on('bot-status-updated', (bot) => {
if (bot.status === Status.ONLINE) {
// 这里的 userId 换成你的账号
bot.sendPrivateMessage(userId, '我上线了~')
}
})

在后续的章节中,我们也将介绍更多的事件和会话的使用方法。

监听事件

在上面的例子中,我们已经了解到事件系统的基本用法:使用 ctx.on() 注册监听器。
它的写法与 Node.js 自带的 EventEmitter 类似:第一个参数表示要监听的事件名称,第二个参数表示事件的回调函数。
同时,我们也提供了类似的函数 ctx.once(),用于注册一个只触发一次的监听器;以及 ctx.off(),用于取消一个已注册的监听器。

这套事件系统与 EventEmitter 的一个不同点在于,无论是 ctx.on() 还是 ctx.once() 都会返回一个 dispose 函数,
调用这个函数即可取消注册监听器。因此你其实不必使用 ctx.once() 和 ctx.off()。下面给一个只触发一次的监听器的例子:

1
2
3
4
5
6
7
8
9
10
11
declare module 'koishi' {
interface Events {
foo(...args: any[]): void
}
}
// ---cut---
// 回调函数只会被触发一次
const dispose = ctx.on('foo', (...args) => {
dispose()
// do something
})

事件的命名

无论是会话事件,生命周期事件还是插件自定义的事件,Koishi 的事件名都遵循着一些既定的规范。遵守规范能够让开发者获得一致的体验,提高开发和调试的效率。它们包括:

1.总是使用 param-case 作为事件名
2.通过命名空间进行管理,使用 / 作为分隔符
3.配对使用 xxx 和 before-xxx 命名具有时序关系的事件

举个例子,koishi-plugin-dialogue 扩展了多达 20 个自定义事件。为了防止命名冲突,所有的事件都以 dialogue/ 开头,并且在特定操作前触发的事件都包含了 before- 前缀,例如:

dialogue/before-search: 获取搜索结果前触发
dialogue/search: 获取完搜索结果后触发

前置事件

前面介绍了,Koishi 有不少监听器满足 before-xxx 的形式。
对于这类监听器的注册,我们也提供了一个语法糖,那就是 ctx.before(‘xxx’, callback)。这种写法也支持命名空间的情况:

1
2
3
4
// @errors: 2304
ctx.before('dialogue/search', callback)
// 相当于
ctx.on('dialogue/before-search', callback, true)

默认情况下,事件的多个回调函数的执行顺序取决于它们添加的顺序。先注册的回调函数会先被执行。
如果你希望提高某个回调函数的优先级,可以给 ctx.on() 传入第三个参数 prepend,
设置为 true 即表示添加到事件执行队列的开头而非结尾,相当于 emitter.prependListener()。

对于 ctx.before(),情况则正好相反。默认的行为的先注册的回调函数后执行,
同时 ctx.before() 的第三个参数 append 则表示添加到事件执行队列的末尾而非开头。

触发事件

如果你开发的插件希望允许其他插件扩展,那么触发事件就是最简单的方式。

触发事件的基本用法也都与 EventEmitter 类似,第一个参数是事件名称,之后的参数对应回调函数的参数。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
declare module 'koishi' {
interface Events {
'custom-event'(...args: any[]): void
}
}

// ---cut---
// @errors: 2304
ctx.emit('custom-event', arg1, arg2, ...rest)
// 对应于
ctx.on('custom-event', (arg1, arg2, ...rest) => {})

触发方式

Koishi 的事件系统与 EventEmitter 的另一个区别在于,触发一个事件可以有着多种形式,目前支持 4 个不同的方法,足以适应绝大多数需求。

emit: 同时触发所有 event 事件的回调函数
parallel: 上述方法对应的异步版本
bail: 依次触发所有 event 事件的回调函数;当返回一个 false, null, undefined 以外的值时将这个值作为结果返回
serial: 上述方法对应的异步版本

过滤触发上下文

如果你的自定义事件与某个特定会话相关 (并不需要是会话事件),你可以在触发事件的时候传入一个额外的一参数 session,以实现对触发上下文的过滤:

1
2
3
4
5
6
7
8
9
10
declare module 'koishi' {
interface Events {
'custom-event'(...args: any[]): void
}
}

// ---cut---
// @errors: 2304
// 无法匹配该会话的上下文中注册的回调函数不会被执行 (可能有点绕)
ctx.emit(session, 'custom-event', arg1, arg2, ...rest)

更一般地,即使是不使用会话的事件也能主动选择触发的上下文,其语法完全一致:

1
2
const thisArg = { [Context.filter]: callback }
ctx.emit(thisArg, 'custom-event', arg1, arg2, ...rest)

触发事件时传入的一参数如果是对象,则会作为事件回调函数的 this。
并且如果这个对象有 Context.filter 属性,那么这个属性将被用于过滤触发上下文。
对应的值是一个函数,传入一个上下文对象,返回一个 boolean 表示是否应该在该上下文上触发该事件。
而上面介绍的会话事件只是一种特殊情况而已。

自定义事件

在本节的最后,我们来聊聊插件扩展的事件系统。
如果你是插件的开发者,想要自定义一些事件,那么只需要在你的插件中添加下面的代码:

1
2
3
4
5
6
7
declare module 'koishi' {
interface Events {
// 方法名称对应自定义事件的名称
// 方法签名对应事件的回调函数签名
'kook/message-btn-click'(...args: any[]): void
}
}

如果你监听的事件由其他插件扩展而来,那么你同样需要通过一行额外的代码来导入相应的类型:

1
2
3
4
5
6
7
// 从 @koishijs/plugin-adapter-kook 导入事件类型
// 这里的 import {} from 会在编译时被删除,不会影响运行时的行为
// 请不要写成 import '@koishijs/plugin-adapter-kook'
import {} from '@koishijs/plugin-adapter-kook'

// 如果没有上面的类型导入,下面的代码会报错
ctx.on('kook/message-btn-click', callback)