中间件

有了接收事件和发送消息的能力,似乎你就能完成一切工作了——很多机器人框架也的确是这么想的。
但是从 Koishi 的角度,这还远远不够。
因为当我们面临更复杂的需求时,新的问题也会随之产生:

如何限制消息能触发的应答次数?
如何进行权限管理?
如何提高机器人的性能?

这些问题的答案将我们引向另一套更高级的系统——这也就是中间件的由来。

中间件是对消息事件处理流程的再封装。
你注册的所有中间件将会由一个事件监听器进行统一管理,数据流向下游,控制权流回上游——这可以有效确保了任意消息都只被处理一次。
被认定为无需继续处理的消息不会进入下游的中间件——这让我们能够轻易地实现权限管理。
与此同时,Koishi 的中间件也支持异步调用,这使得你可以在中间件函数中实现任何逻辑。
事实上,相比更加底层地调用事件监听器,使用中间件处理消息才是 Koishi 更加推荐的做法。

基本用法

还记得上一节介绍的基本示例吗?让我们把它改成中间件的形式:

1
2
3
4
5
6
7
8
9
// 如果收到“天王盖地虎”,就回应“宝塔镇河妖”
ctx.middleware((session, next) => {
if (session.content === '天王盖地虎') {
return '宝塔镇河妖'
} else {
// 如果去掉这一行,那么不满足上述条件的消息就不会进入下一个中间件了
return next()
}
})

中间件与事件的写法非常相似,但有三点区别:

事件使用 ctx.on() 注册,而中间件使用 ctx.middleware() 注册
中间件的回调函数接受额外的第二个参数 next,只有调用了它才会进入接下来的流程
中间件支持直接返回要发送的内容,而事件需要手动调用 session.send()

同事件类似,注册中间件时也会返回一个新的函数,调用这个函数就可以取消该中间件:

1
2
3
4
5
// 如果收到“天王盖地虎”,就回应“宝塔镇河妖”
declare const callback: import('koishi').Middleware
// ---cut---
const dispose = ctx.middleware(callback)
dispose() // 取消中间件

异步中间件

中间件也可以是异步的。下面给出一个示例:

1
2
3
4
5
6
7
8
9
10
ctx.middleware(async (session, next) => {
// 获取数据库中的用户信息
// 这里只是示例,事实上 Koishi 会自动获取数据库中的信息并存放在 session.user 中
const user = await session.getUser(session.userId)
if (user.authority === 0) {
return '抱歉,你没有权限访问机器人。'
} else {
return next()
}
})

前置中间件

从上面的两个例子中不难看出,中间件是一种消息过滤的利器。
但是反过来,当你需要的恰恰是捕获全部消息时,中间件反而不会是最佳选择——因为更早注册的中间件可能会将消息过滤掉,导致你注册的回调函数根本不被执行。
因此在这种情况下,我们更推荐使用事件监听器。然而,还存在着这样一种情况:你既需要捕获全部的消息,又要对其中的一些加以回复,这又该怎么处理呢?

听起来这种需求有些奇怪,让我们举个具体点例子吧:
假如你写的是一个复读插件,它需要在每次连续接收到 3 条相同消息时进行复读。我们不难使用事件监听器实现这种逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let times = 0 // 复读次数
let message = '' // 当前消息

ctx.on('message', (session) => {
// 这里其实有个小问题,因为来自不同群的消息都会触发这个回调函数
// 因此理想的做法应该是分别记录每个群的当前消息和复读次数
// 但这里我们假设机器人只处理一个群,简化示例的逻辑
if (session.content === message) {
times += 1
if (times === 3) session.send(message)
} else {
times = 0
message = session.content
}
})

但是这样写出的机器人就存在所有用事件监听器写出的机器人的通病——如果这条消息本身可以触发其他回应,机器人就会多次回应。
更糟糕的是,你无法预测哪一次回应先发生,因此这样写出的机器人就会产生延迟复读的迷惑行为。
为了避免这种情况发生,Koishi 对这种情况也有对应的解决方案,那就是前置中间件。

与前置事件类似,向 ctx.middleware() 传入额外的第二个参数 true 以注册前置中间件。
所有消息会优先经过前置中间件,像事件监听器一样,并且你获得了决定这条消息是否继续触发其他中间件的能力,这是事件监听器所不具有的。

1
2
3
4
5
6
7
8
9
10
11
12
13
let times = 0 // 复读次数
let message = '' // 当前消息

