v1.1.0

开发插件

在 Fraq 中,插件(Plugin)是一个可选、可复用的模块化组件,用于封装特定的功能或一组相关的功能。插件可以包含指令、事件处理器等,并且可以被其他插件依赖和调用,只要声明并提供了对应的服务(Service)。下面我们来介绍一下如何定义和使用插件,以及插件项目的规范。

创建插件项目

Fraq 对于插件项目也提供了一个模板仓库,你可以克隆这个仓库来创建一个新的插件项目。克隆完成后,修改项目的 package.json 文件,其中用中文标注出了若干需要修改的地方,包括:

  • name 字段:插件的包名,必须满足以下条件之一:
    • fraq-plugin- 开头,例如 fraq-plugin-echo
    • 在你的 npm scope 下以 fraq-plugin- 开头,例如 @your-scope/fraq-plugin-echo
  • description 字段:插件的描述信息,应当用中文在 1~2 句话内概括插件的功能。
  • author 字段:插件的作者信息,通常是你的 GitHub 用户名。
  • repository 字段:插件的源代码仓库地址,通常是你的 GitHub 仓库地址,形如 git+https://github.com/your-username/your-repo.git
  • fraq 字段:专属于 Fraq 的插件元信息,目前包含以下字段:
    • category:插件的分类。可用的分类见准备发布章节。

定义与安装插件

插件通过 definePlugin 函数来定义。例如,我们将之前例子中的 Echo 机器人封装成一个插件:

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

export const EchoPlugin = definePlugin({
  name: 'echo', // 插件的名称,需要与包名保持一致
  apply(ctx) {
    ctx.router
      .command('echo')
      .arg('content', param.str())
      .execute((session, { content }) => {
        session.reply(`You said: ${content}`);
      });
  },
});

// 必须同时将插件进行默认导出
export default EchoPlugin;

我们可以看到,插件是一个对象,它有一个 name 字段和一个 apply 方法。name 用于标识插件,apply 会在插件被加载时被调用,并且会传入一个 Context 对象作为参数。在 apply 方法中,我们可以像之前一样定义指令、事件处理器等。

在安装插件时,只需要调用 ctx.install 方法,并传入插件对象即可:

import EchoPlugin from '...';

ctx.install(EchoPlugin);

即使是在你自己开发的机器人中,也强烈建议将功能模块封装成插件,这样可以更好地组织代码,并且在需要的时候可以方便地复用和共享这些功能模块。

接受用户配置

有时候,我们希望插件能够接受用户的配置,以便在不同的场景下有不同的表现。通过 definePlugin 传入的 apply 回调函数可以接受更多的参数作为用户配置。例如,我们想要给机器人的回复消息添加一个用户可配置的表情前缀,可以这样定义插件:

definePlugin({
  name: 'echo',
  apply(
    ctx,
    options: {
      prefixFaceId: number;
    },
  ) {
    ctx.router
      .command('echo')
      .arg('content', param.str())
      .execute((session, { content }) => {
        session.reply(
          msg`${seg.face(options.prefixFaceId)} You said: ${content}`,
        );
      });
  },
});

这样,用户在安装插件时需要进行如下配置:

ctx.install(EchoPlugin, {
  prefixFaceId: 42,
});

definePlugin 并没有限制 apply 函数的参数数量,你可以根据需要定义任意的参数来接受用户配置。你甚至可以定义如下的插件:

definePlugin({
  name: 'example',
  apply(ctx, foo: string, bar: number, baz: boolean) {
    // ...
  },
});

但建议不要定义过多的参数,以免让用户在安装插件时感到困惑。

强烈建议接受一个配置对象作为参数,并且所有的配置项都是 JSON 可序列化的类型。这样可以方便通过 Fraq CLI 来进行插件的安装和配置。

依赖注入

插件不仅能用来封装功能,还可以用来提供服务(Service)供其他插件依赖和调用。

定义服务

假如我们编写了一个插件 a,它提供了一个服务 AlphaService,其他插件可以依赖这个服务来调用它的方法。我们可以这样定义 AlphaService

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

class AlphaService {
  static readonly token = serviceToken<AlphaService>('a/AlphaService');

  alpha() {
    return 'alpha';
  }
}

这里的 service token 用于向 Fraq 声明这个服务的唯一标识符,所有服务 class 都应该具有这样的静态 token 属性。serviceToken 函数接收一个字符串作为参数,这个字符串可以是任意的,但建议使用类似 plugin-name/ServiceName 的格式来命名,例如:

  • fraq-plugin-aAlphaServicea/AlphaService
  • @acme/fraq-plugin-bBetaServiceacme/b/BetaService
  • @fraqjs/plugin-honoHonoServicefraqjs/hono/HonoService

插件可以在定义时声明并通过 ctx.provide 方法提供一个服务:

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

声明依赖

当定义了 AlphaService发布了插件 a 后,其他插件可以将 fraq-plugin-a 添加到它们的 peerDependencies

{
  "peerDependencies": {
    "fraq-plugin-a": "^0.1.0"
  }
}

