Fraqv0.14.0

定义指令

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

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

这段代码包含了三个要素:指令叫什么、需要什么参数、以及收到这个指令时要执行什么操作。command('echo') 指定名称,后续的 .arg(...) 逐个声明参数,最后的 .execute(...) 注册处理函数。“快速开始” 一节已经对上述代码进行了简要的拆解,下面我们将更深入地介绍 Fraq 中的指令系统的设计与使用方法。

定义参数

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

  • param.literal(literal): 匹配一个字面量参数,只有当输入与指定的字面量完全匹配时才会成功。
  • param.str(): 匹配一个字符串参数。
  • param.num(): 匹配一个数字参数。
  • param.greedy(): 匹配当前文本消息段中的剩余文本,不会跨过后续消息段。
  • param.catchAll(): 匹配所有剩余内容,捕获当前位置之后的所有文本和非文本消息段,并以消息段数组返回。这个参数必须放在最后。
  • param.union(...literals): 匹配一个联合参数,输入必须与提供的字面量列表中的一个完全匹配。
  • param.segment(type): 匹配一个给定类型的非文本消息段参数,例如 mentionimage。这在解析一些富文本输入时非常有用,例如,一些指令可能会接受一张图片作为参数。

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

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

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

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

它会被解析成:

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

描述信息

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

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

使用 refine

如果你使用过 Zod,可能会对它的 refine 方法比较熟悉。Fraq 中的参数也提供了类似的 refine 方法,允许我们在参数被成功解析之后对其进行进一步的验证。例如,如果我们想要一个数字参数必须是正数,我们可以这样定义:

param.num().refine((value) => value > 0);

除了基本的验证之外,refine 方法还能收缩参数类型,例如:

param.str().refine((value): value is `${number}.${number}` => {
  const parts = value.split('.');
  return (
    parts.length === 2 && !isNaN(Number(parts[0])) && !isNaN(Number(parts[1]))
  );
});

这样,参数的类型就从 string 收缩成了一个特定格式的字符串类型 ${number}.${number},这在某些需要特定格式输入的场景下非常有用。

指令组

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

ctx.router
  .group('parent')
  .command('child1')
  .arg('arg1', param.str())
  .execute((session, { arg1 }) => {
    // Handle child1 command
  });

ctx.router
  .group('parent')
  .command('child2')
  .arg('arg2', param.num())
  .execute((session, { arg2 }) => {
    // Handle child2 command
  });

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

指令组可以嵌套:

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

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

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

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

模式匹配

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

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

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

如果需要捕获完整的剩余消息内容,包括文本、提及、图片等消息段,可以使用 param.catchAll()

ctx.router
  .rawPattern()
  .arg('content', param.catchAll())
  .execute((session, { content }) => {
    // content: milky.IncomingSegment[]
  });

param.catchAll() 会消费当前位置之后的所有剩余内容,因此它只能作为一个模式中的最后一个参数注册。

指令重载

模式匹配还可以用于指令重载。在上面的例子中,我们一直都假设每个指令都只有一种参数模式,但实际上有时候我们可能希望一个指令能够接受多种不同的参数模式。例如,我们可能希望 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 方法来定义不同的参数模式:

const tp = ctx.router.group('tp');

tp.rawPattern()
  .arg('x', param.num())
  .arg('y', param.num())
  .arg('z', param.num())
  .execute((session, { x, y, z }) => {
    // Handle tp with coordinates only
  });

tp.rawPattern()
  .arg('player', param.str())
  .arg('x', param.num())
  .arg('y', param.num())
  .arg('z', param.num())
  .execute((session, { player, x, y, z }) => {
    // Handle tp with player and coordinates
  });

tp.rawPattern()
  .arg('player1', param.str())
  .arg('player2', param.str())
  .execute((session, { player1, player2 }) => {
    // Handle tp with player to player
  });

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

const test = ctx.router.group('test');

test
  .rawPattern()
  .arg('arg', param.str())
  .execute((session, { arg }) => {
    // Handle test with string argument
  });

test
  .rawPattern()
  .arg('arg', param.num())
  .execute((session, { arg }) => {
    // Handle test with number argument
  });

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

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

指令元信息

除了参数模式,指令还可以携带额外的元信息,帮助文档生成、指令查询等场景使用。

