在经历几年的各种折腾后,想必都知道从良是最好的归途。最近知道腾讯官方也做了QQ机器人平台,在 QQ开放平台 提供了官方的 api 文档。已供开发者们使用。
目前门槛较高,建议使用 Koishi 框架 进行开发 Bot,这个框架若熟悉 Node.js 的会比较合适。
← 这不就是古明地恋的眼睛吗
koishi 也是提供了很多教程和学习的方式,这边汇总一下:
视频
Koishi机器人教程01-Koishi入门 【3集】
「无需写代码, 十分钟搭建自己的QQ机器人! Koishi低代码可视化编程插件blockly介绍」
文档
论坛
Q群和其他方式
https://koishi.chat/zh-CN/about/contact.html
先体验后开发,直接照着 视频教程 安装,主要是了解它的一个特性
内容里描述了这几个东西:
适配器
通过适配器接入不同平台,可以在各种平台上运行。包括 telegram、微信公众号 等
插件
插件平台提供各种特色的功能,插件平台来自 npm 的数据。机器人的功能离不开插件的实现
沙盒
模拟平台与 Bot 直接的对话,用于测试插件运行的效果
日志
直观看到程序的执行和报错提示,方便通过日志内容去询问他人
安装下载所需工具
corepack enable
corepack prepare --all --activate
设置阿里镜像
npm config set registry https://registry.npmmirror.com
部署 koishi 项目
yarn create koishi
# 如若没有提示 none 完成,使用下方代码
yarn create koishi --mirror https://ghproxy.com/https://github.com
绑定npm 账号 [用于后续发布插件]
# 登录绑定
npm login --registry https://registry.npmjs.org
# [暂时不用] 打包
yarn build
# [暂时不用] 提交包
npm publish --workspace koishi-plugin-插件名 --access public --registry https://registry.npmjs.org
在 QQ开放平台 注册账号后,进行对应配置。企业账号和QQ机器人大赛参与的账号可解除所有限制;
点击创建机器人,填写内容。完成创建
完成后,在开发设置中,记住官方提供的 APPID、机器人令牌、机器人密钥 信息
测试运行
在下载完成的 Koishi 项目中使用 shift + 右键 打开终端,运行如下指令
yarn dev
确定没有问题,Koishi 就会以网页的方式打开
创建插件
ctrl + c 关闭正在运行的 Koishi 项目,在终端输入指令创建插件
yarn setup 插件包名
会提示 description 是包名的描述
创建完成后,在 Koishi 项目的 external 文件夹下,就有了对应项目
添加测试插件
运行 Koishi Web端后,在界面的右上角有个插件 符号,点击找到刚刚自己创建的 插件名,添加启用即可
在 Koishi 软件的 插件配置 → adapter-qq 项中,填入对应的机器人信息;企业账号需勾选以下 intents 项;
启用该插件,如若没有问题。左下角会显示登录成功的绿色气泡框和机器人头像;说明已成功对接QQ机器人平台
完成一个最简单的指令回复内容效果,研究它的机制;其中要知道 Koishi 理念是事件驱动,意味着它是遇到事件后才执行特定操作;
因此 机器人是在收到某个事件以后,对特定的事件做出响应,其他情况下一般不响应
apply(ctx: Context, config: Config){} 是存放事件的载体,事件处理函数都放在这个作用域中
interface Config {} 是声明插件使用用户输入的存值
Config: Schema<Config> = Schema.object({}) 是获取插件使用用户输入的值来取值
import { Session } from 'inspector';
import { Bot, Context, Schema } from 'koishi'
export const name = 'sayhi-demo'
export interface Config {
uname?: String
}
// 用户配置项 需要在 Config 声明
// string() 文本类型数据
// description() 描述
// default() 默认值
// required() 必填 [不能与 default() 一起使用]
export const Config: Schema<Config> = Schema.object({
uname: Schema.string().description('主人名').default('smm'),
})
// config 获取用户手动填入的配置项
export function apply(ctx: Context, config: Config) {
// write your plugin here
// command 接收指令 action 回调函数
ctx.command('sayhi', '说你好').action((Argv) => {
// 发送信息
Argv.session.send("Hello word" + config.uname.toString());
})
}
效果展示
通过 ctx.command() 的 action() 回调,它接收 Argv 对象,其中 Argv 的 session 属性可以获取用户信息和内容;
session.userId 用于储存用户的信息,有着唯一性。有了用户信息后就可以存储用户对应的数据
下面演示如何获取每个用户使用机器人次数 (仅演示,一般该信息会存在数据库)
// config 获取用户手动填入的配置项
export function apply(ctx: Context, config: Config) {
// write your plugin here
// 接收到消息事件
ctx.command('getTime').action(async (Argv) => {
// console.log(Session);
// Argv.session.content 发送消息者发送的内容
// Argv.session.userId 发送消息者的信息
// Argv.session.messageId 发送的消息的信息
// ...
return `当前已对话次数:${getCallTimeData(Argv.session.userId)}\n`
// return next();
})
}
const callList = {};
// 统计次数
function getCallTimeData(userId) {
userId = String(userId)
// 若不存在 赋值 0
if (!callList[userId]) callList[userId] = 0;
// 自增并返回
return ++callList[userId];
}
效果展示
获取图片
使用 ctx.commend().action(({sission})=>{}) 中的 session.content 可以获取用户消息。得到的内容实际上是混合的:
<at id="xxx"> 你好 <img src="..."/>
左侧为返回的格式
官方在更新版本的时候,不一定一直使用固定的格式,例如从 <image url> 改为 <img src> 这就困难了
早期通过正则的方式去获得对应数据,实际上这个是不合规的。正确的方式应该使用 h.select() 的方式获取。使用官方提供的内容选择操作才是开发规范。
错误的
import { Context, Schema } from 'koishi'
// ...
ctx.command('取图').action(async ({session}) => {
const regex = /src="([^"]+)"/;
// 匹配双引号之间的内容作为地址
const match = regex.exec(session.content);
const imageUrl = match[1];
})
正确的
import { Context, Schema, h } from 'koishi'
// ...
ctx.command('取图').action(async ({session}) => {
const [img] = h.select(session.elements, 'img');
const imageUrl = img.attrs.src;
})
打印一下 session.element 属性,就可以看到里面的对象信息如下:
[
Element {
type: 'at',
attrs: { id: 'xxx' },
children: []
},
Element {
type: 'text',
attrs: { content: ' 你好 ' },
children: []
},
Element {
type: 'img',
attrs: {
src: 'https://gchat.qpic.cn/...'
},
children: []
}
]
获取内容、获取艾特信息
如上所示,修改 h.select(session.element,””) 参数二 的值,即可获取对应的内容。注意它返回的是数组
- text 获取内容
- at 获取艾特信息
更多内容可以查看 官方文档
需求
指定一个位置传入用户的参数,例如:/搜图 六花,Koishi 去获取 六花 这个需要接收的参数
方案
使用 command('搜图 <name>').action(({session},name)=>{})
的方式去获得 name
的参数
ctx
.command('搜图 <name>')
.action(async ({ session }, name) => {
// 取得用户的参数
const queryName = name.trim()
// ... 后续处理逻辑
})
可以使用多个参数,以上同理
强调传入的类型
在传参时,可指定参数的类型,例如 :text
,:number
,等,
text
类型允许传入的参数有间隔,一般作为文本传入,如果不强调text
类型,当存在空格时会直接作为下个参数接收number
类型会对用户传入的参数做校验,确认输入的为数值类型字符串,并转换为数值类型数据
ctx
.command('计算 <a:number> <type> <b:number>')
.action(async ({ session }, a, type, b) => {
switch (type) {
case "加":
return `结果为:${a + b}`
case "减":
return `结果为:${a - b}`
default:
return `目前我只会加减`
}
})
效果展示
接上文,如果不需要用户立即输入参数,在有效的时间内发送,可以使用 session.prompt(time)
方法
ctx
.command('计算 <type>')
.action(async ({ session }, type) => {
await session.send("请输入第一个数")
const a = Number(await session.prompt(10000))
await session.send("请输入第二个数")
const b = Number(await session.prompt(10000))
if (isNaN(a) || isNaN(b)) {
return `请输入数值`
}
switch (type) {
case "加":
return `结果为:${a + b}`
case "减":
return `结果为:${a - b}`
default:
return `目前我只会加减`
}
})
效果展示
思路
ctx.command().action(Argv)
的 群标识id 它位于 Argv.session.guildId
中,而 ctx.middleware(session,next)
的 群标识id 位于 session.guildId
上。
通过它作为数据存储的 key 后,因此它就可以来访问目标群的独立数据。
session.guildId 用于储存用户群的信息编码,一般有着唯一性
该数据为临时数据,可以预先制作一个数据模板。里面存放各个群内的信息对象,并在信息对象中独立传入对应数据。
创建一个对象,里面存放每个群的数据,通过 getGuildData()
访问
// 公共数据模板
const guildData = {}
// 获取分群数据
function getguildData(guildId) {
// 私聊作为 10000 标识用于规避,可设置别的标识
const info = guildId || 10000
// 若不存在 赋值模板样式
if (!guildData[info]) guildData[info] = {
// 存的值
a:'123',
b:'234',
// 存的方法
getData(){
// 直接操作构造函数值
return this.a
}
};
// 返回分群数据
return guildData[info];
}
在每次收到事件或执行指令后,首先去获取当前群的数据。
ctx.command('1a2b').option('/1a2b', '开始玩猜数字').action((Argv) => {
const temp = getguildData(Argv.session.guild);
// ...
})
ctx.middleware(async (session, next) => {
const temp = getguildData(session.guild);
console.log(temp.playing);
// ...
})
接下来的若需要取值进行读取或者修改等操作,直接在对应创建的分群数据信息变量中操作即可。
没错,koishi
也有生命周期的设定,不过我们一般常用的只是 ready
事件。对此其他回调可以查阅 官方文档
async function getNewData() {
try {
// 初始化对应数据
const res = await ctx.http.get("xxx")
}
}
// 在实例加载完成后触发回调
ctx.on('ready', () => {
getNewData()
})
如果需要插件通过指令修改自身对应的配置项,可以使用 rootFoot.scope.update(ctx.config, true)
方案,即使它并不是最佳方案,是实验性的
export const Config: Schema<Config> = Schema.object({
total: Schema.number().default(100).description('初始金额')
})
export function apply(ctx: Context, config: Config) {
let rootFoot = null
// 准备就绪
ctx.on('ready', () => {
rootFoot = ctx
})
ctx
.command('查看余额')
.action(async ({ session }) => {
return '现在的总余额为:' + config.total
})
ctx
.command('花钱 <buy>')
.action(async ({ session }, buy) => {
ctx.config.total--
// 直接修改配置项文件 [实验性]
rootFoot.scope.update(ctx.config, true)
return `${h.image('xxx.png')}你购买 ${buy ? buy : '某样东西'} 花了1块钱`
})
}
效果展示
QQ官方机器人平台支持 md语法 格式的消息发送,但由于对低版本的QQ不具备兼容性。请酌情配置
需要先在 QQ官方机器人管理端 提交申请,申请完成后在配置里 创建模板;一个机器人最多可创建5个MD模板
设置模板样式
模板审核通过后,对应模板的数据,使用适配器发送消息,写入模板中对应的值。
import { Context, Schema } from 'koishi'
import { config } from 'process'
import { QQBot } from '@koishijs/plugin-adapter-qq'
// ...
export function apply(ctx: Context, config: Config) {
// write your plugin here
ctx.command('md').action(async ({ session }) => {
// ...
const params = [
{key:'value_1_pic',values:['https://.../xx.jpg']},
{key:'value_1_title',values:['这是文本']},
// ...
]
// 以 md 消息发送
const md = {
msg_id: session.messageId,
msg_type: 2,
markdown: {
custom_template_id: "102077582_1702986560" as unknown as number,
params:params as unknown as any
}
}
await (session.bot as unknown as QQBot).internal.sendMessage(session.channelId, md);
})
}
koishi 官方提供了一个较为友好的文档。介绍如何合理的进行永久化的存储操作。官方文档
在大多数持久化场景下,要存储的数据都是与实例相关、且不会被占用的文件。
这种情况下,我们建议将数据存放于实例目录下的特定目录中。根据数据的用途,这个目录可以是:
data
:存放数据文件 (可以在不同实例间迁移)。cache
:存放缓存文件 (没有迁移价值的持久化数据)。temp
:存放临时文件 (非持久化数据,下一次启动即会失效)。
这样做的好处是,当你需要迁移实例时,只需要将 data
目录复制到新的实例目录下即可
ctx.baseDir
koishi 提供了一个 ctx.baseDir
的值用于获取实例目录。必要时使用 path.join 进行路径拼接。
即,我们插件若有存储数据的需求,除了使用官方的数据库,也可以将文件按类型放在上述的几个文件夹中;
具体代码可参考如下:
import * as fs from 'fs/promises';
import * as path from 'path';
async function setOrCreateFile(path: string, data: string): Promise<void> {
try {
await fs.writeFile(path, data);
} catch (error) {
await createDirectoryPath(path);
await fs.writeFile(path, data);
}
}
async function getOrCreateFile(path: string, isArr = false): Promise<string> {
try {
await fs.access(path);
return await fs.readFile(path, 'utf-8');
} catch (error) {
await createDirectoryPath(path);
await fs.writeFile(path, isArr ? '[]' : '{}');
return await fs.readFile(path, 'utf-8');
}
}
async function createDirectoryPath(filePath: string): Promise<void> {
const directoryPath = path.dirname(filePath);
await fs.mkdir(directoryPath, { recursive: true });
}
export { setOrCreateFile, getOrCreateFile };
import { Context, Schema } from 'koishi'
import fs from 'fs/promises'
import path from 'path'
import { getOrCreateFile, setOrCreateFile } from './fileUtils';
export interface Config {
fishData: string,
achieveData: string,
}
export const Config: Schema<Config> = Schema.object({
fishData: Schema.string().default('/data/fishData/data.json').description('玩家数据存放路径'),
achieveData: Schema.string().default('/data/fishData/achieveData.json').description('玩家成就数据存放路径')
})
export function apply(ctx: Context, config: Config) {
// 写入 koishi 下的目标路径文件
async function setBaseDirData(upath: string, strJson: string) {
return await setOrCreateFile(path.join(ctx.baseDir, upath), strJson);
}
// 获取 koishi 下的目标路径文件
async function getBaseDirData(upath: string) {
return await getOrCreateFile(path.join(ctx.baseDir, upath));
}
}