Fraqv0.7.0

定义指令

在一开始的例子中,我们已经见过了一个简单的指令定义:

ctx.router.command(
  'echo',
  {
    content: param.str(),
  },
  (session, { content }) => {
    // ...
  },
);

这段代码包含了三个要素:指令叫什么、需要什么参数、以及收到这个指令时要执行什么操作。“快速开始” 一节已经对上述代码进行了简要的拆解,下面我们将更深入地介绍 Fraq 中的指令系统的设计与使用方法。

认识参数类型

Fraq 提供了多种参数类型来满足不同的需求:

  • param.literal(literal): 匹配一个字面量参数,只有当输入与指定的字面量完全匹配时才会成功。
  • param.str(): 匹配一个字符串参数。
  • param.num(): 匹配一个数字参数。
  • param.greedy(): 匹配一个贪婪参数,捕获输入的剩余部分。这个参数只能放在最后,并且只匹配后续输入只剩下纯文本的情况。
  • param.union(...literals): 匹配一个联合参数,输入必须与提供的字面量列表中的一个完全匹配。
  • param.segment(type): 匹配一个给定类型的非文本消息段参数,例如 mentionimage。这在解析一些富文本输入时非常有用,例如,一些指令可能会接受一张图片作为参数。

我们用一个例子来完整地展示一下这些参数是如何使用的:

ctx.router.command(
  'foo',
  {
    l: param.literal('hello'),
    s: param.str(),
    n: param.num(),
    u: param.union('option1', 'option2', 'option3'),
    seg: param.segment('mention'),
    g: param.greedy(),
  },
  (session, { l, s, n, u, seg, g }) => {
    // ...
  },
);

满足上述指令的一个用户输入可以是:

foo hello world 123 option2 @someuser this is a greedy parameter

它会被解析成:

{
  "l": "hello",
  "s": "world",
  "n": 123,
  "u": "option2",
  "seg": {
    "type": "mention",
    "data": { "user_id": 123456789, "name": "someuser" }
  },
  "g": "this is a greedy parameter"
}

除此之外,还可以在定义完参数类型后使用 describe 方法来为参数添加描述信息,这些描述信息会在生成的帮助文档中显示,帮助用户更好地理解每个参数的作用和用法,例如:

param.str().describe('要回显的文本内容');

指令组

我们经常会给一个指令定义多个子指令(Subcommand)。在 Fraq 中,可以使用 ctx.router.group 来创建一个指令组,它会返回一个新的 Router 对象,我们可以在其中定义子指令:

ctx.router
  .group('parent')
  .command('child1', { arg1: param.str() }, (session, { arg1 }) => {
    // Handle child1 command
  })
  .command('child2', { arg2: param.num() }, (session, { arg2 }) => {
    // Handle child2 command
  });

这样,我们就定义了一个名为 parent 的指令组,其中包含两个子指令 child1child2。每个子指令都有自己的参数和处理函数。当用户输入 parent child1 <arg1> 时,child1 的处理函数将被调用;当用户输入 parent child2 <arg2> 时,child2 的处理函数将被调用。

指令组可以嵌套:

const parentGroup = ctx.router.group('parent');
parentGroup
  .command('child1', { arg1: param.str() }, (session, { arg1 }) => {
    // Handle child1 command
  })
  .command('child2', { arg2: param.num() }, (session, { arg2 }) => {
    // Handle child2 command
  });

const subGroup = parentGroup.group('sub');
subGroup.command('child3', { arg3: param.greedy() }, (session, { arg3 }) => {
  // Handle child3 command
});

这个 router 可以接受以下的输入:

  • parent child1 <arg1> - 调用 child1 的处理函数
  • parent child2 <arg2> - 调用 child2 的处理函数
  • parent sub child3 <arg3> - 调用 child3 的处理函数

模式匹配

ctx.router 提供了一个名为 rawPattern 的方法,它允许我们直接使用一个参数模式对象从头开始匹配用户输入,而不是定义指令名称和参数类型,通过检测用户输入是否符合这个模式来决定是否调用对应的处理函数。例如,如果我们需要用户先引用(回复)一条消息,再输入指令,我们可以定义如下的 Raw Pattern:

