QQ官方机器人开发

前置准备
Koishi框架

在经历几年的各种折腾后,想必都知道从良是最好的归途。最近知道腾讯官方也做了QQ机器人平台,在 QQ开放平台 提供了官方的 api 文档。已供开发者们使用。

目前门槛较高,建议使用 Koishi 框架 进行开发 Bot,这个框架若熟悉 Node.js 的会比较合适。

%title插图%num

← 这不就是古明地恋的眼睛吗

koishi 也是提供了很多教程和学习的方式,这边汇总一下:

视频

Koishi机器人教程01-Koishi入门 【3集】
「无需写代码, 十分钟搭建自己的QQ机器人! Koishi低代码可视化编程插件blockly介绍」

文档

开发指南
进阶指南
API参考

论坛

https://forum.koishi.xyz/

Q群和其他方式

https://koishi.chat/zh-CN/about/contact.html

说明

Kioshi 官网地址

先体验后开发,直接照着 视频教程 安装,主要是了解它的一个特性

内容里描述了这几个东西:

适配器

通过适配器接入不同平台,可以在各种平台上运行。包括 telegram、微信公众号 等

插件

插件平台提供各种特色的功能,插件平台来自 npm 的数据。机器人的功能离不开插件的实现

沙盒

模拟平台与 Bot 直接的对话,用于测试插件运行的效果

日志

直观看到程序的执行和报错提示,方便通过日志内容去询问他人

部署

安装下载所需工具

PowerShell
corepack enable
corepack prepare --all --activate

设置阿里镜像

PowerShell
npm config set registry https://registry.npmmirror.com

部署 koishi 项目

PowerShell
yarn create koishi
# 如若没有提示 none 完成,使用下方代码
yarn create koishi --mirror https://ghproxy.com/https://github.com
%title插图%num
项目名字可随意,建议规范化

绑定npm 账号 [用于后续发布插件]

PowerShell
# 登录绑定
npm login --registry https://registry.npmjs.org
# [暂时不用] 打包
yarn build
# [暂时不用] 提交包
npm publish --workspace koishi-plugin-插件名 --access public --registry https://registry.npmjs.org
运行项目
获取机器人信息

QQ开放平台 注册账号后,进行对应配置。企业账号和QQ机器人大赛参与的账号可解除所有限制;

%title插图%num

点击创建机器人,填写内容。完成创建

%title插图%num

完成后,在开发设置中,记住官方提供的 APPID、机器人令牌、机器人密钥 信息

%title插图%num
Koishi启动

测试运行

在下载完成的 Koishi 项目中使用 shift + 右键 打开终端,运行如下指令

PowerShell
yarn dev

确定没有问题,Koishi 就会以网页的方式打开

%title插图%num

创建插件

ctrl + c 关闭正在运行的 Koishi 项目,在终端输入指令创建插件

PowerShell
yarn setup 插件包名

会提示 description 是包名的描述

%title插图%num

创建完成后,在 Koishi 项目的 external 文件夹下,就有了对应项目

添加测试插件

运行 Koishi Web端后,在界面的右上角有个插件 符号,点击找到刚刚自己创建的 插件名,添加启用即可

Koishi绑定

在 Koishi 软件的 插件配置 → adapter-qq 项中,填入对应的机器人信息;企业账号需勾选以下 intents 项;

%title插图%num

启用该插件,如若没有问题。左下角会显示登录成功的绿色气泡框和机器人头像;说明已成功对接QQ机器人平台

%title插图%num
开始制作
演示

完成一个最简单的指令回复内容效果,研究它的机制;其中要知道 Koishi 理念是事件驱动,意味着它是遇到事件后才执行特定操作;

因此 机器人是在收到某个事件以后,对特定的事件做出响应,其他情况下一般不响应

apply(ctx: Context, config: Config){} 是存放事件的载体,事件处理函数都放在这个作用域中
interface Config {} 是声明插件使用用户输入的存值
Config: Schema<Config> = Schema.object({}) 是获取插件使用用户输入的值来取值

TypeScript
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());
  })
}

效果展示

%title插图%num
获取用户信息

ctx.command() 相关教程

通过 ctx.command() 的 action() 回调,它接收 Argv 对象,其中 Argv 的 session 属性可以获取用户信息和内容;

session.userId 用于储存用户的信息,有着唯一性。有了用户信息后就可以存储用户对应的数据

