├── .eslintignore
├── .eslintrc
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── jest.config.js
├── package.json
├── prettier.config.js
├── src
├── CQHelper.ts
├── Command.ts
├── HttpPlugin.ts
├── RobotFactory.ts
├── Session.ts
├── index.ts
└── logger.ts
└── tsconfig.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.js
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "xhmm/ts-node"
4 | ],
5 | "parserOptions": {
6 | "ecmaVersion": 2020,
7 | "sourceType": "module",
8 | "ecmaFeatures": {
9 | "modules": true
10 | }
11 | },
12 | "rules": {
13 | /* 下面rules 已更新在 my-config-files */
14 | "node/no-unsupported-features/es-syntax": "off",
15 | "node/no-missing-require": "off",
16 | "node/no-missing-import": "off"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .idea
3 | build
4 | ps.txt
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## 0.6.0 (2019年12月23日)
4 | ### Breaking changes:
5 | - removed need to manually pass redis client when using `Session`, checkout `README` to view updated doc.
6 | ### Enhancements:
7 | - removed `signale`, now use `debug` for logging, checkout `README` about how to open/close debug mode.
8 |
9 |
10 | ## 0.5.1 (2019年11月18日)
11 | ### Enhancements:
12 | - 添加了已登录酷Q机器人和create传入的机器人不一致时的检测
13 |
14 | ## 0.5.0 (2019年11月9日)
15 | ### Fix bugs:
16 | - `HttpPlguin`未设置`method`为`POST`导致api调用报错
17 |
18 | ## 0.4.4 (2019年10月23日)
19 | ### Breaking changes:
20 | - `HttpPlugin`类的`sendMsg`接口参数名修改,移除了`Number`冗余单词
21 | - `HttpPluginError`类的实例属性`apiName`改为`APIName`
22 | - `AnonymousUser`重新加回了`flag`属性以便其他api调用时使用
23 |
24 | ## 0.4.3 (2019年10月22日)
25 | ### Fix bugs:
26 | - dependency @xhmm/util bug fix
27 |
28 | ## 0.4.1 (2019年10月20日)
29 | ### Features:
30 | - 现支持所有消息类型的处理(不同情况下的私聊、群内匿名和非匿名)。并提供了完整的type guard来帮助ts代码的正确类型提示(文档暂未提供使用示例)
31 | ### Breaking changes:
32 | - `historyMessage`字段的key值不再省略'session'单词,value值现是一个二维数组,里面保存了当前session函数接收的所有消息
33 |
34 |
35 | ## 0.4.0 (2019年10月20日)
36 | ### Breaking changes:
37 | - `parse`函数的返回值不再是赋给`this.data`,而是需要在`user`/`group`/`both`函数参数中使用`data`属性来获取。
38 | 迁移方式:若是使用`typescript`,则使用`tsc`编译会触发`Property data doesn't exist ...`,然后进行相关文件的改写。若是使用`javascript`,则使用`ctrl+f`搜索含有`this.data`语句的文件,然后进行改写。
39 | ### Fix bugs:
40 | - async session函数未被await
41 |
42 | ## 0.3.1 (2019年10月20日)
43 | ### Fix bugs:
44 | - 使用指令数组判断含艾特的消息时空格信息导致不成功
45 |
46 | ## 0.3.0 (2019年10月19日)
47 | ### Fix bugs:
48 | - 修复了使用多机器人时仅首次被创建的机器人会生效
49 |
50 | ## 0.2.0 (2019年10月19日)
51 | ### Features:
52 | - 新增了`both`函数
53 | - 新增了`Logger`类用于日志输出控制
54 | - 新增了`scope`修饰器
55 | - 解析函数和处理函数的参数属性新增了`requestBody`
56 |
57 | ### Breaking changes:
58 | - 群组命令的触发模式默认从`TriggerType.at`改为了`TriggerType.both`
59 | - 解析函数和处理函数的参数属性的`messages`更名为了`message`
60 | - 解析函数和处理函数的参数属性的`stringMessages`更名为了`rawMessage`
61 | - `include`和`exclude`修饰器不可同时
62 |
63 | ### Enhancements:
64 | - 修饰器添加了warning语句以帮助正确使用
65 | - 日志信息更为全面
66 | - 当命令类使用`setNext`设置了不存在的session函数时,不再抛错而是重置会话并打印警告信息
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 XHMM
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
🍋 Lemon-Bot
2 |
3 | cqhttp 4.15
4 | nodejs todo
5 |
6 | 一个基于酷Q和CoolQ HTTP API插件的QQ机器人Nodejs开发框架。
7 |
8 | - 支持多命令匹配、命令自定义解析
9 |
10 | - 使用修饰器进行灵活的命令触发控制
11 |
12 | - 支持会话上下文功能
13 |
14 | - 支持多机器人运行
15 |
16 | - and more ...
17 |
18 |
19 |
20 | ## 前言
21 |
22 | 该项目仍处于早期开发版,故版本变动较为频繁,但会尽可能保证基本开发方式不变,具体变动见 [Changelog](https://github.com/XHMM/lemon-bot/blob/master/CHANGELOG.md)
23 |
24 | ## 准备
25 |
26 | 1. 安装 [nodejs](https://nodejs.org/en/download/)
27 |
28 | 2. 安装 酷Q 和 HTTP插件:
29 |
30 | - Windows: 首先前往酷Q的[版本发布](https://cqp.cc/b/news)页面下载(Air为免费版,Pro为收费版),下载后解压启动 `CAQ.exe` 或 `CQP.exe` 并登陆你的QQ机器人账号。然后根据[CoolQ HTTP API插件文档](https://cqhttp.cc/docs/)中的"手动安装"部分的教程进行插件安装。
31 | - Linux / Mac: 查看[CoolQ HTTP API插件文档](https://cqhttp.cc/docs/)中的"使用Docker"部分的教程进行安装
32 |
33 | 3. 修改 HTTP插件 配置:
34 |
35 | 每个账号的配置文件存放路径一般为 `/path/to/酷Q/data/app/io.github.richardchien.coolqhttpapi/config/QQ号.json` (也可能是 `.ini` 格式)。 附:[插件文档](https://cqhttp.cc/docs/#/Configuration?id=配置项)
36 |
37 | - 非docker环境:手动前往配置文件所在目录进行修改:
38 |
39 | ```metadata json
40 | {
41 | "port": 5700, // HTTP插件的运行端口,请自行指定或使用默认值
42 | "use_http": true, // 须设为true
43 | "post_url": "http://127.0.0.1:8888/coolq", // 这是node服务器的运行地址以及监听的路由,你只可以修改端口号,请勿修改路由地址
44 | "access_token": "", // 可选。若指定此值,则使用框架时也须配置
45 | "secret": "", // 可选。若指定此值,则使用框架时也须配置
46 | "post_message_format": "array" // 【重要】请务必将该选项设为array
47 | }
48 | ```
49 |
50 | - docker环境: 使用环境变量注入的形式来在容器创建时设置好配置,或是手动前往挂载目录下按照上述修改配置文件。
51 |
52 | 4. 安装该node包: `npm i lemon-bot`
53 |
54 | 5. 由于该框架使用了 [decorator](https://www.typescriptlang.org/docs/handbook/decorators.html) 语法:
55 |
56 | - 若你是使用 Javascript 进行开发,则需要[配置babel](https://babeljs.io/docs/en/babel-plugin-proposal-decorators)以支持该特性。
57 |
58 | - 若是使用 Typescript,则需要在`tsconfig.json`中启用decorator:
59 |
60 | ```metadata json
61 | {
62 | "compilerOptions": {
63 | "experimentalDecorators": true,
64 |
65 | // 此外,由于该框架的编译版本为es6,故请务必设置自己的target为 *非es5*, 否则会报错
66 | "target": "es6"
67 | }
68 | }
69 | ```
70 |
71 | 6. 该框架使用 `debug` 模块进行日志打印。在开发阶段建议开启日志输出来方便调试和排错:设置环境变量 `DEBUG=lemon-bot*` ,然后运行主程序即可。
72 |
73 | ## Demo
74 |
75 | 在 `index.ts` 文件里写入下述代码:
76 |
77 | ```js
78 | import { Command, RobotFactory, HttpPlugin } from "lemon-bot";
79 |
80 | class SimpleCommand extends Command {
81 | // 当机器人接收到"测试"或是"test"文本后,会触发该命令
82 | directive() {
83 | return ["测试", "test"];
84 | }
85 |
86 | // 当消息是私发给机器人时,会使用user函数进行响应
87 | user({ fromUser }) {
88 | return "你好呀" + fromUser;
89 | }
90 |
91 | // 当机器人在QQ群内并检测到上述指令后,会使用group函数进行响应
92 | group({ fromUser, fromGroup }) {
93 | // 返回值为数组时,机器人会连续发送多条消息。
94 | return ["触发群是" + fromGroup, "触发用户是" + fromUser];
95 | }
96 | }
97 |
98 | const robot = RobotFactory.create({
99 | port: 8888, // node应用的运行端口。需要和插件配置文件的post_url对应
100 | robot: 1326099664, // 机器人QQ号
101 | httpPlugin: new HttpPlugin("http://localhost:5700"), // 用于调用HTTP Plugin API
102 | commands: [new SimpleCommand()] // 该机器人可处理的命令
103 | });
104 |
105 | robot.start(); // 启动
106 | ```
107 |
108 | 在运行该代码前,请确保:
109 |
110 | - 酷Q和HTTP插件处于运行状态,且上述代码中的 `robot` 值为当前登录的机器人QQ
111 | - 按照要求修改了HTTP插件的配置文件
112 |
113 | 然后在命令行内输入 `npx ts-node index.ts` 即可启动机器人。
114 |
115 | 一对一聊天测试:用一个加了机器人为好友的QQ号向机器人发送 "测试" 或 "test" ,会发现机器人返回了 "你好呀[你的QQ号]"。
116 |
117 | 群聊测试:将该机器人拉入群内,然后在群内发送"测试"或"test",会发现机器人连续返回了两条消息: "触发群是[机器人所在Q群]" 和 "触发用户是[你的QQ号]"。
118 |
119 | ## 案例
120 |
121 | - [小心机器人](https://github.com/XHMM/bot-xiaoxin)
122 |
123 | ## API文档
124 |
125 | Tips:下述涉及的类型定义和enum定义可直接前往源码内查看,可帮助更好的理解其含义。
126 |
127 | ### Class RobotFactory
128 |
129 | 该类用于机器人的创建。
130 |
131 | #### static create(config: CreateParams): CreateReturn
132 |
133 | `CreateParams`:该函数所需参数是一个对象,接受如下属性:
134 |
135 | | key | type | description | optional |
136 | | ---------- | ---------- | ------------------------------------------------------ | -------- |
137 | | port | number | node服务器的运行端口 | |
138 | | robot | number | 机器人QQ号 | |
139 | | httpPlugin | HttpPlugin | HTTP插件实例 | |
140 | | commands | Command[] | 需要注册的命令 | |
141 | | session | Session | 传入该参数运行使用session函数 | optional |
142 | | secret | string | 须和HTTP插件配置文件值保持一致,用于对上报数据进行验证 | optional |
143 | | context | any | 该属性可在Command类中使用`this.context`访问 | optional |
144 |
145 | `CreateReturn`:该函数的返回值是一个对象,包含如下属性
146 |
147 | | key | type | description |
148 | | ----- | ----------------- | ----------- |
149 | | start | ()=>Promise | 启动机器人 |
150 | | stop | () => void | 停止机器人 |
151 |
152 | Example:
153 |
154 | ```js
155 | const robot = RobotFactory.create({
156 | port: 8888,
157 | robot: 1326099664,
158 | httpPlugin: new HttpPlugin("http://localhost:5700"),
159 | commands: [new SimpleCommand()]
160 | });
161 | robot.start();
162 | ```
163 |
164 |
165 |
166 | ### Class Command
167 |
168 | 该类需要被继承使用,用来创建命令。下面将以继承类的角度进行描述:
169 |
170 | #### 继承类的基本结构:
171 |
172 | ```typescript
173 | // 导入基类
174 | import { Command} from 'lemon-bot';
175 | // 导入ts类型定义提升开发体验
176 | import { ParseParams, ParseReturn, UserHandlerParams, GroupHandlerParams, SessionHandlerParams, HandlerReturn } from 'lemon-bot'
177 |
178 | class MyCommand extends Command {
179 | // 下面两个实例属性是默认提供的,可在函数内直接访问,故请不要出现同名属性。
180 | context: C;
181 | httpPlugin;
182 |
183 | // 下面的[directive函数]和[parse函数]必须至少提供一个
184 | directive(): string[]
185 | parse(params: ParseParams): ParseReturn
186 |
187 | // 下面的三种函数必须至少提供一个
188 | user(params: UserHandlerParams): HandlerReturn
189 | group(params: GroupHandlerParams): HandlerReturn
190 | both(params: BothHandlerParams): HandlerReturn
191 |
192 | // 下面的函数都是以session开头,叫做[session函数],可提供任意多个,详见下方文档描述
193 | sessionA(params: SessionParams): HandlerReturn
194 | sessionB(params: SessionParams): HandlerReturn
195 | }
196 | ```
197 |
198 | #### 属性、函数说明:
199 |
200 | ##### context属性
201 |
202 | 该属性的值等同于使用 `RobotFactory.create` 时传给 `context `参数的内容,默认为null。
203 |
204 | ##### httpPlugin属性
205 |
206 | 该属性的值为使用 `RobotFactory.create` 时传给 `httpPlugin` 参数的内容。
207 |
208 | ##### **directive**函数
209 |
210 | 该函数应返回一个字符串数组。假如它返回了 `["天气", "weather"]` ,并且**没有**定义 `parse函数` 时,当接收到用户消息后,会判断消息内容是否等于"天气"或者"weather",若相等,则会执行 `user函数` 或 `group函数` 或 `both函数` ,若不相等,则会进行下一个命令的判断。(该函数的触发条件同样会受到下述`trigger`修饰器的约束)
211 |
212 | ##### parse函数
213 |
214 | 上面的 `directive函数` 无法实现自定义命令解析,比如想要获取 "天气 西安" 这一消息中的城市信息,则需要使用 `parse函数` 手动处理,该函数的返回值信息可在 `user函数` 、`group函数`、`both函数` 的参数中访问。**提醒:** 若提供了该函数,则不会再使用 `directive` 函数进行命令处理。
215 |
216 | `directive函数` 和 `parse函数` 是允许同时存在的,并且十分建议不要省略 `directive函数` 的声明,因为通过该函数的返回值内容可以提升代码阅读性,方便识别该命令的用途。
217 |
218 | ##### user函数
219 |
220 | 提供该函数表示当前命令支持用户和机器人私聊的场景。
221 |
222 | ##### group函数
223 |
224 | 提供该函数表示当前命令支持群组聊天下的场景。**提醒** :群组消息包括了 匿名 和 非匿名 两种情况,故该函数参数的 `fromUser` 字段可能为QQ号,也可能为一个对象描述了匿名用户信息。可使用该函数参数中的 `messageFromType` 判断。
225 |
226 | ##### both函数
227 |
228 | 当你的命令处理逻辑在私聊和群聊下比较相似时,若同时提供 `user函数` 和 `group函数` 会增加代码冗余,故该 `both函数` 就是用来同时处理来自私聊或群聊的消息。**提醒:**若提供了该函数,则 `user函数` 和 `group函数` 会无效。此外,你需要手动判断消息源是用户消息、群组非匿名消息还是群组匿名消息,可使用函数参数的`messageFromType` 字段来判断。
229 |
230 | ##### session函数
231 |
232 | 该函数的功能参见下方[session函数](#class-session)的描述。
233 |
234 | #### 函数参数说明:
235 |
236 | 上述函数中,`directive函数` 是无参的,其余类型的函数参数都是一个对象,该对象的属性内容如下(scope列描述了该属性存在于哪些函数,all表示每个函数都有该参数):
237 |
238 | | key | type | description | scope |
239 | | --------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
240 | | data | any | 值为 `parse` 函数的返回值 | user,group,both |
241 | | message | Message[] | 以二维数组的形式表示的消息内容 | all |
242 | | rawMessage | string | 以字符串形式表示的消息内容 | all |
243 | | requestBody | any | 原始http请求的body数据,具体内容可查看HTTP插件文档 | all |
244 | | fromUser | number\| AnonymousUser | 发送消息者的QQ,为非数字时代表是匿名用户 | all |
245 | | fromGroup | number\|undefined | 发送消息者所在的Q群,`user函数`中的该值为 `undefined` | all |
246 | | robot | number | 执行该指令的机器人 | all |
247 | | isAt | boolean | 是否艾特了机器人 | group |
248 | | messageFromType | enum MessageFromType | 消息来自方:group指群聊, anonymous指群内匿名聊, user指私聊 | user,group,both |
249 | | setEnd | () => Promise | 是个异步函数,用来设置会话上下文结束 | session |
250 | | historyMessage | Record> | 一个对象,保存了历史会话消息,key为在 `group函数` 或 `user函数` 内调用`setNext`时指定的参数名称 (含‘session’单词前缀) | session |
251 | | setNext | (sessionName: string, expireSeconds?: number) => Promise | 是个异步函数,用来设置下一个需要执行的`session函数` | user,group,both,session |
252 |
253 | #### 函数返回值:
254 |
255 | ##### parse函数
256 |
257 | - 无返回值或是返回了 `undefined`:表示用户消息不满足该命令的处理条件
258 | - 其他任意类型的值:该值在 `user函数` 、`group函数`、`both函数` 参数的 `data` 属性中访问到
259 |
260 | ##### user函数、group函数、session函数
261 |
262 | - 无返回值或是返回了`undefined`:表示处理完毕,但不返回任何消息
263 | - `{ atSender:boolean, content: string }`:为一个对象时,`atSender` 表示是否艾特发送者(仅群聊有效),`content` 为响应内容
264 | - `string[]`:表示连续响应多条消息
265 | - `string`:表示响应一条不艾特发送者的消息
266 |
267 |
268 |
269 | #### 装饰器:
270 |
271 | ##### include( number[] )
272 |
273 | 可用于 `group函数` 和 `user函数`,表示只有这些QQ群/QQ号才可以触发该命令:
274 |
275 | ```js
276 | @include([ 12312, 21223 ])
277 | user() {} //只有QQ为为它俩的用户可触发该命令
278 |
279 | @include([ 3423344 ])
280 | group() {} // 只有QQ群号为它的群可触发该命令
281 | ```
282 |
283 | ##### exclude( number[] )
284 |
285 | 可用于 `group函数` 和 `user函数`,表示这些QQ群/QQ号不能触发该命令。**同时使用`include`和`exclude`会报错。**
286 |
287 | ```js
288 | @exclude([ 12312, 21223 ])
289 | user() {} // 除了上述两位QQ用户不能触发该命令,其他用户可触发
290 |
291 | @include([ 3333 ])
292 | group() {} // 只有群号为3333的群可触发该命令
293 | ```
294 |
295 | ##### trigger( triggerType )
296 |
297 | 可用于 `group函数` 和 `user函数`,表示命令触发方式,可赋值为:
298 |
299 | - `TriggerType.at` :用户必须艾特机器人并发送消息方可触发命令
300 | - `TriggerType.noAt`:用户之间在群内发送消息可触发命令,艾特机器人即使命令正确也不会触发
301 | - `TriggerType.both` (默认值):艾特或者不艾特机器人都可触发命令
302 |
303 | ```js
304 | @trigger(TriggerType.noAt)
305 | group() {}
306 | ```
307 |
308 | ##### scope( triggerScope )
309 |
310 | 可用于 `group函数` 和 `user函数`,表示什么身份的用户可触发该命令,可赋值为:
311 |
312 | - `TriggerScope.all` (默认值):所有用户都可触发
313 | - `TriggerScope.owner`:仅群主可触发
314 | - `TriggerScope.admin`:仅管理员可触发
315 | - `TriggerScope.member`:仅普通成员可触发
316 | - 使用或运算`|`产生组合值:比如 `TriggerScope.owner | TriggerScope.admin` 表示群主和管理员可触发
317 |
318 | ```js
319 | @trigger(TriggerScope.all)
320 | group() {}
321 | ```
322 |
323 |
324 |
325 | ### Class CQMessageHelper
326 |
327 | 一个用来处理数组格式消息的工具类,该方法接收的 `message` 参数即为 `user函数` 、`group函数`、`both函数` 、`session函数` 参数的 `message` 属性。
328 |
329 | ##### static removeAt(message: Message[]): Message[]
330 |
331 | 移除消息数组的艾特语句
332 |
333 | ##### static isAt(robotQQ: number, message: Message[]): boolean
334 |
335 | 判断消息数组是否含有艾特robotQQ的语句
336 |
337 | ##### static toRawMessage(message: Message[], removeAt?: boolean): string
338 |
339 | 将消息数组转换为字符串形式,特殊形式的信息则会变成[CQ码](https://docs.cqp.im/manual/cqcode/)形式。
340 |
341 | ### Class CQRawMessageHelper
342 |
343 | 一个用来字符串格式消息的工具类。该方法接收的 `message` 参数即为 `user函数` 、`group函数`、`both函数` 、`session函数` 参数的 `rawMessage` 属性。
344 |
345 | ##### static removeAt(message: string): string
346 |
347 | 移除字符串消息中的艾特CQ码
348 |
349 |
350 |
351 | ### Class HttpPlugin
352 |
353 | 该类用于主动调用[HTTP插件提供的API](https://cqhttp.cc/docs/#/API?id=api-列表)。
354 |
355 | #### constructor(endpoint: string, config?: PluginConfig)
356 |
357 | | key | type | description | optional |
358 | | -------- | ------------ | ------------------ | -------- |
359 | | endpoint | string | HTTP插件的运行地址 | |
360 | | config | PluginConfig | 插件配置信息 | optional |
361 |
362 | `PluginConfig`:一个对象,包含如下属性
363 |
364 | | key | type | description | optional |
365 | | ----------- | ------ | ---------------------------------------------------------- | -------- |
366 | | accessToken | string | 须和HTTP插件配置文件值保持一致。在调用API时会验证该token。 | optional |
367 |
368 | 该类提供的实例方法名称是[HTTP插件文档](https://cqhttp.cc/docs/#/API?id=api-列表)提供API的 **驼峰式命名**,方法的返回值一个promise,其resolve值等同于HTTP插件文档的json对象,但方法的参数类型请以下述文档为准。
369 |
370 | 目前提供了如下接口的实现:
371 |
372 | ##### sendPrivateMsg(personQQ: number, message: string, escape?: boolean)
373 |
374 | ##### sendGroupMsg(groupQQ: number, message: string, escape?: boolean)
375 |
376 | ##### sendMsg(numbers: { user?: number; group?: number; }, message: string, escape?: boolean)
377 |
378 | ##### getGroupList()
379 |
380 | ##### getGroupMemberList(groupQQ: number)
381 |
382 | ##### downloadImage(cqFile: string)
383 |
384 |
385 |
386 | ### Class Session
387 |
388 | 该类用于启用上下文功能。
389 |
390 | #### constructor(options? any)
391 |
392 | 该构造函数的参数类型同 [ioredis]( https://github.com/luin/ioredis/blob/master/API.md#Redis ) 库的`Redis` 构造函数的参数类型,example:
393 |
394 | ```js
395 | import { RobotFactory, HttpPlugin, Session } from 'lemon-bot';
396 |
397 | const robot = RobotFactory.create({
398 | // ...
399 | session: new Session(6379)
400 | });
401 | ```
402 |
403 | #### 如何启用上下文功能?
404 |
405 | 通过在 `create` 函数里传入 `session` 参数(如上述代码所示),即可开启使用`session函数`/上下文功能。
406 |
407 | #### 什么是session函数?
408 |
409 | `session函数` 指的是以"session"单词开头的写在Command继承类里的函数,继承类里可以有任意多个 `session函数`。在 `session函数` 未过期前,接下来发给机器人的消息即使满足了其他命令的处理条件,但并不会执行他们,而是直接执行 `session函数`中的逻辑,直到session过期或调用 `setEnd` 手动结束。
410 |
411 | #### 如何使用session函数?
412 |
413 | 在 `user函数` 或 `group函数` 的参数中有个 `setNext` 属性,在 `session函数` 的参数中有 `setNext` 和 `setEnd` 这两个属性,他们都是异步函数,通过调用它们即可触发上下文功能,下面是函数说明:
414 |
415 | - `setNext(name: string, expireSeconds?: number): Promise` :`name`的值为其他 `session函数` 的函数名或是省略"session"单词后的部分 (**警告:** `name` 是大小写敏感的),`expireSeconds` 选填,表示会话过期时间,默认为5分钟。
416 |
417 | 调用 `setNext` 后,当机器人再次接受到该用户会话后,将直接执行 `setNext` 参数指定的函数。然后你可以继续调用 `setNext` 指定其他函数,每次执行 `session函数` 时,都可以获取到历史消息记录,从而进行自己的逻辑处理。
418 |
419 | - `setEnd(): Promise` :
420 |
421 | 调用该函数表示结束当前会话上下文,当机器人再次接收到消息后,将会按照常规的解析流程处理:即先判断 `directive函数` 的返回值或者是执行 `parse函数`,然后执行 `group函数` 或 `user函数` 。**警告:** 请别忘记调用该函数来终止会话,否则在session过期前将会一直执行本次的session函数。
422 |
423 | #### session函数demo:
424 |
425 | 现在我们改造上面Demo部分中的代码,来演示 `session函数` 的使用,
426 |
427 | **警告:** 下述例子设置了`count` 实例属性,由于不同的HTTP请求会共享命令,以及可能的并发等原因,无法确保`count` 属性的值与预期一致,故强烈不建议在类中设置实例属性,共享属性可使用第三方存储如 `redis` 来进行保存。下述代码仅为演示session函数的使用:
428 |
429 | ```js
430 | import { Command, RobotFactory, HttpPlugin, Session } from "lemon-bot";
431 |
432 | class SimpleCommand extends Command {
433 | count = 3;
434 |
435 | directive() {
436 | return ["测试", "test"];
437 | }
438 |
439 | async user({ fromUser, setNext }) {
440 | await setNext('A');
441 | return "user run with " + this.count;
442 | }
443 |
444 | async sessionA({ setNext }) {
445 | this.count--;
446 | await setNext("B", 10);
447 | return "sessionA run with " + this.count;
448 | }
449 |
450 | async sessionB({ setNext }) {
451 | this.count--;
452 | await setNext("sessionC");
453 | return "sessionB run with " + this.count;
454 | }
455 |
456 | async sessionC({ setNext, setEnd }) {
457 | this.count--;
458 | await setEnd();
459 | return "sessionC run with 结束";
460 | }
461 | }
462 |
463 | const robot = RobotFactory.create({
464 | port: 8888,
465 | robot: 834679887,
466 | httpPlugin: new HttpPlugin("http://localhost:5700"),
467 | commands: [new SimpleCommand()],
468 | session: new Session()
469 | });
470 |
471 | robot.start();
472 | ```
473 |
474 | 运行上述代码前,请确保 :
475 |
476 | - redis处于运行状态并可访问其默认的6379端口
477 | - `robot`字段为你当前酷Q登陆的账号
478 |
479 | 然后在命令行内输入 `npx ts-node index.ts` 启动机器人,然后开始向你的机器人发送下面的信息:
480 |
481 | - 发送 "测试":会执行 `user函数` ,返回 "user run with 3"
482 | - 发送 任意消息:会执行 `sessionA`,返回"sessionA run with 2"
483 | - 10s内发送任意消息:会执行 `sessionB`,返回"sessionB run with 1"
484 | - 发送 任意消息:会执行 `sessionC`,返回"sessionC run with 结束"
485 | - 发送 "测试":将重新从 `user函数`开始解析,返回 "user run with 0"
486 |
487 | ## 安全指南
488 |
489 | 1. 尽可能避免HTTP插件的上报地址(即node服务器地址)可被外网访问,这会导致收到恶意请求。
490 | 2. 尽可能避免HTTP插件的运行地址可被公网访问,这会导致攻击者可进行API调用。若必需要公网下可访问,则应在配置文件中配置 access_token ,然后在代码的 `HttpPlugin` 实例中传入 `accessToken`参数,设置后,当在调用API时会自动验证token值。
491 |
492 |
493 |
494 | ## Recipes
495 |
496 | ### 1. 文件目录如何组织?
497 |
498 | 建议使用如下结构:
499 |
500 | ```
501 | +-- commands
502 | | +-- SearchQuestionCommand.ts
503 | | +-- HelpCommand.ts
504 | | +-- WordCommand.ts
505 | +-- index.ts
506 | ```
507 |
508 | ### 2. 如何提供一个默认的消息处理函数?
509 |
510 | 假如我们目前的 `commands` 数组是 `[ new ACommand(), new BCommand() ]`,现在我希望当用户发来的消息都不满足这些命令的解析条件时,将它交由一个默认的处理命令:
511 |
512 | 1. 实现一个返回值始终为 **非`undefined`** 的`parse`函数:
513 |
514 | ```js
515 | export default class DefaultCommand extends Command {
516 | parse() {return true;}
517 | user(){
518 | return "默认返回"
519 | }
520 | }
521 | ```
522 |
523 | 2. 将该类的实例对象放在 `commands` 数组的**最后一位** :
524 |
525 | ```js
526 | const robot = new RobotFactory.create({
527 | // ...
528 | commands: [new ACommand(), new BCommand(), new DefaultCommand()]
529 | })
530 | ```
531 |
532 | ### 3. 如何实现群组下不同成员共享session函数?
533 |
534 | 比如我想实现这样一个命令: 当我艾特机器人回复"开始收集反馈"后,接下来群员的发言内容会全部被采集,直到我艾特机器人发送 "收集结束"。
535 |
536 | 答: 目前的 `session函数` 的触发条件必须是 "消息是同一用户发送,如果用户位于群内,必须是在同一个群内发送消息",目前暂不支持触发条件是 "消息可以来自同一群组的不同用户"的情况。开发者可以通过使用redis并在默认消息处理命令里进行判断是否处于"消息反馈"状态下并进行处理。
537 |
538 |
539 |
540 | ## TODO
541 |
542 | - [ ] 完善API接口实现
543 | - [ ] 添加非消息事件上报的处理
544 | - [ ] 编写测试😫
545 | - [ ] 使用文档工具生成更易阅读的文档
546 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lemon-bot",
3 | "version": "0.6.0",
4 | "description": "a qq bot framework",
5 | "main": "build/index.js",
6 | "typings": "build/index.d.ts",
7 | "scripts": {
8 | "build": "rimraf build && tsc",
9 | "test": "jest",
10 | "eslint:fix": "eslint --fix --ext .ts src --ignore-path ./.gitignore",
11 | "api": "ts-node -T ./src/api/generate.ts",
12 | "prepublishOnly": "npm run build"
13 | },
14 | "engines": {
15 | "node": ">=10.0.0"
16 | },
17 | "keywords": [
18 | "qq bot",
19 | "qq机器人",
20 | "酷Q"
21 | ],
22 | "repository": "https://github.com/XHMM/lemon-bot",
23 | "author": "XHMM",
24 | "license": "MIT",
25 | "dependencies": {
26 | "@xhmm/utils": "0.0.7",
27 | "debug": "^4.1.1",
28 | "express": "^4.17.1",
29 | "ioredis": "^4.17.3",
30 | "node-fetch": "^2.6.0"
31 | },
32 | "devDependencies": {
33 | "@types/debug": "^4.1.5",
34 | "@types/express": "^4.17.6",
35 | "@types/ioredis": "^4.16.4",
36 | "@types/jest": "^26.0.0",
37 | "@types/node-fetch": "^2.5.7",
38 | "eslint": "^7.2.0",
39 | "eslint-config-xhmm": "^0.1.15",
40 | "jest": "^26.0.1",
41 | "prettier": "^2.0.5",
42 | "prettier-config-xhmm": "0.0.6",
43 | "rimraf": "^3.0.2",
44 | "ts-jest": "^26.1.0",
45 | "ts-node": "^8.10.2",
46 | "typescript": "^3.9.5"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ...require('prettier-config-xhmm')
3 | };
--------------------------------------------------------------------------------
/src/CQHelper.ts:
--------------------------------------------------------------------------------
1 | import { MessageFromType } from './Command';
2 |
3 | type MessageBase = {
4 | type: T;
5 | data: D;
6 | };
7 |
8 | type TextMessage = MessageBase<'text', { text: string }>;
9 | type AtMessage = MessageBase<'at', { qq: string }>; // at,若是手动复制的@xx是属于文本消息
10 | type EmojiMessage = MessageBase<'emoji', { id: string }>;
11 | type SmallFaceMessage = MessageBase<'sface', { id: string }>; // 小表情
12 | type FaceMessage = MessageBase<'face', { id: string }>; // 小表情
13 |
14 | type ImageMessage = MessageBase<'image', { file: string; url: string }>; // 图片
15 | type RecordMessage = MessageBase<'record', { file: string }>; // 语音
16 | type BigFaceMessage = MessageBase<'bface', { id: string; p: string }>; // 大表情
17 | type RichMessage = MessageBase<'rich', { content: string }>; // 分享、王者组队等
18 | type DiceMessage = MessageBase<'dice', { type: string }>; // 投骰子
19 | type RPSMessage = MessageBase<'rps', { type: string }>; // 剪刀石头布
20 | // 发送mp4 mp3及其他文件内容 是undefined,应该是需要pro版本
21 | // 拍照界面的短视频 是text: '[视频]你的QQ暂不支持查看视频短片,请升级到最新版本后查看。'
22 | // 红包 是text: '[QQ红包]请使用新版手机QQ查收红包。'
23 |
24 | export type Message =
25 | | TextMessage
26 | | AtMessage
27 | | RichMessage
28 | | FaceMessage
29 | | EmojiMessage
30 | | ImageMessage
31 | | RecordMessage
32 | | BigFaceMessage
33 | | SmallFaceMessage
34 | | DiceMessage
35 | | RPSMessage;
36 |
37 | export class CQMessageHelper {
38 | // 由于配置文件的post_message_format (https://cqhttp.cc/docs/4.11/#/Configuration?id=%E9%85%8D%E7%BD%AE%E9%A1%B9) 可能为两种形式,所以需要做兼容处理,统一转换为数组类型
39 | static normalizeMessage(message: Message[] | string): Message[] {
40 | if (typeof message === 'string') {
41 | // TODO: 当配置是string时直接抛出,后续看情况考虑是否提供自定义解析(源码参考位置 src/cqsdk/message.cpp)
42 | throw new Error('请设置HTTP插件的配置文件的post_message_format为array');
43 | } else return message;
44 | }
45 |
46 | static removeAt(message: Message[]): Message[] {
47 | return message.filter(msg => msg.type !== 'at');
48 | }
49 | static isAt(robotQQ: number, messages: Message[]): boolean {
50 | return messages.some(msg => msg.type === 'at' && +msg.data.qq === robotQQ);
51 | }
52 |
53 | // 数组形式转为字符串形式
54 | static toRawMessage(messages: Message[], removeAt = false): string {
55 | const textTypes = ['text', 'emoji', 'sface', 'face'];
56 | if (!removeAt) textTypes.push('at');
57 | const text = messages
58 | .filter(msg => textTypes.includes(msg.type))
59 | .map(msg => {
60 | if (msg.type === 'text') return CQMessageHelper.escapeTextMessage(msg).data.text;
61 | if (msg.type === 'at') return `[CQ:at,qq=${msg.data.qq}]`;
62 | if (msg.type === 'emoji') return `[CQ:emoji,id=${msg.data.id}]`;
63 | if (msg.type === 'sface') return `[CQ:bface,id=${msg.data.id}]`;
64 | if (msg.type === 'face') return `[CQ:face,id=${msg.data.id}]`;
65 | })
66 | .join('')
67 | .trim();
68 | return text;
69 | }
70 |
71 | // 当用户发送的文本信息是比如 [CQ:emoji,id=128562],若不转义则会被当做emoji表情而不是一个普通文本
72 | static escapeTextMessage(message: TextMessage): TextMessage {
73 | const map = {
74 | '&': '&',
75 | '[': '[',
76 | ']': ']',
77 | };
78 | const escapedText = message.data.text
79 | .split('')
80 | .map(char => {
81 | if (char in map) return map[char];
82 | return char;
83 | })
84 | .join('');
85 | return {
86 | type: 'text',
87 | data: {
88 | text: escapedText,
89 | },
90 | };
91 | }
92 | }
93 |
94 | export class CQRawMessageHelper {
95 | static removeAt(str: string): string {
96 | const reg = /\[CQ:at,qq=\d+]/;
97 | return str.replace(reg, '').trim();
98 | }
99 |
100 | static isFileMessage(
101 | str: string
102 | ): {
103 | result: boolean;
104 | file?: string;
105 | path?: string;
106 | } {
107 | const reg = /\[CQ:image,file=(.+),url=(.+)]/;
108 | const res = reg.exec(str);
109 | if (res === null)
110 | return {
111 | result: false,
112 | };
113 | return {
114 | result: true,
115 | file: res[1],
116 | path: res[2],
117 | };
118 | }
119 |
120 | // https://d.cqp.me/Pro/CQ%E7%A0%81
121 | static parseCQ(
122 | cqStr
123 | ): {
124 | func: string;
125 | params: Record;
126 | } | null {
127 | try {
128 | const sIndex = cqStr.indexOf(':');
129 | const eIndex = cqStr.indexOf(',');
130 | const func = cqStr.slice(sIndex + 1, eIndex);
131 |
132 | const contentStr = cqStr.slice(eIndex + 1, -1);
133 | const params = {};
134 | contentStr.split(',').map(item => {
135 | // eslint-disable-next-line prefer-const
136 | let [k, v] = item.split('=');
137 | v = v
138 | .replace(/&/g, '&')
139 | .replace(/[/g, '[')
140 | .replace(/]/g, ']')
141 | .replace(/,/g, ',');
142 | try {
143 | v = JSON.parse(v);
144 | } catch (e) {
145 | //
146 | }
147 | params[k] = v;
148 | });
149 | return {
150 | func,
151 | params,
152 | };
153 | } catch (e) {
154 | return null;
155 | }
156 | }
157 | }
158 |
159 | export class CQMessageFromTypeHelper {
160 | static getMessageFromType({ message_type, sub_type }): MessageFromType {
161 | if (message_type === 'group' && sub_type === 'normal') return MessageFromType.qqGroupNormal;
162 | if (message_type === 'group' && sub_type === 'anonymous') return MessageFromType.qqGroupAnonymous;
163 | if (message_type === 'private' && sub_type === 'friend') return MessageFromType.userFriend;
164 | if (message_type === 'private' && sub_type === 'group') return MessageFromType.userGroup;
165 | if (message_type === 'private' && sub_type === 'other') return MessageFromType.userOther;
166 | // TODO: 文档没写讨论组的匿名信息,需要自测下!
167 | return MessageFromType.unknown;
168 | }
169 |
170 | // 是 用户消息
171 | static isUserMessage(messageFromType: MessageFromType): boolean {
172 | return (
173 | messageFromType === MessageFromType.userFriend ||
174 | messageFromType === MessageFromType.userGroup ||
175 | messageFromType === MessageFromType.userOther
176 | );
177 | }
178 |
179 | // 是 Q群消息
180 | static isQQGroupMessage(messageFromType: MessageFromType): boolean {
181 | return (
182 | CQMessageFromTypeHelper.isQQGroupNormalMessage(messageFromType) ||
183 | CQMessageFromTypeHelper.isQQGroupAnonymousMessage(messageFromType)
184 | );
185 | }
186 | // 是 Q群普通消息
187 | static isQQGroupNormalMessage(messageFromType: MessageFromType): boolean {
188 | return messageFromType === MessageFromType.qqGroupNormal;
189 | }
190 | // 是 Q群匿名消息
191 | static isQQGroupAnonymousMessage(messageFromType: MessageFromType): boolean {
192 | return messageFromType === MessageFromType.qqGroupAnonymous;
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/src/Command.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/unbound-method */
2 | import { assertType, type } from '@xhmm/utils';
3 | import { Message, CQMessageFromTypeHelper } from './CQHelper';
4 | import { HttpPlugin } from './HttpPlugin';
5 | import { HistoryMessage } from './Session';
6 | import { warn } from './logger';
7 |
8 | type OrPromise = T | Promise;
9 |
10 | // 命令生效范围
11 | export enum Scope {
12 | user = 'user', // 仅在和机器人私聊时刻触发该命令
13 | group = 'group', // 仅在群组内发消息时刻触发该命令
14 | both = 'both', // 私聊和群内都可触发该命令
15 | }
16 |
17 | // 群组内时命令触发方式
18 | export enum TriggerType {
19 | at = 'at', // at表示需要艾特机器人并输入内容方可触发
20 | noAt = 'noAt', //noAt表示需要直接输入内容不能艾特
21 | both = 'both', // both表示两种皆可
22 | }
23 |
24 | // 群组内什么身份可触发
25 | export enum TriggerScope {
26 | 'all' = 0b111, // 所有人
27 | 'owner' = 0b100, // 群主
28 | 'admin' = 0b010, // 管理员
29 | 'member' = 0b01, // 普通群员
30 | }
31 |
32 | export enum MessageFromType {
33 | 'userFriend' = 'userFriend',
34 | 'userGroup' = 'userGroup',
35 | 'userOther' = 'userOther', // cqhttp文档有个这类型,but not clear what exactly it is
36 |
37 | 'qqGroupNormal' = 'qqGroupNormal',
38 | 'qqGroupAnonymous' = 'qqGroupAnonymous',
39 |
40 | 'unknown' = 'unknown',
41 | }
42 |
43 | // 每个上报请求都可用下述字段来唯一标识
44 | export interface RequestIdentity {
45 | robot: number;
46 | messageFromType: MessageFromType;
47 | fromUser: number | AnonymousUser;
48 | fromGroup: number | undefined;
49 | }
50 |
51 | export interface AnonymousUser {
52 | id: number;
53 | name: string;
54 | flag: string; // 这个字段每次发消息都是会变的,在生成session key时须对其移除
55 | }
56 |
57 | export interface UserMessageInfo extends RequestIdentity {
58 | messageFromType:
59 | | MessageFromType.userFriend
60 | | MessageFromType.userGroup
61 | | MessageFromType.userOther;
62 | fromUser: number;
63 | fromGroup: undefined;
64 | }
65 | export interface QQGroupNormalMessageInfo extends RequestIdentity {
66 | messageFromType: MessageFromType.qqGroupNormal;
67 | fromUser: number;
68 | fromGroup: number;
69 | }
70 | export interface QQGroupAnonymousMessageInfo extends RequestIdentity {
71 | messageFromType: MessageFromType.qqGroupAnonymous;
72 | fromUser: AnonymousUser;
73 | fromGroup: number;
74 | }
75 |
76 |
77 | //// 类特殊函数
78 | interface BaseParams {
79 | message: Message[];
80 | rawMessage: string;
81 | requestBody: any;
82 | }
83 |
84 | // parse函数
85 | export interface ParseParams extends BaseParams, RequestIdentity {}
86 |
87 | // 用于设置session name和过期时间
88 | type SetNextFn = (sessionFunctionName: string, expireSeconds?: number) => Promise;
89 | // 用于删除session
90 | type SetEndFn = () => Promise;
91 | interface HandlerBaseParams extends BaseParams {
92 | setNext: SetNextFn;
93 | }
94 | // user函数
95 | export interface UserHandlerParams extends HandlerBaseParams, UserMessageInfo {
96 | data: D;
97 | }
98 |
99 | // group函数
100 | interface GroupHandlerBaseParams extends HandlerBaseParams {
101 | isAt: boolean;
102 | data: D;
103 | }
104 | export type GroupHandlerParams = GroupHandlerBaseParams &
105 | (QQGroupAnonymousMessageInfo | QQGroupNormalMessageInfo);
106 |
107 | // both函数
108 | export interface BothHandlerParams extends HandlerBaseParams, RequestIdentity {
109 | data: D;
110 | }
111 |
112 | // session函数
113 | export interface SessionHandlerParams extends HandlerBaseParams {
114 | setEnd: SetEndFn;
115 | historyMessage: HistoryMessage;
116 | }
117 |
118 | type HandlerParams = UserHandlerParams | GroupHandlerParams | BothHandlerParams;
119 | // return
120 | export type HandlerReturn =
121 | | {
122 | atSender: boolean;
123 | content: string;
124 | }
125 | | string[]
126 | | string
127 | | void;
128 |
129 | export type ParseReturn = any;
130 | export type UserHandlerReturn = HandlerReturn;
131 | export type GroupHandlerReturn = HandlerReturn;
132 | export type BothHandlerReturn = HandlerReturn;
133 | export type SessionHandlerReturn = HandlerReturn;
134 | //// 类特殊函数END
135 |
136 | export abstract class Command {
137 | context: C; // 值为create时传入的内容
138 | httpPlugin: HttpPlugin; // 值为create时传入的内容
139 |
140 | private _scope: Scope; // 该命令的作用域
141 | private _directives: string[]; // 该命令的指令名称
142 |
143 | private _includeGroup: number[] = []; // @include
144 | private _excludeGroup: number[] = []; // @exclude
145 | private _includeUser: number[] = []; // @include
146 | private _excludeUser: number[] = []; // @exclude
147 | private _triggerType: TriggerType = TriggerType.at; // @trigger
148 | private _triggerScope: TriggerScope = TriggerScope.all; // @scope
149 |
150 | constructor() {
151 | if (this.directive) assertType(this.directive, 'function');
152 | if (this.parse) assertType(this.parse, 'function');
153 | if (!this.directive && !this.parse) throw new Error('必须为Command类提供directive函数或parse函数');
154 |
155 | const hasUserHandler = type(this.user) === 'function';
156 | const hasGroupHandler = type(this.group) === 'function';
157 | const hasBothHandler = type(this.both) === 'function';
158 | if (hasBothHandler) this._scope = Scope.both;
159 | else if (hasGroupHandler && hasUserHandler) this._scope = Scope.both;
160 | else {
161 | if (!hasUserHandler && !hasGroupHandler) throw new Error('必须为Command类提供user函数或group函数或both函数');
162 | if (hasGroupHandler) this._scope = Scope.group;
163 | if (hasUserHandler) this._scope = Scope.user;
164 | }
165 |
166 | const defaultDirective = new.target.name + 'Default';
167 | if (type(this.directive) === 'function') {
168 | const directives = this.directive!();
169 | if (type(directives) === 'array' || directives.length !== 0) {
170 | this._directives = directives;
171 | return;
172 | } else this._directives = [defaultDirective];
173 | } else this._directives = [defaultDirective];
174 | }
175 |
176 |
177 | get scope(): Scope {
178 | return this._scope;
179 | }
180 | get directives(): string[] {
181 | return this._directives;
182 | }
183 |
184 | get excludeGroup(): number[] {
185 | return this._excludeGroup;
186 | }
187 | get includeGroup(): number[] {
188 | return this._includeGroup;
189 | }
190 | get includeUser(): number[] {
191 | return this._includeUser;
192 | }
193 | get excludeUser(): number[] {
194 | return this._excludeUser;
195 | }
196 | get triggerType(): TriggerType {
197 | return this._triggerType;
198 | }
199 | get triggerScope(): TriggerScope {
200 | return this._triggerScope;
201 | }
202 |
203 | directive?(): string[];
204 | parse?(params: ParseParams): OrPromise;
205 |
206 | user?(params: UserHandlerParams): OrPromise;
207 | group?(params: GroupHandlerParams): OrPromise;
208 | both?(params: BothHandlerParams): OrPromise;
209 | }
210 |
211 | //// 修饰器
212 | /**
213 | * @description 用于user和group函数。指定该选项时,只有这里面的qq/qq群可触发该命令
214 | * @param include qq/qq群号
215 | */
216 | export function include(include: number[]) {
217 | return function(proto, name) {
218 | if (name === 'group') {
219 | if ('_excludeGroup' in proto) throw new Error('@exclude and @include cannot used at the same time');
220 | proto._includeGroup = include;
221 | } else if (name === 'user') {
222 | if ('_excludeUser' in proto) throw new Error('@exclude and @include cannot used at the same time');
223 | proto._includeUser = include;
224 | } else warn('@include only works on user or group function');
225 | };
226 | }
227 |
228 | /**
229 | * @description 用于user和group函数。指定该选项时,这里面的qq/qq群不可触发该命令。
230 | * @param exclude qq/qq群号
231 | */
232 | export function exclude(exclude: number[]) {
233 | return function(proto, name, descriptor) {
234 | if (name === 'group') {
235 | if ('_includeGroup' in proto) throw new Error('@exclude and @include cannot used at the same time');
236 | proto._excludeGroup = exclude;
237 | } else if (name === 'user') {
238 | if ('_includeUser' in proto) throw new Error('@exclude and @include cannot used at the same time');
239 | proto._excludeUser = exclude;
240 | } else console.warn('@exclude only works on user or group function');
241 | };
242 | }
243 |
244 | /**
245 | * @description 用于group和both函数。设置群组内命令触发方式
246 | * @param type 触发方式
247 | */
248 | export function trigger(type: TriggerType) {
249 | return function(proto, name) {
250 | if (name !== 'group' && name !== 'both') {
251 | warn('@trigger only works on group or both function');
252 | } else proto._triggerType = type;
253 | };
254 | }
255 |
256 | /**
257 | * @description 用于group和both函数。设置群组内什么身份可触发命令
258 | * @param role 群员身份
259 | */
260 | export function scope(role: TriggerScope) {
261 | return function(proto, name, descriptor) {
262 | if (name !== 'group' && name !== 'both') {
263 | warn('@trigger only works on group or both function');
264 | } else proto._triggerScope = role;
265 | };
266 | }
267 | //// 修饰器END
268 |
269 | //// type guard
270 | // 用户消息
271 | export function fromUserMessage(p: HandlerParams): p is UserHandlerParams {
272 | return CQMessageFromTypeHelper.isUserMessage(p.messageFromType);
273 | }
274 | // q群所有消息
275 | export function fromQQGroupMessage(
276 | p: HandlerParams
277 | ): p is GroupHandlerBaseParams & (QQGroupNormalMessageInfo | QQGroupAnonymousMessageInfo) {
278 | return CQMessageFromTypeHelper.isQQGroupMessage(p.messageFromType);
279 | }
280 | // q群普通消息
281 | export function fromQQGroupNormalMessage(p: HandlerParams): p is GroupHandlerBaseParams & QQGroupNormalMessageInfo {
282 | return CQMessageFromTypeHelper.isQQGroupNormalMessage(p.messageFromType);
283 | }
284 | // q群匿名消息
285 | export function fromQQGroupAnonymousMessage(
286 | p: HandlerParams
287 | ): p is GroupHandlerBaseParams & QQGroupAnonymousMessageInfo {
288 | return CQMessageFromTypeHelper.isQQGroupAnonymousMessage(p.messageFromType);
289 | }
290 | //// type guard END
291 |
--------------------------------------------------------------------------------
/src/HttpPlugin.ts:
--------------------------------------------------------------------------------
1 | import nodeFetch from 'node-fetch';
2 | import { conditionalObjectMerge } from '@xhmm/utils';
3 |
4 | enum APIList {
5 | 'send_private_msg' = 'send_private_msg',
6 | 'send_group_msg' = 'send_group_msg',
7 | 'send_msg' = 'send_msg',
8 | // 'send_discuss_msg' = 'send_discuss_msg',
9 | 'delete_msg' = 'delete_msg',
10 | 'send_like' = 'send_like',
11 | 'set_group_kick' = 'set_group_kick',
12 | 'set_group_ban' = 'set_group_ban',
13 | 'set_group_anonymous_ban' = 'set_group_anonymous_ban',
14 | 'set_group_whole_ban' = 'set_group_whole_ban',
15 | 'set_group_admin' = 'set_group_admin',
16 | 'set_group_anonymous' = 'set_group_anonymous',
17 | 'set_group_card' = 'set_group_card',
18 | 'set_group_leave' = 'set_group_leave',
19 | 'set_group_special_title' = 'set_group_special_title',
20 | // 'set_discuss_leave' = 'set_discuss_leave',
21 | 'set_friend_add_request' = 'set_friend_add_request',
22 | 'set_group_add_request' = 'set_group_add_request',
23 | 'get_login_info' = 'get_login_info',
24 | 'get_stranger_info' = 'get_stranger_info',
25 | 'get_friend_list' = 'get_friend_list',
26 | 'get_group_list' = 'get_group_list',
27 | 'get_group_info' = 'get_group_info',
28 | 'get_group_member_info' = 'get_group_member_info',
29 | 'get_group_member_list' = 'get_group_member_list',
30 | 'get_cookies' = 'get_cookies',
31 | 'get_csrf_token' = 'get_csrf_token',
32 | 'get_credentials' = 'get_credentials',
33 | 'get_record' = 'get_record',
34 | 'get_image' = 'get_image',
35 | 'can_send_image' = 'can_send_image',
36 | 'can_send_record' = 'can_send_record',
37 | 'get_status' = 'get_status',
38 | 'get_version_info' = 'get_version_info',
39 | 'set_restart_plugin' = 'set_restart_plugin',
40 | 'clean_data_dir' = 'clean_data_dir',
41 | 'clean_plugin_log' = 'clean_plugin_log'
42 | }
43 | interface SendPrivateMsgResponse {
44 | message_id: number;
45 | }
46 | interface SendGroupMsgResponse {
47 | message_id: number;
48 | }
49 | interface SendMsgResponse {
50 | message_id: number;
51 | }
52 | type GetGroupListResponse = Array<{
53 | group_id: number;
54 | group_name: string;
55 | }>;
56 | type GetGroupMemberListResponse = Array<{
57 | group_id: number;
58 | user_id: number;
59 | nickname: string;
60 | card: string; // 群名片/备注
61 | sex: 'male' | 'female' | 'unknown';
62 | age: number;
63 | area: string;
64 | join_time: number;
65 | last_sent_time: string;
66 | level: string;
67 | role: 'owner' | 'admin' | 'member';
68 | unfriendly: boolean;
69 | title: string; // 专属头衔
70 | title_expire_time: number;
71 | card_changeable: boolean;
72 | }>;
73 | interface GetImageResponse {
74 | file: string; // 下载后的图片的本地路径
75 | }
76 |
77 | class HttpPluginError extends Error {
78 | private APIName: APIList;
79 | private retcode?: number;
80 | constructor(APIName: APIList, message: string, retcode?: number) {
81 | if (retcode) super(`${APIName} failed, ${message}(${retcode})`);
82 | else super(`${APIName} failed, ${message}`);
83 | this.name = this.constructor.name;
84 | this.APIName = APIName;
85 | if (retcode) this.retcode = retcode;
86 | Error.captureStackTrace(this, this.constructor);
87 | }
88 | }
89 |
90 | interface PluginConfig {
91 | accessToken?: string; // 需要搭配配置文件
92 | }
93 | export class HttpPlugin {
94 | endpoint: string;
95 | config: PluginConfig;
96 |
97 | constructor(endpoint: string, config?: PluginConfig) {
98 | this.endpoint = endpoint;
99 | this.config = config || {};
100 | }
101 |
102 | async sendPrivateMsg(personQQ: number, message: string, escape = false): Promise {
103 | return await this.getResponseData(APIList.send_private_msg, {
104 | user_id: personQQ,
105 | message,
106 | auto_escape: escape,
107 | });
108 | }
109 |
110 | async sendGroupMsg(groupQQ: number, message: string, escape = false): Promise {
111 | return await this.getResponseData(APIList.send_group_msg, {
112 | group_id: groupQQ,
113 | message,
114 | auto_escape: escape,
115 | });
116 | }
117 |
118 | async sendMsg(numbers: { user?: number; group?: number }, message: string, escape = false): Promise {
119 | return await this.getResponseData(APIList.send_msg, {
120 | user_id: numbers.user,
121 | group_id: numbers.group,
122 | message,
123 | auto_escape: escape,
124 | });
125 | }
126 |
127 | async getGroupList(): Promise {
128 | return await this.getResponseData(APIList.get_group_list);
129 | }
130 |
131 | async getGroupMemberList(groupQQ: number): Promise {
132 | return await this.getResponseData(APIList.get_group_member_list, {
133 | group_id: groupQQ,
134 | });
135 | }
136 |
137 | async downloadImage(cqFile: string): Promise {
138 | return await this.getResponseData(APIList.get_image, {
139 | file: cqFile,
140 | });
141 | }
142 |
143 | private async getResponseData(api: APIList, data?: Record): Promise {
144 | try {
145 | const response = await nodeFetch(`${this.endpoint}/${api}`, {
146 | method: 'POST',
147 | headers: conditionalObjectMerge({
148 | 'Content-Type': 'application/json'
149 | }, [
150 | this.config.accessToken !== undefined,
151 | {
152 | Authorization: `Bearer ${this.config.accessToken}`,
153 | },
154 | ]),
155 | body: JSON.stringify(data)
156 | });
157 | if (response.status === 200) {
158 | // https://cqhttp.cc/docs/#/API?id=%E5%93%8D%E5%BA%94%E8%AF%B4%E6%98%8E
159 | const { status, retcode, data } = await response.json();
160 | if (status === 'ok' && retcode === 0) return data;
161 | let reason = `请前往 https://cqhttp.cc/docs/#/API?id=响应说明 或 酷Q运行日志(不是http插件) 根据状态码${retcode}查询原因`;
162 | if (status === 'failed') {
163 | if (retcode === -23) reason = `找不到与目标QQ的关系,消息无法发送`;
164 | if (retcode === -34) reason = '机器人被禁言了';
165 | if (retcode === -38) reason = '接收者帐号错误或帐号不在该群组内';
166 | if (retcode === 100) reason = '参数缺失或参数无效(比如QQ号小于0、message字段无内容等)';
167 | }
168 | return Promise.reject(
169 | new HttpPluginError(api, reason, retcode)
170 | );
171 | } else {
172 | return Promise.reject(new HttpPluginError(api, `HTTP响应码是${response.status}`));
173 | }
174 | } catch (e) {
175 | throw new HttpPluginError(api, e.message);
176 | }
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/src/RobotFactory.ts:
--------------------------------------------------------------------------------
1 | import { Server } from "http";
2 | import { Request, Response } from 'express';
3 | import { hasRepeat, type } from '@xhmm/utils';
4 | import * as debugMod from 'debug';
5 | import { Command, Scope, TriggerType, SessionHandlerParams, TriggerScope, RequestIdentity, MessageFromType } from './Command';
6 | import { HttpPlugin } from './HttpPlugin';
7 | import { CQMessageHelper, CQRawMessageHelper, CQMessageFromTypeHelper } from './CQHelper';
8 | import { Session, SessionData } from './Session';
9 | import { error } from './logger';
10 |
11 | interface CreateParams {
12 | port: number;
13 | robot: number;
14 | httpPlugin: HttpPlugin;
15 | commands: Command[];
16 | session?: Session | null;
17 | secret?: string;
18 | context?: C; // 该对象下内容可在XxxCommand里使用this.context.xx访问
19 | }
20 | interface CreateReturn {
21 | start(): Promise;
22 | stop(): void;
23 | }
24 |
25 | type QQK = string; // 作为key时,qq是字符串
26 | type QQV = number; // 作为value时,qq是数字
27 | type PortK = string;
28 | type PortV = number;
29 | type Directive = string;
30 | type CommandsMap = Record<
31 | QQK,
32 | {
33 | qq: QQV;
34 | port: PortV;
35 | commands: CreateParams['commands'];
36 | session: CreateParams['session'];
37 | secret: string;
38 | httpPlugin: HttpPlugin;
39 | }
40 | >;
41 | type ServersMap = Record;
42 |
43 | export class RobotFactory {
44 | // 不同机器人和其命令以及运行在的node端口
45 | private static commandsMap: CommandsMap = {};
46 | // 不同端口和对应的node服务器
47 | private static serversMap: ServersMap = {};
48 |
49 | public static create({
50 | port,
51 | robot,
52 | httpPlugin,
53 | commands,
54 | session = null,
55 | secret = '',
56 | context,
57 | }: CreateParams): CreateReturn {
58 | // note: Object.keys(obj)返回的都是字符串类型!
59 |
60 | const debug = debugMod(`lemon-bot[QQ:${robot} PORT:${port}]`);
61 |
62 | const allDirectives: Directive[] = [];
63 | for (const command of commands) {
64 | allDirectives.push(...command.directives);
65 | }
66 | if (hasRepeat(allDirectives)) throw new Error(`Command类中出现了重复指令,请对重复得到指令名进行修改:\n${allDirectives}`);
67 |
68 | // 验证robotQQ是否合法
69 | if (Object.keys(RobotFactory.commandsMap).includes(robot + ''))
70 | throw new Error(`机器人${robot}已存在,不可重复创建`);
71 |
72 | // 缓存每个机器人可处理的命令
73 | if (session) debug(` - session函数处理已启用`);
74 | else debug(` - session函数处理未开启`);
75 | for (const [index, command] of Object.entries(commands)) {
76 | debug(
77 | ` - [命令] 指令集:${command.directives.join(',')} 解析函数:${command.parse ? '有' : '无'} 作用域:${
78 | command.scope
79 | } ${
80 | command.scope === Scope.user ? '' : `是否艾特:${command.triggerType}`
81 | }`
82 | );
83 | command.context = context; // 注册context
84 | command.httpPlugin = httpPlugin; // 注册httpPlugin
85 | }
86 | RobotFactory.commandsMap[robot + ''] = {
87 | commands: commands,
88 | port,
89 | session,
90 | qq: robot,
91 | secret,
92 | httpPlugin,
93 | };
94 |
95 | // 若该端口下服务器未创建,则创建并注册
96 | if (!Object.keys(RobotFactory.serversMap).includes(port + '')) {
97 | const app = createServer(RobotFactory.commandsMap, debug);
98 | RobotFactory.serversMap[port + ''] = [app, 'idle'];
99 | }
100 |
101 | // 启动服务器,即调用listen方法
102 | function start(): Promise {
103 | return new Promise((resolve, reject) => {
104 | const [app, status] = RobotFactory.serversMap[port];
105 | if (status === 'idle') {
106 | RobotFactory.serversMap[port][1] = 'listening';
107 | app
108 | .listen(port, () => {
109 | debug(` - ${port} 端口开始监听运行在 ${httpPlugin.endpoint} 的HTTP插件的事件上报`);
110 | resolve();
111 | })
112 | .on('error', err => {
113 | error(err);
114 | reject(err);
115 | });
116 | } else {
117 | debug(` - ${port} 端口开始监听运行在 ${httpPlugin.endpoint} 的HTTP插件的事件上报`);
118 | resolve();
119 | }
120 | });
121 | }
122 | // 停止当前机器人,则移除当前停止机器人的注册信息
123 | function stop(): void {
124 | delete RobotFactory.commandsMap[robot + ''];
125 | RobotFactory.serversMap[robot + ''][0].close((err) => {
126 | debug(` - ${port} 端口已停止监听运行在 ${httpPlugin.endpoint} 的HTTP插件的事件上报`);
127 | })
128 | }
129 | return {
130 | start,
131 | stop,
132 | };
133 | }
134 | }
135 |
136 | function createServer(commandsMap: Readonly, debug: any): Server {
137 | const express = require('express');
138 | const crypto = require('crypto');
139 | const http = require('http');
140 |
141 | const app = express();
142 | const server = http.createServer(app)
143 | app.use(express.json());
144 |
145 | app.post('/coolq', async (req: Request, res: Response) => {
146 | const requestRobot = +req.header('X-Self-ID')!;
147 | if (!requestRobot) {
148 | debug('[请求终止] 该请求无机器人头信息(X-Self-ID),不做处理');
149 | res.end();
150 | return;
151 | }
152 | if (!(requestRobot in commandsMap)) {
153 | debug(`[请求终止] 请求机器人${requestRobot}不在已注册的的机器人列表,请检查create的robot参数和酷Q登录的机器人是否一致`);
154 | res.end();
155 | return;
156 | }
157 |
158 | /*
159 | 机器人A的create传入port是8888,配置文件post_url是8888
160 | 机器人B的create传入port是8889,配置文件post_url误配为8888,本应是8889
161 | 当给机器人B发消息时会被8888端口服务器处理,故做下述判断解决该情况
162 | */
163 | const serverPort = commandsMap[requestRobot].port;
164 | if (serverPort !== server.address().port) {
165 | throw new Error(`端口号配置错误,请检查机器人${requestRobot}的HTTP插件配置文件的post_url端口号是否为${serverPort}`)
166 | }
167 | const secret = commandsMap[requestRobot].secret;
168 | if (secret) {
169 | let signature = req.header('X-Signature');
170 | if (!signature) throw new Error('无X-Signature请求头,请确保HTTP插件的配置文件配置了secret选项');
171 | signature = signature.split('=')[1];
172 |
173 | const hmac = crypto.createHmac('sha1', secret);
174 | hmac.update(JSON.stringify(req.body));
175 | const test = hmac.digest('hex');
176 | if (test !== signature) {
177 | debug('[请求终止] 消息体与签名不符,结束');
178 | res.end();
179 | return;
180 | }
181 | }
182 |
183 | //// 请求信息统一在此设置,后面代码不要使用req.body的值
184 | const session = commandsMap[requestRobot].session;
185 | const httpPlugin = commandsMap[requestRobot].httpPlugin;
186 | const commands = commandsMap[requestRobot].commands;
187 | const message = req.body.message && CQMessageHelper.normalizeMessage(req.body.message);
188 | const rawMessage = req.body.raw_message && req.body.raw_message;
189 | const messageFromType = CQMessageFromTypeHelper.getMessageFromType({ message_type: req.body.message_type, sub_type: req.body.sub_type });
190 | if (messageFromType === MessageFromType.unknown) {
191 | debug('[请求终止] 暂不支持的消息类型,不做处理');
192 | res.end();
193 | return;
194 | }
195 | const isAt = CQMessageHelper.isAt(requestRobot, message);
196 | const requestBody = req.body;
197 | const userRole = req.body.sender.role || 'member';
198 | const userNumber = CQMessageFromTypeHelper.isQQGroupAnonymousMessage(messageFromType) ? req.body.anonymous : req.body.user_id;
199 | if (typeof userNumber === 'object' && ('flag' in userNumber)) {
200 | delete userNumber.flag;
201 | }
202 | const groupNumber = req.body.group_id
203 | const robotNumber = requestRobot;
204 | //// 请求信息统一在此设置,后面代码不要使用req.body的值 END
205 |
206 | const requestIdentity: RequestIdentity = {
207 | messageFromType,
208 | fromGroup: groupNumber,
209 | fromUser: userNumber,
210 | robot: robotNumber,
211 | };
212 |
213 | const noSessionError = (): never => {
214 | throw new Error('未设置session参数,无法使用该函数');
215 | };
216 | let sessionData: SessionData | null = null;
217 | if (session) {
218 | sessionData = await session.getSession(requestIdentity);
219 | }
220 | // 若找到了sessionData,则必须要提供相关处理数据,否则报错
221 | if (sessionData) {
222 | for (const command of commands) {
223 | const className = command.constructor.name;
224 | if (sessionData.className !== className) continue;
225 | // @ts-ignore
226 | const sessionNames = Object.getOwnPropertyNames(command.__proto__)
227 | .filter(item => {
228 | // @ts-ignore
229 | return item.startsWith('session') && typeof command.__proto__[item] === 'function'
230 | });
231 | if (sessionNames.includes(sessionData.sessionName)) {
232 | const storedHistoryMessage = sessionData.historyMessage;
233 | if (sessionData.sessionName in storedHistoryMessage)
234 | storedHistoryMessage[sessionData.sessionName].push(message);
235 | else storedHistoryMessage[sessionData.sessionName] = [message];
236 | if (session) await session.updateSession(requestIdentity, 'historyMessage', storedHistoryMessage);
237 | const setNext = session
238 | ? session.setSession.bind(session, requestIdentity, {
239 | className: sessionData.className,
240 | historyMessage: storedHistoryMessage,
241 | })
242 | : noSessionError;
243 | const setEnd = session ? session.removeSession.bind(session, requestIdentity) : noSessionError;
244 | const sessionHandlerParams: SessionHandlerParams = {
245 | setNext,
246 | setEnd,
247 | ...requestIdentity,
248 | message,
249 | requestBody,
250 | rawMessage,
251 | historyMessage: sessionData.historyMessage,
252 | };
253 | const replyData = await command[sessionData.sessionName].call(command, sessionHandlerParams);
254 | await handleReplyData(res, replyData, {
255 | userNumber,
256 | groupNumber,
257 | httpPlugin,
258 | });
259 | debug(`[消息处理] 使用${className}类的session${sessionData.sessionName}函数处理完毕`);
260 | return;
261 | }
262 | }
263 | res.end();
264 | await session!.removeSession(requestIdentity);
265 | debug(
266 | `[消息处理] 未在${sessionData.className}类中找到与缓存匹配的${
267 | sessionData.sessionName
268 | }函数,当前会话已重置`
269 | );
270 | }
271 | // 若无session或是sessionData为null,则按正常流程解析并处理指令
272 | else {
273 | for (const command of commands) {
274 | const className = command.constructor.name;
275 | const { includeGroup, excludeGroup, includeUser, excludeUser, scope, directives, triggerScope, triggerType } = command;
276 | const parse = command.parse && command.parse.bind(command);
277 | const user = command.user && command.user.bind(command);
278 | const group = command.group && command.group.bind(command);
279 | const both = command.both && command.both.bind(command);
280 |
281 | // 判断当前命令和消息源是否匹配
282 | const matchGroupScope =
283 | (scope === Scope.group || scope === Scope.both) && CQMessageFromTypeHelper.isQQGroupMessage(messageFromType) ;
284 | const matchUserScope = (scope === Scope.user || scope === Scope.both) && CQMessageFromTypeHelper.isUserMessage(messageFromType);
285 |
286 | if (matchGroupScope || matchUserScope) {
287 | if (matchGroupScope) {
288 | if (triggerType === TriggerType.at && !isAt) continue;
289 | if (triggerType === TriggerType.noAt && isAt) continue;
290 |
291 | if (!includeGroup.includes(groupNumber)) continue;
292 | if (excludeGroup.includes(groupNumber)) continue;
293 |
294 | // @ts-ignore
295 | if ((TriggerScope[userRole] & triggerScope) === 0) continue;
296 | }
297 | if (matchUserScope) {
298 | if (!includeUser.includes(userNumber)) continue;
299 | if (excludeUser.includes(userNumber)) continue;
300 | }
301 |
302 | // --- 根据指令或解析函数进行处理
303 | let parsedData = null;
304 | const baseInfo = {
305 | requestBody,
306 | message,
307 | rawMessage,
308 | };
309 | if (parse) {
310 | // 若parse函数返回非undefined,表明解析成功,否则继续循环
311 | parsedData = await parse({
312 | ...requestIdentity,
313 | ...baseInfo,
314 | });
315 | if (typeof parsedData === 'undefined') {
316 | continue;
317 | }
318 | debug(`[消息处理] 使用${className}类的parse函数处理通过`);
319 | }
320 | // 若无parse函数,则直接和指令集进行相等性匹配,不匹配则继续循环
321 | else {
322 | if (!directives.includes(CQRawMessageHelper.removeAt(rawMessage))) continue;
323 | debug(`[消息处理] 使用${className}类的指令集处理通过`);
324 | }
325 |
326 | let replyData;
327 | // 若提供了both函数,则不再调用user/group函数
328 | if ((matchUserScope || matchGroupScope) && both) {
329 | replyData = await both({
330 | ...baseInfo,
331 | ...requestIdentity,
332 | data: parsedData,
333 | setNext: session
334 | ? session.setSession.bind(session, requestIdentity, {
335 | className,
336 | historyMessage: {
337 | both: [message],
338 | },
339 | })
340 | : noSessionError,
341 | });
342 | debug(
343 | `[消息处理] 使用${className}类的both函数处理完毕${typeof replyData === 'undefined' ? '(无返回值)' : ''}`
344 | );
345 | } else {
346 | if (matchGroupScope && group) {
347 | replyData = await group({
348 | ...baseInfo,
349 | ...requestIdentity,
350 | // @ts-ignore
351 | messageFromType: requestIdentity.messageFromType,
352 | data: parsedData,
353 | isAt,
354 | setNext: session
355 | ? session.setSession.bind(session, requestIdentity, {
356 | className,
357 | historyMessage: {
358 | group: [message],
359 | },
360 | })
361 | : noSessionError,
362 | });
363 | debug(
364 | `[消息处理] 使用${className}类的group函数处理完毕${
365 | typeof replyData === 'undefined' ? '(无返回值)' : ''
366 | }`
367 | );
368 | }
369 | if (matchUserScope && user) {
370 | replyData = await user({
371 | ...baseInfo,
372 | ...requestIdentity,
373 | // @ts-ignore
374 | messageFromType: requestIdentity.messageFromType,
375 | data: parsedData,
376 | setNext: session
377 | ? session.setSession.bind(session, requestIdentity, {
378 | className,
379 | historyMessage: {
380 | user: [message],
381 | },
382 | })
383 | : noSessionError,
384 | });
385 | debug(
386 | `[消息处理] 使用${className}类的user函数处理完毕${typeof replyData === 'undefined' ? '(无返回值)' : ''}`
387 | );
388 | }
389 | }
390 | await handleReplyData(res, replyData, {
391 | userNumber,
392 | groupNumber,
393 | httpPlugin,
394 | });
395 | return;
396 | }
397 | }
398 | }
399 | res.end();
400 | return;
401 | });
402 |
403 | return server;
404 | }
405 |
406 | async function handleReplyData(
407 | res: Response,
408 | replyData,
409 | deps: {
410 | httpPlugin: HttpPlugin;
411 | userNumber?: number;
412 | groupNumber?: number;
413 | }
414 | ): Promise {
415 | const replyType = type(replyData);
416 | if (replyType === 'array') {
417 | for (const reply of replyData as string[]) {
418 | await deps.httpPlugin.sendMsg(
419 | {
420 | user: deps.userNumber,
421 | group: deps.groupNumber,
422 | },
423 | reply.toString()
424 | );
425 | }
426 | res.end();
427 | return;
428 | } else if (replyType === 'object') {
429 | res.json({
430 | at_sender: typeof replyData.atSender === 'boolean' ? replyData.atSender : false,
431 | reply: replyData.content || 'Hi',
432 | });
433 | return;
434 | } else if (replyType === 'string') {
435 | res.json({
436 | at_sender: false,
437 | reply: replyData as string,
438 | });
439 | return;
440 | } else {
441 | try {
442 | // 尝试转换为字符串,若不为空则返回
443 | const str = replyData.toString();
444 | if (str)
445 | res.json({
446 | at_sender: false,
447 | reply: str as string,
448 | });
449 | else res.end();
450 | return;
451 | } catch (e) {
452 | res.end();
453 | }
454 | }
455 | }
456 |
--------------------------------------------------------------------------------
/src/Session.ts:
--------------------------------------------------------------------------------
1 | import { assertType } from '@xhmm/utils';
2 | import * as IORedis from 'ioredis';
3 | import * as debugMod from 'debug';
4 | import { Message } from './CQHelper';
5 | import { RequestIdentity } from './Command';
6 |
7 | type SessionKey = string;
8 |
9 | export type HistoryMessage = Record>; // string是session函数的名称
10 |
11 | export interface SessionData extends RequestIdentity {
12 | className: string; // 本次会话的session所属类
13 | sessionName: string; // 本次会话需要被执行的session函数
14 | historyMessage: HistoryMessage;
15 | }
16 |
17 | export class Session {
18 | private static readonly debug = debugMod(`lemon-bot[Session]`);
19 | private readonly redis: IORedis.Redis;
20 |
21 | constructor(port?: number, host?: string, options?: IORedis.RedisOptions);
22 | constructor(host?: string, options?: IORedis.RedisOptions);
23 | constructor(options?: IORedis.RedisOptions);
24 | constructor(...options: any) {
25 | this.redis = new IORedis(options);
26 | }
27 |
28 | private static genSessionKey(params: RequestIdentity): SessionKey {
29 | const clone: RequestIdentity = JSON.parse(JSON.stringify(params));
30 | if (typeof clone.fromUser === 'object') {
31 | if (clone.fromUser.flag) delete clone.fromUser.flag;
32 | }
33 | const key = Object.values(clone).join('-');
34 | return key;
35 | }
36 |
37 | async getSession(params: RequestIdentity): Promise {
38 | const key = Session.genSessionKey(params)
39 | const data = await this.redis.hgetall(key);
40 | if (Object.keys(data).length !== 0) {
41 | Object.keys(data).map(key => {
42 | try {
43 | data[key] = JSON.parse(data[key] + '');
44 | } catch (e) {
45 | //
46 | }
47 | });
48 | Session.debug(`获取到key为${key}的session记录`);
49 | return data as unknown as SessionData;
50 | }
51 | return null;
52 | }
53 |
54 | async setSession(
55 | params: RequestIdentity,
56 | data: Omit,
57 | sessionName: SessionData['sessionName'],
58 | expireSeconds = 300
59 | ): Promise {
60 | const key = Session.genSessionKey(params);
61 | sessionName = sessionName.toString();
62 | assertType(sessionName, 'string');
63 |
64 | if (!sessionName.startsWith('session')) sessionName = 'session' + sessionName;
65 |
66 | const storedData: SessionData = {
67 | ...params,
68 | className: data.className,
69 | historyMessage: data.historyMessage,
70 | sessionName,
71 | };
72 |
73 | await this.redis.hmset(
74 | key,
75 | ...Object.entries(storedData).map(([key, val]) => [key, typeof val === 'undefined' ? '' : JSON.stringify(val)])
76 | );
77 | await this.redis.expire(key, expireSeconds);
78 | Session.debug(`Key is ${key}: 函数名为${sessionName}的session函数已建立,时长${expireSeconds}秒`);
79 | }
80 |
81 | async updateSession(
82 | params: RequestIdentity,
83 | hashKey: T,
84 | val: SessionData[T]
85 | ): Promise {
86 | const key = Session.genSessionKey(params);
87 | await this.redis.hset(key, hashKey, JSON.stringify(val));
88 | Session.debug(`Key is ${key}: 该session的${hashKey}字段已被更新`);
89 | }
90 |
91 | async removeSession(params: RequestIdentity): Promise {
92 | const key = Session.genSessionKey(params);
93 | await this.redis.del(key);
94 | Session.debug(`Key is ${key}: 该session会话已被清除`);
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './RobotFactory';
2 | export * from './Command';
3 | export * from './Session';
4 | export * from './HttpPlugin';
5 | export * from './CQHelper';
6 |
--------------------------------------------------------------------------------
/src/logger.ts:
--------------------------------------------------------------------------------
1 | export function warn(msg: string): void {
2 | console.log(`[warning] ${msg}`);
3 | }
4 |
5 | export function error(msg: any): void {
6 | console.error(msg);
7 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6", // cannot be es5 because I have 'extends Error' code, you can google "typescript extends error" for why
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "alwaysStrict": true,
7 | "lib": ["dom", "es2015", "es2016", "es2017", "es2018"],
8 | "strictNullChecks": true,
9 | "experimentalDecorators": true,
10 | "outDir": "build",
11 | "strictBindCallApply": true,
12 | "declaration": true,
13 | "removeComments": true,
14 | "resolveJsonModule": true,
15 | "inlineSourceMap": true
16 | },
17 | "include": ["src"],
18 | "exclude": ["node_modules"]
19 | }
20 |
--------------------------------------------------------------------------------