├── .commitlintrc.json ├── .cz-config.json ├── .dockerignore ├── .env ├── .github └── workflows │ └── deploy-docker.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .node-version ├── Dockerfile ├── README.md ├── base.Dockerfile ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml └── src ├── app.js ├── library └── baseClass │ ├── index.js │ └── taskClass.js ├── models ├── helper │ └── baseModel.js ├── index.js ├── pc.js └── user.js ├── plugins ├── config │ ├── configs │ │ └── production.js │ └── index.js ├── sensible.js ├── sequelize.js ├── support.js ├── wechat-bot │ ├── index.js │ └── wechat │ │ ├── index.js │ │ └── task │ │ ├── index.js │ │ ├── openwrt.js │ │ └── timerSend.js └── wxhelper │ ├── index.js │ └── wxhelper.js ├── routes ├── pc │ └── index.js ├── user │ └── index.js └── wechat │ └── index.js └── utils ├── other.js └── ssh.js /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.cz-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "value": "build", 5 | "name": "build: 主要目的是修改项目构建系统(例如 glup,webpack,rollup 的配置等)的提交" 6 | }, 7 | { 8 | "value": "ci", 9 | "name": "ci: 更改持续集成软件的配置文件" 10 | }, 11 | { 12 | "value": "docs", 13 | "name": "docs: 变更的只有文档" 14 | }, 15 | { 16 | "value": "feat", 17 | "name": "feat: 新增功能" 18 | }, 19 | { 20 | "value": "fix", 21 | "name": "fix: 修复 bug" 22 | }, 23 | { 24 | "value": "perf", 25 | "name": "perf: 提升性能、体验" 26 | }, 27 | { 28 | "value": "refactor", 29 | "name": "refactor: 代码重构(既没有新增功能,也没有修复 bug)" 30 | }, 31 | { 32 | "value": "style", 33 | "name": "style: 不影响程序逻辑的代码修改(修改空白字符,格式缩进,补全缺失的分号等,没有改变代码逻辑)" 34 | }, 35 | { 36 | "value": "test", 37 | "name": "test: 新增测试用例或是更新现有测试" 38 | }, 39 | { 40 | "value": "revert", 41 | "name": "revert: 回滚某个更早之前的提交" 42 | }, 43 | { 44 | "value": "chore", 45 | "name": "chore: 不属于以上类型的其他类型" 46 | } 47 | ], 48 | "messages": { 49 | "type": "选择一种你的提交类型:", 50 | "scope": "\n选择一个scope (可选):", 51 | "customScope": "自定义scope", 52 | "subject": "简短描述:\n", 53 | "body": "详细描述,使用\"|\"换行(可选):\n", 54 | "breaking": "非兼容性说明 (可选):\n", 55 | "footer": "关联关闭的issue,例如:#31, #34(可选):\n", 56 | "confirmCommit": "确定提交?" 57 | }, 58 | "allowBreakingChanges": [ 59 | "feat", 60 | "fix" 61 | ], 62 | "skipQuestions": [ 63 | "scope", 64 | "customScope", 65 | "breaking", 66 | "footer" 67 | ], 68 | "subjectLimit": 100 69 | } 70 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .husky 4 | .vscode 5 | node_modules -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV = development -------------------------------------------------------------------------------- /.github/workflows/deploy-docker.yml: -------------------------------------------------------------------------------- 1 | name: build docker image and deploy 2 | 3 | on: 4 | push: 5 | # 以下路径/文件才会触发,关于路径条件官方文档https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onpushpull_requestpaths 6 | paths: 7 | - '.github/**' 8 | - 'src/**' 9 | - 'base.Dockerfile' 10 | - 'Dockerfile' 11 | - '.dockerignore' 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | env: 17 | # docker 私库地址 18 | DOCKER_REGISTRY_URL: registry.cn-shenzhen.aliyuncs.com 19 | # 容器名称 20 | CONTAINER_NAME: wechat-bot 21 | # docker run 命令的部分参数 远程服务器run已经写了 name 参数和镜像 22 | DOCKER_RUN_PARAMS: -p 4300:4300 --restart=always -e DATABASE_NAME=${{secrets.DATABASE_NAME}} -e DATABASE_USER_NAME=${{secrets.DATABASE_USER_NAME}} -e DATABASE_USER_PASSWORD=${{secrets.DATABASE_USER_PASSWORD}} -e DATABASE_HOST=${{secrets.DATABASE_HOST}} -e DATABASE_PORT=${{secrets.DATABASE_PORT}} 23 | 24 | steps: 25 | - name: 检出代码到本地 26 | uses: actions/checkout@v2 27 | 28 | # 使用 QEMU 模拟器 Buildx 构建跨平台的镜像 29 | - name: 安装 QEMU 30 | uses: docker/setup-qemu-action@v1 31 | 32 | - name: 安装 Docker Buildx 33 | uses: docker/setup-buildx-action@v1 34 | 35 | - name: 登录 docker 镜像私库 36 | uses: docker/login-action@v1 37 | with: 38 | registry: ${{env.DOCKER_REGISTRY_URL}} 39 | username: ${{secrets.DOCKER_USERNAME}} 40 | password: ${{secrets.DOCKER_PASSWORD}} 41 | 42 | - name: 获取改变文件 43 | id: changedFiles 44 | uses: jitterbit/get-changed-files@v1 45 | 46 | - name: 构建基础镜像并推送 47 | # base.Dockerfile、package.json 有变化时才构建 48 | if: contains(steps.changedFiles.outputs.all, 'base.Dockerfile') || contains(steps.changedFiles.outputs.all, 'package.json') 49 | uses: docker/build-push-action@v2 50 | with: 51 | platforms: linux/amd64,linux/arm64 52 | tags: ${{env.DOCKER_REGISTRY_URL}}/iamc/${{env.CONTAINER_NAME}}:base 53 | context: . 54 | file: ./base.Dockerfile 55 | push: true 56 | 57 | - name: 拉取基础镜像到本地 58 | run: docker pull ${{env.DOCKER_REGISTRY_URL}}/iamc/${{env.CONTAINER_NAME}}:base 59 | 60 | - name: 动态设置环境变量 61 | run: | 62 | fullCommit="${{github.sha}}" 63 | # 定义变量 以 commit id前7位做版本标识(因为github commit 页面只展示前7位,这里一样方便搜索) 64 | IMAGE_TAG="${DOCKER_REGISTRY_URL}/iamc/${CONTAINER_NAME}:${fullCommit:0:7}" 65 | echo $IMAGE_TAG 66 | # 写入 IMAGE_TAG 到 GITHUB_ENV 环境文件,变成环境变量,后面的流程可以使用 命令语法 https://docs.github.com/cn/actions/reference/workflow-commands-for-github-actions#setting-an-environment-variable 67 | echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV 68 | 69 | - name: 构建并推送 70 | uses: docker/build-push-action@v2 71 | with: 72 | platforms: linux/amd64,linux/arm64 73 | tags: ${{env.IMAGE_TAG}} 74 | push: true 75 | 76 | - name: 通知服务器更新部署 77 | uses: appleboy/ssh-action@master 78 | with: 79 | host: ${{secrets.SERVICE_HOSTS}} 80 | username: ${{secrets.SERVICE_USERNAME}} 81 | key: ${{secrets.SERVICE_PRIVATE_KEY}} 82 | script: | 83 | wget -N --no-check-certificate "http://assets.e8q.cn/sh/update-docker-deploy.sh" 84 | chmod +x update-docker-deploy.sh 85 | ./update-docker-deploy.sh -R ${{env.DOCKER_REGISTRY_URL}} -U ${{secrets.DOCKER_USERNAME}} -P ${{secrets.DOCKER_PASSWORD}} -T ${{env.IMAGE_TAG}} -N "${{env.CONTAINER_NAME}}" -A "${{env.DOCKER_RUN_PARAMS}}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # 0x 40 | profile-* 41 | 42 | # mac files 43 | .DS_Store 44 | 45 | # vim swap files 46 | *.swp 47 | 48 | # webstorm 49 | .idea 50 | 51 | # vscode 52 | .vscode 53 | *code-workspace 54 | 55 | # clinic 56 | profile* 57 | *clinic* 58 | *flamegraph* 59 | 60 | src/plugins/config/configs/development.js -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.4.1 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.cn-shenzhen.aliyuncs.com/iamc/wechat-bot:base 2 | 3 | # 将当前目录下的所有文件(除了.dockerignore排除的路径),都拷贝进入镜像的工作目录下 4 | COPY . . 5 | 6 | # 启动 7 | CMD pnpm start -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 写给自己用的 wx 机器人 2 | 3 | ## CI/CD 4 | 5 | 当有代码 push 到仓库,[Actions](https://github.com/iamobj/wechat-bot/actions) 会自动按以下思路顺序执行: 6 | 7 | 1. 编译最新 docker 镜像 8 | 9 | 2. 推送到阿里云提供的 docker 镜像仓库(有免费版) 10 | 11 | 3. ssh 远程到服务器,执行更新 docker 部署脚本 12 | 13 | [点击我查看脚本](https://github.com/iamobj/sh/blob/main/sh/update-docker-deploy.sh)。脚本内容包括停止旧容器,重拉新镜像,然后启动;支持删除旧镜像,可通过参数配置保留最近几份 14 | 15 | [点击我查看 actions 流程配置文件](https://github.com/iamobj/wechat-bot/blob/main/.github/workflows/deploy-docker.yml) 16 | 17 | [怎么优化 docker 镜像](https://juejin.cn/post/6991689670027542564) 18 | 19 | ## 项目管理 20 | 21 | [点我查看项目看板](https://github.com/iamobj/wechat-bot/projects/1) 22 | 23 | ## 功能清单 24 | 25 |
26 | 指令控制路由器 27 | 查看端口转发规则列表:op dkzfls 28 |
29 | 开关端口转发:op dkzf {on/off} {端口号} 30 |
31 | 重启:op reboot 32 |
33 | 获取公网ip:op ip 34 |
35 | -------------------------------------------------------------------------------- /base.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine AS base 2 | 3 | # 设置时区 4 | RUN apk --update --no-cache add tzdata \ 5 | && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ 6 | && echo "Asia/Shanghai" > /etc/timezone \ 7 | && apk del tzdata \ 8 | && npm i -g pnpm 9 | 10 | # 设置环境变量 11 | ENV NODE_ENV=production \ 12 | APP_PATH=/node/app 13 | 14 | # 设置工作目录 15 | WORKDIR $APP_PATH 16 | 17 | # 拷贝 package.json 到工作跟目录下 18 | COPY package.json pnpm-lock.yaml . 19 | 20 | # 安装依赖 21 | RUN pnpm i -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | rules: { 5 | 'no-console': 'off', 6 | 'no-unused-vars': 'warn', 7 | 'unused-imports/no-unused-vars': 'warn', 8 | 'node/prefer-global/process': 'off', 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechat-bot", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "description": "微信机器人", 6 | "author": { 7 | "name": "iamc", 8 | "email": "twboss8@126.com" 9 | }, 10 | "license": "ISC", 11 | "keywords": [], 12 | "main": "./src/app.js", 13 | "imports": { 14 | "#src/*": "./src/*" 15 | }, 16 | "scripts": { 17 | "prepare": "if [ \"$NODE_ENV\" != \"production\" ]; then husky install; fi", 18 | "start": "fastify start -p 4300 -l info ./src/app.js", 19 | "dev": "fastify start -p 4301 -w -l info -P ./src/app.js", 20 | "lint": "eslint \"src/**/*.js\" --fix", 21 | "commit": "./node_modules/cz-customizable/standalone.js" 22 | }, 23 | "dependencies": { 24 | "@fastify/autoload": "^5.10.0", 25 | "@fastify/sensible": "^5.6.0", 26 | "axios": "^1.7.7", 27 | "dayjs": "^1.11.13", 28 | "fastify": "^4.28.1", 29 | "fastify-cli": "^6.3.0", 30 | "fastify-plugin": "^5.0.0", 31 | "node-schedule": "^2.1.1", 32 | "node-ssh": "13.2.0", 33 | "pg": "^8.12.0", 34 | "pg-hstore": "^2.3.4", 35 | "sequelize": "^6.37.3", 36 | "ws": "^8.18.0" 37 | }, 38 | "devDependencies": { 39 | "@antfu/eslint-config": "^3.1.0", 40 | "@commitlint/cli": "^12.1.4", 41 | "@commitlint/config-conventional": "^12.1.4", 42 | "cz-customizable": "^6.3.0", 43 | "eslint": "^9.9.1", 44 | "husky": "^7.0.1", 45 | "lint-staged": "^11.0.1", 46 | "tap": "^15.0.9" 47 | }, 48 | "config": { 49 | "cz-customizable": { 50 | "config": ".cz-config.json" 51 | } 52 | }, 53 | "lint-staged": { 54 | "src/**/*.js": [ 55 | "eslint --fix" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import AutoLoad from '@fastify/autoload' 3 | 4 | const __dirname = path.resolve('src') 5 | 6 | export default async (fastify, opts) => { 7 | fastify.register(AutoLoad, { 8 | dir: path.join(__dirname, 'plugins'), 9 | maxDepth: 1, // 限制嵌套插件加载的深度,不然会递归所有文件夹 10 | options: Object.assign({}, opts), 11 | }) 12 | 13 | fastify.register(AutoLoad, { 14 | dir: path.join(__dirname, 'routes'), 15 | options: Object.assign({}, opts), 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/library/baseClass/index.js: -------------------------------------------------------------------------------- 1 | // 基础类 2 | import Task from './taskClass.js' 3 | 4 | export { 5 | Task, 6 | } 7 | -------------------------------------------------------------------------------- /src/library/baseClass/taskClass.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 基础任务类 让子类具备任务机制功能 3 | */ 4 | class Task { 5 | constructor() { 6 | this.taskPool = {}// 任务池 7 | this.queue = []// 队列 8 | } 9 | 10 | /** 11 | * 定义任务 12 | * @param {string} name 任务名称 13 | * @param {string[]} deps 当前任务所依赖的任务名称,待依赖任务完成后再执行 14 | * @param {Function} handle 任务方法 15 | * @param {*} config 任务额外参数 16 | * @returns 实例 17 | */ 18 | task({ name, deps = [], handle, config }) { 19 | if (!name) { 20 | throw new Error('task(name, deps, handle, config) 缺少name参数') 21 | } 22 | 23 | if (this.taskPool[name]) { 24 | throw new Error('任务已存在,请勿重复添加') 25 | } 26 | 27 | this.taskPool[name] = { 28 | name, 29 | deps, 30 | handle, 31 | config, 32 | } 33 | return this 34 | } 35 | 36 | /** 37 | * 执行任务方法 38 | * @param {object | Function} task 任务方法 39 | * @param {*} arg 任务参数 40 | * @returns 41 | */ 42 | _taskHandle(task, arg) { 43 | if (typeof task.install === 'function') { 44 | return task.install.apply(task.install, arg) 45 | } 46 | else if (typeof task === 'function') { 47 | return task.apply(task, arg) 48 | } 49 | } 50 | 51 | /** 52 | * 任务的 promise 53 | * @param {string} key 任务名称 54 | * @returns 任务的 promise 55 | */ 56 | async _getTaskPromise(key) { 57 | const task = this.taskPool[key] 58 | if (!task.promise) { 59 | // eslint-disable-next-line no-async-promise-executor 60 | task.promise = new Promise(async (resolve) => { 61 | await Promise.all(task.deps.map(key => this._getTaskPromise(key))) 62 | // 改用settimeout,可以让外层的promise 异常捕获更快 63 | setTimeout(async () => { 64 | await this._taskHandle(task.handle, [this].concat(task.config)) 65 | resolve(true) 66 | }, 0) 67 | }) 68 | } 69 | return task.promise 70 | } 71 | 72 | /** 73 | * 注入任务 74 | * @param {string | string[] | Function} task 任务名、任务名数组、任务方法 75 | * @param {*} config 任务额外参数,task 为任务方法时需要才需要传 76 | * @returns 实例 77 | */ 78 | use(task, config) { 79 | if (typeof task === 'string' || Array.isArray(task)) { 80 | // 注入任务池中的任务 81 | const tasks = [].concat(task) // 转为数组 82 | this.queue.push(async () => { 83 | await Promise.all(tasks 84 | .map(key => this._getTaskPromise(key))) 85 | }) 86 | } 87 | else { 88 | // 注入指定任务 89 | this.queue.push(() => this._taskHandle(task, [this].concat(config))) 90 | } 91 | return this 92 | } 93 | 94 | /** 95 | * 执行任务池里的任务 96 | */ 97 | async run() { 98 | for (const task of this.queue) { 99 | await task() 100 | } 101 | 102 | return this 103 | } 104 | 105 | /** 106 | * 模拟 sleep 功能 107 | * @param {number} timeout 等待的时间,单位毫秒 108 | */ 109 | sleep(timeout = 0) { 110 | return new Promise((resolve) => { 111 | setTimeout(() => { 112 | resolve(true) 113 | }, timeout) 114 | }) 115 | } 116 | 117 | /** 118 | * 获取所有定义的任务 119 | */ 120 | getAllTask() { 121 | return Object.keys(this.taskPool) 122 | } 123 | } 124 | 125 | export default Task 126 | -------------------------------------------------------------------------------- /src/models/helper/baseModel.js: -------------------------------------------------------------------------------- 1 | import { Model, Sequelize } from 'sequelize' 2 | 3 | class BaseModel extends Model { 4 | constructor(...args) { 5 | super(...args) 6 | this.model = this.constructor 7 | } 8 | 9 | static init(attributes, options) { 10 | const attrs = { 11 | id: { 12 | type: Sequelize.INTEGER, 13 | primaryKey: true, 14 | autoIncrement: true, 15 | }, 16 | ...attributes, 17 | } 18 | 19 | const ops = { 20 | paranoid: true, // 软删除 21 | ...options, 22 | } 23 | const model = super.init(attrs, ops) 24 | return model 25 | } 26 | } 27 | 28 | export { BaseModel } 29 | -------------------------------------------------------------------------------- /src/models/index.js: -------------------------------------------------------------------------------- 1 | import { basename, dirname, join } from 'node:path' 2 | import { readdirSync } from 'node:fs' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = dirname(__filename) 7 | const _basename = basename(__filename) 8 | const excludeFiles = [_basename] 9 | 10 | const db = {} 11 | 12 | export default async function (sequelize) { 13 | const files = readdirSync(__dirname) 14 | .filter((file) => { 15 | return ((!excludeFiles.includes(file)) && (file.endsWith('.js'))) 16 | }) 17 | 18 | for (const file of files) { 19 | const { default: model } = await import(join(__dirname, file)) 20 | const modelName = file.replace('.js', '') 21 | db[modelName] = model(sequelize) 22 | } 23 | 24 | return db 25 | } 26 | 27 | export { db } 28 | -------------------------------------------------------------------------------- /src/models/pc.js: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize' 2 | import { BaseModel } from './helper/baseModel.js' 3 | 4 | class Pc extends BaseModel {} 5 | 6 | export default function (sequelize) { 7 | Pc.init({ 8 | type: { 9 | type: Sequelize.STRING, 10 | allowNull: false, 11 | comment: '类型:sshAccount-通过ssh账号登录,sshKey-通过ssh密钥登录', 12 | }, 13 | name: { 14 | type: Sequelize.STRING, 15 | allowNull: false, 16 | comment: '名称', 17 | }, 18 | host: { 19 | type: Sequelize.STRING, 20 | allowNull: false, 21 | comment: '主机地址', 22 | }, 23 | port: { 24 | type: Sequelize.INTEGER, 25 | allowNull: false, 26 | comment: '端口', 27 | }, 28 | authWxids: { 29 | type: Sequelize.ARRAY(Sequelize.STRING), 30 | comment: '授权可以操作的微信id组合', 31 | }, 32 | account: { 33 | type: Sequelize.STRING, 34 | comment: '账号', 35 | }, 36 | password: { 37 | type: Sequelize.STRING, 38 | comment: '密码', 39 | }, 40 | sshKey: { 41 | type: Sequelize.STRING, 42 | comment: 'ssh密钥', 43 | }, 44 | remark: { 45 | type: Sequelize.STRING, 46 | comment: '备注', 47 | }, 48 | }, { 49 | sequelize, 50 | modelName: 'pc', 51 | }) 52 | 53 | return Pc 54 | } 55 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize' 2 | import { BaseModel } from './helper/baseModel.js' 3 | 4 | class User extends BaseModel {} 5 | 6 | export default function (sequelize) { 7 | User.init({ 8 | name: { 9 | type: Sequelize.STRING, 10 | allowNull: false, 11 | comment: '名称', 12 | }, 13 | mobilePhone: { 14 | type: Sequelize.STRING, 15 | allowNull: false, 16 | unique: true, 17 | comment: '手机号', 18 | }, 19 | password: { 20 | type: Sequelize.STRING, 21 | comment: '密码', 22 | }, 23 | wxCode: { 24 | type: Sequelize.STRING, 25 | comment: '微信号', 26 | }, 27 | wxId: { 28 | type: Sequelize.STRING, 29 | unique: true, 30 | comment: '微信id', 31 | }, 32 | wxName: { 33 | type: Sequelize.STRING, 34 | comment: '微信名', 35 | }, 36 | isDisable: { 37 | type: Sequelize.BOOLEAN, 38 | defaultValue: false, 39 | comment: '是否禁用', 40 | }, 41 | authCode: { 42 | type: Sequelize.STRING, 43 | unique: true, 44 | comment: '授权码', 45 | }, 46 | expirationDate: { 47 | type: Sequelize.DATE, 48 | comment: '过期时间', 49 | }, 50 | remark: { 51 | type: Sequelize.STRING, 52 | comment: '备注', 53 | }, 54 | }, { 55 | sequelize, 56 | modelName: 'user', 57 | }) 58 | 59 | return User 60 | } 61 | -------------------------------------------------------------------------------- /src/plugins/config/configs/production.js: -------------------------------------------------------------------------------- 1 | export default { 2 | db: { 3 | DATABASE_NAME: process.env.DATABASE_NAME, 4 | DATABASE_USER_NAME: process.env.DATABASE_USER_NAME, 5 | DATABASE_USER_PASSWORD: process.env.DATABASE_USER_PASSWORD, 6 | DATABASE_HOST: process.env.DATABASE_HOST, 7 | DATABASE_PORT: process.env.DATABASE_PORT, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /src/plugins/config/index.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | 3 | const defaults = { 4 | pluginName: 'configs', 5 | instance: 'configs', 6 | } 7 | 8 | async function configPlugin(fastify, opts, done) { 9 | const { default: config } = await import(`./configs/${process.env.NODE_ENV}.js`) 10 | 11 | fastify.decorate(defaults.instance, config) 12 | done() 13 | } 14 | 15 | export default fp(configPlugin, { 16 | name: defaults.pluginName, 17 | }) 18 | 19 | export const configDefaults = defaults 20 | -------------------------------------------------------------------------------- /src/plugins/sensible.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import fastifySensible from '@fastify/sensible' 3 | 4 | export default fp(async (fastify, opts) => { 5 | fastify.register(fastifySensible, { 6 | errorHandler: false, 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/plugins/sequelize.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import Sequelize from 'sequelize' 3 | import { configDefaults } from './config/index.js' 4 | import modelsInit from '#src/models/index.js' 5 | 6 | const defaults = { 7 | pluginName: 'sequelize', 8 | instance: 'sequelize', 9 | } 10 | 11 | async function sequelizePlugin(fastify, opts, done) { 12 | const { configs } = fastify 13 | const sequelize = new Sequelize({ 14 | dialect: 'postgres', 15 | database: configs.db.DATABASE_NAME, 16 | username: configs.db.DATABASE_USER_NAME, 17 | password: configs.db.DATABASE_USER_PASSWORD, 18 | host: configs.db.DATABASE_HOST, 19 | port: configs.db.DATABASE_PORT, 20 | }) 21 | 22 | try { 23 | await sequelize.authenticate() 24 | const db = await modelsInit(sequelize) 25 | await sequelize.sync() 26 | fastify.decorate(defaults.instance, db) 27 | } 28 | catch (e) { 29 | console.error('数据库连接失败', e) 30 | } 31 | 32 | fastify.addHook( 33 | 'onClose', 34 | (instance, done) => sequelize.close().then(() => done()), 35 | ) 36 | 37 | done() 38 | } 39 | 40 | export default fp(sequelizePlugin, { 41 | name: defaults.pluginName, 42 | dependencies: [configDefaults.pluginName], 43 | }) 44 | 45 | export const sequelizeDefaults = defaults 46 | -------------------------------------------------------------------------------- /src/plugins/support.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | 3 | export default fp(async (fastify, opts) => { 4 | fastify.decorate('someSupport', () => { 5 | return 'hugs' 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/plugins/wechat-bot/index.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import { sequelizeDefaults } from '../sequelize.js' 3 | 4 | const defaults = { 5 | pluginName: 'wechatBot', 6 | instance: 'wechatBots', 7 | } 8 | 9 | function wechatBotPlugin(fastify, opts, done) { 10 | // wechatBotInit().then((wechatBots) => { 11 | // if (process.env.NODE_ENV !== 'development') { 12 | // // 机器人初始化成功且不是开发环境就让熊小三机器人通知服务启动成功 13 | // wechatBots.熊小三.sendByTarget({ 14 | // targetKey: 'wxcode', 15 | // targetValue: 'xh-boss', 16 | // content: `【${process.env.NODE_ENV}】微信机器人服务启动成功`, 17 | // }) 18 | // } 19 | // }) 20 | 21 | // fastify.decorate(defaults.instance, wechatBots) 22 | 23 | done() 24 | } 25 | 26 | export default fp(wechatBotPlugin, { 27 | name: defaults.pluginName, 28 | dependencies: [sequelizeDefaults.pluginName], 29 | }) 30 | -------------------------------------------------------------------------------- /src/plugins/wechat-bot/wechat/index.js: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws' 2 | import dayjs from 'dayjs' 3 | import { openwrt } from './task/index.js' 4 | import { JiYouGroupPush } from './task/timerSend.js' 5 | import { Task } from '#src/library/baseClass/index.js' 6 | 7 | const CODES = { 8 | HEART_BEAT: 5005, 9 | RECV_TXT_MSG: 1, 10 | RECV_PIC_MSG: 3, 11 | USER_LIST: 5000, 12 | GET_USER_LIST_SUCCSESS: 5001, 13 | GET_USER_LIST_FAIL: 5002, 14 | TXT_MSG: 555, 15 | PIC_MSG: 500, 16 | AT_MSG: 550, 17 | CHATROOM_MEMBER: 5010, 18 | CHATROOM_MEMBER_NICK: 5020, 19 | PERSONAL_INFO: 6500, 20 | DEBUG_SWITCH: 6000, 21 | PERSONAL_DETAIL: 6550, 22 | DESTROY_ALL: 9999, 23 | NEW_FRIEND_REQUEST: 37, // 微信好友请求消息 24 | AGREE_TO_FRIEND_REQUEST: 10000, // 同意微信好友请求消息 25 | ATTATCH_FILE: 5003, 26 | } 27 | 28 | class WechatBot extends Task { 29 | constructor(wechatWebSocket) { 30 | super() 31 | this.wechatWebSocket = wechatWebSocket 32 | 33 | this._personCache = {} // 微信联系人缓存 34 | 35 | this.recvMsgEvents = {} // 接收消息事件 36 | return this 37 | } 38 | 39 | _createId() { 40 | return Date.now().toString() 41 | } 42 | 43 | /** 44 | * 注册接收消息事件 45 | * @param {string} eventName 事件名称 46 | * @param {string} type 消息类型 text-文本消息 pic-图片消息 47 | * @param {Function} fn 回调函数 48 | */ 49 | registerRecvMsgEvent({ eventName, type, fn }) { 50 | const obj = this.recvMsgEvents?.[type] || (this.recvMsgEvents[type] = {}) 51 | 52 | if (eventName in obj) { 53 | throw new Error(`注册接收${type}消息事件: ${eventName} 已经被注册`) 54 | } 55 | else { 56 | obj[eventName] = fn 57 | } 58 | } 59 | 60 | /** 61 | * 处理接收到的消息 62 | */ 63 | handleRecvMsg() { 64 | const that = this 65 | this.wechatWebSocket.addEventListener('message', async (d) => { 66 | const data = JSON.parse(d.data) 67 | 68 | if (typeof data.content === 'string' && process.env.NODE_ENV === 'development') { 69 | // 开发环境测试指令消息前面增加 'd ' 避免触发生产环境指令 70 | data.content = data.content.replace('d ', '') 71 | } 72 | 73 | let eventName 74 | 75 | switch (data.type) { 76 | case CODES.RECV_TXT_MSG: 77 | eventName = data.content.split(' ')[0] 78 | that.recvMsgEvents?.text?.[eventName]?.(data) 79 | break 80 | case CODES.RECV_PIC_MSG: 81 | console.log(data) 82 | break 83 | default: 84 | break 85 | } 86 | }) 87 | } 88 | 89 | /** 90 | * 获取微信联系人列表 91 | */ 92 | getPersonList() { 93 | // eslint-disable-next-line no-async-promise-executor 94 | return new Promise(async (resolve, reject) => { 95 | const params = { 96 | type: CODES.USER_LIST, 97 | roomid: 'null', // null 98 | wxid: 'null', // not null 99 | content: 'null', // not null 100 | nickname: 'null', 101 | ext: 'null', 102 | } 103 | const data = await this.send(params) 104 | resolve(data) 105 | }) 106 | } 107 | 108 | send(options) { 109 | return new Promise((resolve, reject) => { 110 | const params = { 111 | id: this._createId(), 112 | type: CODES.TXT_MSG, 113 | wxid: '', 114 | roomid: '', 115 | content: '', 116 | nickname: 'null', 117 | ext: 'null', 118 | ...options, 119 | } 120 | this.wechatWebSocket.send(JSON.stringify(params)) 121 | 122 | const that = this 123 | this.wechatWebSocket.addEventListener('message', function handle(d) { 124 | const data = JSON.parse(d.data) 125 | switch (data.type) { 126 | case CODES.TXT_MSG: 127 | // 文本消息需要判断状态 128 | if (data.status === 'SUCCSESSED') { 129 | resolve(data) 130 | } 131 | else { 132 | reject(data) 133 | } 134 | break 135 | 136 | case params.type: 137 | resolve(data) 138 | break 139 | default: 140 | reject(data) 141 | break 142 | } 143 | 144 | // 移除监听事件 145 | that.wechatWebSocket.removeEventListener('message', handle) 146 | }) 147 | }) 148 | } 149 | 150 | /** 151 | * 发送AT消息,只支持 AT 一个用户 152 | * @param {object} options 153 | * @param {string} options.roomid 群id 154 | * @param {string} options.wxid 需要at的用户id 155 | * @param {string} options.content 消息内容 156 | * @param {string} [options.nickname] 需要at的用户昵称,这里传入的会填入到消息中,at 符号后面会显示这里传入的昵称,不填则只是 at 空白 157 | * @returns 158 | */ 159 | sendAtMsg(options) { 160 | const { roomid, wxid, content, nickname } = options 161 | return new Promise((resolve, reject) => { 162 | const params = { 163 | id: this._createId(), 164 | type: CODES.AT_MSG, 165 | roomid, 166 | wxid, 167 | content, 168 | nickname, 169 | ext: 'null', 170 | } 171 | this.wechatWebSocket.send(JSON.stringify(params)) 172 | 173 | const that = this 174 | this.wechatWebSocket.addEventListener('message', function handle(d) { 175 | const data = JSON.parse(d.data) 176 | switch (data.type) { 177 | case CODES.TXT_MSG: 178 | // 文本消息需要判断状态 179 | if (data.status === 'SUCCSESSED') { 180 | resolve(data) 181 | } 182 | else { 183 | reject(data) 184 | } 185 | break 186 | 187 | case params.type: 188 | resolve(data) 189 | break 190 | default: 191 | reject(data) 192 | break 193 | } 194 | 195 | // 移除监听事件 196 | that.wechatWebSocket.removeEventListener('message', handle) 197 | }) 198 | }) 199 | } 200 | 201 | /** 202 | * 通过匹配目标字段发送消息(最好是通过微信号发送,因为微信号是唯一的) 203 | * @param {string} targetKey 目标key(可以是personList属性中任一) eg:name-微信名称 remarks-备注名称 wxcode-微信号 204 | * @param {string} targetValue 对应目标key的值 205 | * @param {string} content 发送的内容 '[imgMsg]'开头的内容为发送图片消息,不支持gif 206 | */ 207 | async sendByTarget({ targetKey, targetValue, ...options }) { 208 | // 先从缓存里找 209 | let targetWxid = targetKey === 'wxid' ? targetValue : this._personCache?.[targetKey]?.[targetValue] 210 | 211 | // 缓存里没有找到就实时请求寻找,找到了缓存起来方便下次查找 212 | if (!targetWxid) { 213 | const { content: list } = await this.getPersonList() 214 | const target = list.find(item => item[targetKey] === targetValue) 215 | if (target) { 216 | targetWxid = target.wxid 217 | 218 | if (Object.prototype.toString.call(this._personCache[targetKey]) === '[object Object]') { 219 | this._personCache[targetKey][targetValue] = targetWxid 220 | } 221 | else { 222 | this._personCache[targetKey] = { 223 | [targetValue]: targetWxid, 224 | } 225 | } 226 | } 227 | else { 228 | return Promise.reject(new Error('没有找到对应人或群')) 229 | } 230 | } 231 | 232 | if (options.content.startsWith('[imgMsg]')) { 233 | // 如果是发送图片需要额外处理 234 | options.type = CODES.PIC_MSG 235 | options.content = options.content.replace('[imgMsg]', '') 236 | } 237 | 238 | await this.send({ 239 | ...options, 240 | wxid: targetWxid, 241 | }) 242 | } 243 | } 244 | 245 | /** 246 | * 创建微信机器人实例 247 | * @param {object} wechatBotItem 微信机器人配置item 248 | */ 249 | function createWeChatInstance(wechatBotItem) { 250 | return new Promise((resolve, reject) => { 251 | wechatBotItem.ws = new WebSocket(wechatBotItem.wsUrl) 252 | 253 | wechatBotItem.ws.on('open', () => { 254 | console.log(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 微信机器人【${wechatBotItem.name}】webSocket已连接`) 255 | 256 | wechatBotItem.wechatBot = new WechatBot(wechatBotItem.ws) 257 | resolve() 258 | // 实例完成 当前这个机器人有初始化函数就执行 259 | wechatBotItem.init && wechatBotItem.init() 260 | }) 261 | 262 | wechatBotItem.ws.on('error', async (e) => { 263 | console.error(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 微信机器人【${wechatBotItem.name}】webSocket出错`, e) 264 | 265 | wechatBotItem.wechatBot = null 266 | 267 | // 重新创建机器人实例 268 | await webSocketReconnect(wechatBotItem) 269 | resolve() 270 | }) 271 | 272 | wechatBotItem.ws.on('close', async () => { 273 | console.log(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 微信机器人【${wechatBotItem.name}】webSocket断开`) 274 | 275 | wechatBotItem.wechatBot = null 276 | 277 | // 重新创建机器人实例 278 | await webSocketReconnect(wechatBotItem) 279 | resolve() 280 | }) 281 | }) 282 | } 283 | 284 | /** 285 | * 重新初始化微信机器人 286 | * @param {object} wechatBotItem 微信机器人配置item 287 | */ 288 | function webSocketReconnect(wechatBotItem) { 289 | return new Promise((resolve, reject) => { 290 | if (wechatBotItem._reconnectTimer) { 291 | clearTimeout(wechatBotItem._reconnectTimer) 292 | wechatBotItem._reconnectTimer = null 293 | } 294 | // 使用定时器控制请求频率,避免请求过多 失败会再次调用重新初始化方法,直到连接成功 295 | wechatBotItem._reconnectTimer = setTimeout(async () => { 296 | console.log(`${dayjs().format('YYYY-MM-DD HH:mm:ss')} 正在尝试重连微信机器人【${wechatBotItem.name}】webSocket...`) 297 | 298 | // 创建成功就把定时器变量置空释放内存 299 | await createWeChatInstance(wechatBotItem) 300 | wechatBotItem._reconnectTimer = null 301 | resolve() 302 | }, 4000) 303 | }) 304 | } 305 | 306 | /** 307 | * proxy 劫持 wechatBots,方便 wechatBots.熊小三.send() 直接访问机器人实例方法,而不是 wechatBots.熊小三.wechatBot.send() 308 | * @param {object} wechatBots 微信机器人配置对象 309 | */ 310 | function proxyWechatBots(wechatBots) { 311 | Object.keys(wechatBots).forEach((key) => { 312 | wechatBots[key] = new Proxy(wechatBots[key], { 313 | get(target, property, receiver) { 314 | if (Reflect.has(target, property)) { 315 | return Reflect.get(target, property) 316 | } 317 | 318 | const wechatBot = Reflect.get(target, 'wechatBot') 319 | if (!wechatBot) { 320 | throw new Error(`【${target.name}】机器人下线或没有实例化,请联系管理员检查该机器人`) 321 | } 322 | if (Reflect.has(wechatBot, property)) { 323 | return Reflect.get(wechatBot, property) 324 | } 325 | return undefined 326 | }, 327 | }) 328 | }) 329 | } 330 | 331 | /** 332 | * 微信机器人配置 333 | */ 334 | const wechatBots = { 335 | 熊小三: { 336 | name: '熊小三', 337 | wsUrl: 'ws://192.168.50.16:5555', 338 | ws: null, // 微信机器人webSocket实例 339 | wechatBot: null, // 微信机器人实例 340 | // 微信机器人实例创建完成后需要执行多个任务 341 | init() { 342 | const { wechatBot } = this 343 | wechatBot.handleRecvMsg() 344 | 345 | wechatBot 346 | .task({ 347 | name: '熊猫阁基友群定时推送', 348 | handle: wechatBot => JiYouGroupPush(wechatBot), 349 | }) 350 | .task({ 351 | name: '指令控制软路由', 352 | handle: wechatBot => openwrt({ wechatBot }), 353 | }) 354 | .use(wechatBot.getAllTask()) // 注入定义的所有任务 355 | .run() 356 | }, 357 | }, 358 | } 359 | 360 | /** 361 | * 初始化所有微信机器人 362 | */ 363 | async function wechatBotInit() { 364 | await Promise.all(Object.keys(wechatBots).map(async (key) => { 365 | await createWeChatInstance(wechatBots[key]) 366 | })) 367 | 368 | proxyWechatBots(wechatBots) 369 | 370 | return wechatBots 371 | } 372 | 373 | export { 374 | wechatBotInit, 375 | wechatBots, 376 | CODES, 377 | } 378 | -------------------------------------------------------------------------------- /src/plugins/wechat-bot/wechat/task/index.js: -------------------------------------------------------------------------------- 1 | import timerSend from './timerSend.js' 2 | import openwrt from './openwrt.js' 3 | 4 | export { 5 | timerSend, 6 | openwrt, 7 | } 8 | -------------------------------------------------------------------------------- /src/plugins/wechat-bot/wechat/task/openwrt.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/consistent-function-scoping */ 2 | // 指令控制软路由 TODO:有时间用类重写 op 类 3 | import { db } from '#src/models/index.js' 4 | import { openSsh } from '#src/utils/ssh.js' 5 | 6 | let wechatBotIns 7 | let sshIns 8 | let wxData // 微信接收到的数据 9 | const defaults = { 10 | eventName: 'op', 11 | } 12 | 13 | /** 14 | * 获取端口转发所有规则 15 | * @param {boolean} isSort 是否开启排序 端口状态为on的排前面 16 | * @returns 返回规则列表 17 | */ 18 | async function getPortForwardList(isSort = true) { 19 | const { stdout, stderr } = await sshIns.execCommand('ubus call uci get \'{"config": "firewall", "type": "redirect"}\'') 20 | if (stderr) { 21 | return Promise.reject(stderr) 22 | } 23 | 24 | const { values: obj } = JSON.parse(stdout) 25 | if (isSort) { 26 | return Object.values(obj).sort((v1, v2) => { 27 | if (v1.enabled === '0') { 28 | return 1 29 | } 30 | if (v2.enabled === '0') { 31 | return -1 32 | } 33 | return 0 34 | }) 35 | } 36 | return Object.values(obj) 37 | } 38 | 39 | // 端口转发开关 40 | async function portForwardSwitch(type, port) { 41 | const forwardTypeMap = { 42 | on, 43 | off, 44 | } 45 | 46 | try { 47 | await forwardTypeMap?.[type]?.(port) 48 | await wechatBotIns.sendByTarget({ 49 | targetKey: 'wxid', 50 | targetValue: wxData.wxid, 51 | content: `${type} ${port} success`, 52 | }) 53 | 54 | portForwardList() 55 | } 56 | catch (e) { 57 | await wechatBotIns.sendByTarget({ 58 | targetKey: 'wxid', 59 | targetValue: wxData.wxid, 60 | content: `${type} ${port} fail`, 61 | }) 62 | 63 | portForwardList() 64 | return Promise.reject(e) 65 | } 66 | 67 | async function off(port) { 68 | const target = await getTarget(port) 69 | const { stdout, stderr } = await sshIns.execCommand(` 70 | ubus call uci set '{"config": "firewall", "section": "${target['.name']}", "values": {"enabled": "0"}}' 71 | ubus call uci commit '{"config": "firewall"}' 72 | /etc/init.d/firewall restart`) 73 | 74 | if (typeof stdout === 'string') { 75 | return stdout 76 | } 77 | return Promise.reject(stderr) 78 | } 79 | 80 | async function on(port) { 81 | const target = await getTarget(port) 82 | const { stdout, stderr } = await sshIns.execCommand(` 83 | ubus call uci delete '{"config": "firewall", "section": "${target['.name']}", "option": "enabled"}' 84 | ubus call uci commit '{"config": "firewall"}' 85 | /etc/init.d/firewall restart`) 86 | 87 | if (typeof stdout === 'string') { 88 | return stdout 89 | } 90 | return Promise.reject(stderr) 91 | } 92 | 93 | // 根据端口获取目标端口防火墙转发规则配置 94 | async function getTarget(port) { 95 | try { 96 | const list = await getPortForwardList() 97 | 98 | const target = list.find(item => item.src_dport === port) 99 | return target 100 | } 101 | catch (e) { 102 | return Promise.reject(e) 103 | } 104 | } 105 | } 106 | 107 | // 查看端口转发规则列表 108 | async function portForwardList() { 109 | try { 110 | const list = await getPortForwardList() 111 | const content = list.reduce((acc, item) => { 112 | acc += ` 113 | ${item.name}--${item.src_dport}--${item.enabled ? 'off' : 'on'} 114 | ` 115 | return acc 116 | }, '') 117 | 118 | await wechatBotIns.sendByTarget({ 119 | targetKey: 'wxid', 120 | targetValue: wxData.wxid, 121 | content, 122 | }) 123 | } 124 | catch (e) { 125 | await wechatBotIns.sendByTarget({ 126 | targetKey: 'wxid', 127 | targetValue: wxData.wxid, 128 | content: 'get port forward list fail', 129 | }) 130 | return Promise.reject(e) 131 | } 132 | } 133 | 134 | // 重启路由 135 | async function restartRouter() { 136 | try { 137 | await sshIns.execCommand('reboot') 138 | await wechatBotIns.sendByTarget({ 139 | targetKey: 'wxid', 140 | targetValue: wxData.wxid, 141 | content: 'reboot success', 142 | }) 143 | } 144 | catch (e) { 145 | await wechatBotIns.sendByTarget({ 146 | targetKey: 'wxid', 147 | targetValue: wxData.wxid, 148 | content: 'reboot fail', 149 | }) 150 | return Promise.reject(e) 151 | } 152 | } 153 | 154 | // 获取公网ip 155 | async function getPublicIp() { 156 | try { 157 | const { stdout, stderr } = await sshIns.execCommand('curl -s https://ip.3322.net/') 158 | if (stderr) { 159 | return Promise.reject(stderr) 160 | } 161 | await wechatBotIns.sendByTarget({ 162 | targetKey: 'wxid', 163 | targetValue: wxData.wxid, 164 | content: stdout, 165 | }) 166 | } 167 | catch (e) { 168 | await wechatBotIns.sendByTarget({ 169 | targetKey: 'wxid', 170 | targetValue: wxData.wxid, 171 | content: 'get public ip fail', 172 | }) 173 | return Promise.reject(e) 174 | } 175 | } 176 | 177 | // 指令映射方法 178 | const fnMap = { 179 | dkzf: portForwardSwitch, 180 | dkzfls: portForwardList, 181 | reboot: restartRouter, 182 | ip: getPublicIp, 183 | } 184 | 185 | async function handleOpenwrt(data) { 186 | wxData = data 187 | const { pc } = db 188 | const pcData = await pc.findByPk(1) 189 | 190 | if (!pcData.authWxids.includes(data.wxid)) { 191 | await wechatBotIns.sendByTarget({ 192 | targetKey: 'wxid', 193 | targetValue: data.wxid, 194 | content: '你没有权限使用该功能', 195 | }) 196 | return 197 | } 198 | 199 | sshIns = await openSsh(pcData) 200 | 201 | const [eventName, fnName, ...params] = data.content.split(' ') 202 | await fnMap[fnName]?.(...params) 203 | 204 | const noExitInstructions = ['reboot'] // 不需要退出的指令 205 | if (!noExitInstructions.includes(fnName)) { 206 | sshIns.execCommand('exit') 207 | } 208 | } 209 | 210 | export default ({ wechatBot }) => { 211 | wechatBotIns = wechatBot 212 | wechatBot.registerRecvMsgEvent({ 213 | eventName: defaults.eventName, 214 | type: 'text', 215 | fn: handleOpenwrt, 216 | }) 217 | } 218 | -------------------------------------------------------------------------------- /src/plugins/wechat-bot/wechat/task/timerSend.js: -------------------------------------------------------------------------------- 1 | // 定时器,定时推送消息 2 | import schedule from 'node-schedule' 3 | import axios from 'axios' 4 | 5 | function timerSend({ wechatBot, onBeforeSend, CODES, targetKey, targetValue, content, timeRule }) { 6 | schedule.scheduleJob(timeRule, async () => { 7 | if (onBeforeSend) { 8 | const flag = await onBeforeSend() 9 | // 如果返回false,则不发送 10 | if (!flag) 11 | return 12 | } 13 | const _content = typeof content === 'function' ? content() : content 14 | 15 | wechatBot.sendByTarget({ 16 | targetKey, 17 | targetValue, 18 | content: _content, 19 | }) 20 | }) 21 | } 22 | 23 | export default timerSend 24 | 25 | /** 26 | * 基友群基金操作定时推送,每周一到周五14点40分提醒操作基金 27 | */ 28 | export function JiYouGroupPush(wechatBot) { 29 | // eslint-disable-next-line unicorn/consistent-function-scoping 30 | const contentFn = () => { 31 | const contentPool = [ 32 | '[imgMsg]C:\\Users\\iamc\\Desktop\\wechat-bot-imgs\\2.jpg', 33 | '2点40啦,请各位老板系好安全带,准备起飞!!!!', 34 | '2点40啦,祝各位老板起飞!!!!', 35 | '2点40啦,老板们起飞了别忘记打赏我哦', 36 | ] 37 | return contentPool[Math.floor(Math.random() * contentPool.length)] 38 | } 39 | 40 | timerSend({ 41 | wechatBot, 42 | onBeforeSend: async () => { 43 | // 判断是否是工作日,避免节假日推送 44 | const { data } = await axios.get('https://timor.tech/api/holiday/info', { 45 | params: { 46 | t: new Date(new Date().toLocaleDateString()).getTime(), 47 | }, 48 | headers: { 49 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36', 50 | }, 51 | }) 52 | if (data?.type?.type === 0) { 53 | return true 54 | } 55 | return false 56 | }, 57 | timeRule: { 58 | dayOfWeek: [new schedule.Range(1, 5)], 59 | hour: 14, 60 | minute: 40, 61 | }, 62 | targetKey: 'remarks', 63 | targetValue: '熊猫阁基友群', 64 | content: contentFn, 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/plugins/wxhelper/index.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin' 2 | import { Wxhelper } from './wxhelper.js' 3 | 4 | const defaults = { 5 | pluginName: 'wxhelper', 6 | instance: 'wxhelper', 7 | } 8 | 9 | export default fp(async (fastify, opts, done) => { 10 | const wxhelperIns = new Wxhelper('http://192.168.50.115:29088/api') 11 | if (process.env.NODE_ENV !== 'development') { 12 | const isLogin = await wxhelperIns.checkLogin() 13 | if (isLogin) { 14 | await wxhelperIns.sendTextMsg({ 15 | wxid: 'wxid_d52rxumg20z022', 16 | msg: `【${process.env.NODE_ENV}】微信机器人服务启动成功`, 17 | }) 18 | } 19 | } 20 | 21 | fastify.decorate(defaults.instance, wxhelperIns) 22 | 23 | done() 24 | }, { 25 | name: defaults.pluginName, 26 | }) 27 | -------------------------------------------------------------------------------- /src/plugins/wxhelper/wxhelper.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export class Wxhelper { 4 | constructor(baseUrl) { 5 | this.fetch = this.#createAxiosInstance(baseUrl) 6 | } 7 | 8 | async checkLogin() { 9 | const { data } = await this.fetch.post('/checkLogin') 10 | return data.code === 1 11 | } 12 | 13 | async sendTextMsg({ wxid, msg }) { 14 | const { data } = await this.fetch.post('/sendTextMsg', { 15 | wxid, 16 | msg, 17 | }) 18 | return data 19 | } 20 | 21 | /** 22 | * 发送 AT 消息 23 | * @param {object} param0 24 | * @param {string[]} param0.wxids 多个用,隔开 25 | * @param {string} param0.chatRoomId 群id 26 | * @param {string} param0.msg 27 | * @returns 28 | */ 29 | async sendAtMsg({ wxids, chatRoomId, msg }) { 30 | const { data } = await this.fetch.post('/sendAtText', { 31 | wxids, 32 | chatRoomId, 33 | msg, 34 | }) 35 | return data 36 | } 37 | 38 | #createAxiosInstance(baseUrl) { 39 | const instance = axios.create({ 40 | baseURL: baseUrl, 41 | }) 42 | 43 | return instance 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/routes/pc/index.js: -------------------------------------------------------------------------------- 1 | export default async (fastify, opts) => { 2 | fastify.get('/', async (req, reply) => { 3 | return '' 4 | }) 5 | 6 | fastify.post('/', async (req, reply) => { 7 | // const { pc } = fastify.sequelize 8 | // const { type, name, host, port, authWxids, account, password, sshKey, remark } = req.body 9 | // const result = await pc.create({ type, name, host, port, authWxids, account, password, sshKey, remark }) 10 | // return result 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/routes/user/index.js: -------------------------------------------------------------------------------- 1 | export default async (fastify, opts) => { 2 | fastify.get('/', async (req, reply) => { 3 | return '' 4 | }) 5 | 6 | fastify.post('/', async (req, reply) => { 7 | // const { user } = fastify.sequelize 8 | // const result = await user.create(req.body) 9 | // return result 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/routes/wechat/index.js: -------------------------------------------------------------------------------- 1 | export default async (fastify, opts) => { 2 | const { wechatBots, wxhelper, sequelize } = fastify 3 | 4 | // fastify.get('/', async (req, reply) => { 5 | // const res = await wechatBots.熊小三.getPersonList() 6 | // // await wechatBots.熊小三.sendByTarget({ 7 | // // targetKey: 'wxcode', 8 | // // targetValue: 'xh-boss', 9 | // // content: '消息内容' 10 | // // }) 11 | // return res 12 | // }) 13 | 14 | fastify.post('/sendMsg', async (req, reply) => { 15 | const { authCode, content, toWxIds, atMsg } = req.body 16 | const user = await sequelize.user.findOne({ 17 | where: { authCode }, 18 | }) 19 | if (!user) { 20 | reply.send({ 21 | statusCode: 1, 22 | message: '无效的authCode', 23 | }) 24 | return 25 | } 26 | 27 | if (user.isDisable) { 28 | reply.send({ 29 | statusCode: 1, 30 | message: 'authCode已被禁用', 31 | }) 32 | return 33 | } 34 | 35 | if (user.expirationDate && new Date() > user.expirationDate) { 36 | reply.send({ 37 | statusCode: 1, 38 | message: 'authCode已过期', 39 | }) 40 | return 41 | } 42 | 43 | if (atMsg) { 44 | if (user.wxId !== 'wxid_d52rxumg20z022') { 45 | reply.send({ 46 | statusCode: 1, 47 | message: '无权限发送at消息', 48 | }) 49 | return 50 | } 51 | 52 | await wxhelper.sendAtMsg({ 53 | wxids: atMsg.wxIds.join(','), 54 | chatRoomId: atMsg.chatRoomId, 55 | msg: atMsg.content, 56 | }) 57 | 58 | reply.send({ 59 | statusCode: 0, 60 | message: 'success', 61 | }) 62 | return 63 | } 64 | 65 | let _toWxid = user.wxId 66 | if (user.wxId === 'wxid_d52rxumg20z022' && toWxIds) { 67 | // 只允许自己指定发送对象 68 | _toWxid = toWxIds 69 | } 70 | 71 | if (Array.isArray(_toWxid)) { 72 | // 不用 promise.all ,一个一个的发避免被风控 73 | for (const _wxid of _toWxid) { 74 | await wxhelper.sendTextMsg({ 75 | wxid: _wxid, 76 | msg: content, 77 | }) 78 | } 79 | } 80 | else { 81 | await wxhelper.sendTextMsg({ 82 | wxid: _toWxid, 83 | msg: content, 84 | }) 85 | } 86 | 87 | reply.send({ 88 | statusCode: 0, 89 | message: 'success', 90 | }) 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/other.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 判断路径是不是图片 3 | */ 4 | export function pathIsImg(path) { 5 | const reg = /\.(?:jpg|jpeg|png|gif|bmp|webp)$/i 6 | return reg.test(path) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/ssh.js: -------------------------------------------------------------------------------- 1 | import { NodeSSH } from 'node-ssh' 2 | 3 | export async function openSsh(data) { 4 | try { 5 | const { type } = data 6 | if (type === 'sshAccount') { 7 | return await openSshByAccount(data) 8 | } 9 | } 10 | catch (e) { 11 | return Promise.reject(e) 12 | } 13 | } 14 | 15 | export async function openSshByAccount(data) { 16 | const ssh = new NodeSSH() 17 | const { host, port, account, password } = data 18 | try { 19 | await ssh.connect({ 20 | host, 21 | port, 22 | username: account, 23 | password, 24 | }) 25 | return ssh 26 | } 27 | catch (e) { 28 | return Promise.reject(e) 29 | } 30 | } 31 | --------------------------------------------------------------------------------