Fraqv0.14.0
深入了解

命令路由 (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 LocationARouter 会进行如下尝试:

  • [command] hello <name: string> <age: number>:第一个 token teleporthello 不匹配,尝试失败。
  • [rawPattern] <reference: reply segment> <fetch: literal('fetch')>:第一个 token teleport 不是 reply 消息段,尝试失败。
  • [filter] (session) => session.raw.sender_id === 10001:过滤函数返回 true,继续尝试子规则。
    • [command] secret:第一个 token teleportsecret 不匹配,尝试失败。
  • [group] teleport:第一个 token teleportteleport 匹配,消费一个 token,继续尝试子规则。
    • [rawPattern] <location: string> <delay: number>:第二个 token LocationA<location: string> 匹配,继续尝试后续 token,但消息中没有更多 token 了,尝试失败。
    • [rawPattern] <location: string>:第二个 token LocationA<location: string> 匹配,且消息中没有更多 token 了,尝试成功,调用对应的处理函数,后续的规则不再尝试。

白盒函数

Router 对于命令处理而言是一个黑盒,但对于插件开发者而言,有时我们需要获取一些路由规则的信息,因此 Router 提供了下面的白盒函数:

  • router.routes:返回当前 Router 中定义的所有路由规则的名称、参数和处理函数,但不包括 groupfilter 规则下的子规则。
  • router.branches:输入一个 Session,返回这个 Session 有可能触发的所有 commandrawPattern 规则的名称、参数和处理函数,包括 groupfilter 规则下的规则。这个函数不会尝试进行模式匹配,只在过滤函数不满足条件时排除掉对应的规则分支。
  • router.match:输入一个 Session 和消息,尝试对消息内容进行匹配但不触发处理函数,返回匹配到的第一条 commandrawPattern 规则的名称、参数和处理函数。如果没有匹配到任何规则,则返回 undefined
  • router.aliasesOf:输入一个指令名称,以查询某个指令在当前 Router 层级中注册的别名列表。

On this page