├── .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 |  
5 |  
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 | 
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)
其他类型
系统类型
- ✅ 登录(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 | [](https://star-history.com/#danni-cool/wechatbot-webhook&Date)
487 |
488 | ## Contributors
489 |
490 | Thanks to all our contributors!
491 |
492 | 
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 |  
5 |  
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 | 
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 |
--------------------------------------------------------------------------------