ctx.middleware((session, next) => {
if (session.content === message) {
times += 1
if (times === 3) return message
} else {
times = 0
message = session.content
return next()
}
}, true /* true 表示这是前置中间件 */)

临时中间件

有的时候,你也可能需要实现这样一种逻辑:
你的中间件产生了一个响应,但你认为这个响应优先级较低,希望等后续中间件执行完毕后,如果信号仍然未被截取,就执行之前的响应。
这当然可以通过注册新的中间件并取消的方法来实现,但是由于这个新注册的中间件可能不被执行,你需要手动处理许多的边界情况。

为了应对这种问题 Koishi 提供了更加方便的写法:你只需要在调用 next 时再次传入一个回调函数即可!
这个回调函数只接受一个 next 参数,且只会加入当前的中间件执行队列;无论这个回调函数执行与否,在本次中间件解析完成后,它都会被清除。下面是一个例子:

1
2
3
4
5
6
7
8
ctx.middleware((session, next) => {
if (session.content === 'hlep') {
// 如果该 session 没有被截获,则这里的回调函数将会被执行
return next('你想说的是 help 吗?')
} else {
return next()
}
})

除此以外,临时中间件还有下面的用途。
让我们先回到上面介绍的前置中间件。它虽然能够成功解决问题,但是如果有两个插件都注册了类似的前置中间件,就仍然可能发生一个前置中间件截获了消息,导致另一个前置中间件获取不到消息的情况发生。
但是,借助临时中间件,我们便有了更好的解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
let times = 0 // 复读次数
let message = '' // 当前消息

ctx.middleware((session, next) => {
if (session.content === message) {
times += 1
if (times === 3) return next(message)
} else {
times = 0
message = session.content
return next()
}
}, true)

搭配使用上面几种中间件,你的机器人便拥有了无限可能。
在 koishi-plugin-repeater 库中,就有着一个官方实现的复读功能,它远比上面的示例所显示的更加强大。
如果想深入了解中间件机制,可以去研究一下这个功能的源代码

消息元素

当然,一个聊天平台所能发送或接收的内容往往不只有纯文本。为此,我们引入了 消息元素 (Element) 的概念。

消息元素类似于 HTML 元素,它是组成消息的基本单位。一个元素可以表示具有特定语义的内容,
如文本、表情、图片、引用、元信息等。Koishi 会将这些元素转换为平台所支持的格式,以便在不同平台之间发送和接收消息。

基本用法

一个典型的元素包含名称、属性和内容。在 Koishi 中,我们通常使用 JSX 或 API 的方式创建元素。下面是一些例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// JSX

// 欢迎 @用户名 入群!
session.send(<>欢迎 <at id={userId}/> 入群!</>)

// 发送一张 Koishi 图标
session.send(<img src="https://koishi.chat/logo.png"/>)

// API

// 欢迎 @用户名 入群!
session.send('欢迎 ' + h('at', { id: session.userId }) + ' 入群!')

// 发送一张 Koishi 图标
session.send(h('img', { src: 'https://koishi.chat/logo.png' }))

这两种写法各有优劣,不同人可能会有不同的偏好。但无论哪一种写法都表达了同样的意思。

标准元素

Koishi 提供了一系列标准元素,它们覆盖了绝大部分常见的需求。例如:

at:提及用户
quote:引用回复
img:嵌入图片
message:发送消息

尽管一个平台不太可能支持所有的行为,但适配器对每一个标准元素都进行了最大程度的适配。
例如,对于不支持斜体的平台,我们会将斜体元素转换为普通文本;对于无法同时发送多张图片的平台,我们会将多张图片转换为多条消息分别发送等等。
这样一来,开发者便可以在不同平台上使用同一套代码,而不用担心平台差异。

我们先对比较常用的一些元素进行介绍,你可以稍后在 这个页面 查看所有的标准元素。

提及用户和消息

使用 at 元素提及用户:

1
欢迎 <at id={userId}/> 入群!

使用 quote 元素引用回复:

1
<quote id={messageId}/> 你说得对

嵌入图片和其他资源

使用 img, audio, video 和 file 元素嵌入图片、音频、视频和文件,它们的用法是类似的。这里以图片为例:

1
<img src="https://koishi.chat/logo.png"/>

上面是对于网络图片的用法,如果你想发送本地图片,可以使用 file: URL:

