实现机器人
Bot
对应着由 Koishi 操纵的聊天平台机器人账号。其上封装了一系列方法,用于发送消息、获取频道信息等操作。要实现一个聊天平台的 Bot
类,只需要实现这些方法即可。
通用接口
让我们先回忆一下上一节介绍的 ReplBot
:
class ReplBot<C extends Context> extends Bot<C> {
constructor(ctx: C, config: Config) {
super(ctx, config)
this.platform = 'repl'
this.selfId = 'koishi'
ctx.plugin(ReplAdapter, this)
}
async createMessage(channelId: string, content: h.Fragment) {
process.stdout.write(h('', content).toString(true))
process.stdout.write(EOL)
return []
}
}
这里仅仅实现了 createMessage
一个方法,而真正的聊天平台往往具备更多通用能力,例如获取群组、频道、用户信息,添加表态,管理群组成员以及处理邀请等等。
Koishi 提供了一套通用的 机器人接口。适配器应当尽可能地实现这些标准方法,但这并不是必需的。对于平台没有提供能力的 API,可以直接略去实现。
访问内部接口
尽管上面的通用接口足以应对大多数插件的需求,但这并不能将平台的能力发挥到极致。为此,Koishi 也允许 Bot
提供一套内部接口,用于直接调用平台的原生能力。
TIP
为什么不能直接在 Bot
类上添加方法?
首先,插件并不能确定所拿到的 Bot
对象来自哪一个适配器,就算想要用上原生能力也必须强行做类型转换 (你稍后就能看到内部接口是如何解决类型问题的);其次,原生接口可能与通用接口有相同的名称,随着 Koishi 未来进一步扩展通用接口,会有很大可能性引发接口冲突。
在插件中访问
在插件中访问内部接口有两种方法。我们以 Discord 平台为例展示。
第一种是直接通过 bot.internal
属性访问。这个属性在 Bot
基类中的类型是 any
,因此你可以直接使用其上的方法,也可以通过类型断言来获取更好的类型提示:
// 这一行写在文件头
import type { DiscordBot } from '@koishijs/plugin-adapter-discord'
(bot as DiscordBot).internal.getGuild(guildId)
另一种方法是在有 Session
对象的环境中,直接通过 session[platform]
就可以访问到对应适配器的内部接口。这种方式不仅无需类型断言,并且能够直接访问到会话的原始数据。你可以用这种方式对特定平台提供定制化的支持:
// 这一行写在文件头
import {} from '@koishijs/plugin-adapter-discord'
if (session.discord) {
session.discord.getGuild(guildId) // 内部接口
session.discord.t // 原始事件名称
session.discord.d // 原始事件数据
} else {
// 其他平台的处理
}
TIP
在上面的例子中,我们仅仅从 @koishijs/plugin-adapter-discord 中导入了类型。这种情况下插件并不实际依赖 Discord 适配器,因此你只需要将该依赖写入 devDependencies
即可。对于尚未接入 Discord 平台的用户,也不需要为运行你的插件多装一个适配器,仍然确保了安装体积的精简。关于这部分的讨论,可以参考 服务与依赖 一节。
在适配器中访问
内部接口不仅能为插件提供更全面的平台能力,对适配器本身的实现也有很大的帮助。让我们截取 Discord 适配器的一段代码作为示例:
class Internal {
// 这里的实现先略去
}
// 将 Discord 的数据结构转换为通用数据结构
const decodeGuild = (data: Discord.Guild): Universal.Guild => ({
guildId: data.id,
guildName: data.name,
})
class DiscordBot<C extends Context> extends Bot<C> {
constructor(ctx: C, config: Config) {
super(ctx, config)
this.internal = new Internal()
ctx.plugin(DiscordAdapter, this)
}
// 获取群组数据
async getGuild(guildId: string) {
const data = await this.internal.getGuild(guildId)
return decodeGuild(data)
}
// 获取群组列表
async getGuildList() {
const data = await this.internal.getCurrentUserGuilds()
return { data: data.map(decodeGuild) }
}
}
在上面这段代码中,Discord 平台与 Koishi 都定义了一个 Guild
接口。前者包含了更多信息,但由于它们的关键字段不完全相同,因此并不能直接把请求的结果作为通用方法的返回值。
为此,我们实现了一个 decodeGuild
函数,将 Discord 的数据结构转换为 Koishi 的通用数据结构。与此同时,我们把网络请求的部分放在 Internal
类中实现,并在 Bot
类中直接调用内部方法。可以看到,这样编写出来的代码结构相比直接把请求放在 Bot
类中要清晰得多。
实现内部接口
不同的平台由于其 API 的差异性,Internal
类的实现方式也会有所不同。对于简单的平台,你完全可以手动实现每一个内部接口 (甚至可以不实现 Internal
类,就像 REPL 适配器那样);但如果平台本身就有上百个 API,手写每一个内部接口显然既费时又啰嗦。因此,Koishi 提供了一些技巧以简化你的适配工作。我们这里仍然以 Discord 为例。
使用 HTTP 服务
让我们进一步完成上面的代码。Discord 的 API 是 Restful 的,并且需要 Authorization
请求头。我们通过在 Internal
类中传入一个 http
对象简化网络请求的写法:
class Internal {
constructor(private http: HTTP) {}
getGuild(guildId: string) {
return this.http.get(`/guilds/${guildId}`)
}
getCurrentUserGuilds() {
return this.http.get('/users/@me/guilds')
}
}
class DiscordBot<C extends Context> extends Bot<C> {
constructor(ctx: C, config: Config) {
super(ctx, config)
const http = ctx.http.extend({
endpoint: 'https://discord.com/api/v10',
headers: {
Authorization: `Bot ${config.token}`,
},
})
this.internal = new Internal(http)
}
}
ctx.http
是 Koishi 的基础服务,其上封装了一套基于 fetch 的网络请求 API。这里,我们使用 ctx.http.extend()
方法创建了一个新的 HTTP
实例,其上的请求会继承传入的配置。这样我们就无需每次请求都写一遍请求头了。
反射网络请求
在 HTTP
的帮助下,我们甚至可以直接对网络请求进行反射,从而自动生成内部接口。
class Internal {
constructor(private http: HTTP) {}
static define(path: string, methods: Partial<Record<HTTP.Method, string | string[]>>) {
for (const key in methods) {
const method = key as HTTP.Method
for (const name of makeArray(methods[method])) {
this.prototype[name] = async function (this: Internal, ...args: any[]) {
// 将参数填入路径中
const url = path.replace(/\{([^}]+)\}/g, () => {
if (!args.length) throw new TypeError('missing arguments')
return args.shift()
})
return this.http(method, url)
}
}
}
}
}
有了这个 Internal.define()
方法,我们就可以批量定义内部接口了:
Internal.define('/guilds/{guild.id}', {
GET: 'getGuild',
PATCH: 'modifyGuild',
DELETE: 'deleteGuild',
})
Internal.define('/users/@me/guilds', {
GET: 'getCurrentUserGuilds',
})
最后别忘了通过类型合并的方式,将这些方法添加到 Internal
类型上:
interface Internal {
getGuild(guildId: string): Promise<Discord.Guild>
modifyGuild(guildId: string, data: Discord.PartialGuild): Promise<Discord.Guild>
deleteGuild(guildId: string): Promise<void>
getCurrentUserGuilds(): Promise<Discord.Guild[]>
}
上面的代码还没有考虑请求体和异常处理等问题,如果想要深入了解,可以阅读 Discord 适配器的 源码。事实上,Discord 的接口已经是比较复杂的了。相信有了这些技巧的加持,其他平台的适配器你一定也能手到擒来。
注入会话属性
在本节的最后,我们还有一点伏笔没有回收。我们还需要在 Session
对象中注入 discord
属性,以便插件能够访问到内部接口:
declare module 'koishi' {
interface Session {
discord?: Internal & Payload
}
}
这里的 Internal
对应着内部接口,而 Payload
则对应着原始事件数据。当构造会话对象时 (将在下一节具体介绍),我们需要将这些数据注入到 Session
对象中:
session.setInternal('discord', payload)