├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ └── npm.yml ├── .gitignore ├── .markdownlintrc ├── .travis.yml ├── .vscode └── settings.json ├── CODEOWNERS ├── LICENSE ├── NOTICE ├── README.md ├── commonjs ├── code-root.d.ts ├── code-root.js ├── code-root.spec.ts └── package.json ├── docs └── images │ ├── puppeteer-logo.png │ └── wechaty-puppet-wechat.png ├── examples └── ding-dong-bot.ts ├── package.json ├── scripts ├── generate-package-json.sh ├── npm-pack-testing.sh └── package-publish-config-tag.sh ├── src ├── bridge.spec.ts ├── bridge.ts ├── cjs.spec.ts ├── cjs.ts ├── config.ts ├── env-vars.ts ├── event.spec.ts ├── event.ts ├── firer.spec.ts ├── firer.ts ├── mod.ts ├── package-json.spec.ts ├── package-json.ts ├── puppet-wechat.spec.ts ├── puppet-wechat.ts ├── pure-function-helpers │ ├── is-type.ts │ ├── message-extname.ts │ ├── message-filename.ts │ ├── message-raw-payload-parser.ts │ ├── mod.ts │ ├── normalize-scan-status.spec.ts │ ├── normalize-scan-status.ts │ ├── parse-mention-id-list.spec.ts │ ├── parse-mention-id-list.ts │ ├── retry-policy.ts │ ├── web-message-type.ts │ └── xml.ts ├── typings.d.ts ├── web-schemas.ts └── wechaty-bro.js ├── tests ├── fixtures │ ├── inject-file.js │ └── smoke-testing.ts ├── puppeteer-attachment.spec.ts ├── puppeteer-contact.spec.ts ├── puppeteer-friendship.spec.ts ├── puppeteer-message.spec.ts ├── puppeteer-room.spec.ts └── puppeteer.spec.ts ├── tsconfig.cjs.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const rules = { 2 | } 3 | 4 | module.exports = { 5 | extends: '@chatie', 6 | rules, 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: NPM 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | strategy: 9 | matrix: 10 | os: 11 | - ubuntu-latest 12 | # - windows-latest 13 | # - macos-latest 14 | node-version: 15 | - 16 16 | 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | cache-dependency-path: package.json 26 | 27 | - name: Install Dependencies 28 | run: npm install 29 | 30 | - name: Test 31 | run: npm test 32 | 33 | pack: 34 | name: Pack 35 | needs: build 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | - uses: actions/setup-node@v2 40 | with: 41 | node-version: 16 42 | cache: npm 43 | cache-dependency-path: package.json 44 | 45 | - name: Install Dependencies 46 | run: npm install 47 | 48 | - name: Generate Package JSON 49 | run: ./scripts/generate-package-json.sh 50 | 51 | - name: Pack Testing 52 | run: ./scripts/npm-pack-testing.sh 53 | 54 | publish: 55 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v')) 56 | name: Publish 57 | needs: [build, pack] 58 | runs-on: ubuntu-latest 59 | steps: 60 | - uses: actions/checkout@v2 61 | - uses: actions/setup-node@v2 62 | with: 63 | node-version: 16 64 | registry-url: https://registry.npmjs.org/ 65 | cache: npm 66 | cache-dependency-path: package.json 67 | 68 | - name: Install Dependencies 69 | run: npm install 70 | 71 | - name: Generate Package JSON 72 | run: ./scripts/generate-package-json.sh 73 | 74 | - name: Set Publish Config 75 | run: ./scripts/package-publish-config-tag.sh 76 | 77 | - name: Build Dist 78 | run: npm run dist 79 | 80 | - name: Check Branch 81 | id: check-branch 82 | run: | 83 | if [[ ${{ github.ref }} =~ ^refs/heads/(main|v[0-9]+\.[0-9]+.*)$ ]]; then 84 | echo ::set-output name=match::true 85 | fi # See: https://stackoverflow.com/a/58869470/1123955 86 | - name: Is A Publish Branch 87 | if: steps.check-branch.outputs.match == 'true' 88 | run: | 89 | NAME=$(npx pkg-jq -r .name) 90 | VERSION=$(npx pkg-jq -r .version) 91 | if npx version-exists "$NAME" "$VERSION" 92 | then echo "$NAME@$VERSION exists on NPM, skipped." 93 | else npm publish 94 | fi 95 | env: 96 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 97 | - name: Is Not A Publish Branch 98 | if: steps.check-branch.outputs.match != 'true' 99 | run: echo 'Not A Publish Branch' 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | .idea 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | *.pid.lock 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | /dist/ 64 | /package-lock.json 65 | .DS_Store 66 | *.memory-card.json 67 | 68 | -------------------------------------------------------------------------------- /.markdownlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "no-trailing-punctuation": { 4 | "punctuation": ".,;:!" 5 | }, 6 | "MD013": false, 7 | "MD033": false, 8 | "first-line-h1": false, 9 | "no-hard-tabs": true, 10 | "no-trailing-spaces": { 11 | "br_spaces": 2 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | 5 | os: 6 | - linux 7 | - osx 8 | 9 | cache: 10 | directories: 11 | - node_modules 12 | 13 | script: 14 | - node --version 15 | - npm --version 16 | - echo "Testing Started ..." 17 | - npm test 18 | - echo "Testing Finished." 19 | 20 | stages: 21 | - test 22 | - pack 23 | - name: deploy 24 | if: (type = push) AND branch =~ ^(master|v\d+\.\d+)$ 25 | 26 | jobs: 27 | include: 28 | - stage: pack 29 | script: 30 | - echo "NPM Pack Testing Started ..." 31 | - npm version 32 | - ./scripts/generate-version.sh 33 | - npm run test:pack 34 | - echo "NPM Pack Testing Finished." 35 | 36 | - stage: deploy 37 | script: 38 | - echo "NPM Deploying Started ..." 39 | - npm version 40 | - ./scripts/generate-version.sh 41 | - ./scripts/package-publish-config-tag.sh 42 | - npm run dist 43 | - echo "NPM Building Finished." 44 | 45 | deploy: 46 | provider: npm 47 | email: zixia@zixia.net 48 | api_key: "$NPM_TOKEN" 49 | skip_cleanup: true 50 | on: 51 | all_branches: true 52 | 53 | notifications: 54 | webhooks: 55 | urls: 56 | - https://webhooks.gitter.im/e/41a19fbf1d54a04e5217 57 | on_success: always # options: [always|never|change] default: always 58 | on_failure: always # options: [always|never|change] default: always 59 | on_start: never # options: [always|never|change] default: always 60 | email: 61 | on_success: change 62 | on_failure: change 63 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | 4 | "editor.fontFamily": "Consolas, 'Courier New', monospace", 5 | "editor.fontLigatures": true, 6 | 7 | "editor.tokenColorCustomizations": { 8 | "textMateRules": [ 9 | { 10 | "scope": [ 11 | //following will be in italics (=Pacifico) 12 | "comment", 13 | // "entity.name.type.class", //class names 14 | "keyword", //import, export, return… 15 | "support.class.builtin.js", //String, Number, Boolean…, this, super 16 | "storage.modifier", //static keyword 17 | "storage.type.class.js", //class keyword 18 | "storage.type.function.js", // function keyword 19 | "storage.type.js", // Variable declarations 20 | "keyword.control.import.js", // Imports 21 | "keyword.control.from.js", // From-Keyword 22 | "entity.name.type.js", // new … Expression 23 | "keyword.control.flow.js", // await 24 | "keyword.control.conditional.js", // if 25 | "keyword.control.loop.js", // for 26 | "keyword.operator.new.js", // new 27 | ], 28 | "settings": { 29 | "fontStyle": "italic", 30 | }, 31 | }, 32 | { 33 | "scope": [ 34 | //following will be excluded from italics (My theme (Monokai dark) has some defaults I don't want to be in italics) 35 | "invalid", 36 | "keyword.operator", 37 | "constant.numeric.css", 38 | "keyword.other.unit.px.css", 39 | "constant.numeric.decimal.js", 40 | "constant.numeric.json", 41 | "entity.name.type.class.js" 42 | ], 43 | "settings": { 44 | "fontStyle": "", 45 | }, 46 | } 47 | ] 48 | }, 49 | 50 | "files.exclude": { 51 | "dist/": true, 52 | "doc/": true, 53 | "node_modules/": true, 54 | "package/": true, 55 | }, 56 | "alignment": { 57 | "operatorPadding": "right", 58 | "indentBase": "firstline", 59 | "surroundSpace": { 60 | "colon": [1, 1], // The first number specify how much space to add to the left, can be negative. The second number is how much space to the right, can be negative. 61 | "assignment": [1, 1], // The same as above. 62 | "arrow": [1, 1], // The same as above. 63 | "comment": 2, // Special how much space to add between the trailing comment and the code. 64 | // If this value is negative, it means don't align the trailing comment. 65 | } 66 | }, 67 | "editor.formatOnSave": false, 68 | "python.pythonPath": "python3", 69 | "eslint.validate": [ 70 | "javascript", 71 | "typescript", 72 | ], 73 | } 74 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/about-codeowners/ 3 | # 4 | 5 | /docs/ @lijiarui 6 | /src/bridge.*.ts @mukaiu @xjchengo 7 | /src/event.ts @binsee 8 | /src/firer.*.ts @xinbenlv 9 | /src/wechaty-bro.js @binsee @mukaiu @hczhcz @cherry-geqi @zhenyong 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright May 2018 Huan LI 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Wechaty, a Conversational AI RPA SDK for Chatbot 2 | Copyright 2016 Huan (李卓桓) and Wechaty Contributors. 3 | 4 | This product includes software developed at 5 | The Wechaty Organization (https://github.com/wechaty). 6 | 7 | This software contains code derived from the Stackoverflow, 8 | including various modifications by GitHub. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WECHATY-PUPPET-WECHAT 2 | 3 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-blue.svg)](https://github.com/wechaty/wechaty) 4 | [![NPM Version](https://badge.fury.io/js/wechaty-puppet-wechat.svg)](https://badge.fury.io/js/wechaty-puppet-wechat) 5 | [![npm (tag)](https://img.shields.io/npm/v/wechaty-puppet-wechat/next.svg)](https://www.npmjs.com/package/wechaty-puppet-wechat?activeTab=versions) 6 | [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-blue.svg)](https://www.typescriptlang.org/) 7 | [![NPM](https://github.com/wechaty/wechaty-puppet-wechat/workflows/NPM/badge.svg)](https://github.com/wechaty/wechaty-puppet-wechat/actions?query=workflow%3ANPM) 8 | [![ES Modules](https://img.shields.io/badge/ES-Modules-brightgreen)](https://github.com/Chatie/tsconfig/issues/16) 9 | 10 | [![Wechaty Puppet Puppeteer](docs/images/wechaty-puppet-wechat.png)](https://github.com/wechaty/wechaty-puppet-wechat) 11 | 12 | > Picture Credit: [https://www.forsbergplustwo.com](https://www.forsbergplustwo.com/blogs/news/pdf-generation-with-chrome-headless-in-ruby-using-puppeteer-on-heroku) 13 | 14 | Wechaty Puppet for WeChat 15 | 16 | - This repository is a sub module of Wechaty. See: 17 | - Source code before moved to here can be found at Wechaty repository: [Wechaty/src/puppet-puppeteer#a2c56e6](https://github.com/wechaty/wechaty/tree/a2c56e62642f9004243e3ad8e9c9d0b0dd1a4761/src/puppet-puppeteer) 18 | 19 | ## KNOWN LIMITATIONS 20 | 21 | 1. Solved by UOS. ~~WeChat Account that registered after 2017 mignt not be able to login Web Wechat, so it can not use PuppetWeChat with Wechaty. Please make sure your WeChat Account can be able to login by visiting ~~ 22 | 1. Web API can not create room and invite members to room since 2018. 23 | 1. Can not Receive/Send message from Work Wechat. 24 | 25 | If you want to break the above limitations, please consider to use a Wechaty Puppet other than using Web API, like [wechaty-puppet-padchat](https://github.com/lijiarui/wechaty-puppet-padchat). 26 | 27 | Learn more about the Puppet at [Wechaty wiki: Puppet](https://github.com/wechaty/wechaty/wiki/Puppet) 28 | 29 | ## Note for Developers in China 30 | 31 | Cause `storage.googleapis.com` is blocked in mainland china, you'd better config by following guide. 32 | 33 | ### 1. Linux & Mac 34 | 35 | ```shell 36 | PUPPETEER_DOWNLOAD_HOST=https://registry.npmmirror.com/mirrors npm install wechaty-puppet-wechat 37 | ``` 38 | 39 | ### 2. Windows 40 | 41 | ```shell 42 | SET PUPPETEER_DOWNLOAD_HOST=https://registry.npmmirror.com/mirrors npm install wechaty-puppet-wechat 43 | ``` 44 | 45 | Learn more from 46 | 47 | ## How to set puppeteer launchOptions? 48 | 49 | An example of adding executablePath to puppeteer.launch(): 50 | 51 | ```js 52 | const bot = new Wechaty({ 53 | name: 'mybot', 54 | puppet: 'wechaty-puppet-wechat', 55 | // ... 56 | puppetOptions: { 57 | endpoint: '' 58 | } 59 | }); 60 | 61 | // or 62 | const bot = new Wechaty({ 63 | name: 'mybot', 64 | puppet: 'wechaty-puppet-wechat', 65 | // ... 66 | puppetOptions: { 67 | launchOptions: { 68 | executablePath: '', 69 | // ... others launchOptions, see: https://github.com/GoogleChrome/puppeteer/blob/v1.18.1/docs/api.md#puppeteerlaunchoptions 70 | } 71 | } 72 | }); 73 | ``` 74 | 75 | We use [stealth](https://www.npmjs.com/package/puppeteer-extra-plugin-stealth) to make puppeteer more like a normal browser, if you want to disabled it, just set the `WECHATY_PUPPET_WECHAT_PUPPETEER_STEALTHLESS` environment variable to `1`. eg. `WECHATY_PUPPET_WECHAT_PUPPETEER_STEALTHLESS=1 ts-node your-bot.ts` 76 | 77 | [In rare cases](https://github.com/wechaty/matrix-appservice-wechaty/issues/78#issuecomment-882208894), we could meet some problem and see `Error: Could not find expected browser` when we start PuppetWeChatBridge and try to run `initBrowser()`. A easy way to solve this problem is set `WECHATY_PUPPET_WECHAT_ENDPOINT` environment variable to ``. eg. `WECHATY_PUPPET_WECHAT_ENDPOINT=/usr/bin/chromium-browser ts-node your-bot.ts` 78 | 79 | ## puppetOptions 80 | 81 | | Option | value | default value | description | 82 | | ------------- | :-----: | :-----------: | :-------------------------------------------------------------------------------------------------------------------------- | 83 | | token | string | - | your own uos extspam value, see [#127](https://github.com/wechaty/wechaty-puppet-wechat/issues/127) | 84 | | endpoint | string | - | puppeteerlaunchoptions.executablePath | 85 | | head | boolean | false | puppeteerlaunchoptions.headless | 86 | | launchOptions | object | - | same to [puppeteerlaunchoptions](https://github.com/GoogleChrome/puppeteer/blob/v1.18.1/docs/api.md#puppeteerlaunchoptions) | 87 | | stealthless | boolean | false | disabled [puppeteer-extra-plugin-stealth](https://www.npmjs.com/package/puppeteer-extra-plugin-stealth) or not | 88 | | uos | boolean | false | enable [UOS patch](https://github.com/wechaty/puppet-wechat/issues/127) or not | 89 | 90 | ## HISTORY 91 | 92 | ### master v1.12 (Mar 11, 2022) 93 | 94 | Release stable for the standard Web Protocol 95 | 96 | ### v1.0 (Oct 30, 2021) 97 | 98 | Release 1.0 of Wechaty Puppet for WeChat 99 | 100 | 1. v0.30 (Sep, 2021): ESM support. 101 | 102 | ### v0.28 (Apr 13, 2021) 103 | 104 | [重磅:绕过登录限制,wechaty免费版web协议重放荣光](https://wechaty.js.org/2021/04/13/wechaty-uos-web/) 105 | 106 | 1. Support UOS with puppeteer [#127](https://github.com/wechaty/wechaty-puppet-wechat/issues/127) 107 | 1. 添加uos请求头支持 [#129](https://github.com/wechaty/wechaty-puppet-wechat/pull/129) 108 | 109 | ### v0.26 (Mar 4, 2021) 110 | 111 | Rename NPM package name from `wechaty-puppet-puppeteer` to `wechaty-puppet-wechat` 112 | 113 | ### v0.24 (Feb 20, 2021) 114 | 115 | 1. Puppeteer from v5 to v7 116 | 1. Upgrade other deps 117 | 118 | ### v0.22 (Jun 18, 2020) 119 | 120 | Release a version before upgrade. 121 | 122 | ### v0.14 (Aug, 2018) 123 | 124 | 1. First Stable Release 125 | 1. Follow latest typings 126 | 127 | ### v0.2 (May, 2018) 128 | 129 | 1. Promote to solo package: `wechaty-puppet-puppeteer` 130 | 131 | ## FAQ 132 | 133 | ### 1. chrome-linux/chrome: error while loading shared libraries: libXXX.so.x: cannot open shared object file: No such file or directory 134 | 135 | You need to be able to run chrome in your Linux environment. If you are using Ubuntu Linux: 136 | 137 | - _error while loading shared libraries: libnss3.so: cannot open shared object file: No such file or directory_ 138 | - `apt install libnss3` 139 | - _error while loading shared libraries: libgbm.so.1: cannot open shared object file: No such file or directory_ 140 | - `apt install libgbm-dev` 141 | - _error while loading shared libraries: libxshmfence.so.1: cannot open shared object file: No such file or directory_ 142 | - `apt install libxshmfence-dev` 143 | - _error while loading shared libraries: libX11.so.6: cannot open shared object file: No such file or directory_ 144 | - `apt install libxss1` 145 | 146 | See: 147 | 148 | ## AUTHOR 149 | 150 | [Huan LI](http://linkedin.com/in/zixia) Tencent TVP of Chatbot \ 151 | 152 | 153 | 154 | profile for zixia on Stack Exchange, a network of free, community-driven Q&A sites 155 | 156 | 157 | ## COPYRIGHT & LICENSE 158 | 159 | - Code & Docs © 2016-now Huan LI \ 160 | - Code released under the Apache-2.0 License 161 | - Docs released under Creative Commons 162 | -------------------------------------------------------------------------------- /commonjs/code-root.d.ts: -------------------------------------------------------------------------------- 1 | export declare const codeRoot: string 2 | -------------------------------------------------------------------------------- /commonjs/code-root.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const codeRoot = path.join( 4 | __dirname, 5 | '..', 6 | ) 7 | 8 | module.exports = { 9 | codeRoot, 10 | } 11 | -------------------------------------------------------------------------------- /commonjs/code-root.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S ts-node --project tsconfig.cjs.json 2 | 3 | import { test } from 'tstest' 4 | 5 | import { codeRoot } from './code-root' 6 | 7 | test('CJS: codeRoot()', async t => { 8 | t.ok(codeRoot, 'should exist codeRoot') 9 | }) 10 | -------------------------------------------------------------------------------- /commonjs/package.json: -------------------------------------------------------------------------------- 1 | { "type": "commonjs" } 2 | -------------------------------------------------------------------------------- /docs/images/puppeteer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/puppet-wechat/09fedb147bebee1e28ac03c50debb7267af4b3d9/docs/images/puppeteer-logo.png -------------------------------------------------------------------------------- /docs/images/wechaty-puppet-wechat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/puppet-wechat/09fedb147bebee1e28ac03c50debb7267af4b3d9/docs/images/wechaty-puppet-wechat.png -------------------------------------------------------------------------------- /examples/ding-dong-bot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import * as PUPPET from 'wechaty-puppet' 20 | import { 21 | FileBox, 22 | } from 'file-box' 23 | 24 | import { PuppetWeChat } from '../src/mod.js' 25 | 26 | /** 27 | * 28 | * 1. Declare your Bot! 29 | * 30 | */ 31 | const puppet = new PuppetWeChat() 32 | 33 | /** 34 | * 35 | * 2. Register event handlers for Bot 36 | * 37 | */ 38 | puppet 39 | .on('logout', onLogout) 40 | .on('login', onLogin) 41 | .on('scan', onScan) 42 | .on('error', onError) 43 | .on('message', onMessage) 44 | 45 | /** 46 | * 47 | * 3. Start the bot! 48 | * 49 | */ 50 | puppet.start() 51 | .catch(async e => { 52 | console.error('Bot start() fail:', e as Error) 53 | await puppet.stop() 54 | process.exit(-1) 55 | }) 56 | 57 | /** 58 | * 59 | * 4. You are all set. ;-] 60 | * 61 | */ 62 | 63 | /** 64 | * 65 | * 5. Define Event Handler Functions for: 66 | * `scan`, `login`, `logout`, `error`, and `message` 67 | * 68 | */ 69 | function onScan (payload: PUPPET.payloads.EventScan) { 70 | if (payload.qrcode) { 71 | // Generate a QR Code online via 72 | // http://goqr.me/api/doc/create-qr-code/ 73 | const qrcodeImageUrl = [ 74 | 'https://wechaty.js.org/qrcode/', 75 | encodeURIComponent(payload.qrcode), 76 | ].join('') 77 | 78 | console.info(`[${payload.status}] ${qrcodeImageUrl}\nScan QR Code above to log in: `) 79 | } else { 80 | console.info(`[${payload.status}]`) 81 | } 82 | } 83 | 84 | function onLogin (payload: PUPPET.payloads.EventLogin) { 85 | console.info(`${payload.contactId} login`) 86 | puppet.messageSendText(payload.contactId, 'Wechaty login').catch(console.error) 87 | } 88 | 89 | function onLogout (payload: PUPPET.payloads.EventLogout) { 90 | console.info(`${payload.contactId} logouted`) 91 | } 92 | 93 | function onError (payload: PUPPET.payloads.EventError) { 94 | console.error('Bot error:', payload.data) 95 | /* 96 | if (bot.isLoggedIn) { 97 | bot.say('Wechaty error: ' + (e as Error).message).catch(console.error) 98 | } 99 | */ 100 | } 101 | 102 | /** 103 | * 104 | * 6. The most important handler is for: 105 | * dealing with Messages. 106 | * 107 | */ 108 | async function onMessage (payload: PUPPET.payloads.EventMessage) { 109 | const messagePayload = await puppet.messagePayload(payload.messageId) 110 | console.info(JSON.stringify(messagePayload)) 111 | 112 | if (messagePayload.type === PUPPET.types.Message.Text 113 | && /^ding$/i.test(messagePayload.text || '') 114 | ) { 115 | const conversationId = messagePayload.roomId || messagePayload.talkerId 116 | 117 | if (!conversationId) { 118 | throw new Error('no conversation id') 119 | } 120 | await puppet.messageSendText(conversationId, 'dong') 121 | 122 | const fileBox = FileBox.fromUrl('https://wechaty.github.io/wechaty/images/bot-qr-code.png') 123 | await puppet.messageSendFile(conversationId, fileBox) 124 | } 125 | } 126 | 127 | /** 128 | * 129 | * 7. Output the Welcome Message 130 | * 131 | */ 132 | const welcome = ` 133 | Puppet Version: ${puppet.version()} 134 | 135 | Please wait... I'm trying to login in... 136 | 137 | ` 138 | console.info(welcome) 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechaty-puppet-wechat", 3 | "version": "1.18.4", 4 | "description": "Puppet WeChat for Wechaty", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "import": "./dist/esm/src/mod.js", 9 | "require": "./dist/cjs/src/mod.js" 10 | } 11 | }, 12 | "typings": "./dist/esm/src/mod.d.ts", 13 | "engines": { 14 | "node": ">=16", 15 | "npm": ">=7" 16 | }, 17 | "scripts": { 18 | "build": "tsc && tsc -p tsconfig.cjs.json", 19 | "clean": "shx rm -fr dist/*", 20 | "dist": "npm-run-all clean build dist:commonjs dist:copy", 21 | "dist:commonjs": "jq -n \"{ type: \\\"commonjs\\\" }\" > dist/cjs/package.json", 22 | "dist:copy": "npm-run-all copy:esm copy:cjs", 23 | "copy:js": "shx cp src/*.js dist/src/", 24 | "copy:esm": "shx cp -R commonjs/ dist/esm/ && shx cp src/*.js dist/esm/src/", 25 | "copy:cjs": "shx cp -R commonjs/ dist/cjs/ && shx cp src/*.js dist/cjs/src/", 26 | "lint": "npm run lint:es && npm run lint:ts && npm run lint:md", 27 | "lint:es": "eslint --ignore-pattern fixtures/ \"src/**/*.ts\" \"tests/**/*.ts\"", 28 | "lint:md": "markdownlint README.md", 29 | "lint:ts": "tsc --isolatedModules --noEmit", 30 | "start": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" node examples/ding-dong-bot.ts", 31 | "test": "npm-run-all lint test:unit", 32 | "test:pack": "bash -x scripts/npm-pack-testing.sh", 33 | "test:unit": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" TAP_TIMEOUT=60 tap \"src/**/*.spec.ts\" \"tests/**/*.spec.ts\"" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/wechaty/puppet-wechat.git" 38 | }, 39 | "keywords": [ 40 | "chatie", 41 | "wechaty", 42 | "wechat", 43 | "chatbot", 44 | "bot", 45 | "sdk", 46 | "puppet", 47 | "weixin" 48 | ], 49 | "author": "Huan LI ", 50 | "license": "Apache-2.0", 51 | "bugs": { 52 | "url": "https://github.com/wechaty/puppet-wechat/issues" 53 | }, 54 | "devDependencies": { 55 | "@chatie/eslint-config": "^1.0.4", 56 | "@chatie/git-scripts": "^0.6.2", 57 | "@chatie/semver": "^0.4.7", 58 | "@chatie/tsconfig": "^4.6.3", 59 | "@types/md5": "^2.3.2", 60 | "@types/mime": "^2.0.3", 61 | "@types/qr-image": "^3.2.5", 62 | "@types/request": "^2.48.8", 63 | "@types/xml2js": "^0.4.9", 64 | "why-is-node-running": "^2.2.1" 65 | }, 66 | "peerDependencies": { 67 | "wechaty-puppet": "^1.18.3" 68 | }, 69 | "homepage": "https://github.com/wechaty/puppet-wechat#readme", 70 | "dependencies": { 71 | "cockatiel": "^2.0.2", 72 | "md5": "^2.3.0", 73 | "mime": "^3.0.0", 74 | "puppeteer": "^13.5.1", 75 | "puppeteer-extra": "^3.2.3", 76 | "puppeteer-extra-plugin-stealth": "^2.9.0", 77 | "qr-image": "^3.2.0", 78 | "request": "^2.88.2", 79 | "rx-queue": "^1.0.5", 80 | "rxjs": "^7.5.5", 81 | "state-switch": "^1.6.3", 82 | "watchdog": "^0.8.17", 83 | "xml2js": "^0.4.23" 84 | }, 85 | "files": [ 86 | "bin/", 87 | "dist/", 88 | "src/" 89 | ], 90 | "tap": { 91 | "check-coverage": false 92 | }, 93 | "publishConfig": { 94 | "access": "public", 95 | "tag": "next" 96 | }, 97 | "git": { 98 | "scripts": { 99 | "pre-push": "npx git-scripts-pre-push" 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /scripts/generate-package-json.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SRC_PACKAGE_JSON_TS_FILE='src/package-json.ts' 5 | 6 | [ -f ${SRC_PACKAGE_JSON_TS_FILE} ] || { 7 | echo ${SRC_PACKAGE_JSON_TS_FILE}" not found" 8 | exit 1 9 | } 10 | 11 | cat <<_SRC_ > ${SRC_PACKAGE_JSON_TS_FILE} 12 | /** 13 | * This file was auto generated from scripts/generate-version.sh 14 | */ 15 | import type { PackageJson } from 'type-fest' 16 | export const packageJson: PackageJson = $(cat package.json) as any 17 | _SRC_ 18 | -------------------------------------------------------------------------------- /scripts/npm-pack-testing.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=$(npx pkg-jq -r .version) 5 | 6 | if npx --package @chatie/semver semver-is-prod "$VERSION"; then 7 | NPM_TAG=latest 8 | else 9 | NPM_TAG=next 10 | fi 11 | 12 | npm run dist 13 | npm pack 14 | 15 | TMPDIR="/tmp/npm-pack-testing.$$" 16 | mkdir "$TMPDIR" 17 | mv ./*-*.*.*.tgz "$TMPDIR" 18 | cp tests/fixtures/smoke-testing.ts "$TMPDIR" 19 | 20 | cd $TMPDIR 21 | 22 | npm init -y 23 | npm install --production *-*.*.*.tgz \ 24 | @chatie/tsconfig@$NPM_TAG \ 25 | "wechaty-puppet@$NPM_TAG" \ 26 | "wechaty@$NPM_TAG" \ 27 | 28 | # 29 | # CommonJS 30 | # 31 | ./node_modules/.bin/tsc \ 32 | --target es6 \ 33 | --module CommonJS \ 34 | \ 35 | --moduleResolution node \ 36 | --esModuleInterop \ 37 | --lib esnext \ 38 | --noEmitOnError \ 39 | --noImplicitAny \ 40 | --skipLibCheck \ 41 | smoke-testing.ts 42 | 43 | echo 44 | echo "CommonJS: pack testing..." 45 | node smoke-testing.js 46 | 47 | # 48 | # ES Modules 49 | # 50 | 51 | # https://stackoverflow.com/a/59203952/1123955 52 | echo "`jq '.type="module"' package.json`" > package.json 53 | 54 | ./node_modules/.bin/tsc \ 55 | --target es2020 \ 56 | --module es2020 \ 57 | \ 58 | --moduleResolution node \ 59 | --esModuleInterop \ 60 | --lib esnext \ 61 | --noEmitOnError \ 62 | --noImplicitAny \ 63 | --skipLibCheck \ 64 | smoke-testing.ts 65 | 66 | echo 67 | echo "ES Module: pack testing..." 68 | node smoke-testing.js 69 | -------------------------------------------------------------------------------- /scripts/package-publish-config-tag.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=$(npx pkg-jq -r .version) 5 | 6 | if npx --package @chatie/semver semver-is-prod $VERSION; then 7 | npx pkg-jq -i '.publishConfig.tag="latest"' 8 | echo "production release: publicConfig.tag set to latest." 9 | else 10 | npx pkg-jq -i '.publishConfig.tag="next"' 11 | echo 'development release: publicConfig.tag set to next.' 12 | fi 13 | 14 | -------------------------------------------------------------------------------- /src/bridge.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { test } from 'tstest' 21 | 22 | // import sinon from 'sinon' 23 | 24 | import puppeteer from 'puppeteer' 25 | // import { spy } from 'sinon' 26 | 27 | import { 28 | MemoryCard, 29 | } from 'memory-card' 30 | 31 | // import { 32 | // log, 33 | // } from './config' 34 | // log.silly('BridgeTesting', 'import typings for Brolog') 35 | 36 | import Bridge from './bridge.js' 37 | 38 | const PUPPETEER_LAUNCH_OPTIONS = { 39 | args: [ 40 | '--disable-gpu', 41 | '--disable-setuid-sandbox', 42 | '--no-sandbox', 43 | ], 44 | headless: true, 45 | } 46 | 47 | test('PuppetWeChatBridge restart', async (t) => { 48 | const memory = new MemoryCard() 49 | await memory.load() 50 | 51 | let bridge 52 | 53 | let ttl = 3 54 | while (ttl-- > 0) { 55 | try { 56 | bridge = new Bridge({ memory }) 57 | await bridge.start() 58 | await bridge.stop() 59 | t.pass('Bridge instnace restart ttl #' + ttl) 60 | } catch (e) { 61 | t.fail('Bridge instance: ' + e) 62 | } 63 | } 64 | }) 65 | 66 | /* eslint indent: off */ 67 | test('preHtmlToXml()', async (t) => { 68 | const BLOCKED_HTML_ZH = [ 69 | '
',
 70 |       '<error>',
 71 |         '<ret>1203</ret>',
 72 |         '<message>当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过Windows微信、Mac微信或者手机客户端微信登录。</message>',
 73 |       '</error>',
 74 |     '
', 75 | ].join('') 76 | 77 | const BLOCKED_XML_ZH = [ 78 | '', 79 | '1203', 80 | '当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过Windows微信、Mac微信或者手机客户端微信登录。', 81 | '', 82 | ].join('') 83 | 84 | const memory = new MemoryCard() 85 | const bridge = new Bridge({ memory }) 86 | 87 | const xml = bridge.preHtmlToXml(BLOCKED_HTML_ZH) 88 | t.equal(xml, BLOCKED_XML_ZH, 'should parse html to xml') 89 | }) 90 | 91 | test('testBlockedMessage()', async t => { 92 | const BLOCKED_HTML_ZH = [ 93 | '
',
 94 |       '<error>',
 95 |         '<ret>1203</ret>',
 96 |         '<message>当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过手机客户端或者windows微信登录。</message>',
 97 |       '</error>',
 98 |     '
', 99 | ].join('') 100 | 101 | const BLOCKED_XML_ZH = ` 102 | 103 | 1203 104 | 当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。你可以通过手机客户端或者windows微信登录。 105 | 106 | ` 107 | const BLOCKED_TEXT_ZH = [ 108 | '当前登录环境异常。为了你的帐号安全,暂时不能登录web微信。', 109 | '你可以通过手机客户端或者windows微信登录。', 110 | ].join('') 111 | const BLOCKED_XML_EN = ` 112 | 113 | 1203 114 | For account security, newly registered WeChat accounts are unable to log in to Web WeChat. To use WeChat on a computer, use Windows WeChat or Mac WeChat at http://wechat.com 115 | 116 | ` 117 | const BLOCKED_TEXT_EN = [ 118 | 'For account security, newly registered WeChat accounts are unable to log in to Web WeChat.', 119 | ' To use WeChat on a computer, use Windows WeChat or Mac WeChat at http://wechat.com', 120 | ].join('') 121 | 122 | void t.test('not blocked', async t => { 123 | const memory = new MemoryCard() 124 | const bridge = new Bridge({ memory }) 125 | 126 | const msg = await bridge.testBlockedMessage('this is not xml') 127 | t.equal(msg, false, 'should return false when no block message') 128 | }) 129 | 130 | void t.test('html', async t => { 131 | const memory = new MemoryCard() 132 | const bridge = new Bridge({ memory }) 133 | 134 | const msg = await bridge.testBlockedMessage(BLOCKED_HTML_ZH) 135 | t.equal(msg, BLOCKED_TEXT_ZH, 'should get zh blocked message') 136 | }) 137 | 138 | void t.test('zh', async t => { 139 | const memory = new MemoryCard() 140 | const bridge = new Bridge({ memory }) 141 | 142 | const msg = await bridge.testBlockedMessage(BLOCKED_XML_ZH) 143 | t.equal(msg, BLOCKED_TEXT_ZH, 'should get zh blocked message') 144 | }) 145 | 146 | test('en', async t => { 147 | const memory = new MemoryCard() 148 | const bridge = new Bridge({ memory }) 149 | 150 | const msg = await bridge.testBlockedMessage(BLOCKED_XML_EN) 151 | t.equal(msg, BLOCKED_TEXT_EN, 'should get en blocked message') 152 | }) 153 | }) 154 | 155 | test('clickSwitchAccount()', async t => { 156 | const SWITCH_ACCOUNT_HTML = ` 157 |
158 | 159 |

Confirm login on mobile WeChat

160 | Log in 161 | Switch Account 162 |
163 | ` 164 | const memory = new MemoryCard() 165 | const bridge = new Bridge({ memory }) 166 | 167 | void t.test('switch account needed', async t => { 168 | const browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 169 | const page = await browser.newPage() 170 | 171 | await page.setContent(SWITCH_ACCOUNT_HTML) 172 | const clicked = await bridge.clickSwitchAccount(page) 173 | 174 | await page.close() 175 | await browser.close() 176 | 177 | t.equal(clicked, true, 'should click the switch account button') 178 | }) 179 | 180 | void t.test('switch account not needed', async t => { 181 | const browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 182 | const page = await browser.newPage() 183 | 184 | await page.setContent('

ok

') 185 | const clicked = await bridge.clickSwitchAccount(page) 186 | 187 | await page.close() 188 | await browser.close() 189 | 190 | t.equal(clicked, false, 'should no button found') 191 | }) 192 | }) 193 | 194 | test('WechatyBro.ding()', async t => { 195 | const memory = new MemoryCard(Math.random().toString(36).substr(2, 5)) 196 | await memory.load() 197 | const bridge = new Bridge({ 198 | memory, 199 | }) 200 | t.ok(bridge, 'should instanciated a bridge') 201 | 202 | try { 203 | await bridge.start() 204 | t.pass('should init Bridge') 205 | 206 | const retDing = await bridge.evaluate(() => { 207 | // eslint-disable-next-line 208 | return WechatyBro.ding() 209 | }) as string 210 | 211 | t.equal(retDing, 'dong', 'should got dong after execute WechatyBro.ding()') 212 | 213 | const retCode = await bridge.proxyWechaty('loginState') 214 | t.equal(typeof retCode, 'boolean', 'should got a boolean after call proxyWechaty(loginState)') 215 | 216 | await bridge.stop() 217 | t.pass('b.quit()') 218 | } catch (err) { 219 | t.fail('exception: ' + (err as Error).message) 220 | } finally { 221 | await memory.destroy() 222 | } 223 | }) 224 | -------------------------------------------------------------------------------- /src/cjs.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { 6 | codeRoot, 7 | } from './cjs.js' 8 | 9 | test('ESM: codeRoot', async t => { 10 | t.ok(codeRoot, 'should exists "codeRoot"') 11 | }) 12 | -------------------------------------------------------------------------------- /src/cjs.ts: -------------------------------------------------------------------------------- 1 | import codeRootPkg from '../commonjs/code-root.js' 2 | const codeRoot = codeRootPkg['codeRoot'] 3 | 4 | export { 5 | codeRoot, 6 | } 7 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { 4 | log, 5 | } from 'wechaty-puppet' 6 | import { 7 | FileBox, 8 | } from 'file-box' 9 | import qrImage from 'qr-image' 10 | 11 | import { packageJson } from './package-json.js' 12 | import type { Readable } from 'stream' 13 | 14 | const VERSION = packageJson.version || '0.0.0' 15 | 16 | function qrCodeForChatie (): FileBox { 17 | const CHATIE_OFFICIAL_ACCOUNT_QRCODE = 'http://weixin.qq.com/r/qymXj7DEO_1ErfTs93y5' 18 | const name = 'qrcode-for-chatie.png' 19 | const type = 'png' 20 | 21 | const qrStream = qrImage.image(CHATIE_OFFICIAL_ACCOUNT_QRCODE, { type }) 22 | return FileBox.fromStream(qrStream as Readable, name) 23 | } 24 | 25 | const MEMORY_SLOT = 'PUPPET_WECHAT' 26 | 27 | export { 28 | VERSION, 29 | log, 30 | MEMORY_SLOT, 31 | qrCodeForChatie, 32 | } 33 | -------------------------------------------------------------------------------- /src/env-vars.ts: -------------------------------------------------------------------------------- 1 | import { log } from 'wechaty-puppet' 2 | 3 | function WECHATY_PUPPET_WECHAT_PUPPETEER_HEAD (value?: boolean): boolean { 4 | if (typeof value !== 'undefined') { 5 | return value 6 | } 7 | return !!process.env['WECHATY_PUPPET_WECHAT_PUPPETEER_HEAD'] 8 | } 9 | 10 | const WECHATY_PUPPET_WECHAT_PUPPETEER_STEALTHLESS = (value?: boolean): boolean => { 11 | if (typeof value !== 'undefined') { 12 | return value 13 | } 14 | return !!process.env['WECHATY_PUPPET_WECHAT_PUPPETEER_STEALTHLESS'] 15 | } 16 | 17 | const WECHATY_PUPPET_WECHAT_ENDPOINT = (value?: string): undefined | string => { 18 | if (typeof value !== 'undefined') { 19 | return value 20 | } 21 | 22 | if (process.env['WECHATY_PUPPET_WECHAT_ENDPOINT']) { 23 | return process.env['WECHATY_PUPPET_WECHAT_ENDPOINT'] 24 | } 25 | 26 | if (process.env['WECHATY_PUPPET_PUPPETEER_ENDPOINT']) { 27 | log.warn('PuppetWeChat', 'WECHATY_PUPPET_PUPPETEER_ENDPOINT deprecated, use WECHATY_PUPPET_WECHAT_ENDPOINT instead.') 28 | return process.env['WECHATY_PUPPET_PUPPETEER_ENDPOINT'] 29 | } 30 | 31 | return undefined 32 | } 33 | 34 | const WECHATY_PUPPET_WECHAT_TOKEN = (value?: string): undefined | string => { 35 | if (typeof value !== 'undefined') { 36 | return value 37 | } 38 | return process.env['WECHATY_PUPPET_WECHAT_TOKEN'] 39 | } 40 | 41 | const WECHATY_PUPPET_WECHAT_PUPPETEER_UOS = (value?: boolean): boolean => { 42 | if (typeof value !== 'undefined') { 43 | return value 44 | } 45 | return /^(true|1)$/i.test(String(process.env['WECHATY_PUPPET_WECHAT_PUPPETEER_UOS'])) 46 | } 47 | 48 | export { 49 | WECHATY_PUPPET_WECHAT_PUPPETEER_HEAD, 50 | WECHATY_PUPPET_WECHAT_PUPPETEER_STEALTHLESS, 51 | WECHATY_PUPPET_WECHAT_ENDPOINT, 52 | WECHATY_PUPPET_WECHAT_TOKEN, 53 | WECHATY_PUPPET_WECHAT_PUPPETEER_UOS, 54 | } 55 | -------------------------------------------------------------------------------- /src/event.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Wechaty - https://github.com/chatie/wechaty 5 | * 6 | * @copyright 2016-2018 Huan LI 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import { test } from 'tstest' 22 | 23 | import { 24 | // Event, 25 | PuppetWeChat, 26 | } from './puppet-wechat.js' 27 | 28 | test('Puppet Puppeteer Event smoke testing', async (t) => { 29 | const puppet = new PuppetWeChat() 30 | 31 | try { 32 | await puppet.start() 33 | t.pass('should be inited') 34 | await puppet.stop() 35 | t.pass('should be quited') 36 | } catch (e) { 37 | t.fail('exception: ' + (e as Error).message) 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /src/event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import type { 20 | WatchdogFood, 21 | } from 'watchdog' 22 | import { GError } from 'gerror' 23 | 24 | import { 25 | log, 26 | } from './config.js' 27 | // import { 28 | // PuppetScanEvent, 29 | // } from 'wechaty-puppet' 30 | 31 | import { 32 | Firer, 33 | } from './firer.js' 34 | import type { 35 | PuppetWeChat, 36 | } from './puppet-wechat.js' 37 | import { 38 | WebMessageRawPayload, 39 | WebMessageType, 40 | } from './web-schemas.js' 41 | 42 | import { 43 | normalizeScanStatus, 44 | } from './pure-function-helpers/normalize-scan-status.js' 45 | 46 | export const Event = { 47 | onDing, 48 | 49 | onLog, 50 | onLogin, 51 | onLogout, 52 | 53 | onMessage, 54 | onScan, 55 | onUnload, 56 | 57 | } 58 | 59 | function onDing ( 60 | this: PuppetWeChat, 61 | data: any, 62 | ): void { 63 | log.silly('PuppetWeChatEvent', 'onDing(%s)', data) 64 | this.emit('heartbeat', { data }) 65 | } 66 | 67 | async function onScan ( 68 | this : PuppetWeChat, 69 | // Do not use PuppetScanPayload at here, use { code: number, url: string } instead, 70 | // because this is related with Browser Hook Code: 71 | // wechaty-bro.js 72 | payloadFromBrowser : { code: number, url: string }, 73 | ): Promise { 74 | log.verbose('PuppetWeChatEvent', 'onScan({code: %d, url: %s})', payloadFromBrowser.code, payloadFromBrowser.url) 75 | 76 | // if (this.state.inactive()) { 77 | // log.verbose('PuppetWeChatEvent', 'onScan(%s) state.inactive()=%s, NOOP', 78 | // payload, this.state.inactive()) 79 | // return 80 | // } 81 | 82 | this.scanPayload = { 83 | qrcode: payloadFromBrowser.url, 84 | status: payloadFromBrowser.code, 85 | } 86 | 87 | /** 88 | * When wx.qq.com push a new QRCode to Scan, there will be cookie updates(?) 89 | */ 90 | await this.saveCookie() 91 | 92 | if (this.isLoggedIn) { 93 | log.verbose('PuppetWeChatEvent', 'onScan() there has user when got a scan event. emit logout and set it to null') 94 | await this.logout() 95 | } 96 | 97 | // feed watchDog a `scan` type of food 98 | const food: WatchdogFood = { 99 | data: payloadFromBrowser, 100 | type: 'scan', 101 | } 102 | this.emit('heartbeat', food) 103 | 104 | const qrcode = payloadFromBrowser.url.replace(/\/qrcode\//, '/l/') 105 | const status = normalizeScanStatus(payloadFromBrowser.code) 106 | 107 | this.emit('scan', { qrcode, status }) 108 | } 109 | 110 | function onLog (data: any): void { 111 | log.silly('PuppetWeChatEvent', 'onLog(%s)', data) 112 | } 113 | 114 | async function onLogin ( 115 | this: PuppetWeChat, 116 | note: string, 117 | ttl = 30, 118 | ): Promise { 119 | log.verbose('PuppetWeChatEvent', 'onLogin(%s, %d)', note, ttl) 120 | 121 | const TTL_WAIT_MILLISECONDS = 1 * 1000 122 | if (ttl <= 0) { 123 | log.verbose('PuppetWeChatEvent', 'onLogin(%s) TTL expired') 124 | this.emit('error', GError.from('onLogin() TTL expired.')) 125 | return 126 | } 127 | 128 | // if (this.state.inactive()) { 129 | // log.verbose('PuppetWeChatEvent', 'onLogin(%s, %d) state.inactive()=%s, NOOP', 130 | // note, ttl, this.state.inactive()) 131 | // return 132 | // } 133 | 134 | if (this.isLoggedIn) { 135 | throw new Error('onLogin() user had already logined: ' + this.currentUserId) 136 | // await this.logout() 137 | } 138 | 139 | this.scanPayload = undefined 140 | 141 | try { 142 | /** 143 | * save login user id to this.userId 144 | * 145 | * issue #772: this.bridge might not inited if the 'login' event fired too fast(because of auto login) 146 | */ 147 | const userId = await this.bridge.getUserName() 148 | 149 | if (!userId) { 150 | log.verbose('PuppetWeChatEvent', 'onLogin() browser not fully loaded(ttl=%d), retry later', ttl) 151 | const html = await this.bridge.innerHTML() 152 | log.silly('PuppetWeChatEvent', 'onLogin() innerHTML: %s', html.substr(0, 500)) 153 | setTimeout(this.wrapAsync(onLogin.bind(this, note, ttl - 1)), TTL_WAIT_MILLISECONDS) 154 | return 155 | } 156 | 157 | log.silly('PuppetWeChatEvent', 'bridge.getUserName: %s', userId) 158 | 159 | // const user = this.Contact.load(userId) 160 | // await user.ready() 161 | 162 | log.silly('PuppetWeChatEvent', `onLogin() user ${userId} logined`) 163 | 164 | // if (this.state.active() === true) { 165 | await this.saveCookie() 166 | // } 167 | 168 | // fix issue https://github.com/Chatie/wechaty-puppet-wechat/issues/107 169 | // we do not wait `ready` before emit `login` 170 | this.waitStable().catch(e => { 171 | log.error('PuppetWeChatEvent', 'onLogin() this.waitStable() rejection: %s', e && (e as Error).message) 172 | }) 173 | 174 | await this.login(userId) 175 | 176 | } catch (e) { 177 | log.error('PuppetWeChatEvent', 'onLogin() exception: %s', e as Error) 178 | throw e 179 | } 180 | } 181 | 182 | async function onLogout ( 183 | this: PuppetWeChat, 184 | data: any, 185 | ): Promise { 186 | log.verbose('PuppetWeChatEvent', 'onLogout(%s)', data) 187 | 188 | if (this.isLoggedIn) { 189 | await this.logout() 190 | } else { 191 | // not logged-in??? 192 | log.error('PuppetWeChatEvent', 'onLogout() without self-user') 193 | } 194 | } 195 | 196 | async function onMessage ( 197 | this : PuppetWeChat, 198 | rawPayload : WebMessageRawPayload, 199 | ): Promise { 200 | const firer = new Firer(this) 201 | 202 | /** 203 | * Fire Events if match message type & content 204 | */ 205 | switch (rawPayload.MsgType) { 206 | 207 | case WebMessageType.VERIFYMSG: 208 | this.emit('friendship', { friendshipId: rawPayload.MsgId }) 209 | // firer.checkFriendRequest(rawPayload) 210 | break 211 | 212 | case WebMessageType.SYS: 213 | /** 214 | * /^@@/.test() return true means it's a room 215 | */ 216 | if (/^@@/.test(rawPayload.FromUserName)) { 217 | const joinResult = await firer.checkRoomJoin(rawPayload) 218 | const leaveResult = await firer.checkRoomLeave(rawPayload) 219 | const topicRestul = await firer.checkRoomTopic(rawPayload) 220 | 221 | if (!joinResult && !leaveResult && !topicRestul) { 222 | log.silly('PuppetWeChatEvent', `checkRoomSystem message: <${rawPayload.Content}> not found`) 223 | } 224 | } else { 225 | await firer.checkFriendConfirm(rawPayload) 226 | } 227 | break 228 | } 229 | 230 | this.emit('message', { messageId: rawPayload.MsgId }) 231 | } 232 | 233 | async function onUnload (this: PuppetWeChat): Promise { 234 | log.silly('PuppetWeChatEvent', 'onUnload()') 235 | /* 236 | try { 237 | await this.quit() 238 | await this.init() 239 | } catch (e) { 240 | log.error('PuppetWeChatEvent', 'onUnload() exception: %s', e as Error) 241 | this.emit('error', e) 242 | throw e 243 | } 244 | */ 245 | } 246 | -------------------------------------------------------------------------------- /src/firer.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | 21 | /** 22 | * Process the Message to find which event to FIRE 23 | */ 24 | 25 | import { test } from 'tstest' 26 | 27 | import { Firer } from './firer.js' 28 | import type { PuppetWeChat } from './puppet-wechat.js' 29 | 30 | const CURRENT_USER_ID = 'current-user-id' 31 | const mockPuppetWeChat = { 32 | currentUserId: CURRENT_USER_ID, 33 | } as any as PuppetWeChat 34 | 35 | test('parseFriendConfirm()', async (t) => { 36 | const contentList = [ 37 | [ 38 | 'You have added 李卓桓 as your WeChat contact. Start chatting!', 39 | '李卓桓', 40 | ], 41 | [ 42 | '你已添加了李卓桓,现在可以开始聊天了。', 43 | '李卓桓', 44 | ], 45 | [ 46 | 'johnbassserver@gmail.com just added you to his/her contacts list. Send a message to him/her now!', 47 | 'johnbassserver@gmail.com', 48 | ], 49 | [ 50 | 'johnbassserver@gmail.com刚刚把你添加到通讯录,现在可以开始聊天了。', 51 | 'johnbassserver@gmail.com', 52 | ], 53 | ] 54 | let result: boolean 55 | 56 | const firer = new Firer(mockPuppetWeChat) 57 | 58 | contentList.forEach(([content]) => { 59 | result = (firer as any).parseFriendConfirm(content) 60 | t.true(result, 'should be truthy for confirm msg: ' + content) 61 | }) 62 | 63 | result = (firer as any).parseFriendConfirm('fsdfsdfasdfasdfadsa') 64 | t.false(result, 'should be falsy for other msg') 65 | }) 66 | 67 | test('parseRoomJoin()', async (t) => { 68 | const contentList: Array<[string, string, string[]]> = [ 69 | [ 70 | 'You invited 管理员 to the group chat. ', 71 | 'You', 72 | ['管理员'], 73 | ], 74 | [ 75 | 'You invited 李卓桓.PreAngel、Bruce LEE to the group chat. ', 76 | 'You', 77 | ['李卓桓.PreAngel', 'Bruce LEE'], 78 | ], 79 | [ 80 | '管理员 invited 小桔建群助手 to the group chat', 81 | '管理员', 82 | ['小桔建群助手'], 83 | ], 84 | [ 85 | '管理员 invited 庆次、小桔妹 to the group chat', 86 | '管理员', 87 | ['庆次', '小桔妹'], 88 | ], 89 | [ 90 | '你邀请"管理员"加入了群聊 ', 91 | '你', 92 | ['管理员'], 93 | ], 94 | [ 95 | '"管理员"邀请"宁锐锋"加入了群聊', 96 | '管理员', 97 | ['宁锐锋'], 98 | ], 99 | [ 100 | '"管理员"通过扫描你分享的二维码加入群聊 ', 101 | '你', 102 | ['管理员'], 103 | ], 104 | [ 105 | '" 桔小秘"通过扫描"李佳芮"分享的二维码加入群聊', 106 | '李佳芮', 107 | ['桔小秘'], 108 | ], 109 | [ 110 | '"管理员" joined group chat via the QR code you shared. ', 111 | 'you', 112 | ['管理员'], 113 | ], 114 | [ 115 | '"宁锐锋" joined the group chat via the QR Code shared by "管理员".', 116 | '管理员', 117 | ['宁锐锋'], 118 | ], 119 | ] 120 | 121 | const firer = new Firer(mockPuppetWeChat) 122 | 123 | let result 124 | contentList.forEach(([content, inviter, inviteeList]) => { 125 | result = (firer as any).parseRoomJoin(content) 126 | t.ok(result, 'should check room join message right for ' + content) 127 | t.same(result[0], inviteeList, 'should get inviteeList right') 128 | t.equal(result[1], inviter, 'should get inviter right') 129 | }) 130 | 131 | t.throws(() => { 132 | (firer as any).parseRoomJoin('fsadfsadfsdfsdfs') 133 | }, Error, 'should throws if message is not expected') 134 | }) 135 | 136 | test('parseRoomLeave()', async (t) => { 137 | const contentLeaverList = [ 138 | [ 139 | 'You removed "Bruce LEE" from the group chat', 140 | 'Bruce LEE', 141 | ], 142 | [ 143 | '你将"李佳芮"移出了群聊', 144 | '李佳芮', 145 | ], 146 | ] 147 | 148 | const contentRemoverList = [ 149 | [ 150 | 'You were removed from the group chat by "桔小秘"', 151 | '桔小秘', 152 | ], 153 | [ 154 | '你被"李佳芮"移出群聊', 155 | '李佳芮', 156 | ], 157 | ] 158 | 159 | const firer = new Firer(mockPuppetWeChat) 160 | 161 | contentLeaverList.forEach(([content, leaver]) => { 162 | const resultLeaver = (firer as any).parseRoomLeave(content)[0] 163 | t.ok(resultLeaver, 'should get leaver for leave message: ' + content) 164 | t.equal(resultLeaver, leaver, 'should get leaver name right') 165 | }) 166 | 167 | contentRemoverList.forEach(([content, remover]) => { 168 | const resultRemover = (firer as any).parseRoomLeave(content)[1] 169 | t.ok(resultRemover, 'should get remover for leave message: ' + content) 170 | t.equal(resultRemover, remover, 'should get leaver name right') 171 | }) 172 | 173 | t.throws(() => { 174 | (firer as any).parseRoomLeave('fafdsfsdfafa') 175 | }, Error, 'should throw if message is not expected') 176 | }) 177 | 178 | test('parseRoomTopic()', async (t) => { 179 | const contentList = [ 180 | [ 181 | '"李卓桓.PreAngel" changed the group name to "ding"', 182 | '李卓桓.PreAngel', 183 | 'ding', 184 | ], 185 | [ 186 | '"李佳芮"修改群名为“dong”', 187 | '李佳芮', 188 | 'dong', 189 | ], 190 | ] 191 | 192 | const firer = new Firer(mockPuppetWeChat) 193 | 194 | let result 195 | contentList.forEach(([content, changer, topic]) => { 196 | result = (firer as any).parseRoomTopic(content) 197 | t.ok(result, 'should check topic right for content: ' + content) 198 | t.equal(topic, result[0], 'should get right topic') 199 | t.equal(changer, result[1], 'should get right changer') 200 | }) 201 | 202 | t.throws(() => { 203 | (firer as any).parseRoomTopic('fafdsfsdfafa') 204 | }, Error, 'should throw if message is not expected') 205 | 206 | }) 207 | -------------------------------------------------------------------------------- /src/firer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import { 20 | log, 21 | } from './config.js' 22 | 23 | import type { PuppetWeChat } from './puppet-wechat.js' 24 | import type { 25 | // WebRecomendInfo, 26 | WebMessageRawPayload, 27 | } from './web-schemas.js' 28 | 29 | // import { 30 | // // FriendRequestPayload, 31 | // FriendRequestType, 32 | // FriendRequestPayloadReceive, 33 | // FriendRequestPayloadConfirm, 34 | // } from 'wechaty-puppet' 35 | 36 | const REGEX_CONFIG = { 37 | friendConfirm: [ 38 | /^You have added (.+) as your WeChat contact. Start chatting!$/, 39 | /^你已添加了(.+),现在可以开始聊天了。$/, 40 | /^(.+) just added you to his\/her contacts list. Send a message to him\/her now!$/, 41 | /^(.+)刚刚把你添加到通讯录,现在可以开始聊天了。$/, 42 | ], 43 | 44 | roomJoinInvite: [ 45 | // There are 3 blank(charCode is 32) here. eg: You invited 管理员 to the group chat. 46 | /^(.+?) invited (.+) to the group chat.\s+$/, 47 | 48 | // There no no blank or punctuation here. eg: 管理员 invited 小桔建群助手 to the group chat 49 | /^(.+?) invited (.+) to the group chat$/, 50 | 51 | // There are 2 blank(charCode is 32) here. eg: 你邀请"管理员"加入了群聊 52 | /^(.+?)邀请"(.+)"加入了群聊\s+$/, 53 | 54 | // There no no blank or punctuation here. eg: "管理员"邀请"宁锐锋"加入了群聊 55 | /^"(.+?)"邀请"(.+)"加入了群聊$/, 56 | ], 57 | 58 | roomJoinQrcode: [ 59 | // Wechat change this, should desperate. See more in pr#651 60 | // /^" (.+)" joined the group chat via the QR Code shared by "?(.+?)".$/, 61 | 62 | // There are 2 blank(charCode is 32) here. Qrcode is shared by bot. 63 | // eg: "管理员" joined group chat via the QR code you shared. 64 | /^"(.+)" joined group chat via the QR code "?(.+?)"? shared.\s+$/, 65 | 66 | // There are no blank(charCode is 32) here. Qrcode isn't shared by bot. 67 | // eg: "宁锐锋" joined the group chat via the QR Code shared by "管理员". 68 | /^"(.+)" joined the group chat via the QR Code shared by "?(.+?)".$/, 69 | 70 | // There are 2 blank(charCode is 32) here. Qrcode is shared by bot. eg: "管理员"通过扫描你分享的二维码加入群聊 71 | /^"(.+)"通过扫描(.+?)分享的二维码加入群聊\s+$/, 72 | 73 | // There are 1 blank(charCode is 32) here. Qrode isn't shared by bot. eg: " 苏轼"通过扫描"管理员"分享的二维码加入群聊 74 | /^"\s+(.+)"通过扫描"(.+?)"分享的二维码加入群聊$/, 75 | ], 76 | 77 | // no list 78 | roomLeaveIKickOther: [ 79 | /^(You) removed "(.+)" from the group chat$/, 80 | /^(你)将"(.+)"移出了群聊$/, 81 | ], 82 | 83 | roomLeaveOtherKickMe: [ 84 | /^(You) were removed from the group chat by "(.+)"$/, 85 | /^(你)被"(.+)"移出群聊$/, 86 | ], 87 | 88 | roomTopic: [ 89 | /^"?(.+?)"? changed the group name to "(.+)"$/, 90 | /^"?(.+?)"?修改群名为“(.+)”$/, 91 | ], 92 | } 93 | 94 | export class Firer { 95 | 96 | constructor ( 97 | public puppet: PuppetWeChat, 98 | ) { 99 | // 100 | } 101 | 102 | // public async checkFriendRequest( 103 | // rawPayload : WebMessageRawPayload, 104 | // ): Promise { 105 | // if (!rawPayload.RecommendInfo) { 106 | // throw new Error('no RecommendInfo') 107 | // } 108 | // const recommendInfo: WebRecomendInfo = rawPayload.RecommendInfo 109 | // log.verbose('PuppetWeChatFirer', 'fireFriendRequest(%s)', recommendInfo) 110 | 111 | // if (!recommendInfo) { 112 | // throw new Error('no recommendInfo') 113 | // } 114 | 115 | // const contactId = recommendInfo.UserName 116 | // const hello = recommendInfo.Content 117 | // const ticket = recommendInfo.Ticket 118 | // const type = FriendRequestType.Receive 119 | // const id = cuid() 120 | 121 | // const payloadReceive: FriendRequestPayloadReceive = { 122 | // id, 123 | // contactId, 124 | // hello, 125 | // ticket, 126 | // type, 127 | // } 128 | 129 | // this.puppet.cacheFriendRequestPayload.set(id, payloadReceive) 130 | 131 | // this.puppet.emit('friend', id) 132 | // } 133 | 134 | public async checkFriendConfirm ( 135 | rawPayload : WebMessageRawPayload, 136 | ) { 137 | const content = rawPayload.Content 138 | log.silly('PuppetWeChatFirer', 'fireFriendConfirm(%s)', content) 139 | 140 | if (!this.parseFriendConfirm(content)) { 141 | return 142 | } 143 | 144 | // const contactId = rawPayload.FromUserName 145 | // const type = FriendRequestType.Confirm 146 | 147 | // const id = cuid() 148 | 149 | // const payloadConfirm: FriendRequestPayloadConfirm = { 150 | // id, 151 | // contactId, 152 | // type, 153 | // } 154 | 155 | // this.puppet.cacheFriendRequestPayload.set(id, payloadConfirm) 156 | 157 | this.puppet.emit('friendship', { friendshipId: rawPayload.MsgId }) 158 | } 159 | 160 | public async checkRoomJoin ( 161 | rawPayload : WebMessageRawPayload, 162 | ): Promise { 163 | 164 | const text = rawPayload.Content 165 | const roomId = rawPayload.FromUserName 166 | 167 | /** 168 | * Get the display names of invitee & inviter 169 | */ 170 | let inviteeNameList : string[] 171 | let inviterName : string 172 | 173 | try { 174 | [inviteeNameList, inviterName] = this.parseRoomJoin(text) 175 | } catch (e) { 176 | log.silly('PuppetWeChatFirer', 'checkRoomJoin() "%s" is not a join message', text) 177 | return false // not a room join message 178 | } 179 | log.silly('PuppetWeChatFirer', 'checkRoomJoin() inviteeList: %s, inviter: %s', 180 | inviteeNameList.join(','), 181 | inviterName, 182 | ) 183 | 184 | /** 185 | * Convert the display name to Contact ID 186 | */ 187 | let inviterContactId: undefined | string 188 | const inviteeContactIdList: string[] = [] 189 | 190 | if (/^You|你$/i.test(inviterName)) { // === 'You' || inviter === '你' || inviter === 'you' 191 | inviterContactId = this.puppet.currentUserId 192 | } 193 | 194 | const sleep = 1000 195 | const timeout = 60 * 1000 196 | let ttl = timeout / sleep 197 | 198 | let ready = true 199 | 200 | while (ttl-- > 0) { 201 | log.silly('PuppetWeChatFirer', 'fireRoomJoin() retry() ttl %d', ttl) 202 | 203 | if (!ready) { 204 | await new Promise(resolve => setTimeout(resolve, timeout)) 205 | ready = true 206 | } 207 | 208 | /** 209 | * loop inviteeNameList 210 | * set inviteeContactIdList 211 | */ 212 | for (let i = 0; i < inviteeNameList.length; i++) { 213 | const inviteeName = inviteeNameList[i]! 214 | 215 | const inviteeContactId = inviteeContactIdList[i] 216 | if (inviteeContactId) { 217 | /** 218 | * had already got ContactId for Room Member 219 | * try to resolve the ContactPayload 220 | */ 221 | try { 222 | await this.puppet.contactPayload(inviteeContactId) 223 | } catch (e) { 224 | log.warn('PuppetWeChatFirer', 'fireRoomJoin() contactPayload(%s) exception: %s', 225 | inviteeContactId, 226 | (e as Error).message, 227 | ) 228 | ready = false 229 | } 230 | } else { 231 | /** 232 | * only had Name of RoomMember 233 | * try to resolve the ContactId & ContactPayload 234 | */ 235 | const memberIdList = await this.puppet.roomMemberSearch(roomId, inviteeName) 236 | 237 | if (memberIdList.length <= 0) { 238 | ready = false 239 | } 240 | 241 | const contactId = memberIdList[0] 242 | // XXX: Take out the first one if we have matched many contact. 243 | inviteeContactIdList[i] = contactId! 244 | 245 | if (!contactId) { 246 | ready = false 247 | } else { 248 | try { 249 | await this.puppet.contactPayload(contactId) 250 | } catch (e) { 251 | ready = false 252 | } 253 | } 254 | 255 | } 256 | 257 | } 258 | 259 | if (!inviterContactId) { 260 | const contactIdList = await this.puppet.roomMemberSearch(roomId, inviterName) 261 | 262 | if (contactIdList.length > 0) { 263 | inviterContactId = contactIdList[0] 264 | } else { 265 | ready = false 266 | } 267 | } 268 | 269 | if (ready) { 270 | log.silly('PuppetWeChatFirer', 'fireRoomJoin() resolve() inviteeContactIdList: %s, inviterContactId: %s', 271 | inviteeContactIdList.join(','), 272 | inviterContactId, 273 | ) 274 | /** 275 | * Resolve All Payload again to make sure the data is ready. 276 | */ 277 | await Promise.all( 278 | inviteeContactIdList 279 | .filter(id => !!id) 280 | .map( 281 | id => this.puppet.contactPayload(id!), 282 | ), 283 | ) 284 | 285 | if (!inviterContactId) { 286 | throw new Error('no inviterContactId') 287 | } 288 | 289 | await this.puppet.contactPayload(inviterContactId) 290 | await this.puppet.roomPayload(roomId) 291 | 292 | const timestamp = Math.floor(Date.now() / 1000) // in seconds 293 | this.puppet.emit('room-join', { 294 | inviteeIdList : inviteeContactIdList, 295 | inviterId : inviterContactId, 296 | roomId, 297 | timestamp, 298 | }) 299 | 300 | return true 301 | } 302 | } 303 | 304 | log.warn('PuppetWeChatFier', 'fireRoomJoin() resolve payload fail.') 305 | return false 306 | } 307 | 308 | /** 309 | * You removed "Bruce LEE" from the group chat 310 | */ 311 | public async checkRoomLeave ( 312 | rawPayload : WebMessageRawPayload, 313 | ): Promise { 314 | log.verbose('PuppetWeChatFirer', 'fireRoomLeave(%s)', rawPayload.Content) 315 | 316 | const roomId = rawPayload.FromUserName 317 | 318 | let leaverName : string 319 | let removerName : string 320 | 321 | try { 322 | [leaverName, removerName] = this.parseRoomLeave(rawPayload.Content) 323 | } catch (e) { 324 | log.silly('PuppetWeChatFirer', 'fireRoomLeave() %s', (e as Error).message) 325 | return false 326 | } 327 | log.silly('PuppetWeChatFirer', 'fireRoomLeave() got leaverName: %s', leaverName) 328 | 329 | /** 330 | * FIXME: leaver maybe is a list 331 | * @lijiarui: I have checked, leaver will never be a list. 332 | * If the bot remove 2 leavers at the same time, 333 | * it will be 2 sys message, instead of 1 sys message contains 2 leavers. 334 | */ 335 | let leaverContactId : undefined | string 336 | let removerContactId : undefined | string 337 | 338 | if (/^(You|你)$/i.test(leaverName)) { 339 | leaverContactId = this.puppet.currentUserId 340 | } else if (/^(You|你)$/i.test(removerName)) { 341 | removerContactId = this.puppet.currentUserId 342 | } 343 | 344 | if (!leaverContactId) { 345 | const idList = await this.puppet.roomMemberSearch(roomId, leaverName) 346 | leaverContactId = idList[0] 347 | } 348 | 349 | if (!removerContactId) { 350 | const idList = await this.puppet.roomMemberSearch(roomId, removerName) 351 | removerContactId = idList[0] 352 | } 353 | 354 | if (!leaverContactId || !removerContactId) { 355 | throw new Error('no id') 356 | } 357 | /** 358 | * FIXME: leaver maybe is a list 359 | * @lijiarui 2017: I have checked, leaver will never be a list. If the bot remove 2 leavers at the same time, 360 | * it will be 2 sys message, instead of 1 sys message contains 2 leavers. 361 | * @huan 2018 May: we need to generilize the pattern for future usage. 362 | */ 363 | const timestamp = Math.floor(Date.now() / 1000) // in seconds 364 | 365 | this.puppet.emit('room-leave', { 366 | removeeIdList : [leaverContactId], 367 | removerId : removerContactId, 368 | roomId, 369 | timestamp, 370 | }) 371 | 372 | setTimeout(() => { 373 | this.puppet.roomPayloadDirty(roomId) 374 | .then(() => this.puppet.roomPayload(roomId)) 375 | .catch(console.error) 376 | }, 10 * 1000) // reload the room data, especially for memberList 377 | 378 | return true 379 | } 380 | 381 | public async checkRoomTopic ( 382 | rawPayload : WebMessageRawPayload, 383 | ): Promise { 384 | let topic : string 385 | let changer : string 386 | 387 | try { 388 | [topic, changer] = this.parseRoomTopic(rawPayload.Content) 389 | } catch (e) { // not found 390 | return false 391 | } 392 | 393 | const roomId = rawPayload.FromUserName 394 | 395 | const roomPayload = await this.puppet.roomPayload(roomId) 396 | const oldTopic = roomPayload.topic 397 | 398 | let changerContactId: undefined | string 399 | if (/^(You|你)$/.test(changer)) { 400 | changerContactId = this.puppet.currentUserId 401 | } else { 402 | changerContactId = (await this.puppet.roomMemberSearch(roomId, changer))[0] 403 | } 404 | 405 | if (!changerContactId) { 406 | log.error('PuppetWeChatFirer', 'fireRoomTopic() changer contact not found for %s', changer) 407 | return false 408 | } 409 | 410 | try { 411 | const timestamp = Math.floor(Date.now() / 1000) // in seconds 412 | 413 | this.puppet.emit('room-topic', { 414 | changerId : changerContactId, 415 | newTopic : topic, 416 | oldTopic, 417 | roomId, 418 | timestamp, 419 | }) 420 | return true 421 | } catch (e) { 422 | log.error('PuppetWeChatFirer', 'fireRoomTopic() co exception: %s', (e as Error).stack) 423 | return false 424 | } 425 | } 426 | 427 | /** 428 | * try to find FriendRequest Confirmation Message 429 | */ 430 | private parseFriendConfirm ( 431 | content: string, 432 | ): boolean { 433 | const reList = REGEX_CONFIG.friendConfirm 434 | 435 | const found = reList.some(re => re.test(content)) 436 | if (found) { 437 | return true 438 | } else { 439 | return false 440 | } 441 | } 442 | 443 | /** 444 | * try to find 'join' event for Room 445 | * 446 | * 1. 447 | * You invited 管理员 to the group chat. 448 | * You invited 李卓桓.PreAngel、Bruce LEE to the group chat. 449 | * 2. 450 | * 管理员 invited 小桔建群助手 to the group chat 451 | * 管理员 invited 庆次、小桔妹 to the group chat 452 | */ 453 | private parseRoomJoin ( 454 | content: string, 455 | ): [string[], string] { 456 | log.verbose('PuppetWeChatFirer', 'parseRoomJoin(%s)', content) 457 | 458 | const reListInvite = REGEX_CONFIG.roomJoinInvite 459 | const reListQrcode = REGEX_CONFIG.roomJoinQrcode 460 | 461 | let foundInvite: null | string[] = null 462 | for (const re of reListInvite) { 463 | foundInvite = content.match(re) 464 | if (foundInvite) { 465 | break 466 | } 467 | } 468 | // reListInvite.some(re => !!(foundInvite = content.match(re))) 469 | 470 | let foundQrcode: null | string[] = [] 471 | for (const re of reListQrcode) { 472 | foundQrcode = content.match(re) 473 | if (foundQrcode) { 474 | break 475 | } 476 | } 477 | // reListQrcode.some(re => !!(foundQrcode = content.match(re))) 478 | 479 | if ((!foundInvite || !foundInvite.length) && (!foundQrcode || !foundQrcode.length)) { 480 | throw new Error('parseRoomJoin() not found matched re of ' + content) 481 | } 482 | /** 483 | * 管理员 invited 庆次、小桔妹 to the group chat 484 | * "管理员"通过扫描你分享的二维码加入群聊 485 | */ 486 | const [inviter, inviteeStr] = foundInvite ? [foundInvite[1], foundInvite[2]] : [foundQrcode![2], foundQrcode![1]] 487 | 488 | // FIXME: should also compatible english split 489 | const inviteeList = inviteeStr?.split(/、/) || [] 490 | 491 | return [inviteeList, inviter!] // put invitee at first place 492 | } 493 | 494 | private parseRoomLeave ( 495 | content: string, 496 | ): [string, string] { 497 | let matchIKickOther: null | string[] = null 498 | for (const re of REGEX_CONFIG.roomLeaveIKickOther) { 499 | matchIKickOther = content.match(re) 500 | if (matchIKickOther) { 501 | break 502 | } 503 | } 504 | 505 | let matchOtherKickMe: null | string[] = null 506 | for (const re of REGEX_CONFIG.roomLeaveOtherKickMe) { 507 | matchOtherKickMe = content.match(re) 508 | if (matchOtherKickMe) { 509 | break 510 | } 511 | } 512 | 513 | let leaverName : undefined | string 514 | let removerName : undefined | string 515 | 516 | if (matchIKickOther && matchIKickOther.length) { 517 | leaverName = matchIKickOther[2] 518 | removerName = matchIKickOther[1] 519 | } else if (matchOtherKickMe && matchOtherKickMe.length) { 520 | leaverName = matchOtherKickMe[1] 521 | removerName = matchOtherKickMe[2] 522 | } else { 523 | throw new Error('no match') 524 | } 525 | 526 | return [leaverName!, removerName!] 527 | } 528 | 529 | private parseRoomTopic ( 530 | content: string, 531 | ): [string, string] { 532 | const reList = REGEX_CONFIG.roomTopic 533 | 534 | let found: null | string[] = null 535 | for (const re of reList) { 536 | found = content.match(re) 537 | if (found) { 538 | break 539 | } 540 | } 541 | if (!found || !found.length) { 542 | throw new Error('checkRoomTopic() not found') 543 | } 544 | const [, changer, topic] = found 545 | return [topic!, changer!] 546 | } 547 | 548 | } 549 | 550 | export default Firer 551 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import { PuppetWeChat } from './puppet-wechat.js' 20 | 21 | export { 22 | VERSION, 23 | log, 24 | } from './config.js' 25 | 26 | export { 27 | PuppetWeChat, 28 | } 29 | export default PuppetWeChat 30 | -------------------------------------------------------------------------------- /src/package-json.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { packageJson } from './package-json.js' 6 | 7 | test('Make sure the packageJson is fresh in source code', async t => { 8 | const keyNum = Object.keys(packageJson).length 9 | t.equal(keyNum, 0, 'packageJson should be empty in source code, only updated before publish to NPM') 10 | }) 11 | -------------------------------------------------------------------------------- /src/package-json.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will be overwrite when we publish NPM module 3 | * by scripts/generate_version.ts 4 | */ 5 | import type { PackageJson } from 'type-fest' 6 | 7 | /** 8 | * Huan(202108): 9 | * The below default values is only for unit testing 10 | */ 11 | export const packageJson: PackageJson = {} 12 | -------------------------------------------------------------------------------- /src/puppet-wechat.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { 21 | test, 22 | sinon, 23 | } from 'tstest'// const sinonTest = require('sinon-test')(sinon, { 24 | // useFakeTimers: { // https://github.com/sinonjs/lolex 25 | // advanceTimeDelta : 10, 26 | // shouldAdvanceTime : true, 27 | // }, 28 | // }) 29 | 30 | // import { log } from './config' 31 | // log.level('silly') 32 | import whyIsNodeRunning from 'why-is-node-running' 33 | 34 | import { Bridge } from './bridge.js' 35 | import { Event } from './event.js' 36 | import { PuppetWeChat } from './puppet-wechat.js' 37 | 38 | class PuppetTest extends PuppetWeChat { 39 | 40 | override contactRawPayload (id: string) { 41 | return super.contactRawPayload(id) 42 | } 43 | 44 | override roomRawPayload (id: string) { 45 | return super.roomRawPayload(id) 46 | } 47 | 48 | override messageRawPayload (id: string) { 49 | return super.messageRawPayload(id) 50 | } 51 | 52 | } 53 | 54 | // test('Puppet smoke testing', async t => { 55 | // const puppet = new PuppetTest() 56 | // const wechaty = new WechatyTest({ puppet }) 57 | // wechaty.initPuppetAccessory(puppet) 58 | 59 | // t.ok(puppet.state.inactive(), 'should be OFF state after instanciate') 60 | // puppet.state.active('pending') 61 | // t.ok(puppet.state.active(), 'should be ON state after set') 62 | // t.ok(puppet.state.pending(), 'should be pending state after set') 63 | // }) 64 | 65 | test('login/logout events', async t => { 66 | const sandbox = sinon.createSandbox() 67 | 68 | try { 69 | const puppet = new PuppetTest() 70 | 71 | sandbox.stub(Event, 'onScan') // block the scan event to prevent reset logined user 72 | 73 | sandbox.stub(Bridge.prototype, 'getUserName').resolves('mockedUserName') 74 | sandbox.stub(Bridge.prototype, 'contactList') 75 | .onFirstCall().resolves([]) 76 | .onSecondCall().resolves(['1']) 77 | .resolves(['1', '2']) 78 | 79 | sandbox.stub(puppet, 'contactRawPayload').resolves({ 80 | NickName: 'mockedNickName', 81 | UserName: 'mockedUserName', 82 | } as any) 83 | // sandbox.stub(puppet, 'waitStable').resolves() 84 | 85 | const readySpy = sandbox.spy() 86 | puppet.on('ready', readySpy) 87 | 88 | await puppet.start() 89 | t.pass('should be inited') 90 | t.equal(puppet.isLoggedIn, false, 'should be not logined') 91 | 92 | const future = new Promise(resolve => puppet.once('login', resolve)) 93 | .catch(e => t.fail(e)) 94 | puppet.bridge.emit('login', 'TestPuppetWeChat') 95 | await future 96 | 97 | t.equal(puppet.isLoggedIn, true, 'should be logined') 98 | 99 | t.ok((puppet.bridge.getUserName as any).called, 'bridge.getUserName should be called') 100 | 101 | // FIXME: improve the performance of the test by mocking the time 102 | // TODO(huan) July 2018: use sinon.clock / sinon.useFakeTimers() at here 103 | await new Promise(resolve => setTimeout(resolve, 7000)) 104 | 105 | // Puppet will not ready the contact, so the contactRawPayload might not be called at here. Huan, 2018.6 106 | // t.ok((puppet.contactRawPayload as any).called, 'puppet.contactRawPayload should be called') 107 | 108 | t.ok((Bridge.prototype.contactList as any).called, 'contactList stub should be called') 109 | 110 | /** 111 | * 6 times is: 112 | * 113 | * 0, 1, 2 is for first 3 calls for contactList() 114 | * 115 | * 3, 4, 5 is PuppetWeChat.waitStable() for `unchangedNum` to reach 3 times. 116 | */ 117 | t.equal((Bridge.prototype.contactList as any).callCount, 6, 'should call stubContacList 6 times') 118 | 119 | t.ok(readySpy.called, 'should emit ready event, after login') 120 | 121 | const LOGOUT_FIRED = 'logoutFired' 122 | const logoutPromise = new Promise((resolve) => puppet.once('logout', () => resolve(LOGOUT_FIRED))) 123 | puppet.bridge.emit('logout') 124 | t.equal(await logoutPromise, LOGOUT_FIRED, 'should fire logout event') 125 | await new Promise(setImmediate) 126 | t.equal(puppet.isLoggedIn, false, 'should be logouted') 127 | 128 | await puppet.stop() 129 | } catch (e) { 130 | t.fail(e as any) 131 | } finally { 132 | sandbox.restore() 133 | } 134 | }) 135 | 136 | /** 137 | * FIXME: increase test times from 1 to 3 Huan(202006) 138 | */ 139 | test('PuppetWechat perfect restart', async t => { 140 | const puppet = new PuppetWeChat() 141 | 142 | let n = 1 143 | 144 | while (n--) { 145 | await puppet.start() 146 | // await new Promise(resolve => setTimeout(resolve, 1000)) 147 | await puppet.stop() 148 | t.pass(`perfect restart #${n}`) 149 | } 150 | 151 | void whyIsNodeRunning 152 | // setInterval(whyIsNodeRunning, 5000) 153 | }) 154 | -------------------------------------------------------------------------------- /src/pure-function-helpers/is-type.ts: -------------------------------------------------------------------------------- 1 | export function isRoomId (id: string): boolean { 2 | return /^@@/.test(id) 3 | } 4 | 5 | export function isContactId (id: string): boolean { 6 | return !isRoomId(id) 7 | } 8 | -------------------------------------------------------------------------------- /src/pure-function-helpers/message-extname.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebAppMsgType, 3 | WebMessageRawPayload, 4 | WebMessageType, 5 | } from '../web-schemas.js' 6 | 7 | export function messageExtname ( 8 | rawPayload: WebMessageRawPayload, 9 | ): string { 10 | let ext: string 11 | 12 | // const type = this.type() 13 | 14 | switch (rawPayload.MsgType) { 15 | case WebMessageType.EMOTICON: 16 | ext = '.gif' 17 | break 18 | 19 | case WebMessageType.IMAGE: 20 | ext = '.jpg' 21 | break 22 | 23 | case WebMessageType.VIDEO: 24 | case WebMessageType.MICROVIDEO: 25 | ext = '.mp4' 26 | break 27 | 28 | case WebMessageType.VOICE: 29 | ext = '.mp3' 30 | break 31 | 32 | case WebMessageType.APP: 33 | switch (rawPayload.AppMsgType) { 34 | case WebAppMsgType.URL: 35 | ext = '.url' // XXX 36 | break 37 | default: 38 | ext = '.' + rawPayload.MsgType 39 | break 40 | } 41 | break 42 | 43 | case WebMessageType.TEXT: 44 | if (rawPayload.SubMsgType === WebMessageType.LOCATION) { 45 | ext = '.jpg' 46 | } 47 | ext = '.' + rawPayload.MsgType 48 | 49 | break 50 | 51 | default: 52 | ext = '.' + rawPayload.MsgType 53 | } 54 | 55 | return ext 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/pure-function-helpers/message-filename.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | WebMessageRawPayload, 3 | } from '../web-schemas.js' 4 | 5 | import { 6 | messageExtname, 7 | } from './message-extname.js' 8 | 9 | export function messageFilename ( 10 | rawPayload: WebMessageRawPayload, 11 | ): string { 12 | 13 | let guessFilename = rawPayload.FileName || rawPayload.MediaId || rawPayload.MsgId 14 | 15 | const re = /\.[a-z0-9]{1,7}$/i 16 | if (!re.test(guessFilename)) { 17 | if (rawPayload.MMAppMsgFileExt) { 18 | guessFilename += '.' + rawPayload.MMAppMsgFileExt 19 | } else { 20 | guessFilename += messageExtname(rawPayload) 21 | } 22 | } 23 | 24 | return guessFilename 25 | } 26 | -------------------------------------------------------------------------------- /src/pure-function-helpers/message-raw-payload-parser.ts: -------------------------------------------------------------------------------- 1 | import type * as PUPPET from 'wechaty-puppet' 2 | 3 | import type { 4 | WebMessageRawPayload, 5 | } from '../web-schemas.js' 6 | 7 | import { 8 | isRoomId, 9 | } from './is-type.js' 10 | 11 | import { 12 | messageFilename, 13 | } from './message-filename.js' 14 | import { 15 | webMessageType, 16 | } from './web-message-type.js' 17 | 18 | export function messageRawPayloadParser ( 19 | rawPayload: WebMessageRawPayload, 20 | ): PUPPET.payloads.Message { 21 | const id = rawPayload.MsgId 22 | const talkerId = rawPayload.MMActualSender // MMPeerUserName 23 | const text: string = rawPayload.MMActualContent // Content has @id prefix added by wx 24 | const timestamp: number = rawPayload.MMDisplayTime // Javascript timestamp of milliseconds 25 | const msgFileName: undefined | string = messageFilename(rawPayload) || undefined 26 | 27 | let roomId : undefined | string 28 | let listenerId : undefined | string 29 | 30 | // FIXME: has there any better method to know the room ID? 31 | if (rawPayload.MMIsChatRoom) { 32 | if (isRoomId(rawPayload.FromUserName)) { 33 | roomId = rawPayload.FromUserName // MMPeerUserName always eq FromUserName ? 34 | } else if (isRoomId(rawPayload.ToUserName)) { 35 | roomId = rawPayload.ToUserName 36 | } else { 37 | throw new Error('parse found a room message, but neither FromUserName nor ToUserName is a room(/^@@/)') 38 | } 39 | 40 | // console.log('rawPayload.FromUserName: ', rawPayload.FromUserName) 41 | // console.log('rawPayload.ToUserName: ', rawPayload.ToUserName) 42 | // console.log('rawPayload.MMPeerUserName: ', rawPayload.MMPeerUserName) 43 | } 44 | 45 | if (rawPayload.ToUserName) { 46 | if (!isRoomId(rawPayload.ToUserName)) { 47 | // if a message in room without any specific receiver, then it will set to be `undefined` 48 | listenerId = rawPayload.ToUserName 49 | } 50 | } 51 | 52 | const type: PUPPET.types.Message = webMessageType(rawPayload) 53 | 54 | const payloadBase = { 55 | filename: msgFileName, 56 | id, 57 | mentionIdList: [], 58 | talkerId, 59 | text, 60 | timestamp, 61 | type, 62 | } 63 | 64 | let payload: PUPPET.payloads.Message 65 | 66 | if (listenerId) { 67 | payload = { 68 | ...payloadBase, 69 | listenerId, 70 | roomId, 71 | } 72 | } else if (roomId) { 73 | payload = { 74 | ...payloadBase, 75 | listenerId, 76 | roomId, 77 | } 78 | } else { 79 | throw new Error('neither roomId nor listenerId') 80 | } 81 | 82 | return payload 83 | } 84 | -------------------------------------------------------------------------------- /src/pure-function-helpers/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './is-type.js' 2 | export * from './message-extname.js' 3 | export * from './message-filename.js' 4 | export * from './message-raw-payload-parser.js' 5 | export * from './web-message-type.js' 6 | export * from './xml.js' 7 | export * from './retry-policy.js' 8 | export { parseMentionIdList } from './parse-mention-id-list.js' 9 | -------------------------------------------------------------------------------- /src/pure-function-helpers/normalize-scan-status.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Wechaty - https://github.com/chatie/wechaty 5 | * 6 | * @copyright 2016-2018 Huan LI 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | 22 | import { 23 | test, 24 | } from 'tstest' 25 | 26 | import { 27 | ScanStatus, 28 | } from 'wechaty-puppet/types' 29 | 30 | import { normalizeScanStatus } from './normalize-scan-status.js' 31 | 32 | test('normalizeScanStatus()', async t => { 33 | const SCAN_STATUS_LIST = [ 34 | [0, ScanStatus.Waiting], 35 | [200, ScanStatus.Confirmed], 36 | [201, ScanStatus.Scanned], 37 | [408, ScanStatus.Timeout], 38 | ] 39 | 40 | for (const [puppeteerStatus, EXPECT_PUPPET_STATUS] of SCAN_STATUS_LIST) { 41 | const puppetStatus = normalizeScanStatus(puppeteerStatus!) 42 | t.equal(puppetStatus, EXPECT_PUPPET_STATUS, `should convert status code from puppeer(${puppeteerStatus}) to puppet(${EXPECT_PUPPET_STATUS})`) 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /src/pure-function-helpers/normalize-scan-status.ts: -------------------------------------------------------------------------------- 1 | import { ScanStatus } from 'wechaty-puppet/types' 2 | 3 | export function normalizeScanStatus ( 4 | status: number, 5 | ): ScanStatus { 6 | switch (status) { 7 | case 0: 8 | return ScanStatus.Waiting 9 | 10 | case 200: 11 | return ScanStatus.Confirmed 12 | 13 | case 201: 14 | return ScanStatus.Scanned 15 | 16 | case 408: 17 | // No scan after 2 minute ... 18 | return ScanStatus.Timeout 19 | 20 | default: 21 | throw new Error('unsupported scan status: ' + status) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/pure-function-helpers/parse-mention-id-list.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Wechaty - https://github.com/chatie/wechaty 5 | * 6 | * @copyright 2016-2018 Huan LI 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | 22 | import { test } from 'tstest' 23 | import type { Puppet } from 'wechaty-puppet' 24 | 25 | import { parseMentionIdList } from './parse-mention-id-list.js' 26 | 27 | /* eslint-disable quotes */ 28 | /* eslint-disable no-useless-escape */ 29 | /* eslint-disable no-irregular-whitespace */ 30 | 31 | test('parseMentionIdList()', async t => { 32 | const rawObj11 = JSON.parse(`{"MsgId":"6475340302153501409","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:
@_@","Status":3,"ImgStatus":1,"CreateTime":1489823176,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":6475340302153502000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@_@","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"6475340302153501409","ClientMsgId":"6475340302153501409","MMActualContent":"@_@","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:46","MMDisplayTime":1489823176,"MMTime":"15:46"}`) 33 | 34 | const rawObj12 = JSON.parse(`{"MsgId":"3670467504370401673","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:
user@email.com","Status":3,"ImgStatus":1,"CreateTime":1489823281,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3670467504370402000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:user@email.com","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"3670467504370401673","ClientMsgId":"3670467504370401673","MMActualContent":"user@email.com","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:48","MMDisplayTime":1489823176,"MMTime":""}`) 35 | 36 | const rawObj13 = JSON.parse(`{"MsgId":"6796857876930585020","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:
@_@ wow! my email is ruiruibupt@gmail.com","Status":3,"ImgStatus":1,"CreateTime":1489823387,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":6796857876930585000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@_@ wow! my email is ruiruibupt@gmail.com","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"6796857876930585020","ClientMsgId":"6796857876930585020","MMActualContent":"@_@ wow! my email is ruiruibupt@gmail.com","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:49","MMDisplayTime":1489823387,"MMTime":"15:49"}`) 37 | 38 | const rawObj21 = JSON.parse(`{"MsgId":"2661793617819734017","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:
@小桔同学 你好啊","Status":3,"ImgStatus":1,"CreateTime":1489823541,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":2661793617819734000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@小桔同学 你好啊","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"2661793617819734017","ClientMsgId":"2661793617819734017","MMActualContent":"@小桔同学 你好啊","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:52","MMDisplayTime":1489823387,"MMTime":""}`) 39 | 40 | const rawObj22 = JSON.parse(`{"MsgId":"5278536998175085820","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:
@wuli舞哩客服 和@小桔同学 是好朋友","Status":3,"ImgStatus":1,"CreateTime":1489823598,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":5278536998175086000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@wuli舞哩客服 和@小桔同学 是好朋友","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"5278536998175085820","ClientMsgId":"5278536998175085820","MMActualContent":"@wuli舞哩客服 和@小桔同学 是好朋友","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:53","MMDisplayTime":1489823598,"MMTime":"15:53"}`) 41 | 42 | const rawObj31 = JSON.parse(`{"MsgId":"7410792097315403535","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:
@wuli舞哩客服 我的邮箱是 ruiruibupt@gmail.com","Status":3,"ImgStatus":1,"CreateTime":1489823684,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":7410792097315404000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@wuli舞哩客服 我的邮箱是 ruiruibupt@gmail.com","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"7410792097315403535","ClientMsgId":"7410792097315403535","MMActualContent":"@wuli舞哩客服 我的邮箱是 ruiruibupt@gmail.com","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:54","MMDisplayTime":1489823598,"MMTime":""}`) 43 | 44 | const rawObj32 = JSON.parse(`{"MsgId":"3807714644369652210","FromUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","ToUserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","MsgType":1,"Content":"@4c32c97337cbb325442c304d6a44e374:
@_@ wow,@wuli舞哩客服 和@小桔同学 看起来很有默契","Status":3,"ImgStatus":1,"CreateTime":1489823764,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3807714644369652000,"OriContent":"","MMPeerUserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","MMDigest":"22acb030-ff09-11e6-8a73-cff62d9268c5:@_@ wow,@wuli舞哩客服 和@小桔同学 看起来很有默契","MMIsSend":false,"MMIsChatRoom":true,"MMUnread":true,"LocalID":"3807714644369652210","ClientMsgId":"3807714644369652210","MMActualContent":"@_@ wow,@wuli舞哩客服 和@小桔同学 看起来很有默契","MMActualSender":"@4c32c97337cbb325442c304d6a44e374","MMDigestTime":"15:56","MMDisplayTime":1489823598,"MMTime":""}`) 45 | 46 | // const RAW_OBJ = JSON.parse(`{"domain":null,"_events":{},"_eventsCount":0,"id":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","rawObj":{"Alias":"","AppAccountFlag":0,"AttrStatus":0,"ChatRoomId":0,"City":"","ContactFlag":2,"DisplayName":"","EncryChatRoomId":"@864285b52c943fbdbfd0d8990546c404","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=648035057&username=@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4&skey=","HideInputBarFlag":0,"IsOwner":1,"KeyWord":"","MMFromBatchGet":true,"MMFromBatchget":true,"MMInChatroom":true,"MMOrderSymbol":"FUFEIRUQUN","MemberCount":3,"MemberList":[{"AttrStatus":2147584103,"DisplayName":"","KeyWord":"qq5","MemberStatus":0,"NickName":"22acb030-ff09-11e6-8a73-cff62d9268c5","PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Uin":0,"UserName":"@4c32c97337cbb325442c304d6a44e374"},{"AttrStatus":135205,"DisplayName":"","KeyWord":"","MemberStatus":0,"NickName":"小桔同学","PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Uin":0,"UserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2"},{"AttrStatus":233509,"DisplayName":"","KeyWord":"","MemberStatus":0,"NickName":"50fb16c0-ff09-11e6-9fe5-dda97284d25b","PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Uin":0,"UserName":"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855"}],"NickName":"付费入群","OwnerUin":0,"PYInitial":"FFRQ","PYQuanPin":"fufeiruqun","Province":"","RemarkName":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Sex":0,"Signature":"","SnsFlag":0,"StarFriend":0,"Statues":1,"Uin":0,"UniFriend":0,"UserName":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","VerifyFlag":0,"stranger":true},"obj":{"id":"@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4","encryId":"@864285b52c943fbdbfd0d8990546c404","topic":"付费入群","ownerUin":0,"memberList":[{"id":"@4c32c97337cbb325442c304d6a44e374","rawObj":{"Alias":"ruirui_0914","AppAccountFlag":0,"AttrStatus":2147584103,"ChatRoomId":0,"City":"海淀","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=648035215&username=@4c32c97337cbb325442c304d6a44e374&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"qq5","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"李佳芮","OwnerUin":0,"PYInitial":"LJR","PYQuanPin":"lijiarui","Province":"北京","RemarkName":"22acb030-ff09-11e6-8a73-cff62d9268c5","RemarkPYInitial":"22ACB030FF0911E68A73CFF62D9268C5","RemarkPYQuanPin":"22acb030ff0911e68a73cff62d9268c5","Sex":2,"Signature":"出洞计划 | 向前一步","SnsFlag":49,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@4c32c97337cbb325442c304d6a44e374","VerifyFlag":0,"_h":50,"_index":16,"_offsetTop":696,"stranger":false},"obj":{"id":"@4c32c97337cbb325442c304d6a44e374","uin":0,"weixin":"ruirui_0914","name":"李佳芮","alias":"22acb030-ff09-11e6-8a73-cff62d9268c5","sex":2,"province":"北京","city":"海淀","signature":"出洞计划 | 向前一步","address":"ruirui_0914","star":false,"stranger":false,"avatar":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=648035215&username=@4c32c97337cbb325442c304d6a44e374&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7"}},{"id":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","rawObj":{"AppAccountFlag":0,"ContactFlag":0,"HeadImgFlag":1,"HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=665886775&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"MMOrderSymbol":"~","NickName":"小桔同学","PYInitial":"","PYQuanPin":"","RemarkName":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Sex":0,"Signature":"我是一个性感的机器人","SnsFlag":1,"StarFriend":0,"Uin":244009576,"UserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","VerifyFlag":0,"WebWxPluginSwitch":0,"stranger":false},"obj":{"id":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","uin":244009576,"name":"小桔同学","alias":"","sex":0,"signature":"我是一个性感的机器人","star":false,"stranger":false,"avatar":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=665886775&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7"}},{"id":"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855","rawObj":{"$$hashKey":"01J","Alias":"dancewuli","AppAccountFlag":0,"AttrStatus":233509,"ChatRoomId":0,"City":"","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=635310858&username=@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"wuli舞哩客服","OwnerUin":0,"PYInitial":"WULIWLKF","PYQuanPin":"wuliwulikefu","Province":"","RemarkName":"50fb16c0-ff09-11e6-9fe5-dda97284d25b","RemarkPYInitial":"50FB16C0FF0911E69FE5DDA97284D25B","RemarkPYQuanPin":"50fb16c0ff0911e69fe5dda97284d25b","Sex":0,"Signature":"","SnsFlag":1,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855","VerifyFlag":0,"_h":50,"_index":10,"_offsetTop":396,"stranger":false},"obj":{"id":"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855","uin":0,"weixin":"dancewuli","name":"wuli舞哩客服","alias":"50fb16c0-ff09-11e6-9fe5-dda97284d25b","sex":0,"province":"","city":"","signature":"","address":"dancewuli","star":false,"stranger":false,"avatar":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=635310858&username=@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7"}}],"nameMap":{"@4c32c97337cbb325442c304d6a44e374":"22acb030-ff09-11e6-8a73-cff62d9268c5","@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2":"小桔同学","@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855":"50fb16c0-ff09-11e6-9fe5-dda97284d25b"},"aliasMap":{"@4c32c97337cbb325442c304d6a44e374":"","@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2":"","@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855":""}}}`) 47 | 48 | const CONTACT_LIST = JSON.parse(`{"@4c32c97337cbb325442c304d6a44e374":{"Alias":"ruirui_0914","AppAccountFlag":0,"AttrStatus":2147584103,"ChatRoomId":0,"City":"海淀","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=648035215&username=@4c32c97337cbb325442c304d6a44e374&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"qq5","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"李佳芮","OwnerUin":0,"PYInitial":"LJR","PYQuanPin":"lijiarui","Province":"北京","RemarkName":"22acb030-ff09-11e6-8a73-cff62d9268c5","RemarkPYInitial":"22ACB030FF0911E68A73CFF62D9268C5","RemarkPYQuanPin":"22acb030ff0911e68a73cff62d9268c5","Sex":2,"Signature":"出洞计划 | 向前一步","SnsFlag":49,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@4c32c97337cbb325442c304d6a44e374","VerifyFlag":0,"_h":50,"_index":16,"_offsetTop":696,"stranger":false},"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2":{"AppAccountFlag":0,"ContactFlag":0,"HeadImgFlag":1,"HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=665886775&username=@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"MMOrderSymbol":"~","NickName":"小桔同学","PYInitial":"","PYQuanPin":"","RemarkName":"","RemarkPYInitial":"","RemarkPYQuanPin":"","Sex":0,"Signature":"我是一个性感的机器人","SnsFlag":1,"StarFriend":0,"Uin":244009576,"UserName":"@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2","VerifyFlag":0,"WebWxPluginSwitch":0,"stranger":false},"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855":{"$$hashKey":"01J","Alias":"dancewuli","AppAccountFlag":0,"AttrStatus":233509,"ChatRoomId":0,"City":"","ContactFlag":3,"DisplayName":"","EncryChatRoomId":"","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=635310858&username=@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855&skey=@crypt_ee003aea_5265cdd0c2676aab3df86b0249ae90f7","HideInputBarFlag":0,"IsOwner":0,"KeyWord":"","MMOrderSymbol":"~","MemberCount":0,"MemberList":[],"NickName":"wuli舞哩客服","OwnerUin":0,"PYInitial":"WULIWLKF","PYQuanPin":"wuliwulikefu","Province":"","RemarkName":"50fb16c0-ff09-11e6-9fe5-dda97284d25b","RemarkPYInitial":"50FB16C0FF0911E69FE5DDA97284D25B","RemarkPYQuanPin":"50fb16c0ff0911e69fe5dda97284d25b","Sex":0,"Signature":"","SnsFlag":1,"StarFriend":0,"Statues":0,"Uin":0,"UniFriend":0,"UserName":"@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855","VerifyFlag":0,"_h":50,"_index":10,"_offsetTop":396,"stranger":false}}`) 49 | 50 | const ROOM_ID = '@@9cdc696e490bd76c57e7dd54792dc1408e27d65e312178b1943e88579b7939f4' 51 | 52 | // Mock 53 | async function roomMemberSearch ( 54 | roomId: string, 55 | name: string, 56 | ): Promise { 57 | return new Promise(resolve => { 58 | if (roomId !== ROOM_ID) { 59 | resolve([]) 60 | } 61 | 62 | const idList = [] 63 | for (const [id, payload] of Object.entries(CONTACT_LIST) as [string, any]) { 64 | const nameList = [payload.Alias, payload.NickName, payload.RemarkName] 65 | if (nameList.includes(name)) { 66 | idList.push(id) 67 | } 68 | } 69 | 70 | resolve(idList) 71 | }) 72 | } 73 | 74 | const puppet = { 75 | roomMemberSearch, 76 | } as any as Puppet 77 | 78 | const msg11 = rawObj11.Content 79 | const room11 = rawObj11.FromUserName 80 | const mentionContactList11 = await parseMentionIdList(puppet, room11, msg11) 81 | t.equal(mentionContactList11.length, 0, '@_@ in message should not be treat as contact') 82 | 83 | const msg12 = rawObj12.Content 84 | const room12 = rawObj12.FromUserName 85 | const mentionContactList12 = await parseMentionIdList(puppet, room12, msg12) 86 | t.equal(mentionContactList12.length, 0, 'user@email.com in message should not be treat as contact') 87 | 88 | const msg13 = rawObj13.Content 89 | const room13 = rawObj13.FromUserName 90 | const mentionContactList13 = await parseMentionIdList(puppet, room13, msg13) 91 | t.equal(mentionContactList13.length, 0, '@_@ wow! my email is ruiruibupt@gmail.com in message should not be treat as contact') 92 | 93 | const msg21 = rawObj21.Content 94 | const room21 = rawObj21.FromUserName 95 | const mentionContactList21 = await parseMentionIdList(puppet, room21, msg21) 96 | t.equal(mentionContactList21.length, 1, '@小桔同学 is a contact') 97 | t.equal(mentionContactList21[0], '@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2', 'should get 小桔同学 id right') 98 | 99 | const msg22 = rawObj22.Content 100 | const room22 = rawObj22.FromUserName 101 | const mentionContactList22 = await parseMentionIdList(puppet, room22, msg22) 102 | t.equal(mentionContactList22.length, 2, '@小桔同学 and @wuli舞哩客服 is a contact') 103 | t.equal(mentionContactList22[1], '@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2', 'should get 小桔同学 id right') 104 | t.equal(mentionContactList22[0], '@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855', 'should get wuli舞哩客服 id right') 105 | 106 | const msg31 = rawObj31.Content 107 | const room31 = rawObj31.FromUserName 108 | const mentionContactList31 = await parseMentionIdList(puppet, room31, msg31) 109 | t.equal(mentionContactList31.length, 1, '@wuli舞哩客服 is a contact') 110 | t.equal(mentionContactList31[0], '@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855', 'should get wuli舞哩客服 id right') 111 | 112 | const msg32 = rawObj32.Content 113 | const room32 = rawObj32.FromUserName 114 | const mentionContactList32 = await parseMentionIdList(puppet, room32, msg32) 115 | t.equal(mentionContactList32.length, 2, '@小桔同学 and @wuli舞哩客服 is a contact') 116 | t.equal(mentionContactList32[0], '@36d55130f6a91bae4a2ed2cc5f19c56a9258c65ce3db9777f74f607223ef0855', 'should get wuli舞哩客服 id right') 117 | t.equal(mentionContactList32[1], '@cd7d467d7464e8ff6b0acd29364654f3666df5d04551f6082bfc875f90a6afd2', 'should get 小桔同学 id right') 118 | }) 119 | -------------------------------------------------------------------------------- /src/pure-function-helpers/parse-mention-id-list.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Huan(202109): from Add mention - wechaty/wechaty#362 3 | * https://github.com/wechaty/wechaty/pull/362/files 4 | */ 5 | import { 6 | log, 7 | Puppet, 8 | } from 'wechaty-puppet' 9 | 10 | /** 11 | * mobile: \u2005 12 | * PC、mac: \u0020 13 | * Web: \s 14 | */ 15 | const AT_SEPRATOR_REGEX = /[\u2005\u0020\s]+/ 16 | 17 | /** 18 | * 19 | * Get message mentioned contactList. 20 | * 21 | * Message event table as follows 22 | * 23 | * | | Web | Mac PC Client | iOS Mobile | android Mobile | 24 | * | :--- | :--: | :----: | :---: | :---: | 25 | * | [You were mentioned] tip ([有人@我]的提示) | ✘ | √ | √ | √ | 26 | * | Identify magic code (8197) by copy & paste in mobile | ✘ | √ | √ | ✘ | 27 | * | Identify magic code (8197) by programming | ✘ | ✘ | ✘ | ✘ | 28 | * | Identify two contacts with the same roomAlias by [You were mentioned] tip | ✘ | ✘ | √ | √ | 29 | * 30 | * @returns {Promise} - Return message mentioned contactList 31 | * 32 | * @example 33 | * const contactList = await message.mention() 34 | * console.log(contactList) 35 | */ 36 | async function parseMentionIdList ( 37 | puppet: Puppet, 38 | roomId: string, 39 | text: string, 40 | ): Promise { 41 | log.verbose('Message', 'mention()') 42 | 43 | const atList = text.split(AT_SEPRATOR_REGEX) 44 | // console.log('atList: ', atList) 45 | if (atList.length === 0) return [] 46 | 47 | // Using `filter(e => e.indexOf('@') > -1)` to filter the string without `@` 48 | const mentionNameList = atList 49 | .filter(str => str.includes('@')) 50 | .map(str => multipleAt(str)) 51 | .flat() 52 | .filter(name => !!name) 53 | 54 | // convert 'hello@a@b@c' to [ 'c', 'b@c', 'a@b@c' ] 55 | function multipleAt (str: string) { 56 | str = str.replace(/^.*?@/, '@') 57 | let name = '' 58 | const nameList: string[] = [] 59 | str.split('@') 60 | .filter(mentionName => !!mentionName) 61 | .reverse() 62 | .forEach(mentionName => { 63 | // console.log('mentionName: ', mentionName) 64 | name = mentionName + '@' + name 65 | nameList.push(name.slice(0, -1)) // get rid of the `@` at beginning 66 | }) 67 | return nameList 68 | } 69 | 70 | log.silly('wechaty-puppet-wechat', 'mentionIdList(%s), mentionNameList = "%s"', 71 | text, 72 | JSON.stringify(mentionNameList), 73 | ) 74 | 75 | const contactIdListNested = await Promise.all( 76 | mentionNameList.map( 77 | name => puppet.roomMemberSearch(roomId, name), 78 | ), 79 | ) 80 | 81 | const contactIdList = contactIdListNested.flat() 82 | 83 | if (contactIdList.length === 0) { 84 | log.silly('wechaty-puppet-wechat', 85 | [ 86 | 'mentionIdList() contactIdList can not found member', 87 | 'using roomMemberSearch() from mentionNameList:', 88 | mentionNameList.join(', '), 89 | ].join(''), 90 | ) 91 | } 92 | return contactIdList 93 | } 94 | 95 | export { parseMentionIdList } 96 | -------------------------------------------------------------------------------- /src/pure-function-helpers/retry-policy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Policy, 3 | RetryPolicy, 4 | } from 'cockatiel' 5 | import { 6 | log, 7 | } from 'wechaty-puppet' 8 | 9 | function getRetryPolicy (): RetryPolicy { 10 | const policy = Policy 11 | .handleAll() 12 | .retry() 13 | .attempts(10) 14 | .exponential({ 15 | /** 16 | * ExponentialBackoff 17 | * https://github.com/connor4312/cockatiel#exponentialbackoff 18 | */ 19 | initialDelay : 1000, 20 | maxAttempts : 10, 21 | maxDelay : 10 * 1000, 22 | }) 23 | 24 | policy.onRetry(reason => log.silly('wechaty', 25 | 'retry-policy getRetryPolicy policy.onRetry() reason: "%s"', 26 | JSON.stringify(reason), 27 | )) 28 | policy.onSuccess(({ duration }) => log.silly('wechaty', 29 | 'retry-policy getRetryPolicy policy.onSuccess(): retry call ran in %s ms', 30 | duration, 31 | )) 32 | return policy 33 | } 34 | 35 | /** 36 | * Create a retry policy that'll try whatever function we execute 3 37 | * times with a randomized exponential backoff. 38 | * 39 | * https://github.com/connor4312/cockatiel#policyretry 40 | */ 41 | const retryPolicy = getRetryPolicy() 42 | 43 | export { 44 | retryPolicy, 45 | } 46 | -------------------------------------------------------------------------------- /src/pure-function-helpers/web-message-type.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WebAppMsgType, 3 | WebMessageRawPayload, 4 | WebMessageType, 5 | } from '../web-schemas.js' 6 | 7 | import * as PUPPET from 'wechaty-puppet' 8 | 9 | export function webMessageType ( 10 | rawPayload: WebMessageRawPayload, 11 | ): PUPPET.types.Message { 12 | 13 | switch (rawPayload.MsgType) { 14 | case WebMessageType.TEXT: 15 | switch (rawPayload.SubMsgType) { 16 | case WebMessageType.LOCATION: 17 | return PUPPET.types.Message.Attachment 18 | 19 | default: 20 | return PUPPET.types.Message.Text 21 | } 22 | 23 | case WebMessageType.EMOTICON: 24 | case WebMessageType.IMAGE: 25 | return PUPPET.types.Message.Image 26 | 27 | case WebMessageType.VOICE: 28 | return PUPPET.types.Message.Audio 29 | 30 | case WebMessageType.MICROVIDEO: 31 | case WebMessageType.VIDEO: 32 | return PUPPET.types.Message.Video 33 | 34 | case WebMessageType.APP: 35 | switch (rawPayload.AppMsgType) { 36 | case WebAppMsgType.ATTACH: 37 | case WebAppMsgType.URL: 38 | case WebAppMsgType.READER_TYPE: 39 | return PUPPET.types.Message.Attachment 40 | 41 | default: 42 | return PUPPET.types.Message.Text 43 | } 44 | 45 | /** 46 | * Treat those Types as TEXT 47 | * 48 | * Friendship is a SYS message 49 | * FIXME: should we use better message type at here??? 50 | */ 51 | case WebMessageType.SYS: 52 | return PUPPET.types.Message.Text 53 | // add recall type 54 | case WebMessageType.RECALLED: 55 | return PUPPET.types.Message.Recalled 56 | // VERIFYMSG = 37, 57 | // POSSIBLEFRIEND_MSG = 40, 58 | // SHARECARD = 42, 59 | // LOCATION = 48, 60 | // VOIPMSG = 50, 61 | // STATUSNOTIFY = 51, 62 | // VOIPNOTIFY = 52, 63 | // VOIPINVITE = 53, 64 | // SYSNOTICE = 9999, 65 | // RECALLED = 10002, 66 | default: 67 | return PUPPET.types.Message.Text 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/pure-function-helpers/xml.ts: -------------------------------------------------------------------------------- 1 | export function stripHtml (html?: string): string { 2 | if (!html) { 3 | return '' 4 | } 5 | return html.replace(/(<([^>]+)>)/ig, '') 6 | } 7 | 8 | export function unescapeHtml (str?: string): string { 9 | if (!str) { 10 | return '' 11 | } 12 | return str 13 | .replace(/'/g, "'") 14 | .replace(/"/g, '"') 15 | .replace(/>/g, '>') 16 | .replace(/</g, '<') 17 | .replace(/&/g, '&') 18 | } 19 | 20 | export function digestEmoji (html?: string): string { 21 | if (!html) { 22 | return '' 23 | } 24 | return html 25 | .replace(/]+>/g, 26 | '$3', 27 | ) // 28 | .replace(/<\/span>/g, 29 | '[$2]', 30 | ) // '' 31 | } 32 | 33 | /** 34 | * unifyEmoji: the same emoji will be encoded as different xml code in browser. unify them. 35 | * 36 | * from: 37 | * to: 38 | * 39 | */ 40 | export function unifyEmoji (html?: string): string { 41 | if (!html) { 42 | return '' 43 | } 44 | return html 45 | .replace(/]+>/g, 46 | '', 47 | ) // 48 | .replace(/<\/span>/g, 49 | '', 50 | ) // '' 51 | } 52 | 53 | export function stripEmoji (html?: string): string { 54 | if (!html) { 55 | return '' 56 | } 57 | return html 58 | .replace(/]+>/g, 59 | '', 60 | ) // 61 | .replace(/<\/span>/g, 62 | '', 63 | ) // '' 64 | } 65 | 66 | export function plainText (html?: string): string { 67 | if (!html) { 68 | return '' 69 | } 70 | return stripHtml( 71 | unescapeHtml( 72 | stripHtml( 73 | digestEmoji( 74 | html, 75 | ), 76 | ), 77 | ), 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | // declare const window: any 2 | 3 | // Extend the `Window` from Browser 4 | interface Window { 5 | wechatyPuppetBridgeEmit?: Function, // from puppeteer 6 | } 7 | 8 | declare const WechatyBro: any 9 | 10 | declare module 'puppeteer-extra-plugin-stealth' { 11 | function plugin(config?:any): any; 12 | export = plugin; 13 | } 14 | 15 | declare module 'puppeteer-extra' { 16 | import { Browser, LaunchOptions } from 'puppeteer' 17 | export function use(plugin:any): any; 18 | export function launch(opts?:LaunchOptions): Promise; 19 | } 20 | 21 | declare module 'why-is-node-running' 22 | -------------------------------------------------------------------------------- /src/web-schemas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | export interface WebContactRawPayload { 20 | Alias: string, 21 | City: string, 22 | NickName: string, 23 | Province: string, 24 | RemarkName: string, 25 | Sex: number, 26 | Signature: string, 27 | StarFriend: string, 28 | Uin: string, 29 | UserName: string, 30 | HeadImgUrl: string, 31 | 32 | stranger?: string, // assign by injectio.js 33 | VerifyFlag: number, 34 | } 35 | 36 | export interface WebMessageMediaPayload { 37 | ToUserName: string, 38 | MsgType: number, 39 | MediaId: string, 40 | FileName: string, 41 | FileSize: number, 42 | FileMd5?: string, 43 | FileType?: number, 44 | MMFileExt?: string, 45 | Signature?: string, 46 | } 47 | 48 | /** 49 | * from Message 50 | */ 51 | export interface WebRecomendInfo { 52 | UserName : string, 53 | NickName : string, // display_name 54 | Content : string, // request message 55 | HeadImgUrl : string, // message.RecommendInfo.HeadImgUrl 56 | 57 | Ticket : string, // a pass token 58 | VerifyFlag : number, 59 | } 60 | 61 | /** 62 | * 63 | * Enum for MsgType values. 64 | * @enum {number} 65 | * @property {number} TEXT - MsgType.TEXT (1) for TEXT 66 | * @property {number} IMAGE - MsgType.IMAGE (3) for IMAGE 67 | * @property {number} VOICE - MsgType.VOICE (34) for VOICE 68 | * @property {number} VERIFYMSG - MsgType.VERIFYMSG (37) for VERIFYMSG 69 | * @property {number} POSSIBLEFRIEND_MSG - MsgType.POSSIBLEFRIEND_MSG (40) for POSSIBLEFRIEND_MSG 70 | * @property {number} SHARECARD - MsgType.SHARECARD (42) for SHARECARD 71 | * @property {number} VIDEO - MsgType.VIDEO (43) for VIDEO 72 | * @property {number} EMOTICON - MsgType.EMOTICON (47) for EMOTICON 73 | * @property {number} LOCATION - MsgType.LOCATION (48) for LOCATION 74 | * @property {number} APP - MsgType.APP (49) for APP 75 | * @property {number} VOIPMSG - MsgType.VOIPMSG (50) for VOIPMSG 76 | * @property {number} STATUSNOTIFY - MsgType.STATUSNOTIFY (51) for STATUSNOTIFY 77 | * @property {number} VOIPNOTIFY - MsgType.VOIPNOTIFY (52) for VOIPNOTIFY 78 | * @property {number} VOIPINVITE - MsgType.VOIPINVITE (53) for VOIPINVITE 79 | * @property {number} MICROVIDEO - MsgType.MICROVIDEO (62) for MICROVIDEO 80 | * @property {number} SYSNOTICE - MsgType.SYSNOTICE (9999) for SYSNOTICE 81 | * @property {number} SYS - MsgType.SYS (10000) for SYS 82 | * @property {number} RECALLED - MsgType.RECALLED (10002) for RECALLED 83 | */ 84 | export enum WebMessageType { 85 | TEXT = 1, 86 | IMAGE = 3, 87 | VOICE = 34, 88 | VERIFYMSG = 37, 89 | POSSIBLEFRIEND_MSG = 40, 90 | SHARECARD = 42, 91 | VIDEO = 43, 92 | EMOTICON = 47, 93 | LOCATION = 48, 94 | APP = 49, 95 | VOIPMSG = 50, 96 | STATUSNOTIFY = 51, 97 | VOIPNOTIFY = 52, 98 | VOIPINVITE = 53, 99 | MICROVIDEO = 62, 100 | SYSNOTICE = 9999, 101 | SYS = 10000, 102 | RECALLED = 10002, 103 | } 104 | 105 | // export type MessageTypeName = 'TEXT' | 'IMAGE' | 'VOICE' | 'VERIFYMSG' | 'POSSIBLEFRIEND_MSG' 106 | // | 'SHARECARD' | 'VIDEO' | 'EMOTICON' | 'LOCATION' | 'APP' | 'VOIPMSG' | 'STATUSNOTIFY' 107 | // | 'VOIPNOTIFY' | 'VOIPINVITE' | 'MICROVIDEO' | 'SYSNOTICE' | 'SYS' | 'RECALLED' 108 | 109 | // export type MessageTypeValue = 1 | 3 | 34 | 37 | 40 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 9999 | 10000 | 10002 110 | 111 | // export interface WebMsgTypeDict { 112 | // [index: string]: string|number, 113 | // // MessageTypeName: MessageTypeValue 114 | // // , MessageTypeValue: MessageTypeName 115 | // } 116 | 117 | /** 118 | * 119 | * Enum for AppMsgType values. 120 | * 121 | * @enum {number} 122 | * @property {number} TEXT - AppMsgType.TEXT (1) for TEXT 123 | * @property {number} IMG - AppMsgType.IMG (2) for IMG 124 | * @property {number} AUDIO - AppMsgType.AUDIO (3) for AUDIO 125 | * @property {number} VIDEO - AppMsgType.VIDEO (4) for VIDEO 126 | * @property {number} URL - AppMsgType.URL (5) for URL 127 | * @property {number} ATTACH - AppMsgType.ATTACH (6) for ATTACH 128 | * @property {number} OPEN - AppMsgType.OPEN (7) for OPEN 129 | * @property {number} EMOJI - AppMsgType.EMOJI (8) for EMOJI 130 | * @property {number} VOICE_REMIND - AppMsgType.VOICE_REMIND (9) for VOICE_REMIND 131 | * @property {number} SCAN_GOOD - AppMsgType.SCAN_GOOD (10) for SCAN_GOOD 132 | * @property {number} GOOD - AppMsgType.GOOD (13) for GOOD 133 | * @property {number} EMOTION - AppMsgType.EMOTION (15) for EMOTION 134 | * @property {number} CARD_TICKET - AppMsgType.CARD_TICKET (16) for CARD_TICKET 135 | * @property {number} REALTIME_SHARE_LOCATION - AppMsgType.REALTIME_SHARE_LOCATION (17) for REALTIME_SHARE_LOCATION 136 | * @property {number} TRANSFERS - AppMsgType.TRANSFERS (2e3) for TRANSFERS 137 | * @property {number} RED_ENVELOPES - AppMsgType.RED_ENVELOPES (2001) for RED_ENVELOPES 138 | * @property {number} READER_TYPE - AppMsgType.READER_TYPE (100001) for READER_TYPE 139 | */ 140 | export enum WebAppMsgType { 141 | TEXT = 1, 142 | IMG = 2, 143 | AUDIO = 3, 144 | VIDEO = 4, 145 | URL = 5, 146 | ATTACH = 6, 147 | OPEN = 7, 148 | EMOJI = 8, 149 | VOICE_REMIND = 9, 150 | SCAN_GOOD = 10, 151 | GOOD = 13, 152 | EMOTION = 15, 153 | CARD_TICKET = 16, 154 | REALTIME_SHARE_LOCATION = 17, 155 | TRANSFERS = 2e3, 156 | RED_ENVELOPES = 2001, 157 | READER_TYPE = 100001, 158 | } 159 | /** 160 | * MsgSendStatus from webwx-app 161 | * @see https://github.com/wechaty/webwx-app-tracker/blob/a12c78fb8bd7186c0f3bb0e18dd611151e6b8aac/formatted/webwxApp.js#L7520-L7524 162 | * 163 | * //msg send status 164 | * MSG_SEND_STATUS_READY: 0 165 | * MSG_SEND_STATUS_SENDING: 1 166 | * MSG_SEND_STATUS_SUCC: 2 167 | * MSG_SEND_STATUS_FAIL: 5 168 | */ 169 | export enum MsgSendStatus{ 170 | READY=0, 171 | SENDING=1, 172 | SUCCESS=2, 173 | FAIL=5, 174 | } 175 | export interface WebMessageRawPayload { 176 | MsgId: string, 177 | 178 | MMActualSender: string, // getUserContact(message.MMActualSender,message.MMPeerUserName).isContact() 179 | MMPeerUserName: string, // message.MsgType == CONF.MSGTYPE_TEXT && message.MMPeerUserName == 'newsapp' 180 | ToUserName: string, 181 | FromUserName: string, 182 | MMActualContent: string, // Content has @id prefix added by wx 183 | Content: string, 184 | 185 | MMDigest: string, 186 | MMDisplayTime: number, // Javascript timestamp of milliseconds 187 | CreateTime: number, 188 | 189 | /** 190 | * MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL 191 | * class="cover" mm-src="{{getMsgImg(message.MsgId,'slave')}}" 192 | */ 193 | Url: string, 194 | MMAppMsgDesc: string, // class="desc" ng-bind="message.MMAppMsgDesc" 195 | 196 | /** 197 | * Attachment 198 | * 199 | * MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_ATTACH 200 | */ 201 | FileName: string, // FileName: '钢甲互联项目BP1108.pdf', 202 | FileSize: number, // FileSize: '2845701', 203 | MediaId: string, // MediaId: '@crypt_b1a45e3f_c21dceb3ac01349... 204 | MMFileExt: string, // doc, docx ... 'undefined'? 205 | Signature: string, // checkUpload return the signature used to upload large files 206 | 207 | MMAppMsgFileExt: string, // doc, docx ... 'undefined'? 208 | MMAppMsgFileSize: string, // '2.7MB', 209 | MMAppMsgDownloadUrl: string, // 'https://file.wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetmedia?sender=@4f549c2dafd5ad731afa4d857bf03c10&mediaid=@crypt_b1a45e3f 210 | // 下载 213 | MMUploadProgress: number, // < 100 214 | 215 | /** 216 | * 模板消息 217 | * MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_READER_TYPE 218 | * item.url 219 | * item.title 220 | * item.pub_time 221 | * item.cover 222 | * item.digest 223 | */ 224 | MMCategory: any[], // item in message.MMCategory 225 | 226 | /** 227 | * Type 228 | * 229 | * MsgType == CONF.MSGTYPE_VOICE : ng-style="{'width':40 + 7*message.VoiceLength/1000} 230 | */ 231 | MsgType: number, 232 | AppMsgType: WebAppMsgType, // message.MsgType == CONF.MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL 233 | // message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType != CONF.MSGTYPE_LOCATION 234 | 235 | SubMsgType: WebMessageType, // "msgType":"{{message.MsgType}}","subType":{{message.SubMsgType||0}},"msgId":"{{message.MsgId}}" 236 | 237 | /** 238 | * Status-es 239 | */ 240 | Status: string, 241 | MMStatus: MsgSendStatus, // img ng-show="message.MMStatus == 1" class="ico_loading" 242 | // ng-click="resendMsg(message)" ng-show="message.MMStatus == 5" title="重新发送" 243 | MMFileStatus: number, //

244 | // CONF.MM_SEND_FILE_STATUS_QUEUED, MM_SEND_FILE_STATUS_SENDING 245 | 246 | /** 247 | * Location 248 | */ 249 | MMLocationUrl: string, // ng-if="message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType == CONF.MSGTYPE_LOCATION" 250 | // 251 | // 'http://apis.map.qq.com/uri/v1/geocoder?coord=40.075041,116.338994' 252 | MMLocationDesc: string, // MMLocationDesc: '北京市昌平区回龙观龙腾苑(五区)内(龙腾街南)', 253 | 254 | /** 255 | * MsgType == CONF.MSGTYPE_EMOTICON 256 | * 257 | * getMsgImg(message.MsgId,'big',message) 258 | */ 259 | 260 | /** 261 | * Image 262 | * 263 | * getMsgImg(message.MsgId,'slave') 264 | */ 265 | MMImgStyle: string, // ng-style="message.MMImgStyle" 266 | MMPreviewSrc: string, // message.MMPreviewSrc || message.MMThumbSrc || getMsgImg(message.MsgId,'slave') 267 | MMThumbSrc: string, 268 | 269 | /** 270 | * Friend Request & ShareCard ? 271 | * 272 | * MsgType == CONF.MSGTYPE_SHARECARD" ng-click="showProfile($event,message.RecommendInfo.UserName) 273 | * MsgType == CONF.MSGTYPE_VERIFYMSG 274 | */ 275 | RecommendInfo? : WebRecomendInfo, 276 | 277 | /** 278 | * Transpond Message 279 | */ 280 | MsgIdBeforeTranspond? : string, // oldMsg.MsgIdBeforeTranspond || oldMsg.MsgId, 281 | isTranspond? : boolean, 282 | MMSourceMsgId? : string, 283 | MMSendContent? : string, 284 | 285 | MMIsChatRoom? : boolean, 286 | 287 | } 288 | 289 | /** 290 | * UploadMediaType from webwx-app 291 | * @see https://github.com/wechaty/webwx-app-tracker/blob/a12c78fb8bd7186c0f3bb0e18dd611151e6b8aac/formatted/webwxApp.js#L7545-L7549 292 | * 293 | * //upload media type 294 | * UPLOAD_MEDIA_TYPE_IMAGE: 1 295 | * UPLOAD_MEDIA_TYPE_VIDEO: 2 296 | * UPLOAD_MEDIA_TYPE_AUDIO: 3 297 | * UPLOAD_MEDIA_TYPE_ATTACHMENT: 4, 298 | */ 299 | export enum UploadMediaType { 300 | Unknown = 0, 301 | Image = 1, 302 | Video = 2, 303 | Audio = 3, 304 | Attachment = 4, 305 | } 306 | 307 | export interface WebRoomRawMember { 308 | UserName : string, 309 | NickName : string, 310 | DisplayName : string, 311 | HeadImgUrl : string, 312 | } 313 | 314 | export interface WebRoomRawPayload { 315 | UserName: string, 316 | EncryChatRoomId: string, 317 | NickName: string, 318 | OwnerUin: number, 319 | ChatRoomOwner: string, 320 | MemberList?: WebRoomRawMember[], 321 | } 322 | -------------------------------------------------------------------------------- /tests/fixtures/inject-file.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const retObj = { 3 | code: 42, 4 | message: 'meaning of the life', 5 | } 6 | return retObj 7 | }()) 8 | -------------------------------------------------------------------------------- /tests/fixtures/smoke-testing.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import { 3 | PuppetWeChat, 4 | VERSION, 5 | log, 6 | } from 'wechaty-puppet-wechat' 7 | 8 | log.level('verbose') 9 | 10 | async function main () { 11 | const puppet = new PuppetWeChat() 12 | const future = new Promise(resolve => puppet.once('scan', resolve)) 13 | 14 | await puppet.start() 15 | await future 16 | 17 | log.info('SmokeTesting', 'main() event `scan` received!') 18 | 19 | await puppet.stop() 20 | 21 | if (VERSION === '0.0.0') { 22 | throw new Error('VERSION should not be 0.0.0 when publishing') 23 | } 24 | 25 | log.info('SmokeTesting', `Puppet v${puppet.version()} smoke testing passed.`) 26 | return 0 27 | } 28 | 29 | main() 30 | .then(process.exit) 31 | .catch(e => { 32 | console.error(e) 33 | process.exit(1) 34 | }) 35 | -------------------------------------------------------------------------------- /tests/puppeteer-attachment.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { test, sinon } from 'tstest' 21 | 22 | import { log } from '../src/config.js' 23 | 24 | import { PuppetWeChat } from '../src/puppet-wechat.js' 25 | import { WebMessageMediaPayload, WebMessageType } from '../src/web-schemas.js' 26 | import { FileBox } from 'file-box' 27 | import request from 'request' 28 | 29 | class PuppetTest extends PuppetWeChat { 30 | 31 | override getExtName (filename:string) { 32 | return super.getExtName(filename) 33 | } 34 | 35 | override getMsgType (ext: string): WebMessageType { 36 | return super.getMsgType(ext) 37 | } 38 | 39 | } 40 | 41 | test('Send Attachment', async (t) => { 42 | const puppet = new PuppetTest() 43 | 44 | const sandbox = sinon.createSandbox() 45 | sandbox.stub(puppet.bridge, 'getCheckUploadUrl').returns(Promise.resolve('getCheckUploadUrl')) 46 | sandbox.stub(puppet.bridge, 'getUploadMediaUrl').returns(Promise.resolve('getUploadMediaUrl')) 47 | sandbox.stub(puppet.bridge, 'getBaseRequest').returns(Promise.resolve('{}')) 48 | sandbox.stub(puppet.bridge, 'getPassticket').returns(Promise.resolve('getPassticket')) 49 | sandbox.stub(puppet.bridge, 'cookies').returns(Promise.resolve([])) 50 | sandbox.stub(puppet.bridge, 'hostname').returns(Promise.resolve('hostname')) 51 | sandbox.replaceGetter(puppet, 'currentUserId', () => 'currentUserId') 52 | const conversationId = 'filehelper' 53 | const uploadMediaUrl = await puppet.bridge.getUploadMediaUrl() 54 | const checkUploadUrl = await puppet.bridge.getCheckUploadUrl() 55 | const mockedResCheckUpload = { 56 | AESKey: 'AESKey', 57 | Signature: 'Signature', 58 | } 59 | const mockedResUploadMedia = { 60 | MediaId: 'MediaId', 61 | } 62 | const mockSendMedia = async (msg: WebMessageMediaPayload) => { 63 | log.silly('TestMessage', 'mocked bridge.sendMedia(%o)', msg) 64 | const ext = puppet.getExtName(msg.FileName) 65 | const msgType = puppet.getMsgType(ext) 66 | t.match(msg.MMFileExt, /^\w+$/, 'MMFileExt should match /^\\w+$/') 67 | t.equal(msg.MsgType, msgType, `MsgType should be "${msgType}"`) 68 | t.equal(msg.MMFileExt, ext, `MMFileExt should be "${ext}"`) 69 | return true 70 | } 71 | const mockPostRequest = ( 72 | options: request.RequiredUriUrl & request.CoreOptions, 73 | callback?: request.RequestCallback, 74 | ): request.Request => { 75 | log.silly('TestMessage', 'mocked request.post(%o)', options) 76 | let path: string | null = null 77 | if ('url' in options) { 78 | if (typeof options.url === 'object') { 79 | path = options.url.path as string 80 | } else { 81 | path = options.url 82 | } 83 | } else if ('uri' in options) { 84 | if (typeof options.uri === 'object') { 85 | path = options.uri.path as string 86 | } else { 87 | path = options.uri 88 | } 89 | } 90 | if (path && callback) { 91 | if (path.includes(uploadMediaUrl)) { 92 | log.silly( 93 | 'TestMessage', 94 | 'requesting %s:%o', 95 | uploadMediaUrl, 96 | options.formData, 97 | ) 98 | const formData = options.formData as { 99 | name: string; 100 | mediatype: string; 101 | type: string; 102 | uploadmediarequest: string; 103 | } 104 | const uploadmediarequest = JSON.parse(formData.uploadmediarequest) as { 105 | AESKey: string; 106 | BaseRequest: any; 107 | ClientMediaId: number; 108 | DataLen: number; 109 | FileMd5: string; 110 | FromUserName: string; 111 | MediaType: number; 112 | Signature: string; 113 | StartPos: number; 114 | ToUserName: string; 115 | TotalLen: number; 116 | UploadType: number; 117 | } 118 | const name = formData.name 119 | const ext = puppet.getExtName(name) 120 | let mediatype: string 121 | switch (puppet.getMsgType(ext)) { 122 | case WebMessageType.IMAGE: 123 | mediatype = 'pic' 124 | break 125 | case WebMessageType.VIDEO: 126 | mediatype = 'video' 127 | break 128 | default: 129 | mediatype = 'doc' 130 | } 131 | t.equal(formData.mediatype, mediatype, `mediatype should be "${mediatype}"`) 132 | t.equal(uploadmediarequest.MediaType, 4, 'MediaType should be 4') 133 | t.equal(uploadmediarequest.UploadType, 2, 'UploadType should be 2') 134 | 135 | callback(null, {} as any, mockedResUploadMedia) 136 | } else if (path.includes(checkUploadUrl)) { 137 | callback(null, {} as any, mockedResCheckUpload) 138 | } else { 139 | log.silly('Unknown request:%s', path) 140 | } 141 | } 142 | return null as any 143 | } 144 | sandbox.stub(puppet.bridge, 'sendMedia').callsFake(mockSendMedia) 145 | sandbox.stub(request, 'post').callsFake(mockPostRequest) 146 | 147 | await Promise.all( 148 | [ 149 | 'gif', 150 | 'png', 151 | 'jpg', 152 | 'jpeg', 153 | 'bmp', 154 | 'gif', 155 | 'html', 156 | 'txt', 157 | 'docx', 158 | 'doc', 159 | 'xlsx', 160 | 'csv', 161 | 'mp3', 162 | 'mp4', 163 | 'mkv', 164 | ].map(async (ext) => { 165 | const giffile = FileBox.fromBuffer(Buffer.alloc(10), 'test.' + ext) 166 | await puppet.messageSendFile(conversationId, giffile) 167 | }), 168 | ) 169 | sandbox.restore() 170 | }) 171 | -------------------------------------------------------------------------------- /tests/puppeteer-contact.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Wechaty - https://github.com/chatie/wechaty 5 | * 6 | * @copyright 2016-2018 Huan LI 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | */ 21 | import { 22 | test, 23 | sinon, 24 | } from 'tstest' 25 | // import { 26 | // cloneClass, 27 | // } from 'clone-class' 28 | 29 | import { 30 | log, 31 | } from '../src/config.js' 32 | 33 | import PuppetWeChat from '../src/puppet-wechat.js' 34 | 35 | test('Contact smoke testing', async t => { 36 | const UserName = '@0bb3e4dd746fdbd4a80546aef66f4085' 37 | const NickName = 'NickNameTest' 38 | const RemarkName = 'AliasTest' 39 | 40 | const sandbox = sinon.createSandbox() 41 | 42 | function mockContactPayload (id: string) { 43 | log.verbose('PuppeteerContactTest', 'mockContactPayload(%s)', id) 44 | return new Promise(resolve => { 45 | if (id !== UserName) return resolve({}) 46 | setImmediate(() => resolve({ 47 | NickName, 48 | RemarkName, 49 | UserName, 50 | })) 51 | }) 52 | } 53 | 54 | const puppet = new PuppetWeChat() 55 | sandbox.stub(puppet as any, 'contactRawPayload').callsFake(mockContactPayload as any) 56 | 57 | const contactPayload = await puppet.contactPayload(UserName) 58 | 59 | // const MyContact = cloneClass(Contact) 60 | // MyContact.puppet = puppet as any // FIXME: any 61 | 62 | // const c = new MyContact(UserName) 63 | t.equal(contactPayload.id, UserName, 'id/UserName right') 64 | 65 | t.equal(contactPayload.name, NickName, 'NickName set') 66 | t.equal(contactPayload.alias, RemarkName, 'should get the right alias from Contact') 67 | 68 | sandbox.restore() 69 | 70 | // const contact1 = await Contact.find({name: 'NickNameTest'}) 71 | // t.is(contact1.id, UserName, 'should find contact by name') 72 | 73 | // const contact2 = await Contact.find({alias: 'AliasTest'}) 74 | // t.is(contact2.id, UserName, 'should find contact by alias') 75 | }) 76 | -------------------------------------------------------------------------------- /tests/puppeteer-friendship.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | 21 | import { 22 | test, 23 | sinon, 24 | } from 'tstest' 25 | 26 | import * as PUPPET from 'wechaty-puppet' 27 | 28 | import { 29 | PuppetWeChat, 30 | } from '../src/puppet-wechat.js' 31 | import type { 32 | WebMessageRawPayload, 33 | } from '../src/web-schemas.js' 34 | 35 | // class WechatyTest extends Wechaty { 36 | // public initPuppetAccessory (puppet: PuppetWeChat) { 37 | // super.initPuppetAccessory(puppet) 38 | // } 39 | // } 40 | 41 | class PuppetTest extends PuppetWeChat { 42 | 43 | public override contactRawPayload (id: string) { 44 | return super.contactRawPayload(id) 45 | } 46 | 47 | public override roomRawPayload (id: string) { 48 | return super.roomRawPayload(id) 49 | } 50 | 51 | public override messageRawPayload (id: string) { 52 | return super.messageRawPayload(id) 53 | } 54 | 55 | } 56 | 57 | test('PuppetWeChatFriendship.receive smoke testing', async (t) => { 58 | const puppet = new PuppetTest() 59 | // const wechaty = new WechatyTest({ puppet }) 60 | // wechaty.initPuppetAccessory(puppet) 61 | 62 | const rawMessagePayload: WebMessageRawPayload = JSON.parse(` 63 | {"MsgId":"3225371967511173931","FromUserName":"fmessage","ToUserName":"@f7321198e0349f1b38c9f2ef158f70eb","MsgType":37,"Content":"<msg fromusername=\\"wxid_a8d806dzznm822\\" encryptusername=\\"v1_c1e03a32c60dd9a9e14f1092132808a2de0ad363f79b303693654282954fbe4d3e12481166f4b841f28de3dd58b0bd54@stranger\\" fromnickname=\\"李卓桓.PreAngel\\" content=\\"我是群聊&quot;Wechaty&quot;的李卓桓.PreAngel\\" shortpy=\\"LZHPREANGEL\\" imagestatus=\\"3\\" scene=\\"14\\" country=\\"CN\\" province=\\"Beijing\\" city=\\"Haidian\\" sign=\\"投资人中最会飞的程序员。好友请加 918999 ,因为本号好友已满。\\" percard=\\"1\\" sex=\\"1\\" alias=\\"zixia008\\" weibo=\\"\\" weibonickname=\\"\\" albumflag=\\"0\\" albumstyle=\\"0\\" albumbgimgid=\\"911623988445184_911623988445184\\" snsflag=\\"49\\" snsbgimgid=\\"http://mmsns.qpic.cn/mmsns/zZSYtpeVianSQYekFNbuiajROicLficBzzeGuvQjnWdGDZ4budZovamibQnoKWba7D2LeuQRPffS8aeE/0\\" snsbgobjectid=\\"12183966160653848744\\" mhash=\\"\\" mfullhash=\\"\\" bigheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/0\\" smallheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/132\\" ticket=\\"v2_ba70dfbdb1b10168d61c1ab491be19e219db11ed5c28701f605efb4dccbf132f664d8a4c9ef6e852b2a4e8d8638be81d125c2e641f01903669539c53f1e582b2@stranger\\" opcode=\\"2\\" googlecontact=\\"\\" qrticket=\\"\\" chatroomusername=\\"2332413729@chatroom\\" sourceusername=\\"\\" sourcenickname=\\"\\"><brandlist count=\\"0\\" ver=\\"670564024\\"></brandlist></msg>","Status":3,"ImgStatus":1,"CreateTime":1475567560,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","NickName":"李卓桓.PreAngel","Province":"北京","City":"海淀","Content":"我是群聊\\"Wechaty\\"的李卓桓.PreAngel","Signature":"投资人中最会飞的程序员。好友请加 918999 ,因为本号好友已满。","Alias":"zixia008","Scene":14,"AttrStatus":233251,"Sex":1,"Ticket":"v2_ba70dfbdb1b10168d61c1ab491be19e219db11ed5c28701f605efb4dccbf132f664d8a4c9ef6e852b2a4e8d8638be81d125c2e641f01903669539c53f1e582b2@stranger","OpCode":2,"HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49&skey=@crypt_f9cec94b_5b073dca472bd5e41771d309bb8c37bd&msgid=3225371967511173931","MMFromVerifyMsg":true},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3225371967511174000,"MMPeerUserName":"fmessage","MMDigest":"李卓桓.PreAngel想要将你加为朋友","MMIsSend":false,"MMIsChatRoom":false,"MMUnread":true,"LocalID":"3225371967511173931","ClientMsgId":"3225371967511173931","MMActualContent":"<msg fromusername=\\"wxid_a8d806dzznm822\\" encryptusername=\\"v1_c1e03a32c60dd9a9e14f1092132808a2de0ad363f79b303693654282954fbe4d3e12481166f4b841f28de3dd58b0bd54@stranger\\" fromnickname=\\"李卓桓.PreAngel\\" content=\\"我是群聊&quot;Wechaty&quot;的李卓桓.PreAngel\\" shortpy=\\"LZHPREANGEL\\" imagestatus=\\"3\\" scene=\\"14\\" country=\\"CN\\" province=\\"Beijing\\" city=\\"Haidian\\" sign=\\"投资人中最会飞的程序员。好友请加 918999 ,因为本号好友已满。\\" percard=\\"1\\" sex=\\"1\\" alias=\\"zixia008\\" weibo=\\"\\" weibonickname=\\"\\" albumflag=\\"0\\" albumstyle=\\"0\\" albumbgimgid=\\"911623988445184_911623988445184\\" snsflag=\\"49\\" snsbgimgid=\\"http://mmsns.qpic.cn/mmsns/zZSYtpeVianSQYekFNbuiajROicLficBzzeGuvQjnWdGDZ4budZovamibQnoKWba7D2LeuQRPffS8aeE/0\\" snsbgobjectid=\\"12183966160653848744\\" mhash=\\"\\" mfullhash=\\"\\" bigheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/0\\" smallheadimgurl=\\"http://wx.qlogo.cn/mmhead/ver_1/xct7OPTbuU6iaS8gTaK2VibhRs3rATwnU1rCUwWu8ic89EGOynaic2Y4MUdKr66khhAplcfFlm7xbXhum5reania3fXDXH6CI9c3Bb4BODmYAh04/132\\" ticket=\\"v2_ba70dfbdb1b10168d61c1ab491be19e219db11ed5c28701f605efb4dccbf132f664d8a4c9ef6e852b2a4e8d8638be81d125c2e641f01903669539c53f1e582b2@stranger\\" opcode=\\"2\\" googlecontact=\\"\\" qrticket=\\"\\" chatroomusername=\\"2332413729@chatroom\\" sourceusername=\\"\\" sourcenickname=\\"\\"><brandlist count=\\"0\\" ver=\\"670564024\\"></brandlist></msg>","MMActualSender":"fmessage","MMDigestTime":"15:52","MMDisplayTime":1475567560,"MMTime":"15:52"} 64 | `) 65 | 66 | const info = rawMessagePayload.RecommendInfo! 67 | 68 | const hello = info.Content 69 | const ticket = info.Ticket 70 | const id = 'id' 71 | const type = PUPPET.types.Friendship.Receive 72 | 73 | const payload: PUPPET.payloads.Friendship = { 74 | contactId: info.UserName, 75 | hello, 76 | id, 77 | ticket, 78 | timestamp: Math.floor(Date.now() / 1000), // in seconds 79 | type, 80 | } 81 | 82 | const sandbox = sinon.createSandbox() 83 | 84 | // Huan(202002) 85 | // FIXME: Argument of type 'FriendshipPayloadReceive' is not assignable to parameter of type 'void | undefined'. 86 | // Type 'FriendshipPayloadReceive' is not assignable to type 'void'.ts(2345) 87 | sandbox.stub(puppet, 'friendshipPayload').resolves(payload as any) 88 | // sandbox.stub(puppet, 'friendshipPayloadCache').returns(payload) 89 | 90 | // const contact = wechaty.Contact.load(info.UserName) 91 | // const contactPayload = await puppet.contactPayload(info.UserName) 92 | 93 | // const fr = wechaty.Friendship.load(id) 94 | // await fr.ready() 95 | const friendshipPayload = await puppet.friendshipPayload(id) 96 | 97 | t.equal(friendshipPayload.hello, '我是群聊"Wechaty"的李卓桓.PreAngel', 'should has right request message') 98 | t.equal(friendshipPayload.contactId, info.UserName, 'should have a Contact id') 99 | t.equal(friendshipPayload.type, PUPPET.types.Friendship.Receive, 'should be receive type') 100 | 101 | sandbox.restore() 102 | }) 103 | 104 | test('PuppetWeChatFriendship.confirm smoke testing', async (t) => { 105 | 106 | const CONTACT_ID = 'contact-id' 107 | 108 | const puppet = new PuppetTest() 109 | // const wechaty = new WechatyTest({ puppet }) 110 | // wechaty.initPuppetAccessory(puppet) 111 | 112 | const rawMessagePayload: WebMessageRawPayload = JSON.parse(` 113 | {"MsgId":"3382012679535022763","FromUserName":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","ToUserName":"@f7321198e0349f1b38c9f2ef158f70eb","MsgType":10000,"Content":"You have added 李卓桓.PreAngel as your WeChat contact. Start chatting!","Status":4,"ImgStatus":1,"CreateTime":1475569920,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3382012679535022600,"MMPeerUserName":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","MMDigest":"You have added 李卓桓.PreAngel as your WeChat contact. Start chatting!","MMIsSend":false,"MMIsChatRoom":false,"LocalID":"3382012679535022763","ClientMsgId":"3382012679535022763","MMActualContent":"You have added 李卓桓.PreAngel as your WeChat contact. Start chatting!","MMActualSender":"@04a0fa314d0d8d50dc54e2ec908744ebf46b87404d143fd9a6692182dd90bd49","MMDigestTime":"16:32","MMDisplayTime":1475569920,"MMTime":"16:32"} 114 | `) 115 | 116 | const friendshipPayload: PUPPET.payloads.Friendship = { 117 | contactId : CONTACT_ID, 118 | id : 'id', 119 | timestamp : Math.floor(Date.now() / 1000), // in seconds 120 | type : PUPPET.types.Friendship.Confirm, 121 | } 122 | 123 | const sandbox = sinon.createSandbox() 124 | 125 | sandbox.stub(puppet, 'messageRawPayload').resolves(rawMessagePayload) 126 | 127 | sandbox.stub(puppet, 'contactPayload').resolves({} as any) 128 | // sandbox.stub(puppet, 'contactPayloadCache') .returns({}) 129 | 130 | /** 131 | * Huan(202002) 132 | * FIXME: Argument of type 'FriendshipPayloadReceive' is not assignable to parameter of type 'void | undefined'. 133 | * Type 'FriendshipPayloadReceive' is not assignable to type 'void'.ts(2345) 134 | */ 135 | sandbox.stub(puppet, 'friendshipPayload').resolves(friendshipPayload as any) 136 | // sandbox.stub(puppet, 'friendshipPayloadCache') .returns(friendshipPayload) 137 | 138 | // const msg = wechaty.Message.create(rawMessagePayload.MsgId) 139 | // await msg.ready() 140 | const msgPayload = await puppet.messagePayload(rawMessagePayload.MsgId) 141 | 142 | t.ok(/^You have added (.+) as your WeChat contact. Start chatting!$/.test(msgPayload.text || ''), 'should match confirm message') 143 | 144 | // const fr = wechaty.Friendship.load('xx') 145 | // await fr.ready() 146 | const friendshipPayload2 = await puppet.friendshipPayload('xx') 147 | 148 | t.equal(friendshipPayload2.contactId, CONTACT_ID, 'should have a Contact id') 149 | t.equal(friendshipPayload2.type, PUPPET.types.Friendship.Confirm, 'should be confirm type') 150 | 151 | sandbox.restore() 152 | }) 153 | -------------------------------------------------------------------------------- /tests/puppeteer-message.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import { 21 | test, 22 | sinon, 23 | } from 'tstest' 24 | 25 | import { 26 | // config, 27 | log, 28 | } from '../src/config.js' 29 | 30 | import * as PUPPET from 'wechaty-puppet' 31 | 32 | import { 33 | PuppetWeChat, 34 | } from '../src/puppet-wechat.js' 35 | import type { 36 | WebMessageRawPayload, 37 | // WebRoomRawPayload, 38 | } from '../src/web-schemas.js' 39 | 40 | // class WechatyTest extends Wechaty { 41 | // public initPuppetAccessory (puppet: PuppetWeChat) { 42 | // super.initPuppetAccessory(puppet) 43 | // } 44 | // } 45 | 46 | class PuppetTest extends PuppetWeChat { 47 | 48 | public override contactRawPayload (id: string) { 49 | return super.contactRawPayload(id) 50 | } 51 | 52 | public override roomRawPayload (id: string) { 53 | return super.roomRawPayload(id) 54 | } 55 | 56 | public override messageRawPayload (id: string) { 57 | return super.messageRawPayload(id) 58 | } 59 | 60 | } 61 | 62 | // class PuppetWeChatTest extends PuppetWeChat { 63 | // public id?: string = undefined 64 | // } 65 | 66 | test('constructor()', async t => { 67 | const puppet = new PuppetTest() 68 | // const wechaty = new WechatyTest({ puppet }) 69 | // wechaty.initPuppetAccessory(puppet) 70 | 71 | const MOCK_USER_ID = 'TEST-USER-ID' 72 | 73 | const rawPayload: WebMessageRawPayload = JSON.parse('{"MsgId":"179242112323992762","FromUserName":"@0bb3e4dd746fdbd4a80546aef66f4085","ToUserName":"@16d20edf23a3bf3bc71bb4140e91619f3ff33b4e33f7fcd25e65c1b02c7861ab","MsgType":1,"Content":"test123","Status":3,"ImgStatus":1,"CreateTime":1461652670,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":179242112323992770,"MMPeerUserName":"@0bb3e4dd746fdbd4a80546aef66f4085","MMDigest":"test123","MMIsSend":false,"MMIsChatRoom":false,"MMUnread":true,"LocalID":"179242112323992762","ClientMsgId":"179242112323992762","MMActualContent":"test123","MMActualSender":"@0bb3e4dd746fdbd4a80546aef66f4085","MMDigestTime":"14:37","MMDisplayTime":1461652670,"MMTime":"14:37"}') 74 | 75 | const EXPECTED = { 76 | id: '179242112323992762', 77 | talkerId: '@0bb3e4dd746fdbd4a80546aef66f4085', 78 | } 79 | const sandbox = sinon.createSandbox() 80 | const mockMessagePayload = async (/* _: string */) => { 81 | const payload: PUPPET.payloads.Message = { 82 | id : EXPECTED.id, 83 | listenerId : 'listenerId', 84 | mentionIdList : [], 85 | talkerId : EXPECTED.talkerId, 86 | timestamp : Date.now(), 87 | type : PUPPET.types.Message.Text, 88 | } 89 | return payload 90 | } 91 | 92 | sandbox.stub(puppet, 'contactPayload').returns({} as any) 93 | // sandbox.stub(puppet, 'contactPayloadCache').returns({}) 94 | 95 | sandbox.stub(puppet, 'roomPayload').returns({} as any) 96 | // sandbox.stub(puppet, 'roomPayloadCache').returns({}) 97 | 98 | sandbox.stub(puppet, 'messagePayload').callsFake(mockMessagePayload) 99 | // sandbox.stub(puppet, 'messagePayloadCache').callsFake(mockMessagePayload) 100 | 101 | await puppet.login(MOCK_USER_ID) 102 | 103 | const msgPayload = await puppet.messagePayload(rawPayload.MsgId) 104 | 105 | t.equal(msgPayload.id, EXPECTED.id, 'id right') 106 | t.equal(msgPayload.talkerId, EXPECTED.talkerId, 'talkerId right') 107 | 108 | sandbox.restore() 109 | }) 110 | 111 | // Issue #445 112 | // XXX have to use test.serial() because mockGetContact can not be parallel 113 | test('ready()', async t => { 114 | 115 | // must different with other rawData, because Contact class with load() will cache the result. or use Contact.resetPool() 116 | const rawPayload: WebMessageRawPayload = JSON.parse('{"MsgId":"3009511950433684462","FromUserName":"@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823","ToUserName":"@b58f91e0c5c9e841e290d862ddb63c14","MsgType":1,"Content":"哈哈","Status":3,"ImgStatus":1,"CreateTime":1462887888,"VoiceLength":0,"PlayLength":0,"FileName":"","FileSize":"","MediaId":"","Url":"","AppMsgType":0,"StatusNotifyCode":0,"StatusNotifyUserName":"","RecommendInfo":{"UserName":"","NickName":"","QQNum":0,"Province":"","City":"","Content":"","Signature":"","Alias":"","Scene":0,"VerifyFlag":0,"AttrStatus":0,"Sex":0,"Ticket":"","OpCode":0},"ForwardFlag":0,"AppInfo":{"AppID":"","Type":0},"HasProductId":0,"Ticket":"","ImgHeight":0,"ImgWidth":0,"SubMsgType":0,"NewMsgId":3009511950433684500,"MMPeerUserName":"@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823","MMDigest":"哈哈","MMIsSend":false,"MMIsChatRoom":false,"MMUnread":false,"LocalID":"3009511950433684462","ClientMsgId":"3009511950433684462","MMActualContent":"哈哈","MMActualSender":"@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823","MMDigestTime":"21:44","MMDisplayTime":1462887888,"MMTime":"21:44","_h":104,"_index":0,"_offsetTop":0,"$$hashKey":"098"}') 117 | 118 | const expectedFromUserName = '@0748ee480711bf20af91c298a0d7dcc77c30a680c1004157386b81cf13474823' 119 | const expectedToUserName = '@b58f91e0c5c9e841e290d862ddb63c14' 120 | const expectedFromNickName = 'From Nick Name@Test' 121 | const expectedToNickName = 'To Nick Name@Test' 122 | const expectedMsgId = '3009511950433684462' 123 | 124 | // Mock 125 | function mockContactRawPayload (id: string) { 126 | log.silly('TestMessage', 'mocked getContact(%s)', id) 127 | return new Promise(resolve => { 128 | let obj = {} 129 | switch (id) { 130 | case expectedFromUserName: 131 | obj = { 132 | NickName: expectedFromNickName, 133 | UserName: expectedFromUserName, 134 | } 135 | break 136 | case expectedToUserName: 137 | obj = { 138 | NickName: expectedToNickName, 139 | UserName: expectedToUserName, 140 | } 141 | break 142 | default: 143 | log.error('TestMessage', 'mocked getContact(%s) unknown', id) 144 | t.fail(`mocked getContact(${id}) unknown`) 145 | break 146 | } 147 | log.silly('TestMessage', 'setTimeout mocked getContact') 148 | setTimeout(() => { 149 | log.silly('TestMessage', 'mocked getContact resolved') 150 | return resolve(obj) 151 | }, 100) 152 | }) 153 | } 154 | 155 | async function mockMessageRawPayload (id: string) { 156 | if (id === rawPayload.MsgId) { 157 | return rawPayload as any 158 | } 159 | return {} 160 | } 161 | 162 | const sandbox = sinon.createSandbox() 163 | 164 | const puppet = new PuppetTest() 165 | 166 | // const wechaty = new WechatyTest({ puppet }) 167 | // wechaty.initPuppetAccessory(puppet) 168 | 169 | sandbox.stub(puppet, 'contactRawPayload').callsFake(mockContactRawPayload) 170 | sandbox.stub(puppet, 'messageRawPayload').callsFake(mockMessageRawPayload) 171 | 172 | // const m = wechaty.Message.create(rawPayload.MsgId) 173 | const msgPayload = await puppet.messagePayload(rawPayload.MsgId) 174 | 175 | t.equal(msgPayload.id, expectedMsgId, 'id/MsgId right') 176 | 177 | const talkerId = msgPayload.talkerId 178 | const listenerId = msgPayload.listenerId 179 | 180 | if (!talkerId || !listenerId) { 181 | throw new Error('no talker or no listener') 182 | } 183 | 184 | const fromContactPayload = await puppet.contactPayload(talkerId) 185 | const toContactPayload = await puppet.contactPayload(listenerId) 186 | 187 | t.equal(talkerId, expectedFromUserName, 'contact ready for FromUserName') 188 | t.equal(fromContactPayload.name, expectedFromNickName, 'contact ready for FromNickName') 189 | t.equal(listenerId, expectedToUserName, 'contact ready for ToUserName') 190 | t.equal(toContactPayload.name, expectedToNickName, 'contact ready for ToNickName') 191 | 192 | sandbox.restore() 193 | }) 194 | 195 | // test('find()', async t => { 196 | // const puppet = new PuppetWeChat() 197 | // // const wechaty = new WechatyTest({ puppet }) 198 | // // wechaty.initPuppetAccessory(puppet) 199 | 200 | // const sandbox = sinon.createSandbox() 201 | 202 | // sandbox.stub(puppet, 'contactPayload').resolves({}) 203 | // sandbox.stub(puppet, 'contactPayloadCache').returns({}) 204 | 205 | // const MOCK_USER_ID = 'TEST-USER-ID' 206 | // await puppet.login(MOCK_USER_ID) 207 | 208 | // const msg = await wechaty.Message.find({ 209 | // id: 'xxx', 210 | // }) 211 | 212 | // t.ok(msg, 'Message found') 213 | // t.ok(msg!.id, 'Message.id is ok') 214 | 215 | // sandbox.restore() 216 | // }) 217 | 218 | // test('findAll()', async t => { 219 | // const puppet = new PuppetTest() 220 | // const wechaty = new WechatyTest({ puppet }) 221 | // wechaty.initPuppetAccessory(puppet) 222 | 223 | // const sandbox = sinon.createSandbox() 224 | 225 | // sandbox.stub(puppet, 'contactPayload').resolves({}) 226 | // sandbox.stub(puppet, 'contactPayloadCache').returns({}) 227 | 228 | // const MOCK_USER_ID = 'TEST-USER-ID' 229 | // await puppet.login(MOCK_USER_ID) 230 | 231 | // const msgList = await wechaty.Message.findAll({ 232 | // from: 'yyy', 233 | // }) 234 | 235 | // t.equal(msgList.length, 2, 'Message.findAll with limit 2') 236 | 237 | // sandbox.restore() 238 | // }) 239 | -------------------------------------------------------------------------------- /tests/puppeteer-room.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | 21 | import { 22 | test, 23 | sinon, 24 | } from 'tstest' 25 | import { 26 | log, 27 | } from '../src/config.js' 28 | 29 | import { 30 | PuppetWeChat, 31 | } from '../src/puppet-wechat.js' 32 | import type { 33 | WebRoomRawPayload, 34 | } from '../src/web-schemas.js' 35 | 36 | class PuppetWeChatTest extends PuppetWeChat { 37 | 38 | // public override id?: string = undefined 39 | 40 | } 41 | 42 | const ROOM_RAW_PAYLOAD: WebRoomRawPayload = JSON.parse('{"RemarkPYQuanPin":"","RemarkPYInitial":"","PYInitial":"TZZGQNTSHGFJ","PYQuanPin":"tongzhizhongguoqingniantianshihuiguanfangjia","Uin":0,"UserName":"@@e2355db381dc46a77c0b95516d05e7486135cb6370d8a6af66925d89d50ec278","NickName":"(通知)中国青年天使会官方家","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgetheadimg?seq=670397504&username=@@e2355db381dc46a77c0b95516d05e7486135cb6370d8a6af66925d89d50ec278&skey=","ContactFlag":2,"MemberCount":146,"MemberList":[{"Uin":0,"UserName":"@ecff4a7a86f23455dc42317269aa36ab","NickName":"童玮亮","AttrStatus":103423,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"dap","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@ecff4a7a86f23455dc42317269aa36ab&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@eac4377ecfd59e4321262f892177169f","NickName":"麦刚","AttrStatus":33674247,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"mai","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@eac4377ecfd59e4321262f892177169f&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@ad85207730aa94e006ddce28f74e6878","NickName":"田美坤Maggie","AttrStatus":112679,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"田美坤","KeyWord":"tia","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@ad85207730aa94e006ddce28f74e6878&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":2351423900,"UserName":"@33cc239d22b20d56395bbbd0967b28b9","NickName":"周宏光","AttrStatus":327869,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"周宏光","KeyWord":"acc","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@33cc239d22b20d56395bbbd0967b28b9&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@5e77381e1e3b5641ddcee44670b6e83a","NickName":"牛文文","AttrStatus":100349,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"niu","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@5e77381e1e3b5641ddcee44670b6e83a&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@56941ef97f3e9c70af88667fdd613b44","NickName":"羊东 东方红酒窖","AttrStatus":33675367,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"Yan","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@56941ef97f3e9c70af88667fdd613b44&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@72c4767ce32db488871fdd1c27173b81","NickName":"李竹~英诺天使(此号已满)","AttrStatus":235261,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"liz","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@72c4767ce32db488871fdd1c27173b81&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@0b0e2eb9501ab2d84f9f800f6a0b4216","NickName":"周静彤 杨宁助理","AttrStatus":230885,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"zlo","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@0b0e2eb9501ab2d84f9f800f6a0b4216&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@4bfa767be0cd3fb78409b9735d1dcc57","NickName":"周哲 Jeremy","AttrStatus":33791995,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"zho","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@4bfa767be0cd3fb78409b9735d1dcc57&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"},{"Uin":0,"UserName":"@ad954bf2159a572b7743a5bc134739f4","NickName":"vicky张","AttrStatus":100477,"PYInitial":"","PYQuanPin":"","RemarkPYInitial":"","RemarkPYQuanPin":"","MemberStatus":0,"DisplayName":"","KeyWord":"hua","HeadImgUrl":"/cgi-bin/mmwebwx-bin/webwxgeticon?seq=0&username=@ad954bf2159a572b7743a5bc134739f4&skey=@crypt_f9cec94b_f23a307a23231cfb5098faf91ff759ca&chatroomid=@4b8baa99bdfc354443711412126d2aaf"}],"RemarkName":"","HideInputBarFlag":0,"Sex":0,"Signature":"","VerifyFlag":0,"OwnerUin":2351423900,"StarFriend":0,"AppAccountFlag":0,"Statues":0,"AttrStatus":0,"Province":"","City":"","Alias":"","SnsFlag":0,"UniFriend":0,"DisplayName":"","ChatRoomId":0,"KeyWord":"","EncryChatRoomId":"@4b8baa99bdfc354443711412126d2aaf","MMFromBatchGet":true,"MMOrderSymbol":"TONGZHIZHONGGUOQINGNIANTIANSHIHUIGUANFANGJIA","MMFromBatchget":true,"MMInChatroom":true}') 43 | const CONTACT_RAW_PAYLOAD_DICT = JSON.parse('{"@ad85207730aa94e006ddce28f74e6878":{ "UserName": "@ad85207730aa94e006ddce28f74e6878","NickName": "田美坤Maggie","RemarkName": "" },"@72c4767ce32db488871fdd1c27173b81":{ "UserName": "@72c4767ce32db488871fdd1c27173b81","NickName": "李竹~英诺天使(此号已满)","RemarkName": "" },"@ecff4a7a86f23455dc42317269aa36ab":{ "UserName": "@ecff4a7a86f23455dc42317269aa36ab","NickName": "童玮亮","RemarkName": "童玮亮备注" }}') 44 | 45 | const ROOM_EXPECTED = { 46 | encryId: '@4b8baa99bdfc354443711412126d2aaf', 47 | id: '@@e2355db381dc46a77c0b95516d05e7486135cb6370d8a6af66925d89d50ec278', 48 | memberId1: '@ad85207730aa94e006ddce28f74e6878', 49 | memberId2: '@72c4767ce32db488871fdd1c27173b81', 50 | memberId3: '@ecff4a7a86f23455dc42317269aa36ab', 51 | memberNick1: '田美坤', 52 | memberNick2: '李竹~英诺天使(此号已满)', 53 | memberNick3: '童玮亮备注', 54 | ownerId: '@33cc239d22b20d56395bbbd0967b28b9', 55 | topic: '(通知)中国青年天使会官方家', 56 | } 57 | 58 | test('Room smoke testing', async t => { 59 | const MOCK_USER_ID = 'TEST-USER-ID' 60 | 61 | // Mock 62 | const mockContactRoomRawPayload = (id: string) => { 63 | log.verbose('PuppeteerRoomTest', 'mockContactRawPayload(%s)', id) 64 | return new Promise(resolve => { 65 | if (id === ROOM_EXPECTED.id) { 66 | setImmediate(() => resolve(ROOM_RAW_PAYLOAD)) 67 | } else if (id in CONTACT_RAW_PAYLOAD_DICT) { 68 | setImmediate(() => resolve(CONTACT_RAW_PAYLOAD_DICT[id])) 69 | } else { 70 | // ignore other ids 71 | setImmediate(() => resolve({ id })) 72 | } 73 | }) 74 | } 75 | 76 | const sandbox = sinon.createSandbox() 77 | 78 | const puppet = new PuppetWeChatTest() 79 | 80 | sandbox.stub(puppet, 'contactRawPayload').callsFake(mockContactRoomRawPayload) 81 | sandbox.stub(puppet, 'roomRawPayload').callsFake(mockContactRoomRawPayload) 82 | 83 | sandbox.stub(puppet, 'id').value('pretend-to-be-logined') 84 | await puppet.login(MOCK_USER_ID) 85 | const roomPayload = await puppet.roomPayload(ROOM_EXPECTED.id) 86 | 87 | t.equal(roomPayload.id, ROOM_EXPECTED.id, 'should set id/UserName right') 88 | 89 | // t.is((r as any).payload[.('encryId') , EXPECTED.encryId, 'should set EncryChatRoomId') 90 | 91 | t.equal(roomPayload.topic, ROOM_EXPECTED.topic, 'should set topic/NickName') 92 | 93 | // const contact1 = new wechaty.Contact(ROOM_EXPECTED.memberId1) 94 | // const alias1 = await room.alias(contact1) 95 | // const contactPayload1 = await puppet.contactPayload(ROOM_EXPECTED.memberId1) 96 | const roomMemberPayload1 = await puppet.roomMemberPayload(ROOM_EXPECTED.id, ROOM_EXPECTED.memberId1) 97 | 98 | t.equal(roomMemberPayload1.roomAlias, ROOM_EXPECTED.memberNick1, 'should get roomAlias') 99 | 100 | // const name1 = r.alias(contact1) 101 | // t.is(name1, EXPECTED.memberNick1, 'should get roomAlias') 102 | 103 | // const contact2 = wechaty.Contact.load(ROOM_EXPECTED.memberId2) 104 | // const alias2 = await room.alias(contact2) 105 | // const contactPayload2 = await puppet.contactPayload(ROOM_EXPECTED.memberId2) 106 | const memberPayload2 = await puppet.roomMemberPayload(ROOM_EXPECTED.id, ROOM_EXPECTED.memberId2) 107 | 108 | t.equal(memberPayload2.roomAlias, '', 'should return null if not set roomAlias') 109 | 110 | // const name2 = r.alias(contact2) 111 | // t.is(name2, null, 'should return null if not set roomAlias') 112 | const memberIdList = await puppet.roomMemberList(ROOM_EXPECTED.id) 113 | t.equal(memberIdList.includes(ROOM_EXPECTED.memberId1), true, 'should has contact1') 114 | 115 | // const noSuchContact = wechaty.Contact.load('not exist id') 116 | // t.equal(await room.has(noSuchContact), false, 'should has no this member') 117 | 118 | // const owner = room.owner() 119 | // t.true(owner === null || owner instanceof wechaty.Contact, 'should get Contact instance for owner, or null') 120 | 121 | // wxApp hide uin for all contacts. 122 | // t.is(r.owner().id, EXPECTED.ownerId, 'should get owner right by OwnerUin & Uin') 123 | 124 | // const contactA = await room.member(ROOM_EXPECTED.memberNick1) 125 | 126 | // if (!contactA) { 127 | // throw new Error(`member(${ROOM_EXPECTED.memberNick1}) should get member by roomAlias by default`) 128 | // } 129 | 130 | const resultA = await puppet.roomMemberSearch(ROOM_EXPECTED.id, ROOM_EXPECTED.memberNick1) 131 | const resultB = await puppet.roomMemberSearch(ROOM_EXPECTED.id, ROOM_EXPECTED.memberNick2) 132 | const resultC = await puppet.roomMemberSearch(ROOM_EXPECTED.id, ROOM_EXPECTED.memberNick3) 133 | const resultD = await puppet.roomMemberSearch(ROOM_EXPECTED.id, { roomAlias: ROOM_EXPECTED.memberNick1 }) 134 | // const contactB = await room.member(ROOM_EXPECTED.memberNick2) 135 | // const contactC = await room.member(ROOM_EXPECTED.memberNick3) 136 | // const contactD = await room.member({ roomAlias: ROOM_EXPECTED.memberNick1 }) 137 | 138 | t.equal(resultA[0], ROOM_EXPECTED.memberId1, `should get the right id from ${ROOM_EXPECTED.memberId1}, find member by default`) 139 | t.equal(resultB[0], ROOM_EXPECTED.memberId2, `should get the right id from ${ROOM_EXPECTED.memberId2}, find member by default`) 140 | t.equal(resultC[0], ROOM_EXPECTED.memberId3, `should get the right id from ${ROOM_EXPECTED.memberId3}, find member by default`) 141 | t.equal(resultD[0], ROOM_EXPECTED.memberId1, `should get the right id from ${ROOM_EXPECTED.memberId1}, find member by roomAlias`) 142 | 143 | sandbox.restore() 144 | }) 145 | 146 | // test('Room static method', async t => { 147 | 148 | // const puppet = new PuppetWeChat() 149 | // const wechaty = new WechatyTest({ puppet }) 150 | // wechaty.initPuppetAccessory(puppet) 151 | 152 | // try { 153 | // const result = await wechaty.Room.find({ topic: 'xxx' }) 154 | // t.equal(result, null, `should return null if cannot find the room`) 155 | // } catch (e) { 156 | // t.pass('should throw before login or not found') 157 | // } 158 | 159 | // const roomList = await wechaty.Room.findAll({ 160 | // topic: 'yyy', 161 | // }) 162 | 163 | // t.equal(roomList.length, 0, 'should return empty array before login') 164 | // }) 165 | -------------------------------------------------------------------------------- /tests/puppeteer.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty - https://github.com/chatie/wechaty 4 | * 5 | * @copyright 2016-2018 Huan LI 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * 19 | */ 20 | import fs from 'fs' 21 | import path from 'path' 22 | 23 | import { 24 | test, 25 | sinon, 26 | } from 'tstest' 27 | import puppeteer from 'puppeteer' 28 | 29 | import { 30 | codeRoot, 31 | } from '../src/cjs.js' 32 | 33 | const PUPPETEER_LAUNCH_OPTIONS = { 34 | args: [ 35 | '--disable-gpu', 36 | '--disable-setuid-sandbox', 37 | '--no-sandbox', 38 | ], 39 | headless: true, 40 | } 41 | 42 | test('Puppeteer smoke testing', async t => { 43 | let browser 44 | let page 45 | 46 | try { 47 | browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 48 | t.ok(browser, 'Browser instnace') 49 | 50 | const version = await browser.version() 51 | t.ok(version, 'should get version') 52 | 53 | page = await browser.newPage() 54 | t.pass('should create newPage for browser') 55 | await page.goto('https://wx.qq.com/') 56 | t.pass('should open wx.qq.com') 57 | 58 | const result = await page.evaluate(() => 42) 59 | t.equal(result, 42, 'should get 42') 60 | 61 | } catch (e) { 62 | t.fail(e as any) 63 | } finally { 64 | if (page) { 65 | await page.close() 66 | } 67 | if (browser) { 68 | await browser.close() 69 | } 70 | } 71 | }) 72 | 73 | test('evaluate() a function that returns a Promise', async t => { 74 | try { 75 | const browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 76 | const page = await browser.newPage() 77 | 78 | const result = await page.evaluate(() => Promise.resolve(42)) 79 | t.equal(result, 42, 'should get resolved value of promise inside browser') 80 | 81 | await page.close() 82 | await browser.close() 83 | } catch (e) { 84 | t.fail(e as any) 85 | } 86 | }) 87 | 88 | test('evaluate() a file and get the returns value', async t => { 89 | const EXPECTED_OBJ = { 90 | code: 42, 91 | message: 'meaning of the life', 92 | } 93 | 94 | try { 95 | const browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 96 | const page = await browser.newPage() 97 | 98 | const file = path.join( 99 | codeRoot, 100 | 'tests', 101 | 'fixtures/inject-file.js', 102 | ) 103 | const source = fs.readFileSync(file).toString() 104 | 105 | const result = await page.evaluate(source) 106 | t.same(result, EXPECTED_OBJ, 'should inject file inside browser and return the value') 107 | 108 | const noWechaty = await page.evaluate('typeof WechatyBro === "undefined"') 109 | t.equal(noWechaty, true, 'should no wechaty by default') 110 | 111 | const hasWindow = await page.evaluate('typeof window === "object"') 112 | t.equal(hasWindow, true, 'should has window by default') 113 | 114 | await page.close() 115 | await browser.close() 116 | 117 | } catch (e) { 118 | t.fail(e as any) 119 | } 120 | }) 121 | 122 | test('page.on(console)', async t => { 123 | const EXPECTED_ARG1 = 'arg1' 124 | const EXPECTED_ARG2 = 2 125 | // const EXPECTED_ARG3 = { arg3: 3 } 126 | 127 | const browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 128 | const page = await browser.newPage() 129 | 130 | const spy = sinon.spy() 131 | 132 | page.on('console', spy) 133 | await page.evaluate((...args) => { 134 | console.info.apply(console, args as [any?, ...any[]]) 135 | }, EXPECTED_ARG1, EXPECTED_ARG2) // , EXPECTED_ARG3) 136 | 137 | // wait a while to let chrome fire the event 138 | await new Promise(resolve => setTimeout(resolve, 3)) 139 | 140 | t.ok(spy.calledOnce, 'should be called once') 141 | 142 | const consoleMessage = spy.firstCall.args[0] 143 | t.equal(consoleMessage.type(), 'info', 'should get info type for `console.info`') 144 | t.equal(consoleMessage.text(), EXPECTED_ARG1 + ' ' + EXPECTED_ARG2, 'should get console.info 1st/2nd arg') 145 | 146 | await page.close() 147 | await browser.close() 148 | }) 149 | 150 | test('page.exposeFunction()', async t => { 151 | const browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 152 | const page = await browser.newPage() 153 | 154 | const spy = sinon.spy() 155 | 156 | await page.exposeFunction('nodeFunc', spy) 157 | await page.evaluate('nodeFunc(42)') 158 | t.ok(spy.calledOnce, 'should be called once inside browser') 159 | t.equal(spy.firstCall.args[0], 42, 'should be called with 42') 160 | 161 | await page.close() 162 | await browser.close() 163 | }) 164 | 165 | test('other demos', async t => { 166 | const EXPECTED_URL = 'https://github.com/' 167 | 168 | try { 169 | const browser = await puppeteer.launch(PUPPETEER_LAUNCH_OPTIONS) 170 | 171 | const version = await browser.version() 172 | t.ok(version, 'should get version') 173 | 174 | const page = await browser.newPage() 175 | await page.goto(EXPECTED_URL) 176 | // await page.goto('https://www.chromestatus.com/features', {waitUntil: 'networkidle'}); 177 | // await page.waitForSelector('h3 a'); 178 | // await page.click('input[type="submit"]'); 179 | 180 | // not the same with the document of ConsoleMessage??? 181 | 182 | page.on('dialog', dialog => { 183 | console.info(dialog) 184 | console.info('dialog:', dialog.type, dialog.message()) 185 | dialog.accept('ok').catch(console.error) 186 | }) 187 | 188 | page.on('error', (e, ...args) => { 189 | console.error('error', e as Error) 190 | console.error('error:args:', args) 191 | }) 192 | page.on('pageerror', (e, ...args) => { 193 | console.error('pageerror', e as Error) 194 | console.error('pageerror:args:', args) 195 | }) 196 | 197 | page.on('load', (e, ...args) => { 198 | console.info('load:e:', e as Error) 199 | console.info('load:args:', args) 200 | }) 201 | 202 | await page.setRequestInterception(true) 203 | 204 | page.on('request', interceptedRequest => { 205 | if (interceptedRequest.url().endsWith('.png') 206 | || interceptedRequest.url().endsWith('.jpg') 207 | ) { 208 | interceptedRequest.abort().catch(console.error) 209 | } else { 210 | interceptedRequest.continue().catch(console.error) 211 | } 212 | }) 213 | 214 | page.on('requestfailed', (...args: any[]) => { 215 | console.info('requestfailed:args:', args) 216 | }) 217 | 218 | page.on('response', (/* res, ...args */) => { 219 | // console.info('response:res:', res) 220 | // console.info('response:args:', args) 221 | }) 222 | 223 | // page.click(selector[, options]) 224 | // await page.injectFile(path.join(__dirname, 'wechaty-bro.js')) 225 | const cookieList = await page.cookies() 226 | t.ok(cookieList.length, 'should get cookies') 227 | t.ok(cookieList[0]?.name, 'should get cookies with name') 228 | 229 | /** 230 | * Huan(202109): skip the below Error 231 | * message: "Protocol error (Network.setCookies): Invalid cookie fields" 232 | */ 233 | // const cookie: puppeteer.Protocol.Network.CookieParam = { 234 | // domain : 'qq.com', 235 | // expires : 1234324132, 236 | // httpOnly : false, 237 | // name : 'test-name', 238 | // path : '/', 239 | // priority: 'Medium', 240 | // sameParty: true, 241 | // sameSite : 'Strict', 242 | // secure : false, 243 | // value : 'test-value', 244 | // } 245 | // await page.setCookie(cookie) 246 | 247 | const result = await page.evaluate(() => 8 * 7) 248 | t.equal(result, 56, 'should evaluated function for () => 8 * 7 = 56') 249 | 250 | t.equal(await page.evaluate('1 + 2'), 3, 'should evaluated 1 + 2 = 3') 251 | 252 | const url = page.url() 253 | t.equal(url, EXPECTED_URL, 'should get the url right') 254 | // await new Promise(r => setTimeout(r, 3000)) 255 | 256 | await page.close() 257 | await browser.close() 258 | } catch (e) { 259 | t.fail(e as any) 260 | } 261 | }) 262 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "dist/cjs", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@chatie/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "dist/esm", 5 | "lib": [ 6 | "dom", 7 | "esnext", 8 | ], 9 | }, 10 | "exclude": [ 11 | "node_modules/", 12 | "dist/", 13 | "tests/fixtures/", 14 | ], 15 | "include": [ 16 | "app/**/*.ts", 17 | "bin/*.ts", 18 | "bot/**/*.ts", 19 | "examples/**/*.ts", 20 | "scripts/**/*.ts", 21 | "src/**/*.ts", 22 | "src/wechaty-bro.js", 23 | "tests/**/*.spec.ts", 24 | ], 25 | } 26 | --------------------------------------------------------------------------------