Fraqv0.14.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)供其他插件依赖和调用。假设我们有一个 AlphaService,它提供了一个 alpha 方法:

class AlphaService {
  alpha() {
    return 'alpha';
  }
}

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

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

其他插件可以用 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 会抛出一个错误,提示缺少依赖。

使用 requires 声明依赖

可以使用 requires 字段来声明对 AlphaService 的依赖,并通过 ctx.resolve 方法来获取这个服务的实例:

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

需要注意的是,injectrequires 不能同时使用在同一个插件中,因为它们的作用是相同的,都是用来声明插件的依赖关系的;如果同时使用,Fraq 会抛出一个错误提示。推荐使用 inject 这种更加简便的方式来声明和解析依赖。

声明可选依赖

除此之外,我们还可以用 optionalInject 来声明可选依赖,例如:

const PluginC = definePlugin({
  name: 'plugin-c',
  optionalInject: {
    alpha: AlphaService,
  },
  apply(ctx) {
    if (ctx.alpha) {
      console.log(ctx.alpha.alpha());
    } else {
      console.log('AlphaService is not provided');
    }
  },
});

当然也可以用 optionalRequires 来声明可选依赖。Context 还提供了 tryResolveisProvided 方法,分别用于尝试获取一个服务的实例(如果没有提供则返回 undefined)和检查一个服务是否已经被提供了。用例如下:

const PluginC = definePlugin({
  name: 'plugin-c',
  optionalRequires: [AlphaService],
  apply(ctx) {
    if (ctx.isProvided(AlphaService)) {
      // 可以被安全地调用,因为我们已经检查过它是否被提供了
      const alphaService = ctx.resolve(AlphaService);
      console.log(alphaService.alpha());
    } else {
      console.log('AlphaService is not provided');
    }
    // OR:
    const alphaService = ctx.tryResolve(AlphaService);
    if (alphaService) {
      console.log(alphaService.alpha());
    } else {
      console.log('AlphaService is not provided');
    }
  },
});

Service 的生命周期

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

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

class DisposableService implements Disposable {
  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)

使用 node:test 等测试框架并且结合 @fraqjs/mock 来分单元、分场景测试插件中的单个功能是否按照预期工作。@fraqjs/mock 是一个用于模拟 Milky 客户端的库,并且包含了模拟消息发送、API Stub 等功能。

对于单元测试,建议将测试文件命名为 *.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