1
2
3
4
5
6
7
8
import { pathToFileURL } from 'url'
import { resolve } from 'path'

// 发送相对路径下的 logo.png
h.image(pathToFileURL(resolve(__dirname, 'logo.png')).href)

// 等价于下面的写法
<img src={pathToFileURL(resolve(__dirname, 'logo.png')).href}/>

如果图片以二进制数据的形式存在于内存中,你也可以直接通过 h.image() 构造 data: URL:

1
2
3
4
5
// 这里的二参数是图片的 MIME 类型
h.image(buffer, 'image/png')

// 等价于下面的写法
<img src={'data:image/png;base64,' + buffer.toString('base64')}/>

进阶用法

在前面的几节中,我们已经了解了基础的交互概念。以他们为基础,Koishi 提供了一些进阶的用法,用于处理真实应用场景中的交互需求。

机器人对象

我们通常将机器人做出的交互行为分为两种:主动交互和被动交互。
主动交互是指机器人主动进行某些操作,而被动交互则是指机器人接收到特定事件后做出的响应。
一个机器人的大部分交互都应该是被动的,而主动交互则可用于一些特殊情况,比如定时任务、通知推送等。

Koishi 提供的交互性 API 可能存在于 ctx,session 和 bot 三种对象中。
其中,上下文对象 ctx 可以在插件参数中取得,会话对象 session 可以在被动交互中取得,而机器人对象 bot 则可以从上述两个对象中访问到:

1
2
3
4
5
// 从 session 中访问 bot
session.bot

// 从 ctx 中访问 bot,其中 platform 和 selfId 分别是平台名称和机器人 ID
ctx.bots[`${platform}:${selfId}`]

广播消息

主动交互中的一种常见需求是同时向多个频道发送消息。Koishi 提供了两套方法来实现消息的广播。最基础的写法是直接使用 bot.broadcast():

1
2
// 一参数填写你要发送到的频道 ID 列表
await bot.broadcast(['123456', '456789'], '全体目光向我看齐')

但这样写需要知道每一个频道对应哪个机器人。对于启用了多个机器人的场景下,这么写就有点不方便了。
幸运的是,Koishi 还有另一个方法:ctx.broadcast()。在启用了数据库的情况下,此方法会自动获取每个频道的受理人,并以对应的账号发送消息:

1
await ctx.broadcast(['telegram:123456', 'discord:456789'], '全体目光向我看齐')

等待输入

当你需要进行一些交互式操作时,可以使用 session.prompt():

1
2
3
4
5
6
7
await session.send('请输入用户名:')

const name = await session.prompt()
if (!name) return '输入超时。'

// 执行后续操作
return `${name},请多指教!`

你可以给这个方法传入一个 timeout 参数,或使用 delay.prompt 配置项,来作为等待的时间。

延时发送

如果你需要连续发送多条消息,那么在各条消息之间留下一定的时间间隔是很重要的:
一方面它可以防止消息刷屏和消息错位 (后发的条消息呈现在先发的消息前面),提高了阅读体验;
另一方面它能够有效降低机器人发送消息的频率,防止被平台误封。这个时候,session.sendQueued() 可以解决你的问题。

1
2
3
4
5
6
// 发送两条消息,中间间隔一段时间,这个时间由系统计算决定
await session.sendQueued('message1')
await session.sendQueued('message2')

// 清空等待队列
await session.cancelQueued()

你也可以在发送时手动定义等待的时长:

1
2
3
4
5
6
7
import { Time } from 'koishi'

// 如果消息队列非空,在前一条消息发送完成后 1s 发送本消息
await session.sendQueued('message3', Time.second)

// 清空等待队列,并设定下一条消息发送距离现在至少 0.5s
await session.cancelQueued(0.5 * Time.second)

事实上,对于不同的消息长度,系统等待的时间也是不一样的,你可以通过配置项修改这个行为:

1
2
3
4
5
delay:
# 消息里每有一个字符就等待 0.02s
character: 20
# 每条消息至少等待 0.5s
message: 500

这样一来,一段长度为 60 个字符的消息发送后,下一条消息发送前就需要等待 1.2 秒了。

执行指令

我们还可以实用 session.execute() 来让用户执行某条指令:

1
2
3
4
5
6
7
8
// 当用户输入“查看帮助”时,执行 help 指令
ctx.middleware((session, next) => {
if (session.content === '查看帮助') {
return session.execute('help', next)
} else {
return next()
}
})