Fraqv0.7.0
深入了解

上下文 (Context)

在此前的例子中,我们已经了解了 Context 的许多用法:

  • ctx.router 是一个 Router 实例,用于定义指令、进行模式匹配等。
  • ctx.client 是一个 MilkyClient 实例,用于调用 Milky 的 API。
  • ctx.logger 是一个 Logger 实例,用于记录日志。
  • ctx.on 用于监听事件,例如 message_receivemessage_recall 等,并返回一个注销监听器的函数。
  • ctx.install 用于安装插件。
  • ctx.providectx.resolve 用于提供和解析服务。
  • ctx.start 用于启动机器人。

正如一开始所提到的,Context 是 Fraq 的核心类,代表了一个机器人实例。它包含了机器人的配置、状态、路由等信息。我们通过调用 ctx 的各种方法来 “组装” 这个机器人,例如定义指令、安装插件、提供服务等。最后,我们调用 ctx.start 来启动这个机器人,使其开始监听用户输入并响应。

创建 Context

最常见的方式是通过 Milky 协议端的地址创建 Context

const ctx = Context.fromUrl('http://localhost:30001', {
  accessToken: '<如果你的 Milky 协议端配置了 token,请在这里添加>',
});

如果你需要接入自定义协议客户端(例如 Mock Client),可以直接传入一个实现了 MilkyClient 接口的对象:

const ctx = Context.fromClient(client);

这种方式只要求客户端提供 Milky API 调用能力和事件流入口,具体通信方式可以由客户端自行决定。

设定计时任务

Context 提供了 timeoutinterval 方法来设定计时任务,例如:

ctx.timeout(() => {
  console.log('这是一个一次性计时器,3 秒后触发');
}, 3000);

ctx.interval(() => {
  console.log('这是一个循环计时器,每 2 秒触发一次');
}, 2000);

这两个方法与 JavaScript 中的 setTimeoutsetInterval 类似,都会返回 NodeJS.Timeout 对象。你可以通过 clearTimeoutclearInterval 来取消这些计时任务,这些任务也会在 Context 停止时自动被清除。

继承与隔离

我们可以通过 ctx.fork 来创建一个继承当前 ctx 的新 Context 实例。这个新实例会与父 Context 使用同一个协议客户端,并且可以访问父 Context 提供的服务;但在新创建出的这个 Context 上进行配置、定义路由以及安装插件等操作不会影响到父 Context。我们结合上一节的依赖注入来看看这个特性:

const PluginA = definePlugin({
  name: 'plugin-a',
  provides: [AlphaService],
  apply(ctx) {
    ctx.provide(AlphaService, new AlphaService());
  },
});

const PluginB = definePlugin({
  name: 'plugin-b',
  requires: [AlphaService],
  apply(ctx) {
    const alphaService = ctx.resolve(AlphaService);
    console.log(alphaService.alpha());
  },
});

const fork1 = ctx.fork('fork1');
fork1.install(PluginA);

const fork2 = ctx.fork('fork2');
fork2.install(PluginB);

ctx.start();

这段代码会抛出错误,因为 PluginB 依赖了 AlphaService,但是 AlphaService 是在 fork1 上提供的,而 fork2 无法访问 fork1 上提供的服务。

乍一看,这个特性似乎限制了我们在不同的 Context 之间共享服务,但实际上它提供了更大的灵活性。我们看下面一个例子:

class OmegaService {
  constructor(public name: string) {}
  omega() {
    return `omega ${this.name}`;
  }
}

const pluginX = definePlugin({
  name: 'plugin-x',
  provides: [OmegaService],
  apply(ctx, omegaName: string) {
    ctx.provide(OmegaService, new OmegaService(omegaName));
  },
});

const pluginY = definePlugin({
  name: 'plugin-y',
  requires: [OmegaService],
  apply(ctx) {
    const omegaService = ctx.resolve(OmegaService);
    console.log(omegaService.omega());
  },
});

const fork1 = ctx.fork('fork1');
fork1.install(pluginX, 'fork one');
fork1.install(pluginY);

const fork2 = ctx.fork('fork2');
fork2.install(pluginX, 'fork two');
fork2.install(pluginY);

ctx.start();

在这个例子中,我们在两个不同的 fork 上提供了不同配置的 OmegaService,并且在同一个 fork 上安装了依赖于 OmegaServicepluginY。当我们运行这段代码时,输出将会是:

omega fork one
omega fork two

此外,在父 Context 上提供的服务是可以被所有子 Context 访问的,并且如果在新的 Context 上提供了一个与父 Context 上同类型的服务,那么在这个新的 Context 上解析这个服务时会优先返回新的服务实例,而不是父 Context 上的服务实例。我们仍然以上面的 OmegaService 为例:

ctx.install(pluginX, 'parent context');
const fork1 = ctx.fork('fork1');
fork1.install(pluginX, 'fork one');
fork1.install(pluginY);
ctx.start();