并且用 inject 字段来声明对这个服务的依赖,并且在 apply 方法中直接通过 ctx 参数来访问这个服务实例:

const PluginB = definePlugin({
  name: 'plugin-b',
  inject: {
    alpha: AlphaService,
  },
  apply(ctx) {
    console.log(ctx.alpha.alpha()); // prints 'alpha'
  },
});

这里的 providesinject 很重要,它告诉 Fraq 哪个插件提供了 AlphaService,哪个插件依赖了 AlphaService,Fraq 会根据这些信息来正确地加载和初始化插件。例如,如果用户同时安装了 PluginAPluginB,Fraq 会确保在加载 PluginB 之前先加载 PluginA,以保证 AlphaService 已经被提供了。如果用户安装了 PluginB 而没有安装 PluginA,Fraq 会抛出一个错误,提示缺少依赖。

除此之外,我们还可以用 optionalInject 来声明可选依赖。在这种情况下,应该将插件添加到 peerDependencies 的同时在 peerDependenciesMeta 中声明它是可选的:

{
  "peerDependencies": {
    "fraq-plugin-a": "^0.1.0"
  },
  "peerDependenciesMeta": {
    "fraq-plugin-a": {
      "optional": true
    }
  }
}

并且使用 serviceToken 来声明依赖,而不是直接使用服务类,这样,如果可选依赖没有被提供,Fraq 不会阻止当前插件启动,而是会为该属性注入 undefined。例如:

import { serviceToken, definePlugin } from '@fraqjs/fraq';
import type { AlphaService } from 'fraq-plugin-a';
// 只通过 import type 导入服务类型
// 这一行会被 tsdown 等工具在编译时移除,因此不会引入对 fraq-plugin-a 的实际依赖

const PluginC = definePlugin({
  name: 'plugin-c',
  optionalInject: {
    alpha: serviceToken<AlphaService>('a/AlphaService'),
    // 这里的 service token 可以在不同模块中通过相同的 key 创建,指向同一个服务
  },
  apply(ctx) {
    if (ctx.alpha) {
      console.log(ctx.alpha.alpha());
    } else {
      console.log('AlphaService is not provided');
    }
  },
});

将服务绑定到作用域

除了直接使用 provide(class, instance) 来提供服务的单个实例之外,Context 还提供了 provide(class, factory) 方法来将服务绑定到作用域,这样每个插件实例都会有一个独立的服务实例。例如:

class AlphaService {
  static readonly token = serviceToken<AlphaService>('a/AlphaService');

  constructor(readonly suffix: string) {
    this.suffix = suffix;
  }

  alpha() {
    return `alpha@${this.suffix}`;
  }
}

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

这里的 scope 是一个 ServiceScope,它包含如下属性:

  • context:依赖该服务的插件所属的 Context 实例。
  • contextPath:依赖该服务的插件所属的 Context 的路径。
  • plugin:依赖该服务的插件的名称。

假设有下面两个插件都依赖了 AlphaService

const PluginB = definePlugin({
  name: 'plugin-b',
  inject: {
    alpha: AlphaService,
  },
  apply(ctx) {
    console.log(ctx.alpha.alpha()); // prints 'alpha@plugin-b'
  },
});

const PluginC = definePlugin({
  name: 'plugin-c',
  inject: {
    alpha: AlphaService,
  },
  apply(ctx) {
    console.log(ctx.alpha.alpha()); // prints 'alpha@plugin-c'
  },
});

Fraq 会为 PluginBPluginC 分别创建一个独立的 AlphaService 实例,因此 ctx.alpha.alpha() 的输出会根据插件的名称而不同。

Service 的生命周期

Fraq 提供了一个 Disposable 接口,如果一个服务实现了这个接口,那么当插件被卸载时,Fraq 会自动调用它的 dispose 方法来进行清理工作。例如:

import { type Disposable, serviceToken } from '@fraqjs/fraq';

class DisposableService implements Disposable {
  static readonly token = serviceToken<DisposableService>(
    'example/DisposableService',
  );

  dispose() {
    console.log('Service is being disposed');
  }
}

esnext.disposable.d.ts 也提供了一个 Disposable 接口,但其声明与 Fraq 的 Disposable 接口不同。

ESNextDisposable 接口声明如下:

interface Disposable {
  [Symbol.dispose](): void;
}

因此,如果你想要在 Fraq 中使用 Disposable 接口来管理服务的生命周期,请确保你显式导入了 @fraqjs/fraq 包中的 Disposable 接口,否则你实现的将是 ESNext 中的 Disposable 接口。

相应地,为了避免这种情况的发生,Fraq 在 Contextprovide 方法中增加了一个检查,如果你提供的服务实现了 ESNextDisposable 但没有实现 Fraq 的 Disposable,Fraq 会抛出一个错误,提示你正确地导入和实现 Fraq 的 Disposable 接口。

那么在 Context 卸载 DisposableService 时,控制台会输出 Service is being disposed。关于 Context 生命周期结束时的行为,请见上下文 (Context) 中的介绍。

