├── . babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── apidoc.json ├── assets └── main.css ├── components └── Say.vue ├── config.ts ├── global.d.ts ├── layouts ├── admin.vue ├── default.vue └── error.vue ├── logs ├── error │ └── .gitkeep ├── info │ └── .gitkeep └── response │ └── .gitkeep ├── nuxt.config.ts ├── package.json ├── pages ├── admin │ ├── enume.js │ ├── friend │ │ └── index.vue │ ├── group │ │ └── index.vue │ ├── index.vue │ ├── reply │ │ ├── index.vue │ │ └── modal.vue │ └── task │ │ ├── index.vue │ │ └── modal.vue ├── auth │ └── login.vue └── index.vue ├── plugins ├── antd-ui.ts └── axios.ts ├── pm2.config.js ├── screenshots └── admin.png ├── server ├── bot │ ├── index.ts │ └── lib │ │ ├── FriendShip.ts │ │ ├── Login.ts │ │ ├── Message.ts │ │ ├── Room.ts │ │ └── Task.ts ├── config │ ├── db.ts │ └── log4js.ts ├── controller │ ├── admin.ts │ └── robot.ts ├── index.ts ├── middleware │ ├── botLogin.ts │ ├── getUser.ts │ └── resformat.ts ├── models │ ├── auth.ts │ ├── friend.ts │ ├── group.ts │ ├── memory.ts │ ├── reply.ts │ ├── robot.ts │ └── task.ts ├── routes │ └── api.ts └── util │ ├── ajax.ts │ ├── index.ts │ ├── logger.ts │ └── tiangou.ts ├── static ├── favicon.ico ├── tgrj.jpg └── tgrj2.jpg ├── store ├── index.ts └── module │ └── robot.ts ├── tsconfig.json └── typings ├── auth.d.ts ├── friend.d.ts ├── group.d.ts ├── index.d.ts ├── memory.d.ts ├── reply.d.ts ├── robot.d.ts ├── task.d.ts └── vue-shim.d.ts /. babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .nuxt 4 | .eslintrc.js 5 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | }, 7 | parserOptions: { 8 | parser: '@typescript-eslint/parser', 9 | }, 10 | extends: ['@nuxtjs', 'plugin:nuxt/recommended'], 11 | plugins: ['@typescript-eslint'], 12 | // add your custom rules here 13 | rules: { 14 | 'nuxt/no-cjs-in-config': 'off', 15 | 'no-useless-catch': 'off', 16 | 'no-console': 'off', 17 | 'no-throw-literal': 'off', 18 | 'require-await': 'off', 19 | 'no-case-declarations': 'off', 20 | 'default-param-last': 'off', 21 | 'prefer-regex-literals': 'off', 22 | 'no-new': 'off', 23 | 'import/no-mutable-exports': 'off', 24 | 'comma-dangle': [2, 'always-multiline'], 25 | indent: ['error', 2, { SwitchCase: 1 }], 26 | 'vue/this-in-template': 'off', 27 | 'vue/no-mutating-props': 'off', 28 | 'vue/require-prop-types': 'off', 29 | 'vue/custom-event-name-casing': 'off', 30 | 'vue/no-parsing-error': ['error', { 31 | 'control-character-in-input-stream': false, 32 | }], 33 | /* 更多配置请戳 http://eslint.cn/docs/rules/ */ 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################################################ 2 | ############### .gitignore ################## 3 | ################################################ 4 | # 5 | # This file is only relevant if you are using git. 6 | # 7 | # Files which match the splat patterns below will 8 | # be ignored by git. This keeps random crap and 9 | # sensitive credentials from being uploaded to 10 | # your repository. It allows you to configure your 11 | # app for your machine without accidentally 12 | # committing settings which will smash the local 13 | # settings of other developers on your team. 14 | # 15 | # Some reasonable defaults are included below, 16 | # but, of course, you should modify/extend/prune 17 | # to fit your needs! 18 | ################################################ 19 | 20 | 21 | 22 | 23 | ################################################ 24 | # Local Configuration 25 | # 26 | # Explicitly ignore files which contain: 27 | # 28 | # 1. Sensitive information you'd rather not push to 29 | # your git repository. 30 | # e.g., your personal API keys or passwords. 31 | # 32 | # 2. Environment-specific configuration 33 | # Basically, anything that would be annoying 34 | # to have to change every time you do a 35 | # `git pull` 36 | # e.g., your local development database, or 37 | # the S3 bucket you're using for file uploads 38 | # development. 39 | # 40 | ################################################ 41 | 42 | .nuxt 43 | dist 44 | tmp 45 | *-card.json 46 | .local.config.* 47 | static/apidoc/* 48 | dist.* 49 | .nuxt.* 50 | build.sh 51 | 52 | 53 | 54 | ################################################ 55 | # Dependencies 56 | # 57 | # When releasing a production app, you may 58 | # consider including your node_modules and 59 | # bower_components directory in your git repo, 60 | # but during development, its best to exclude it, 61 | # since different developers may be working on 62 | # different kernels, where dependencies would 63 | # need to be recompiled anyway. 64 | # 65 | # More on that here about node_modules dir: 66 | # http://www.futurealoof.com/posts/nodemodules-in-git.html 67 | # (credit Mikeal Rogers, @mikeal) 68 | # 69 | # About bower_components dir, you can see this: 70 | # http://addyosmani.com/blog/checking-in-front-end-dependencies/ 71 | # (credit Addy Osmani, @addyosmani) 72 | # 73 | ################################################ 74 | 75 | node_modules 76 | bower_components 77 | 78 | 79 | 80 | 81 | 82 | ################################################ 83 | # Node.js / NPM 84 | # 85 | # Common files generated by Node, NPM, and the 86 | # related ecosystem. 87 | ################################################ 88 | lib-cov 89 | *.seed 90 | *.log 91 | *.out 92 | *.pid 93 | npm-debug.log 94 | 95 | 96 | 97 | 98 | 99 | ################################################ 100 | # Miscellaneous 101 | # 102 | # Common files generated by text editors, 103 | # operating systems, file systems, etc. 104 | ################################################ 105 | 106 | *~ 107 | *# 108 | .DS_STORE 109 | .netbeans 110 | nbproject 111 | .idea 112 | .node_history 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wxbot 2 | 3 | 微信机器人,个人微信号小助手平台, nodejs + nuxt + wechaty 技术栈 4 | 5 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-green.svg)](https://github.com/chatie/wechaty) 6 | [![Wechaty开源激励计划](https://img.shields.io/badge/Wechaty-开源激励计划-green.svg)](https://github.com/juzibot/Welcome/wiki/Everything-about-Wechaty) 7 | 8 | ## 准备 9 | 10 | 微信机器人开源库调研,[GitHub](https://github.com/) 找到以下 3 个开源作品: 11 | 12 | - [itchat](https://github.com/littlecodersh/ItChat) 13 | - `itchat` 是一个开源的微信个人号接口,使用 `python` 调用微信 14 | - 使用不到 `30` 行的代码,你就可以完成一个能够处理所有信息的微信机器人 15 | - [wechaty](https://github.com/wechaty/wechaty) 16 | - `wechaty` 是适用于微信个人的 `Bot SDK` ,可以使用 `6` 行 `js` 创建一个机器人 17 | - 具有包括 `linux`,`Windows`,`MacOS` 和 `Docker` 在内的跨平台支持,基于 `Node.js` 18 | - [vbot](https://github.com/Hanson/vbot) 19 | - `vbot` 是基于微信 `web` 版的接口,使用 `http 协议` 以及轮询方式实现 20 | - 亮点在于通过匿名函数,能够实现多种有趣的玩法 21 | - 通过 `API`,更方便的打造属于自己的网页版微信,基于 `PHP` 22 | 23 | ## 初识 wechaty 24 | 25 | `Wechaty` 是一个开源的的对话机器人 `SDK`,支持 `个人号` 微信。它是一个使用 `Typescript` 构建的 `Node.js` 应用。支持多种微信接入方案,包括网页,`ipad`,`ios`,`windows`,`android` 等。同时支持 `Linux`, `Windows`, `Darwin(OSX/Mac)` 和 `Docker` 多个平台。 26 | 27 | 先看一下官方文档: 28 | 29 | - [wechaty-github](https://github.com/wechaty/wechaty) 30 | - [wechaty中文文档](https://wechaty.js.org/v/zh/) 31 | 32 | 只需要 6 行代码,你就可以 通过个人号 搭建一个 微信机器人功能 ,用来自动管理微信消息。 33 | 34 | ```js 35 | import { Wechaty } from 'wechaty' 36 | 37 | Wechaty.instance() 38 | .on('scan', qrcode => console.log('扫码登录:' + qrcode)) 39 | .on('login', user => console.log('登录成功:' + user)) 40 | .on('message', message => console.log('收到消息:' + message)) 41 | .on('friendship', friendship => console.log('收到好友请求:' + friendship)) 42 | .on('room-invite', invitation => console.log('收到入群邀请:' + invitation)) 43 | .start() 44 | ``` 45 | 46 | 更多功能包括: 47 | 48 | - 消息处理:关键词回复 49 | - 群管理:自动入群,拉人,踢人 50 | - 自动处理好友请求 51 | - 智能对话:通过简单配置,即可加入智能对话系统,完成指定任务 52 | - ... 请自行开脑洞 53 | 54 | 好了,文档齐全 & api 丰富,完全满足我的需求,就选这个库了。 55 | 56 | 首先跑一个示例看看 [wechaty-getting-started](https://github.com/wechaty/wechaty-getting-started)。下载完之后先 `npm install & npm start` 一顿操作,然后运行就有了登录二维码,拿出手机扫码,然后 GG。 57 | 58 | ## 基于 Web 微信的限制 59 | 60 | 查找资料 [基于nodejs + wachaty开发微信机器人平台](https://juejin.im/post/6844904158886117383),发现已经有大佬踩过坑了。 61 | 62 | 原来2017年之后注册的微信号都无法登录网页版微信,而2017年之前注册得微信账号也有很大几率登录不上,找朋友试了也都不行。 63 | 64 | 检验你的微信号是否支持网页微信登录: 65 | 66 | 67 | 68 | 点击链接链接,PC端进入然后手机扫码登录,若是可以登上,即可以使用上述示例 69 | 70 | 然后又去看了 vbot 和 itchat,但发现也都是是基于网页协议实现的 71 | 72 | 从网上查资料,大概有一下几种实现方式: 73 | 74 | - Web网页端:2017年后不再支持新号登录,仅支持老号,并且掉线严重,功能缺失严重 75 | - Xposed技术:在2019年6月份,微信官方在行业重点打击Xposed,自此行业内一片哀嚎遍野,陆续向iPad/MAC协议转型。具体案例请点击 76 | - PC Hook:代码注入型,也就是逆向开发。封号情况偏多,使用容易出现追封,公司大规模封号等情况,且目前在营销行业使用率较少,比较偏小团队使用 77 | - 模拟机:延迟高、消息实时到达率低、模拟人为操作效率慢、功能偏少,承担不了商业化功能 78 | - ipad协议:安全性较好,功能满足,行业占有率高,但具有能力研发人员偏少,基本两三个团队研发,且目前已有团队解散,部分微信号段登录失败、且通过grpc,mmtls研发,被检测几率存在 79 | - MAC协议:安全性相比iPad协议更好,功能性相比ipad协议少些,行业内具有研发能力更少,安全性、稳定性比较优秀,不会出现追封、批量封的情况 80 | - 混合通道:微信内部通道,最高权限,基于MAC与Ipad协议,非grpc,mmtls,功能合适,微信正版通道,不会出现技术封号问题 81 | 82 | 看了看,内部通道是不可能的,只有ipad协议个mac协议目前最好了 83 | 84 | ## wechaty-puppet 85 | 86 | ~~使用 [wechaty-puppet-padplus](https://github.com/wechaty/wechaty-puppet-padplus) 一套基于 ipad 协议的包。~~ 87 | 88 | 目前 ~~wechaty-puppet-padplus~~ 已弃用,后续使用 [wechaty-puppet-service](https://github.com/wechaty/wechaty-puppet-service)。 89 | 90 | 不过天下没有免费的午餐,需要申请 `token`,见 [Wechaty Token 申请及使用文档和常见问题](https://github.com/juzibot/Welcome/wiki/Everything-about-Wechaty)。 91 | 92 | ## 聊天机器人 API 93 | 94 | 目前网络上有许多非常好的智能聊天机器人,这里找了6个目前使用很广泛的: 95 | 96 | - [海知智能](http://docs.ruyi.ai/416309) 功能很强大,不仅仅用于聊天。需申请 key,免费 97 | - [思知对话机器人](https://www.ownthink.com/) 注册很简单,调用也很简单,而且完全免费 98 | - [图灵机器人](http://www.turingapi.com/) 需要注册账号,可以申请 5 个机器人,未认证账户每个机器人只能回 3 条/天,认证账户每个机器人能用 100 条/天 99 | - [青云客智能机器人](http://api.qingyunke.com/) 无须申请,无数量限制,但有点智障,分手神器,慎用 100 | - [腾讯闲聊](https://ai.qq.com/console/capability/detail/8) 需要注册和申请,还需要加密处理 101 | - [天行机器人](https://www.tianapi.com/apiview/47) 白嫖用户绑定微信后有 10000 次永久额度,之后 1 元 10000 次 102 | 103 | ## 搭建微信机器人平台 104 | 105 | 项目初始参考 [`wxbot`](https://github.com/beclass/wxbot) 搭建机器人后台管理。 106 | 107 | ### 项目介绍 108 | 109 | - 控制台 110 | - 绑定机器人 111 | - 登录 112 | - 自动通过好友验证关键词设置,当有人添加机器人时,关键词匹配后直接通过 113 | - 好友验证通过自动回复 114 | - 退出 115 | - 自动回复 116 | - 普通消息 117 | - 针对好友/某个群聊/所有群聊 设置关键词自动回复 118 | - 加群邀请 119 | - 机器人回复群聊列表,好友可以选择性进群 120 | - 踢人指令 121 | - 机器人识别指令,自动把成员移出群聊 122 | - 我的好友 123 | - 单独对某个好友送消息 124 | - 我的群聊 125 | - 群聊列表,管理所有群聊 126 | - 设置群聊名称,发布公告,发送群消息 127 | - 设置群聊基本信息,入群欢迎语,成员违规次数上限,是否受机器人控制 128 | - 定时任务 129 | - 针对好友/某个群聊/所有群聊设置定时任务,机器人在指定时间会触发消息推送 130 | - 智能聊天 131 | - 低智商对话 132 | - 成语接龙,查天气,查酒店,歇后语... 133 | 134 | ![admin](./screenshots/admin.png) 135 | 136 | ### 技术构成 137 | 138 | - 服务端 [Node.js](https://nodejs.org/) 139 | - SSR框架 [NuxtJS](https://nuxtjs.org/) 140 | - 前端框架 [Vue](https://vuejs.org/) 141 | - UI组件 [Ant Design of Vue](https://www.antdv.com/docs/vue/introduce-cn/) 142 | - 持久化 [MongoDB](https://www.mongodb.org/) 143 | - 协议 [wechaty-puppet-service](https://github.com/wechaty/wechaty-puppet-service) 144 | 145 | 这里就直接介绍下机器人模块 146 | 147 | ```js 148 | |-- server/ 149 | |———- /lib 150 | |------ FriendShip.js # 友谊关系,好友添加监听 151 | |------ Login.js # 机器人登录退出 152 | |------ Message.js # 消息监听处理 153 | |------ Room.js # 加群,退出群聊 154 | |------ Task # 机器人定时任务 155 | |———- index.js # 入口文件 156 | ``` 157 | 158 | ### 快速开始 159 | 160 | #### 准备条件 161 | 162 | - 安装 [Node.js](https://nodejs.org/en/download/) (v10 以上版本)、[MongoDB](https://www.mongodb.org/downloads/)。 163 | - 推荐安装 [cnpm](https://cnpmjs.org/) 164 | 165 | #### 安装依赖 166 | 167 | ```sh 168 | cnpm i 169 | ``` 170 | 171 | #### 启动服务 172 | 173 | - 开发模式 174 | 175 | ```sh 176 | npm run dev 177 | ``` 178 | 179 | - 生产模式 180 | 181 | 先编译项目 182 | 183 | ```sh 184 | npm run build 185 | ``` 186 | 187 | 再启动服务 188 | 189 | ```sh 190 | npm start 191 | ``` 192 | 193 | 打开浏览器,访问 [http://localhost:3000/](http://localhost:3000) 194 | 195 | #### 系统配置 196 | 197 | 根据实际情况修改 `config.js` 配置文件,修改后需要重启服务才能生效。 198 | 参数说明: 199 | 200 | ##### host 201 | 202 | `String` 类型,主机名,配置为 `0.0.0.0` 表示监听任意主机。 203 | 204 | ##### port 205 | 206 | `Number` 类型,端口号。 207 | 208 | ##### mongoUrl 209 | 210 | `String` 类型,MongoDB 链接。 211 | 212 | ##### secret 213 | 214 | `String` 类型,[JWT](https://github.com/auth0/node-jsonwebtoken) 秘钥。 215 | 216 | ##### tianApiKey 217 | 218 | `String` 类型,[天行数据秘钥](https://www.tianapi.com/console/) 219 | 220 | ### 线上部署 221 | 222 | #### 使用PM2 223 | 224 | 推荐使用 [pm2](https://pm2.keymetrics.io/) 进行 `Node.js` 的进程管理和持久运行。 225 | 226 | ##### 安装 227 | 228 | ```sh 229 | cnpm i -g pm2 230 | ``` 231 | 232 | ##### 启动 233 | 234 | ```sh 235 | pm2 start pm2.config.js 236 | ``` 237 | 238 | ### 踩坑 239 | 240 | 1.`Wechaty Token` 申请及使用文档和常见问题 241 | 242 | - [Wechaty 开源激励计划2.0 申请表](https://juzibot.wjx.cn/jq/80103105.aspx) 243 | - [填写项目信息](https://github.com/juzibot/Welcome/wiki/Support-Developers) 244 | - [Wechaty Token 申请及使用文档和常见问题](https://github.com/juzibot/Welcome/wiki/Everything-about-Wechaty) 245 | 246 | 2.`tianApiKey` 申请及天行机器人配置 247 | 248 | - 首先,去 [天行数据](https://www.tianapi.com/console/) 注册账号,申请 `APIKEY` 249 | - 其次,申请 [天行机器人](https://www.tianapi.com/apiview/47) 接口,用于机器人自动回复 250 | - 最后,别忘记配置 [机器人身份设置](https://www.tianapi.com/console/),否则在机器人回复中会有奇怪的代码串,如 `{robotname}` 251 | 252 | 3.部署中执行 `sudo pm2` 报错 `command not found` 问题 253 | 254 | - 原因是没有将 `pm2` 加至环境变量中,先找到 `node` 的目录 可以用 `whereis node` 来查找,然后查找 `whereis pm2`,再使用 `ln` 建立软连接 255 | - 参考文章 [Linux下使用pm2部署node以及安装后command not found解决](https://blog.csdn.net/d597180714/article/details/82619735) 256 | 257 | 4.部署中执行 `sudo pm2` 报错 `permission denied` 问题 258 | 259 | - 原因是项目会动态生成中 `logs` 目录中的文件,报错权限不足,即 `permission denied` 260 | - 参考文章 [解决 pm2 中的 permission denied 问题](https://blog.csdn.net/geol200709/article/details/81744477) 261 | 262 | 4.Ubuntu MaxReports 报错问题 263 | 264 | - 安装报错依赖关系问题 265 | - 参考文章 [Ubuntu16.04 由于已经达到 MaxReports 限制,没有写入 apport 报告](https://blog.csdn.net/lanhaixuanvv/article/details/78387545?locationNum=1&fps=1) 266 | 267 | 5.Ubuntu 安装 node-canvas 以及中文乱码/自定义字体的问题 268 | 269 | - 首先 sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev 安装完成后 npm install canvas 270 | - 中文乱码/自定义字体的问题解决方案:①安装 sudo apt-get install ttf-wqy-microhei #文泉驿-微米黑 ②注册 registerFont('/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', { family: 'WQY' }) ③使用 ctx.font = 'bold 22px "WYQ"' 271 | - 参考文章 [node-canvas](https://github.com/Automattic/node-canvas) 272 | 273 | ## 感谢 274 | 275 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-green.svg)](https://github.com/chatie/wechaty) 276 | [![Wechaty开源激励计划](https://img.shields.io/badge/Wechaty-开源激励计划-green.svg)](https://github.com/juzibot/Welcome/wiki/Everything-about-Wechaty) 277 | 278 | - 感谢 [beclass](https://github.com/beclass) 的开源项目 [`wxbot`](https://github.com/beclass/wxbot),这是一套优秀的微信机器人平台。 279 | - 感谢 [Wechaty](https://wechaty.github.io/) 团队提供微信机器人 `SDK`,让开发者可以专注于业务代码。 280 | - 感谢 [句子互动](https://www.juzibot.com) 提供的 `pad` 协议版 `token`。 281 | -------------------------------------------------------------------------------- /apidoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wxbot", 3 | "version": "1.0.0", 4 | "description": "API接口文档", 5 | "apidoc": { 6 | "title": "API接口文档", 7 | "url" : "/api" 8 | }, 9 | "sampleUrl": "/api" 10 | } -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | .page-enter-active, .page-leave-active { 2 | transition: opacity .3s; 3 | } 4 | .page-enter, .page-leave-active { 5 | opacity: 0; 6 | } 7 | .container{padding: 10px;} 8 | @media (max-width: 576px) { 9 | .container { 10 | padding-left: 5px; 11 | padding-right: 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /components/Say.vue: -------------------------------------------------------------------------------- 1 | 15 | 44 | -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | const local = { 2 | port: 3000, 3 | host: '0.0.0.0', 4 | mongoUrl: 'mongodb://localhost:27017/wxrobot', 5 | secret: '123456', 6 | tianApiUrl: 'http://api.tianapi.com/txapi/', 7 | tianApiKey: 'b763560bb7c00141e22975caab915455', 8 | } 9 | const development = {} 10 | const production = { 11 | port: 8081, 12 | mongoUrl: 'mongodb://username:password@ip:port/wxrobot', 13 | } 14 | let config = Object.assign({}, local, development) 15 | if (process.env.NODE_ENV === 'production') { 16 | config = Object.assign({}, local, production) 17 | } 18 | export default config 19 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // 允许 TypeScript 直接引用 Vue 文件. 4 | declare module '*.vue' { 5 | const content: any 6 | export default content 7 | } 8 | 9 | declare module '*.pug' { 10 | const content: any 11 | export default content 12 | } 13 | declare module '*.styl' { 14 | const content: any 15 | export default content 16 | } 17 | 18 | declare module '*.json' { 19 | const content: any 20 | export default content 21 | } 22 | 23 | declare module 'nuxt' 24 | -------------------------------------------------------------------------------- /layouts/admin.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 180 | 202 | 266 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /layouts/error.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /logs/error/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wux-weapp/wxbot/f7d79de65aeeef2dc99f789b66ff2fb036c34747/logs/error/.gitkeep -------------------------------------------------------------------------------- /logs/info/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wux-weapp/wxbot/f7d79de65aeeef2dc99f789b66ff2fb036c34747/logs/info/.gitkeep -------------------------------------------------------------------------------- /logs/response/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wux-weapp/wxbot/f7d79de65aeeef2dc99f789b66ff2fb036c34747/logs/response/.gitkeep -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { NuxtConfig } from '@nuxt/types' 2 | import config from './config' 3 | const { host, port } = config 4 | const server = { host, port } 5 | export default { 6 | telemetry: false, 7 | ssr: false, 8 | /* 9 | ** Headers of the page 10 | */ 11 | head: { 12 | title: process.env.npm_package_name || '', 13 | meta: [ 14 | { charset: 'utf-8' }, 15 | { 16 | name: 'viewport', 17 | content: 'width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0', 18 | }, 19 | { 20 | hid: 'description', 21 | name: 'description', 22 | content: process.env.npm_package_description || '', 23 | }, 24 | ], 25 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }], 26 | }, 27 | server, 28 | /* 29 | ** Customize the progress-bar color 30 | */ 31 | loading: { color: '#f80' }, 32 | /* 33 | ** Global CSS 34 | */ 35 | css: ['assets/main.css', 'ant-design-vue/dist/antd.css'], 36 | /* 37 | ** Plugins to load before mounting the App 38 | */ 39 | plugins: ['@/plugins/antd-ui', '@/plugins/axios'], 40 | /* 41 | ** Nuxt.js dev-modules 42 | */ 43 | buildModules: ['@nuxt/typescript-build'], 44 | /* 45 | ** Nuxt.js modules 46 | */ 47 | modules: ['@nuxtjs/axios', '@nuxtjs/auth'], 48 | router: { 49 | // middleware: 'stats' 50 | }, 51 | auth: { 52 | strategies: { 53 | local: { 54 | endpoints: { 55 | login: { 56 | url: '/auth/login', 57 | method: 'post', 58 | propertyName: 'token', 59 | }, 60 | logout: { url: '/auth/logout', method: 'post' }, 61 | user: { url: '/auth/user', method: 'get', propertyName: 'user' }, 62 | }, 63 | }, 64 | }, 65 | redirect: { 66 | login: '/auth/login', 67 | logout: '/', 68 | callback: '/auth/login', 69 | home: '/', 70 | }, 71 | }, 72 | /* 73 | ** Axios module configuration 74 | ** See https://axios.nuxtjs.org/options 75 | */ 76 | axios: { 77 | proxy: true, // 表示开启代理 78 | prefix: '/api', // 表示给请求url加个前缀 /api 79 | credentials: true, // 表示跨域请求时是否需要使用凭证 80 | }, 81 | /* 82 | ** Build configuration 83 | */ 84 | build: { 85 | /* 86 | ** You can extend webpack config here 87 | */ 88 | extend (config, ctx) { 89 | // Run ESLint on save 90 | // if (ctx.isDev && ctx.isClient) { 91 | // config.module.rules.push( 92 | // { 93 | // enforce: 'pre', 94 | // test: /\.(js|vue)$/, 95 | // loader: 'eslint-loader', 96 | // exclude: /(node_modules)/, 97 | // options: { 98 | // fix: true, 99 | // }, 100 | // }, 101 | // { 102 | // test: /\.ts$/, 103 | // exclude: [/node_modules/, /vendor/, /\.nuxt/], 104 | // loader: 'ts-loader', 105 | // options: { 106 | // appendTsSuffixTo: [/\.vue$/], 107 | // transpileOnly: true, 108 | // }, 109 | // }, 110 | // ) 111 | // } 112 | }, 113 | }, 114 | } as NuxtConfig 115 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wxbot", 3 | "version": "2.0.1", 4 | "description": "微信机器人", 5 | "author": "skyvow", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/wux-weapp/wxbot" 9 | }, 10 | "scripts": { 11 | "apidoc": "./node_modules/.bin/apidoc -i server/controller/ -o static/apidoc", 12 | "local": "cross-env NODE_ENV=test node local/index.js", 13 | "dev:site": "cross-env NODE_ENV=development ONLY_SITE=true ts-node server/index.ts --watch server", 14 | "dev": "cross-env NODE_ENV=development ts-node server/index.ts --watch server", 15 | "lint": "eslint --ext .ts,.js,.vue --ignore-path .gitignore .", 16 | "lint:fix": "eslint --ext .ts,.js,.vue --ignore-path .gitignore . --fix", 17 | "build": "nuxt-ts build", 18 | "start": "cross-env NODE_ENV=production ts-node server/index.ts", 19 | "generate": "nuxt-ts generate" 20 | }, 21 | "dependencies": { 22 | "@nuxt/typescript-build": "^2.0.3", 23 | "@nuxt/typescript-runtime": "^2.0.0", 24 | "@nuxtjs/auth": "4.9.1", 25 | "@nuxtjs/axios": "5.9.7", 26 | "ant-design-vue": "1.6.5", 27 | "canvas": "^2.6.1", 28 | "file-box": "^0.16.4", 29 | "jsonwebtoken": "^8.5.1", 30 | "koa": "^2.6.2", 31 | "koa-bodyparser": "^4.3.0", 32 | "koa-jwt": "^3.6.0", 33 | "koa-router": "^8.0.8", 34 | "log4js": "^6.2.1", 35 | "moment": "^2.24.0", 36 | "mongoose": "^5.9.10", 37 | "node-schedule": "^1.3.2", 38 | "node-uuid": "^1.4.8", 39 | "nuxt": "^2.10.2", 40 | "qrcodejs2": "^0.0.2", 41 | "urllib": "^2.34.2", 42 | "vue-class-component": "^7.2.6", 43 | "vue-property-decorator": "^9.0.2", 44 | "wechaty": "^0.56.6", 45 | "wechaty-puppet": "^0.34.1", 46 | "wechaty-puppet-service": "^0.14.5" 47 | }, 48 | "devDependencies": { 49 | "@nuxt/types": "^2.14.7", 50 | "@nuxtjs/eslint-config": "^5.0.0", 51 | "@types/jsonwebtoken": "^8.5.0", 52 | "@types/koa": "^2.11.6", 53 | "@types/koa-bodyparser": "^4.3.0", 54 | "@types/koa-router": "^7.4.1", 55 | "@types/mongoose": "^5.10.1", 56 | "@types/node": "^12.19.3", 57 | "@types/node-schedule": "^1.3.1", 58 | "@types/node-uuid": "^0.0.28", 59 | "@typescript-eslint/eslint-plugin": "^4.7.0", 60 | "@typescript-eslint/parser": "^4.7.0", 61 | "apidoc": "^0.25.0", 62 | "cross-env": "^5.2.1", 63 | "eslint": "^7.13.0", 64 | "eslint-loader": "^4.0.2", 65 | "eslint-plugin-apidoc": "^0.0.7", 66 | "eslint-plugin-nuxt": "^2.0.0", 67 | "node-sass": "^4.9.4", 68 | "nodemon": "^2.0.6", 69 | "sass-loader": "^7.1.0", 70 | "ts-loader": "^8.0.11", 71 | "ts-node": "^9.0.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pages/admin/enume.js: -------------------------------------------------------------------------------- 1 | const replyTypes = ['普通消息', '发送群邀请', '踢人指令'] 2 | const factorsList = ['通用', '私聊', '群聊', '通用群聊'] 3 | const statusList = ['停用', '启用'] 4 | const units = ['每分钟', '每小时', '每天', '自定义'] 5 | const taskFactors = ['个人', '群聊', '通用群聊'] 6 | const taskTypes = ['普通消息'] 7 | export { replyTypes, factorsList, statusList, units, taskFactors, taskTypes } 8 | -------------------------------------------------------------------------------- /pages/admin/friend/index.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 164 | -------------------------------------------------------------------------------- /pages/admin/group/index.vue: -------------------------------------------------------------------------------- 1 | 95 | 217 | 218 | 224 | -------------------------------------------------------------------------------- /pages/admin/index.vue: -------------------------------------------------------------------------------- 1 | 81 | 203 | 215 | -------------------------------------------------------------------------------- /pages/admin/reply/index.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 198 | -------------------------------------------------------------------------------- /pages/admin/reply/modal.vue: -------------------------------------------------------------------------------- 1 | 49 | 106 | -------------------------------------------------------------------------------- /pages/admin/task/index.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 208 | -------------------------------------------------------------------------------- /pages/admin/task/modal.vue: -------------------------------------------------------------------------------- 1 | 81 | 187 | -------------------------------------------------------------------------------- /pages/auth/login.vue: -------------------------------------------------------------------------------- 1 | 29 | 51 | 70 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 100 | -------------------------------------------------------------------------------- /plugins/antd-ui.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Antd from 'ant-design-vue/lib' 3 | 4 | Vue.use(Antd) 5 | -------------------------------------------------------------------------------- /plugins/axios.ts: -------------------------------------------------------------------------------- 1 | import { message } from 'ant-design-vue' 2 | import { AxiosError, AxiosResponse, AxiosRequestConfig } from 'axios' 3 | import { IAsyncResult } from '@/typings' 4 | export default function ({ store, redirect, app: { $axios } }) { 5 | // request拦截器 6 | $axios.onRequest((config: AxiosRequestConfig) => {}) 7 | $axios.onError((error: AxiosError) => { 8 | console.log('axios请求错误') 9 | console.log(error) 10 | }) 11 | // response拦截器,数据返回后,你可以先在这里进行一个简单的判断 12 | $axios.interceptors.response.use( 13 | (response: AxiosResponse>) => { 14 | const { success, errcode, errmsg } = response.data 15 | if (typeof errcode !== 'undefined' && !success) { 16 | if (response.config.url !== '/auth/user') { 17 | message.error(errmsg as string) 18 | } 19 | if (errcode === 401) { 20 | redirect('/auth/login') 21 | } 22 | return false 23 | } 24 | return response.data 25 | }, 26 | (error: AxiosError) => { 27 | console.error(error) 28 | message.error('网络连接失败,请检查网络状态和系统代理设置') 29 | }, 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /pm2.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'wxbot', 5 | // 指定解释器 6 | interpreter: './node_modules/.bin/ts-node', 7 | // 解释器参数 -P 表示项目路径,会自动使用项目的 tsconfig.json 8 | interpreter_args: '-P tsconfig.json', 9 | // 项目路径 10 | cwd: './', 11 | // 运行的脚本 12 | script: './server/index.ts', 13 | env: { 14 | NODE_ENV: 'development', 15 | }, 16 | env_production: { 17 | NODE_ENV: 'production', 18 | }, 19 | }, 20 | ], 21 | } 22 | -------------------------------------------------------------------------------- /screenshots/admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wux-weapp/wxbot/f7d79de65aeeef2dc99f789b66ff2fb036c34747/screenshots/admin.png -------------------------------------------------------------------------------- /server/bot/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: robot 3 | * @Author: skyvow 4 | * @Date: 2020-04-29 19:03:52 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-14 15:34:40 7 | */ 8 | import { Wechaty } from 'wechaty' 9 | import { ScanStatus } from 'wechaty-puppet' 10 | import logger from '../util/logger' 11 | import { Robot } from '../models/robot' 12 | import { onLogin, onLogout } from './lib/Login' 13 | import onFriendShip from './lib/FriendShip' 14 | import onMessage from './lib/Message' 15 | import { onRoomJoin, onRoomLeave } from './lib/Room' 16 | import { ContactSelf } from 'wechaty/dist/src/user/mod' 17 | class Bot { 18 | _id: string 19 | debug: boolean 20 | constructor (_id: string, debug: boolean = false) { 21 | this._id = _id 22 | this.debug = debug 23 | } 24 | 25 | log (...args: any[]) { 26 | if (this.debug) { 27 | console.log(...args) 28 | } 29 | } 30 | 31 | // 启动 32 | async start () { 33 | if (process.env.NODE_ENV === 'development' && process.env.ONLY_SITE === 'true') { 34 | const info = '已启动站点调式模式,机器人相关操作被静止' 35 | logger.info(info) 36 | console.log(info) 37 | return { 38 | info, 39 | } 40 | } 41 | const robot = await Robot.findOne({ _id: this._id }, { token: 1, nickName: 1, id: 1 }) 42 | if (!robot) { 43 | throw { message: '机器人不存在' } 44 | } 45 | if (!robot.token) { 46 | throw { message: '缺少协议token' } 47 | } 48 | const bot = new Wechaty({ 49 | puppet: 'wechaty-puppet-service', 50 | puppetOptions: { 51 | token: robot.token, 52 | }, 53 | name: robot.nickName, 54 | }) 55 | const res = await new Promise((resolve) => { 56 | bot 57 | .on('scan', (qrcode, status) => { 58 | if (status === ScanStatus.Waiting) { 59 | resolve({ qrcode }) 60 | } 61 | }) 62 | .on('login', async (user: ContactSelf) => { 63 | const res = await onLogin(bot, this._id, user) 64 | resolve(res) 65 | }) 66 | .on('message', onMessage) 67 | .on('friendship', onFriendShip) 68 | .on('room-join', onRoomJoin) 69 | .on('room-leave', onRoomLeave) 70 | .on('error', (error) => { 71 | logger.error('机器故障,error:' + error) 72 | }) 73 | .on('logout', onLogout) 74 | .start() 75 | }) 76 | return res 77 | } 78 | } 79 | export default Bot 80 | -------------------------------------------------------------------------------- /server/bot/lib/FriendShip.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 友谊关系 3 | * @Author: skyvow 4 | * @Date: 2020-04-29 17:31:10 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-14 15:34:06 7 | */ 8 | import { Friendship } from 'wechaty' 9 | import logger from '../../util/logger' 10 | import { Robot } from '../../models/robot' 11 | const onFriendShip = async (friendship: Friendship) => { 12 | const contact = friendship.contact() 13 | const name = contact.name() 14 | const hello = friendship.hello() 15 | let log: string 16 | try { 17 | log = `添加好友 ${name}` 18 | logger.info(log) 19 | const robot = await Robot.findOne({ id: global.bot.id }, { addFriendKeywords: 1, addFriendReply: 1 }) 20 | if (!robot) { return } 21 | switch (friendship.type()) { 22 | /** 23 | * 1.新的好友请求 24 | * 通过'request.hello()'获取验证消息 25 | * 通过'request.accept()'接受请求 26 | */ 27 | case Friendship.Type.Receive: 28 | if (robot.addFriendKeywords.some(str => str === hello)) { 29 | log = `自动添加好友成功,因为验证消息是 "${hello}"` 30 | // 通过验证 31 | await friendship.accept() 32 | } else { 33 | log = `没有通过验证:因为关键词 "${hello}" 不匹配` 34 | } 35 | break 36 | /** 37 | * 确认添加 38 | */ 39 | case Friendship.Type.Confirm: 40 | log = `${name} 已经添加你为好友` 41 | // 发个提示 42 | global.bot.say(`${name} 添加了你为好友`) 43 | if (robot.addFriendReply) { 44 | await contact.say(robot.addFriendReply) 45 | } 46 | break 47 | } 48 | } catch (e) { 49 | log = e.message 50 | } 51 | logger.info(log) 52 | } 53 | export default onFriendShip 54 | -------------------------------------------------------------------------------- /server/bot/lib/Login.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 登录 3 | * @Author: skyvow 4 | * @Date: 2020-04-29 18:51:49 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-15 18:15:23 7 | */ 8 | import logger from '../../util/logger' 9 | import { Robot } from '../../models/robot' 10 | import { Group } from '../../models/group' 11 | import { Friend } from '../../models/friend' 12 | import { init, stop } from './Task' 13 | import { ContactSelf } from 'wechaty/dist/src/user/mod' 14 | /** 15 | * 登录 16 | * @param {object} bot 17 | * @param {string} _id 18 | * @param {object} user 19 | */ 20 | const onLogin = async (bot: any, robotId: string, user: any) => { 21 | const robot = await Robot.findOne({ _id: robotId }, { startSay: 1, nickName: 1 }) 22 | if (!robot) { return } 23 | logger.info(`机器人 "${robot.nickName}" 登陆啦!!!`) 24 | console.log(`机器人 "${robot.nickName}" 登陆啦!!!`) 25 | await Robot.updateOne( 26 | { _id: robotId }, 27 | { 28 | status: 1, 29 | lastLoginT: new Date(), 30 | name: user.payload.name, 31 | id: user.id, 32 | weixin: user.payload.weixin, 33 | avatar: user.payload.avatar, 34 | }, 35 | ) 36 | bot.id = user.id 37 | // 初始化群聊 38 | const roomList = await bot.Room.findAll() 39 | for (let i = 0; i < roomList.length; i++) { 40 | const group = await Group.findOne({ id: roomList[i].id }, { _id: 1 }) 41 | if (!group) { 42 | roomList[i].payload.robotId = user.id 43 | await Group.create(roomList[i].payload) 44 | } else { 45 | await Group.updateOne({ _id: group._id }, roomList[i].payload) 46 | } 47 | } 48 | // 初始化好友 49 | const friends = await bot.Contact.findAll() 50 | const friendsA: any[] = [] 51 | const notids = ['filehelper', 'fmessage', user.id] 52 | friends.forEach((item: any) => { 53 | if (item.payload.friend && !notids.includes(item.payload.id)) { 54 | item.payload.robotId = user.id 55 | friendsA.push(item.payload) 56 | } 57 | }) 58 | for (let j = 0; j < friendsA.length; j++) { 59 | const friend = await Friend.findOne({ id: friendsA[j].id }, { _id: 1 }) 60 | if (!friend) { 61 | await Friend.create(friendsA[j]) 62 | } else { 63 | await Friend.updateOne({ _id: friend._id }, friendsA[j]) 64 | } 65 | } 66 | global.bot = bot 67 | await bot.say(robot.startSay) 68 | init() 69 | return { isLogin: true } 70 | } 71 | /** 72 | * 退出 73 | * @param {ContactSelf} user 74 | */ 75 | async function onLogout (user: ContactSelf) { 76 | await Robot.updateOne({ id: global.bot.id }, { status: 0 }) 77 | stop() 78 | delete global.bot 79 | logger.info(`机器人 "${user.name()}" 退出!!!`) 80 | } 81 | export { onLogin, onLogout } 82 | 83 | /* eslint-disable */ 84 | declare global { 85 | namespace NodeJS { 86 | interface Global { 87 | bot: any 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /server/bot/lib/Message.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 消息监听 3 | * @Author: skyvow 4 | * @Date: 2020-04-26 15:27:24 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-31 18:02:12 7 | */ 8 | import { Message, Room } from 'wechaty' 9 | import { IMemoryInfo } from '@/typings' 10 | import { Group } from '../../models/group' 11 | import { Robot } from '../../models/robot' 12 | import { Reply } from '../../models/reply' 13 | import { Memory } from '../../models/memory' 14 | import { getReply } from '../../util/ajax' 15 | 16 | async function onMessage (msg: Message) { 17 | if (msg.self()) { 18 | return 19 | } 20 | console.log('=============================') 21 | console.log(`msg : ${msg}`) 22 | console.log(`from: ${msg.talker() ? msg.talker().name() : null}: ${msg.talker() ? msg.talker().id : null}`) 23 | if (msg.type() === Message.Type.Text) { 24 | // 来自群聊 25 | const room = msg.room() 26 | if (room) { 27 | const group = await Group.findOne({ id: room.id }, { control: 1 }) 28 | if (!group || !group.control) { 29 | return 30 | } 31 | if (await msg.mentionSelf()) { 32 | // @自己 33 | let self = '' 34 | self = '@' + global.bot.name() 35 | let sendText = msg.text().replace(self, '') 36 | sendText = sendText.trim() 37 | // 获取需要回复的内容 38 | let content = await keyWordReply(sendText, room.id) 39 | if (!content) { 40 | content = await getReply(sendText, 'normal') 41 | } 42 | console.log(`reply: ${content}`) 43 | room.say(content) 44 | return 45 | } 46 | // @成员 47 | let sendText = msg.text() 48 | let person = '' 49 | if (sendText.indexOf('@') === 0) { 50 | const str = sendText.replace('@', '').split(' ') 51 | if (!str[1]) { 52 | return 53 | } 54 | person = str[0].trim() 55 | sendText = str[1].trim() 56 | } 57 | let content = await keyWordReply(sendText, room.id, person, room) 58 | if (content) { 59 | if (person) { 60 | content = `@${person} ${content}` 61 | } else { 62 | content = `「${msg.talker().name()}:${msg.text()}」\n- - - - - - - - - - - - - - -\n${content}` 63 | } 64 | console.log(`reply: ${content}`) 65 | room.say(content) 66 | } 67 | return 68 | } 69 | // 私聊 70 | if (await isRoomName(msg)) { 71 | return 72 | } 73 | let content = await keyWordReply(msg.text()) 74 | if (!content) { 75 | content = await getReply(msg.text(), 'normal') 76 | } 77 | console.log(`reply: ${content}`) 78 | await msg.say(content) 79 | } 80 | } 81 | /** 82 | * @description 收到消息是否群聊名称 83 | * @param {Object} bot 实例对象 84 | * @param {Object} msg 消息对象 85 | * @return {Bool} 86 | */ 87 | async function isRoomName (msg: Message): Promise { 88 | const group = await Group.findOne({ joinCode: msg.text() }, { id: 1 }) 89 | if (group) { 90 | // 通过群聊id获取群聊实例 91 | const room = await global.bot.Room.find({ id: group.id }) 92 | if (await room.has(msg.talker())) { 93 | await msg.say('您已经在群聊中了') 94 | return true 95 | } 96 | await room.add(msg.talker()) 97 | await msg.say('已发送群邀请') 98 | return true 99 | } 100 | return false 101 | } 102 | 103 | /** 104 | * 自定义回复 105 | * @param {string} _keyword 关键字 106 | * @param {string} roomId 群聊id 107 | * @param {string} person 艾特的群成员 108 | * @param {string} room 群聊 109 | */ 110 | async function keyWordReply (_keyword?: string, roomId?: string, person?: string, room?: Room) { 111 | const [keyword, ...args] = _keyword ? _keyword.split(' ') : [] 112 | try { 113 | const res = await Reply.findOne({ keyword, status: 1 }, { content: 1, type: 1, factor: 1, roomId: 1 }) 114 | if (!res) { 115 | return false 116 | } 117 | if (roomId) { 118 | // 群聊 119 | if (res.type === 0) { 120 | if (res.factor === 0 || res.factor === 3) { 121 | return getReply(res.content, 'keyword', args) 122 | } 123 | if (res.factor === 2 && roomId === res.roomId) { 124 | return getReply(res.content, 'keyword', args) 125 | } 126 | } 127 | if (res.type === 2) { 128 | if (person) { 129 | const group = await Group.findOne({ id: roomId }, { maxFoul: 1 }) 130 | if (!group) { return false } 131 | let foulCount = await Memory.countDocuments({ 132 | person, 133 | cmd: keyword, 134 | roomId, 135 | }) 136 | if (group.maxFoul - 1 === foulCount) { 137 | const contact = await global.bot.Contact.find({ name: person }) 138 | if (!room) { return false } 139 | await room.del(contact) 140 | await Memory.deleteMany({ person, roomId }) 141 | } else { 142 | await Memory.create({ person, cmd: keyword, roomId } as IMemoryInfo) 143 | foulCount++ 144 | return `您一定是违反了群的相关规则,如果再收到${group.maxFoul - foulCount}次同类消息,您将被移出本群。` 145 | } 146 | } 147 | } 148 | return false 149 | } 150 | // 私聊 151 | if (res.type === 1) { 152 | const robot = await Robot.findOne({ id: global.bot.id }, { id: 1, nickName: 1 }) 153 | if (!robot) { return false } 154 | const roomList = await Group.find({ robotId: robot.id, autojoin: true }, { topic: 1, id: 1, joinCode: 1 }) 155 | if (!roomList) { return false } 156 | let content = `${robot.nickName}管理群聊有${roomList.length}个:\n\n` 157 | roomList.forEach(item => { 158 | content += `${item.joinCode}:【${item.topic}】\n` 159 | }) 160 | content += '\n回复字母即可加入对应的群哦,比如发送 ' + roomList[0].joinCode 161 | return content 162 | } 163 | if (res.factor === 0 || res.factor === 1) { 164 | return getReply(res.content, 'keyword', args) 165 | } 166 | return false 167 | } catch (err) { 168 | return false 169 | } 170 | } 171 | 172 | export default onMessage 173 | -------------------------------------------------------------------------------- /server/bot/lib/Room.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: room 3 | * @Author: skyvow 4 | * @Date: 2020-04-30 19:33:58 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-14 12:34:25 7 | */ 8 | 9 | /** 10 | * 进入房间 11 | * @param {String} room 群聊 12 | * @param {*} inviteeList 受邀者名单 13 | * @param {*} inviter 邀请者 14 | */ 15 | import { Contact, Room } from 'wechaty' 16 | import { Group } from '../../models/group' 17 | async function onRoomJoin (room: any, inviteeList: any[], inviter: any) { 18 | const group = await Group.findOne({ id: room.id }, { roomJoinReply: 1 }) 19 | if (!group) { 20 | room.payload.robotId = global.bot.id 21 | await Group.create(room.payload) 22 | room.say( 23 | `大家好,我是机器人${global.bot.options.name}\n欢迎大家找我聊天或者玩游戏哦。比如 @${global.bot.options.name} 成语接龙`, 24 | ) 25 | return 26 | } 27 | inviteeList.forEach((c) => { 28 | room.say('\n' + group.roomJoinReply, c) 29 | }) 30 | } 31 | /** 32 | * 踢出房间,此功能仅限于bot踢出房间,如果房间用户自己退出不会触发 33 | * @param {Room} room 34 | * @param {Contact[]} leaverList 35 | */ 36 | async function onRoomLeave (room: Room, leaverList: Contact[]) { 37 | const isrobot = leaverList.find(item => item.id === global.bot.id) 38 | if (isrobot) { 39 | await Group.deleteOne({ id: room.id }) 40 | return 41 | } 42 | const group = Group.findOne({ id: room.id }, { id: 1 }) 43 | if (group) { 44 | leaverList.forEach((c) => { 45 | room.say(`「${c.name()}」离开了群聊`) 46 | }) 47 | } 48 | } 49 | export { onRoomJoin, onRoomLeave } 50 | -------------------------------------------------------------------------------- /server/bot/lib/Task.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 定时任务 3 | * @Author: skyvow 4 | * @Date: 2020-05-09 17:03:09 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-15 16:56:40 7 | */ 8 | import schedule from 'node-schedule' 9 | import logger from '../../util/logger' 10 | import { ITaskModel, Task } from '../../models/task' 11 | import { Group } from '../../models/group' 12 | import { getReply } from '../../util/ajax' 13 | import { ITaskRuleInfo } from '~/typings' 14 | 15 | const tasks: { 16 | [key: string]: schedule.Job 17 | } = {} 18 | /** 19 | * 初始化任务列表 20 | */ 21 | const init = async () => { 22 | stop() 23 | logger.info('初始化任务...') 24 | const list = await Task.find({ status: 1, robotId: global.bot.id }) 25 | for (let i = 0; i < list.length; i++) { 26 | await start(list[i]) 27 | } 28 | logger.info(`已初始化 ${list.length} 个任务`) 29 | } 30 | /** 31 | * 重启任务 32 | * @param {object} data 33 | */ 34 | const restart = async (data: ITaskModel) => { 35 | try { 36 | if (!global.bot) { 37 | throw { message: '机器人已掉线,请重新登录' } 38 | } 39 | const task = tasks[data._id] 40 | if (task) { 41 | stop(data._id) 42 | if (data.status === 0) { 43 | logger.info(`${data.name} 定时任务已关闭------`) 44 | } 45 | return 46 | } 47 | await start(data) 48 | } catch (err) { 49 | throw err 50 | } 51 | } 52 | /** 53 | * 开启任务 54 | * @param {object} data 55 | */ 56 | const start = async (data: ITaskModel) => { 57 | try { 58 | const rule: ITaskRuleInfo = { 59 | second: data.second, 60 | } 61 | if (data.minute || data.minute === 0) { 62 | rule.minute = data.minute 63 | } 64 | if (data.hour || data.hour === 0) { 65 | rule.hour = data.hour 66 | } 67 | if (data.dayOfWeek) { 68 | rule.dayOfWeek = data.dayOfWeek 69 | } 70 | if (data.dayOfMonth) { 71 | rule.dayOfMonth = data.dayOfMonth 72 | } 73 | const sc = schedule.scheduleJob(data.cron ? data.cron : rule, async () => { 74 | logger.info(`${data.name} 定时任务已启动------`) 75 | if (data.factor === 0) { 76 | const contact = await global.bot.Contact.find({ id: data.friendId }) 77 | const msg = await getReply(data.content, 'task') 78 | await contact.say(msg) 79 | } 80 | if (data.factor === 1) { 81 | const room = await global.bot.Room.find({ id: data.roomId }) 82 | const msg = await getReply(data.content, 'task') 83 | await room.say(msg) 84 | } 85 | if (data.factor === 2) { 86 | const groups = await Group.find({ control: true }, { id: 1 }) 87 | const msg = await getReply(data.content, 'task') 88 | for (let i = 0, len = groups.length; i < len; i++) { 89 | const room = await global.bot.Room.find({ id: groups[i].id }) 90 | await room.say(msg) 91 | } 92 | } 93 | }) 94 | tasks[data._id] = sc 95 | } catch (err) { 96 | throw err 97 | } 98 | } 99 | /** 100 | * 停止任务 101 | * 102 | * 当 id 存在时,停止指定的任务,否则停止所有任务 103 | * @param {string} id 104 | */ 105 | const stop = async (id?: string) => { 106 | try { 107 | if (!id) { 108 | for (const key in tasks) { 109 | tasks[key].cancel() 110 | delete tasks[key] 111 | } 112 | } else { 113 | tasks[id].cancel() 114 | delete tasks[id] 115 | } 116 | } catch (err) { 117 | console.log(err) 118 | } 119 | } 120 | export { init, start, restart, stop } 121 | -------------------------------------------------------------------------------- /server/config/db.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import config from '../../config' 3 | 4 | const connect = () => { 5 | mongoose.connect(config.mongoUrl, { 6 | useNewUrlParser: true, 7 | useUnifiedTopology: true, 8 | }) 9 | mongoose.set('useFindAndModify', false) 10 | mongoose.set('useCreateIndex', true) 11 | const db = mongoose.connection 12 | mongoose.Promise = global.Promise 13 | db.on('error', function (err) { 14 | console.log('数据库连接出错', err) 15 | }) 16 | db.on('open', function () { 17 | console.log('数据库连接成功') 18 | }) 19 | db.on('disconnected', function () { 20 | console.log('数据库连接断开') 21 | }) 22 | } 23 | 24 | export { connect, mongoose } 25 | -------------------------------------------------------------------------------- /server/config/log4js.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | // 日志根目录 3 | const baseLogPath = path.resolve(__dirname, '../../logs') 4 | // 错误日志目录 5 | const errorPath = '/error' 6 | // 错误日志文件名 7 | const errorFileName = 'error' 8 | // 错误日志输出完整路径 9 | const errorLogPath = baseLogPath + errorPath + '/' + errorFileName 10 | // var errorLogPath = path.resolve(__dirname, "../logs/error/error"); 11 | // 响应日志目录 12 | const responsePath = '/response' 13 | // 响应日志文件名 14 | const responseFileName = 'response' 15 | // 响应日志输出完整路径 16 | const responseLogPath = baseLogPath + responsePath + '/' + responseFileName 17 | // var responseLogPath = path.resolve(__dirname, "../logs/response/response"); 18 | // 响应日志目录 19 | const infoPath = '/info' 20 | // 响应日志文件名 21 | const infoFileName = 'info' 22 | // 响应日志输出完整路径 23 | const infoLogPath = baseLogPath + infoPath + '/' + infoFileName 24 | export default { 25 | // 日志格式等设置 26 | appenders: { 27 | 'rule-console': { type: 'console' }, 28 | errorLogger: { 29 | type: 'dateFile', 30 | filename: errorLogPath, 31 | pattern: '-yyyy-MM-dd.log', 32 | alwaysIncludePattern: true, 33 | encoding: 'utf-8', 34 | maxLogSize: 10000, 35 | numBackups: 3, 36 | path: errorPath, 37 | }, 38 | resLogger: { 39 | type: 'dateFile', 40 | filename: responseLogPath, 41 | pattern: '-yyyy-MM-dd.log', 42 | alwaysIncludePattern: true, 43 | encoding: 'utf-8', 44 | maxLogSize: 10000, 45 | numBackups: 3, 46 | path: responsePath, 47 | }, 48 | infoLogger: { 49 | type: 'dateFile', 50 | filename: infoLogPath, 51 | pattern: '-yyyy-MM-dd.log', 52 | alwaysIncludePattern: true, 53 | encoding: 'utf-8', 54 | maxLogSize: 10000, 55 | numBackups: 3, 56 | path: infoPath, 57 | }, 58 | }, 59 | // 供外部调用的名称和对应设置定义 60 | categories: { 61 | default: { appenders: ['rule-console'], level: 'all' }, 62 | resLogger: { appenders: ['resLogger'], level: 'info' }, 63 | errorLogger: { appenders: ['errorLogger'], level: 'error' }, 64 | infoLogger: { appenders: ['infoLogger'], level: 'info' }, 65 | http: { appenders: ['resLogger'], level: 'info' }, 66 | }, 67 | pm2: true, 68 | // replaceConsole: true, 69 | baseLogPath, 70 | } 71 | -------------------------------------------------------------------------------- /server/controller/admin.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'koa' 2 | import * as authDB from '../models/auth' 3 | import * as groupDB from '../models/group' 4 | import * as friendDB from '../models/friend' 5 | import * as replyDB from '../models/reply' 6 | import * as taskDB from '../models/task' 7 | 8 | class Index { 9 | /** 10 | * @apiDefine Header 11 | * @apiHeader {String} Authorization jsonwebtoken 12 | */ 13 | 14 | /** 15 | * @apiDefine Success 16 | * @apiSuccess {Boolean} success 标识码,true表示成功,false表示失败 17 | * @apiSuccess {Object} data 数据内容 18 | */ 19 | 20 | /** 21 | * @api {post} /auth/login 平台用户登录 22 | * @apiDescription 平台用户登录 23 | * @apiName login 24 | * @apiGroup admin.auth 25 | * 26 | * @apiParam {String} username 用户名 27 | * @apiParam {String} password 用户密码 28 | * 29 | * @apiPermission none 30 | * @apiSampleRequest /auth/login 31 | * 32 | * @apiUse Header 33 | * @apiUse Success 34 | * 35 | * @apiSuccessExample Success-Response: 36 | * HTTP/1.1 200 OK 37 | * { 38 | * "success": true, 39 | * "data": {} 40 | * } 41 | */ 42 | static login = async (ctx: Context) => { 43 | try { 44 | const result = await authDB.Dao.login(ctx.request.body) 45 | ctx.body = result 46 | } catch (err) { 47 | throw err 48 | } 49 | } 50 | 51 | /** 52 | * @api {post} /auth/logout 平台用户登出 53 | * @apiDescription 平台用户登出 54 | * @apiName logout 55 | * @apiGroup admin.auth 56 | * 57 | * @apiPermission none 58 | * @apiSampleRequest /auth/logout 59 | * 60 | * @apiUse Header 61 | * @apiUse Success 62 | * 63 | * @apiSuccessExample Success-Response: 64 | * HTTP/1.1 200 OK 65 | * { 66 | * "success": true, 67 | * "data": {} 68 | * } 69 | */ 70 | static logout = async (ctx: Context) => { 71 | ctx.body = null 72 | } 73 | 74 | /** 75 | * @api {get} /auth/user 获取平台用户信息 76 | * @apiDescription 获取平台用户信息 77 | * @apiName getUser 78 | * @apiGroup admin.auth 79 | * 80 | * @apiPermission none 81 | * @apiSampleRequest /auth/user 82 | * 83 | * @apiUse Header 84 | * @apiUse Success 85 | * 86 | * @apiSuccessExample Success-Response: 87 | * HTTP/1.1 200 OK 88 | * { 89 | * "success": true, 90 | * "data": {} 91 | * } 92 | */ 93 | static getUser = async (ctx: Context) => { 94 | try { 95 | const result = await authDB.Dao.getUser(ctx.query.user) 96 | ctx.body = { user: result } 97 | } catch (err) { 98 | throw err 99 | } 100 | } 101 | 102 | /** 103 | * @api {get} /admin/robot/:id 获取指定机器人的信息 104 | * @apiDescription 获取指定机器人的信息 105 | * @apiName getRobot 106 | * @apiGroup admin.robot 107 | * 108 | * @apiParam {String} id 机器人id 109 | * 110 | * @apiPermission none 111 | * @apiSampleRequest /admin/robot/:id 112 | * 113 | * @apiUse Header 114 | * @apiUse Success 115 | * 116 | * @apiSuccessExample Success-Response: 117 | * HTTP/1.1 200 OK 118 | * { 119 | * "success": true, 120 | * "data": {} 121 | * } 122 | */ 123 | static getRobot = async (ctx: Context) => { 124 | try { 125 | const result = await authDB.Dao.getRobot(ctx.params.id) 126 | ctx.body = result 127 | } catch (err) { 128 | throw err 129 | } 130 | } 131 | 132 | /** 133 | * @api {post} /admin/robot 新增一个机器人的信息 134 | * @apiDescription 新增一个机器人的信息 135 | * @apiName addRobot 136 | * @apiGroup admin.robot 137 | * 138 | * @apiParam {String} nickName 机器人名称 139 | * @apiParam {String} startSay 启动提示语 140 | * @apiParam {String} unknownSay 知识盲区回复 141 | * @apiParam {String[]} addFriendKeywords 好友通过关键字 142 | * @apiParam {String} addFriendReply 好友通过自动回复 143 | * @apiParam {String} token 协议Token 144 | * 145 | * @apiPermission none 146 | * @apiSampleRequest /admin/robot 147 | * 148 | * @apiUse Header 149 | * @apiUse Success 150 | * 151 | * @apiSuccessExample Success-Response: 152 | * HTTP/1.1 200 OK 153 | * { 154 | * "success": true, 155 | * "data": {} 156 | * } 157 | */ 158 | static addRobot = async (ctx: Context) => { 159 | try { 160 | const result = await authDB.Dao.addRobot(ctx.request.body) 161 | ctx.body = result 162 | } catch (err) { 163 | throw err 164 | } 165 | } 166 | 167 | /** 168 | * @api {put} /admin/robot/:id 更新指定机器人的信息 169 | * @apiDescription 更新指定机器人的信息 170 | * @apiName updateRobot 171 | * @apiGroup admin.robot 172 | * 173 | * @apiParam {String} id 机器人id 174 | * @apiParam {String} nickName 机器人名称 175 | * @apiParam {String} startSay 启动提示语 176 | * @apiParam {String} unknownSay 知识盲区回复 177 | * @apiParam {String[]} addFriendKeywords 好友通过关键字 178 | * @apiParam {String} addFriendReply 好友通过自动回复 179 | * @apiParam {String} token 协议Token 180 | * 181 | * @apiPermission none 182 | * @apiSampleRequest /admin/robot/:id 183 | * 184 | * @apiUse Header 185 | * @apiUse Success 186 | * 187 | * @apiSuccessExample Success-Response: 188 | * HTTP/1.1 200 OK 189 | * { 190 | * "success": true, 191 | * "data": {} 192 | * } 193 | */ 194 | static updateRobot = async (ctx: Context) => { 195 | try { 196 | const result = await authDB.Dao.updateRobot(ctx.params.id, ctx.request.body) 197 | ctx.body = result 198 | } catch (err) { 199 | throw err 200 | } 201 | } 202 | 203 | /** 204 | * @api {get} /admin/group 获取群列表 205 | * @apiDescription 获取群列表 206 | * @apiName getGroups 207 | * @apiGroup admin.group 208 | * 209 | * @apiPermission none 210 | * @apiSampleRequest /admin/group 211 | * 212 | * @apiUse Header 213 | * @apiUse Success 214 | * 215 | * @apiSuccessExample Success-Response: 216 | * HTTP/1.1 200 OK 217 | * { 218 | * "success": true, 219 | * "data": {} 220 | * } 221 | */ 222 | static getGroups = async (ctx: Context) => { 223 | try { 224 | const result = await groupDB.Dao.myGroups(ctx.query) 225 | ctx.body = result 226 | } catch (err) { 227 | throw err 228 | } 229 | } 230 | 231 | /** 232 | * @api {put} /admin/group/:id 更新指定群的信息 233 | * @apiDescription 更新指定群的信息 234 | * @apiName updateGroup 235 | * @apiGroup admin.group 236 | * 237 | * @apiParam {String} id 群id 238 | * @apiParam {String} roomJoinReply 入群欢迎语 239 | * @apiParam {Boolean} autojoin 是否开启自动加群 240 | * @apiParam {String} joinCode 群编码(字母),用于私聊自动回复“加群”匹配 241 | * @apiParam {Number} maxFoul 群员违规上限 242 | * @apiParam {Boolean} control 是否开启机器人控制 243 | * 244 | * @apiPermission none 245 | * @apiSampleRequest /admin/group/:id 246 | * 247 | * @apiUse Header 248 | * @apiUse Success 249 | * 250 | * @apiSuccessExample Success-Response: 251 | * HTTP/1.1 200 OK 252 | * { 253 | * "success": true, 254 | * "data": {} 255 | * } 256 | */ 257 | static updateGroup = async (ctx: Context) => { 258 | try { 259 | const result = await groupDB.Dao.update(ctx.params.id, ctx.request.body) 260 | ctx.body = result 261 | } catch (err) { 262 | throw err 263 | } 264 | } 265 | 266 | /** 267 | * @api {get} /admin/friend 获取好友列表 268 | * @apiDescription 获取好友列表 269 | * @apiName getFriends 270 | * @apiGroup admin.friend 271 | * 272 | * @apiPermission none 273 | * @apiSampleRequest /admin/friend 274 | * 275 | * @apiUse Header 276 | * @apiUse Success 277 | * 278 | * @apiSuccessExample Success-Response: 279 | * HTTP/1.1 200 OK 280 | * { 281 | * "success": true, 282 | * "data": {} 283 | * } 284 | */ 285 | static getFriends = async (ctx: Context) => { 286 | try { 287 | const result = await friendDB.Dao.list(ctx.query) 288 | ctx.body = result 289 | } catch (err) { 290 | throw err 291 | } 292 | } 293 | 294 | /** 295 | * @api {get} /admin/reply 获取自动回复列表 296 | * @apiDescription 获取自动回复列表 297 | * @apiName getReplys 298 | * @apiGroup admin.reply 299 | * 300 | * @apiPermission none 301 | * @apiSampleRequest /admin/reply 302 | * 303 | * @apiUse Header 304 | * @apiUse Success 305 | * 306 | * @apiSuccessExample Success-Response: 307 | * HTTP/1.1 200 OK 308 | * { 309 | * "success": true, 310 | * "data": {} 311 | * } 312 | */ 313 | static getReplys = async (ctx: Context) => { 314 | try { 315 | const result = await replyDB.Dao.list(ctx.query) 316 | ctx.body = result 317 | } catch (err) { 318 | throw err 319 | } 320 | } 321 | 322 | /** 323 | * @api {post} /admin/reply 新增一个自动回复 324 | * @apiDescription 新增一个自动回复 325 | * @apiName addReply 326 | * @apiGroup admin.reply 327 | * 328 | * @apiParam {String} robotId 机器人id 329 | * @apiParam {String} keyword 关键词 330 | * @apiParam {String} content 回复内容 331 | * @apiParam {Number} type 类型 0:普通消息,1:发送群邀请(仅在私聊触发) 2:踢人指令(仅在群聊触发) 332 | * @apiParam {Number} factor 触发场景 0:通用,1:私聊 2:群聊 3:通用群聊 333 | * @apiParam {Number} status 状态 0停用 1启用 334 | * @apiParam {String} roomId 群id 335 | * @apiParam {String} remark 备注 336 | * 337 | * @apiPermission none 338 | * @apiSampleRequest /admin/reply 339 | * 340 | * @apiUse Header 341 | * @apiUse Success 342 | * 343 | * @apiSuccessExample Success-Response: 344 | * HTTP/1.1 200 OK 345 | * { 346 | * "success": true, 347 | * "data": {} 348 | * } 349 | */ 350 | static addReply = async (ctx: Context) => { 351 | try { 352 | if (!ctx.request.body.robotId) { 353 | throw { message: '未绑定机器人' } 354 | } 355 | const result = await replyDB.Dao.add(ctx.request.body) 356 | ctx.body = result 357 | } catch (err) { 358 | throw err 359 | } 360 | } 361 | 362 | /** 363 | * @api {put} /admin/reply/:id 更新指定的自动回复 364 | * @apiDescription 更新指定的自动回复 365 | * @apiName updateReply 366 | * @apiGroup admin.reply 367 | * 368 | * @apiParam {String} id 自动回复id 369 | * @apiParam {String} robotId 机器人id 370 | * @apiParam {String} keyword 关键词 371 | * @apiParam {String} content 回复内容 372 | * @apiParam {Number} type 类型 0:普通消息,1:发送群邀请(仅在私聊触发) 2:踢人指令(仅在群聊触发) 373 | * @apiParam {Number} factor 触发场景 0:通用,1:私聊 2:群聊 3:通用群聊 374 | * @apiParam {Number} status 状态 0停用 1启用 375 | * @apiParam {String} roomId 群id 376 | * @apiParam {String} remark 备注 377 | * 378 | * @apiPermission none 379 | * @apiSampleRequest /admin/reply/:id 380 | * 381 | * @apiUse Header 382 | * @apiUse Success 383 | * 384 | * @apiSuccessExample Success-Response: 385 | * HTTP/1.1 200 OK 386 | * { 387 | * "success": true, 388 | * "data": {} 389 | * } 390 | */ 391 | static updateReply = async (ctx: Context) => { 392 | try { 393 | const result = await replyDB.Dao.update(ctx.params.id, ctx.request.body) 394 | ctx.body = result 395 | } catch (err) { 396 | throw err 397 | } 398 | } 399 | 400 | /** 401 | * @api {post} /admin/reply 批量删除指定的自动回复 402 | * @apiDescription 批量删除指定的自动回复 403 | * @apiName deleteReply 404 | * @apiGroup admin.reply 405 | * 406 | * @apiParam {String[]} ids 自动回复id 407 | * 408 | * @apiPermission none 409 | * @apiSampleRequest /admin/reply 410 | * 411 | * @apiUse Header 412 | * @apiUse Success 413 | * 414 | * @apiSuccessExample Success-Response: 415 | * HTTP/1.1 200 OK 416 | * { 417 | * "success": true, 418 | * "data": {} 419 | * } 420 | */ 421 | static deleteReply = async (ctx: Context) => { 422 | try { 423 | const result = await replyDB.Dao.delete(ctx.query['ids[]']) 424 | ctx.body = result 425 | } catch (err) { 426 | throw err 427 | } 428 | } 429 | 430 | /** 431 | * @api {get} /admin/task 获取定时任务列表 432 | * @apiDescription 获取定时任务列表 433 | * @apiName getTasks 434 | * @apiGroup admin.task 435 | * 436 | * @apiPermission none 437 | * @apiSampleRequest /admin/task 438 | * 439 | * @apiUse Header 440 | * @apiUse Success 441 | * 442 | * @apiSuccessExample Success-Response: 443 | * HTTP/1.1 200 OK 444 | * { 445 | * "success": true, 446 | * "data": {} 447 | * } 448 | */ 449 | static getTasks = async (ctx: Context) => { 450 | try { 451 | const result = await taskDB.Dao.list(ctx.query) 452 | ctx.body = result 453 | } catch (err) { 454 | throw err 455 | } 456 | } 457 | 458 | /** 459 | * @api {post} /admin/task 新增一个定时任务 460 | * @apiDescription 新增一个定时任务 461 | * @apiName addTask 462 | * @apiGroup admin.task 463 | * 464 | * @apiParam {String} robotId 机器人id 465 | * @apiParam {String} name 名称 466 | * @apiParam {Number} type 类型 0:普通消息,1:其他 467 | * @apiParam {String} content 发送内容 468 | * @apiParam {Number} factor 触发场景 0:个人,1:群聊,2:通用群聊 469 | * @apiParam {Number} status 状态 0停用 1启用 470 | * @apiParam {String} friendId 联系人 471 | * @apiParam {String} roomId 群id 472 | * @apiParam {String} cron cron表达式,优先级高于 rule(dayOfWeek,month...) 指定的时间 473 | * @apiParam {Number} unit 时间单位 0:每分钟,1:每小时,2:每天,3:自定义 474 | * @apiParam {Number} dayOfWeek 指定周几 475 | * @apiParam {Number} month 指定某月 476 | * @apiParam {Number} dayOfMonth 指定某天 477 | * @apiParam {Number} hour 指定某时 478 | * @apiParam {Number} minute 指定某分 479 | * @apiParam {Number} second 指定某秒 480 | * 481 | * @apiPermission none 482 | * @apiSampleRequest /admin/task 483 | * 484 | * @apiUse Header 485 | * @apiUse Success 486 | * 487 | * @apiSuccessExample Success-Response: 488 | * HTTP/1.1 200 OK 489 | * { 490 | * "success": true, 491 | * "data": {} 492 | * } 493 | */ 494 | static addTask = async (ctx: Context) => { 495 | try { 496 | if (!ctx.request.body.robotId) { 497 | throw { message: '未绑定机器人' } 498 | } 499 | const result = await taskDB.Dao.add(ctx.request.body) 500 | ctx.body = result 501 | } catch (err) { 502 | throw err 503 | } 504 | } 505 | 506 | /** 507 | * @api {put} /admin/task/:id 更新指定的定时任务 508 | * @apiDescription 更新指定的定时任务 509 | * @apiName updateTask 510 | * @apiGroup admin.task 511 | * 512 | * @apiParam {String} id 定时任务id 513 | * @apiParam {String} robotId 机器人id 514 | * @apiParam {String} name 名称 515 | * @apiParam {Number} type 类型 0:普通消息,1:其他 516 | * @apiParam {String} content 发送内容 517 | * @apiParam {Number} factor 触发场景 0:个人,1:群聊,2:通用群聊 518 | * @apiParam {Number} status 状态 0停用 1启用 519 | * @apiParam {String} friendId 联系人 520 | * @apiParam {String} roomId 群id 521 | * @apiParam {String} cron cron表达式,优先级高于 rule(dayOfWeek,month...) 指定的时间 522 | * @apiParam {Number} unit 时间单位 0:每分钟,1:每小时,2:每天,3:自定义 523 | * @apiParam {Number} dayOfWeek 指定周几 524 | * @apiParam {Number} month 指定某月 525 | * @apiParam {Number} dayOfMonth 指定某天 526 | * @apiParam {Number} hour 指定某时 527 | * @apiParam {Number} minute 指定某分 528 | * @apiParam {Number} second 指定某秒 529 | * 530 | * @apiPermission none 531 | * @apiSampleRequest /admin/task/:id 532 | * 533 | * @apiUse Header 534 | * @apiUse Success 535 | * 536 | * @apiSuccessExample Success-Response: 537 | * HTTP/1.1 200 OK 538 | * { 539 | * "success": true, 540 | * "data": {} 541 | * } 542 | */ 543 | static updateTask = async (ctx: Context) => { 544 | try { 545 | const result = await taskDB.Dao.update(ctx.params.id, ctx.request.body) 546 | ctx.body = result 547 | } catch (err) { 548 | throw err 549 | } 550 | } 551 | 552 | /** 553 | * @api {post} /admin/task 批量删除指定的定时任务 554 | * @apiDescription 批量删除指定的定时任务 555 | * @apiName deleteTask 556 | * @apiGroup admin.task 557 | * 558 | * @apiParam {String[]} ids 定时任务id 559 | * 560 | * @apiPermission none 561 | * @apiSampleRequest /admin/task 562 | * 563 | * @apiUse Header 564 | * @apiUse Success 565 | * 566 | * @apiSuccessExample Success-Response: 567 | * HTTP/1.1 200 OK 568 | * { 569 | * "success": true, 570 | * "data": {} 571 | * } 572 | */ 573 | static deleteTask = async (ctx: Context) => { 574 | try { 575 | const result = await taskDB.Dao.delete(ctx.query['ids[]']) 576 | ctx.body = result 577 | } catch (err) { 578 | throw err 579 | } 580 | } 581 | } 582 | 583 | export default Index 584 | -------------------------------------------------------------------------------- /server/controller/robot.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: robot 3 | * @Author: skyvow 4 | * @Date: 2020-04-24 21:09:47 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-12 15:49:06 7 | */ 8 | import { Context } from 'koa' 9 | import Bot from '../bot' 10 | import { Robot } from '../models/robot' 11 | import { Group } from '../models/group' 12 | class Index { 13 | /** 14 | * @apiDefine Header 15 | * @apiHeader {String} Authorization jsonwebtoken 16 | */ 17 | 18 | /** 19 | * @apiDefine Success 20 | * @apiSuccess {Boolean} success 标识码,true表示成功,false表示失败 21 | * @apiSuccess {Object} data 数据内容 22 | */ 23 | 24 | /** 25 | * @api {post} /robot/login 机器人登录 26 | * @apiDescription 机器人登录 27 | * @apiName login 28 | * @apiGroup robot 29 | * 30 | * @apiParam {String} id 机器人id 31 | * 32 | * @apiPermission none 33 | * @apiSampleRequest /robot/login 34 | * 35 | * @apiUse Header 36 | * @apiUse Success 37 | * 38 | * @apiSuccessExample Success-Response: 39 | * HTTP/1.1 200 OK 40 | * { 41 | * "success": true, 42 | * "data": {} 43 | * } 44 | */ 45 | static async login (ctx: Context) { 46 | try { 47 | if (!ctx.request.body.id) { 48 | throw { message: '缺少id' } 49 | } 50 | const bot = new Bot(ctx.request.body.id) 51 | const result = await bot.start() 52 | ctx.body = result 53 | } catch (err) { 54 | throw err 55 | } 56 | } 57 | 58 | /** 59 | * @api {post} /robot/logout 机器人登出 60 | * @apiDescription 机器人登出 61 | * @apiName logout 62 | * @apiGroup robot 63 | * 64 | * @apiParam {String} id 机器人id 65 | * 66 | * @apiPermission none 67 | * @apiSampleRequest /robot/logout 68 | * 69 | * @apiUse Header 70 | * @apiUse Success 71 | * 72 | * @apiSuccessExample Success-Response: 73 | * HTTP/1.1 200 OK 74 | * { 75 | * "success": true, 76 | * "data": {} 77 | * } 78 | */ 79 | static logout = async (ctx: Context) => { 80 | try { 81 | if (!global.bot) { 82 | await Robot.updateOne({ _id: ctx.request.body.id }, { status: 0 }) 83 | delete global.bot 84 | return (ctx.body = {}) 85 | } 86 | await global.bot.logout() 87 | ctx.body = {} 88 | } catch (err) { 89 | throw err 90 | } 91 | } 92 | 93 | /** 94 | * @api {post} /robot/friend/say 发送消息给好友 95 | * @apiDescription 发送消息给好友 96 | * @apiName friendSay 97 | * @apiGroup robot 98 | * 99 | * @apiParam {String} id 好友id 100 | * @apiParam {String} content 消息内容 101 | * 102 | * @apiPermission none 103 | * @apiSampleRequest /robot/friend/say 104 | * 105 | * @apiUse Header 106 | * @apiUse Success 107 | * 108 | * @apiSuccessExample Success-Response: 109 | * HTTP/1.1 200 OK 110 | * { 111 | * "success": true, 112 | * "data": {} 113 | * } 114 | */ 115 | static friendSay = async (ctx: Context) => { 116 | try { 117 | const contact = await global.bot.Contact.find({ 118 | id: ctx.request.body.id, 119 | }) 120 | await contact.say(ctx.request.body.content) 121 | ctx.body = {} 122 | } catch (err) { 123 | throw err 124 | } 125 | } 126 | 127 | /** 128 | * @api {post} /robot/room/say 发送群消息 129 | * @apiDescription 发送群消息 130 | * @apiName roomSay 131 | * @apiGroup robot 132 | * 133 | * @apiParam {String} id 群id 134 | * @apiParam {String} content 消息内容 135 | * 136 | * @apiPermission none 137 | * @apiSampleRequest /robot/room/say 138 | * 139 | * @apiUse Header 140 | * @apiUse Success 141 | * 142 | * @apiSuccessExample Success-Response: 143 | * HTTP/1.1 200 OK 144 | * { 145 | * "success": true, 146 | * "data": {} 147 | * } 148 | */ 149 | static roomSay = async (ctx: Context) => { 150 | try { 151 | const room = await global.bot.Room.find({ id: ctx.request.body.id }) 152 | await room.say(ctx.request.body.content) 153 | ctx.body = {} 154 | } catch (err) { 155 | throw err 156 | } 157 | } 158 | 159 | /** 160 | * @api {get} /robot/room/:id 获取群名称&群公告 161 | * @apiDescription 获取群名称&群公告 162 | * @apiName getRoom 163 | * @apiGroup robot 164 | * 165 | * @apiParam {String} id 群id 166 | * 167 | * @apiPermission none 168 | * @apiSampleRequest /robot/room/:id 169 | * 170 | * @apiUse Header 171 | * @apiUse Success 172 | * 173 | * @apiSuccessExample Success-Response: 174 | * HTTP/1.1 200 OK 175 | * { 176 | * "success": true, 177 | * "data": {} 178 | * } 179 | */ 180 | static getRoom = async (ctx: Context) => { 181 | try { 182 | const room = await global.bot.Room.find({ id: ctx.params.id }) 183 | const topic = await room.topic() 184 | const announce = await room.announce() 185 | ctx.body = { topic, announce } 186 | } catch (err) { 187 | throw err 188 | } 189 | } 190 | 191 | /** 192 | * @api {put} /robot/room/:id 设置群名称&群公告 193 | * @apiDescription 设置群名称&群公告 194 | * @apiName updateRoom 195 | * @apiGroup robot 196 | * 197 | * @apiParam {String} id 群id 198 | * @apiParam {String} topic 群名称 199 | * @apiParam {String} announce 群公告 200 | * 201 | * @apiPermission none 202 | * @apiSampleRequest /robot/room/:id 203 | * 204 | * @apiUse Header 205 | * @apiUse Success 206 | * 207 | * @apiSuccessExample Success-Response: 208 | * HTTP/1.1 200 OK 209 | * { 210 | * "success": true, 211 | * "data": {} 212 | * } 213 | */ 214 | static updateRoom = async (ctx: Context) => { 215 | try { 216 | const room = await global.bot.Room.find({ id: ctx.params.id }) 217 | if (ctx.request.body.topic) { 218 | await room.topic(ctx.request.body.topic) 219 | await Group.updateOne({ id: ctx.params.id }, { topic: ctx.request.body.topic }) 220 | } 221 | if (ctx.request.body.announce) { 222 | await room.announce(ctx.request.body.announce) 223 | } 224 | ctx.body = {} 225 | } catch (err) { 226 | throw { message: '没有权限,不是群主或者管理员' } 227 | } 228 | } 229 | 230 | /** 231 | * @api {post} /robot/room/quit 退群 232 | * @apiDescription 退群 233 | * @apiName roomQuit 234 | * @apiGroup robot 235 | * 236 | * @apiParam {String} id 群id 237 | * 238 | * @apiPermission none 239 | * @apiSampleRequest /robot/room/quit 240 | * 241 | * @apiUse Header 242 | * @apiUse Success 243 | * 244 | * @apiSuccessExample Success-Response: 245 | * HTTP/1.1 200 OK 246 | * { 247 | * "success": true, 248 | * "data": {} 249 | * } 250 | */ 251 | static roomQuit = async (ctx: Context) => { 252 | try { 253 | const room = await global.bot.Room.find({ id: ctx.request.body.id }) 254 | if (room) { 255 | await Group.deleteOne({ id: ctx.request.body.id }) 256 | await room.quit() 257 | } 258 | ctx.body = {} 259 | } catch (err) { 260 | throw err 261 | } 262 | } 263 | } 264 | 265 | export default Index 266 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import Koa, { Context, Next } from 'koa' 3 | import consola from 'consola' 4 | import { Nuxt, Builder } from 'nuxt' 5 | import bodyParser from 'koa-bodyparser' 6 | import jwtKoa from 'koa-jwt' 7 | import NuxtConfig from '../nuxt.config' 8 | import config from '../config' 9 | import logger from './util/logger' 10 | import api from './routes/api' 11 | import resformat from './middleware/resformat' 12 | import { connect } from './config/db' 13 | import logConfig from './config/log4js' 14 | const app = new Koa() 15 | NuxtConfig.dev = app.env !== 'production' 16 | app.use(bodyParser({ enableTypes: ['json', 'text', 'form'] })) 17 | async function start () { 18 | // Instantiate nuxt.js 19 | const nuxt = new Nuxt(NuxtConfig) 20 | const { host = process.env.HOST || '127.0.0.1', port = process.env.PORT || 3001 } = nuxt.options.server 21 | await nuxt.ready() 22 | app.use(resformat('^/api/')) 23 | app.use(api.routes()) 24 | app.use(api.allowedMethods()) 25 | if (NuxtConfig.dev) { 26 | const builder = new Builder(nuxt) 27 | await builder.build() 28 | } 29 | app.use((ctx: Koa.Context) => { 30 | ctx.status = 200 31 | ctx.respond = false 32 | // ctx.req.ctx = ctx 33 | nuxt.render(ctx.req, ctx.res) 34 | }) 35 | app.listen(port, host) 36 | consola.ready({ 37 | message: `Server listening on http://${host}:${port}`, 38 | badge: true, 39 | }) 40 | } 41 | // error 42 | app.use(async (ctx: Context, next: Next) => { 43 | const startT = new Date() as any 44 | let ms: number 45 | try { 46 | await next().catch((err) => { 47 | if (err.status === 401) { 48 | ctx.body = { errcode: 401, errmsg: 'Authentication' } 49 | } else { 50 | throw err 51 | } 52 | }) 53 | ms = (new Date() as any) - startT 54 | } catch (error) { 55 | console.log(error) 56 | ms = (new Date() as any) - startT 57 | logger.logError(ctx, error, ms) 58 | } 59 | }) 60 | app.use( 61 | jwtKoa({ secret: config.secret }).unless({ 62 | path: [/^\/api\/auth\/login/, /^\/api\/auth\/logout/, /^\/api\/robot\/login/, /^((?!\/api\/).)*$/], 63 | }), 64 | ) 65 | connect() 66 | const { baseLogPath, appenders } = logConfig 67 | const confirmPath = function (pathStr: string) { 68 | if (!fs.existsSync(pathStr)) { 69 | fs.mkdirSync(pathStr) 70 | } 71 | } 72 | /** 73 | * init log 74 | */ 75 | const initLogPath = function () { 76 | if (baseLogPath) { 77 | confirmPath(baseLogPath) 78 | for (let i = 0, len = (appenders as any).length; i < len; i++) { 79 | if ((appenders as any)[i].path) { 80 | confirmPath(baseLogPath + (appenders as any)[i].path) 81 | } 82 | } 83 | } 84 | } 85 | start() 86 | initLogPath() 87 | -------------------------------------------------------------------------------- /server/middleware/botLogin.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | 3 | export default function () { 4 | return async function (ctx: Context, next: Next) { 5 | if (!global.bot) { 6 | throw { message: '机器人已掉线,请重新登录' } 7 | } 8 | await next() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/middleware/getUser.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from 'koa' 2 | import { verifyToken } from '../util' 3 | export default function () { 4 | return async function (ctx: Context, next: Next) { 5 | const res: any = verifyToken(ctx.header.authorization) 6 | if (ctx.method === 'GET') { 7 | ctx.query.user = res.id 8 | } else { 9 | ctx.request.body.user = res.id 10 | } 11 | await next() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/middleware/resformat.ts: -------------------------------------------------------------------------------- 1 | import { Next, Context } from 'koa' 2 | import { IAsyncResult } from '@/typings' 3 | 4 | /* 5 | * @Desc: 格式化响应数据 6 | * @Author: skyvow 7 | * @Date: 2020-04-20 19:13:19 8 | * @LastEditors: skyvow 9 | * @LastEditTime: 2020-05-04 23:15:59 10 | */ 11 | const urlFilter = function (pattern: string | RegExp) { 12 | return async (ctx: Context, next: Next) => { 13 | const reg = new RegExp(pattern) 14 | try { 15 | await next() 16 | } catch (error) { 17 | ctx.status = 200 18 | ctx.body = { 19 | errcode: error.code ? error.code : 1, 20 | errmsg: error.message, 21 | } as IAsyncResult 22 | throw error 23 | } 24 | if (reg.test(ctx.originalUrl)) { 25 | if (ctx.body && ctx.body.original) { 26 | return (ctx.body = ctx.body.body) 27 | } 28 | ctx.body = { 29 | success: true, 30 | data: ctx.body, 31 | } as IAsyncResult 32 | } 33 | } 34 | } 35 | export default urlFilter 36 | -------------------------------------------------------------------------------- /server/models/auth.ts: -------------------------------------------------------------------------------- 1 | import { IAuthInfo, IRobotInfo } from '@/typings' 2 | import { mongoose } from '../config/db' 3 | import { encryptPassword, createToken } from '../util' 4 | import { Robot } from './robot' 5 | const Schema = mongoose.Schema 6 | const schema = new Schema({ 7 | username: { 8 | type: String, 9 | required: true, 10 | }, 11 | password: String, 12 | salt: String, 13 | createTime: { type: Date, default: new Date() }, 14 | lastLoginT: Date, 15 | loginIp: String, 16 | }) 17 | 18 | export interface IAuthModel extends IAuthInfo, mongoose.Document {} 19 | 20 | const Auth = mongoose.model('auth', schema, 'auth') 21 | 22 | const Dao = { 23 | login: async (params: IAuthInfo) => { 24 | try { 25 | const user = await Auth.findOne({ username: params.username }) 26 | if (!user) { 27 | throw { message: '用户不存在' } 28 | } 29 | if (user.password !== encryptPassword(user.salt, params.password)) { 30 | throw { message: '密码有误' } 31 | } 32 | return { token: createToken({ id: user.id }) } 33 | } catch (err) { 34 | throw err 35 | } 36 | }, 37 | getUser: async (userId: string) => { 38 | try { 39 | const user = await Auth.findOne({ _id: userId }, { username: 1 }) 40 | if (!user) { 41 | throw { message: '用户不存在' } 42 | } 43 | const robot = await Robot.findOne({ user: user._id }, { id: 1 }) 44 | return { 45 | username: user.username, 46 | robotId: (robot && robot.id) || null, 47 | robot_id: (robot && robot._id) || null, 48 | } 49 | } catch (err) { 50 | throw err 51 | } 52 | }, 53 | getRobot: async (_id: string) => { 54 | try { 55 | const result = await Robot.findOne({ _id }) 56 | return result 57 | } catch (err) { 58 | throw err 59 | } 60 | }, 61 | addRobot: async (params: IRobotInfo) => { 62 | try { 63 | const result = await Robot.create(params) 64 | return result 65 | } catch (err) { 66 | throw err 67 | } 68 | }, 69 | updateRobot: async (_id: string, params: IAuthInfo) => { 70 | try { 71 | const result = await Robot.updateOne({ _id }, params) 72 | return result 73 | } catch (err) { 74 | throw err 75 | } 76 | }, 77 | } 78 | 79 | export { Auth, Dao } 80 | 81 | const init = async () => { 82 | const exists = await Auth.exists({}) 83 | if (!exists) { 84 | await Auth.create({ 85 | username: 'admin', 86 | salt: '123456', 87 | password: '146123f005da382f342c4e593eb5bf5192d2267c', 88 | }) 89 | // await Auth.create({ username: 'admin', salt: '123456', password: encryptPassword('123456', '111111') }) 90 | // await Auth.create({ username: 'guest', salt: '123456', password: encryptPassword('123456', '111111') }) 91 | } 92 | } 93 | init() 94 | -------------------------------------------------------------------------------- /server/models/friend.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 好友 3 | * @Author: skyvow 4 | * @Date: 2020-04-30 15:39:55 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-06 18:45:47 7 | */ 8 | import { IFriendInfo, IQueryInfo } from '@/typings' 9 | import { mongoose } from '../config/db' 10 | import { parseSearch } from '../util' 11 | const Schema = mongoose.Schema 12 | const schema = new Schema({ 13 | id: { type: String }, // 唯一 14 | name: String, // 昵称 15 | alias: String, // 备注 16 | avatar: String, // 头像 17 | province: String, // 省份 18 | city: String, // 城市 19 | gender: Number, // 性别 20 | weixin: String, // 微信 21 | robotId: String, // 机器人id 22 | }) 23 | 24 | export interface IFriendModel extends IFriendInfo, mongoose.Document {} 25 | 26 | const Friend = mongoose.model('friend', schema, 'friend') 27 | const Dao = { 28 | list: async (params: IQueryInfo) => { 29 | try { 30 | const page = Number(params.page || 1) 31 | const limit = Number(params.pageSize || 10) 32 | const start = (page - 1) * limit 33 | const condition = parseSearch(params) 34 | const sortF = { skip: start, limit, sort: { _id: 1 } } 35 | const fields = {} 36 | const total = await Friend.countDocuments(condition) 37 | const list = await Friend.find(condition, fields, sortF) 38 | return { total, list } 39 | } catch (err) { 40 | throw err 41 | } 42 | }, 43 | } 44 | export { Friend, Dao } 45 | -------------------------------------------------------------------------------- /server/models/group.ts: -------------------------------------------------------------------------------- 1 | import { IGroupInfo, IQueryInfo } from '@/typings' 2 | import { mongoose } from '../config/db' 3 | import { parseSearch } from '../util' 4 | const Schema = mongoose.Schema 5 | const schema = new Schema({ 6 | id: String, // 群id 7 | adminIdList: Array, 8 | avatar: String, 9 | ownerId: String, 10 | topic: String, 11 | memberIdList: { type: Array }, 12 | robotId: String, // 机器人id 13 | roomJoinReply: { type: String, default: '你好,欢迎加入!' }, 14 | autojoin: { type: Boolean, default: false }, 15 | joinCode: String, 16 | maxFoul: { type: Number, default: 3 }, 17 | control: { type: Boolean, default: false }, 18 | }) 19 | 20 | export interface IGroupModel extends IGroupInfo, mongoose.Document {} 21 | 22 | const Group = mongoose.model('group', schema, 'group') 23 | const Dao = { 24 | myGroups: async (params: IQueryInfo) => { 25 | try { 26 | const condition = parseSearch(params) 27 | const sortF = { sort: { control: -1, joinCode: 1 } } 28 | const fields = {} 29 | const result = await Group.find(condition, fields, sortF) 30 | return result 31 | } catch (err) { 32 | throw err 33 | } 34 | }, 35 | update: async (id: string, params: IGroupInfo) => { 36 | try { 37 | const result = await Group.findByIdAndUpdate(id, params, { 38 | new: true, 39 | }).exec() 40 | return result 41 | } catch (err) { 42 | throw err 43 | } 44 | }, 45 | } 46 | export { Group, Dao } 47 | -------------------------------------------------------------------------------- /server/models/memory.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 机器人记忆 3 | * @Author: skyvow 4 | * @Date: 2020-05-08 19:10:38 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-08 19:42:52 7 | */ 8 | import { IMemoryInfo } from '@/typings' 9 | import { mongoose } from '../config/db' 10 | const Schema = mongoose.Schema 11 | const schema = new Schema({ 12 | person: String, // 联系人 13 | cmd: String, // 指令 14 | roomId: String, // 群id 15 | remark: String, // 备注 16 | }) 17 | 18 | export interface IMemoryModel extends IMemoryInfo, mongoose.Document {} 19 | 20 | const Memory = mongoose.model('memory', schema, 'memory') 21 | export { Memory } 22 | -------------------------------------------------------------------------------- /server/models/reply.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 自动回复 3 | * @Author: skyvow 4 | * @Date: 2020-05-06 18:24:06 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-15 11:08:25 7 | */ 8 | import { IReplyInfo, IQueryInfo } from '@/typings' 9 | import { mongoose } from '../config/db' 10 | import { parseSearch } from '../util' 11 | const Schema = mongoose.Schema 12 | const schema = new Schema({ 13 | keyword: String, // 关键词 14 | content: String, // 回复内容 15 | type: { type: Number, default: 0 }, // 类型 0:普通消息,1:发送群邀请(仅在私聊触发) 2:踢人指令(仅在群聊触发) 16 | factor: { type: Number, default: 0 }, // 触发场景 0:通用,1:私聊 2:群聊 3:通用群聊 17 | status: { type: Number, default: 1 }, // 状态 0停用 1启用 18 | roomId: String, // 群id 19 | robotId: String, // 机器人id 20 | remark: String, // 备注 21 | }) 22 | 23 | export interface IReplyModel extends IReplyInfo, mongoose.Document {} 24 | 25 | const Reply = mongoose.model('reply', schema, 'reply') 26 | const Dao = { 27 | list: async (params: IQueryInfo) => { 28 | try { 29 | const page = Number(params.page || 1) 30 | const limit = Number(params.pageSize || 10) 31 | const start = (page - 1) * limit 32 | const condition = parseSearch(params) 33 | const sortF = { skip: start, limit, sort: { _id: 1 } } 34 | const fields = {} 35 | const total = await Reply.countDocuments(condition) 36 | const list = await Reply.find(condition, fields, sortF) 37 | return { total, list } 38 | } catch (err) { 39 | throw err 40 | } 41 | }, 42 | add: async (params: IReplyInfo) => { 43 | try { 44 | const query: any = { 45 | keyword: params.keyword, 46 | factor: params.factor, 47 | robotId: params.robotId, 48 | } 49 | if (params.factor === 2) { 50 | query.roomId = params.roomId 51 | } 52 | const ishave = await Reply.findOne(query, { _id: 1 }) 53 | if (ishave) { 54 | throw { message: '同一关键字同一场景只能存在1个' } 55 | } 56 | const result = await Reply.create(params) 57 | return result 58 | } catch (err) { 59 | throw err 60 | } 61 | }, 62 | update: async (_id: string, params: IReplyInfo) => { 63 | try { 64 | const result = await Reply.updateOne({ _id }, params) 65 | return result 66 | } catch (err) { 67 | throw err 68 | } 69 | }, 70 | delete: async (ids: string[]) => { 71 | try { 72 | const result = await Reply.deleteMany({ _id: { $in: ids } }) 73 | return result 74 | } catch (err) { 75 | throw err 76 | } 77 | }, 78 | } 79 | export { Reply, Dao } 80 | -------------------------------------------------------------------------------- /server/models/robot.ts: -------------------------------------------------------------------------------- 1 | import { IRobotInfo } from '@/typings' 2 | import { mongoose } from '../config/db' 3 | const Schema = mongoose.Schema 4 | const schema = new Schema({ 5 | nickName: String, 6 | startSay: String, 7 | unknownSay: String, 8 | addFriendKeywords: Array, 9 | addFriendReply: String, 10 | name: String, 11 | avatar: String, 12 | id: String, 13 | weixin: String, 14 | status: { type: Number, default: 0 }, // 状态 0未启动 1已启动 15 | user: { type: Schema.Types.ObjectId, ref: 'auth' }, 16 | createTime: { type: Date, default: new Date() }, 17 | modifyTime: { type: Date, default: new Date() }, 18 | token: String, 19 | lastLoginT: Date, 20 | lastLoginIp: String, 21 | }) 22 | 23 | export interface IRobotModel extends IRobotInfo, mongoose.Document {} 24 | 25 | const Robot = mongoose.model('robot', schema, 'robot') 26 | export { Robot } 27 | -------------------------------------------------------------------------------- /server/models/task.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: 定时消息 3 | * @Author: skyvow 4 | * @Date: 2020-05-06 19:06:00 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-15 11:08:16 7 | */ 8 | import { ITaskInfo, IQueryInfo } from '@/typings' 9 | import { mongoose } from '../config/db' 10 | import { parseSearch } from '../util' 11 | import { restart, stop } from '../bot/lib/Task' 12 | const Schema = mongoose.Schema 13 | const schema = new Schema({ 14 | name: String, // 名称 15 | type: { type: Number, default: 0 }, // 类型 0:普通消息 16 | content: String, // 发送内容 17 | factor: { type: Number, default: 0 }, // 触发场景 0:个人,1:群聊 18 | status: { type: Number, default: 1 }, // 状态 0停用 1启用 19 | friendId: String, // 联系人 20 | roomId: String, // 群id 21 | robotId: String, // 机器人id 22 | cron: String, // cron表达式 23 | unit: Number, // 时间单位 24 | dayOfWeek: Number, 25 | month: Number, 26 | dayOfMonth: Number, 27 | hour: Number, 28 | minute: Number, 29 | second: Number, 30 | }) 31 | 32 | export interface ITaskModel extends ITaskInfo, mongoose.Document {} 33 | 34 | const Task = mongoose.model('task', schema, 'task') 35 | const Dao = { 36 | list: async (params: IQueryInfo) => { 37 | try { 38 | const page = Number(params.page || 1) 39 | const limit = Number(params.pageSize || 10) 40 | const start = (page - 1) * limit 41 | const condition = parseSearch(params) 42 | const sortF = { skip: start, limit, sort: { _id: 1 } } 43 | const fields = {} 44 | const total = await Task.countDocuments(condition) 45 | const list = await Task.find(condition, fields, sortF) 46 | return { total, list } 47 | } catch (err) { 48 | throw err 49 | } 50 | }, 51 | add: async (params: ITaskInfo) => { 52 | try { 53 | const result = await Task.create(params) 54 | await restart(result) 55 | return result 56 | } catch (err) { 57 | throw err 58 | } 59 | }, 60 | update: async (_id: string, params: ITaskInfo) => { 61 | try { 62 | const result = await Task.findByIdAndUpdate({ _id }, params, { 63 | new: true, 64 | }) 65 | if (result) { 66 | await restart(result) 67 | } 68 | return result 69 | } catch (err) { 70 | throw err 71 | } 72 | }, 73 | delete: async (ids: string[]) => { 74 | try { 75 | const result = await Task.deleteMany({ _id: { $in: ids } }) 76 | ids.forEach(item => stop(item)) 77 | return result 78 | } catch (err) { 79 | throw err 80 | } 81 | }, 82 | } 83 | export { Task, Dao } 84 | -------------------------------------------------------------------------------- /server/routes/api.ts: -------------------------------------------------------------------------------- 1 | import KoaRouter from 'koa-router' 2 | import getUser from '../middleware/getUser' 3 | import botLogin from '../middleware/botLogin' 4 | import AdminCtrl from '../controller/admin' 5 | import RobotCtrl from '../controller/robot' 6 | const router = new KoaRouter() 7 | 8 | router.prefix('/api') 9 | 10 | router.post('/auth/login', AdminCtrl.login) 11 | router.get('/auth/user', getUser(), AdminCtrl.getUser) 12 | router.post('/auth/logout', AdminCtrl.logout) 13 | 14 | router.get('/admin/robot/:id', AdminCtrl.getRobot) 15 | router.post('/admin/robot', getUser(), AdminCtrl.addRobot) 16 | router.put('/admin/robot/:id', AdminCtrl.updateRobot) 17 | 18 | router.get('/admin/group', AdminCtrl.getGroups) 19 | router.put('/admin/group/:id', AdminCtrl.updateGroup) 20 | 21 | router.get('/admin/friend', AdminCtrl.getFriends) 22 | 23 | router.get('/admin/reply', AdminCtrl.getReplys) 24 | router.post('/admin/reply', AdminCtrl.addReply) 25 | router.put('/admin/reply/:id', AdminCtrl.updateReply) 26 | router.post('/admin/reply', AdminCtrl.deleteReply) 27 | 28 | router.get('/admin/task', AdminCtrl.getTasks) 29 | router.post('/admin/task', AdminCtrl.addTask) 30 | router.put('/admin/task/:id', AdminCtrl.updateTask) 31 | router.post('/admin/task', AdminCtrl.deleteTask) 32 | 33 | router.post('/robot/login', RobotCtrl.login) 34 | router.post('/robot/logout', RobotCtrl.logout) 35 | 36 | router.post('/robot/friend/say', botLogin(), RobotCtrl.friendSay) 37 | 38 | router.post('/robot/room/say', botLogin(), RobotCtrl.roomSay) 39 | router.get('/robot/room/:id', botLogin(), RobotCtrl.getRoom) 40 | router.put('/robot/room/:id', botLogin(), RobotCtrl.updateRoom) 41 | router.post('/robot/room/quit', botLogin(), RobotCtrl.roomQuit) 42 | 43 | export default router 44 | -------------------------------------------------------------------------------- /server/util/ajax.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { FileBox } from 'file-box' 3 | import urllib from 'urllib' 4 | import { v1 } from 'node-uuid' 5 | import config from '../../config' 6 | import { getTGImage } from './tiangou' 7 | const md5 = crypto.createHash('md5') 8 | const uniqueId = md5.update(v1()).digest('hex') 9 | 10 | const { tianApiUrl, tianApiKey } = config 11 | const articleTypes = ['__JUEJIN__', '掘金早报', '__TIANGOU__', '舔狗日记', '__ZHIHU__', '知乎日报'] 12 | 13 | function getDateString (split = ['-', '-', '']) { 14 | return `${new Date().getFullYear()}${split[0]}${new Date().getMonth() + 1}${split[1]}${new Date().getDate()}${ 15 | split[2] 16 | }` 17 | } 18 | 19 | async function request (url: string, params: any = {}) { 20 | const pkg = { 21 | method: params.method || 'get', 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | }, 25 | data: params.data || {}, 26 | encoding: null, 27 | timeout: 5000, 28 | } 29 | /* eslint prefer-const: "off" */ 30 | let { status, data } = await urllib.request(url, pkg) 31 | if (status !== 200) { 32 | return '不好意思,我断网了' 33 | } 34 | data = JSON.parse(data.toString()) 35 | // if (data.code !== 200) return '我累啦,等我休息好再来哈' 36 | // console.log(data) 37 | return data 38 | } 39 | 40 | /** 41 | * 掘金早报 42 | * 43 | * @see https://juejin.im/ 44 | * 45 | * @returns 46 | */ 47 | async function getArticleFromJUEJIN () { 48 | const data = await request('https://api.juejin.cn/recommend_api/v1/article/recommend_all_feed', { 49 | method: 'POST', 50 | data: { 51 | id_type: 2, 52 | client_type: 2608, 53 | sort_type: 200, 54 | cursor: '0', 55 | limit: 20, 56 | }, 57 | }) 58 | if (data && data.err_no === 0) { 59 | const result = data.data 60 | .filter((v: any) => v.item_info.article_info) 61 | .slice(0, 5) 62 | .reduce( 63 | (acc: any[], item: any, i: number) => { 64 | const articleInfo = item.item_info.article_info 65 | return [...acc, `${i + 1}.${articleInfo.title} - https://juejin.im/post/${articleInfo.article_id}`] 66 | }, 67 | [`掘金早报 - ${getDateString()}`], 68 | ) 69 | .join('\n') 70 | return result 71 | } 72 | return null 73 | } 74 | 75 | /** 76 | * 舔狗日记 77 | * 78 | * @see https://www.tianapi.com/gethttp/180 79 | * 80 | * @returns 81 | */ 82 | async function getArticleFromTIANGOU (args: any[] = []) { 83 | const day = new Date().getDate() 84 | const month = new Date().getMonth() + 1 85 | const d = `${month}月${day}日` 86 | const [type = 'P', date = d, weather = '晴', content] = args 87 | let data: string = content 88 | if (!content) { 89 | const res = await request(tianApiUrl + 'tiangou/index', { 90 | data: { key: tianApiKey }, 91 | }) 92 | if (res.code !== 200) { 93 | return '我累啦,等我休息好再来哈' 94 | } 95 | data = res.newslist[0].content 96 | } 97 | if (type !== 'P') { return data } 98 | const dataUrl = await getTGImage(data, { 99 | date, 100 | weather 101 | }) 102 | const fileBox = FileBox.fromDataURL(dataUrl, 'tgrj.png') 103 | return fileBox 104 | } 105 | 106 | /** 107 | * 知乎日报 108 | * 109 | * @see https://daily.zhihu.com/ 110 | * 111 | * @returns 112 | */ 113 | async function getArticleFromZHIHU () { 114 | const data = await request('https://news-at.zhihu.com/api/4/news/latest') 115 | if (!data.stories) { 116 | return '我累啦,等我休息好再来哈' 117 | } 118 | const result = data.stories 119 | .slice(0, 5) 120 | .reduce( 121 | (acc: any[], item: any, i: number) => { 122 | return [...acc, `${i + 1}.${item.title} - ${item.url}`] 123 | }, 124 | [`知乎日报 - ${getDateString()}`], 125 | ) 126 | .join('\n') 127 | return result 128 | } 129 | 130 | /** 131 | * 机器人回复内容 132 | * 133 | * @see https://www.tianapi.com/apiview/47 134 | * 135 | * @param {String} keyword 收到消息 136 | * @returns 137 | */ 138 | async function getReplyToMSG (keyword: string) { 139 | const url = tianApiUrl + 'robot/index' 140 | const pkg = { 141 | method: 'get', 142 | headers: { 143 | 'Content-Type': 'application/json', 144 | }, 145 | data: { 146 | key: tianApiKey, 147 | question: keyword, 148 | mode: 1, 149 | datatype: 0, 150 | userid: uniqueId, 151 | limit: 1, 152 | }, 153 | encoding: null, 154 | timeout: 5000, 155 | } 156 | const data = await request(url, pkg) 157 | if (data.code !== 200) { 158 | return '我累啦,等我休息好再来哈' 159 | } 160 | return data.newslist[0].reply 161 | } 162 | 163 | async function getArticle (type: string, args: any[] = []) { 164 | let result: any = '不好意思,我断网了' 165 | switch (type) { 166 | case '__JUEJIN__': 167 | case '掘金早报': 168 | result = await getArticleFromJUEJIN() 169 | break 170 | case '__TIANGOU__': 171 | case '舔狗日记': 172 | result = await getArticleFromTIANGOU(args) 173 | break 174 | case '__ZHIHU__': 175 | case '知乎日报': 176 | result = await getArticleFromZHIHU() 177 | break 178 | } 179 | return result 180 | } 181 | 182 | getArticle('__TIANGOU__', ['P', '2.25', '小雨']) 183 | 184 | /** 185 | * 针对自定义内容进行回复 186 | * 187 | * @param {String} content 自定义内容 188 | */ 189 | async function getReplyToContent (content: string, args: any[] = []) { 190 | const [type = '', ...argsH] = content ? content.split(' ') : [] 191 | if (type && articleTypes.includes(type)) { 192 | try { 193 | const msg = await getArticle(type, argsH.length ? argsH : args) 194 | return msg 195 | } catch (err) { 196 | console.log(err) 197 | } 198 | } 199 | return type 200 | } 201 | 202 | /** 203 | * 机器人回复内容 204 | * 205 | * @param {String} msg 收到消息 206 | * @param {Number} type 消息类型, normal: 普通消息回复, keyword: 关键词内容回复, task: 定时任务内容回复 207 | */ 208 | async function getReply (msg: string = '', type: 'normal' | 'keyword' | 'task' = 'normal', args?) { 209 | switch (type) { 210 | case 'keyword': 211 | case 'task': 212 | return await getReplyToContent(msg, args) 213 | default: 214 | return await getReplyToMSG(msg) 215 | } 216 | } 217 | 218 | export { getReplyToMSG, getReplyToContent, getReply } 219 | -------------------------------------------------------------------------------- /server/util/index.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import jwt from 'jsonwebtoken' 3 | import config from '../../config' 4 | const { secret } = config 5 | const encryptPassword = (salt: string, password: string) => { 6 | const str = salt + password 7 | const md5 = crypto.createHash('sha1') 8 | md5.update(str) 9 | return md5.digest('hex') 10 | } 11 | const generateStr = (len: number, charType: string) => { 12 | len = len || 6 13 | charType = charType || 'number' 14 | const chars1 = 'ABCDEFGHJKMNPQRSTUVWXYabcdefghjkmnpqrstuvwxy' 15 | const chars2 = '0123456789' 16 | const chars = charType === 'string' ? chars1 : chars2 17 | const maxPos = chars.length 18 | let str = '' 19 | for (let i = 0; i < len; i++) { 20 | str += chars.charAt(Math.floor(Math.random() * maxPos)) 21 | } 22 | return str 23 | } 24 | const createToken = (payload = {}, expiresIn: string = '24h') => { 25 | return jwt.sign(payload, secret, { expiresIn }) 26 | } 27 | const verifyToken = (token: string) => { 28 | return jwt.verify(token.split(' ')[1], secret) 29 | } 30 | const parseSearch = function (params: any) { 31 | const result: any = {} 32 | for (const key in params) { 33 | if (key.indexOf('search') === 0 && key.split('$$').length >= 3 && params[key] !== '') { 34 | const field = key.split('$$')[1] 35 | // 搜索字段 36 | const sType = key.split('$$')[2] 37 | // 搜索方式 38 | let value = params[key] || '' 39 | // 值 40 | if (value !== 'undefined' && value !== '' && value !== 'null') { 41 | switch (sType) { 42 | case 'all': 43 | value = { 44 | $all: [new RegExp('.*' + value + '.*', 'gi')], 45 | } 46 | for (let i = 0; i < field.split('|').length; i++) { 47 | result[field.split('|')[i]] = value 48 | } 49 | break 50 | case 'orAndall': 51 | let tempRegExp 52 | const params: any[] = [] 53 | for (let i = 0; i < field.split('|').length; i++) { 54 | for (let j = 0; j < value.split('|').length; j++) { 55 | const item: any = {} 56 | tempRegExp = { 57 | $all: [new RegExp('.*' + value.split('|')[j] + '.*', 'gi')], 58 | } 59 | item[field.split('|')[i]] = tempRegExp 60 | params.push(item) 61 | } 62 | } 63 | result.$or = params 64 | break 65 | case 'or': 66 | for (let i = 0; i < field.split('|').length; i++) { 67 | const params: any[] = [] 68 | for (let j = 0; j < value.split('|').length; j++) { 69 | params.push(value.split('|')[j]) 70 | } 71 | result[field.split('|')[i]] = { 72 | $in: params, 73 | } 74 | } 75 | break 76 | case 'gte': 77 | for (let i = 0; i < field.split('|').length; i++) { 78 | result[field.split('|')[i]] = { 79 | $gte: value, 80 | } 81 | } 82 | break 83 | case 'lte': 84 | for (let i = 0; i < field.split('|').length; i++) { 85 | result[field.split('|')[i]] = { 86 | $lte: value, 87 | } 88 | } 89 | break 90 | case 'between': 91 | const values = value.split('|') 92 | if (values.length === 2) { 93 | for (let i = 0; i < field.split('|').length; i++) { 94 | if (values[0] !== '' && values[1] !== '') { 95 | result[field.split('|')[i]] = { 96 | $gte: values[0], 97 | $lte: values[1], 98 | } 99 | } else if (values[0] !== '') { 100 | result[field.split('|')[i]] = { 101 | $gte: values[0], 102 | } 103 | } else if (values[1] !== '') { 104 | result[field.split('|')[i]] = { 105 | $lte: values[1], 106 | } 107 | } 108 | } 109 | } 110 | break 111 | default: 112 | for (let i = 0; i < field.split('|').length; i++) { 113 | result[field.split('|')[i]] = value 114 | } 115 | break 116 | } 117 | } 118 | } 119 | } 120 | return result 121 | } 122 | export { encryptPassword, generateStr, createToken, verifyToken, parseSearch } 123 | -------------------------------------------------------------------------------- /server/util/logger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Desc: logger 3 | * @Author: skyvow 4 | * @Date: 2020-04-26 14:28:26 5 | * @LastEditors: skyvow 6 | * @LastEditTime: 2020-05-15 18:29:05 7 | */ 8 | import { Context } from 'koa' 9 | import log4js from 'log4js' 10 | import logConfig from '../config/log4js' 11 | // 加载配置文件 12 | log4js.configure(logConfig) 13 | const logUtil: { 14 | logError: (ctx: Context, error: Error, resTime: number) => void 15 | error: (err: string) => void 16 | info: (info: string) => void 17 | logResponse: (ctx: Context, resTime: number) => void 18 | logInfo: (info: any) => void 19 | } = { 20 | logError () {}, 21 | error () {}, 22 | info () {}, 23 | logResponse () {}, 24 | logInfo () {}, 25 | } 26 | // 调用预先定义的日志名称 27 | const resLogger = log4js.getLogger('resLogger') 28 | const errorLogger = log4js.getLogger('errorLogger') 29 | const infoLogger = log4js.getLogger('infoLogger') 30 | const consoleLogger = log4js.getLogger() 31 | // 封装错误日志 32 | logUtil.logError = function (ctx, error, resTime) { 33 | if (ctx && error) { 34 | errorLogger.error(formatError(ctx, error, resTime)) 35 | } 36 | } 37 | logUtil.error = function (err) { 38 | errorLogger.error(err) 39 | } 40 | logUtil.info = function (info) { 41 | infoLogger.info(info) 42 | } 43 | // 封装响应日志 44 | logUtil.logResponse = function (ctx, resTime) { 45 | if (ctx) { 46 | resLogger.info(formatRes(ctx, resTime)) 47 | } 48 | } 49 | logUtil.logInfo = function (info) { 50 | if (info) { 51 | consoleLogger.info(formatInfo(info)) 52 | } 53 | } 54 | const formatInfo = function (info: any) { 55 | let logText = '' 56 | logText += '\n' + '***************info log start ***************' + '\n' 57 | logText += 'info detail: ' + '\n' + JSON.stringify(info) + '\n' 58 | logText += '*************** info log end ***************' + '\n' 59 | return logText 60 | } 61 | // 格式化响应日志 62 | const formatRes = function (ctx: Context, resTime: number) { 63 | let logText = '' 64 | logText += '\n' + '*************** response log start ***************' + '\n' 65 | logText += formatReqLog(ctx.request, resTime) 66 | logText += 'response status: ' + ctx.status + '\n' 67 | logText += 'response body: ' + '\n' + JSON.stringify(ctx.body) + '\n' 68 | logText += '*************** response log end ***************' + '\n' 69 | return logText 70 | } 71 | // 格式化错误日志 72 | const formatError = function (ctx: Context, err: Error, resTime: number) { 73 | let logText = '' 74 | logText += '\n' + '*************** error log start ***************' + '\n' 75 | logText += formatReqLog(ctx.request, resTime) 76 | logText += 'err name: ' + err.name + '\n' 77 | logText += 'err message: ' + err.message + '\n' 78 | logText += 'err stack: ' + err.stack + '\n' 79 | logText += '*************** error log end ***************' + '\n' 80 | return logText 81 | } 82 | // 格式化请求日志 83 | const formatReqLog = function (req: any, resTime: number) { 84 | let logText = '' 85 | const method = req.method 86 | logText += 'request method: ' + method + '\n' 87 | logText += 'request originalUrl: ' + req.originalUrl + '\n' 88 | logText += 'request client ip: ' + req.ip + '\n' 89 | if (method === 'GET') { 90 | logText += 'request query: ' + JSON.stringify(req.query) + '\n' 91 | } else { 92 | logText += 'request body: ' + '\n' + JSON.stringify(req.body) + '\n' 93 | } 94 | // 服务器响应时间 95 | logText += 'response time: ' + resTime + '\n' 96 | return logText 97 | } 98 | export default logUtil 99 | -------------------------------------------------------------------------------- /server/util/tiangou.ts: -------------------------------------------------------------------------------- 1 | import { CanvasRenderingContext2D, createCanvas, loadImage } from 'canvas' 2 | import fs from 'fs' 3 | 4 | const TIANGOU_F_PATH = 'static/tgrj2.jpg' 5 | const TIANGOU_M_PATH = 'static/tgrj.jpg' 6 | 7 | function findBreakPoint (text: string, width: number, ctx: CanvasRenderingContext2D) { 8 | let min = 0 9 | let max = text.length - 1 10 | while (min <= max) { 11 | const middle = Math.floor((min + max) / 2) 12 | const middleWidth = ctx.measureText(text.substr(0, middle)).width 13 | const oneCharWiderThanMiddleWidth = ctx.measureText(text.substr(0, middle + 1)).width 14 | if (middleWidth <= width && oneCharWiderThanMiddleWidth > width) { 15 | return middle 16 | } 17 | if (middleWidth < width) { 18 | min = middle + 1 19 | } else { 20 | max = middle - 1 21 | } 22 | } 23 | return -1 24 | } 25 | 26 | function breakLinesForCanvas (text: string, width: number, font: string) { 27 | const canvas = createCanvas(400, 4000) 28 | const ctx = canvas.getContext('2d') 29 | const result: string[] = [] 30 | let breakPoint = 0 31 | if (font) { 32 | ctx.font = font 33 | } 34 | while ((breakPoint = findBreakPoint(text, width, ctx)) !== -1) { 35 | result.push(text.substr(0, breakPoint)) 36 | text = text.substr(breakPoint) 37 | } 38 | if (text) { 39 | result.push(text) 40 | } 41 | ctx.clearRect(0, 0, 400, 4000) 42 | return result 43 | } 44 | 45 | export async function getTGImage ( 46 | text: string, 47 | { date, weather } : { date?: string, weather?: string } 48 | ) { 49 | const day = new Date().getDate() 50 | const month = new Date().getMonth() + 1 51 | const d = `${month}月${day}日` 52 | const result: string[] = [ 53 | `${date || d} ${weather || '晴'}`, 54 | ...breakLinesForCanvas(text, 400 - 20 * 2, 'bold 22px "Microsoft YaHei"') 55 | ] 56 | const image = await loadImage(day % 2 ? TIANGOU_F_PATH : TIANGOU_M_PATH) 57 | const height = 350 + result.length * 30 58 | const canvas = createCanvas(400, height) 59 | const ctx = canvas.getContext('2d') 60 | ctx.fillStyle = '#ffffff' 61 | ctx.fillRect(0, 0, 400, height) 62 | ctx.fillStyle = '#000000' 63 | ctx.drawImage(image, 0, 0, 400, 600) 64 | ctx.font = 'bold 22px "Microsoft YaHei"' 65 | result.forEach((line, index) => { 66 | ctx.fillText(line, 20, (index ? 350 : 320) + (30 * index)) 67 | }) 68 | if (process.env.NODE_ENV !== 'production') { 69 | fs.writeFileSync(`tmp/tgrj-${month}-${day}.png`, canvas.toBuffer()) 70 | } 71 | return canvas.toDataURL('image/png') 72 | } 73 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wux-weapp/wxbot/f7d79de65aeeef2dc99f789b66ff2fb036c34747/static/favicon.ico -------------------------------------------------------------------------------- /static/tgrj.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wux-weapp/wxbot/f7d79de65aeeef2dc99f789b66ff2fb036c34747/static/tgrj.jpg -------------------------------------------------------------------------------- /static/tgrj2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wux-weapp/wxbot/f7d79de65aeeef2dc99f789b66ff2fb036c34747/static/tgrj2.jpg -------------------------------------------------------------------------------- /store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import robot from './module/robot' 4 | 5 | Vue.use(Vuex) 6 | 7 | const store = () => 8 | new Vuex.Store({ 9 | state: {}, 10 | mutations: {}, 11 | modules: { 12 | robot, 13 | }, 14 | }) 15 | 16 | export default store 17 | -------------------------------------------------------------------------------- /store/module/robot.ts: -------------------------------------------------------------------------------- 1 | const state = { 2 | appName: 'wxbot', 3 | } 4 | export default { 5 | state, 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "esnext", 8 | "esnext.asynciterable", 9 | "dom" 10 | ], 11 | "esModuleInterop": true, 12 | "allowJs": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "strict": true, 16 | "noEmit": true, 17 | "experimentalDecorators": true, 18 | "skipLibCheck": true, 19 | "noImplicitAny": false, 20 | "baseUrl": ".", 21 | "paths": { 22 | "~/*": [ 23 | "./*" 24 | ], 25 | "@/*": [ 26 | "./*" 27 | ] 28 | }, 29 | "types": [ 30 | "@types/node", 31 | "@nuxt/types" 32 | ] 33 | }, 34 | "exclude": [ 35 | "node_modules", 36 | ".nuxt", 37 | "dist" 38 | ] 39 | } -------------------------------------------------------------------------------- /typings/auth.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 平台用户信息类型 3 | * 4 | * @export 5 | * @interface IAuthInfo 6 | */ 7 | export interface IAuthInfo { 8 | /** 9 | * 用户名 10 | * 11 | * @type {string} 12 | * @memberof IAuthInfo 13 | */ 14 | username: string 15 | 16 | /** 17 | * 用户密码 18 | * 19 | * @type {string} 20 | * @memberof IAuthInfo 21 | */ 22 | password: string 23 | 24 | /** 25 | * Salt 值 26 | * 27 | * @type {string} 28 | * @memberof IAuthInfo 29 | */ 30 | salt: string 31 | } 32 | -------------------------------------------------------------------------------- /typings/friend.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 好友信息类型 3 | * 4 | * @export 5 | * @interface IFriendInfo 6 | */ 7 | export interface IFriendInfo { 8 | /** 9 | * 昵称 10 | * 11 | * @type {string} 12 | * @memberof IFriendInfo 13 | */ 14 | name: string 15 | 16 | /** 17 | * 备注 18 | * 19 | * @type {string} 20 | * @memberof IFriendInfo 21 | */ 22 | alias: string 23 | 24 | /** 25 | * 头像 26 | * 27 | * @type {string} 28 | * @memberof IFriendInfo 29 | */ 30 | avatar: string 31 | 32 | /** 33 | * 省份 34 | * 35 | * @type {string} 36 | * @memberof IFriendInfo 37 | */ 38 | province: string 39 | 40 | /** 41 | * 城市 42 | * 43 | * @type {string} 44 | * @memberof IFriendInfo 45 | */ 46 | city: string 47 | 48 | /** 49 | * 性别 50 | * 51 | * @type {number} 52 | * @memberof IFriendInfo 53 | */ 54 | gender: number 55 | 56 | /** 57 | * 微信 58 | * 59 | * @type {string} 60 | * @memberof IFriendInfo 61 | */ 62 | weixin: string 63 | 64 | /** 65 | * 机器人id 66 | * 67 | * @type {string} 68 | * @memberof IFriendInfo 69 | */ 70 | robotId: string 71 | } 72 | -------------------------------------------------------------------------------- /typings/group.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 群信息类型 3 | * 4 | * @export 5 | * @interface IGroupInfo 6 | */ 7 | export interface IGroupInfo { 8 | /** 9 | * 群管理员列表 10 | * 11 | * @type {string[]} 12 | * @memberof IGroupInfo 13 | */ 14 | adminIdList: string[] 15 | 16 | /** 17 | * 群头像 18 | * 19 | * @type {string} 20 | * @memberof IGroupInfo 21 | */ 22 | avatar: string 23 | 24 | /** 25 | * 群所有者 26 | * 27 | * @type {string} 28 | * @memberof IGroupInfo 29 | */ 30 | ownerId: string 31 | 32 | /** 33 | * 群名称 34 | * 35 | * @type {string} 36 | * @memberof IGroupInfo 37 | */ 38 | topic: string 39 | 40 | /** 41 | * 群员列表 42 | * 43 | * @type {string[]} 44 | * @memberof IGroupInfo 45 | */ 46 | memberIdList: string[] 47 | 48 | /** 49 | * 机器人id 50 | * 51 | * @type {string} 52 | * @memberof IGroupInfo 53 | */ 54 | robotId: string 55 | 56 | /** 57 | * 入群欢迎语 58 | * 59 | * @type {string} 60 | * @memberof IGroupInfo 61 | */ 62 | roomJoinReply: string 63 | 64 | /** 65 | * 是否开启自动加群 66 | * 67 | * @type {boolean} 68 | * @memberof IGroupInfo 69 | */ 70 | autojoin: boolean 71 | 72 | /** 73 | * 群编码(字母),用于私聊自动回复“加群”匹配 74 | * 75 | * @type {string} 76 | * @memberof IGroupInfo 77 | */ 78 | joinCode: string 79 | 80 | /** 81 | * 群员违规上限 82 | * 83 | * @type {number} 84 | * @memberof IGroupInfo 85 | */ 86 | maxFoul: number 87 | 88 | /** 89 | * 是否开启机器人控制 90 | * 91 | * @type {boolean} 92 | * @memberof IGroupInfo 93 | */ 94 | control: boolean 95 | } 96 | -------------------------------------------------------------------------------- /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './auth' 2 | export * from './friend' 3 | export * from './group' 4 | export * from './memory' 5 | export * from './reply' 6 | export * from './robot' 7 | export * from './task' 8 | 9 | /** 10 | * 接口返回信息类型 11 | * 12 | * @export 13 | * @interface IAsyncResult 14 | * @template T 15 | */ 16 | export interface IAsyncResult { 17 | /** 18 | * 接口是否调用成功 19 | * 20 | * @type {boolean} 21 | * @memberof IAsyncResult 22 | */ 23 | success?: boolean 24 | 25 | /** 26 | * 接口返回数据 27 | * 28 | * @type {T} 29 | * @memberof IAsyncResult 30 | */ 31 | data?: T 32 | 33 | /** 34 | * 错误状态码 35 | * 36 | * @type {number} 37 | * @memberof IAsyncResult 38 | */ 39 | errcode?: number 40 | 41 | /** 42 | * 错误信息 43 | * 44 | * @type {string} 45 | * @memberof IAsyncResult 46 | */ 47 | errmsg?: string 48 | } 49 | 50 | /** 51 | * 查询参数类型 52 | * 53 | * @export 54 | * @interface IQueryInfo 55 | */ 56 | export interface IQueryInfo { 57 | /** 58 | * 当前页数 59 | * 60 | * @type {number} 61 | * @memberof IQueryInfo 62 | */ 63 | page: number 64 | 65 | /** 66 | * 每页条数 67 | * 68 | * @type {number} 69 | * @memberof IQueryInfo 70 | */ 71 | pageSize: number 72 | 73 | [key: string]: any 74 | } 75 | -------------------------------------------------------------------------------- /typings/memory.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 记忆信息类型 3 | * 4 | * @export 5 | * @interface IMemoryInfo 6 | */ 7 | export interface IMemoryInfo { 8 | /** 9 | * 联系人 10 | * 11 | * @type {string} 12 | * @memberof IMemoryInfo 13 | */ 14 | person: string 15 | 16 | /** 17 | * 指令 18 | * 19 | * @type {string} 20 | * @memberof IMemoryInfo 21 | */ 22 | cmd: string 23 | 24 | /** 25 | * 群id 26 | * 27 | * @type {string} 28 | * @memberof IMemoryInfo 29 | */ 30 | roomId: string 31 | 32 | /** 33 | * 备注 34 | * 35 | * @type {string} 36 | * @memberof IMemoryInfo 37 | */ 38 | remark: string 39 | } 40 | -------------------------------------------------------------------------------- /typings/reply.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 自动回复信息类型 3 | * 4 | * @export 5 | * @interface IReplyInfo 6 | */ 7 | export interface IReplyInfo { 8 | /** 9 | * 关键词 10 | * 11 | * @type {string} 12 | * @memberof IReplyInfo 13 | */ 14 | keyword: string 15 | 16 | /** 17 | * 回复内容 18 | * 19 | * @type {string} 20 | * @memberof IReplyInfo 21 | */ 22 | content: string 23 | 24 | /** 25 | * 类型 0:普通消息,1:发送群邀请(仅在私聊触发) 2:踢人指令(仅在群聊触发) 26 | * 27 | * @type {(0 | 1 | 2)} 28 | * @memberof IReplyInfo 29 | */ 30 | type: 0 | 1 | 2 31 | 32 | /** 33 | * 触发场景 0:通用,1:私聊 2:群聊 3:通用群聊 34 | * 35 | * @type {(0 | 1 | 2| 3)} 36 | * @memberof IReplyInfo 37 | */ 38 | factor: 0 | 1 | 2| 3 39 | 40 | /** 41 | * 状态 0停用 1启用 42 | * 43 | * @type {(0 | 1)} 44 | * @memberof IReplyInfo 45 | */ 46 | status: 0 | 1 47 | 48 | /** 49 | * 群id 50 | * 51 | * @type {string} 52 | * @memberof IReplyInfo 53 | */ 54 | roomId: string 55 | 56 | /** 57 | * 机器人id 58 | * 59 | * @type {string} 60 | * @memberof IReplyInfo 61 | */ 62 | robotId: string 63 | 64 | /** 65 | * 备注 66 | * 67 | * @type {string} 68 | * @memberof IReplyInfo 69 | */ 70 | remark: string 71 | } 72 | -------------------------------------------------------------------------------- /typings/robot.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 机器人信息类型 3 | * 4 | * @export 5 | * @interface IRobotInfo 6 | */ 7 | export interface IRobotInfo { 8 | /** 9 | * 机器人名称 10 | * 11 | * @type {string} 12 | * @memberof IRobotInfo 13 | */ 14 | nickName: string 15 | 16 | /** 17 | * 启动提示语 18 | * 19 | * @type {string} 20 | * @memberof IRobotInfo 21 | */ 22 | startSay: string 23 | 24 | /** 25 | * 知识盲区回复 26 | * 27 | * @type {string} 28 | * @memberof IRobotInfo 29 | */ 30 | unknownSay: string 31 | 32 | /** 33 | * 好友通过关键字 34 | * 35 | * @type {string[]} 36 | * @memberof IRobotInfo 37 | */ 38 | addFriendKeywords: string[] 39 | 40 | /** 41 | * 好友通过自动回复 42 | * 43 | * @type {string} 44 | * @memberof IRobotInfo 45 | */ 46 | addFriendReply: string 47 | 48 | /** 49 | * 名称 50 | * 51 | * @type {string} 52 | * @memberof IRobotInfo 53 | */ 54 | name: string 55 | 56 | /** 57 | * 头像 58 | * 59 | * @type {string} 60 | * @memberof IRobotInfo 61 | */ 62 | avatar: string 63 | 64 | /** 65 | * 微信 66 | * 67 | * @type {string} 68 | * @memberof IRobotInfo 69 | */ 70 | weixin: string 71 | 72 | /** 73 | * 状态 0未启动 1已启动 74 | * 75 | * @type {(0 | 1)} 76 | * @memberof IRobotInfo 77 | */ 78 | status: 0 | 1 79 | 80 | /** 81 | * 协议Token 82 | * 83 | * @type {string} 84 | * @memberof IRobotInfo 85 | */ 86 | token: string 87 | } 88 | -------------------------------------------------------------------------------- /typings/task.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 定时任务规则信息类型 3 | * 4 | * @export 5 | * @interface ITaskRuleInfo 6 | */ 7 | export interface ITaskRuleInfo { 8 | /** 9 | * 指定周几 10 | * 11 | * @type {number} 12 | * @memberof ITaskInfo 13 | */ 14 | dayOfWeek?: number 15 | 16 | /** 17 | * 指定某月 18 | * 19 | * @type {number} 20 | * @memberof ITaskInfo 21 | */ 22 | month?: number 23 | 24 | /** 25 | * 指定某天 26 | * 27 | * @type {number} 28 | * @memberof ITaskInfo 29 | */ 30 | dayOfMonth?: number 31 | 32 | /** 33 | * 指定某时 34 | * 35 | * @type {number} 36 | * @memberof ITaskInfo 37 | */ 38 | hour?: number 39 | 40 | /** 41 | * 指定某分 42 | * 43 | * @type {number} 44 | * @memberof ITaskInfo 45 | */ 46 | minute?: number 47 | 48 | /** 49 | * 指定某秒 50 | * 51 | * @type {number} 52 | * @memberof ITaskInfo 53 | */ 54 | second?: number 55 | } 56 | 57 | /** 58 | * 定时任务信息类型 59 | * 60 | * @export 61 | * @interface ITaskInfo 62 | */ 63 | export interface ITaskInfo extends ITaskRuleInfo { 64 | /** 65 | * 名称 66 | * 67 | * @type {string} 68 | * @memberof ITaskInfo 69 | */ 70 | name: string 71 | 72 | /** 73 | * 类型 0:普通消息,1:其他 74 | * 75 | * @type {(0 | 1)} 76 | * @memberof ITaskInfo 77 | */ 78 | type: 0 | 1 79 | 80 | /** 81 | * 发送内容 82 | * 83 | * @type {string} 84 | * @memberof ITaskInfo 85 | */ 86 | content: string 87 | 88 | /** 89 | * 触发场景 0:个人,1:群聊,2:通用群聊 90 | * 91 | * @type {(0 | 1 | 2)} 92 | * @memberof ITaskInfo 93 | */ 94 | factor: 0 | 1 | 2 95 | 96 | /** 97 | * 状态 0停用 1启用 98 | * 99 | * @type {(0 | 1)} 100 | * @memberof ITaskInfo 101 | */ 102 | status: 0 | 1 103 | 104 | /** 105 | * 联系人 106 | * 107 | * @type {string} 108 | * @memberof ITaskInfo 109 | */ 110 | friendId: string 111 | 112 | /** 113 | * 群id 114 | * 115 | * @type {string} 116 | * @memberof ITaskInfo 117 | */ 118 | roomId: string 119 | 120 | /** 121 | * 机器人id 122 | * 123 | * @type {string} 124 | * @memberof ITaskInfo 125 | */ 126 | robotId: string 127 | 128 | /** 129 | * cron表达式,优先级高于 rule(dayOfWeek,month...) 指定的时间 130 | * 131 | * @type {string} 132 | * @memberof ITaskInfo 133 | */ 134 | cron: string 135 | 136 | /** 137 | * 时间单位 0:每分钟,1:每小时,2:每天,3:自定义 138 | * 139 | * @type {(0 | 1 | 2 | 3)} 140 | * @memberof ITaskInfo 141 | */ 142 | unit: 0 | 1 | 2 | 3 143 | } 144 | -------------------------------------------------------------------------------- /typings/vue-shim.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | 6 | // declare module 'vue/types/vue' { 7 | // interface Vue { 8 | // $auth: any 9 | // } 10 | // } 11 | --------------------------------------------------------------------------------