lemon-bot
Version:
a qq bot framework
531 lines (344 loc) • 23.7 kB
Markdown
<h1>🍋 Lemon-Bot</h1>
一个基于酷Q和CoolQ HTTP API插件的QQ机器人Nodejs开发框架。
- 支持多命令匹配、命令自定义解析
- 使用修饰器进行灵活的命令触发控制
- 支持会话上下文功能
- 支持多机器人运行
- and more ...
## 前言
该项目仍处于早期开发版,故版本变动较为频繁,但会尽可能保证基本开发方式不变,具体变动见 [Changelog](https://github.com/XHMM/lemon-bot/blob/master/CHANGELOG.md)
## 准备
1. 安装 [nodejs](https://nodejs.org/en/download/) (该框架基于v10.16.3版本进行开发与测试)
2. 安装 酷Q 和 HTTP插件:
- Windows: 首先前往酷Q的[版本发布](https://cqp.cc/b/news)页面下载(Air为免费版,Pro为收费版),下载后解压启动 `CAQ.exe` 或 `CQP.exe` 并登陆你的QQ机器人账号。然后根据[CoolQ HTTP API插件文档](https://cqhttp.cc/docs/)中的"手动安装"部分的教程进行插件安装。
- Linux / Mac: 查看[CoolQ HTTP API插件文档](https://cqhttp.cc/docs/)中的"使用Docker"部分的教程进行安装
3. 修改HTTP插件的配置文件: 每个账号的配置文件存放路径一般为 `/path/to/酷Q/data/app/io.github.richardchien.coolqhttpapi/config/QQ号.json` (也可能是 `.ini` 格式)。下面以 `.json` 格式来说明在使用该框架时必须要进行修改的配置项(其他配置说明可见[插件文档](https://cqhttp.cc/docs/#/Configuration?id=配置项)):
```metadata json
{
"port": 5700, // HTTP插件的运行端口,请自行指定
"use_http": true, // 须设为true
"post_url": "http://127.0.0.1:8888/coolq", // 这是node服务器的运行地址以及监听的路由,你只可以修改端口号,请勿修改路由地址
"access_token": "", // 可选。若指定此值,则使用框架时也须配置
"secret": "", // 可选。若指定此值,则使用框架时也须配置
"post_message_format": "array" // 请将该选项务必设为array
}
```
4. 安装该node模块: `npm i lemon-bot`
5. 由于该框架使用了 [decorator](https://www.typescriptlang.org/docs/handbook/decorators.html) 语法:
- 若你是使用 Javascript 进行开发,则需要[配置babel](https://babeljs.io/docs/en/babel-plugin-proposal-decorators)以支持该特性。
- 若是使用 Typescript,则需要在`tsconfig.json`中启用decorator:
```metadata json
{
"compilerOptions": {
// ...
"experimentalDecorators": true
}
}
```
6. 该框架使用 `debug` 模块进行日志打印。在开发阶段建议开启日志输出来方便调试和排错:设置环境变量 `DEBUG=lemon-bot*` ,然后运行主程序即可。
## Demo
在 `index.ts` 文件里写入下述代码:
```js
import { Command, RobotFactory, HttpPlugin } from "lemon-bot";
class SimpleCommand extends Command {
// 当机器人接收到"测试"或是"test"文本后,会触发该命令
directive() {
return ["测试", "test"];
}
// 当消息是私发给机器人时,会使用user函数进行响应
user({ fromUser }) {
return "你好呀" + fromUser;
}
// 当机器人在QQ群内并检测到上述指令后,会使用group函数进行响应
group({ fromUser, fromGroup }) {
// 返回值为数组时,机器人会连续发送多条消息。
return ["触发群是" + fromGroup, "触发用户是" + fromUser];
}
}
const robot = RobotFactory.create({
port: 8888, // node应用的运行端口。需要和插件配置文件的post_url对应
robot: 1326099664, // 机器人QQ号
httpPlugin: new HttpPlugin("http://localhost:5700"), // 用于调用HTTP Plugin API
commands: [new SimpleCommand()] // 该机器人可处理的命令
});
robot.start(); // 启动
```
在运行该代码前,请确保:
- 酷Q和HTTP插件处于运行状态,且上述代码中的 `robot` 值为当前登录的机器人QQ
- 安装要求修改了HTTP插件的配置文件
然后在命令行内输入 `npx ts-node index.ts` 即可启动机器人。
一对一聊天测试:用一个加了机器人为好友的QQ号向机器人发送 "测试" 或 "test" ,会发现机器人返回了 "你好呀[你的QQ号]"。
群聊测试:将该机器人拉入群内,然后在群内发送"测试"或"test",会发现机器人连续返回了两条消息: "触发群是[机器人所在Q群]" 和 "触发用户是[你的QQ号]"。
## 案例
- [小心机器人](https://github.com/XHMM/bot-xiaoxin)
## API文档
Tips:下述涉及的类型定义和enum定义可直接前往源码内查看,可帮助更好的理解其含义。
### Class RobotFactory
该类用于机器人的创建。
#### static create(config: CreateParams): CreateReturn
`CreateParams`:该函数所需参数是一个对象,接受如下属性:
| key | type | description | optional |
| ---------- | ---------- | ------------------------------------------------------ | -------- |
| port | number | node服务器的运行端口 | |
| robot | number | 机器人QQ号 | |
| httpPlugin | HttpPlugin | HTTP插件实例 | |
| commands | Command[] | 需要注册的命令 | |
| session | Session | 传入该参数运行使用session函数 | optional |
| secret | string | 须和HTTP插件配置文件值保持一致,用于对上报数据进行验证 | optional |
| context | any | 该属性会作为Command继承类的成员属性,默认值为null | optional |
`CreateReturn`:该函数的返回值是一个对象,包含如下属性
| key | type | description |
| ----- | ----------------- | ----------- |
| start | ()=>Promise<void> | 启动机器人 |
| stop | () => void | 停止机器人 |
Example:
```js
const robot = RobotFactory.create({
port: 8888,
robot: 1326099664,
httpPlugin: new HttpPlugin("http://localhost:5700"),
commands: [new SimpleCommand()]
});
robot.start();
```
### Class Command
该类需要被继承使用,用来创建命令。下面将以继承类的角度进行描述:
#### 继承类的基本结构:
```typescript
// 导入基类
import { Command} from 'lemon-bot';
// 导入ts类型定义提升开发体验
import { ParseParams, ParseReturn, UserHandlerParams, GroupHandlerParams, SessionHandlerParams, HandlerReturn } from 'lemon-bot'
class MyCommand extends Command<C> {
context: C;
httpPlugin;
// 下面的[directive函数]和[parse函数]必须至少提供一个
directive(): string[]
parse(params: ParseParams): ParseReturn
// 下面的三种函数必须至少提供一个
user(params: UserHandlerParams): HandlerReturn
group(params: GroupHandlerParams): HandlerReturn
both(params: BothHandlerParams): HandlerReturn
// 下面的函数都是以session开头,叫做[session函数],可提供任意多个,详见下方文档描述
sessionA(params: SessionParams): HandlerReturn
sessionB(params: SessionParams): HandlerReturn
}
```
##### context属性
该属性的值等同于使用 `RobotFactory.create` 时传给 `context `参数的内容,默认为null。
##### httpPlugin属性
该属性的值为使用 `RobotFactory.create` 时传给 `httpPlugin` 参数的内容。
##### **directive**函数
该函数应返回一个字符串数组。假如它返回了 `["天气", "weather"]` ,并且**没有**定义 `parse函数` 时,当接收到用户消息后,会判断消息内容是否等于"天气"或者"weather",若相等,则会执行 `user函数` 或 `group函数` 或 `both函数` ,若不相等,则会进行下一个命令的判断。(该函数的触发条件同样会受到下述`trigger`修饰器的约束)
##### parse函数
上述的 `directive函数` 无法实现自定义命令解析,比如想要获取 "天气 西安" 这一消息中的城市信息,则需要使用 `parse函数` 手动处理,该函数的返回值信息可在 `user函数` 、`group函数`、`both函数` 的参数中访问。**提醒:**若提供了该函数,则不会再使用 `directive` 函数进行命令处理。
`directive函数` 和 `parse函数` 是允许同时存在的,并且十分建议不要省略 `directive函数` 的声明,因为通过该函数的返回值内容可以提升代码阅读性,方便识别该命令的用途。
##### user函数
提供该函数表示当前命令支持用户和机器人私聊的场景。
##### group函数
提供该函数表示当前命令支持群组聊天下的场景。**提醒** :群组消息包括了 匿名 和 非匿名 两种情况,故该函数参数的 `fromUser` 字段可能为QQ号,也可能为一个对象描述了匿名用户信息。可使用该函数参数中的 `messageFromType` 判断。
##### both函数
当你的命令处理逻辑在私聊和群聊下比较相似时,若同时提供 `user函数` 和 `group函数` 会增加代码冗余,故该 `both函数` 就是用来同时处理来自私聊或群聊的消息。**提醒:**若提供了该函数,则 `user函数` 和 `group函数` 会无效。此外,你需要手动判断消息源是用户消息、群组非匿名消息还是群组匿名消息,可使用函数参数的`messageFromType` 字段来判断。
##### session函数
该函数的功能参见下方[session函数](#class-session)的描述。
#### 函数参数说明:
上述函数中,`directive函数` 是无参的,其余类型的函数参数都是一个对象,该对象的属性内容如下(scope列描述了该属性存在于哪些函数,all表示每个函数都有该参数):
| key | type | description | scope |
| --------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
| data | any | 值为 `parse` 函数的返回值 | user,group,both |
| message | Message[] | 以二维数组的形式表示的消息内容 | all |
| rawMessage | string | 以字符串形式表示的消息内容 | all |
| requestBody | any | 原始http请求的body数据,具体内容可查看HTTP插件文档 | all |
| fromUser | number\| AnonymousUser | 发送消息者的QQ,为非数字时代表是匿名用户 | all |
| fromGroup | number\|undefined | 发送消息者所在的Q群,`user函数`中的该值为 `undefined` | all |
| robot | number | 执行该指令的机器人 | all |
| isAt | boolean | 是否艾特了机器人 | group |
| messageFromType | enum MessageFromType | 消息来自方:group指群聊, anonymous指群内匿名聊, user指私聊 | user,group,both |
| setEnd | () => Promise<void> | 是个异步函数,用来设置会话上下文结束 | session |
| historyMessage | Record<string, Array<Message[]>> | 一个对象,保存了历史会话消息,key为在 `group函数` 或 `user函数` 内调用`setNext`时指定的参数名称 (含‘session’单词前缀) | session |
| setNext | (sessionName: string, expireSeconds?: number) => Promise<void> | 是个异步函数,用来设置下一个需要执行的`session函数` | user,group,both,session |
#### 函数返回值:
##### parse函数
- 无返回值或是返回了 `undefined`:表示用户消息不满足该命令的处理条件
- 其他任意类型的值:该值在 `user函数` 、`group函数`、`both函数` 参数的 `data` 属性中访问到
##### user函数、group函数、session函数
- 无返回值或是返回了`undefined`:表示处理完毕,但不返回任何消息
- `{ atSender:boolean, content: string }`:为一个对象时,`atSender` 表示是否艾特发送者(仅群聊有效),`content` 为响应内容
- `string[]`:表示连续响应多条消息
- `string`:表示响应一条不艾特发送者的消息
#### 装饰器:
##### include( number[] )
可用于 `group函数` 和 `user函数`,表示只有这些QQ群/QQ号才可以触发该命令:
```js
@include([ 12312, 21223 ])
user() {} //只有QQ为为它俩的用户可触发该命令
@include([ 3423344 ])
group() {} // 只有QQ群号为它的群可触发该命令
```
##### exclude( number[] )
可用于 `group函数` 和 `user函数`,表示这些QQ群/QQ号不能触发该命令。**同时使用`include`和`exclude`会报错。**
```js
@exclude([ 12312, 21223 ])
user() {} // 除了上述两位QQ用户不能触发该命令,其他用户可触发
@include([ 3333 ])
group() {} // 只有群号为3333的群可触发该命令
```
##### trigger( triggerType )
可用于 `group函数` 和 `user函数`,表示命令触发方式,可赋值为:
- `TriggerType.at` :用户必须艾特机器人并发送消息方可触发命令
- `TriggerType.noAt`:用户之间在群内发送消息可触发命令,艾特机器人即使命令正确也不会触发
- `TriggerType.both` (默认值):艾特或者不艾特机器人都可触发命令
```js
@trigger(TriggerType.noAt)
group() {}
```
##### scope( triggerScope )
可用于 `group函数` 和 `user函数`,表示什么身份的用户可触发该命令,可赋值为:
- `TriggerScope.all` (默认值):所有用户都可触发
- `TriggerScope.owner`:仅群主可触发
- `TriggerScope.admin`:仅管理员可触发
- `TriggerScope.member`:仅普通成员可触发
- 使用或运算`|`产生组合值:比如 `TriggerScope.owner | TriggerScope.admin` 表示群主和管理员可触发
```js
@trigger(TriggerScope.all)
group() {}
```
### Class CQMessageHelper
一个用来处理数组格式消息的工具类,该方法接收的 `message` 参数即为 `user函数` 、`group函数`、`both函数` 、`session函数` 参数的 `message` 属性。
##### static removeAt(message: Message[]): Message[]
移除消息数组的艾特语句
##### static isAt(robotQQ: number, message: Message[]): boolean
判断消息数组是否含有艾特robotQQ的语句
##### static toRawMessage(message: Message[], removeAt?: boolean): string
将消息数组转换为字符串形式,特殊形式的信息则会变成[CQ码](https://docs.cqp.im/manual/cqcode/)形式。
### Class CQRawMessageHelper
一个用来字符串格式消息的工具类。该方法接收的 `message` 参数即为 `user函数` 、`group函数`、`both函数` 、`session函数` 参数的 `rawMessage` 属性。
##### static removeAt(message: string): string
移除字符串消息中的艾特CQ码
### Class HttpPlugin
该类用于主动调用[HTTP插件提供的API](https://cqhttp.cc/docs/#/API?id=api-列表)。
#### constructor(endpoint: string, config?: PluginConfig)
| key | type | description | optional |
| -------- | ------------ | ------------------ | -------- |
| endpoint | string | HTTP插件的运行地址 | |
| config | PluginConfig | 插件配置信息 | optional |
`PluginConfig`:一个对象,包含如下属性
| key | type | description | optional |
| ----------- | ------ | ---------------------------------------------------------- | -------- |
| accessToken | string | 须和HTTP插件配置文件值保持一致。在调用API时会验证该token。 | optional |
该类提供的实例方法名称是[HTTP插件文档](https://cqhttp.cc/docs/#/API?id=api-列表)提供API的 **驼峰式命名**,方法的返回值一个promise,其resolve值等同于HTTP插件文档的json对象,但方法的参数类型请以下述文档为准。
目前提供了如下接口的实现:
##### sendPrivateMsg(personQQ: number, message: string, escape?: boolean)
##### sendGroupMsg(groupQQ: number, message: string, escape?: boolean)
##### sendMsg(numbers: { user?: number; group?: number; }, message: string, escape?: boolean)
##### getGroupList()
##### getGroupMemberList(groupQQ: number)
##### downloadImage(cqFile: string)
### Class Session
该类用于启用上下文功能。
#### constructor(options? any)
该构造函数的参数类型同 [ioredis]( https://github.com/luin/ioredis/blob/master/API.md#Redis ) 库的`Redis` 构造函数的参数类型,example:
```js
import { RobotFactory, HttpPlugin, Session } from 'lemon-bot';
const robot = RobotFactory.create({
// ...
session: new Session(6379)
});
```
#### 如何启用上下文功能?
通过在 `create` 函数里传入 `session` 参数(如上述代码所示),即可开启使用`session函数`/上下文功能。
#### 什么是session函数?
`session函数` 指的是以"session"单词开头的写在Command继承类里的函数,继承类里可以有任意多个 `session函数`。在 `session函数` 未过期前,接下来发给机器人的消息即使满足了其他命令的处理条件,但并不会执行他们,而是直接执行 `session函数`中的逻辑,直到session过期或调用 `setEnd` 手动结束。
#### 如何使用session函数?
在 `user函数` 或 `group函数` 的参数中有个 `setNext` 属性,在 `session函数` 的参数中有 `setNext` 和 `setEnd` 这两个属性,他们都是异步函数,通过调用它们即可触发上下文功能,下面是函数说明:
- `setNext(name: string, expireSeconds?: number): Promise<void>` :`name`的值为其他 `session函数` 的函数名或是省略"session"单词后的部分 (**警告:** `name` 是大小写敏感的),`expireSeconds` 选填,表示会话过期时间,默认为5分钟。
调用 `setNext` 后,当机器人再次接受到该用户会话后,将直接执行 `setNext` 参数指定的函数。然后你可以继续调用 `setNext` 指定其他函数,每次执行 `session函数` 时,都可以获取到历史消息记录,从而进行自己的逻辑处理。
- `setEnd(): Promise<void>` :
调用该函数表示结束当前会话上下文,当机器人再次接收到消息后,将会按照常规的解析流程处理:即先判断 `directive函数` 的返回值或者是执行 `parse函数`,然后执行 `group函数` 或 `user函数` 。**警告:** 请别忘记调用该函数来终止会话,否则在session过期前将会一直执行本次的session函数。
#### session函数demo:
现在我们改造上面Demo部分中的代码,来演示 `session函数` 的使用,
**警告:** 下述例子设置了`count` 实例属性,由于不同的HTTP请求会共享命令,以及可能的并发等原因,无法确保`count` 属性的值与预期一致,故强烈不建议在类中设置实例属性,共享属性可使用第三方存储如 `redis` 来进行保存。下述代码仅为演示session函数的使用:
```js
import { Command, RobotFactory, HttpPlugin, Session } from "lemon-bot";
class SimpleCommand extends Command {
count = 3;
directive() {
return ["测试", "test"];
}
async user({ fromUser, setNext }) {
await setNext('A');
return "user run with " + this.count;
}
async sessionA({ setNext }) {
this.count--;
await setNext("B", 10);
return "sessionA run with " + this.count;
}
async sessionB({ setNext }) {
this.count--;
await setNext("sessionC");
return "sessionB run with " + this.count;
}
async sessionC({ setNext, setEnd }) {
this.count--;
await setEnd();
return "sessionC run with 结束";
}
}
const robot = RobotFactory.create({
port: 8888,
robot: 834679887,
httpPlugin: new HttpPlugin("http://localhost:5700"),
commands: [new SimpleCommand()],
session: new Session()
});
robot.start();
```
运行上述代码前,请确保 :
- redis处于运行状态并可访问其默认的6379端口
- `robot`字段为你当前酷Q登陆的账号
然后在命令行内输入 `npx ts-node index.ts` 启动机器人,然后开始向你的机器人发送下面的信息:
- 发送 "测试":会执行 `user函数` ,返回 "user run with 3"
- 发送 任意消息:会执行 `sessionA`,返回"sessionA run with 2"
- 10s内发送任意消息:会执行 `sessionB`,返回"sessionB run with 1"
- 发送 任意消息:会执行 `sessionC`,返回"sessionC run with 结束"
- 发送 "测试":将重新从 `user函数`开始解析,返回 "user run with 0"
## 安全指南
1. 尽可能避免HTTP插件的上报地址(即node服务器地址)可被外网访问,这会导致收到恶意请求。
2. 尽可能避免HTTP插件的运行地址可被公网访问,这会导致攻击者可进行API调用。若必需要公网下可访问,则应在配置文件中配置 access_token ,然后在代码的 `HttpPlugin` 实例中传入 `accessToken`参数,设置后,当在调用API时会自动验证token值。
## Recipes
### 1. 文件目录如何组织?
建议使用如下结构:
```
+-- commands
| +-- SearchQuestionCommand.ts
| +-- HelpCommand.ts
| +-- WordCommand.ts
+-- index.ts
```
### 2. 如何提供一个默认的消息处理函数?
假如我们目前的 `commands` 数组是 `[ new ACommand(), new BCommand() ]`,现在我希望当用户发来的消息都不满足这些命令的解析条件时,将它交由一个默认的处理命令:
1. 实现一个返回值始终为 **非`undefined`** 的`parse`函数:
```js
export default class DefaultCommand extends Command {
parse() {return true;}
user(){
return "默认返回"
}
}
```
2. 将该类的实例对象放在 `commands` 数组的**最后一位** :
```js
const robot = new RobotFactory.create({
// ...
commands: [new ACommand(), new BCommand(), new DefaultCommand()]
})
```
### 3. 如何实现群组下不同成员共享session函数?
比如我想实现这样一个命令: 当我艾特机器人回复"开始收集反馈"后,接下来群员的发言内容会全部被采集,直到我艾特机器人发送 "收集结束"。
答: 目前的 `session函数` 的触发条件必须是 "消息是同一用户发送,如果用户位于群内,必须是在同一个群内发送消息",目前暂不支持触发条件是 "消息可以来自同一群组的不同用户"的情况。开发者可以通过使用redis并在默认消息处理命令里进行判断是否处于"消息反馈"状态下并进行处理。
## TODO
- [ ] 完善API接口实现
- [ ] 添加非消息事件上报的处理
- [ ] 编写测试😫
- [ ] 使用文档工具生成更易阅读的文档