这段代码的输出将会是 omega fork one,而不是 omega parent context,因为在 fork1 上提供了一个新的 OmegaService 实例,它会覆盖掉父 Context 上的实例。

总之,Context 的继承与隔离特性使得我们可以在不同的 Context 上提供不同配置的服务,并且保证了这些服务之间不会互相干扰。这对于构建复杂的机器人应用非常有用。

事件过滤

ctx.fork 支持传入一个 Filter 对象来过滤事件,例如:

ctx.fork('fork1', {
  message_receive: ({ data }) =>
    data.message_scene === 'group' && data.peer_id === 123456,
  message_recall: ({ data }) =>
    data.message_scene === 'group' && data.peer_id === 123456,
});

在这个例子中,我们创建了一个新的 Context,并且只让它监听来自群聊 123456 的消息接收和消息撤回事件。Filter 对象的每个属性都是一个函数,这些函数会在对应事件发生时被调用,只有当函数返回 true 时,事件才会被传递给这个新的 Context。而对于没有在 Filter 对象中指定过滤函数的事件,则默认会被拦截,不会传递给这个新的 Context

Fraq 提供了一个 filter 命名空间来提供一些预设的过滤函数,例如:

import { filter } from '@fraqjs/fraq';

const filter1 = filter.group(123456, 654321);
// 监听来自群聊 123456 和 654321 的事件,包括:
// - message_receive
// - message_recall
// - group_join_request
// - ...
// 等所有与消息和群聊相关的事件。

const filter2 = filter.friend(10001, 10002);
// 监听来自好友 10001 和 10002 的事件,包括:
// - message_receive
// - message_recall
// - friend_nudge
// - ...
// 等所有与消息和好友相关的事件。

const filter3 = filter.or(filter.group(123456), filter.friend(10001));
// 监听来自群聊 123456 和好友 10001 的事件。

const filter4 = filter.and(filter.group(123456), filter.admin());
// 监听来自群聊 123456 的管理员的消息事件。

const filter5 = filter.not(filter.group(123456));
// 监听不来自群聊 123456 的事件,也就是来自其他群聊、好友或讨论组的事件。

随后,就可以将这些过滤器传入 ctx.fork 来创建新的 Context,例如:

ctx.fork('fork1', filter1).install(pluginA); // pluginA 只会监听来自群聊 123456 和 654321 的事件
ctx.fork('fork2', filter4).install(pluginB); // pluginB 只会监听来自群聊 123456 的管理员的消息事件
ctx.start();

停止 Context

调用 ctx.stop 方法可以停止 Context,此后 Context 将不再响应任何事件,也无法调用 Milky API。Context 在调用 stop 后无法再次启动。stop 的流程是:

  1. 停止 Context 内部通过 timeoutinterval 创建的所有计时任务。
  2. 停止根事件流,例如断开 WebSocket 连接,禁止后续的事件进入 Context
  3. 按照 fork 的顺序反向调用所有子 Contextstop 方法,停止所有子 Context。子 Contextstop 方法会按照同样的流程停止它的子 Context,并且按照 provide 的顺序反向调用自身(子 Context)注册的所有服务的 dispose 方法(如果服务实现了 Disposable 接口),释放所有服务。
  4. 按照 provide 的顺序反向调用自身(根 Context)注册的所有服务的 dispose 方法。

假如我们有这样的 Context 结构:

root
  pluginA (provides ServiceA)
  pluginB (provides ServiceB)
  fork1 (filter: group 123456)
    pluginC (provides ServiceC)
    fork1-1
      pluginD (provides ServiceD)
  fork2
    pluginE (provides ServiceE)

那么停止的顺序会是:

stop event flow [root]
stop event flow [fork1]
stop event flow [fork1-1]
dispose [ServiceD]
dispose [ServiceC]
stop event flow [fork2]
dispose [ServiceE]
dispose [ServiceB]
dispose [ServiceA]

在调用各个 dispose 方法时,Fraq 会捕获并记录任何可能发生的错误,并继续调用下一个 dispose 方法,而不会因为某个服务的 dispose 方法抛出错误而中断整个停止流程。如果有多个 dispose 方法抛出错误,Fraq 会把它们包装成一个 AggregateError 来抛出。

触发规则

了解了如何创建新的 Context 之后,我们接下来重新从头看一下事件进入 Context 之后会发生什么。当协议端向 Fraq 推送事件时,Context 会接收这个事件,并触发通过 ctx.on 注册的事件监听器。对于 message_receive 事件,Context 还会把消息交给自己的 router,尝试匹配已经定义好的指令和模式。在同一个 router 中,Fraq 会按照指令和模式的定义顺序依次尝试匹配,详细的匹配规则可以参考命令路由 (Router) 中的介绍。

如果通过 ctx.fork 创建了新的 Context,父 Context 接收到的事件会按照新 Context 上配置的过滤器继续传递下去。每个 Context 都有自己独立的 router,因此同一条消息可能会被多个满足过滤条件的 Context 分别处理;而在每个 Context 内部,指令仍然遵循上面提到的顺序匹配规则。

On this page