# 用户管理

在实际的机器人开发过程中,用户系统往往是必不可少的一环——它们确保了机器人能够在可控的范围内运行。然而另一方面,不同功能的用户系统又往往呈现非常不同的特征。因此像 Koishi 这样的框架就必须在内置权限管理的便利性和确保用户系统的自由度之间找到一个平衡。本章就来介绍 Koishi 内置的用户系统实现。

# 内置用户系统

正如上一章中你所见到的那样,Koishi 的数据库是可以扩展的。只要提供了实现,任何数据库都可以用来存储 Koishi 的用户和群数据。内置的字段只有下面几个:

  • user
    • id: QQ 号
    • flag: 状态标签
    • authority: 用户权限
    • usage: 指令调用记录
  • group
    • id: 群号
    • flag: 状态标签
    • assignee: 代理者

下面我们将分别介绍。

# 状态标签

Koishi 使用状态标签来管理用户和群的可能状态。状态标签是一个正整数,它的每一个二进制位表示一种可能状态的开启或关闭。在 Koishi 中,这些状态通过一个枚举类型来进行辨别和修改。以下是目前支持的状态标签列表:

  • UserFlag.ignore: 不响应用户的一切信息
  • GroupFlag.noCommand: 不响应群的一切指令调用
  • GroupFlag.noResponse: 不响应群的一切信息,除了以 at 机器人开始的指令和快捷方式调用
  • GroupFlag.noEmit: 不主动发送任何信息

利用位运算操作符,你可以用下面的方法辨别和修改状态信息:

const { GroupFlag } = require('koishi')

// 判断该群是否被设置了 noResponse 状态
if (meta.$group.flag & GroupFlag.noResponse) {}

// 设置一个 noCommand 状态
meta.$group.flag |= GroupFlag.noCommand

// 取消一个 noEmit 状态
meta.$group.flag &= ~GroupFlag.noEmit

# 用户权限

Koishi 内部有一套默认的权限系统,它存储于用户数据的 authority 字段,并遵循下面的规则:

  • 指数据库中没有的用户拥有 0 级权限
  • 用户的默认权限为 1 级
  • 高权限者能够执行一切低权限者的操作

因此,当 Koishi 使用诸如 database.getUser() 这样的方法向数据库请求用户资料时,如果该用户不存在,则返回一个默认的 0 级用户对象而不是 null。这种特性能够尽可能减少用户存在性的判断代码——我们认为任何账号都是存在于数据库的,只不过有些是 0 级而已。

顺便一提,koishi-plugin-common 包的指令暗含了这样的一套设计准则:

  • 0 级:不存在的用户
  • 1 级:所有用户,只能够接触有限的功能
  • 2 级:高级用户,能够接触几乎一切机器人的功能
  • 3 级:管理员,能够直接操作机器人事务
  • 4 级:高级管理员,能够管理其他账号

这套准则被用于这个包的每个指令中(例如 admin 指令的权限要求便是 4 级),但是你也可以手动进行更改,扩展出你所需要的权限系统。只有 0 级和 1 级用户的概念才属于 Koishi 用户系统的核心规则。

稍后,我们将看到 Koishi 是如何将这些权限用于指令调用的。

# 代理者

群数据的 assignee 字段被称为代理者字段,其中存储了机器人的 QQ 号。当这个值非空时,Koishi 会限制对该群内其他机器人的信息的处理。这听起来可能有点奇怪,不过请想象一下当你的多个机器人同时加了一个群时,一旦稍有不慎就可能导致这些机器人多次响应同一个输入,甚至可能导致循环触发等严重的后果。而代理者机制将会改变这一点。

当获取到群数据后,如果 assignee 字段非空且不为机器人 QQ 号,则强制按照 GroupFlag.noResponse 为真的机制继续处理。因此,对于这种情况,我们仍然可以通过 @ 机器人的方式调用指令,但除此以外的中间件是不会被该机器人处理的(即使是在指令调用中使用 next 注册的临时中间件也是如此)。这套机制可以确保 Koishi 管理的同源机器人中只有一个会响应来自其所在群的信息。

# 指令权限管理

利用上面描述的这套用户系统,我们就可以实现指令的调用权限控制。本节将分别介绍目前支持的权限控制形式。通过向 ctx.command() 传入一个对象作为第二或者第三个参数可以修改有关权限管理的一些设置。

# 设置调用权限

你可以通过这样的方式设置一个指令的调用权限:

// 设置 echo 命令的调用权限为 2 级
ctx.command('echo <message...>', '输出收到的信息', { authority: 2 })
  // 设置 -t 选项的调用权限为 3 级
  .option('-t, --timeout <seconds>', '设定延迟发送的时间', { authority: 3 })