start 方法

插件还可以定义一个可选的 start 方法,这个方法会在所有插件都被加载和初始化之后被调用。start 方法通常用于执行一些需要在所有插件都准备好之后才能执行的操作。Context 会在所有插件的 apply 方法都被调用之后,按照与调用 apply 方法相同的顺序来调用插件的 start 方法。

不要滥用 start 方法,只有在确实需要在所有插件都准备好之后才能执行的操作才应该放在 start 方法中。大多数情况下,你应该把插件的逻辑放在 apply 方法中,这样可以更好地利用 Fraq 的依赖注入机制,并且让插件的加载和初始化过程更加清晰和可预测。

为了防止滥用,start 方法只接受一个 Context 参数,而不会接受与 apply 方法相同的用户配置参数。

编写测试

Fraq 建议将测试代码放在 test 目录下。Fraq 中的测试一般分为两大类:

冒烟测试 (Smoke Test)

在一个真实的 Fraq 实例中测试插件的整体功能是否正常,形如在 “快速开始” 中提到的启动脚本。你可以在这个启动脚本中安装你的插件,将其对接到一个真实的 Milky 实例上,实际测试它的功能是否正常。随后,你可以用 tsx 来运行这个脚本,来进行冒烟测试:

npx tsx smoke-test.ts

“Smoke Test” 一词来源于电子硬件行业。在电路板组装完成后,第一个测试就是通电,检查电路板是否有元件因短路而冒烟烧毁。后来这个术语被软件行业借用,指代在一个真实环境中测试软件的基本功能是否正常的测试方法。

你可能不愿意在测试过程中让其他用户意外触发插件,这时你可以给 Context 配置 filter,详见 “上下文 (Context)“ 的相关章节。

Mock 测试 (Mock Test)

在编写机器人时,我们不一定总是拥有 / 愿意运行一个完整的 Milky 协议端来测试我们的代码,在这种情况下,创建一个模拟(Mock)环境是非常有用的。@fraqjs/plugin-mock 提供了这样的功能:它以一个 MockService 拦截所有出站 API 调用并注入事件,让你可以在没有真实协议端的情况下驱动插件、检查其行为。

对于单元测试,建议将测试文件命名为 *.test.ts,并且放在 test 目录下。你可以在 package.json 中添加一个 test script 来运行你的测试,例如:

{
  "scripts": {
    "test": "tsx --test test/**/*.test.ts"
  }
}

准备发布

在完成了一个插件后,你可能会想要将其发布到 npm Registry 上,以便其他人也能使用它。在发布之前,你还需要修改 package.json 中的 fraq.category 字段来指定插件的分类。Fraq 官方提供了以下几种分类:

分类中文名说明
infrastructure基础服务给其他插件提供额外能力,例如数据库、鉴权、网络请求
development开发与运维帮助管理员维护 Fraq,例如控制台、日志、状态查询
management管理工具监听并处理群聊事件,例如群管理、内容审核、入群欢迎
information资讯与生活获取外部公共信息,例如新闻、天气、百科、交通
media媒体与创作处理或生成图片、音频、视频,例如表情包、截图、公式渲染
ai人工智能提供人工智能相关的功能,例如对话、图像生成、语音识别
social社交与互动促进成员之间的参与和关系,例如签到、投票、关系系统
entertainment娱乐与游戏具有抽取、胜负、角色等游戏属性,例如抽奖、竞猜、修仙
game-tools游戏辅助提供与市面游戏相关的辅助功能,例如攻略、战绩查询、音游查分
utilities工具与效率不在上述分类,但解决某个具体问题,例如计算、翻译、定时提醒

发布插件的过程与发布普通的 npm 包是一样的,你需要在你的项目中的 package.json 文件中确保你的插件代码被正确地导出,并且在发布前触发一次构建来生成最终的输出文件。为此,你可以配置 prepack script(这些已经包含在 Fraq 官方插件模板中):

{
  "scripts": {
    "build": "tsdown",
    "prepack": "npm run build"
  }
}

万事俱备,你可以使用 npm publish 命令来发布你的插件了。

2025 年年末开始,npm 增强了对 publish 的安全防护。如果你在发布插件时看到如下的报错:

npm error code ENEEDAUTH
npm error need auth This command requires you to be logged in to https://registry.npmjs.org/
npm error need auth You need to authorize this machine using `npm adduser`
# or
[E404] 404 Not Found - PUT https://registry.npmjs.org/... - Not found

这意味着你需要先使用 npm login 命令登录你的 npm 账号,然后再使用 npm publish 来发布你的插件。npm 要求登录和发布流程都进行 2FA 验证,你也可以考虑通过配置 Granular Access Token 来简化这个流程,详见 npm 官方文档的 Creating and viewing access tokens 章节。

此外,对于已经发布过一次及以上的插件,还可以配置 Trusted Publishers 并且在 GitHub Actions 中发布插件,详见 npm 官方文档的 Trusted publishing for npm packages 章节。

On this page