命令路由 (Router)
在此前的例子中,我们已经多次使用过 ctx.router:
ctx.router.command用于定义一个带名称的指令。ctx.router.rawPattern用于直接按照参数模式匹配用户输入。ctx.router.group用于定义指令组和子指令 / 模式。ctx.router.filter用于让一组路由只在特定会话条件下生效。
这些方法最终都在做同一件事:向当前 Router 中添加一条路由规则。当 Context 收到 message_receive 事件时,它会把消息交给自己的 router,由 router 按照定义顺序寻找第一个完整匹配的分支,并调用这个分支对应的处理函数。
一切消息皆 token
要了解 Router 的工作原理,首先要引入 token 的概念。在 Context 调用 router.dispatch 之后,Router 会将消息传入一个分词器(Tokenizer),然后对消息进行流式读取。分词器会将消息按照 token 的顺序进行读取;分词器也支持回退,Router 只要在路由时记录当前的位置,就可以在后续 token 不匹配时回退到这个位置继续尝试其他分支。
token 分两大类:文本 token 和消息段 token。Router 在遇到文本(text)消息段时,会寻找第一个非空字符作为 token 的起始位置,然后把从这个位置开始的连续非空字符作为一个文本 token;在遇到其他类型的消息段时,会把整个消息段作为一个消息段 token。
此外,分词器还支持两种特殊的读取模式:
- greedy 模式,要求当前位置是文本消息段,它会跳过当前位置前面的空格,然后把当前文本消息段中的剩余文本作为一个字符串读取;它不会跨过后续消息段。
- catch-all 模式,会从当前位置开始读取所有剩余文本和非文本消息段,并返回一个消息段数组;由于它会消费全部剩余内容,
param.catchAll()只能作为模式中的最后一个参数注册。
触发方式
Router 在匹配路由时会尝试消费当前路由的触发方式(Activation)。默认触发方式是 direct,也就是什么都不消费,直接继续匹配。因此定义了 ping 指令后,用户发送 ping 就能触发。
触发方式由一个 resolver 决定。resolver 会接收当前路由的描述信息和 Session,并返回一组可尝试的触发方式:
router.setActivationResolver((route, session) => {
if (route.type === 'command' && route.name === 'ping') {
return [{ type: 'prefix', prefix: '/' }];
}
return [{ type: 'direct' }];
});上面的例子会让 ping 只能通过 /ping 触发,其他路由仍然直接触发。
创建 Context 时也可以通过 routing.activationResolver 提供 resolver。Context 会把它设置到自己的 router,并让子 Context 继承同一个 resolver。
Router 会按照数组顺序依次尝试;数组中的触发方式是“或”的关系。每次尝试时,Router 都会创建一个新的分词器,并在对应路由的 activation 位置消费触发方式:
| 触发方式 | 含义 |
|---|---|
{ type: 'direct' } | 不需要任何先导条件即可触发 |
{ type: 'mention' } | 需要 @ 当前机器人触发 |
{ type: 'prefix', prefix: '/' } | 需要用指定文本前缀触发,例如 /ping 中的 / |
{ type: 'mention', prefix: '/' } | 需要先 @ 当前机器人,再使用文本前缀,例如 @bot /ping |
- 对
command而言,activation 位置在完整路由的开头,也就是指令组路径和指令名之前。例如admin ban使用/前缀时,应输入/admin ban,而不是/admin /ban。 - 对含有
param.literal(...)的rawPattern而言,activation 位置在模式中出现的第一个 literal 之前。literal 之前的参数会先正常匹配;即使模式中还有其他 literal,触发方式也只消费一次。 - 不含 literal 的
rawPattern,如果位于group下(即存在指令组路径),activation 位置在指令组路径之前,与command一致。例如位于teleport组下的<location: string>模式使用/前缀时,应输入/teleport LocationA。不含 literal 且不在任何group下的rawPattern不应用 activation 规则,始终直接按照参数模式匹配。activation resolver 不会为这类路由调用,即使配置中存在匹配rawPattern的 prefix、mention 或空触发方式数组,也不会改变其匹配行为。
例如,下面的模式要求先引用一条消息,再使用 fetch 触发后续处理:
router
.rawPattern()
.arg('reference', param.segment('reply'))
.arg('action', param.literal('fetch'))
.arg('target', param.str())
.execute((session, { reference, target }) => {
// ...
});当该路由使用 { type: 'prefix', prefix: '/' } 时,正确输入是 [reply] /fetch target;使用 { type: 'mention' } 时,正确输入是 [reply] @bot fetch target。/[reply] fetch target、[reply] fetch target 都不会匹配。filter 只控制子路由是否对当前会话生效,不改变这个 activation 位置,因此通过 router.filter(...).rawPattern() 注册的模式遵循相同规则。
如果需要同时要求 mention 和文本前缀,可以在 mention 触发方式中提供 prefix:
{ type: 'mention', prefix: '/' }这种触发方式会依次消费对当前机器人的 mention 和 / 前缀,因此指令输入格式是 @bot /command,上述 raw pattern 的输入格式是 [reply] @bot /fetch target。mention、prefix 缺少任意一个,或二者顺序相反,都不会触发。[{ type: 'mention' }, { type: 'prefix', prefix: '/' }] 仍然表示 mention 或 prefix 任意一种均可触发;它不等价于同时要求二者。
resolver 返回空数组 [] 时,表示当前路由在当前会话下没有任何触发方式。
路由规则
正如上面所说,路由规则分如下四大类:
command:先按照指令名称匹配一个 token,再按照给定的参数模式匹配后续 token。rawPattern:按照给定的参数模式匹配 token;如果位于group下,则从指令组路径之后开始匹配。group:先按照指令名称匹配一个 token,再按照给定的子路由规则匹配后续 token。filter:先按照给定的过滤函数判断当前会话是否满足条件,再按照给定的子路由规则匹配消息。
Router 会按照定义的顺序依次尝试这些规则,直到找到一个完整匹配(即匹配到 Pattern 的最后一个元素时恰好到消息末尾)的规则为止。如果某个规则的指令名称匹配但参数不匹配,或者在匹配完 Pattern 之后消息中还剩下没有被消费的 token,Router 会继续尝试后面的规则。
假设我们定义了如下路由规则:
[command] hello <name: string> <age: number>
[rawPattern] <reference: reply segment> <fetch: literal('fetch')>
[filter] (session) => session.raw.sender_id === 10001
[command] secret
[group] teleport
[rawPattern] <location: string> <delay: number>
[rawPattern] <location: string>
[command] home对于某个 ID 为 10001 的用户输入 teleport LocationA,Router 会进行如下尝试:
[command] hello <name: string> <age: number>:第一个 tokenteleport与hello不匹配,尝试失败。[rawPattern] <reference: reply segment> <fetch: literal('fetch')>:第一个 tokenteleport不是reply消息段,尝试失败。[filter] (session) => session.raw.sender_id === 10001:过滤函数返回true,继续尝试子规则。[command] secret:第一个 tokenteleport与secret不匹配,尝试失败。
[group] teleport:第一个 tokenteleport与teleport匹配,消费一个 token,继续尝试子规则。[rawPattern] <location: string> <delay: number>:第二个 tokenLocationA与<location: string>匹配,继续尝试后续 token,但消息中没有更多 token 了,尝试失败。[rawPattern] <location: string>:第二个 tokenLocationA与<location: string>匹配,且消息中没有更多 token 了,尝试成功,调用对应的处理函数,后续的规则不再尝试。
白盒函数
Router 对于命令处理而言是一个黑盒,但对于插件开发者而言,有时我们需要获取一些路由规则的信息,因此 Router 提供了下面的白盒函数:
router.routes:返回当前Router中定义的所有路由规则的名称、参数和处理函数,但不包括group和filter规则下的子规则。router.branches:输入一个Session,返回这个Session有可能触发的所有command和rawPattern规则的名称、参数和处理函数,包括group和filter规则下的规则。这个函数不会尝试进行模式匹配,只在过滤函数不满足条件时排除掉对应的规则分支。router.match:输入一个Session和消息,尝试对消息内容进行匹配但不触发处理函数,返回匹配到的第一条command或rawPattern规则的名称、参数和处理函数。如果没有匹配到任何规则,则返回undefined。router.aliasesOf:输入一个指令名称,以查询某个指令在当前 Router 层级中注册的别名列表。