├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 提个bug.md │ └── 新功能支持.md ├── release.yml └── workflows │ ├── nightly-build.yml │ ├── publishNPM.yml │ ├── release.yml │ └── update-cli-version.yml ├── .gitignore ├── .husky ├── _ │ ├── .gitignore │ └── husky.sh ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierrc.js ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── docker-compose.yml ├── dockerfile ├── docs ├── Jietu20240506-220141@2x.jpg ├── legacy-api.md └── recvdApi.example.md ├── main.js ├── package.json ├── packages └── cli │ ├── .env.example │ ├── README.md │ ├── index.js │ ├── lib │ └── generateToken.js │ ├── package.json │ ├── preStart.js │ └── scripts │ └── build.js ├── patches ├── wechat4u@0.7.14.patch ├── wechaty-puppet-wechat4u@1.14.13.patch └── wechaty-puppet@1.20.2.patch ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts ├── preStart.js └── tsc-lint.sh ├── src ├── config │ ├── const.js │ ├── log4jsFilter.js │ └── valid.js ├── middleware │ ├── index.js │ ├── loginCheck.js │ └── verifyToken.js ├── route │ ├── index.js │ ├── login.js │ ├── msg.js │ └── resouces.js ├── service │ ├── cache.js │ ├── friendship.js │ ├── index.js │ ├── login.js │ ├── msgSender.js │ └── msgUploader.js ├── static │ └── qrcode.min.js ├── utils │ ├── index.js │ ├── log.js │ ├── msg.js │ ├── nextTick.js │ ├── paramsValid.js │ └── res.js └── wechaty │ └── init.js ├── tsconfig.json └── typings ├── global.d.ts ├── hono.d.ts └── lodash.d.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .git 3 | .editorconfig 4 | .versionrc.js 5 | .env 6 | .vscode 7 | .husky 8 | node_modules 9 | script 10 | docs 11 | cli 12 | loginSession.memory-card.json 13 | packages 14 | pnpm-workspace.yaml 15 | log 16 | typings 17 | .eslintignore 18 | .eslintrc.js 19 | tsconfig.json -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 启动服务的端口 2 | PORT=3001 3 | 4 | # 运行时提示的消息等级(默认info,想有更详细的日志,可以指定为debug) 5 | LOG_LEVEL=info 6 | 7 | # 如果不希望登录一次后就记住当前账号,想每次都扫码登陆,填 true 8 | DISABLE_AUTO_LOGIN= 9 | 10 | # RECVD_MSG_API 是否接收来自自己发的消息 11 | ACCEPT_RECVD_MSG_MYSELF=false 12 | 13 | # 如果想自己处理收到消息的逻辑,在下面填上你的API地址, 默认为空 14 | LOCAL_RECVD_MSG_API= 15 | 16 | # 登录地址Token访问地址: http://localhost:3001/login?token=[LOCAL_LOGIN_API_TOKEN] 17 | LOCAL_LOGIN_API_TOKEN= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | packages/cli/node_modules 3 | root 4 | docs 5 | *.md 6 | packages/cli/lib/**/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.BaseConfig} */ 2 | const config = { 3 | env: { 4 | node: true, 5 | commonjs: true 6 | }, 7 | parser: '@typescript-eslint/parser', 8 | plugins: ['@typescript-eslint', 'prettier'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/eslint-recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'prettier' 14 | ], 15 | parserOptions: { 16 | ecmaVersion: 'latest', 17 | sourceType: 'script' // 对于 CommonJS 18 | }, 19 | rules: { 20 | 'prettier/prettier': 'error' /** check prettier lint */, 21 | '@typescript-eslint/no-var-requires': 'off' /** only run with commonjs */, 22 | '@typescript-eslint/no-unused-vars': 'error', 23 | '@typescript-eslint/ban-ts-comment': 24 | 'off' /** quickly ban all error unnecessary */ 25 | } 26 | } 27 | 28 | module.exports = config 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: dannicool 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | # polar: # Replace with a single Polar username 14 | # custom: ['https://danni.cool/about/#Support-my-open-source-works'] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/提个bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 寻求帮助 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 6 | assignees: '' 7 | 8 | --- 9 | 10 | ⚠️不按照以下格式创建的issue将不会获得回复⚠️ 11 | 12 | ## 提bug前先检查以下是否已经执行 13 | 14 | - [ ] 我已更新了最新版本的代码 15 | - [ ] 我已经仔细地阅读了readme 文档 16 | - [ ] 我已在[FAQ](https://github.com/danni-cool/wechatbot-webhook/issues/72)里查找了可能寻找的答案 17 | - [ ] 我已经尝试搜索过历史关闭issue寻找可能的答案 18 | 19 | 20 | ## bug描述 21 | 有时候一句话问题并不知道在描述什么 22 | 23 | 所以请按以下格式描述问题: 24 | 25 | - **场景**: 26 | - **操作**: 27 | - **表现**: 28 | 29 | ## 提供有用的信息 30 | - 目前使用的版本号:(很可能最新版本已经修复了) 31 | - 提供任何代码片段/日志/输出/截图 以便于帮助快速定位问题 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/新功能支持.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 新功能支持 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ⚠️不按照以下格式创建的issue将不会获得回复⚠️ 11 | 12 | **提出问题的背景** 13 | 14 | 好的建议经常需要结合问题发生的背景以给出最佳方案,所以描述下遇到这个问题的场景。 15 | 16 | **描述下你期望的方案** 17 | 18 | 你想要的功能是? 19 | 20 | **其他信息** 21 | 提供代码/截图/日志/输出等必要文件辅助描述 22 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | # Ignore the release PR created by github-actions 5 | - github-actions 6 | categories: 7 | - title: Breaking Changes 🍭 8 | labels: 9 | - 'change: breaking' 10 | - title: New Features 🎉 11 | labels: 12 | - 'change: feat' 13 | - title: Bug Fixes 🐞 14 | labels: 15 | - 'change: fix' 16 | - title: Document 📖 17 | labels: 18 | - 'change: docs' 19 | - title: Other Changes 20 | labels: 21 | - '*' 22 | -------------------------------------------------------------------------------- /.github/workflows/nightly-build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - nightly 7 | 8 | jobs: 9 | build: 10 | if: contains(github.event.head_commit.message, '[skip ci]') == false 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Login to Docker Hub 18 | uses: docker/login-action@v1 19 | with: 20 | username: dannicool 21 | password: ${{ secrets.DOCKERHUB_TOKEN }} 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v1 25 | 26 | - name: Build and Push Docker Image 27 | uses: docker/build-push-action@v2 28 | with: 29 | context: . 30 | file: ./Dockerfile 31 | push: true 32 | tags: dannicool/docker-wechatbot-webhook:nightly 33 | platforms: linux/amd64,linux/arm64 34 | -------------------------------------------------------------------------------- /.github/workflows/publishNPM.yml: -------------------------------------------------------------------------------- 1 | name: Publish CLI Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | publish-cli: 9 | if: contains(github.event.head_commit.message, 'Publish npm') 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | # 设置 Node.js 环境 15 | - name: Set up Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: '18' 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | # 安装 pnpm 22 | - name: Install pnpm 23 | run: npm install -g pnpm 24 | 25 | # 安装依赖 26 | - name: Install Dependencies 27 | run: pnpm install 28 | working-directory: ./packages/cli 29 | 30 | # 构建(如果需要) 31 | - name: Build package 32 | run: pnpm run build 33 | working-directory: ./packages/cli 34 | 35 | # 发布到 npm 36 | - name: Publish to npm 37 | run: pnpm publish --access public 38 | working-directory: ./packages/cli 39 | env: 40 | NODE_AUTH_TOKEN: ${{ secrets.NPM_SECRET }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | if: contains(github.event.head_commit.message, '[skip ci]') == false && contains(github.event.head_commit.message, 'Publish npm') == false 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Automated Versioning and Changelog 17 | uses: GoogleCloudPlatform/release-please-action@v3 18 | with: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | release-type: node 21 | package-name: docker-wechatbot-webhook 22 | changelog-types: '[{"type":"feat","section":"Features","hidden":false},{"type":"fix","section":"Bug Fixes","hidden":false},{"type":"refactor","section":"Refactor","hidden":false},{"type":"perf","section":"Performance Improvements","hidden":false}]' 23 | 24 | build-and-push: 25 | needs: release 26 | runs-on: ubuntu-latest 27 | if: "${{contains(github.event.head_commit.message, 'chore(main): release')}}" 28 | steps: 29 | - name: Checkout Repository 30 | uses: actions/checkout@v3 31 | 32 | - name: Login to Docker Hub 33 | uses: docker/login-action@v1 34 | with: 35 | username: dannicool 36 | password: ${{ secrets.DOCKERHUB_TOKEN }} 37 | 38 | - name: Set up Docker Buildx 39 | uses: docker/setup-buildx-action@v1 40 | 41 | - name: Build and Push Docker Image 42 | uses: docker/build-push-action@v2 43 | with: 44 | context: . 45 | file: ./Dockerfile 46 | push: true 47 | tags: dannicool/docker-wechatbot-webhook:latest 48 | platforms: linux/amd64,linux/arm64 49 | -------------------------------------------------------------------------------- /.github/workflows/update-cli-version.yml: -------------------------------------------------------------------------------- 1 | name: UPDATE CLI Package 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | update-cli-version: 9 | if: "${{contains(github.event.head_commit.message, 'chore(main): release')}}" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: git-checkout 13 | uses: actions/checkout@v2 14 | 15 | - name: Update CLI package.json 16 | run: | 17 | ROOT_VERSION=$(jq -r '.version' package.json) 18 | NEW_BRANCH="npm-pull-request-of-v${ROOT_VERSION}" 19 | echo "NEW_BRANCH=$NEW_BRANCH" >> $GITHUB_ENV 20 | echo "NEW_VERSION=$ROOT_VERSION" >> $GITHUB_ENV 21 | # git checkout -b $NEW_BRANCH 22 | jq ".version = \"$ROOT_VERSION\"" packages/cli/package.json > temp.json && mv temp.json packages/cli/package.json 23 | # git config --local user.email "action@github.com" 24 | # git config --local user.name "Github Action" 25 | 26 | - name: Push to New Branch 27 | uses: s0/git-publish-subdir-action@develop 28 | env: 29 | REPO: self 30 | BRANCH: ${{env.NEW_BRANCH}} # The branch name where you want to push the assets 31 | FOLDER: packages/cli # The directory where your assets are generated 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GitHub will automatically add this - you don't need to bother getting a token 33 | MESSAGE: "chore(cli): sync package version to cli" # The commit message 34 | 35 | - name: Create Pull Request 36 | uses: peter-evans/create-pull-request@v4 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | commit-message: "chore(cli): release npm version of ${{ env.NEW_VERSION }}" 40 | title: "Publish npm v${{ env.NEW_VERSION }}" 41 | body: "Update npm package version to ${{ env.NEW_VERSION }}" 42 | branch: "npm-pull-request-of-v${{ env.NEW_VERSION }}" 43 | base: main 44 | labels: npm 45 | id: cpr -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | loginSession.memory-card.json 4 | generated 5 | log 6 | tsconfig.tmp.json 7 | packages/cli/lib/bot.js 8 | packages/cli/static 9 | **/*/.DS_store -------------------------------------------------------------------------------- /.husky/_/.gitignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /.husky/_/husky.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | if [ -z "$husky_skip_init" ]; then 3 | debug () { 4 | if [ "$HUSKY_DEBUG" = "1" ]; then 5 | echo "husky (debug) - $1" 6 | fi 7 | } 8 | 9 | readonly hook_name="$(basename -- "$0")" 10 | debug "starting $hook_name..." 11 | 12 | if [ "$HUSKY" = "0" ]; then 13 | debug "HUSKY env variable is set to 0, skipping hook" 14 | exit 0 15 | fi 16 | 17 | if [ -f ~/.huskyrc ]; then 18 | debug "sourcing ~/.huskyrc" 19 | . ~/.huskyrc 20 | fi 21 | 22 | readonly husky_skip_init=1 23 | export husky_skip_init 24 | sh -e "$0" "$@" 25 | exitCode="$?" 26 | 27 | if [ $exitCode != 0 ]; then 28 | echo "husky - $hook_name hook exited with code $exitCode (error)" 29 | fi 30 | 31 | if [ $exitCode = 127 ]; then 32 | echo "husky - command not found in PATH=$PATH" 33 | fi 34 | 35 | exit $exitCode 36 | fi 37 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | auto-install-peers=true 3 | strict-peer-dependencies=true 4 | enable-pre-post-scripts=true -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | "singleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "tabWidth": 2, 7 | "trailingComma": "none", 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": true, 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "eslint.validate": [ 8 | "javascript", 9 | "javascriptreact", 10 | "typescript", 11 | "typescriptreact" 12 | ], 13 | "eslint.workingDirectories": [{ "mode": "auto" }] 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [2.8.2](https://github.com/danni-cool/wechatbot-webhook/compare/v2.8.1...v2.8.2) (2024-11-29) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * 🐛 npm 包访问登陆网页空白 ([#272](https://github.com/danni-cool/wechatbot-webhook/issues/272)) ([2b3644f](https://github.com/danni-cool/wechatbot-webhook/commit/2b3644f742122b4292211cb4fa3e8e28a23cf628)) 8 | 9 | 10 | ## [2.8.1](https://github.com/danni-cool/wechatbot-webhook/compare/v2.8.0...v2.8.1) (2024-11-29) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * 🐛 修复登陆二维码依赖第三方网站被墙的问题 ([#269](https://github.com/danni-cool/wechatbot-webhook/issues/269)) ([2fe96d2](https://github.com/danni-cool/wechatbot-webhook/commit/2fe96d2b1199d78fda4c62f0da7e6aaf95c00f27)) 16 | 17 | ## [2.8.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.7.0...v2.8.0) (2024-03-27) 18 | 19 | 20 | ### Features 21 | 22 | * 🎸 增加静态资源获取接口,recvd_api 增加头像url地址 ([b85f7ba](https://github.com/danni-cool/wechatbot-webhook/commit/b85f7ba316df2fd64780da3f315c2c30a07c70e1)) 23 | 24 | 25 | ## [2.7.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.6.1...v2.7.0) (2024-03-18) 26 | 27 | 28 | ### Features 29 | 30 | * 🎸 发送文件url支持别名场景优化 ([#179](https://github.com/danni-cool/wechatbot-webhook/issues/179)) ([57307f2](https://github.com/danni-cool/wechatbot-webhook/commit/57307f2c6b4b9c233ce63acfccad5c9f6d4265e5)) close: [#186](https://github.com/danni-cool/wechatbot-webhook/issues/186) ([3ac950c](https://github.com/danni-cool/wechatbot-webhook/commit/3ac950cb06acc5dd57d57e35f72928ed3c5c8d51)) Thanks: @Cassius0924 31 | 32 | 33 | ## [2.6.1](https://github.com/danni-cool/wechatbot-webhook/compare/v2.6.0...v2.6.1) (2024-03-02) 34 | 35 | 36 | ### Features 37 | 38 | * 🎸 收消息API支持配置接受自己发的消息 close:[#159](https://github.com/danni-cool/wechatbot-webhook/issues/159) 39 | * 🎸 增加 收消息API 对 unKnown类型消息的支持 [#165](https://github.com/danni-cool/wechatbot-webhook/issues/165) 40 | * 🎸 增加服务稳定性,针对web协议连续登录和登出场景优化,登出后报错至多上报一次 recvd_api, 登出消息通知更加及时。错误类型单独定义 [#140](https://github.com/danni-cool/wechatbot-webhook/issues/140), [#160](https://github.com/danni-cool/wechatbot-webhook/issues/160) 41 | 42 | 43 | ## [2.6.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.5.3...v2.6.0) (2024-02-27) 44 | 45 | 46 | ### Features 47 | 48 | * 🎸 增加全局路由鉴权 close:[#166](https://github.com/danni-cool/wechatbot-webhook/issues/166) ([875dfb3](https://github.com/danni-cool/wechatbot-webhook/commit/875dfb340da79467538bc2fd2c1713f77121a751)) 49 | 50 | 51 | ## [2.5.3](https://github.com/danni-cool/wechatbot-webhook/compare/v2.5.2...v2.5.3) (2024-02-06) 52 | 53 | 54 | ### Bug Fixes 55 | 56 | * 🐛 pnpm 运行命令不执行pre钩子。refer:[#146](https://github.com/danni-cool/wechatbot-webhook/issues/146) ([51ce80e](https://github.com/danni-cool/wechatbot-webhook/commit/51ce80ef8ba06606a7b1a1ea15c8494b1985db40)) 57 | * 🐛 修复friendship事件不上报recvdAPI的问题. close:[#155](https://github.com/danni-cool/wechatbot-webhook/issues/155) ([19b9148](https://github.com/danni-cool/wechatbot-webhook/commit/19b9148debf92115e57ea3239a86be2f1bf24c95)) 58 | 59 | 60 | ## [2.5.2](https://github.com/danni-cool/wechatbot-webhook/compare/v2.5.1...v2.5.2) (2024-01-14) 61 | 62 | 63 | ### Bug Fixes 64 | 65 | * 🐛 修复无重启登录后发送消息失败问题got more than 2 contacts close:[#60](https://github.com/danni-cool/wechatbot-webhook/issues/60) ([9d90ec1](https://github.com/danni-cool/wechatbot-webhook/commit/9d90ec1bd269ed588e8cfe17162af186bc87fe71)) 66 | 67 | 68 | ### Performance Improvements 69 | 70 | * ⚡️ 微信登出状态下不上报RecvdAPI Error级别错误 ([f45d42d](https://github.com/danni-cool/wechatbot-webhook/commit/f45d42d600d1849d79cd581e53b9e11b84b4eb49)) 71 | 72 | 73 | ## [2.5.1](https://github.com/danni-cool/wechatbot-webhook/compare/v2.5.0...v2.5.1) (2024-01-11) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * 🐛 修复收消息api解析文件名和类型问题。close:[#118](https://github.com/danni-cool/wechatbot-webhook/issues/118) ([cd1288e](https://github.com/danni-cool/wechatbot-webhook/commit/cd1288ea2935312675d06cafc1784848276a2b95)) 79 | 80 | 81 | ## [2.5.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.4.2...v2.5.0) (2024-01-05) 82 | 83 | 84 | ### Features 85 | 86 | * 🎸 增加log4js作为日志文件输出 ([17a84f8](https://github.com/danni-cool/wechatbot-webhook/commit/17a84f8aeab132979fc931e878b9a9381b8aff18)) 87 | * 🎸 增加健康检测接口/healthz替换/loginCheck, close:[#99](https://github.com/danni-cool/wechatbot-webhook/issues/99) ([55fb2ee](https://github.com/danni-cool/wechatbot-webhook/commit/55fb2ee2a3bbc343b29e5a2384f74ed84a24d000)) 88 | * 🎸 增加推消息的群发模式 ([d1e23fd](https://github.com/danni-cool/wechatbot-webhook/commit/d1e23fdae5769aeec28102655e672a601a3463fb)) 89 | * 🎸 推消息api支持单请求发多条消息 ([20b5983](https://github.com/danni-cool/wechatbot-webhook/commit/20b598373df74499a6ccfd336b5d8d5c867e0b64)) 90 | 91 | 92 | ### Refactor 93 | 94 | * 💡 http 服务使用hono替换express ([0444b22](https://github.com/danni-cool/wechatbot-webhook/commit/0444b22df0b362b7251f186a8252a76dc92d1d28)) 95 | 96 | 97 | ### Performance Improvements 98 | 99 | * ⚡️ 增加log日志输出稳定性 ([161c235](https://github.com/danni-cool/wechatbot-webhook/commit/161c235f79c4f7b01f22ce8df7efd3a1767fe418)) 100 | * ⚡️ 推消息api增加未登录校验 ([6ef54e3](https://github.com/danni-cool/wechatbot-webhook/commit/6ef54e3915d22686df8971eb1a80a897df40504f)) 101 | 102 | 103 | ## [2.4.2](https://github.com/danni-cool/wechatbot-webhook/compare/v2.4.1...v2.4.2) (2023-12-29) 104 | 105 | 106 | ### Bug Fixes 107 | 108 | * 🐛 修复因为未触发scan事件导致token未初始化可无鉴权访问/login的问题 close:[#102](https://github.com/danni-cool/wechatbot-webhook/issues/102) ([#103](https://github.com/danni-cool/wechatbot-webhook/issues/103)) ([2891e41](https://github.com/danni-cool/wechatbot-webhook/commit/2891e41416bc641aae3f7e372d318df2c9cfa6c1)) 109 | 110 | 111 | ## [2.4.1](https://github.com/danni-cool/docker-wechatbot-webhook/compare/v2.4.0...v2.4.1) (2023-12-24) 112 | 113 | 114 | ### Bug Fixes 115 | 116 | * 🐛 修复 puppet-wechat4u 重建登录失败问题 [#90](https://github.com/danni-cool/wechatbot-webhook/issues/90) ([c7fcaa6](https://github.com/danni-cool/wechatbot-webhook/commit/c7fcaa6fcaf396f1aed0f59975fd2dcac89d1798)) 117 | 118 | 119 | ## [2.4.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.3.3...v2.4.0) (2023-12-22) 120 | 121 | 122 | ### Features 123 | 124 | * 🎸 增加微信非登出状态,重启服务可以自动登录 ([#82](https://github.com/danni-cool/wechatbot-webhook/issues/82)) ([839f866](https://github.com/danni-cool/wechatbot-webhook/commit/839f8662bbafed6e36a990a9040462f373d04e78)) 125 | 126 | 127 | ## [2.3.3](https://github.com/danni-cool/wechatbot-webhook/compare/v2.3.2...v2.3.3) (2023-12-05) 128 | 129 | 130 | ### Bug Fixes 131 | 132 | * 🐛 修复curl post文件时中文文件名的问题 ([85c1407](https://github.com/danni-cool/wechatbot-webhook/commit/85c14078e89f2d131011fd804088cff178e01a72)) 133 | 134 | 135 | ### Performance Improvements 136 | 137 | * ⚡️ 移除大文件patch-file,指定 puppet-wechat4u 修复版本 ([dafce34](https://github.com/danni-cool/wechatbot-webhook/commit/dafce3499e68d13d955e72df512cf2822b346510)) 138 | 139 | 140 | ## [2.3.2](https://github.com/danni-cool/wechatbot-webhook/compare/v2.3.1...v2.3.2) (2023-12-04) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * 🐛 修复因为docker 打包和本地不一致问题 ([03cfc33](https://github.com/danni-cool/wechatbot-webhook/commit/03cfc336c8e73acdd064495eb9c380b619c01f86)) 146 | 147 | 148 | ## [2.3.1](https://github.com/danni-cool/wechatbot-webhook/compare/v2.3.0...v2.3.1) (2023-12-04) 149 | 150 | ### Bug Fixes 151 | 152 | * 修复0.5mb以上文件无法上传问题 ([7e3993c](https://github.com/danni-cool/wechatbot-webhook/commit/7e3993ca1e13931e11089ff68e6498e1dff572c3)) 153 | 154 | 155 | ## [2.3.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.2.2...v2.3.0) (2023-10-29) 156 | 157 | ### Features 158 | 159 | * 🎸 使用form表单发送本地文件支持备注名,移除发送json数据时提示不再支持的type类型 ([69f44e0](https://github.com/danni-cool/wechatbot-webhook/commit/69f44e051aa71ac401179637e1cfe27f1f8c3ffe)) 160 | 161 | 162 | ## [2.2.2](https://github.com/danni-cool/wechatbot-webhook/compare/v2.2.1...v2.2.2) (2023-10-29) 163 | 164 | ### Bug Fixes 165 | 166 | * 修复发送文件链接不带文件格式时无法正确解析的问题 & 移除 fetch 请求库 使用原生支持 ([b0b86b6](https://github.com/danni-cool/wechatbot-webhook/commit/b0b86b623ff939bcaa4aced79a215103e7e7f1ee)) 167 | 168 | 169 | ### Refactor 170 | 171 | * 💡 优化代码 ([8f66412](https://github.com/danni-cool/wechatbot-webhook/commit/8f664127e06d32bfc6eecef1c64e34041030b3a0)) 172 | 173 | 174 | ### Performance Improvements 175 | 176 | * docker 构建优化 ([efdb9e0](https://github.com/danni-cool/wechatbot-webhook/commit/efdb9e086210cc1fac843001b5603cec797592b3)) 177 | 178 | 179 | ## [2.2.1](https://github.com/danni-cool/wechatbot-webhook/compare/v2.2.0...v2.2.1) (2023-10-23) 180 | 181 | ### Refactor 182 | 183 | - 💡 移除patch补丁,更新依赖 ([aacc5a7](https://github.com/danni-cool/wechatbot-webhook/commit/aacc5a7c152a1b0eec1533c6ef2a478b504cdae2)) 184 | 185 | 186 | ## [2.2.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.1.0...v2.2.0) (2023-10-22) 187 | 188 | ### Features 189 | 190 | - 🎸 收消息增加@我的参数isMentioned,收到文件是unknown时优先使用buffer判断文件类型 ([10ec2b7](https://github.com/danni-cool/wechatbot-webhook/commit/10ec2b7dc1a7a9aad96725a6451c0cd2f00ceae4)) 191 | 192 | ## [2.1.0](https://github.com/danni-cool/wechatbot-webhook/compare/v2.0.0...v2.1.0) (2023-10-13) 193 | 194 | ### Features 195 | 196 | - 🎸 个人消息支持给送发备注名,收群消息source.room字段提供群成员更多信息(昵称、备注、id) ([d6ffd54](https://github.com/danni-cool/wechatbot-webhook/commit/d6ffd54c8b6c95d59587192c1356f35a444ccbf7)) 197 | 198 | ### Refactor 199 | 200 | - 💡 删除srouce.room.payload.memberIdList字段 ([34dce0a](https://github.com/danni-cool/wechatbot-webhook/commit/34dce0a4787223380da7775695b0ae8c19892a9a)) 201 | - 💡 移除推消息api对img类型的支持,请用fileUrl替换 ([df461d0](https://github.com/danni-cool/wechatbot-webhook/commit/df461d075316b13883b18a4dd27db57f46075c0e)) 202 | 203 | ## [2.0.0](https://github.com/danni-cool/wechatbot-webhook/compare/v1.5.0...v2.0.0) (2023-10-11) 204 | 205 | ### ⚠ BREAKING CHANGES 206 | 207 | - 🧨 收消息 api 现在支持语音、视频、附件,原只有文件和图片,上报type:img 已移除,会和历史不兼容 208 | 209 | ### Features 210 | 211 | - 🎸 扩展收消息 api 支持的类型 ([4f4af46](https://github.com/danni-cool/wechatbot-webhook/commit/4f4af46a4c6bd46107d61cb970d9b3c2222036c5)) 212 | 213 | ## [1.5.0](https://github.com/danni-cool/wechatbot-webhook/compare/v1.4.0...v1.5.0) (2023-10-11) 214 | 215 | ### Features 216 | 217 | - 🎸 增加 /login api,并作为默认推荐登录api & 代码和文案优化 ([b3012e4](https://github.com/danni-cool/wechatbot-webhook/commit/b3012e41bacf6369f4d6b017a8126919d199801d)) 218 | 219 | ### Bug Fixes 220 | 221 | - 🐛 login api redirect 301 改为 302,解决二维码失效问题 ([c9b6708](https://github.com/danni-cool/wechatbot-webhook/commit/c9b670864dcc8c8b31b7116c722ed50f69fe2b81)) 222 | 223 | ### Performance Improvements 224 | 225 | - ⚡️ 不再需要两套登录api,合二为一 ([9968d66](https://github.com/danni-cool/wechatbot-webhook/commit/9968d6689cbb4d68a7dbb08eda74a2b954e22455)) 226 | 227 | ## [1.4.0](https://github.com/danni-cool/wechatbot-webhook/compare/v1.3.0...v1.4.0) (2023-10-09) 228 | 229 | ### Bug Fixes 230 | 231 | - 🐛 修复登录Api user 值为undefined的问题 ([9711eb8](https://github.com/danni-cool/wechatbot-webhook/commit/9711eb8da3a1cb4fa4dfd23792bb989013040a5b)) 232 | 233 | ### Features 234 | 235 | - 🎸 增加登录后可能登出的时间上报 ([ef3539f](https://github.com/danni-cool/wechatbot-webhook/commit/ef3539f6652124434d54d86a67796acee307ca28)) 236 | - 🎸 推消息api支持文件和文件Url ([350af6a](https://github.com/danni-cool/wechatbot-webhook/commit/350af6a3a8591163f1d2fd8a33c2f56769b215b5)) 237 | 238 | ### Performance Improvements 239 | 240 | - ⚡️ 参数错误时,校验优化,更正项目地址 ([dafafea](https://github.com/danni-cool/wechatbot-webhook/commit/dafafea1519b790c4db1eafe43f1193e78b2aea7)) 241 | - ⚡️ 精简无用代码&增加运行调试模式 ([e3d8bad](https://github.com/danni-cool/wechatbot-webhook/commit/e3d8bad6427105a6f27d246a63840888547c0700)) 242 | 243 | ## [1.3.1](https://github.com/danni-cool/wechatbot-webhook/compare/v1.3.0...v1.3.1) (2023-10-09) 244 | 245 | ### Performance Improvements 246 | 247 | - ⚡️ 参数错误时,校验优化,更正项目地址 ([dafafea](https://github.com/danni-cool/wechatbot-webhook/commit/dafafea1519b790c4db1eafe43f1193e78b2aea7)) 248 | 249 | ## [1.3.0](https://github.com/danni-cool/wechatbot-webhook/compare/v1.2.0...v1.3.0) (2023-10-08) 250 | 251 | ### Features 252 | 253 | - 🎸 login事件也增加通知 ([cb56a4e](https://github.com/danni-cool/wechatbot-webhook/commit/cb56a4e1e44ccaefec1c03a277c1e496321f7098)) 254 | 255 | ## [1.2.0](https://github.com/danni-cool/wechatbot-webhook/compare/v1.1.3...v1.2.0) (2023-10-08) 256 | 257 | ### Features 258 | 259 | - 🎸 增加checklogin api接口和token生成机制 ([1b64d1e](https://github.com/danni-cool/wechatbot-webhook/commit/1b64d1e16eeb2c42697efb2137939d56ab605836)) 260 | - 🎸 支持掉线或者异常时的通知机制 ([6008271](https://github.com/danni-cool/wechatbot-webhook/commit/6008271c983df75bbbdf326b3958f9264c708459)), closes [#9](https://github.com/danni-cool/wechatbot-webhook/issues/9) 261 | 262 | ## [1.1.3](https://github.com/danni-cool/wechatbot-webhook/compare/v1.1.2...v1.1.3) (2023-09-29) 263 | 264 | ### Features 265 | 266 | - 🎸 增加对入参的严格校验 ([5537a95](https://github.com/danni-cool/wechatbot-webhook/commit/5537a955fd1b747ef3c486beffac89b0a1c3d304)) 267 | - 🎸 支持收消息钩子,以及文档优化 ([3638ff7](https://github.com/danni-cool/wechatbot-webhook/commit/3638ff7feb9de02fab5dfe4d90f7079bc884a387)) 268 | 269 | ### Reverts 270 | 271 | - Revert "[skip ci]: change cdn address" ([0b0ec7a](https://github.com/danni-cool/wechatbot-webhook/commit/0b0ec7a32ad1f26498b6d7bd8b390d8260f8d69e)) 272 | 273 | ## [1.1.2](https://github.com/danni-cool/wechatbot-webhook/compare/v1.1.1...v1.1.2) (2023-09-22) 274 | 275 | ### Features 276 | 277 | - 🎸 支持webhook推送到个人,文档优化,workflow优化 ([87bbb5e](https://github.com/danni-cool/wechatbot-webhook/commit/87bbb5e42c48745b3a8a3001817c6391f3af9387)), closes [#1](https://github.com/danni-cool/wechatbot-webhook/issues/1) 278 | 279 | - 🧨 docker 项目地址修改 和 api修改 280 | 281 | ## 1.1.1 (2023-09-21) 282 | 283 | ### Bug Fixes 284 | 285 | - 🐛 修复发送图片来自cloudflare 托管的url 返回 http状态码301图片发送不成功的问题 ([44550a0](https://github.com/danni-cool/wechatbot-webhook/commit/44550a030273a6dcc1b8b296ec8fcdf4f9202849)) 286 | 287 | ## 1.1.0 (2023-09-20) 288 | 289 | ### Features 290 | 291 | - 🎸 增加了参数校验,docker tag 改为latest,更新部分注释 ([61ddd8a](https://github.com/danni-cool/wechatbot-webhook/commit/61ddd8a163ac37f8383fe62c757724f393f87e45)) 292 | 293 | ## 1.0.1 (2023-09-19) 294 | 295 | ### Features 296 | 297 | - 🎸 增加推送支持多图推送 ([9c659ad](https://github.com/danni-cool/wechatbot-webhook/commit/9c659ad15e1365194df1a02560ef4307ed2ecae5)) 298 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/danni-cool/wechatbot-webhook/release.yml) ![npm dowloads](https://img.shields.io/npm/dm/wechatbot-webhook?label=npm/downloads) 5 | ![Docker Pulls](https://img.shields.io/docker/pulls/dannicool/docker-wechatbot-webhook) ![GitHub release (with filter)](https://img.shields.io/github/v/release/danni-cool/wechatbot-webhook) 6 | 7 | 8 | 9 | [🚢 Docker 镜像](https://hub.docker.com/r/dannicool/docker-wechatbot-webhook/tags) | [📦 NPM包](https://www.npmjs.com/package/wechatbot-webhook)|[🔍 FAQ](https://github.com/danni-cool/wechatbot-webhook/issues/72) 10 | 11 | 一个小小的微信机器人webhook,帮你抹平了很多自己开发的障碍,基于 http 请求,与hooks微信不同,因为基于web api,所以优势在于可以部署到arm架构等设备上 12 |
13 | 14 | 15 | ## ✨ Features 16 | 17 | > [!Caution] 18 | > 项目目前基于web微信,其本身就有被限制风险,另外大概两天一掉线,除了正常功能修补,不接新的 feature request。 windows 协议正在WIP,近期应该会和大家见面! 19 | 20 | | **功能** | web协议 | windows协议 | 21 | | --- | --- | --- | 22 | | 目前可用性 | ✅ | ❌ | 23 | | 代码分支 | main | windows | 24 | | Docker Tag | latest | windows | 25 | | **<发送消息>** | ✅ 单条 / 多条 / 群发 | ✅ 单条 / 多条 / 群发 | 26 | | 发文字 | ✅ | ✅ | 27 | | 发图片 | ✅ 本地图片 / url图片解析 | ✅ 本地图片 / url图片解析 | 28 | | 发视频(mp4) | ✅ 本地视频 / url视频解析 | | 29 | | 发文件 | ✅ 本地文件 / url文件解析 | ✅ 本地文件 / url文件解析 | 30 | | **<接收消息>** | | | 31 | | 接收文字 | ✅ | ✅ | 32 | | 接收语音 | ✅ | | 33 | | 接收图片 | ✅ | | 34 | | 接收视频 | ✅ | | 35 | | 接收文件 | ✅ | | 36 | | 接收公众号推文链接 | ✅ | | 37 | | 接收系统通知 | ✅ 上线通知 / 掉线通知 / 异常通知 | | 38 | | [头像获取](#33-获取静态资源接口) | ✅ | | 39 | | [快捷回复](#返回值-response-结构可选) | ✅ | ✅ | 40 | | **<群管理>** | | | 41 | | **<好友管理>** | | | 42 | | 接收好友申请 | ✅ | | 43 | | 通过好友申请 | ✅ | | 44 | | 获取联系人列表 | | | 45 | | **<其他功能>** | | | 46 | | 非掉线自动登录 | ✅ | | 47 | | API 鉴权 | ✅ | ✅ | 48 | | [n8n](https://n8n.io/) 无缝接入 | ✅ | | 49 | | 支持docker部署 | ✅ arm64 / amd64 | ✅ amd64 | 50 | | 日志文件导出 | ✅ | ✅ | 51 | 52 | ### ⚠️ 特别说明: 53 | 54 | 以上提到的功能 ✅ 为已实现,受限于微信协议限制,不同协议支持功能也是不同的,并不是所有功能都可以对接,例如: 55 | 56 | - 企业微信消息的收发 [#142](https://github.com/danni-cool/wechatbot-webhook/issues/142) 57 | - 发送语音消息 / 分享音乐 / 公众号等在 features 中未提到的功能 58 | 59 | ## 🚀 一分钟 Demo 60 | 61 | ### 1. 运行 & 扫码 62 | 63 | ```bash 64 | npx wechatbot-webhook 65 | ``` 66 | 67 | > 除非掉线,默认记住上次登录,换帐号请运行以下命令 `npx wechatbot-webhook -r` 68 | 69 | > 如遇安装报错,请确保自己的node版本 >= 18.14.1 [#227](https://github.com/danni-cool/wechatbot-webhook/issues/227) 70 | 71 | ### 2. 复制推消息 api 72 | 73 | 从命令行中复制推消息api,例如 http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN] 74 | 75 | ![](https://cdn.jsdelivr.net/gh/danni-cool/danni-cool@cdn/image/wechatbot-demo.gif) 76 | 77 | ### 3. 使用以下结构发消息 78 | 79 | 新开个终端试试以下 curl,to、token 字段值换成你要值 80 | 81 | ```bash 82 | curl --location 'http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN]' \ 83 | --header 'Content-Type: application/json' \ 84 | --data '{ "to": "测试昵称", data: { "content": "Hello World!" }}' 85 | ``` 86 | 87 | ## 🔧 开发 88 | 89 | > [!IMPORTANT] 90 | > 包管理器迁移已至 pnpm,安装依赖请使用它,以支持一些不定时的临时包修补(patches)和加速依赖安装 91 | 92 | ## ⛰️ 部署 Deploy(推荐) 93 | 94 | 95 | #### 1.使用 docker 部署 96 | 97 | ##### 拉取最新镜像 98 | 99 | ``` 100 | docker pull dannicool/docker-wechatbot-webhook 101 | ``` 102 | 103 | ##### docker 部署 104 | 105 | ```bash 106 | # 启动容器并映射日志目录,日志按天维度生成,e.g: app.2024-01-01.log 107 | docker run -d --name wxBotWebhook -p 3001:3001 \ 108 | -v ~/wxBot_logs:/app/log \ 109 | dannicool/docker-wechatbot-webhook 110 | ``` 111 | 112 | ##### 使用 compose 部署 (可选) 113 | 114 | ```bash 115 | wget -O docker-compose.yml https://cdn.jsdelivr.net/gh/danni-cool/wechatbot-webhook@main/docker-compose.yml && docker-compose down && docker-compose -p wx_bot_webhook up 116 | ``` 117 | 118 | #### 2.登录 119 | 120 | ```bash 121 | docker logs -f wxBotWebhook 122 | ``` 123 | 124 | 找到二维码登录地址,图下 url 部分,浏览器访问,扫码登录wx 125 | 126 | 127 | 128 | #### 可选 env 参数 129 | 130 | > Tips:需要增加参数使用 -e,多行用 \ 隔开,例如 -e RECVD_MSG_API="" \ 131 | 132 | | 功能 | 变量 | 备注 | 133 | |--|--|--| 134 | | 日志级别 | LOG_LEVEL=info | 日志级别,默认 info,只影响当前日志输出,详细输出考虑使用 debug。无论该值如何变化,日志文件总是记录debug级别的日志 | 135 | | 收消息 API | RECVD_MSG_API= | 如果想自己处理收到消息的逻辑,比如根据消息联动,填上你的处理逻辑 url | 136 | | 收消息 API 接受自己发的消息 | ACCEPT_RECVD_MSG_MYSELF=false | RECVD_MSG_API 是否接收来自自己发的消息(设置为true,即接收, 默认false) | 137 | | 自定义登录 API token | LOGIN_API_TOKEN=abcdefg123 | 你也可以自定义一个自己的登录令牌,不配置的话,默认会生成一个 | 138 | | 禁用自动登录 | DISABLE_AUTO_LOGIN=true | **非微信踢下线账号,可以依靠当前登录的session免登**, 如果想每次都扫码登陆,则增加该条配置 | 139 | 140 | ## 🛠️ API 141 | 142 | ### 1. 推消息 API 143 | 144 | > v2版本接口增加了群发功能,v1 版本接口请移步 [legacy-api](./docs/legacy-api.md) 145 | 146 | - Url: 147 | - Methods: `POST` 148 | - ContentType: `application/json` 149 | - Body: 格式见下面表格 150 | 151 | #### `payload` 结构 152 | 153 | > 发文字或文件外链, 外链会解析成图片或者文件 154 | 155 | | 参数 | 说明 | 数据类型 | 默认值 | 可否为空 | 可选参数 | 156 | | -- | -- | -- | -- | -- | -- | 157 | | to | **消息接收方**,传入`String` 默认是发给昵称(群名同理), 传入`Object` 结构支持发给备注过的人,比如:`{alias: '备注名'}`,群名不支持备注名 | `String` `Object` | - | N | - | 158 | | isRoom | **是否发给群消息**,这个参数决定了找人的时候找的是群还是人,因为昵称其实和群名相同在技术处理上 | `Boolean` | `false` | Y | `true` `false` | 159 | | data | 消息体结构,见下方 `payload.data` | `Object` `Array` | `false` | N | `true` `false` | 160 | 161 | #### `payload.data` 结构 162 | 163 | | 参数 | 说明 | 数据类型 | 默认值 | 可否为空 | 可选参数 | 164 | | -- | -- | -- | -- | -- | -- | 165 | | type | **消息类型**, 字段留空解析为纯文本 | `String` `text` | - | Y | `text` `fileUrl` | 支持 **文字** 和 **文件**, | 166 | | content | **消息内容**,如果希望发多个Url并解析,type 指定为 fileUrl 同时,content 里填 url 以英文逗号分隔 | `String` | - | N | - | 167 | 168 | #### Example(curl) 169 | 170 | ##### 发单条消息 171 | 172 | ```bash 173 | curl --location 'http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN]' \ 174 | --header 'Content-Type: application/json' \ 175 | --data '{ 176 | "to": "testUser", 177 | "data": { "content": "你好👋" } 178 | }' 179 | ``` 180 | 181 | ##### 发文件 url 同时支持修改成目标文件名 182 | 183 | > 有些情况下,直接发送 url 文件名可能不是我们想要的,给 url 拼接 query 参数 `$alias` 可用于指定发送给目标的文件名(注意:别名不做文件转换) 184 | 185 | ```bash 186 | curl --location 'http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN]' \ 187 | --header 'Content-Type: application/json' \ 188 | --data '{ 189 | "to": "testUser", 190 | "data": { 191 | "type": "fileUrl" , 192 | "content": "https://download.samplelib.com/jpeg/sample-clouds-400x300.jpg?$alias=cloud.jpg" 193 | } 194 | }' 195 | ``` 196 | 197 | ##### 发给群消息 198 | 199 | ```bash 200 | curl --location 'http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN]' \ 201 | --header 'Content-Type: application/json' \ 202 | --data '{ 203 | "to": "testGroup", 204 | "isRoom": true, 205 | "data": { "type": "fileUrl" , "content": "https://download.samplelib.com/jpeg/sample-clouds-400x300.jpg" }, 206 | }' 207 | ``` 208 | 209 | ##### 同一对象多条消息(群消息同理) 210 | 211 | ```bash 212 | curl --location 'http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN]' \ 213 | --header 'Content-Type: application/json' \ 214 | --data '{ 215 | "to": "testUser", 216 | "data": [ 217 | { 218 | "type": "text", 219 | "content": "你好👋" 220 | }, 221 | { 222 | "type": "fileUrl", 223 | "content": "https://samplelib.com/lib/preview/mp3/sample-3s.mp3" 224 | } 225 | ] 226 | }' 227 | ``` 228 | 229 | ##### 群发消息 230 | 231 | ``` bash 232 | curl --location 'http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN]' \ 233 | --header 'Content-Type: application/json' \ 234 | --data '[ 235 | { 236 | "to": "testUser1", 237 | "data": { 238 | "content": "你好👋" 239 | } 240 | }, 241 | { 242 | "to": "testUser2", 243 | "data": [ 244 | { 245 | "content": "你好👋" 246 | }, 247 | { 248 | "content": "近况如何?" 249 | } 250 | ] 251 | } 252 | ]' 253 | ``` 254 | 255 | #### 返回值 `response` 结构 256 | 257 | - **`success`**: 消息发送成功与否,群发消息即使部份发送成功也会返回 `true` 258 | - **`message`**: 出错时提示的消息 259 | - 消息发送成功: Message sent successfully 260 | - 参数校验不通过: Some params is not valid, sending task is suspend... 261 | - 消息都发送失败: All Messages [number] sent failed... 262 | - 部份发送成功: Part of the message sent successfully... 263 | - **`task`**: 发送任务详细信息 264 | - `task.successCount`: 发送成功条数 265 | - `task.totalCount`: 总消息条数 266 | - `task.failedCount`: 发送失败条数 267 | - `task.reject`: 因为参数校验不通过的参数和 error 提示 268 | - `task.sentFailed`: 因为发送失败和 error 提示 269 | - `task.notFound`: 因为未找到用户或者群和 error 提示 270 | 271 | > 确保消息单次发送一致性,某一条参数校验失败会终止所有消息发送任务 272 | 273 | ```json 274 | { 275 | "success": true, 276 | "message": "", 277 | "task": { 278 | "successCount": 0, 279 | "totalCount": 0, 280 | "failedCount": 0, 281 | "reject": [], 282 | "sentFailed": [], 283 | "notFound": [] 284 | } 285 | } 286 | ``` 287 | 288 | #### 读文件发送 289 | 290 | > 读文件暂时只支持单条发送 291 | 292 | - Url: 293 | - Methods: `POST` 294 | - ContentType: `multipart/form-data` 295 | - FormData: 格式见下面表格 296 | 297 | ##### `payload` 结构 298 | 299 | | 参数 | 说明 | 数据类型 | 默认值 | 可否为空 | 可选值 | 300 | | ------- | -------------------------------------------------------------------------------- | -------- | ------ | -------- | ------- | 301 | | to | 消息接收方,传入`String` 默认是发给昵称(群名同理), 传入 Json String 结构支持发给备注过的人,比如:--form 'to="{alias: \"小号\"}"',群名不支持备注名称 | `String` | - | N | - | 302 | | isRoom | **是否发的群消息**,formData纯文本只能使用 `String` 类型,`1`代表是,`0`代表否, | `String` | `0` | Y | `1` `0` | 303 | | content | **文件**,本地文件一次只能发一个,多个文件手动调用多次 | `Binary` | - | N | - | 304 | 305 | ##### Curl 306 | 307 | ```bash 308 | curl --location --request POST 'http://localhost:3001/webhook/msg?token=[YOUR_PERSONAL_TOKEN]' \ 309 | --form 'to=testGroup' \ 310 | --form content=@"$HOME/demo.jpg" \ 311 | --form 'isRoom=1' 312 | ``` 313 | 314 | #### 返回值 `response` 结构 315 | 316 | ```json 317 | { 318 | "success": true, 319 | "message": "Message sent successfully" 320 | } 321 | ``` 322 | 323 | ### 2. 收消息 API 324 | 325 | #### `payload` 结构 326 | - Methods: `POST` 327 | - ContentType: `multipart/form-data` 328 | - Form格式如下 329 | 330 | | formData | 说明 | 数据类型 | 可选值 | 示例 | 331 | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ----------------------- | ------------------------------------------------ | 332 | | type |
功能类型
  • ✅ 文字(text)
  • ✅ 链接卡片(urlLink)
  • ✅ 图片(file)
  • ✅ 视频(file)
  • ✅ 附件(file)
  • ✅ 语音(file)
  • ✅ 添加好友邀请(friendship)
