├── .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 | --------------------------------------------------------------------------------