用户系统管理

TIP

本节所介绍的内容需要你安装一个数据库支持。如果你暂时不打算使用数据库,那么可以略过。

在实际的机器人开发过程中,用户系统往往是必不可少的一环——它在展现机器人功能多样性的同时也避免了某个功能被滥用的情况。本章就来介绍 Koishi 内置的用户系统,以及用户数据是如何与指令调用相联系的。

用户系统

Koishi 内置了下面几个数据库字段:

  • user: 用户表
    • id: string 内部编号
    • name: string 用户昵称
    • ???: string 平台编号
    • flag: number 状态标签
    • authority: number 用户权限
    • usage: Record<string, number> 指令调用次数
    • timers: Record<string, number> 指令调用时间
  • channel: 频道表
    • id: string 频道标识符
    • flag: number 状态标签
    • assignee: string 代理者

下面我们将分别介绍它们。

状态标签

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

  • User.Flag.ignore: 不响应用户的任何消息
  • Channel.Flag.ignore: 不响应频道内的任何消息
  • Channel.Flag.silent: 不主动向频道发送任何消息

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

const { Channel } = require('koishi-core')

// 判断会话用户是否被设置了 ignore 状态
if (session.channel.flag & Channel.Flag.ignore) {}

// 为频道设置一个 ignore 状态
session.channel.flag |= Channel.Flag.ignore

// 为频道取消一个 silent 状态
session.channel.flag &= ~Channel.Flag.silent

用户权限

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

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

当我们调用 database.getUser() 这样的原始接口向数据库请求用户资料时,如果该用户不存在,则会返回 undefined;但当我们调用 session.getUser() 这样的高级接口时,如果该用户不存在,则返回一个默认的 0 级用户对象而不是 null。这种特性能够尽可能减少用户存在性的判断代码——我们认为任何账号都是存在于数据库的,只不过有些是 0 级而已。

在此基础上,我们还扩充出了这样的一套 设计准则

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

这套准则被用于 Koishi 的所有官方包的指令中,但是你也可以手动进行更改,扩展出你所需要的权限系统。稍后,我们将看到这些权限是如何被用于指令调用的。

平台相关字段

Koishi v3 起支持多平台和多账户,因此在用户系统中也需要记录相关的信息。

用户数据中有一个可变字段,它记录了你在特定平台上的 用户编号。例如,如果你的 QQ 号的 111222333,Telegram 号为 444555666,那么你的用户数据可能长这样:

{
  "id": 233,
  // 请注意:即使 QQ 号都是数字,这个字段的值也永远是字符串
  "onebot": "111222333",
  "telegram": "444555666",
  "flag": 0,
  ...
}

频道数据的 id 字段被称为 频道标识符,它由 平台名频道编号 两部分组成,中间由冒号分隔。例如,一个群号为 123456789 的 QQ 群的频道标识符为 onebot:123456789

频道数据的 assignee 字段被称为 代理者,其中存储了负责该频道的机器人在对应平台的用户编号。当这个值与一个机器人无法匹配时,Koishi 会限制该机器人对该频道内信息的处理。这听起来可能有点奇怪,不过请想象一下当你的多个机器人同时加了一个频道时,一旦稍有不慎就可能导致这些机器人多次响应同一个输入,甚至可能导致循环触发等严重的后果。

当然,即使机器人的用户编号与频道的代理者编号不匹配,通过 @ 机器人的方式调用指令仍然是安全的和被 Koishi 允许的。代理者机制可以确保 Koishi 管理的同源机器人中同时最多只有一个会响应来自其所在群的信息。

TIP

上文所提到的 OneBot 是一个 QQ 机器人的协议,也是 Koishi 的早期版本的内置协议。

扩展阅读:为什么其他平台的适配器名字都与平台一致,只有 QQ 对应 Onebot?

自动注册数据

前面已经介绍过了,默认情况下每一名用户都是 0 级权限,每一个群都没有代理者,这虽然安全但也给 Koishi 的搭建带来了不小的麻烦。因此,我们也提供了自动注册的配置项:autoAuthorizeautoAssign。下面展示两个例子:

koishi.config.js
module.exports = {
  // 一旦收到来自未知频道的消息,就自动注册频道数据,代理者为收到消息的人
  autoAssign: true,
  // 一旦收到来自未知用户的消息,就自动注册用户数据,权限等级为 1
  autoAuthorize: 1,
}
koishi.config.js
module.exports = {
  // 为频道 123456789 自动分配代理者
  autoAssign: ses => ses.channelId === '123456789',
  // 为用户 987654321 设置 4 级权限
  // 如果收到了来自未知用户的群消息,那么就自动注册用户数据,权限等级为 1
  autoAuthorize: ses => ses.userId === '987654321' ? 4 : ses.groupId ? 1 : 0,
}

指令调用管理

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

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

设置调用权限

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

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

这样一来,1 级或以下权限的用户就无法调用 echo 指令;2 级权限用户只能调用 echo 指令但不能使用 -t 参数;3 级或以上权限的用户不受限制。对于受限的用户,机器人将会回复“权限不足”。

设置访问次数上限

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

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

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

设置最短触发间隔

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

const { Time } = require('koishi-core')

// 设置 lottery 指令每 60 秒只能调用 1 次
ctx.command('lottery', { minInterval: Time.minute })

这样一来,lottery 指令被调用后 60 秒内,如果再次被调用,将会提示“调用过于频繁,请稍后再试”。当然,notUsageminInterval 也同样生效。

多指令共享调用限制

如果我们希望让多个指令共同同一个调用限制,可以通过 usageName 来实现:

ctx.command('lottery 常驻抽卡', { maxUsage: 10 })
ctx.command('accurate 精准抽卡', { maxUsage: 10, usageName: 'lottery' })

这样一来,就能限制每天的 lottery 和 accurate 指令的调用次数之和不超过 10 了。

按需加载与自动更新

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

观察者对象

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

// 定义一个 items 字段,用于存放物品列表
User.extend(() => ({ items: [] }))

ctx.command('lottery')
  .userFields(['items'])
  .action(({ session }) => {
    // 这里假设 item 是一个字符串,表示抽到的物品
    const item = getLotteryItem()
    // 将抽到的物品存放到 user.items 中
    session.user.items.push(item)
    return `恭喜您获得了 ${item}!`
  })

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

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

当然,如果你确实需要阻塞式地等待数据写入,我们也提供了 user._update() 方法。顺便一提,一旦成功执行了观察者的 _update() 方法,之前的缓冲区将会被清空,因此之后不会重复更新数据;对于缓冲区为空的观察者,_update() 方法也会直接返回,不会产生任何的数据库访问。这些都是我们优化的几个细节。

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

声明所需字段

如果说观察者机制帮我们解决了多次更新和数据安全的问题的话,那么这一节要介绍的就是如何控制要加载的内容。在上面的例子中我们看到了 cmd.userFields() 函数,它通过一个 可迭代对象open in new window 或者回调函数来添加所需的用户字段。同理我们也有 cmd.channelFields() 方法,功能类似。

如果你需要对全体指令添加所需的用户字段,可以使用 Command.userFields()。下面是一个例子:

const { Command } = require('koishi-core')

// 注意这不是实例方法,而是类上的静态方法
Command.userFields(['name'])

app.before('command', ({ session, command }) => {
  console.log('%s calls command %s', session.user.name, command.name)
})

如果要控制中间件能取得的用户数据,可以监听 before-user 和 before-channel 事件,通过修改传入的 fields 参数来添加特定的字段。下面是一个例子:

// 定义一个 msgCount 字段,用于存放收到的信息数量
User.extend(() => ({ msgCount: '' }))

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

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