下面演示如何获取每个用户使用机器人次数 (仅演示,一般该信息会存在数据库)

TypeScript
// 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];
}

效果展示

%title插图%num
获取用户内容

获取图片

使用 ctx.commend().action(({sission})=>{}) 中的 session.content 可以获取用户消息。得到的内容实际上是混合的:

HTML
<at id="xxx"> 你好 <img src="..."/>








左侧为返回的格式

%title插图%num

官方在更新版本的时候,不一定一直使用固定的格式,例如从 <image url> 改为 <img src> 这就困难了

早期通过正则的方式去获得对应数据,实际上这个是不合规的。正确的方式应该使用 h.select() 的方式获取。使用官方提供的内容选择操作才是开发规范。

错误的

TypeScript
import { Context, Schema } from 'koishi'

// ...
ctx.command('取图').action(async ({session}) => {

    const regex = /src="([^"]+)"/;
    // 匹配双引号之间的内容作为地址
    const match = regex.exec(session.content);
    const imageUrl = match[1];
})

正确的

TypeScript
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 属性,就可以看到里面的对象信息如下:

TypeScript
[
  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 的参数

JavaScript
  ctx
    .command('搜图 <name>')
    .action(async ({ session }, name) => {
      // 取得用户的参数
      const queryName = name.trim()
      // ... 后续处理逻辑
    })

可以使用多个参数,以上同理

强调传入的类型

在传参时,可指定参数的类型,例如 :text:number ,等,

  • text 类型允许传入的参数有间隔,一般作为文本传入,如果不强调 text 类型,当存在空格时会直接作为下个参数接收
  • number 类型会对用户传入的参数做校验,确认输入的为数值类型字符串,并转换为数值类型数据
JavaScript
  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 `目前我只会加减`
      }
    })

效果展示

%title插图%num
异步获取传参

接上文,如果不需要用户立即输入参数,在有效的时间内发送,可以使用 session.prompt(time) 方法

JavaScript
  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 `目前我只会加减`
      }
    })

效果展示

%title插图%num
多群数据分离

思路

ctx.command().action(Argv) 的 群标识id 它位于 Argv.session.guildId 中,而 ctx.middleware(session,next) 的 群标识id 位于 session.guildId 上。
通过它作为数据存储的 key 后,因此它就可以来访问目标群的独立数据。

session.guildId 用于储存用户群的信息编码,一般有着唯一性

该数据为临时数据,可以预先制作一个数据模板。里面存放各个群内的信息对象,并在信息对象中独立传入对应数据。

创建一个对象,里面存放每个群的数据,通过 getGuildData() 访问

TypeScript
// 公共数据模板
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];
}

在每次收到事件或执行指令后,首先去获取当前群的数据。

TypeScript
ctx.command('1a2b').option('/1a2b', '开始玩猜数字').action((Argv) => {

    const temp = getguildData(Argv.session.guild);

    // ...
  })
TypeScript
ctx.middleware(async (session, next) => {

    const temp = getguildData(session.guild);
    console.log(temp.playing);
    
    // ...
  })

接下来的若需要取值进行读取或者修改等操作,直接在对应创建的分群数据信息变量中操作即可。

生命周期

没错,koishi 也有生命周期的设定,不过我们一般常用的只是 ready 事件。对此其他回调可以查阅 官方文档

JavaScript
  async function getNewData() {
    try {
      // 初始化对应数据
     const res = await ctx.http.get("xxx")
   }
  }
  
  // 在实例加载完成后触发回调
  ctx.on('ready', () => {
    getNewData()
  })
自动更新配置项

如果需要插件通过指令修改自身对应的配置项,可以使用 rootFoot.scope.update(ctx.config, true) 方案,即使它并不是最佳方案,是实验性的

JavaScript
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块钱`
    })
}

效果展示

%title插图%num
发送 MarkDown消息

QQ官方机器人平台支持 md语法 格式的消息发送,但由于对低版本的QQ不具备兼容性。请酌情配置

需要先在 QQ官方机器人管理端 提交申请,申请完成后在配置里 创建模板;一个机器人最多可创建5个MD模板

%title插图%num

设置模板样式

%title插图%num

模板审核通过后,对应模板的数据,使用适配器发送消息,写入模板中对应的值。

JavaScript
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));
  }
}