其他类型
  • 未实现的消息类型(unknown)
系统类型
  • ✅ 登录(system_event_login)
  • ✅ 登出(system_event_logout)
  • ✅ 异常报错(system_event_error)
  • ✅ 快捷回复后消息推送状态通知(system_event_push_notify)
| `String` | `text` `file` `urlLink` `friendship` `unknown` `system_event_login` `system_event_logout` `system_event_error` `system_event_push_notify`| - | 333 | | content | 传输的内容, 文本或传输的文件共用这个字段,结构映射请看示例 | `String` `Binary` | | [示例](docs/recvdApi.example.md#formdatacontent) | 334 | | source | 消息的相关发送方数据, JSON String | `String` | | [示例](docs/recvdApi.example.md#formdatasource) | 335 | | isMentioned | 该消息是@我的消息 [#38](https://github.com/danni-cool/wechatbot-webhook/issues/38) | `String` | `1` `0` | - | 336 | | isMsgFromSelf | 是否是来自自己的消息 [#159](https://github.com/danni-cool/wechatbot-webhook/issues/159) | `String` | `1` `0` | - | 337 | 338 | **服务端处理 formData 一般需要对应的处理程序,假设你已经完成这一步,你将得到以下 request** 339 | 340 | ```json 341 | { 342 | "type": "text", 343 | "content": "你好", 344 | "source": "{\"room\":\"\",\"to\":{\"_events\":{},\"_eventsCount\":0,\"id\":\"@f387910fa45\",\"payload\":{\"alias\":\"\",\"avatar\":\"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=1302335654&username=@f38bfd1e0567910fa45&skey=@crypaafc30\",\"friend\":false,\"gender\":1,\"id\":\"@f38bfd1e10fa45\",\"name\":\"ch.\",\"phone\":[],\"star\":false,\"type\":1}},\"from\":{\"_events\":{},\"_eventsCount\":0,\"id\":\"@6b5111dcc269b6901fbb58\",\"payload\":{\"address\":\"\",\"alias\":\"\",\"avatar\":\"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=123234564&username=@6b5dbb58&skey=@crypt_ec356afc30\",\"city\":\"Mars\",\"friend\":false,\"gender\":1,\"id\":\"@6b5dbd3facb58\",\"name\":\"Daniel\",\"phone\":[],\"province\":\"Earth\",\"signature\":\"\",\"star\":false,\"weixin\":\"\",\"type\":1}}}", 345 | "isMentioned": "0", 346 | "isMsgFromSelf": "0", 347 | "isSystemEvent": "0" // 考虑废弃,请使用type类型判断系统消息 348 | } 349 | ``` 350 | 351 | **收消息 api curl示例(直接导入postman调试)** 352 | 353 | ```curl 354 | curl --location 'https://your.recvdapi.com' \ 355 | --form 'type="file"' \ 356 | --form 'content=@"/Users/Downloads/13482835.jpeg"' \ 357 | --form 'source="{\\\"room\\\":\\\"\\\",\\\"to\\\":{\\\"_events\\\":{},\\\"_eventsCount\\\":0,\\\"id\\\":\\\"@f387910fa45\\\",\\\"payload\\\":{\\\"alias\\\":\\\"\\\",\\\"avatar\\\":\\\"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=1302335654&username=@f38bfd1e0567910fa45&skey=@crypaafc30\\\",\\\"friend\\\":false,\\\"gender\\\":1,\\\"id\\\":\\\"@f38bfd1e10fa45\\\",\\\"name\\\":\\\"ch.\\\",\\\"phone\\\":[],\\\"star\\\":false,\\\"type\\\":1}},\\\"from\\\":{\\\"_events\\\":{},\\\"_eventsCount\\\":0,\\\"id\\\":\\\"@6b5111dcc269b6901fbb58\\\",\\\"payload\\\":{\\\"address\\\":\\\"\\\",\\\"alias\\\":\\\"\\\",\\\"avatar\\\":\\\"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=123234564&username=@6b5dbb58&skey=@crypt_ec356afc30\\\",\\\"city\\\":\\\"Mars\\\",\\\"friend\\\":false,\\\"gender\\\":1,\\\"id\\\":\\\"@6b5dbd3facb58\\\",\\\"name\\\":\\\"Daniel\\\",\\\"phone\\\":[],\\\"province\\\":\\\"Earth\\\",\\\"signature\\\":\\\"\\\",\\\"star\\\":false,\\\"weixin\\\":\\\"\\\",\\\"type\\\":1}}}"' \ 358 | --form 'isMentioned="0"' 359 | ``` 360 | 361 | 362 | #### 返回值 `response` 结构(可选) 363 | 364 | > 如果期望用 `RECVD_MSG_API` 收消息后立即回复(**快捷回复**),请按以下结构返回返回值,无返回值则不会回复消息 365 | 366 | - ContentType: `json` 367 | 368 | | 参数 | 说明 | 数据类型 | 默认值 | 可否为空 | 可选参数 | 369 | | -- | -- | -- | -- | -- | -- | 370 | | success | 该条请求成功与否,返回 false 或者无该字段,不会处理回复,**有一些特殊消息也通过这个字段控制,比如加好友邀请,返回 `true` 则会通过好友请求** | `Boolean` | - | Y | `true` `false` | 371 | | data | 如果需要回复消息的话,需要定义data字段 | `Object` `Object Array` | - | Y | | 372 | 373 | #### `response.data` 结构 374 | 375 | | 参数 | 说明 | 数据类型 | 默认值 | 可否为空 | 可选参数 | 376 | | -- | -- | -- | -- | -- | -- | 377 | | type | **消息类型**,该字段不填默认当文本类型传输 | `String` | `text` | Y | `text` `fileUrl` | 支持 **文字** 和 **文件**, | 378 | | content | **消息内容**,如果希望发多个Url并解析,type 指定为 fileUrl 同时,content 里填 url 以英文逗号分隔 | `String` | - | N | - | 379 | 380 | 如果回复单条消息 381 | 382 | ```json 383 | { 384 | "success": true, 385 | "data": { 386 | "type": "text", 387 | "content": "hello world!" 388 | } 389 | } 390 | ``` 391 | 392 | 组合回复多条消息 393 | 394 | ```json 395 | { 396 | "success": true, 397 | "data": [ 398 | { 399 | "type": "text", 400 | "content": "hello world!" 401 | }, 402 | { 403 | "type": "fileUrl", 404 | "content": "https://samplelib.com/lib/preview/mp3/sample-3s.mp3" 405 | } 406 | ] 407 | } 408 | ``` 409 | 410 | ### 3. 其他API 411 | 412 | #### token 配置说明 413 | > 除了在 docker 启动时配置token,在默认缺省 token 的情况,会默认生成一个写入 `.env` 文件中 414 | 415 | #### 3.1 获取登录二维码接口 416 | - **地址**:`/login` 417 | - **methods**: `GET` 418 | - **query**: token 419 | - **status**: `200` 420 | - **example**: http://localhost:3001/login?token=[YOUR_PERSONAL_TOKEN] 421 | 422 | ##### 登录成功 423 | 424 | 返回 json 包含当前用户 425 | 426 | ```json 427 | {"success":true,"message":"Contactis already login"} 428 | ``` 429 | 430 | ##### 登录失败 431 | 432 | 展示微信登录扫码页面 433 | 434 | #### 3.2 健康检测接口 435 | 436 | 可以主动轮询该接口,检查服务是否正常运行 437 | 438 | - **地址**:`/healthz` 439 | - **methods**: `GET` 440 | - **query**: token 441 | - **status**: `200` 442 | - **example**: http://localhost:3001/healthz?token=[YOUR_PERSONAL_TOKEN] 443 | 444 | 微信已登录, 返回纯文本 `healthy`,否则返回 `unHealthy` 445 | 446 | #### 3.3 获取静态资源接口 447 | 448 | 从 2.8.0 版本开始,可以通过本接口访问到头像等静态资源,具体见 [recvd_api 数据结构示例的 avatar 字段](/docs/recvdApi.example.md#2-formdatasource-string) 449 | 450 | 注意所有上报 recvd_api 的静态资源地址不会默认带上 token, 需要自己拼接,否则会返回 401 错误, 请确保自己微信已登录,需要通过登录态去获取资源 451 | 452 | - **地址**:`/resouces` 453 | - **methods**: `GET` 454 | - **query**: 455 | - token: 登录token 456 | - media: encode过的相对路径,比如 `/avatar/1234567890.jpg` encode为 `avatar%2F1234567890.jpg` 457 | - **status**: `200` `404` `401` 458 | 459 | - **example**:http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bin%2Fwebwxgetheadimg%3Fseq%3D83460%26username%3D%40%4086815a%26skey%3D&token=[YOUR_PERSONAL_TOKEN] 460 | 461 | ##### status: `200` 462 | 463 | 成功获取资源, 返回静态资源文件 464 | 465 | ##### status: `404` 466 | 467 | 获取资源失败 468 | 469 | ##### status: `401` 未携带登录token 470 | 471 | ```json 472 | {"success":false, "message":"Unauthorized: Access is denied due to invalid credentials."} 473 | ``` 474 | 475 | ##### status: `401` 微信登录态已过期 476 | 477 | ```json 478 | { 479 | "success": false, "message": "you must login first" 480 | } 481 | ``` 482 | 483 | 484 | ## 🌟 Star History 485 | 486 | [![Star History Chart](https://api.star-history.com/svg?repos=danni-cool/wechatbot-webhook&type=Date)](https://star-history.com/#danni-cool/wechatbot-webhook&Date) 487 | 488 | ## Contributors 489 | 490 | Thanks to all our contributors! 491 | 492 | ![](https://contrib.rocks/image?repo=danni-cool/wechatbot-webhook) 493 | 494 | ## ⏫ 更新日志 495 | 496 | 更新内容参见 [CHANGELOG](https://github.com/danni-cool/docker-wechat-roomBot/blob/main/CHANGELOG.md) 497 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | wxBotWebhook: 4 | image: dannicool/docker-wechatbot-webhook 5 | container_name: wxbot_app 6 | volumes: 7 | - ./wxBot_logs:/app/log 8 | ports: 9 | - "3001:3001" 10 | environment: 11 | - LOG_LEVEL=info # 调整容器输出级别(不影响日志文件输出等级)运行时提示的消息等级(默认info,debug级别会输出详细的日志) 12 | # - DISABLE_AUTO_LOGIN=true # 如果不希望登录一次后就记住当前账号,想每次都扫码登陆,填 true 13 | # - ACCEPT_RECVD_MSG_MYSELF=true # 如果希望机器人可以自己接收消息,填 true 14 | # - RECVD_MSG_API= # 如果想自己处理收到消息的逻辑,在下面填上你的API地址, 默认为空 15 | # - LOGIN_API_TOKEN= # 登录地址Token访问地址: http://localhost:3001/login?token=[LOCAL_LOGIN_API_TOKEN] 16 | restart: unless-stopped 17 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | # 使用 Node.js 18 作为基础镜像 2 | FROM node:18-alpine 3 | 4 | # 创建工作目录 5 | WORKDIR /app 6 | 7 | # 非依赖变更缓存改层 8 | COPY package.json pnpm-lock.yaml .npmrc ./ 9 | 10 | # 创建 patches 目录并复制所有内容 11 | COPY patches ./patches 12 | 13 | # 安装应用程序依赖项 14 | RUN npm install -g pnpm && pnpm install --production&& pnpm store prune && npm uninstall pnpm -g 15 | 16 | # 复制应用程序代码到工作目录 17 | COPY . . 18 | 19 | # 如果收消息想接入webhook 20 | ENV RECVD_MSG_API= 21 | # 默认登录API接口访问token 22 | ENV LOGIN_API_TOKEN= 23 | # 是否禁用默认登录 24 | ENV DISABLE_AUTO_LOGIN= 25 | # 运行时提示的消息等级(默认info,想有更详细的日志,可以指定为debug) 26 | ENV LOG_LEVEL=info 27 | # RECVD_MSG_API 是否接收来自自己发的消息(设置为true,即接收) 28 | ENV ACCEPT_RECVD_MSG_MYSELF=false 29 | 30 | # 暴露端口(你的 Express 应用程序监听的端口) 31 | EXPOSE 3001 32 | 33 | # 启动应用程序 34 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /docs/Jietu20240506-220141@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danni-cool/wechatbot-webhook/fcd7b47687e7a3a5ef935701b60b4160e76c40c2/docs/Jietu20240506-220141@2x.jpg -------------------------------------------------------------------------------- /docs/legacy-api.md: -------------------------------------------------------------------------------- 1 | # 推消息 API 2 | 3 | ## V1 版本 4 | 5 | - Url: 6 | - Methods: `POST` 7 | 8 | #### Case1. 发文字或文件(外链) 9 | 10 | - ContentType: `application/json` 11 | - Body: 格式见下面表格 12 | 13 | > json 请求发送文件只支持外链 14 | 15 | | 参数 | 说明 | 数据类型 | 默认值 | 可否为空 | 可选参数 | 16 | | -- | -- | -- | -- | -- | -- | 17 | | to | **消息接收方**,传入`String` 默认是发给昵称(群名同理), 传入`Object` 结构支持发给备注过的人,比如:`{alias: '备注名'}`,群名不支持备注名 | `String` `Object` | - | N | - | 18 | | isRoom | **是否发的群消息**,这个参数决定了找人的时候找的是群还是人,因为昵称其实和群名相同在技术处理上 | `Boolean` | `false` | Y | `true` `false` | 19 | | type | **消息类型**,消息不支持自动拆分,请手动拆分| `String` | `text` | N | `text` `fileUrl` | 支持 **文字** 和 **文件**, | 20 | | content | **消息内容**,如果希望发多个Url并解析,type 指定为 fileUrl 同时,content 里填 url 以英文逗号分隔 | `String` | - | N | - | 21 | 22 | #### Example(curl) 23 | 24 | ##### Curl (发文字) 25 | 26 | ```bash 27 | curl --location --request POST 'http://localhost:3001/webhook/msg?token=[YOUR_PERSONAL_TOKEN]' \ 28 | --header 'Content-Type: application/json' \ 29 | --data-raw '{ 30 | "to": "testUser", 31 | "type": "text", 32 | "content": "Hello World!" 33 | }' 34 | ``` 35 | 36 | ##### Curl(发文件,解析url) 37 | 38 | ```bash 39 | curl --location --request POST 'http://localhost:3001/webhook/msg?token=[YOUR_PERSONAL_TOKEN]' \ 40 | --header 'Content-Type: application/json' \ 41 | --data-raw '{ 42 | "to": "testGroup", 43 | "type": "fileUrl", 44 | "content": "https://samplelib.com/lib/preview/mp3/sample-3s.mp3", 45 | "isRoom": true 46 | }' 47 | ``` 48 | 49 | #### Case2. 读文件发送 50 | 51 | - ContentType: `multipart/form-data` 52 | - FormData: 格式见下面表格 53 | 54 | | 参数 | 说明 | 数据类型 | 默认值 | 可否为空 | 可选值 | 55 | | ------- | -------------------------------------------------------------------------------- | -------- | ------ | -------- | ------- | 56 | | to | 消息接收方,传入`String` 默认是发给昵称(群名同理), 传入 Json String 结构支持发给备注过的人,比如:"{alias: '备注名'}",群名不支持备注名称 | `String` | - | N | - | 57 | | isRoom | **是否发的群消息**,formData纯文本只能使用 `String` 类型,`1`代表是,`0`代表否, | `String` | `0` | Y | `1` `0` | 58 | | content | **文件**,本地文件一次只能发一个,多个文件手动调用多次 | `Binary` | - | N | - | 59 | 60 | ##### Curl 61 | 62 | ```bash 63 | curl --location --request POST 'http://localhost:3001/webhook/msg?token=[YOUR_PERSONAL_TOKEN]' \ 64 | --form 'to=testGroup' \ 65 | --form content=@"$HOME/demo.jpg" \ 66 | --form 'isRoom=1' 67 | ``` -------------------------------------------------------------------------------- /docs/recvdApi.example.md: -------------------------------------------------------------------------------- 1 | # RECVD_MSG_API JSON 示例 2 | 3 | ## 1. `formData.type` 不同情况说明 4 | 5 | ### 1.1 功能消息类型 6 | 7 | #### 文字消息 `text` 8 | 9 | - 是否支持快捷回复:✅ 10 | - `formData.content`: `String` 11 | 12 | #### 文件消息 `file` 13 | 14 | - 是否支持快捷回复:✅ 15 | - `formData.content`: `binary` 16 | 17 | #### 公众号推文 `urlLink` 18 | 19 | - 是否支持快捷回复:❌ 20 | - `formData.content`:`json` 21 | 22 | 示例 23 | ```json 24 | { 25 | "description": "AI技术逐渐成为设计师的灵感库", 26 | "thumbnailUrl": "", 27 | "title": "AI神器帮助你从小白秒变设计师", 28 | "url": "http://example.url", 29 | } 30 | ``` 31 | 32 | #### 加好友请求 `friendship` 33 | 34 | - 是否支持快捷回复:✅ 35 | - `formData.content`:`json` 36 | 37 | ```json 38 | { 39 | "name": "加你的人昵称", 40 | "hello": "朋友验证消息" 41 | } 42 | ``` 43 | 44 | > 通过好友请求,需要通过接口返回 `{ "success": true }` 字段 45 | 46 | ### 1.2 其他消息类型 47 | 48 | #### 不支持的消息类型 `unknown` 49 | 50 | - 是否支持快捷回复:✅ 51 | 52 | 没法在当前版本微信中展示的消息,如果能展示值,会以**文本形式**展示,否则为空 53 | 54 | 例如: 55 | - [unknown 类型里拍一拍消息提示](https://github.com/danni-cool/wechatbot-webhook/pull/121) 56 | 57 | 58 | ### 1.3 系统通知消息类型 59 | 60 | - 是否支持快捷回复:❌ 61 | 62 | #### 微信已登录/登出 `system_event_login` | `system_event_logout` 63 | 64 | - `formData.content`: `json` 65 | 66 | ```js 67 | { 68 | "event": "login", // login | logout 69 | 70 | "user": { // 当前的用户信息 71 | "_events": {}, 72 | "_eventsCount": 0, 73 | "id": "@xxxasdfsf", 74 | "payload": { 75 | "alias": "", 76 | "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 77 | "friend": false, 78 | "gender": 1, 79 | "id": "@xxx", 80 | "name": "somebody", 81 | "phone": [], 82 | "star": false, 83 | "type": 1 84 | } 85 | } 86 | } 87 | ``` 88 | 89 | #### 系统运行出错 `system_event_error` 90 | - `formData.content`: `json` 91 | ```js 92 | { 93 | "event": "error", //notifyOfRecvdApiPushMsg 94 | 95 | "user": { // 当前的用户信息 96 | "_events": {}, 97 | "_eventsCount": 0, 98 | "id": "@xxxasdfsf", 99 | "payload": { 100 | "alias": "", 101 | "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 102 | "friend": false, 103 | "gender": 1, 104 | "id": "@xxx", 105 | "name": "somebody", 106 | "phone": [], 107 | "star": false, 108 | "type": 1 109 | } 110 | }, 111 | 112 | "error": {} // 具体出错信息 js error stack 113 | } 114 | ``` 115 | 116 | #### 快捷回复后通知 `system_event_push_notify` 117 | - `formData.content`: `json` 118 | ```js 119 | { 120 | "event": "error", //notifyOfRecvdApiPushMsg 121 | 122 | "user": { // 当前的用户信息 123 | "_events": {}, 124 | "_eventsCount": 0, 125 | "id": "@xxxasdfsf", 126 | "payload": { 127 | "alias": "", 128 | "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 129 | "friend": false, 130 | "gender": 1, 131 | "id": "@xxx", 132 | "name": "somebody", 133 | "phone": [], 134 | "star": false, 135 | "type": 1 136 | } 137 | }, 138 | 139 | // 快捷回复后触发才返回此结构,如果有部分消息推送失败也在此结构能拿到所有信息, 结构同推消息的api结构 140 | "recvdApiReplyNotify": { 141 | "success": true, 142 | "message": "Message sent successfully", 143 | "task": { 144 | "successCount": 1, 145 | "totalCount": 1, 146 | "failedCount": 0, 147 | "reject": [], 148 | "sentFailed": [], 149 | "notFound": [] 150 | } 151 | } 152 | } 153 | ``` 154 | 155 | 156 | ## 2. formData.source `String` 157 | 158 | ```js 159 | { 160 | // 消息来自群,会有以下对象,否则为空字符串 161 | "room": { 162 | "id": "@@xxx", 163 | "topic": "abc" // 群名 164 | "payload": { 165 | "id": "@@xxxx", 166 | "adminIdList": [], 167 | "avatar": "xxxx", // 相对路径,应该要配合解密 168 | "memberList": [ 169 | { 170 | id: '@xxxx', 171 | avatar: "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 172 | name:'昵称', 173 | alias: '备注名'/** 个人备注名,非群备注名 */ } 174 | ] 175 | }, 176 | //以下暂不清楚什么用途,如有兴趣,请查阅 wechaty 官网文档 177 | "_events": {}, 178 | "_eventsCount": 0, 179 | }, 180 | 181 | 182 | // 消息来自个人,会有以下对象,否则为空字符串 183 | "to": { 184 | "id": "@xxx", 185 | 186 | "payload": { 187 | "alias": "", //备注名 188 | "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 189 | "friend": false, 190 | "gender": 1, 191 | "id": "@xxx", 192 | "name": "xxx", 193 | "phone": [], 194 | "signature": "hard mode", 195 | "star": false, 196 | "type": 1 197 | }, 198 | 199 | "_events": {}, 200 | "_eventsCount": 0, 201 | }, 202 | 203 | // 消息发送方 204 | "from": { 205 | "id": "@xxx", 206 | 207 | "payload": { 208 | "alias": "", 209 | "avatar": "http://localhost:3001/resouces?media=%2Fcgi-bin%2Fmmwebwx-bixxx", //请配合 token=[YOUR_PERSONAL_TOKEN] 解密 210 | "city": "北京", 211 | "friend": true, 212 | "gender": 1, 213 | "id": "@xxxx", 214 | "name": "abc", //昵称 215 | "phone": [], 216 | "province": "北京", 217 | "star": false, 218 | "type": 1 219 | }, 220 | 221 | "_events": {}, 222 | "_eventsCount": 0, 223 | } 224 | 225 | } 226 | ``` 227 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ 2 | path: process.env.homeEnvCfg /** 兼容cli调用 */ ?? './.env' 3 | }) 4 | require('./src/utils/index').proxyConsole() 5 | const { PORT } = process.env 6 | const { Hono } = require('hono') 7 | const { serve } = require('@hono/node-server') 8 | const wechatBotInit = require('./src/wechaty/init') 9 | const registerRoute = require('./src/route') 10 | const bot = wechatBotInit() 11 | const app = new Hono() 12 | 13 | registerRoute({ app, bot }) 14 | 15 | serve({ 16 | hostname: '0.0.0.0', 17 | fetch: app.fetch, 18 | port: Number(PORT) 19 | }) 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechatbot-webhook", 3 | "version": "2.8.2", 4 | "description": "一个小小的微信机器人webhook,帮你抹平了很多自己开发的障碍,基于 http 请求", 5 | "keywords": [ 6 | "wechat", 7 | "bot", 8 | "webhook", 9 | "wechaty", 10 | "http-service" 11 | ], 12 | "main": "main.js", 13 | "scripts": { 14 | "prestart": "node ./scripts/preStart", 15 | "start": "node main", 16 | "build:cli": "pnpm --filter wechatbot-webhook run build" 17 | }, 18 | "engines": { 19 | "node": ">=18.14.1" 20 | }, 21 | "author": { 22 | "name": "danni-cool", 23 | "email": "contact@danni.cool" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/danni-cool/wechatbot-webhook.git" 28 | }, 29 | "lint-staged": { 30 | "**/*.{js,jsx,ts,tsx}": [ 31 | "./scripts/tsc-lint.sh", 32 | "eslint --fix", 33 | "prettier --write" 34 | ] 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "pre-commit": "lint-staged" 39 | } 40 | }, 41 | "license": "MIT", 42 | "dependencies": { 43 | "@hono/node-server": "^1.3.3", 44 | "chalk": "^4.1.2", 45 | "dotenv": "^16.3.1", 46 | "file-box": "1.4.15", 47 | "form-data": "^4.0.0", 48 | "gerror": "^1.0.16", 49 | "hono": "^3.11.11", 50 | "lodash.clonedeep": "^4.5.0", 51 | "log4js": "^6.9.1", 52 | "mime": "^3.0.0", 53 | "node-fetch-commonjs": "^3.3.2", 54 | "qrcode-terminal": "^0.12.0", 55 | "wechaty": "^1.20.2" 56 | }, 57 | "devDependencies": { 58 | "@commitlint/config-conventional": "^18.1.0", 59 | "@types/eslint": "^8.56.0", 60 | "@types/mime": "^3.0.4", 61 | "@types/qrcode-terminal": "^0.12.2", 62 | "@typescript-eslint/eslint-plugin": "^6.16.0", 63 | "@typescript-eslint/parser": "^6.16.0", 64 | "eslint": "^8.56.0", 65 | "eslint-config-prettier": "^9.1.0", 66 | "eslint-plugin-prettier": "^5.1.2", 67 | "husky": "^8.0.3", 68 | "i": "^0.3.7", 69 | "npm": "^10.5.0", 70 | "prettier": "^3.1.1", 71 | "tsc-files": "^1.1.4", 72 | "typescript": "^5.3.3", 73 | "wechaty-puppet-mock": "^1.18.2" 74 | }, 75 | "pnpm": { 76 | "patchedDependencies": { 77 | "wechat4u@0.7.14": "patches/wechat4u@0.7.14.patch", 78 | "wechaty-puppet-wechat4u@1.14.13": "patches/wechaty-puppet-wechat4u@1.14.13.patch", 79 | "wechaty-puppet@1.20.2": "patches/wechaty-puppet@1.20.2.patch" 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/cli/.env.example: -------------------------------------------------------------------------------- 1 | # 启动服务的端口 2 | PORT=3001 3 | 4 | # 运行时提示的消息等级(默认info,想有更详细的日志,可以指定为debug) 5 | LOG_LEVEL=info 6 | 7 | # 如果不希望登录一次后就记住当前账号,想每次都扫码登陆,填 true 8 | DISABLE_AUTO_LOGIN= 9 | 10 | # RECVD_MSG_API 是否接收来自自己发的消息 11 | ACCEPT_RECVD_MSG_MYSELF=false 12 | 13 | # 如果想自己处理收到消息的逻辑,在下面填上你的API地址, 默认为空 14 | LOCAL_RECVD_MSG_API= 15 | 16 | # 登录地址Token访问地址: http://localhost:3001/login?token=[LOCAL_LOGIN_API_TOKEN] 17 | LOCAL_LOGIN_API_TOKEN= -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/danni-cool/wechatbot-webhook/release.yml) ![npm dowloads](https://img.shields.io/npm/dm/wechatbot-webhook?label=npm/downloads) 5 | ![Docker Pulls](https://img.shields.io/docker/pulls/dannicool/docker-wechatbot-webhook) ![GitHub release (with filter)](https://img.shields.io/github/v/release/danni-cool/wechatbot-webhook) 6 | 7 | 8 | 9 | [🚢 Docker 镜像](https://hub.docker.com/repository/docker/dannicool/docker-wechatbot-webhook/general) | [🧑‍💻 Github](https://github.com/danni-cool/wechatbot-webhook)|[🔍 FAQ](https://github.com/danni-cool/wechatbot-webhook/issues/72) 10 |
11 | 12 | 开箱即用的微信webhook机器人,通过 http 接口调用即可实现微信消息的发送和接收 13 | 14 | ## ✨ Features 15 | 16 | - **推送消息** (发送文字 / 图片 / 文件) 17 | - 💬 支持消息单条 / 多条 / 群发 18 | - 🌃 消息 url 解析成文件发送 19 | - 📁 支持读文件发送 20 | 21 | - **接收消息**(文字 / 图片 / 语音 / 视频 / 文件 / 好友申请 / 公众号推文链接) 22 | - 🚗 单 API 收发消息(依赖收消息API,被动回复无需公网IP) 23 | - 🪧 登入掉线异常事件通知 24 | 25 | - **其他功能** 26 | - 🤖 支持 非掉线自动登录 27 | - ✈️ 支持 带鉴权 api 接口获取登陆二维码 28 | - 支持 [n8n](https://n8n.io/) 低码平台丝滑接入(webhook 节点) 29 | - 🚢 支持 docker 部署,兼容 `arm64` 和 `amd64` 30 | - ✍️ 支持 日志文件导出 31 | 32 | ### 1. 安装 33 | 34 | ```bash 35 | npm i wechatbot-webhook -g 36 | ``` 37 | 38 | ### 2. 运行 & 扫码 39 | 40 | ![](https://cdn.jsdelivr.net/gh/danni-cool/danni-cool@cdn/image/wechatbot-demo.gif) 41 | 42 | ```bash 43 | wxbot 44 | ``` 45 | 46 | #### 参数 47 | 48 | ```bash 49 | Options: 50 | -V, --version output the version number 51 | -r, --reload 想重新扫码时加该参数,不加默认记住上次登录状态 52 | -e, --edit 打开 .wechat_bot_env 配置文件,可以填写上报消息API等 53 | -h, --help display help for command 54 | ``` 55 | 56 | 57 | ### 3. 复制推消息 api 58 | 59 | 从命令行中复制推消息api,例如 http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN] 60 | 61 | ### 4. 使用以下结构发消息 62 | 63 | 从命令行中复制推消息新开个终端试试以下 curl,to, token字段值换成你要值 64 | 65 | ```bash 66 | curl --location 'http://localhost:3001/webhook/msg/v2?token=[YOUR_PERSONAL_TOKEN]' \ 67 | --header 'Content-Type: application/json' \ 68 | --data '{ "to": "测试昵称", data: { "content": "Hello World!" }}' 69 | ``` 70 | 71 | ## 🛠️ API 72 | 73 | [API Reference](https://github.com/danni-cool/docker-wechatbot-webhook#%EF%B8%8F-api) 74 | 75 | 76 | ## ⏫ 更新日志 77 | 78 | 更新内容参见 [CHANGELOG](https://github.com/danni-cool/docker-wechat-roomBot/blob/main/CHANGELOG.md) -------------------------------------------------------------------------------- /packages/cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path') 3 | const { program } = require('commander') 4 | const { version } = require('./package.json') 5 | const { exec } = require('child_process') 6 | const os = require('os') 7 | const fs = require('fs') 8 | const homeDirectory = os.homedir() 9 | const envFilePath = (process.env.homeEnvCfg = path.join( 10 | homeDirectory, 11 | './.wechat_bot_env' 12 | )) 13 | const memoryCardFile = (process.env.homeMemoryCardPath = path.join( 14 | homeDirectory, 15 | './loginSession.memory-card.json' 16 | )) 17 | 18 | program 19 | .name('wechatbot-webhook') 20 | .description( 21 | [ 22 | '给微信里加个 webhook 机器人', 23 | '项目地址:https://github.com/danni-cool/wechatbot-webhook' 24 | ].join('\n') 25 | ) 26 | .version(version) 27 | .option('-r, --reload', '想重新扫码时加该参数,不加默认记住上次登录状态') 28 | .option('-e, --edit', '打开 .wechat_bot_env 配置文件,可以填写上报消息API等') 29 | .parse() 30 | 31 | const options = program.opts() 32 | 33 | // 清空 memory-card.json 34 | if (options.reload) { 35 | if (!fs.existsSync(memoryCardFile)) { 36 | console.log('暂无登录缓存') 37 | } else { 38 | try { 39 | fs.unlinkSync(memoryCardFile) 40 | console.log('登录缓存已清空') 41 | } catch (err) { 42 | console.error(err) 43 | process.exit(0) 44 | } 45 | } 46 | } 47 | 48 | if (options.edit) { 49 | // 在 Windows 上,可以使用 'start' 命令; 在 macOS 上,使用 'open'; 在 Linux 上,使用 'xdg-open' 50 | const isWindows = process.platform === 'win32' 51 | const command = isWindows 52 | ? `explorer "${envFilePath}"` 53 | : `open "${envFilePath}" || xdg-open "${envFilePath}"` 54 | 55 | console.log(`执行命令: ${command}`) 56 | 57 | exec(command, (err) => { 58 | if (err) { 59 | console.error(`执行出错: ${err}`) 60 | return 61 | } 62 | console.log(`文件 ${envFilePath} 已在编辑器中打开`) 63 | }) 64 | 65 | process.exit(0) 66 | } 67 | 68 | require(`${path.join(__dirname, './preStart.js')}`) 69 | require(path.join(__dirname, './lib/bot.js')) 70 | -------------------------------------------------------------------------------- /packages/cli/lib/generateToken.js: -------------------------------------------------------------------------------- 1 | module.exports = (num = 12) => { 2 | const charset = 3 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~' 4 | let token = '' 5 | for (let i = 0; i < num; i++) { 6 | const randomIndex = Math.floor(Math.random() * charset.length) 7 | token += charset[randomIndex] 8 | } 9 | return token 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechatbot-webhook", 3 | "version": "2.8.2-beta.1", 4 | "description": "给微信里加个 webhook 机器人,支持docker部署", 5 | "keywords": [ 6 | "wechat", 7 | "bot", 8 | "webhook", 9 | "wechaty", 10 | "http-service" 11 | ], 12 | "main": "index.js", 13 | "files": [ 14 | "static", 15 | "lib", 16 | "preStart.js", 17 | ".env.example" 18 | ], 19 | "scripts": { 20 | "build": "node scripts/build.js" 21 | }, 22 | "bin": { 23 | "wxbot": "index.js" 24 | }, 25 | "engines": { 26 | "node": ">=18" 27 | }, 28 | "author": { 29 | "name": "danni-cool", 30 | "email": "contact@danni.cool" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/danni-cool/wechatbot-webhook.git" 35 | }, 36 | "lint-staged": { 37 | "*.{js,jsx,ts,tsx}": [ 38 | "eslint --fix", 39 | "prettier --write" 40 | ] 41 | }, 42 | "husky": { 43 | "hooks": { 44 | "pre-commit": "lint-staged" 45 | } 46 | }, 47 | "license": "MIT", 48 | "devDependencies": { 49 | "esbuild": "^0.19.10" 50 | }, 51 | "dependencies": { 52 | "commander": "^11.1.0", 53 | "wechaty": "^1.20.2", 54 | "wechaty-grpc": "^1.0.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/cli/preStart.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | // 给 shell 调用使用 3 | const fs = require('fs') 4 | const dotenv = require('dotenv') 5 | const path = require('path') 6 | const generateToken = require('./lib/generateToken') 7 | const sourceFile = path.join(__dirname, './.env.example') 8 | const envFilePath = process.env.homeEnvCfg 9 | const chalk = require('chalk') 10 | 11 | // 根据env.example 生成 .env 文件 12 | if (!fs.existsSync(envFilePath)) { 13 | // 如果不存在,则从 env.example 复制 14 | fs.copyFileSync(sourceFile, envFilePath) 15 | console.log(chalk.grey(`${envFilePath} file created`)) 16 | } 17 | 18 | // 读取 .env 文件内容 19 | const envContent = fs.readFileSync(envFilePath, 'utf-8').split('\n') 20 | 21 | // 解析 .env 文件内容 22 | const envConfig = dotenv.parse(envContent.join('\n')) 23 | 24 | // 无配置token,会默认生成一个token 25 | if (envConfig.LOCAL_LOGIN_API_TOKEN) return 26 | 27 | const token = generateToken() 28 | console.log( 29 | `写入初始化token:${chalk.green(token)} => ${chalk.cyan(envFilePath)} \n` 30 | ) 31 | 32 | envConfig.LOCAL_LOGIN_API_TOKEN = token // 添加或修改键值对 33 | 34 | // 生成新的 .env 文件内容,同时保留注释 35 | let newEnv = envContent 36 | .map((line) => { 37 | if (line.startsWith('#')) { 38 | // 保留注释 39 | return line 40 | } 41 | 42 | const [key] = line.split('=') 43 | if (envConfig[key] !== undefined) { 44 | // 更新已存在的键值对 45 | const updatedLine = `${key}=${envConfig[key]}` 46 | delete envConfig[key] // 从 envConfig 中移除已处理的键 47 | return updatedLine 48 | } 49 | 50 | return line 51 | }) 52 | .join('\n') 53 | 54 | // 将未在原始 .env 文件中的新键值对添加到文件末尾 55 | for (const [key, value] of Object.entries(envConfig)) { 56 | newEnv += `\n${key}=${value}` 57 | } 58 | 59 | // 写入新的 .env 文件内容 60 | fs.writeFileSync(envFilePath, newEnv) 61 | -------------------------------------------------------------------------------- /packages/cli/scripts/build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | fs.copyFileSync( 6 | path.join(__dirname, '../../../.env.example'), 7 | path.join(__dirname, '../.env.example') 8 | ) 9 | 10 | function copyDirSync(src, dest) { 11 | if (!src) { 12 | throw new Error('src is null or undefined'); 13 | } 14 | 15 | try { 16 | // 如果目标目录存在,先清空它 17 | if (fs.existsSync(dest)) { 18 | fs.rmSync(dest, { recursive: true, force: true }); 19 | } 20 | 21 | // 创建新的目标目录 22 | fs.mkdirSync(dest, { recursive: true }); 23 | 24 | // 复制文件 25 | fs.readdirSync(src).forEach(file => { 26 | const srcPath = path.join(src, file); 27 | const destPath = path.join(dest, file); 28 | if (fs.lstatSync(srcPath).isDirectory()) { 29 | copyDirSync(srcPath, destPath); 30 | } else { 31 | fs.copyFileSync(srcPath, destPath); 32 | } 33 | }); 34 | } catch (err) { 35 | console.error(err); 36 | throw err; 37 | } 38 | } 39 | 40 | copyDirSync( 41 | path.join(__dirname, '../../../src/static'), 42 | path.join(__dirname, '../static') 43 | ) 44 | 45 | esbuild 46 | .build({ 47 | entryPoints: ['../../main.js'], // 入口文件 48 | outfile: 'lib/bot.js', // 输出文件的路径和名称 49 | bundle: true, // 打包所有的依赖 50 | minify: true, // 压缩输出文件 51 | platform: 'node', // 指定目标平台为 Node.js 52 | external: ['wechaty', 'wechaty-grpc'] // 将 fs 和 path 模块标记为外部依赖 53 | }) 54 | .catch(() => process.exit(1)) 55 | -------------------------------------------------------------------------------- /patches/wechat4u@0.7.14.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/wechat.js b/lib/wechat.js 2 | index b00a1b4e21e1b8216689096618da08e911cfc79d..d64c450843a83af6e5b1962912e4eb94269f0494 100644 3 | --- a/lib/wechat.js 4 | +++ b/lib/wechat.js 5 | @@ -180,7 +180,9 @@ var Wechat = function (_WechatCore) { 6 | key: '_init', 7 | value: function _init() { 8 | var _this5 = this; 9 | - 10 | + 11 | + //HOTFIX: 每次初始化,必定要清空历史数据,不然在重新登录场景会出现多个id对应一个人 12 | + _this5.contacts = {} 13 | return this.init().then(function (data) { 14 | // this.getContact() 这个接口返回通讯录中的联系人(包括已保存的群聊) 15 | // 临时的群聊会话在初始化的接口中可以获取,因此这里也需要更新一遍 contacts 16 | -------------------------------------------------------------------------------- /patches/wechaty-puppet-wechat4u@1.14.13.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/cjs/src/puppet-wechat4u.js b/dist/cjs/src/puppet-wechat4u.js 2 | index 68a52e6eb60c6efa0436984ef4399b1cf38d03af..6d1ae5aa166f61c288b66ea9a8e0273777d22687 100644 3 | --- a/dist/cjs/src/puppet-wechat4u.js 4 | +++ b/dist/cjs/src/puppet-wechat4u.js 5 | @@ -353,6 +353,8 @@ class PuppetWechat4u extends PUPPET.Puppet { 6 | if (!this.getContactInterval) { 7 | this.getContactsInfo(); 8 | this.getContactInterval = setInterval(() => { 9 | + //fix: 修复登出了还一直请求 10 | + this.isLoggedIn && 11 | this.getContactsInfo(); 12 | }, 2000); 13 | } 14 | -------------------------------------------------------------------------------- /patches/wechaty-puppet@1.20.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/cjs/src/mixins/login-mixin.js b/dist/cjs/src/mixins/login-mixin.js 2 | index 01c9a9caea23816ebdd36398bb2cd1f4f0e85559..d273c203e0d41262559cc3c85543485b4affb4ea 100644 3 | --- a/dist/cjs/src/mixins/login-mixin.js 4 | +++ b/dist/cjs/src/mixins/login-mixin.js 5 | @@ -110,6 +110,8 @@ const loginMixin = (mixinBase) => { 6 | this.__currentUserId = undefined; 7 | resolve(); 8 | })); 9 | + // bugfix: 修复wechat4u并未真正登出的问题 10 | + this.wechat4u?.emit('logout'); 11 | } 12 | /** 13 | * @deprecated use `currentUserId` instead. (will be removed in v2.0) 14 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' -------------------------------------------------------------------------------- /scripts/preStart.js: -------------------------------------------------------------------------------- 1 | // 给 shell 调用使用 2 | const fs = require('fs') 3 | const path = require('path') 4 | const { generateToken } = require('../src/utils/index.js') 5 | const sourceFile = path.join(__dirname, '../.env.example') 6 | const destFile = path.join(__dirname, '../.env') 7 | const { logger } = require('../src/utils/log.js') 8 | 9 | // 根据env.example 生成 .env 文件 10 | if (!fs.existsSync(destFile)) { 11 | // 如果不存在,则从 env.example 复制 12 | fs.copyFileSync(sourceFile, destFile) 13 | logger.info('.env file created from .env.example') 14 | } 15 | 16 | // 读取 .env 文件内容 17 | const envContent = fs.readFileSync('.env', 'utf-8').split('\n') 18 | 19 | // 解析 .env 文件内容 20 | const envConfig = require('dotenv').parse(envContent.join('\n')) 21 | 22 | // 无配置token,会默认生成一个token 23 | if (envConfig.LOCAL_LOGIN_API_TOKEN) process.exit(0) // 0 表示正常退出 24 | 25 | const token = generateToken() 26 | logger.info( 27 | `检测未配置 LOGIN_API_TOKEN, 写入初始化值 LOCAL_LOGIN_API_TOKEN=${token} => .env \n` 28 | ) 29 | 30 | envConfig.LOCAL_LOGIN_API_TOKEN = token // 添加或修改键值对 31 | 32 | // 生成新的 .env 文件内容,同时保留注释 33 | let newEnv = envContent 34 | .map((line) => { 35 | if (line.startsWith('#')) { 36 | // 保留注释 37 | return line 38 | } 39 | 40 | const [key] = line.split('=') 41 | if (envConfig[key] !== undefined) { 42 | // 更新已存在的键值对 43 | const updatedLine = `${key}=${envConfig[key]}` 44 | delete envConfig[key] // 从 envConfig 中移除已处理的键 45 | return updatedLine 46 | } 47 | 48 | return line 49 | }) 50 | .join('\n') 51 | 52 | // 将未在原始 .env 文件中的新键值对添加到文件末尾 53 | for (const [key, value] of Object.entries(envConfig)) { 54 | newEnv += `\n${key}=${value}` 55 | } 56 | 57 | // 写入新的 .env 文件内容 58 | fs.writeFileSync('.env', newEnv) 59 | -------------------------------------------------------------------------------- /scripts/tsc-lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | npx tsc --noEmit -------------------------------------------------------------------------------- /src/config/const.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { PORT } = process.env 3 | 4 | const config = { 5 | /** 6 | * 上报消息的api群成员缓存多久(单位:ms) 7 | * @type {number} 8 | */ 9 | roomCachedTime: 1000 * 60 * 5, 10 | /** 服务启动地址 */ 11 | localUrl: `http://localhost:${PORT}` 12 | } 13 | 14 | const { homeEnvCfg, homeMemoryCardPath } = process.env 15 | const isCliEnv = Boolean(homeEnvCfg) 16 | const memoryCardName = isCliEnv ? homeMemoryCardPath : 'loginSession' 17 | const memoryCardPath = isCliEnv 18 | ? homeMemoryCardPath 19 | : path.join(__dirname, '../../', 'loginSession.memory-card.json') 20 | 21 | /** 22 | * Enum for msg type 23 | * @readonly 24 | * @enum {number} */ 25 | const MSG_TYPE_ENUM = { 26 | /** 未知 */ 27 | UNKNOWN: 0, 28 | /** 各种文件 */ 29 | ATTACHMENT: 1, 30 | /** 语音 */ 31 | VOICE: 2, 32 | /** 表情包 */ 33 | EMOTION: 5, 34 | /** 图片 */ 35 | PIC: 6, 36 | /** 文本 */ 37 | TEXT: 7, 38 | /** 公众号链接 */ 39 | MEDIA_URL: 14, 40 | /** 视频 */ 41 | VIDEO: 15, 42 | /** 好友邀请 or 好友通过消息(自定义类型) */ 43 | CUSTOM_FRIENDSHIP: 99, 44 | /** 系统消息类型 */ 45 | /** 登录事件 */ 46 | SYSTEM_EVENT_LOGIN: 1000, 47 | /** 登出事件 */ 48 | SYSTEM_EVENT_LOGOUT: 1001, 49 | /** 错误事件 */ 50 | SYSTEM_EVENT_ERROR: 1002, 51 | /** 推送通知事件 */ 52 | SYSTEM_EVENT_PUSH_NOTIFY: 1003 53 | } 54 | 55 | /** 56 | * Enum for system msg type (legacy) 57 | * @readonly 58 | * @enum {number} */ 59 | const legacySystemMsgStrMap = { 60 | login: MSG_TYPE_ENUM.SYSTEM_EVENT_LOGIN, 61 | logout: MSG_TYPE_ENUM.SYSTEM_EVENT_LOGOUT, 62 | error: MSG_TYPE_ENUM.SYSTEM_EVENT_ERROR, 63 | notifyOfRecvdApiPushMsg: MSG_TYPE_ENUM.SYSTEM_EVENT_PUSH_NOTIFY 64 | } 65 | 66 | /** 67 | * 系统消息类型映射表(外部) 68 | * @enum {string} */ 69 | const systemMsgEnumMap = { 70 | [MSG_TYPE_ENUM.SYSTEM_EVENT_LOGIN]: 'system_event_login', 71 | [MSG_TYPE_ENUM.SYSTEM_EVENT_LOGOUT]: 'system_event_logout', 72 | [MSG_TYPE_ENUM.SYSTEM_EVENT_ERROR]: 'system_event_error', 73 | [MSG_TYPE_ENUM.SYSTEM_EVENT_PUSH_NOTIFY]: 'system_event_push_notify' 74 | } 75 | 76 | const logOutUnofficialCodeList = [ 77 | '400 != 400', 78 | '1101 == 0', 79 | "'1101' == 0", 80 | '1205 == 0', 81 | '3 == 0', 82 | "'1102' == 0" /** 场景:没法发消息了 */, 83 | '-1 == 0' /** 场景:没法发消息 */, 84 | "'-1' == 0" /** 不确定,暂时两种都加上 */ 85 | ] 86 | 87 | module.exports = { 88 | MSG_TYPE_ENUM, 89 | config, 90 | legacySystemMsgStrMap, 91 | systemMsgEnumMap, 92 | memoryCardName, 93 | memoryCardPath, 94 | logOutUnofficialCodeList 95 | } 96 | -------------------------------------------------------------------------------- /src/config/log4jsFilter.js: -------------------------------------------------------------------------------- 1 | /** 只想纯console.log输出,但是不记录到日志文件的白名单 */ 2 | const logOnlyOutputWhiteList = [ 3 | '▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄\n' //扫二维码登录 4 | ] 5 | 6 | /** 不想输出的一些错误信息 */ 7 | const logDontOutpuptBlackList = [ 8 | '[https://github.com/node-fetch/node-fetch/issues/1167]' // form-data 提示的DepracationWarning,会被认为是错误提issue 9 | ] 10 | 11 | module.exports = { 12 | logOnlyOutputWhiteList, 13 | logDontOutpuptBlackList 14 | } 15 | -------------------------------------------------------------------------------- /src/config/valid.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * 推消息v2 发送消息主体结构校验规则 4 | * @param {pushMsgMainOpt} param0 5 | * @returns {{ key: 'to' | 'isRoom' | 'data', val: any, required: boolean, type: string|string[], unValidReason: string}[]} 6 | */ 7 | pushMsgV2ParentRules: ({ to, isRoom, data }) => [ 8 | { 9 | key: 'to', 10 | val: to, 11 | required: true, 12 | type: ['string', 'object'], 13 | unValidReason: '' 14 | }, 15 | { 16 | key: 'isRoom', 17 | val: isRoom, 18 | required: false, 19 | type: 'boolean', 20 | unValidReason: '' 21 | }, 22 | { 23 | key: 'data', 24 | val: data, 25 | required: true, 26 | type: ['object', 'array'], 27 | unValidReason: '' 28 | } 29 | ], 30 | /** 31 | * 推消息v2 发送消息data结构校验规则 32 | * @param {pushMsgUnitTypeOpt} param 33 | * @returns {{ key: 'type' | 'content', val: any, required: boolean, type: string, enum?: Array, unValidReason: string}[]} 34 | */ 35 | pushMsgV2ChildRules: ({ type, content }) => [ 36 | { 37 | key: 'type', 38 | val: type, 39 | required: false, 40 | type: 'string', 41 | enum: ['text', 'fileUrl'], 42 | unValidReason: '' 43 | }, 44 | { 45 | key: 'content', 46 | val: content, 47 | required: true, 48 | type: 'string', 49 | unValidReason: '' 50 | } 51 | ], 52 | 53 | /** 54 | * 推消息v1 发送文件校验规则 55 | * @param {pushFileMsgType} param0 56 | * @returns {{ key: 'to' | 'isRoom' | 'content', val: any, required: boolean, type: string|string[],enum?:(string|number)[], unValidReason: string}[]} 57 | */ 58 | pushFileMsgRules: ({ to, isRoom, content }) => [ 59 | { 60 | key: 'to', 61 | val: to, 62 | required: true, 63 | type: 'string', 64 | unValidReason: '' 65 | }, 66 | { 67 | key: 'isRoom', 68 | val: isRoom, 69 | required: false, 70 | enum: ['1', '0'], 71 | type: 'string', 72 | unValidReason: '' 73 | }, 74 | { 75 | key: 'content', 76 | val: content ?? 0, 77 | required: true, 78 | type: 'file', 79 | unValidReason: '' 80 | } 81 | ], 82 | 83 | /** 84 | * 推消息v1 发送消息校验规则 85 | * @param {pushMsgV1Type} param0 86 | * @returns {{ key: 'to' | 'type' | 'isRoom' | 'content', val: any, required: boolean, type: string|string[],enum?:(string|number)[], unValidReason: string}[]} 87 | */ 88 | pushMsgV1Rules: ({ to, type, content, isRoom }) => [ 89 | { 90 | key: 'to', 91 | val: to, 92 | required: true, 93 | type: ['string', 'object'], 94 | unValidReason: '' 95 | }, 96 | { 97 | key: 'type', 98 | val: type, 99 | required: false, 100 | type: 'string', 101 | enum: ['text', 'fileUrl'], 102 | unValidReason: '' 103 | }, 104 | { 105 | key: 'content', 106 | val: content, 107 | required: true, 108 | type: 'string', 109 | unValidReason: '' 110 | }, 111 | { 112 | key: 'isRoom', 113 | val: isRoom, 114 | required: false, 115 | type: 'boolean', 116 | unValidReason: '' 117 | } 118 | ] 119 | } 120 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./verifyToken.js'), 3 | ...require('./loginCheck.js'), 4 | } 5 | -------------------------------------------------------------------------------- /src/middleware/loginCheck.js: -------------------------------------------------------------------------------- 1 | /** 2 | * middleware of login Check 3 | * @param {import('hono').Context} c 4 | * @param {import('hono').Next} next 5 | */ 6 | module.exports.loginCheck = async (c, next) => { 7 | if (!c.bot.isLoggedIn) { 8 | c.status(401) 9 | return c.json({ 10 | success: false, 11 | message: 'you must login first' 12 | }) 13 | } 14 | 15 | await next() 16 | } 17 | -------------------------------------------------------------------------------- /src/middleware/verifyToken.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get token from query or referer 3 | * @param {import('hono').Context} c 4 | * @returns {string|null} 5 | */ 6 | const getToken = (c) => { 7 | // 首先检查当前请求的query参数 8 | const token = c.req.query('token') 9 | if (token) { 10 | return token 11 | } 12 | 13 | // 如果当前请求没有token,检查referer 14 | const referer = c.req.header('referer') 15 | if (referer) { 16 | try { 17 | const refererUrl = new URL(referer) 18 | return refererUrl.searchParams.get('token') 19 | } catch (error) { 20 | console.error('Invalid referer URL:', error) 21 | } 22 | } 23 | 24 | return null 25 | } 26 | 27 | 28 | /** 29 | * middleware of token verification 30 | * @param {import('hono').Context} c 31 | * @param {import('hono').Next} next 32 | */ 33 | module.exports.verifyToken = async (c, next) => { 34 | // const { token } = c.req.query() 35 | const token = getToken(c) 36 | 37 | if (token !== process.env.globalLoginToken) { 38 | c.status(401) 39 | return c.json({ 40 | success: false, 41 | message: 'Unauthorized: Access is denied due to invalid credentials.' 42 | }) 43 | } 44 | 45 | await next() 46 | } 47 | -------------------------------------------------------------------------------- /src/route/index.js: -------------------------------------------------------------------------------- 1 | const Middleware = require('../middleware/index') 2 | const fs = require('fs') 3 | const path = require('path') 4 | /** 5 | * 注册路由 6 | * @param {Object} param 7 | * @param {import('hono').Hono} param.app 8 | * @param {import('wechaty').Wechaty} param.bot 9 | */ 10 | module.exports = function registerRoute({ app, bot }) { 11 | /** 12 | * @param {import('hono').Context} ctx 13 | * @param {import('hono').Next} next 14 | */ 15 | const attachData = (ctx, next) => { 16 | ctx.bot = bot 17 | return next() 18 | } 19 | // 挂载wecahty实例到全局路由 20 | app.use('*', attachData) 21 | // 全局鉴权 22 | app.use(Middleware.verifyToken) 23 | 24 | // bugfix serveStatic cannot use a project root path, it actually based on cwd path 25 | app.get('/static/*', async (c) => { 26 | //获取*号的路径 27 | const filePath = path.join(__dirname, `../${c.req.path}`) 28 | return c.body(fs.readFileSync(filePath, { 29 | encoding: 'utf-8' 30 | })) 31 | }) 32 | 33 | require('./msg')({ app, bot }) 34 | require('./login')({ app, bot }) 35 | require('./resouces')({ app, bot }) 36 | } 37 | -------------------------------------------------------------------------------- /src/route/login.js: -------------------------------------------------------------------------------- 1 | const { streamSSE } = require('hono/streaming') 2 | 3 | /** 4 | * 注册login路由和处理上报逻辑 5 | * @param {Object} param 6 | * @param {import('hono').Hono} param.app 7 | * @param {import('wechaty').Wechaty} param.bot 8 | */ 9 | 10 | module.exports = function registerLoginCheck({ app, bot }) { 11 | let message = '' 12 | let success = false 13 | 14 | bot 15 | .on('scan', (qrcode) => { 16 | message = qrcode 17 | success = false 18 | }) 19 | .on('login', async (user) => { 20 | message = user + 'is already login' 21 | success = true 22 | }) 23 | .on('logout', () => { 24 | message = '' 25 | success = false 26 | }) 27 | .on('error', async () => { 28 | if (!bot.isLoggedIn) { 29 | success = false 30 | message = '' 31 | } 32 | }) 33 | 34 | app.get( 35 | '/login', 36 | /** @param {import('hono').Context} c */ 37 | async (c) => { 38 | // 登录成功的话,返回登录信息 39 | if (success) { 40 | return c.json({ 41 | success, 42 | message 43 | }) 44 | } else { 45 | // 构建带有iframe的HTML字符串 46 | const html = ` 47 | 48 | 49 | 50 | 51 | 52 | 扫码登录 53 | 54 | 59 | 60 | 61 |
62 | 79 | 80 | 81 | ` 82 | return c.html(html) 83 | } 84 | } 85 | ) 86 | 87 | app.get('/sse', async (c) => { 88 | return streamSSE(c, async (stream) => { 89 | while (true) { 90 | await stream.writeSSE({ 91 | event: !success ? 'qrcode' : 'login', 92 | data: message 93 | }) 94 | await stream.sleep(1000) 95 | } 96 | }) 97 | }) 98 | 99 | app.get( 100 | '/healthz', 101 | /** @param {import('hono').Context} c */ 102 | async (c) => { 103 | // 登录成功的话,返回登录信息 104 | if (success) { 105 | return c.text('healthy') 106 | } else { 107 | return c.text('unHealthy') 108 | } 109 | } 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /src/route/msg.js: -------------------------------------------------------------------------------- 1 | const Service = require('../service') 2 | const Utils = require('../utils/index.js') 3 | const rules = require('../config/valid') 4 | const middleware = require('../middleware') 5 | /** 6 | * 注册推消息钩子 7 | * @param {Object} payload 8 | * @param {import('hono').Hono} payload.app 9 | * @param {import('wechaty').Wechaty} payload.bot 10 | */ 11 | function registerPushHook({ app, bot }) { 12 | // 处理 POST 请求 V2 支持多发模式 13 | app.post('/webhook/msg/v2', middleware.loginCheck, async (c) => { 14 | let body 15 | 16 | try { 17 | body = await c.req.json() 18 | } catch (e) { 19 | return c.json({ 20 | success: false, 21 | message: 'request body is not a valid json! checkout please.' 22 | }) 23 | } 24 | 25 | const { success, task, message, status } = await Service.handleSendV2Msg( 26 | body, 27 | { bot } 28 | ) 29 | 30 | if (status !== 200) { 31 | c.status(status) 32 | } 33 | 34 | return c.json({ 35 | success, 36 | message, 37 | task 38 | }) 39 | }) 40 | 41 | // 处理 POST 请求 V1 只支持单发模式 42 | app.post('/webhook/msg', middleware.loginCheck, async (c) => { 43 | const formPayload = {} 44 | const payload = {} 45 | 46 | let body = null 47 | // 获取请求的 Content-Type 48 | const contentType = c.req.header('Content-Type') 49 | // 表单传文件(暂时只用来传文件) 50 | if (contentType && contentType.includes('multipart/form-data')) { 51 | try { 52 | body = await c.req.parseBody() 53 | } catch (e) { 54 | return c.json({ 55 | success: false, 56 | message: 'request body is not a valid form-data! checkout please.' 57 | }) 58 | } 59 | 60 | /** @type {any} */ 61 | formPayload.to = body.to 62 | /** @type {any} */ 63 | formPayload.isRoom = body.isRoom ?? '0' 64 | /** @type {'file'} */ 65 | formPayload.type = 'file' 66 | /** @type {any} */ 67 | formPayload.content = body.content ?? {} 68 | // 转化上传文件名中文字符但是被编码成 iso885910 的问题 69 | if (formPayload.content.name !== undefined) { 70 | formPayload.content.convertName = Utils.tryConvertCnCharToUtf8Char( 71 | formPayload.content.name 72 | ) 73 | } 74 | 75 | // 校验必填参数 76 | let unValidParamsStr = Utils.getUnValidParamsList( 77 | rules.pushFileMsgRules({ 78 | to: formPayload.to, 79 | /**@type {boolean} */ 80 | isRoom: formPayload.isRoom, 81 | content: formPayload.content.size 82 | }) 83 | ).map(({ unValidReason }) => unValidReason) 84 | 85 | formPayload.isRoom = Boolean( 86 | Number(formPayload.isRoom) 87 | ) /** "1" => true , "0" => false */ 88 | 89 | // 支持jsonLike传递备注名 {alias: 123} 90 | if (/{\s*"?'?alias"?'?\s*:[^}]+}/.test(formPayload.to)) { 91 | try { 92 | formPayload.to = Utils.parseJsonLikeStr(formPayload.to) 93 | } catch (e) { 94 | unValidParamsStr = [ 95 | 'to 参数发消息给备注名, json string 格式不正确' 96 | ].concat(unValidParamsStr) 97 | } 98 | } 99 | formPayload.unValidParamsStr = unValidParamsStr.join(',') 100 | 101 | // json 102 | } else { 103 | try { 104 | body = await c.req.json() 105 | } catch (e) { 106 | return c.json({ 107 | success: false, 108 | message: 'request body is not a valid json! checkout please.' 109 | }) 110 | } 111 | 112 | /** @type {string} */ 113 | payload.to = body.to 114 | /** @type {boolean} */ 115 | payload.isRoom = body.isRoom ?? false 116 | /** @type {'text'|'fileUrl'} */ 117 | payload.type = body.type ?? 'text' 118 | /** @type {string} */ 119 | payload.content = body.content 120 | 121 | // 校验必填参数 122 | payload.unValidParamsStr = Utils.getUnValidParamsList( 123 | rules.pushMsgV1Rules({ 124 | to: payload.to, 125 | type: payload.type, 126 | content: payload.content, 127 | isRoom: payload.isRoom 128 | }) 129 | ) 130 | .map(({ unValidReason }) => unValidReason) 131 | .join(',') 132 | } 133 | 134 | const { to, isRoom, unValidParamsStr, type, content } = 135 | contentType?.includes('multipart/form-data') ? formPayload : payload 136 | 137 | if (unValidParamsStr !== '') { 138 | return c.json({ 139 | success: false, 140 | message: `[${unValidParamsStr}] params is not valid, please checkout the api reference (https://github.com/danni-cool/wechatbot-webhook#%EF%B8%8F-api)` 141 | }) 142 | } 143 | 144 | const msgReceiver = 145 | isRoom === true 146 | ? await bot.Room.find({ topic: to }) 147 | : await bot.Contact.find( 148 | Utils.equalTrueType(to, 'object') ? to : { name: to } 149 | ) 150 | 151 | if (msgReceiver !== undefined) { 152 | const { success, error } = await Service.formatAndSendMsg({ 153 | isRoom, 154 | bot, 155 | type, 156 | content, 157 | msgInstance: msgReceiver 158 | }) 159 | 160 | return c.json({ 161 | success, 162 | message: `Message sent ${success ? 'successfully' : 'failed'}`, 163 | error 164 | }) 165 | } else { 166 | return c.json({ 167 | success: false, 168 | message: `${isRoom === true ? 'Room' : 'User'} is not found` 169 | }) 170 | } 171 | }) 172 | } 173 | 174 | module.exports = registerPushHook 175 | -------------------------------------------------------------------------------- /src/route/resouces.js: -------------------------------------------------------------------------------- 1 | const { downloadFile } = require('../utils/index') 2 | const middleware = require('../middleware') 3 | /** 4 | * 通过该接口代理获取微信静态资源 5 | * @param {Object} param 6 | * @param {import('hono').Hono} param.app 7 | * @param {import('wechaty').Wechaty} param.bot 8 | */ 9 | module.exports = function registerResourceAgentRoute({ app, bot }) { 10 | app.get( 11 | '/resouces', 12 | middleware.loginCheck, 13 | /** @param {import('hono').Context} c */ 14 | async (c) => { 15 | // 暂时不考虑其他puppet的情况 16 | const cookie = 17 | // @ts-ignore 私有变量 18 | bot.__puppet._memory.payload['\rpuppet\nPUPPET-WECHAT4U'].COOKIE 19 | const mediaUrl = c.req.query('media') 20 | const fullResouceUrl = `https://wx2.qq.com${decodeURIComponent( 21 | mediaUrl || '' 22 | )}` 23 | 24 | const { buffer, contentType } = await downloadFile(fullResouceUrl, { 25 | Cookie: Object.entries(cookie).reduce( 26 | (pre, next) => (pre += `${next[0]}=${next[1]};`), 27 | '' 28 | ) 29 | }) 30 | if (buffer) { 31 | contentType && c.header('Content-Type', contentType) 32 | return c.body(buffer) 33 | } else { 34 | c.status(404) 35 | return c.json({ success: false, message: '获取资源失败' }) 36 | } 37 | } 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/service/cache.js: -------------------------------------------------------------------------------- 1 | const cache = { 2 | room: new Map() 3 | } 4 | 5 | const cacheTool = { 6 | /** 7 | * Sets a value in the specified cache namespace with an optional expiration. 8 | * Before setting a value, it clears any expired keys. 9 | * 10 | * @param {keyof cache} namespace - The cache namespace to use. 11 | * @param {{id: string, value: any, expired: number}} payload - The payload containing id and value. 12 | */ 13 | set(namespace, { id, value, expired = 0 }) { 14 | // 每次增加数据前检查有无过期,要清理 15 | clearExpiredKeys(cache[namespace]) 16 | 17 | Object.defineProperty(value, '_expired', { 18 | value: Date.now() + expired, 19 | enumerable: false 20 | }) 21 | 22 | cache[namespace].set(id, value) 23 | }, 24 | /** 25 | * 从缓存里取数据,如果过期时间到了就返回undefined 26 | * @param {keyof cache} namespace 27 | * @param {string} id 28 | */ 29 | get(namespace, id) { 30 | if (!(namespace in cache)) { 31 | return 32 | } 33 | 34 | const result = cache[namespace].get(id) 35 | 36 | if (!result) return 37 | 38 | if (result._expired < Date.now()) { 39 | cache[namespace].delete(id) 40 | return 41 | } else { 42 | return result 43 | } 44 | }, 45 | 46 | /** 47 | * @param {keyof cache} namespace 48 | * @param {string} id 49 | */ 50 | del(namespace, id) { 51 | cache[namespace].delete(id) 52 | } 53 | } 54 | 55 | /** 56 | * @param {Map} cacheMap 57 | */ 58 | function clearExpiredKeys(cacheMap) { 59 | Object.keys(cacheMap).forEach((id) => { 60 | if (cacheMap.get(id)._expired < Date.now()) { 61 | cacheMap.delete(id) 62 | } 63 | }) 64 | } 65 | 66 | module.exports = cacheTool 67 | -------------------------------------------------------------------------------- /src/service/friendship.js: -------------------------------------------------------------------------------- 1 | const Utils = require('../utils/index') 2 | const { handleResSendMsg } = require('./msgSender') 3 | const { sendMsg2RecvdApi } = require('./msgUploader') 4 | const { FriendshipMsg } = require('../utils/msg.js') 5 | const { MSG_TYPE_ENUM } = require('../config/const') 6 | /** 7 | * @param {import('wechaty').Friendship} friendship 8 | * @param {import('wechaty/impls').WechatyInterface} bot 9 | */ 10 | const onRecvdFriendship = async (friendship, bot) => { 11 | const { Friendship } = bot 12 | 13 | let logMsg = 'received `friend` event from ' + friendship.contact().name() 14 | 15 | Utils.logger.info(logMsg) 16 | 17 | switch (friendship.type()) { 18 | // 收到好友邀请 19 | case Friendship.Type.Receive: 20 | try { 21 | const res = await sendMsg2RecvdApi( 22 | new FriendshipMsg({ 23 | name: friendship.contact().name(), 24 | hello: friendship.hello() 25 | }) 26 | ) 27 | 28 | await handleResSendMsg({ 29 | res, 30 | type: MSG_TYPE_ENUM.CUSTOM_FRIENDSHIP, 31 | bot, 32 | friendship 33 | }) 34 | } catch (error) { 35 | Utils.logger.error('尝试回应好友邀请时发生错误:', error) 36 | } 37 | 38 | break 39 | 40 | // 申请的好友通过验证 41 | case Friendship.Type.Confirm: 42 | Utils.logger.info( 43 | 'friend ship confirmed with ' + friendship.contact().name() 44 | ) 45 | break 46 | } 47 | } 48 | 49 | module.exports = { 50 | onRecvdFriendship 51 | } 52 | -------------------------------------------------------------------------------- /src/service/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./login.js'), 3 | ...require('./friendship.js'), 4 | ...require('./msgSender.js'), 5 | ...require('./msgUploader.js') 6 | } 7 | -------------------------------------------------------------------------------- /src/service/login.js: -------------------------------------------------------------------------------- 1 | const { LOGIN_API_TOKEN, LOCAL_LOGIN_API_TOKEN } = process.env 2 | 3 | module.exports = { 4 | // 得到 loginAPIToken 5 | initLoginApiToken() { 6 | if (!process.env.globalLoginToken) { 7 | process.env.globalLoginToken = LOGIN_API_TOKEN || LOCAL_LOGIN_API_TOKEN 8 | } 9 | 10 | return process.env.globalLoginToken 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/service/msgSender.js: -------------------------------------------------------------------------------- 1 | const Utils = require('../utils/index.js') 2 | const chalk = require('chalk') 3 | const { sendMsg2RecvdApi } = require('./msgUploader.js') 4 | const { MSG_TYPE_ENUM } = require('../config/const.js') 5 | const rules = require('../config/valid.js') 6 | 7 | /** 8 | * 根据v2群发逻辑整合归并状态方便调用和处理回调 9 | * @param {*} body 10 | * @param {{bot:import('wechaty/impls').WechatyInterface, skipReceiverCheck?:boolean, messageReceiver?:msgInstanceType}} bot 11 | * */ 12 | const handleSendV2Msg = async function ( 13 | body, 14 | { bot, skipReceiverCheck, messageReceiver } 15 | ) { 16 | let success = false 17 | let message = '' 18 | let status = 200 19 | 20 | const { state, task } = await handleSendV2MsgCollectInfo(body, { 21 | bot, 22 | skipReceiverCheck, 23 | messageReceiver 24 | }) 25 | 26 | //有部分 or 全部参数校验不通过 27 | if (state === 'reject') { 28 | success = false 29 | message = 30 | 'Some params is not valid, sending task is suspend, please checkout before sending message! You can find API reference right there (https://github.com/danni-cool/wechatbot-webhook#%EF%B8%8F-api)' 31 | 32 | // 全部发送成功 or 全部发送失败 or 部份发送成功 33 | } else if (state === 'pass') { 34 | // 全部发送成功 35 | if (task.successCount === task.totalCount) { 36 | success = true 37 | message = 'Message sent successfully' 38 | 39 | // 全部发送失败 40 | } else if (task.failedCount === task.totalCount) { 41 | success = false 42 | message = `All Messages (${task.totalCount}) sent failed, look up data of task for more detail` 43 | 44 | // 部份发送成功 45 | } else if (task.failedCount < task.totalCount) { 46 | success = true 47 | message = 48 | 'Part of the message sent successfully, look up data of task for more detail' 49 | } 50 | // 未知错误 51 | } else { 52 | status = 500 53 | success = false 54 | message = 'Unknown Server Error' 55 | } 56 | 57 | return { 58 | status, 59 | success, 60 | task, 61 | message 62 | } 63 | } 64 | 65 | /** 66 | * 处理v2版本推消息(群发or个人),返回状态 67 | * @param {*} body 68 | * @param {{bot:import('wechaty/impls').WechatyInterface, skipReceiverCheck?:boolean, messageReceiver?:msgInstanceType}} opt 69 | * @returns {Promise<{state:'pass' | 'reject' | '', task:msgV2taskType}>} 70 | */ 71 | const handleSendV2MsgCollectInfo = async function ( 72 | body, 73 | { bot, skipReceiverCheck = false, messageReceiver } 74 | ) { 75 | /**@type {'pass' | 'reject' | ''} */ 76 | let state = '' 77 | /**@type {msgV2taskType} */ 78 | const task = { 79 | successCount: 0, 80 | totalCount: 0, 81 | failedCount: 0, 82 | reject: [], 83 | sentFailed: [], 84 | notFound: [] 85 | } 86 | 87 | /** 88 | * 根据状态组装数据 89 | * @param {statusResolverStatus} status 90 | * @param {statusResolverOpt} param1 91 | */ 92 | function statusResolver( 93 | status, 94 | { rejectReasonObj, sendingTaskObj, notFoundObj, count = 0 } 95 | ) { 96 | switch (status) { 97 | case 'valid': 98 | task.totalCount += count 99 | break 100 | case 'unValidMsgParent': 101 | case 'unValidDataMsg': 102 | case 'RoomAliasNotSupported': 103 | task.totalCount += count 104 | // @ts-ignore 105 | task.reject.push(rejectReasonObj) 106 | // @ts-ignore 107 | rejectReasonObj.data 108 | ? // @ts-ignore 109 | (task.failedCount += rejectReasonObj?.data.length || 1) 110 | : task.failedCount++ 111 | state = 'reject' 112 | break 113 | 114 | case 'not found': 115 | // @ts-ignore 116 | task.notFound.push(notFoundObj) 117 | // @ts-ignore 118 | task.failedCount++ 119 | if (state !== 'reject') { 120 | state = 'pass' 121 | } 122 | break 123 | 124 | case 'SendingTaskDone': 125 | case 'batchSendingTaskDone': 126 | { 127 | // @ts-ignore 128 | const { failedTask, successCount } = sendingTaskObj 129 | 130 | if (failedTask) { 131 | task.sentFailed.push(failedTask) 132 | task.failedCount += failedTask.data.length 133 | } 134 | task.successCount += successCount 135 | if (state !== 'reject') { 136 | state = 'pass' 137 | } 138 | } 139 | break 140 | } 141 | } 142 | 143 | /** 144 | * 根据传入参数校验参数合法性,并且统计 145 | * @param {*} item 146 | */ 147 | function preCheckAndResolveStatus(item) { 148 | const { status, rejectReasonObj, count } = hadnleMsgV2PreCheck(item, { 149 | skipReceiverCheck 150 | }) 151 | 152 | statusResolver(status, { 153 | rejectReasonObj, 154 | count 155 | }) 156 | 157 | return status === 'valid' 158 | } 159 | 160 | if (Array.isArray(body)) { 161 | const ifParamAllValid = 162 | body.reduce((pre, next) => { 163 | const val = preCheckAndResolveStatus(next) 164 | 165 | if (pre !== false) { 166 | pre = val 167 | } 168 | 169 | return pre 170 | }, true) === true 171 | 172 | if (ifParamAllValid) { 173 | // 参数校验成功进入发送逻辑 174 | for (let item of body) { 175 | const { status, rejectReasonObj, sendingTaskObj, notFoundObj } = 176 | await handleMsg2Single(item, { bot, messageReceiver }) 177 | statusResolver(status, { 178 | rejectReasonObj, 179 | sendingTaskObj, 180 | notFoundObj 181 | }) 182 | } 183 | } 184 | } else if (!Array.isArray(body) && (await preCheckAndResolveStatus(body))) { 185 | const { status, rejectReasonObj, sendingTaskObj, notFoundObj } = 186 | await handleMsg2Single(body, { bot, messageReceiver }) 187 | statusResolver(status, { 188 | rejectReasonObj, 189 | sendingTaskObj, 190 | notFoundObj 191 | }) 192 | } 193 | 194 | return { 195 | state, 196 | task 197 | } 198 | } 199 | 200 | /** 发送消息前预先校验参数 201 | * @param {*} body 202 | * @param {{skipReceiverCheck:boolean}} [opt] 203 | */ 204 | const hadnleMsgV2PreCheck = function (body, opt) { 205 | const skipReceiverCheck = !!opt?.skipReceiverCheck 206 | /** @type {preCheckStatus} */ 207 | let status = 'valid' 208 | /** @type { msg2SingleRejectReason | null} */ 209 | let rejectReasonObj = null 210 | /**@type {standardV2Payload} */ 211 | const payload = { 212 | to: body.to, 213 | isRoom: body.isRoom, 214 | data: body.data, 215 | unValidParamsStr: '' 216 | } 217 | const count = Array.isArray(payload.data) ? payload.data.length : 1 218 | 219 | // 跳过发送主体校验(比如recvd单api回复消息,已经知道主体的情况下) 220 | if (!skipReceiverCheck) { 221 | // 校验必填参数 222 | payload.unValidParamsStr = Utils.getUnValidParamsList( 223 | rules.pushMsgV2ParentRules({ 224 | to: payload.to, 225 | isRoom: payload.isRoom ?? false, 226 | data: payload.data 227 | }) 228 | ) 229 | .map(({ unValidReason }) => unValidReason) 230 | .join(',') 231 | 232 | if (payload.unValidParamsStr) { 233 | status = 'unValidMsgParent' 234 | rejectReasonObj = { 235 | to: payload.to, 236 | ...(payload.isRoom !== undefined ? { isRoom: payload.isRoom } : {}), 237 | error: payload.unValidParamsStr 238 | } 239 | } 240 | } 241 | 242 | const { to, isRoom } = payload 243 | 244 | // 继续校验 payload.data的结构 245 | let unValidDataParamsStr 246 | // 检查每条消息的合法性 247 | if (Array.isArray(payload.data)) { 248 | /**@type { rejectReasonDataType[] } */ 249 | const UnValidReasonArr = [] 250 | //给省略了type的添加上type:text 251 | // @ts-ignore 252 | 253 | //检查每一条消息是否合法 254 | payload.data.forEach( 255 | /** 256 | * @param {pushMsgUnitTypeOpt} item 257 | */ 258 | (item) => { 259 | const { type, content } = item 260 | 261 | const error = getPushMsgUnitUnvalidStr({ 262 | type: type ?? 'text', 263 | content 264 | }) 265 | 266 | if (error) { 267 | let tempObj = { 268 | error, 269 | ...item 270 | } 271 | UnValidReasonArr.push(tempObj) 272 | } 273 | 274 | return item 275 | } 276 | ) 277 | 278 | //从payload.data 数组结构中有检测到不合法的结构 279 | if (UnValidReasonArr.length) { 280 | status = 'unValidDataMsg' 281 | rejectReasonObj = { 282 | ...(rejectReasonObj !== null ? rejectReasonObj : {}), 283 | to, 284 | isRoom, 285 | data: UnValidReasonArr 286 | } 287 | } 288 | } else { 289 | unValidDataParamsStr = getPushMsgUnitUnvalidStr({ 290 | type: payload.data.type ?? 'text', 291 | content: payload.data.content 292 | }) 293 | 294 | if (unValidDataParamsStr) { 295 | status = 'unValidDataMsg' 296 | 297 | rejectReasonObj = { 298 | ...(rejectReasonObj !== null ? rejectReasonObj : {}), 299 | to, 300 | isRoom, 301 | data: { 302 | ...payload.data, 303 | error: unValidDataParamsStr 304 | } 305 | } 306 | } 307 | } 308 | 309 | if (['unValidDataMsg', 'unValidMsgParent'].includes(status)) { 310 | return { 311 | status, 312 | rejectReasonObj, 313 | count 314 | } 315 | } 316 | 317 | if (isRoom === true) { 318 | if (typeof to === 'object') { 319 | status = 'RoomAliasNotSupported' 320 | return { 321 | status, 322 | rejectReasonObj: { 323 | to, 324 | isRoom, 325 | error: 326 | '群名只支持群昵称,please checkout the api reference (https://github.com/danni-cool/wechatbot-webhook#%EF%B8%8F-api)' 327 | }, 328 | count 329 | } 330 | } 331 | } 332 | 333 | //参数校验通过 334 | return { 335 | status, 336 | rejectReasonObj, 337 | count 338 | } 339 | } 340 | 341 | /** 342 | * 处理消息发给个人逻辑(单条/多条)(不校验参数) 343 | * @param {*} body 344 | * @param {{bot:import('wechaty/impls').WechatyInterface, messageReceiver?:msgInstanceType }} opt 345 | * @returns {Promise<{status: msg2SingleStatus, notFoundObj: msg2SingleRejectReason | null, rejectReasonObj: msg2SingleRejectReason|null, sendingTaskObj: sendingTaskType | null}>} 346 | */ 347 | const handleMsg2Single = async function (body, { bot, messageReceiver }) { 348 | /**@type {msg2SingleStatus} */ 349 | let status = '' 350 | /** @type { msg2SingleRejectReason | null} */ 351 | let rejectReasonObj = null 352 | /** @type {sendingTaskType | null} */ 353 | let sendingTaskObj = null 354 | /** @type {msg2SingleRejectReason | null} */ 355 | let notFoundObj = null 356 | 357 | const payload = { 358 | /** @type {string| {alias:string}} */ 359 | to: body.to, 360 | /** @type {boolean| undefined} */ 361 | isRoom: body.isRoom, 362 | /** @type {pushMsgUnitTypeOpt | pushMsgUnitTypeOpt[]} */ 363 | data: body.data /** { "type": "", content: "" } */, 364 | unValidParamsStr: '' 365 | } 366 | 367 | const { to, isRoom } = payload 368 | 369 | let msgReceiver = messageReceiver 370 | 371 | // msgReceiver 可以由外部提供 372 | if (!msgReceiver) { 373 | if (isRoom === true && typeof to === 'string') { 374 | msgReceiver = await bot.Room.find({ topic: to }) 375 | } else { 376 | msgReceiver = await bot.Contact.find( 377 | //@ts-expect-error wechaty 貌似未定义 {alias:string} 的场景 378 | Utils.equalTrueType(to, 'object') ? to : { name: to } 379 | ) 380 | } 381 | } 382 | 383 | if (msgReceiver) { 384 | if (Array.isArray(payload.data) && payload.data.length) { 385 | /**@type {(pushMsgUnitTypeOpt & {success?:boolean, error?:string})[]} */ 386 | let msgArr = payload.data 387 | for (let i = 0; i < msgArr.length; i++) { 388 | const { success, error } = await formatAndSendMsg({ 389 | bot, 390 | isRoom, 391 | type: msgArr[i].type || 'text', 392 | // @ts-ignore 393 | content: msgArr[i].content, 394 | msgInstance: msgReceiver 395 | }) 396 | msgArr[i].success = success 397 | if (!success) { 398 | msgArr[i].error = error.toString() 399 | } 400 | } 401 | 402 | const successCount = msgArr.filter(({ success }) => success).length 403 | const failedList = msgArr.filter(({ success }) => !success) 404 | 405 | status = 'batchSendingTaskDone' 406 | sendingTaskObj = { 407 | success: msgArr.some(({ success }) => success), //只要有消息发送成功就为true 408 | successCount: successCount, 409 | failedTask: failedList.length 410 | ? { 411 | to, 412 | ...(isRoom !== undefined ? { isRoom } : {}), 413 | data: msgArr 414 | .filter(({ success }) => !success) 415 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 416 | .map(({ success, ...otherObj }) => otherObj) 417 | } 418 | : null 419 | } 420 | } else if (!Array.isArray(payload.data)) { 421 | const { success } = await formatAndSendMsg({ 422 | isRoom, 423 | bot, 424 | type: payload.data.type ?? 'text', 425 | // @ts-ignore 426 | content: payload.data.content, 427 | msgInstance: msgReceiver 428 | }) 429 | 430 | status = 'SendingTaskDone' 431 | sendingTaskObj = { 432 | success: success, 433 | successCount: Number(success), 434 | failedTask: success 435 | ? null 436 | : { 437 | to, 438 | ...(isRoom !== undefined ? { isRoom } : {}), 439 | data: [payload.data] 440 | } 441 | } 442 | } 443 | } else { 444 | status = 'not found' 445 | notFoundObj = { 446 | to: payload.to, 447 | ...(isRoom === undefined ? {} : { isRoom }), 448 | error: `${isRoom ? 'Room' : 'User'} is not found`, 449 | data: payload.data 450 | } 451 | } 452 | 453 | return { 454 | status, 455 | rejectReasonObj, 456 | sendingTaskObj, 457 | notFoundObj 458 | } 459 | } 460 | 461 | /** 462 | * 根据传入规则校验消息发送单元 type 和 content 是否合法 463 | * @param {pushMsgUnitTypeOpt} param 464 | */ 465 | const getPushMsgUnitUnvalidStr = function ({ type, content }) { 466 | return Utils.getUnValidParamsList( 467 | rules.pushMsgV2ChildRules({ 468 | type, 469 | content 470 | }) 471 | ) 472 | .map(({ unValidReason }) => unValidReason) 473 | .join(',') 474 | } 475 | 476 | /** 477 | * 发送消息核心。这个处理程序将数据转换为标准格式,然后使用 wechaty 发送消息。 478 | * @type {{ 479 | * (payload:{ isRoom?: boolean,bot:import('wechaty/impls').WechatyInterface, type: 'text' | 'fileUrl'|'file', content: string| payloadFormFile, msgInstance: msgInstanceType }) : Promise<{success:boolean, error:any}>; 480 | * }} 481 | */ 482 | const formatAndSendMsg = async function ({ 483 | isRoom = false, 484 | bot, 485 | type, 486 | content, 487 | msgInstance 488 | }) { 489 | let success = false 490 | let error 491 | /** @type {msgStructurePayload} */ 492 | const emitPayload = { 493 | content: '', 494 | type: { 495 | text: MSG_TYPE_ENUM.TEXT, 496 | fileUrl: MSG_TYPE_ENUM.ATTACHMENT, 497 | file: MSG_TYPE_ENUM.ATTACHMENT 498 | }[type], 499 | type_display: { 500 | text: '消息', 501 | fileUrl: '文件', 502 | file: '文件' 503 | }[type], 504 | self: true, 505 | from: bot.currentUser, 506 | to: msgInstance, 507 | // @ts-ignore 此处一定是 roomInstance 508 | room: isRoom ? msgInstance : '' 509 | } 510 | 511 | try { 512 | switch (type) { 513 | // 纯文本 514 | case 'text': 515 | //@ts-expect-errors 重载不是很好使,手动判断 516 | await msgInstance.say(content) 517 | //@ts-expect-errors 重载不是很好使,手动判断 518 | emitPayload.content = content 519 | msgSenderCallback(emitPayload) 520 | break 521 | 522 | case 'fileUrl': { 523 | //@ts-expect-errors 重载不是很好使,手动判断 524 | const fileUrlArr = content.split(',') 525 | 526 | // 单文件 527 | if (fileUrlArr.length === 1) { 528 | //@ts-expect-errors 重载不是很好使,手动判断 529 | const file = await Utils.getMediaFromUrl(content) 530 | //@ts-expect-errors 重载不是很好使,手动判断 531 | emitPayload.content = file 532 | await msgInstance.say(file) 533 | msgSenderCallback(emitPayload) 534 | break 535 | } 536 | 537 | // 多个文件的情况 538 | for (let i = 0; i < fileUrlArr.length; i++) { 539 | const file = await Utils.getMediaFromUrl(fileUrlArr[i]) 540 | //@ts-expect-errors 重载不是很好使,手动判断 541 | emitPayload.content = file 542 | await msgInstance.say(file) 543 | msgSenderCallback(emitPayload) 544 | } 545 | break 546 | } 547 | // 文件 548 | case 'file': 549 | { 550 | //@ts-expect-errors 重载不是很好使,手动判断 551 | const file = await Utils.getBufferFile(content) 552 | await msgInstance.say(file) 553 | //@ts-expect-errors 重载不是很好使,手动判断 554 | emitPayload.content = file 555 | msgSenderCallback(emitPayload) 556 | } 557 | break 558 | default: 559 | throw new Error('发送消息 type 不能为空') 560 | } 561 | success = true 562 | } catch (/** @type {any} */ e) { 563 | error = e 564 | Utils.logger.error(e) 565 | } 566 | 567 | return { success, error } 568 | } 569 | 570 | /** 推消息api发送后 571 | * @param {msgStructurePayload} payload 572 | */ 573 | const msgSenderCallback = async (payload) => { 574 | Utils.logger.info( 575 | `调用 bot api 发送 ${payload.type_display} 给 ${chalk.blue(payload.to)}:`, 576 | typeof payload.content === 'object' 577 | ? payload.content._name ?? 'unknown file' 578 | : payload.content 579 | ) 580 | 581 | if (process.env.ACCEPT_RECVD_MSG_MYSELF !== 'true') return 582 | sendMsg2RecvdApi(new Utils.ApiMsg(payload)) 583 | } 584 | 585 | /** 586 | * 接受 Service.sendMsg2RecvdApi 的response 回调以便回复或作出其他动作 587 | * @param {Object} payload 588 | * @param {Response} [payload.res] 589 | * @param {import('wechaty/impls').WechatyInterface} payload.bot 590 | * @param {import('@src/config/const.js').MSG_TYPE_ENUM} payload.type 591 | * @param {import('wechaty').Friendship} [payload.friendship] 592 | * @param {msgInstanceType} [payload.msgInstance] 593 | */ 594 | const handleResSendMsg = async ({ 595 | res, 596 | bot, 597 | type, 598 | friendship, 599 | msgInstance 600 | }) => { 601 | // to 的逻辑 602 | // 个人 603 | // msgInstance.payload.name 604 | // 群名 605 | // msgInstance.payload.topic 606 | // 好友卡片 607 | // msgInstance.contact().name() 608 | 609 | let success, data, to, isRoom 610 | 611 | if (res?.ok) { 612 | const result = await res.json() 613 | 614 | if (!result) return 615 | 616 | success = result.success 617 | data = result.data 618 | } 619 | 620 | switch (type) { 621 | case MSG_TYPE_ENUM.CUSTOM_FRIENDSHIP: 622 | to = friendship?.contact().name() 623 | success === true 624 | ? //@ts-expect-errors 重载不是很好使,手动判断 625 | await friendship.accept() 626 | : Utils.logger.info( 627 | //@ts-expect-errors 重载不是很好使,手动判断 628 | `not auto accepted, because ${to}'s verify message is: ${friendship.hello()}` 629 | ) 630 | 631 | // 同意且包含回复信息 632 | if (success === true && data !== undefined) { 633 | await Utils.sleep(1000) 634 | recvdApiReplyHandler(data, { 635 | //@ts-expect-errors 重载不是很好使,手动判断 636 | msgInstance: friendship.contact(), 637 | bot, 638 | to 639 | }) 640 | } 641 | 642 | break 643 | 644 | default: 645 | //进入该分支一定有msgInstance,判断是为了让 ts happy 646 | if (success === true && data !== undefined && msgInstance) { 647 | await Utils.sleep(1000) 648 | //@ts-ignore 649 | isRoom = !!msgInstance.payload?.topic 650 | //@ts-ignore 651 | to = isRoom ? msgInstance.payload?.topic : msgInstance.payload.name 652 | recvdApiReplyHandler(data, { msgInstance, bot, to, isRoom }) 653 | } 654 | break 655 | } 656 | } 657 | 658 | /** 659 | * 处理消息回复api和加好友请求后的回复 660 | * @param {pushMsgUnitTypeOpt | pushMsgUnitTypeOpt[]} data 661 | * @param {{msgInstance:msgInstanceType, to?:string, isRoom?:boolean, bot:import('wechaty/impls').WechatyInterface}} opt 662 | */ 663 | const recvdApiReplyHandler = async (data, { msgInstance, bot, to, isRoom }) => { 664 | // 组装标准的请求结构 665 | 666 | const { success, task, message, status } = await handleSendV2Msg( 667 | { to, isRoom, data }, 668 | { skipReceiverCheck: true, bot, messageReceiver: msgInstance } 669 | ) 670 | 671 | sendMsg2RecvdApi( 672 | new Utils.SystemEvent({ 673 | event: 'notifyOfRecvdApiPushMsg', 674 | recvdApiReplyNotify: { 675 | success, 676 | task, 677 | message, 678 | status 679 | } 680 | }) 681 | ) 682 | } 683 | 684 | /** 685 | * 收消息钩子 686 | * @param {import('wechaty').Message} msg 687 | * @param {import('wechaty/impls').WechatyInterface} bot 688 | */ 689 | const onRecvdMessage = async (msg, bot) => { 690 | // 自己发的消息没有必要处理 691 | if (process.env.ACCEPT_RECVD_MSG_MYSELF !== 'true' && msg.self()) return 692 | 693 | handleResSendMsg({ 694 | res: await sendMsg2RecvdApi(msg), 695 | bot, 696 | type: msg.type(), 697 | msgInstance: msg 698 | }) 699 | } 700 | 701 | module.exports = { 702 | formatAndSendMsg, 703 | handleResSendMsg, 704 | onRecvdMessage, 705 | getPushMsgUnitUnvalidStr, 706 | handleSendV2Msg 707 | } 708 | -------------------------------------------------------------------------------- /src/service/msgUploader.js: -------------------------------------------------------------------------------- 1 | const Utils = require('../utils/index') 2 | const fetch = require('node-fetch-commonjs') 3 | const { config, systemMsgEnumMap } = require('../config/const') 4 | const FormData = require('form-data') 5 | const { LOCAL_RECVD_MSG_API, RECVD_MSG_API } = process.env 6 | const { MSG_TYPE_ENUM } = require('../config/const') 7 | const cacheTool = require('../service/cache') 8 | const cloneDeep = require('lodash.clonedeep') 9 | /** 10 | * 收到消息上报接受url 11 | * @typedef {{type:'text'|'fileUrl'}} baseMsgInterface 12 | * @param {extendedMsg} msg 13 | * @returns {Promise} recvdApiReponse 14 | */ 15 | async function sendMsg2RecvdApi(msg) { 16 | // 检测是否配置了webhookurl 17 | let webhookUrl 18 | /** 19 | * @param {string} key 20 | * @param {string|undefined} value 21 | */ 22 | const errorText = (key, value) => { 23 | Utils.logger.error( 24 | `配置参数 ${key}: ${value} <- 不符合 URL 规范, 该 API 将不会收到请求\n` 25 | ) 26 | } 27 | 28 | // 外部传入了以外部为准 29 | if (!['', undefined].includes(RECVD_MSG_API)) { 30 | webhookUrl = ('' + RECVD_MSG_API).startsWith('http') ? RECVD_MSG_API : '' 31 | webhookUrl === '' && errorText('RECVD_MSG_API', RECVD_MSG_API) 32 | // 无外部则用本地 33 | } else if (!['', undefined].includes(LOCAL_RECVD_MSG_API)) { 34 | webhookUrl = ('' + LOCAL_RECVD_MSG_API).startsWith('http') 35 | ? LOCAL_RECVD_MSG_API 36 | : '' 37 | webhookUrl === '' && errorText('LOCAL_RECVD_MSG_API', LOCAL_RECVD_MSG_API) 38 | } 39 | 40 | // 有webhookurl才发送 41 | if (!webhookUrl) return 42 | /** @type {roomInfoForUpload} */ 43 | //@ts-expect-errors 强制as配合 ts-expect-errors 实用更佳 44 | const roomInfo = msg.room() 45 | 46 | if (typeof roomInfo !== 'string' && typeof roomInfo !== 'undefined') { 47 | /**@type {import('wechaty/impls').ContactInterface[] & {_expired?: Number}} */ 48 | let roomMemberInfo = cacheTool.get('room', roomInfo.id) 49 | 50 | if (!roomMemberInfo) { 51 | roomMemberInfo = await roomInfo.memberAll() 52 | 53 | // 频繁获取群成员信息会导致该接口被封,群成员无变化时信息缓存5分钟(仅限上报api) 54 | // 过期是因为群成员自己离开是无通知的 55 | cacheTool.set('room', { 56 | id: roomInfo.id, 57 | value: roomMemberInfo, 58 | expired: config.roomCachedTime 59 | }) 60 | } 61 | roomInfo.payload.memberList = roomMemberInfo.map((item) => ({ 62 | // @ts-expect-error wechaty定义问题,数据在payload里 63 | avatar: Utils.getAssetsAgentUrl(item.payload.avatar), 64 | // @ts-expect-error wechaty定义问题,数据在payload里 65 | id: item.payload.id, 66 | // @ts-expect-error wechaty定义问题,数据在payload里 67 | name: item.payload.name, 68 | // @ts-expect-error wechaty定义问题,数据在payload里 69 | alias: item.payload.alias 70 | })) 71 | // we have memberList already 72 | if (roomInfo.payload && 'memberIdList' in roomInfo.payload) { 73 | //@ts-expect-errors 这里每次返回的都是新对象 74 | delete roomInfo.payload.memberIdList 75 | } 76 | } 77 | 78 | const source = { 79 | room: cloneDeep(roomInfo || {}), 80 | /** @type { import('wechaty').Message['to'] } */ 81 | // @ts-ignore 82 | to: cloneDeep(msg.to() || {}), 83 | from: cloneDeep(msg.talker() || {}) 84 | } 85 | 86 | // @ts-ignore 87 | if (source.to && source.to.payload?.avatar) { 88 | // @ts-ignore 89 | source.to.payload.avatar = Utils.getAssetsAgentUrl(source.to.payload.avatar) 90 | } 91 | 92 | // @ts-ignore 93 | if (source.from.payload?.avatar) { 94 | // @ts-ignore 95 | source.from.payload.avatar = Utils.getAssetsAgentUrl( 96 | // @ts-ignore 97 | source.from.payload.avatar 98 | ) 99 | } 100 | 101 | if (source.room.payload?.avatar) { 102 | source.room.payload.avatar = Utils.getAssetsAgentUrl( 103 | source.room.payload.avatar 104 | ) 105 | } 106 | 107 | // let passed = true 108 | /** @type {import('form-data')} */ 109 | const formData = new FormData() 110 | 111 | formData.append('source', JSON.stringify(source)) 112 | //@ts-expect-errors 自己加的私有属性 113 | formData.append('isSystemEvent', msg.isSystemEvent === true ? '1' : '0') 114 | 115 | // 有人@我 116 | const someoneMentionMe = 117 | msg.mentionSelf && 118 | (await msg.mentionSelf()) /** 原版@我,wechaty web版应该都是false */ 119 | formData.append('isMentioned', someoneMentionMe ? '1' : '0') 120 | 121 | // 判断是否是自己发送的消息 122 | formData.append('isMsgFromSelf', msg.self() ? '1' : '0') 123 | 124 | switch (msg.type()) { 125 | case MSG_TYPE_ENUM.ATTACHMENT: 126 | case MSG_TYPE_ENUM.VOICE: 127 | case MSG_TYPE_ENUM.PIC: 128 | case MSG_TYPE_ENUM.VIDEO: { 129 | // 视频 130 | formData.append('type', 'file') 131 | /**@type {import('file-box').FileBox} */ 132 | //@ts-expect-errors 这里msg一定是wechaty的msg 133 | const steamFile = msg.toFileBox ? await msg.toFileBox() : msg.content() 134 | 135 | let fileInfo = { 136 | // @ts-ignore 137 | ext: steamFile._name.split('.').pop() ?? '', 138 | // @ts-ignore 139 | mime: steamFile._mediaType ?? 'Unknown', 140 | // @ts-ignore 141 | filename: steamFile._name ?? 'UnknownFile' 142 | } 143 | 144 | formData.append( 145 | 'content', 146 | //@ts-expect-errors 需要用到私有属性 147 | steamFile.buffer /** 发送一个文件 */ ?? 148 | //@ts-expect-errors 需要用到私有属性 149 | steamFile.stream /** 同一个文件转发 */, 150 | { 151 | filename: fileInfo.filename, 152 | contentType: fileInfo.mime 153 | } 154 | ) 155 | break 156 | } 157 | // 分享的Url 158 | case MSG_TYPE_ENUM.MEDIA_URL: { 159 | const { payload } = await msg.toUrlLink() 160 | formData.append('type', 'urlLink') 161 | formData.append('content', JSON.stringify(payload)) 162 | break 163 | } 164 | 165 | // 纯文本 166 | case MSG_TYPE_ENUM.TEXT: 167 | formData.append('type', 'text') 168 | formData.append('content', msg.text()) 169 | break 170 | 171 | // 好友邀请消息(自定义消息type) 172 | case MSG_TYPE_ENUM.CUSTOM_FRIENDSHIP: 173 | formData.append('type', 'friendship') 174 | formData.append('content', msg.text()) 175 | break 176 | 177 | // 系统消息(用于上报状态) 178 | case MSG_TYPE_ENUM.SYSTEM_EVENT_LOGIN: 179 | case MSG_TYPE_ENUM.SYSTEM_EVENT_LOGOUT: 180 | case MSG_TYPE_ENUM.SYSTEM_EVENT_PUSH_NOTIFY: 181 | case MSG_TYPE_ENUM.SYSTEM_EVENT_ERROR: 182 | formData.append('type', systemMsgEnumMap[msg.type()]) 183 | formData.append('content', msg.text()) 184 | break 185 | 186 | // 其他统一当unknown处理 187 | case MSG_TYPE_ENUM.UNKNOWN: 188 | case MSG_TYPE_ENUM.EMOTION: // 自定义表情 189 | default: 190 | formData.append('type', 'unknown') 191 | formData.append('content', msg.text()) 192 | break 193 | } 194 | 195 | // if (!passed) return 196 | 197 | Utils.logger.info('starting fetching api: ' + webhookUrl) 198 | //@ts-expect-errors form-data 未定义的私有属性 199 | Utils.logger.debug('fetching payload:', formData._streams) 200 | /**@type {Response} */ 201 | let response 202 | 203 | try { 204 | //@ts-expect-error node-fetch-commonjs 无type 205 | response = await fetch(webhookUrl, { 206 | method: 'POST', 207 | body: formData 208 | }) 209 | 210 | if (!response?.ok) { 211 | Utils.logger.error( 212 | `HTTP error When trying to send Data to RecvdApi: ${response?.status}` 213 | ) 214 | } 215 | } catch (e) { 216 | Utils.logger.error('Error occurred when trying to send Data to RecvdApi', e) 217 | } 218 | 219 | //@ts-expect-errors 提前使用没问题 220 | return response 221 | } 222 | 223 | module.exports = { 224 | sendMsg2RecvdApi 225 | } 226 | -------------------------------------------------------------------------------- /src/static/qrcode.min.js: -------------------------------------------------------------------------------- 1 | var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | const { FileBox } = require('file-box') 2 | const MIME = require('mime') 3 | const { logger } = require('./log') 4 | const { URL } = require('url') 5 | 6 | /** 7 | * 下载媒体文件转化为Buffer 8 | * @param {string} fileUrl 9 | * @returns {Promise<{buffer?: Buffer, fileName?: string, fileNameAlias?: string, contentType?: null | string}>} 10 | */ 11 | const downloadFile = async (fileUrl, headers = {}) => { 12 | try { 13 | const response = await fetch(fileUrl, { headers }) 14 | 15 | if (response.ok) { 16 | const buffer = Buffer.from(await response.arrayBuffer()) 17 | // 使用自定义文件名,解决URL无文件后缀名时,文件被微信解析成不正确的后缀问题 18 | let { fileName, query } = getFileInfoFromUrl(fileUrl) 19 | let contentType = response.headers.get('content-type') 20 | 21 | // deal with unValid Url format like https://pangji-home.com/Fi5DimeGHBLQ3KcELn3DolvENjVU 22 | if (fileName === '') { 23 | // 有些资源文件链接是不会返回文件后缀的 例如 https://pangji-home.com/Fi5DimeGHBLQ3KcELn3DolvENjVU 其实是一张图片 24 | //@ts-expect-errors 不考虑无content-type的情况 25 | const extName = MIME.getExtension(contentType) 26 | fileName = `${Date.now()}.${extName}` 27 | } 28 | 29 | return { 30 | buffer, 31 | fileName, 32 | contentType, 33 | fileNameAlias: query?.$alias 34 | } 35 | } 36 | 37 | return {} 38 | } catch (error) { 39 | logger.error('Error downloading file:' + fileUrl, error) 40 | return {} 41 | } 42 | } 43 | 44 | /** 45 | * @typedef {{fileName: string, query: null | Record} } fileInfoObj 46 | * 从url中提取文件名 47 | * @param {string} url 48 | * @returns {fileInfoObj} 49 | * @example 参数 url 示例 50 | * valid: "http://www.baidu.com/image.png?a=1 => image.png" 51 | * notValid: "https://pangji-home.com/Fi5DimeGHBLQ3KcELn3DolvENjVU => ''" 52 | */ 53 | const getFileInfoFromUrl = (url) => { 54 | /** @type {fileInfoObj} */ 55 | let matchRes = { 56 | fileName: url.match(/.*\/([^/?]*)/)?.[1] || '', // fileName has string.string is Valid filename 57 | query: null 58 | } 59 | 60 | try { 61 | const urlObj = new URL(url) 62 | matchRes.query = Object.fromEntries(urlObj.searchParams) 63 | } catch (e) { 64 | // make ts happy 65 | } 66 | 67 | return matchRes 68 | } 69 | 70 | /** 71 | * 根据url下载文件并转化成FileBox的标准格式 72 | * @param {string} url 73 | * @returns {Promise} 74 | */ 75 | const getMediaFromUrl = async (url) => { 76 | const { buffer, fileName, fileNameAlias } = await downloadFile(url) 77 | //@ts-expect-errors buffer 解析是吧的情况 78 | return FileBox.fromBuffer(buffer, fileNameAlias || fileName) 79 | } 80 | 81 | /** 82 | * @typedef {payloadFormFile} formDataFileInterface 83 | * @param {formDataFileInterface} formDataFile 84 | * @returns 85 | */ 86 | const getBufferFile = async (formDataFile) => { 87 | const arrayBuffer = await formDataFile.arrayBuffer() 88 | return FileBox.fromBuffer( 89 | Buffer.from(arrayBuffer), 90 | formDataFile.convertName ?? formDataFile.name 91 | ) 92 | } 93 | 94 | /** 95 | * 96 | * @param {number} num 97 | * @returns {string} token 98 | */ 99 | const generateToken = (num = 12) => { 100 | const charset = 101 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~' 102 | let token = '' 103 | for (let i = 0; i < num; i++) { 104 | const randomIndex = Math.floor(Math.random() * charset.length) 105 | token += charset[randomIndex] 106 | } 107 | 108 | return token 109 | } 110 | 111 | /** 112 | * @param {string} jsonLikeStr 113 | * @returns {string} 114 | * @example jsonLikeStr 示例结构 115 | * `{"alias":123,'alias2': '123', alias3: 123}` => `{"alias":123,"alias2":"123", "asf":2}` 116 | */ 117 | const parseJsonLikeStr = (jsonLikeStr) => { 118 | const formatStr = jsonLikeStr 119 | .replace(/'?(\w+)'?\s*:/g, '"$1":') 120 | .replace(/:\s*'([^']+)'/g, ':"$1"') 121 | 122 | return JSON.parse(formatStr) 123 | } 124 | 125 | /** 126 | * 检测每个字符是否都可以被iso-8859-1表示,因为curl http1.1 在发送form-data时,文件名是中文的话会被编码成 iso-8859-1表示 127 | * @param {string} str 128 | * @returns {string} 129 | * @see https://github.com/danni-cool/wechatbot-webhook/issues/71 130 | */ 131 | function tryConvertCnCharToUtf8Char(str) { 132 | const isIso88591 = [...str].every((char) => { 133 | const codePoint = char.charCodeAt(0) 134 | return codePoint >= 0x00 && codePoint <= 0xff 135 | }) 136 | 137 | if (isIso88591) { 138 | // 假设原始编码是 ISO-8859-1,将每个字符转换为相应的字节 139 | const bytes = new Uint8Array(str.length) 140 | for (let i = 0; i < str.length; i++) { 141 | bytes[i] = str.charCodeAt(i) 142 | } 143 | 144 | // 使用 TextDecoder 将 ISO-8859-1 编码的字节解码为 UTF-8 字符串 145 | const decoder = new TextDecoder('UTF-8') 146 | return decoder.decode(bytes) 147 | } 148 | 149 | return str 150 | } 151 | 152 | /** 153 | * 创建并返回一个具有额外 resolve 和 reject 方法的 Promise 对象。 154 | * @returns {Promise & { resolve: (value: any) => void, reject: (reason?: any) => void }} 155 | */ 156 | function Defer() { 157 | /**@type {(value: any) => void} */ 158 | let res 159 | /**@type {(reason?: any) => void} */ 160 | let rej 161 | 162 | /** @type {Promise & { resolve: (value: any) => void, reject: (reason?: any) => void }} */ 163 | // @ts-expect-errors 没法完美定义类型,暂时忽略 164 | const promise = new Promise((resolve, reject) => { 165 | res = resolve 166 | rej = reject 167 | }) 168 | 169 | // @ts-expect-errors 没法完美定义类型,暂时忽略 170 | promise.resolve = res 171 | // @ts-expect-errors 没法完美定义类型,暂时忽略 172 | promise.reject = rej 173 | 174 | return promise 175 | } 176 | 177 | /** 178 | * @param {number} ms 179 | */ 180 | const sleep = async (ms) => { 181 | return await new Promise((resolve) => setTimeout(resolve, ms)) 182 | } 183 | 184 | /** 185 | * 删除登录缓存文件 186 | */ 187 | // const deleteMemoryCard = () => { 188 | // //@ts-expect-errors 必定是 pathlike 189 | // if (fs.existsSync(memoryCardPath)) { 190 | // //@ts-expect-errors 必定是 pathlike 191 | // fs.unlinkSync(memoryCardPath) 192 | // } 193 | // } 194 | 195 | module.exports = { 196 | ...require('./msg.js'), 197 | ...require('./nextTick.js'), 198 | ...require('./paramsValid.js'), 199 | ...require('./log.js'), 200 | ...require('./res'), 201 | downloadFile, 202 | getMediaFromUrl, 203 | getBufferFile, 204 | generateToken, 205 | parseJsonLikeStr, 206 | tryConvertCnCharToUtf8Char, 207 | sleep, 208 | Defer 209 | } 210 | -------------------------------------------------------------------------------- /src/utils/log.js: -------------------------------------------------------------------------------- 1 | const { 2 | logOnlyOutputWhiteList, 3 | logDontOutpuptBlackList 4 | } = require('../config/log4jsFilter') 5 | 6 | if (!process.env.homeEnvCfg) { 7 | const log4js = require('log4js') 8 | 9 | // 配置日志 10 | log4js.configure({ 11 | appenders: { 12 | out: { 13 | type: 'stdout', 14 | layout: { 15 | type: 'pattern', 16 | pattern: '%[[%d] [%p] - %m %]' 17 | } 18 | }, 19 | file: { 20 | type: 'dateFile', 21 | filename: 'log/app.log', 22 | pattern: 'yyyy-MM-dd', 23 | level: 'trace', 24 | alwaysIncludePattern: true, 25 | keepFileExt: true, 26 | layout: { 27 | type: 'pattern', 28 | pattern: '[%d] [%p] - %m' 29 | } 30 | }, 31 | logFilter: { 32 | type: 'logLevelFilter', 33 | appender: 'out', 34 | level: process.env.LOG_LEVEL || 'info' 35 | } 36 | }, 37 | categories: { 38 | default: { 39 | appenders: ['logFilter', 'file'], 40 | level: 'trace' 41 | } 42 | } 43 | }) 44 | 45 | /**@type {log4js.Logger} */ 46 | let logger = log4js.getLogger() 47 | const originalConsoleLog = console.log 48 | const originalConsoleWarn = console.warn 49 | const originalConsoleErr = console.error 50 | 51 | const proxyConsole = () => { 52 | // logger.level = logLevel 53 | /** 54 | * 希望排除在log4js里的console输出,即不希望打到日志里去或者显示异常 55 | * @param {any[]} args 56 | */ 57 | const whiteListConditionLog = (args) => { 58 | const arg0 = args?.[0] 59 | 60 | return logOnlyOutputWhiteList 61 | .map((str) => typeof arg0 === 'string' && arg0.includes(str)) 62 | .some(Boolean) 63 | } 64 | 65 | /** 66 | * 希望排除一些错误 67 | * @param {any[]} args 68 | * @returns {boolean} 69 | */ 70 | const blackListConditionError = (args) => { 71 | const arg0 = args?.[0] 72 | 73 | return logDontOutpuptBlackList.some((str) => arg0.includes(str)) 74 | } 75 | 76 | console.log = function (...args) { 77 | try { 78 | if (args?.[1] instanceof Error) { 79 | logger.error(...args) 80 | } else if (!whiteListConditionLog(args)) { 81 | logger.info(...args) // 将输出写入 Log4js 配置的文件 82 | } else { 83 | originalConsoleLog.apply(console, args) // 保持控制台输出 84 | } 85 | } catch (/**@type {any} **/ e) { 86 | originalConsoleLog.apply(console, args) 87 | throw new Error('log4js 记录 console.log 出错:', e) 88 | } 89 | } 90 | 91 | console.warn = function (...args) { 92 | try { 93 | if (!whiteListConditionLog(args)) { 94 | logger.warn(...args) // 将输出写入 Log4js 配置的文件 95 | } else { 96 | originalConsoleWarn.apply(console, args) 97 | } 98 | } catch (/**@type {any} **/ e) { 99 | originalConsoleWarn.apply(console, args) 100 | throw new Error('log4js 记录 console.warn 出错:', e) 101 | } 102 | } 103 | 104 | console.error = function (...args) { 105 | try { 106 | if (blackListConditionError(args)) return 107 | 108 | if (!whiteListConditionLog(args)) { 109 | logger.error(...args) // 将输出写入 Log4js 配置的文件 110 | } else { 111 | originalConsoleErr.apply(console, args) 112 | } 113 | } catch (/**@type {any} **/ e) { 114 | originalConsoleErr.apply(console, args) 115 | throw new Error('log4js 记录 console.error 出错:', e) 116 | } 117 | } 118 | } 119 | 120 | module.exports = { 121 | // @ts-ignore 122 | logger, 123 | proxyConsole 124 | } 125 | // cli环境使用console 126 | } else { 127 | // @ts-ignore 128 | module.exports = { 129 | proxyConsole() {}, 130 | logger: { 131 | /** 132 | * @param {*} payload 133 | */ 134 | debug: (...payload) => console.log.apply(console, payload), 135 | /** 136 | * @param {*} payload 137 | */ 138 | warn: (...payload) => console.warn.apply(console, payload), 139 | /** 140 | * @param {*} payload 141 | */ 142 | error: (...payload) => console.error.apply(console, payload), 143 | /** 144 | * @param {*} payload 145 | */ 146 | info: (...payload) => console.log.apply(console, payload), 147 | /** 148 | * @param {*} payload 149 | */ 150 | trace: (...payload) => console.log.apply(console, payload) 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/utils/msg.js: -------------------------------------------------------------------------------- 1 | const { MSG_TYPE_ENUM, legacySystemMsgStrMap } = require('../config/const') 2 | const { getAssetsAgentUrl } = require('./res') 3 | const cloneDeep = require('lodash.clonedeep') 4 | class CommonMsg { 5 | /** 6 | * @param {commonMsgPayload} payload 7 | */ 8 | constructor({ 9 | text, 10 | type, 11 | isSystemEvent = false, 12 | self = false, 13 | room = '', 14 | to, 15 | from = '', 16 | file = '' 17 | }) { 18 | this.t = type 19 | this.isSelf = self 20 | this.toInfo = to 21 | this.fromInfo = from 22 | this.fileInfo = file 23 | this.roomInfo = room 24 | this.payload = text 25 | /** @deprecated 已经废弃,但保留其旧版本逻辑的兼容性 */ 26 | this.isSystemEvent = isSystemEvent 27 | } 28 | 29 | toUrlLink() { 30 | return { 31 | payload: '' 32 | } 33 | } 34 | 35 | mentionSelf() { 36 | return false 37 | } 38 | 39 | type() { 40 | return this.t 41 | } 42 | 43 | text() { 44 | return this.payload 45 | } 46 | 47 | self() { 48 | return this.isSelf 49 | } 50 | 51 | room() { 52 | return this.roomInfo 53 | } 54 | 55 | content() { 56 | return this.fileInfo 57 | } 58 | 59 | to() { 60 | return this.toInfo 61 | } 62 | 63 | talker() { 64 | return this.fromInfo 65 | } 66 | } 67 | 68 | class ApiMsg extends CommonMsg { 69 | /** @param {msgStructurePayload } payload*/ 70 | constructor({ from, to, room = '', content = '', type, self = false }) { 71 | if (type === MSG_TYPE_ENUM.TEXT) { 72 | super({ 73 | from, 74 | to, 75 | room, 76 | // @ts-expect-error 此处一定是string 77 | text: content, 78 | type, 79 | self 80 | }) 81 | } else { 82 | super({ from, to, room, type, file: content, self }) 83 | } 84 | } 85 | } 86 | 87 | class TextMsg extends CommonMsg { 88 | /** 89 | * @param {string} text 90 | */ 91 | constructor(text) { 92 | super({ text, type: MSG_TYPE_ENUM.TEXT }) 93 | } 94 | } 95 | 96 | class FriendshipMsg extends CommonMsg { 97 | /** 98 | * @param {Record} payload 99 | */ 100 | constructor(payload) { 101 | super({ 102 | text: JSON.stringify(payload), 103 | type: MSG_TYPE_ENUM.CUSTOM_FRIENDSHIP 104 | }) 105 | } 106 | } 107 | 108 | class SystemEvent extends CommonMsg { 109 | /** 110 | * @param {systemEventPayload} payload 111 | */ 112 | constructor(payload) { 113 | const payloadClone = cloneDeep(payload) 114 | if (payloadClone.user?.payload) { 115 | payloadClone.user.payload.avatar = getAssetsAgentUrl( 116 | payloadClone.user.payload.avatar 117 | ) 118 | } 119 | 120 | super({ 121 | text: JSON.stringify(payloadClone), 122 | type: legacySystemMsgStrMap[payload.event], 123 | isSystemEvent: true 124 | }) 125 | } 126 | } 127 | 128 | module.exports = { 129 | CommonMsg, 130 | ApiMsg, 131 | TextMsg, 132 | FriendshipMsg, 133 | SystemEvent 134 | } 135 | -------------------------------------------------------------------------------- /src/utils/nextTick.js: -------------------------------------------------------------------------------- 1 | // 简化版 nextTick 函数,仅使用 Promise 2 | // credit: https://github.com/vuejs/vue/blob/main/src/core/util/next-tick.ts 3 | const { logger } = require('./log') 4 | /**@type {any[]} */ 5 | const callbacks = [] 6 | const freezedTickQueueMap = new Map() 7 | let pending = false 8 | let id = 0 9 | /** 10 | * 微任务回调 11 | * @param {(arg0:any) => any} [cb] 12 | * @param {{ctx?: any, freezed?:boolean, freezedTickId?:number}} [opt] 13 | * @returns 14 | */ 15 | const nextTick = async (cb, opt) => { 16 | /**@type {(value:any)=>void | undefined} */ 17 | let _resolve 18 | 19 | const { ctx, freezed, freezedTickId } = opt || {} 20 | 21 | // 将一个新的函数加入 callbacks 队列 22 | ;(freezed ? freezedTickQueueMap.get(freezedTickId) : callbacks).push(() => { 23 | if (typeof cb === 'function') { 24 | try { 25 | // 如果提供了回调函数,立即执行 26 | // @ts-expect-errors 此处不需要第二参数 27 | cb.call(ctx) 28 | } catch (e) { 29 | // 如果有错误,使用 handleError 处理 30 | handleError(e) 31 | } 32 | } else if (typeof _resolve === 'function') { 33 | // 如果没有回调函数,但有 _resolve 函数(由 Promise 提供),则调用它 34 | _resolve(ctx) 35 | } 36 | }) 37 | 38 | if (!pending && !freezed) { 39 | pending = true 40 | // 使用 Promise 来处理 callbacks 队列 41 | Promise.resolve().then(flushCallbacks) 42 | } 43 | 44 | // 如果没有提供回调函数,返回一个新的 Promise 45 | if (typeof cb !== 'function') { 46 | return await new Promise((resolve) => { 47 | _resolve = resolve 48 | }) 49 | } 50 | } 51 | 52 | function flushCallbacks() { 53 | pending = false 54 | // slice(0) 从数组的第一个元素开始复制,所以复制的是整个数组。 55 | // 在执行回调的过程中,新的回调可能会被添加到 callbacks 数组中。使用副本确保仅执行函数开始时已经存在的回调。 56 | const copies = callbacks.slice(0) 57 | callbacks.length = 0 58 | for (let i = 0; i < copies.length; i++) { 59 | copies[i]() 60 | } 61 | } 62 | 63 | /** 64 | * handleError 和其他辅助函数需要根据实际情况实现 65 | * @param {unknown} e 66 | */ 67 | function handleError(e) { 68 | logger.error('消息任务执行失败:', e) 69 | } 70 | 71 | //增加暂时不被执行的队列 72 | class messageQueue { 73 | constructor() { 74 | this.id = id++ 75 | freezedTickQueueMap.set(id, []) 76 | } 77 | 78 | /** 79 | * @param {(arg0:any) => any} cb 80 | */ 81 | push(cb) { 82 | nextTick(cb, { freezed: true, freezedTickId: this.id }) 83 | } 84 | 85 | flush() { 86 | const unfreezeQueue = freezedTickQueueMap.get(this.id) 87 | nextTick(() => { 88 | logger.info('开始发送队列消息') 89 | }) 90 | unfreezeQueue.forEach((/** @type {any} */ item) => { 91 | callbacks.push(item) 92 | }) 93 | 94 | freezedTickQueueMap.delete(this.id) 95 | } 96 | } 97 | 98 | module.exports = { 99 | nextTick, 100 | messageQueue 101 | } 102 | -------------------------------------------------------------------------------- /src/utils/paramsValid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {any} val 3 | * @param {string} expectType 4 | */ 5 | function equalTrueType(val, expectType) { 6 | return ( 7 | Object.prototype.toString.call(val).toLowerCase() === 8 | `[object ${expectType}]` 9 | ) 10 | } 11 | 12 | /** 13 | * @param {string} str 14 | */ 15 | function capitalizeFirstLetter(str) { 16 | return str.charAt(0).toUpperCase() + str.slice(1) 17 | } 18 | 19 | /** 20 | * 校验输入参数 21 | * @param {pushMsgValidPayload[]} arr 22 | * @returns {{unValidReason:string}[]|[]} 返回不通过校验的数组项,并填充上 unValidReason 的原因 23 | */ 24 | function getUnValidParamsList(arr) { 25 | return arr 26 | .map((item) => { 27 | const emptyVal = 28 | (typeof item.val === 'string' && item.val === '') || 29 | item.val === undefined 30 | const isFile = item.type === 'file' && item.val === 0 31 | 32 | // 必填非空校验 33 | if (item.required && emptyVal) { 34 | item.unValidReason = `${item.key} 不能为空` 35 | } 36 | 37 | //必填是文件(文件校验传入的size是否为0 38 | else if (item.required && isFile) { 39 | item.unValidReason = `${item.key} 上传的文件不能为空` 40 | } 41 | 42 | // 不管有无填值,类型是数组的情况,校验多个结构 e.g: type:[string, object]情况 43 | else if (equalTrueType(item.type, 'array')) { 44 | // @ts-expect-errors 此处已经做了array判断,some一定是数组 45 | item.unValidReason = item.type.some((type) => 46 | equalTrueType(item.val, type) 47 | ) 48 | ? '' 49 | : `${item.key} 的参数类型不是 ${item.type 50 | // @ts-expect-errors 此处已经做了array判断,some一定是数组 51 | .map((key) => capitalizeFirstLetter(key)) 52 | .join(' or ')}` 53 | } 54 | 55 | // 不管有无填值,纯类型的校验 56 | else if (item.type !== 'file' && typeof item.val !== item.type) { 57 | item.unValidReason = `${ 58 | item.key 59 | // @ts-expect-errors 此处已经是除了array 文件后的情况,期望是个普通类型,直接提示 60 | } 的参数类型不是 ${capitalizeFirstLetter(item.type)}` 61 | } 62 | 63 | // 前者通过,如果遇到要校验指定枚举值的情况 64 | if ( 65 | item.unValidReason === '' && 66 | Array.isArray(item.enum) && 67 | item.enum.length > 0 68 | ) { 69 | item.unValidReason = !item.enum.includes(item.val) 70 | ? `${item.key} 必须是 ${item.enum.join(' or ')}` 71 | : '' 72 | } 73 | 74 | return item 75 | }) 76 | .filter(({ unValidReason }) => unValidReason) 77 | } 78 | 79 | module.exports = { 80 | getUnValidParamsList, 81 | equalTrueType 82 | } 83 | -------------------------------------------------------------------------------- /src/utils/res.js: -------------------------------------------------------------------------------- 1 | const { 2 | config: { localUrl } 3 | } = require('../config/const') 4 | 5 | /** 6 | * 将相对资源路径转为代理获取资源路径 7 | * @param {string} relativePath 8 | * @returns {string} 9 | */ 10 | module.exports.getAssetsAgentUrl = (relativePath) => { 11 | if (!relativePath) return '' 12 | 13 | return `${localUrl}/resouces?media=${encodeURIComponent(relativePath)}` 14 | } 15 | -------------------------------------------------------------------------------- /src/wechaty/init.js: -------------------------------------------------------------------------------- 1 | const { version } = require('../../package.json') 2 | const { WechatyBuilder } = require('wechaty') 3 | const { SystemEvent } = require('../utils/msg.js') 4 | const Service = require('../service') 5 | const Utils = require('../utils/index') 6 | const chalk = require('chalk') 7 | const { 8 | memoryCardName, 9 | logOutUnofficialCodeList, 10 | config: { localUrl } 11 | } = require('../config/const') 12 | const token = Service.initLoginApiToken() 13 | const cacheTool = require('../service/cache') 14 | const bot = 15 | process.env.DISABLE_AUTO_LOGIN === 'true' 16 | ? WechatyBuilder.build() 17 | : WechatyBuilder.build({ 18 | name: memoryCardName 19 | }) 20 | 21 | module.exports = function init() { 22 | /** @type {import('wechaty').Contact} */ 23 | let currentUser 24 | let botLoginSuccessLastTime = false 25 | 26 | console.log(chalk.blue(`🤖 wechatbot-webhook v${version} 🤖`)) 27 | 28 | // 启动 Wechaty 机器人 29 | bot 30 | // 扫码登陆事件 31 | .on('scan', (qrcode) => { 32 | Utils.logger.info('✨ 扫描以下二维码以登录 ✨') 33 | require('qrcode-terminal').generate(qrcode, { small: true }) 34 | Utils.logger.info( 35 | [ 36 | 'Or Access the URL to login: ' + 37 | chalk.cyan(`${localUrl}/login?token=${token}`) 38 | ].join('\n') 39 | ) 40 | }) 41 | 42 | // 登陆成功事件 43 | .on('login', async (user) => { 44 | Utils.logger.info('🌱 ' + chalk.green(`User ${user} logged in`)) 45 | Utils.logger.info( 46 | '💬 ' + 47 | `你的推消息 api 为:${chalk.cyan( 48 | `${localUrl}/webhook/msg/v2?token=${token}` 49 | )}` 50 | ) 51 | Utils.logger.info( 52 | '📖 发送消息结构 API 请参考: ' + 53 | `${chalk.cyan( 54 | 'https://github.com/danni-cool/wechatbot-webhook?tab=readme-ov-file#%EF%B8%8F-api' 55 | )}\n` 56 | ) 57 | 58 | currentUser = user 59 | botLoginSuccessLastTime = true 60 | 61 | Service.sendMsg2RecvdApi(new SystemEvent({ event: 'login', user })).catch( 62 | (e) => { 63 | Utils.logger.error('上报login事件给 RECVD_MSG_API 出错', e) 64 | } 65 | ) 66 | }) 67 | 68 | // 登出事件 69 | .on('logout', async (user) => { 70 | /** bugfix: 重置登录会触发多次logout,但是上报只需要登录成功后登出那一次 */ 71 | if (!botLoginSuccessLastTime) return 72 | 73 | botLoginSuccessLastTime = false 74 | 75 | Utils.logger.info(chalk.red(`User ${user.toString()} logout`)) 76 | 77 | // 登出时给接收消息api发送特殊文本 78 | Service.sendMsg2RecvdApi( 79 | new SystemEvent({ event: 'logout', user }) 80 | ).catch((e) => { 81 | Utils.logger.error('上报 logout 事件给 RECVD_MSG_API 出错:', e) 82 | }) 83 | }) 84 | 85 | .on('room-topic', async (room, topic, oldTopic, changer) => { 86 | Utils.logger.info( 87 | `Room ${await room.topic()} topic changed from ${oldTopic} to ${topic} by ${changer.name()}` 88 | ) 89 | }) 90 | 91 | // 群加入 92 | .on('room-join', async (room, inviteeList, inviter) => { 93 | Utils.logger.info( 94 | `Room ${await room.topic()} ${inviter} invited ${inviteeList} to join this room` 95 | ) 96 | cacheTool.get('room', room.id) && cacheTool.del('room', room.id) 97 | }) 98 | 99 | // 有人离开群( If someone leaves the room by themselves, wechat will not notice other people in the room,) 100 | .on('room-leave', async (room, leaver) => { 101 | Utils.logger.info( 102 | `Room ${await room.topic()} ${leaver} leaved from this room` 103 | ) 104 | cacheTool.get('room', room.id) && cacheTool.del('room', room.id) 105 | }) 106 | 107 | // 收到消息事件 108 | .on('message', async (message) => { 109 | Utils.logger.info(`Message: ${message.toString()}`) 110 | Service.onRecvdMessage(message, bot).catch((e) => { 111 | Utils.logger.error('向 RECVD_MSG_API 上报 message 事件出错:', e) 112 | }) 113 | }) 114 | 115 | // 收到加好友请求事件 116 | .on('friendship', async (friendship) => { 117 | await Service.onRecvdFriendship(friendship, bot) 118 | }) 119 | 120 | // 各种出错事件 121 | .on('error', async (error) => { 122 | Utils.logger.error(`\n${chalk.red(error)}\n`) 123 | 124 | if (!bot.isLoggedIn) return 125 | 126 | // wechaty 未知的登出状态,处理异常错误后的登出上报 127 | if ( 128 | logOutUnofficialCodeList.some((item) => error.message.includes(item)) 129 | ) { 130 | await bot.logout() 131 | } 132 | 133 | // 发送error事件给接收消息api 134 | Service.sendMsg2RecvdApi( 135 | new SystemEvent({ event: 'error', error, user: currentUser }) 136 | ).catch((e) => { 137 | Utils.logger.error('上报 error 事件给 RECVD_MSG_API 出错:', e) 138 | }) 139 | }) 140 | 141 | bot.start().catch((e) => { 142 | Utils.logger.error('bot 初始化失败:', e) 143 | }) 144 | 145 | return bot 146 | } 147 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*", "lib/qrcode.min.js"], 3 | "exclude": ["packages/**/*"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@src/*": ["src/*"] 8 | }, 9 | "rootDir": "./", 10 | "target": "ESNext", 11 | "module": "CommonJS", 12 | "moduleResolution": "node", 13 | "allowJs": true, 14 | "checkJs": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "noEmit": true, 19 | "resolveJsonModule": true, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | type msgInstanceType = 2 | | import('wechaty').Message 3 | | import('wechaty').Room 4 | | import('wechaty/impls').ContactInterface 5 | 6 | type extendedMsg = 7 | | import('wechaty').Message 8 | | (import('@src/utils/msg').CommonMsg & { isSystemEvent: boolean }) 9 | 10 | type pushMsgValidPayload = { 11 | key: 12 | | 'to' 13 | | 'isRoom' 14 | | 'type' 15 | | 'data' 16 | | 'content' 17 | | 'data.type' 18 | | 'data.content' 19 | val: string | number 20 | required: boolean 21 | type: string | string[] 22 | enum?: Array 23 | unValidReason: string 24 | } 25 | 26 | type payloadFormFile = File & { convertName: string } 27 | 28 | type pushMsgUnitPayload = { type: 'text' | 'fileUrl'; content: string } 29 | 30 | type pushMsgUnitTypeOpt = { type?: 'text' | 'fileUrl'; content?: string } 31 | 32 | type toType = string | { alias: string } 33 | 34 | type pushMsgMainOpt = { 35 | to: toType 36 | isRoom?: boolean 37 | data: pushMsgUnitTypeOpt | pushMsgUnitTypeOpt[] 38 | } 39 | 40 | type pushMsgMain = { 41 | to: toType 42 | isRoom: boolean 43 | data: pushMsgUnitPayload 44 | } 45 | 46 | type pushFileMsgType = { 47 | to: toType 48 | isRoom: boolean 49 | content: File 50 | } 51 | 52 | type pushMsgV1Type = { 53 | to: toType 54 | isRoom: boolean 55 | content: string 56 | type: 'text' | 'fileUrl' 57 | } 58 | 59 | type msg2SingleStatus = 60 | | '' 61 | | 'SendingTaskDone' 62 | | 'batchSendingTaskDone' 63 | | 'not found' 64 | 65 | type preCheckStatus = 66 | | 'valid' 67 | | 'unValidDataMsg' 68 | | 'unValidMsgParent' 69 | | 'RoomAliasNotSupported' 70 | 71 | type statusResolverStatus = msg2SingleStatus | preCheckStatus 72 | 73 | type statusResolverOpt = { 74 | count?: number 75 | rejectReasonObj?: msg2SingleRejectReason | null 76 | sendingTaskObj?: sendingTaskType | null 77 | notFoundObj?: msg2SingleRejectReason | null 78 | } 79 | 80 | type rejectReasonDataType = pushMsgUnitTypeOpt & { 81 | error?: string 82 | } 83 | 84 | type msg2SingleRejectReason = { 85 | to: toType 86 | isRoom?: boolean | undefined 87 | error?: string 88 | data?: rejectReasonDataType | rejectReasonDataType[] 89 | } 90 | 91 | type failedTaskType = { 92 | to: toType 93 | isRoom?: boolean 94 | data: pushMsgUnitTypeOpt | pushMsgUnitTypeOpt[] | [] 95 | } 96 | 97 | type sendingTaskType = { 98 | success: boolean 99 | successCount: number 100 | failedTask: null | failedTaskType 101 | } 102 | 103 | type msgV2taskType = { 104 | totalCount: number 105 | successCount: number 106 | failedCount: number 107 | reject: msg2SingleRejectReason[] 108 | sentFailed: failedTaskType[] 109 | notFound: msg2SingleRejectReason[] 110 | } 111 | 112 | type standardV2Payload = { 113 | to: string | { alias: string } 114 | isRoom: boolean 115 | data: pushMsgUnitTypeOpt | pushMsgUnitTypeOpt[] 116 | unValidParamsStr: string 117 | } 118 | 119 | type systemEventPayload = { 120 | event: keyof typeof import('@src/config/const').legacySystemMsgStrMap 121 | user?: import('wechaty').Contact 122 | recvdApiReplyNotify?: { 123 | success: boolean 124 | task: msgV2taskType 125 | message: string 126 | status: number 127 | } 128 | error?: import('gerror').GError 129 | } 130 | 131 | type msgStructurePayload = { 132 | content: string | (import('file-box').FileBoxInterface & { _name: string }) 133 | type: number 134 | type_display: string 135 | isSystemEvent?: boolean 136 | self: boolean 137 | from: import('wechaty').Contact | '' 138 | to: msgInstanceType 139 | room: import('wechaty').Room | '' 140 | file?: '' | File 141 | } 142 | 143 | type commonMsgPayload = { 144 | text?: string 145 | type: import('@src/config/const').MSG_TYPE_ENUM 146 | isSystemEvent?: boolean 147 | self?: boolean 148 | from?: import('wechaty').Contact | '' 149 | to?: msgInstanceType 150 | room?: import('wechaty').Room | '' 151 | file?: string | (import('file-box').FileBoxInterface & { _name: string }) 152 | } 153 | 154 | type roomInfoForUpload = import('wechaty/impls').RoomInterface & { 155 | payload: { 156 | memberList: { 157 | id: string 158 | name: string 159 | alias: string | undefined 160 | avatar: string 161 | }[] 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /typings/hono.d.ts: -------------------------------------------------------------------------------- 1 | import { WechatyInterface } from 'wechaty/impls' 2 | 3 | declare module 'hono' { 4 | interface Context { 5 | bot: WechatyInterface 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /typings/lodash.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'lodash.clonedeep' { 2 | // 定义 cloneDeep 函数,接受任意类型的参数,并返回相同的类型 3 | function cloneDeep(value: T): T 4 | // commonjs 导出 5 | export = cloneDeep 6 | } 7 | --------------------------------------------------------------------------------