ctx.router.rawPattern(
  {
    reply: param.segment('reply'),
    content: param.greedy(),
  },
  (session, { reply, content }) => {
    // ...
  },
);

这样就可以通过 reply 来拿到用户引用的消息段信息,通过 content 来拿到用户输入的剩余文本信息了。这比一般的指令定义更灵活,因为它不要求用户输入一个特定的指令名称,只要输入符合模式就可以了。

指令重载

模式匹配还可以用于指令重载。在上面的例子中,我们一直都假设每个指令都只有一种参数模式,但实际上有时候我们可能希望一个指令能够接受多种不同的参数模式。例如,我们可能希望 echo 指令既能接受一个字符串参数,也能接受一个图片消息段参数;一个更典型的例子是游戏《Minecraft》中的 tp 指令,它既可以接受玩家名称作为参数,也可以接受坐标作为参数,例如:

tp 1 2 3            // 将玩家传送到坐标 (1, 2, 3)
tp Steve 1 2 3      // 将玩家 Steve 传送到坐标 (1, 2, 3)
tp Steve Alex       // 将玩家 Steve 传送到玩家 Alex 的位置

为了匹配上述不同的参数模式,我们可以创建一个名为 tp 的指令组,并在其中用 rawPattern 方法来定义不同的参数模式:

ctx.router
  .group('tp')
  .rawPattern(
    { x: param.num(), y: param.num(), z: param.num() },
    (session, { x, y, z }) => {
      // Handle tp with coordinates only
    },
  )
  .rawPattern(
    { player: param.str(), x: param.num(), y: param.num(), z: param.num() },
    (session, { player, x, y, z }) => {
      // Handle tp with player and coordinates
    },
  )
  .rawPattern(
    { player1: param.str(), player2: param.str() },
    (session, { player1, player2 }) => {
      // Handle tp with player to player
    },
  );

定义指令重载时需要格外小心,确保不同的参数模式之间没有歧义,否则可能会导致某些输入无法正确匹配到对应的处理函数。下面是一个引起歧义的案例:

ctx.router
  .group('test')
  .rawPattern({ arg: param.str() }, (session, { arg }) => {
    // Handle test with string argument
  })
  .rawPattern({ arg: param.num() }, (session, { arg }) => {
    // Handle test with number argument
  });

在上面的例子中,如果用户输入 test 123,这个输入既符合第一个模式(因为数字也可以被解析为字符串),又符合第二个模式(因为它是一个数字);但匹配字符串的模式先定义,所以它会被优先匹配到,导致数字参数的处理函数永远无法被调用。

关于命令匹配的具体规则,请参考”深入了解“中的介绍

Session 对象

Session 对象代表了一个会话。它包含以下字段:

  • raw:触发该指令的原始 Milky 消息对象,包含来源、发送者、消息段等信息。
  • reply:一个函数,用于回复消息。它接受一个消息段数组作为参数,并将其发送回原会话。

定义过滤器

有时候我们可能希望某些指令只在特定条件下被触发,例如只有管理员才能使用某个指令。我们可以使用 ctx.router.filter,它会返回一个新的 Router 实例,只有满足过滤条件的输入才能触发该实例下定义的指令:

const adminRouter = ctx.router.filter(
  (session) =>
    session.raw.message_scene === 'group' &&
    session.raw.group_member.role !== 'member',
);

adminRouter.command('admincmd', { arg: param.str() }, (session, { arg }) => {
  // This command can only be triggered by group owner and administrators
});

在上面的例子中,我们创建了一个新的 Router 实例 adminRouter,它只会在用户是群主或管理员时触发指令。我们在 adminRouter 上定义了一个指令 admincmd,只有满足过滤条件的输入才能触发这个指令。

对于插件开发者,非必要情况下不应该主动调用 ctx.router.filter 来控制指令的触发权限,因为这会导致指令的可见性和触发条件不能被插件部署方所预期。更好的做法是将过滤指令的任务交给插件部署方,由插件部署方使用 ctx.fork 来创建新的 Context 实例,并在新的实例上安装插件来实现指令的权限控制。

On this page