描述信息

可以使用 .describe() 方法为指令添加描述信息,这与参数的 .describe() 类似:

ctx.router
  .command('ping')
  .describe('测试机器人是否在线')
  .execute((session) => {
    session.reply('pong');
  });

路由标签与元信息

可以使用 .tag() 给指令或模式添加标签。标签不会改变指令本身的解析规则,但可以供帮助生成、路由查询以及 activationResolver 等功能使用:

ctx.router
  .command('ban')
  .tag('admin', 'moderation')
  .execute((session) => {
    // ...
  });

例如,部署方可以在 activationResolver 中读取 route.meta.tags,让所有带有 admin 标签的指令在群聊中必须先提及机器人,或者必须使用 / 前缀。

如果需要附加更通用的路由元信息,可以使用 .meta()

ctx.router
  .command('status')
  .meta({ category: 'system' })
  .execute((session) => {
    // ...
  });

自定义元信息可以在 activationResolverrouter.branches 这类白盒函数中读取。插件中的 ctx.router 会自动带有插件名和当前 Context 名,因此插件作者通常不需要手动写入 plugincontext 元信息。

指令别名

可以使用 .alias() 方法为指令添加别名。当用户通过别名触发指令时,行为与使用原名完全相同:

ctx.router
  .command('help')
  .alias('h', '帮助')
  .execute((session) => {
    // 用户输入 help、h 或 帮助 都会触发
  });

别名不会在 Router 中创建额外的路由条目,而是在匹配时直接与指令名一起检查。匹配优先级为:

  1. 先按注册顺序匹配指令名和别名,第一个匹配成功即停止。
  2. 如果后续注册的指令名与之前指令的别名冲突,之前的别名会被移除并打印警告。
  3. 如果新指令的别名与已有指令名冲突,该别名会被丢弃并打印警告。
  4. 多条指令的别名冲突时,先注册的指令保留该别名。

由于 group 提供命名空间隔离,别名冲突检测仅在同一个 Router 层级内生效。例如,顶层指令 blockgroup('admin') 内别名也为 block 的指令是互不冲突的,它们分别对应输入 blockadmin block

隐藏指令

某些指令只作为内部工具使用,不希望在帮助列表中展示。可以使用 .hide() 标记:

ctx.router
  .command('internal')
  .hide()
  .execute((session) => {
    // 此指令可以被 dispatch 触发,但不会出现在 branches() 返回的列表中
  });

也可以通过 hidden 字段直接设置:

ctx.router.command({
  name: 'internal',
  hidden: true,
  pattern: {},
  execute(session) {
    // ...
  },
});

隐藏指令仍然可以被正常调度,hidden 仅影响 router.branches 列出的结果,不影响实际路由匹配。

Session 对象

Session 对象代表了当前触发指令的消息上下文。指令处理函数可以通过它读取原始消息、回复当前会话,或对当前消息添加回应:

interface Session {
  selfId: number;
  raw: milky.IncomingMessage;
  reply(
    textOrSegments: string | milky.OutgoingSegment_ZodInput[],
    options?: {
      withQuote?: boolean;
      withMention?: boolean;
    },
  ): Promise<{ messageSeq: number }>;
  reaction(type: 'face' | 'emoji', reactionId: string): Promise<void>;
}
  • selfId:当前机器人的 QQ 号。
  • raw:触发该指令的原始 Milky 消息对象,包含来源、发送者、消息段等信息。
  • reply:回复当前会话。第一个参数可以是一个字符串或消息段数组,第二个参数可以控制是否自动引用原消息或提及发送者。它会返回发送成功后的消息序号。
  • reaction:给触发该指令的消息添加表情回应,仅在群聊场景下生效。type 可以是 'face''emoji'reactionId 是对应的表情 ID。

例如:

ctx.router.command('ping').execute(async (session) => {
  await session.reply('pong', {
    withQuote: true,
    withMention: true,
  });

  await session.reaction('face', '42');
});

如果需要更底层或更完整的协议能力,可以直接使用 ctx.client 调用 Milky API。

定义过滤器

有时候我们可能希望某些指令只在特定条件下被触发,例如只有管理员才能使用某个指令。我们可以使用 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('arg', param.str())
  .execute((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