这样一来,1 级或以下权限的用户就无法调用 echo 指令;2 级权限用户只能调用 echo 指令但不能使用 -t 参数;3 级或以上权限的用户不受限制。

# 设置访问次数上限

有些指令(例如签到抽卡点赞,高性能损耗的计算,限制次数的 API 调用等)我们并不希望被无限制调用,这时我们可以设置每天访问次数的上限:

// 设置 lottery 命令每人每天只能调用 10 次
ctx.command('lottery', '抽卡', { maxUsage: 10 })
  // 设置使用了 -s 的调用不计入总次数
  .option('-s, --show', '查看已经抽到的物品列表', { notUsage: true })

这样一来,所有访问 lottery 指令且不含 -s 选项的调用次数上限便被设成了 10 次。当超出总次数后,机器人将回复“调用次数已达上限”。

# 设置最短触发间隔

有些指令(例如高强度刷屏)我们并不希望被短时间内重复调用,这时我们可以设置最短触发间隔:

// 设置 help 命令每 60 秒只能调用 1 次
ctx.command('help', '抽卡', { minInterval: 60000 })

这样一来,help 命令被调用后 60 秒内,如果再次被调用,将会提示“调用过于频繁,请稍后再试”。

# 取消调用相关提示

上面这些提示都是默认显示的,但是你也可以通过设置 commandConfig.showWarningfalse 来手动关闭。关闭后,无论是以上哪种情况,机器人都将直接不响应调用,不会产生任何提示信息。

# 按需加载与更新数据

上面介绍了一些 Koishi 内置的权限管理行为,而接下来将介绍的是开发者如何读取和更新数据。通常来说,中间件、插件的设计可以让机器人的开发变得更加模块化,但是这也带来了数据流向的问题。如果每个中间件分别从数据库中读取和更新自己所需的字段,那会造成大量重复的请求,导致严重的资源浪费;将所有可能请求的数据都在中间件的一开始就请求完成,并不会解决问题,因为一条信息的解读可能只需要少数几个字段,而大部分都是不需要的;更严重的是,后一种做法将导致资源单次请求,多次更新,从而产生种种数据安全性问题。那么针对这些问题,Koishi 又是如何解决的呢?

# 观察者对象

之前我们已经提到过,你可以在 meta.$user 上获得本次事件相关的用户数据,但实际上 meta.$user 能做的远远不止这些。它的本质其实是一个观察者对象。假如我们有下面的代码:

// 定义一个 msgCount 字段,用于存放发送的信息数量
extendUser(() => ({ msgCount: '' }))

// 手动添加要获取的字段,下面会介绍
app.receiver.on('before-user', fields => fields.add('msgCount'))

app.middleware((meta, next) => {
  // 这里更新了 msgCount 数据
  meta.$user.msgCount++
  return next()
})

上面的代码看起来完全无法工作,因为我们都知道 database.setUser() 是一个异步的函数,但是在上面的中间件中我们没有调用任何异步操作。然而如果你运行这段代码,你会发现用户数据被成功地更新了。这就归功于观察者机制。meta.$user 的本质是一个 Proxy,它检测在其上面做的一切更改并缓存下来。当任务进行完毕后,Koishi 又会自动将变化的部分进行更新,同时将缓冲区清空。

这套机制不仅可以将多次更新合并成一次以提高程序性能,更能解决数据错误的问题。如果两条信息先后被接收到,如果单纯地使用 getUser / setUser 进行处理,可能会发生后一次 getUser 在前一次 setUser 之前完成,导致本应有 2 条信息而被计算成了 1 条的问题。而观察者会随时同步同源数据,数据安全得以保证。

你可以在 这里 看到完整的观察者 API。

# 控制要加载的字段

如果说观察者机制帮我们解决了多次更新和数据安全的问题的话,那么这一节要介绍的就是如何将请求压缩到最小。用一句话来说就是:Koishi 会预测可能要用到的用户字段,并提前加载这部分。默认会提前加载的字段有:

  • group.id
  • group.flag
  • group.assignee
  • user.id
  • user.flag
  • user.name
  • user.authority:如果检测到的是指令,且需要一定的权限才能执行
  • user.usage:如果检测到的是指令,且需要判断调用次数或时间间隔

除此以外的字段都是默认不加载的。如果需要加载这些字段,你可以采取这些方法:

  1. 如果用于指令,可以调用 command.userFields() 或者 command.groupFields() 方法,传入一个可迭代对象(Array, Set 等皆可)来添加所需的字段
  2. 除此以外,可以监听 App 的 before-user 和 before-group 事件,通过修改传入的 fields 参数来添加特定的字段(就像上面的例子中演示的那样)