├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ └── npm.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── NOTICE ├── README.md ├── docs └── images │ └── hostie.png ├── examples └── ding-dong-bot.ts ├── package.json ├── scripts ├── generate-package-json.sh ├── npm-pack-testing.sh └── package-publish-config-tag.sh ├── src ├── auth │ ├── README.md │ ├── auth-impl-token.spec.ts │ ├── auth-impl-token.ts │ ├── ca.spec.ts │ ├── ca.ts │ ├── call-cred.spec.ts │ ├── call-cred.ts │ ├── env-vars.ts │ ├── grpc-js.ts │ ├── mod.ts │ ├── mokey-patch-header-authorization.ts │ └── monkey-patch-header-authorization.spec.ts ├── client │ ├── duck │ │ ├── actions.ts │ │ ├── epic-recover.spec.ts │ │ ├── epic-recover.ts │ │ ├── epics.ts │ │ ├── mod.ts │ │ ├── operations.ts │ │ ├── reducers.ts │ │ ├── selectors.ts │ │ ├── tests.spec.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── grpc-manager.spec.ts │ ├── grpc-manager.ts │ ├── payload-store.spec.ts │ ├── payload-store.ts │ ├── puppet-service.spec.ts │ └── puppet-service.ts ├── config.ts ├── deprecated │ ├── chunk-pb.spec.ts │ ├── chunk-pb.ts │ ├── conversation-id-file-box.spec.ts │ ├── conversation-id-file-box.ts │ ├── file-box-chunk.spec.ts │ ├── file-box-chunk.ts │ ├── file-box-pb.spec.ts │ ├── file-box-pb.ts │ ├── file-box-pb.type.ts │ ├── mod.ts │ ├── next-data.ts │ └── serialize-file-box.ts ├── env-vars.spec.ts ├── env-vars.ts ├── event-type-rev.ts ├── file-box-helper │ ├── mod.ts │ ├── normalize-filebox.spec.ts │ ├── normalize-filebox.ts │ ├── uuidify-file-box-grpc.ts │ └── uuidify-file-box-local.ts ├── mod.spec.ts ├── mod.ts ├── package-json.spec.ts ├── package-json.ts ├── pure-functions │ └── timestamp.ts ├── server │ ├── event-stream-manager.ts │ ├── grpc-error.ts │ ├── health-implementation.ts │ ├── puppet-implementation.ts │ ├── puppet-server.spec.ts │ └── puppet-server.ts └── typings.d.ts ├── tests ├── fixtures │ └── smoke-testing.ts ├── grpc-client.spec.ts ├── grpc-stream.spec.ts ├── integration.spec.ts ├── performance.spec.ts ├── ready-event.spec.ts ├── typings.d.ts └── uuid-file-box.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 | 'no-void': ["error", { "allowAsStatement": true }], 3 | 'dot-notation': ['off'], 4 | '@typescript-eslint/no-misused-promises': ['warn'] 5 | } 6 | 7 | module.exports = { 8 | extends: '@chatie', 9 | rules, 10 | "globals": { 11 | "NodeJS": true 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /.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 | # - 12 16 | # - 14 17 | - 16 18 | 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: npm 27 | cache-dependency-path: package.json 28 | 29 | - name: Install Dependencies 30 | run: npm install 31 | 32 | - name: Test 33 | run: npm test 34 | 35 | pack: 36 | name: Pack 37 | needs: build 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v2 41 | - uses: actions/setup-node@v2 42 | with: 43 | node-version: 16 44 | cache: npm 45 | cache-dependency-path: package.json 46 | 47 | - name: Install Dependencies 48 | run: npm install 49 | 50 | - name: Generate Package JSON 51 | run: ./scripts/generate-package-json.sh 52 | 53 | - name: Pack Testing 54 | run: ./scripts/npm-pack-testing.sh 55 | env: 56 | WECHATY_PUPPET_SERVICE_TOKEN: ${{ secrets.WECHATY_PUPPET_SERVICE_TOKEN || 'puppet_test_testtoken' }} 57 | 58 | publish: 59 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v')) 60 | name: Publish 61 | needs: 62 | - build 63 | - pack 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v2 67 | - uses: actions/setup-node@v2 68 | with: 69 | node-version: 16 70 | registry-url: https://registry.npmjs.org/ 71 | cache: npm 72 | cache-dependency-path: package.json 73 | 74 | - name: Install Dependencies 75 | run: npm install 76 | 77 | - name: Generate Package JSON 78 | run: ./scripts/generate-package-json.sh 79 | 80 | - name: Set Publish Config 81 | run: ./scripts/package-publish-config-tag.sh 82 | 83 | - name: Build Dist 84 | run: npm run dist 85 | 86 | - name: Check Branch 87 | id: check-branch 88 | run: | 89 | if [[ ${{ github.ref }} =~ ^refs/heads/(main|v[0-9]+\.[0-9]+.*)$ ]]; then 90 | echo ::set-output name=match::true 91 | fi # See: https://stackoverflow.com/a/58869470/1123955 92 | - name: Is A Publish Branch 93 | if: steps.check-branch.outputs.match == 'true' 94 | run: | 95 | NAME=$(npx pkg-jq -r .name) 96 | VERSION=$(npx pkg-jq -r .version) 97 | if npx version-exists "$NAME" "$VERSION" 98 | then echo "$NAME@$VERSION exists on NPM, skipped." 99 | else npm publish 100 | fi 101 | env: 102 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 103 | - name: Is Not A Publish Branch 104 | if: steps.check-branch.outputs.match != 'true' 105 | run: echo 'Not A Publish Branch' 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | dist/ 63 | /package-lock.json 64 | t/ 65 | t.* 66 | -------------------------------------------------------------------------------- /.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 | "cSpell.words": [ 74 | "hosties", 75 | "windmemory" 76 | ], 77 | 78 | } 79 | 80 | -------------------------------------------------------------------------------- /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 2019-now 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 is a RPA SDK for Chatbot Makers. 2 | Copyright 2016-now 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-service 2 | 3 | [![NPM Version](https://badge.fury.io/js/wechaty-puppet-service.svg)](https://www.npmjs.com/package/wechaty-puppet-service) 4 | [![NPM](https://github.com/wechaty/wechaty-puppet-service/workflows/NPM/badge.svg)](https://github.com/wechaty/wechaty-puppet-service/actions?query=workflow%3ANPM) 5 | [![ES Modules](https://img.shields.io/badge/ES-Modules-brightgreen)](https://github.com/Chatie/tsconfig/issues/16) 6 | 7 | ![Wechaty Service](https://wechaty.github.io/wechaty-puppet-service/images/hostie.png) 8 | 9 | Wechaty Puppet Service is gRPC for Wechaty Puppet Provider. 10 | 11 | For example, we can cloudify the Wechaty Puppet Provider wechaty-puppet-padlocal 12 | to a Wechaty Puppet Service by running our Wechaty Puppet Service Token Gateway. 13 | 14 | If you want to learn more about what is Wechaty Puppet and Wechaty Puppet Service, 15 | we have a blog post to explain them in details at 16 | 17 | 18 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-brightgreen.svg)](https://github.com/Wechaty/wechaty) 19 | 20 | ## Features 21 | 22 | 1. Consume Wechaty Puppet Service 23 | 1. Provide Wechaty Puppet Service 24 | 25 | ## Usage 26 | 27 | ```ts 28 | import { WechatyBuilder } from 'wechaty' 29 | 30 | const wechaty = WechatyBuilder.build({ 31 | puppet: 'wechaty-puppet-service', 32 | puppetOptions: { 33 | token: `${TOKEN}` 34 | } 35 | }) 36 | 37 | wechaty.start() 38 | ``` 39 | 40 | Learn more about Wechaty Puppet Token from our official website: 41 | 42 | ## Environment Variables 43 | 44 | ### 1 `WECHATY_PUPPET_SERVICE_TOKEN` 45 | 46 | The token set to this environment variable will become the default value of `puppetOptions.token` 47 | 48 | ```sh 49 | WECHATY_PUPPET_SERVICE_TOKEN=${WECHATY_PUPPET_SERVCIE_TOKEN} node bot.js 50 | ``` 51 | 52 | ## gRPC Health Checking Protocol 53 | 54 | From version 0.37, Wechaty Puppet Service start 55 | supporting the [GRPC Health Checking Protocol](https://github.com/grpc/grpc/blob/master/doc/health-checking.md). 56 | 57 | ```sh 58 | $ npm install --global wechaty-token 59 | $ go install github.com/grpc-ecosystem/grpc-health-probe@latest 60 | 61 | $ wechaty-token discovery uuid_12345678-1234-1234-1234-567812345678 62 | {"host": 1.2.3.4, "port": 5678} 63 | 64 | $ grpc-health-probe -tls -tls-no-verify -addr 1.2.3.4 65 | status: SERVING 66 | ``` 67 | 68 | See: 69 | 70 | - [Add health checking API wechaty/grpc#151](https://github.com/wechaty/grpc/issues/151) 71 | 72 | ## Resources 73 | 74 | ### Authentication 75 | 76 | 1. [Authentication and Security in gRPC Microservices - Jan Tattermusch, Google](https://youtu.be/_y-lzjdVEf0) 77 | 1. [[gRPC #15] Implement gRPC interceptor + JWT authentication in Go](https://youtu.be/kVpB-uH6X-s) 78 | 79 | ## History 80 | 81 | ### master v0.31 82 | 83 | 1. ES Modules supported. 84 | 1. gRPC Health Checking Protocol support 85 | 86 | ### v0.30 (Aug 25, 2021) 87 | 88 | 1. Implemented TLS and server-side token authorization. 89 | 1. Refactor the gRPC client code. 90 | 1. Add local payload cache to reduce the cost of RPC. 91 | 92 | #### New environment variables 93 | 94 | 95 | 96 | 1. `WECHATY_PUPPET_SERVICE_TLS_CA_CERT`: can be overwrite by `options.tlsRootCert`. Set Root CA Cert to verify the server or client. 97 | 98 | For Puppet Server: 99 | 100 | | Environment Variable | Options | Description | 101 | | -------------------- | ------- | ----------- | 102 | | `WECHATY_PUPPET_SERVICE_TLS_SERVER_CERT` | `options.tls.serverCert` | Server CA Cert (string data) | 103 | | `WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY` | `options.tls.serverKey` | Server CA Key (string data) | 104 | | `WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_SERVER` | `options.tls.disable` | Set `true` to disable server TLS | 105 | 106 | For Puppet Client: 107 | 108 | | Environment Variable | Options | Description | 109 | | -------------------- | ------- | ----------- | 110 | | `WECHATY_PUPPET_SERVICE_AUTHORITY` | `options.authority` | Service discovery host, default: `api.chatie.io` | 111 | | `WECHATY_PUPPET_SERVICE_TLS_CA_CERT` | `options.caCert` | Certification Authority Root Cert, default is using Wechaty Community root cert | 112 | | `WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME` | `options.serverName` | Server Name (mast match for SNI) | 113 | | `WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_CLIENT` | `options.tls.disable` | Set `true` to disable client TLS | 114 | 115 | ## Changelog 116 | 117 | ### main v1.0 (Oct 29, 2021) 118 | 119 | Release v1.0 of Wechaty Puppet Service. 120 | 121 | 1. use [wechaty-token](https://github.com/wechaty/token) 122 | for gRPC service discovery with `wechaty` schema (xDS like) 123 | 1. deprecated `WECHATY_SERVICE_DISCOVERY_ENDPOINT`, 124 | replaced by `WECHATY_PUPPET_SERVICE_AUTHORITY`. 125 | (See [#156](https://github.com/wechaty/wechaty-puppet-service/issues/156)) 126 | 1. enable TLS & Token Auth (See [#124](https://github.com/wechaty/wechaty-puppet-service/issues/124)) 127 | 128 | ### v0.14 (Jan 2021) 129 | 130 | Rename from ~~wechaty-puppet-hostie~~ to [wechaty-puppet-service](https://www.npmjs.com/package/wechaty-puppet-service) 131 | (Issue [#118](https://github.com/wechaty/wechaty-puppet-service/issues/118)) 132 | 133 | ### v0.10.4 (Oct 2020) 134 | 135 | 1. Add 'grpc.default_authority' to gRPC client option. 136 | > See: [Issue #78: gRPC server can use the authority to identify current user](https://github.com/wechaty/wechaty-puppet-hostie/pull/78) 137 | 138 | ### v0.6 (Apr 2020) 139 | 140 | Beta Version 141 | 142 | 1. Reconnect to Hostie Server with RxSJ Observables 143 | 144 | ### v0.3 (Feb 2020) 145 | 146 | 1. Publish the NPM module [wechaty-puppet-hostie](https://www.npmjs.com/package/wechaty-puppet-hostie) 147 | 1. Implemented basic hostie features with gRPC module: [@chatie/grpc](https://github.com/Chatie/grpc) 148 | 149 | ### v0.0.1 (Jun 2018) 150 | 151 | Designing the puppet hostie with the following protocols: 152 | 153 | 1. [gRPC](https://grpc.io/) 154 | 1. [JSON RPC](https://www.jsonrpc.org/) 155 | 1. [OpenAPI/Swagger](https://swagger.io/docs/specification/about/) 156 | 157 | ## Maintainers 158 | 159 | - [@huan](https://github.com/huan) Huan 160 | - [@windmemory](https://github.com/windmemory) Yuan 161 | 162 | ## Copyright & License 163 | 164 | - Code & Docs © 2018-now Huan LI \ 165 | - Code released under the Apache-2.0 License 166 | - Docs released under Creative Commons 167 | -------------------------------------------------------------------------------- /docs/images/hostie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/puppet-service/4de1024ee9b615af6c44674f684a84dd8c11ae9e/docs/images/hostie.png -------------------------------------------------------------------------------- /examples/ding-dong-bot.ts: -------------------------------------------------------------------------------- 1 | import * as PUPPET from 'wechaty-puppet' 2 | import { FileBox } from 'file-box' 3 | 4 | import { PuppetService } from '../src/mod.js' 5 | 6 | /** 7 | * 8 | * 1. Declare your Bot! 9 | * 10 | */ 11 | const puppet = new PuppetService() 12 | 13 | /** 14 | * 15 | * 2. Register event handlers for Bot 16 | * 17 | */ 18 | puppet 19 | .on('logout', onLogout) 20 | .on('login', onLogin) 21 | .on('scan', onScan) 22 | .on('error', onError) 23 | .on('message', onMessage) 24 | 25 | /** 26 | * 27 | * 3. Start the bot! 28 | * 29 | */ 30 | puppet.start() 31 | .catch(async e => { 32 | console.error('Bot start() fail:', e) 33 | await puppet.stop() 34 | process.exit(-1) 35 | }) 36 | 37 | /** 38 | * 39 | * 4. You are all set. ;-] 40 | * 41 | */ 42 | 43 | /** 44 | * 45 | * 5. Define Event Handler Functions for: 46 | * `scan`, `login`, `logout`, `error`, and `message` 47 | * 48 | */ 49 | function onScan (payload: PUPPET.payloads.EventScan) { 50 | if (payload.qrcode) { 51 | const qrcodeImageUrl = [ 52 | 'https://wechaty.js.org/qrcode/', 53 | encodeURIComponent(payload.qrcode), 54 | ].join('') 55 | 56 | console.info(`[${payload.status}] ${qrcodeImageUrl}\nScan QR Code above to log in: `) 57 | } else { 58 | console.info(`[${payload.status}] `, PUPPET.types.ScanStatus[payload.status]) 59 | } 60 | } 61 | 62 | async function onLogin (payload: PUPPET.payloads.EventLogin) { 63 | console.info(`${payload.contactId} login`) 64 | 65 | const contactPayload = await puppet.contactPayload(payload.contactId) 66 | console.info(`contact payload: ${JSON.stringify(contactPayload)}`) 67 | 68 | puppet.messageSendText(payload.contactId, 'Wechaty login').catch(console.error) 69 | } 70 | 71 | function onLogout (payload: PUPPET.payloads.EventLogout) { 72 | console.info(`${payload.contactId} logouted`) 73 | } 74 | 75 | function onError (payload: PUPPET.payloads.EventError) { 76 | console.error('Bot error:', payload.data) 77 | /* 78 | if (bot.isLoggedIn) { 79 | bot.say('Wechaty error: ' + e.message).catch(console.error) 80 | } 81 | */ 82 | } 83 | 84 | /** 85 | * 86 | * 6. The most important handler is for: 87 | * dealing with Messages. 88 | * 89 | */ 90 | async function onMessage (payload: PUPPET.payloads.EventMessage) { 91 | console.info(`onMessage(${payload.messageId})`) 92 | 93 | // const DEBUG: boolean = true 94 | // if (DEBUG) { 95 | // return 96 | // } 97 | 98 | const messagePayload = await puppet.messagePayload(payload.messageId) 99 | console.info('messagePayload:', JSON.stringify(messagePayload)) 100 | 101 | if (messagePayload.talkerId) { 102 | const contactPayload = await puppet.contactPayload(messagePayload.talkerId) 103 | console.info(`contactPayload(talkerId:${messagePayload.talkerId}):`, JSON.stringify(contactPayload)) 104 | } 105 | 106 | if (messagePayload.roomId) { 107 | const roomPayload = await puppet.roomPayload(messagePayload.roomId) 108 | console.info('roomPayload:', JSON.stringify(roomPayload)) 109 | } 110 | 111 | if (messagePayload.listenerId) { 112 | const contactPayload = await puppet.contactPayload(messagePayload.listenerId) 113 | console.info(`contactPayload(listenerId:${messagePayload.listenerId}):`, JSON.stringify(contactPayload)) 114 | } 115 | 116 | if (messagePayload.talkerId === puppet.currentUserId) { 117 | console.info('skip self message') 118 | return 119 | } 120 | 121 | if (messagePayload.type === PUPPET.types.Message.Text 122 | && /^ding$/i.test(messagePayload.text || '') 123 | ) { 124 | const conversationId = messagePayload.roomId || messagePayload.talkerId 125 | 126 | if (!conversationId) { 127 | throw new Error('no conversation id') 128 | } 129 | await puppet.messageSendText(conversationId, 'dong') 130 | 131 | const fileBox = FileBox.fromUrl('https://wechaty.github.io/wechaty/images/bot-qr-code.png') 132 | await puppet.messageSendFile(conversationId, fileBox) 133 | } 134 | } 135 | 136 | /** 137 | * 138 | * 7. Output the Welcome Message 139 | * 140 | */ 141 | const welcome = ` 142 | Puppet Version: ${puppet}@${puppet.version()} 143 | 144 | Please wait... I'm trying to login in... 145 | 146 | ` 147 | console.info(welcome) 148 | 149 | // async function loop () { 150 | // while (true) { 151 | // await new Promise(resolve => setTimeout(resolve, 1000)) 152 | // } 153 | // } 154 | 155 | // loop() 156 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechaty-puppet-service", 3 | "version": "1.19.9", 4 | "description": "Puppet Service 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", 21 | "dist:commonjs": "jq -n \"{ type: \\\"commonjs\\\" }\" > dist/cjs/package.json", 22 | "lint": "npm-run-all lint:es lint:ts lint:md", 23 | "lint:md": "markdownlint README.md", 24 | "lint:ts": "tsc --isolatedModules --noEmit", 25 | "start": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" node examples/ding-dong-bot.ts", 26 | "test": "npm run lint && npm run test:unit", 27 | "test:pack": "bash -x scripts/npm-pack-testing.sh", 28 | "test:unit": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" TAP_TIMEOUT=60 tap \"src/**/*.spec.ts\" \"tests/**/*.spec.ts\"", 29 | "lint:es": "eslint --ignore-pattern fixtures/ \"src/**/*.ts\" \"tests/**/*.ts\"" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/wechaty/wechaty-puppet-service.git" 34 | }, 35 | "keywords": [ 36 | "chatie", 37 | "wechaty", 38 | "chatbot", 39 | "bot", 40 | "grpc", 41 | "wechat", 42 | "sdk" 43 | ], 44 | "author": "Huan LI ", 45 | "license": "Apache-2.0", 46 | "bugs": { 47 | "url": "https://github.com/wechaty/wechaty-puppet-service/issues" 48 | }, 49 | "homepage": "https://github.com/wechaty/wechaty-puppet-service#readme", 50 | "devDependencies": { 51 | "@chatie/eslint-config": "^1.0.4", 52 | "@chatie/git-scripts": "^0.6.2", 53 | "@chatie/semver": "^0.4.7", 54 | "@chatie/tsconfig": "^4.6.3", 55 | "@types/google-protobuf": "^3.15.5", 56 | "@types/lru-cache": "^7.5.0", 57 | "@types/semver": "^7.3.9", 58 | "@types/temp": "^0.9.1", 59 | "@types/uuid": "^8.3.4", 60 | "@types/ws": "^8.5.3", 61 | "get-port": "^6.1.2", 62 | "temp": "^0.9.4", 63 | "typed-emitter": "^1.5.0-from-event", 64 | "utility-types": "^3.10.0", 65 | "wechaty-puppet-mock": "^1.18.2", 66 | "why-is-node-running": "^2.2.1" 67 | }, 68 | "peerDependencies": { 69 | "wechaty-puppet": "^1.19.1" 70 | }, 71 | "dependencies": { 72 | "clone-class": "^1.1.1", 73 | "ducks": "^1.0.2", 74 | "file-box": "^1.5.5", 75 | "flash-store": "^1.3.4", 76 | "gerror": "^1.0.16", 77 | "redux-observable": "^2.0.0", 78 | "rxjs": "^7.5.5", 79 | "semver": "^7.3.5", 80 | "stronger-typed-streams": "^0.2.0", 81 | "uuid": "^8.3.2", 82 | "wechaty-grpc": "^1.5.2", 83 | "wechaty-redux": "^1.20.2", 84 | "wechaty-token": "^1.0.6" 85 | }, 86 | "publishConfig": { 87 | "access": "public", 88 | "tag": "next" 89 | }, 90 | "files": [ 91 | "bin/", 92 | "dist/", 93 | "src/" 94 | ], 95 | "tap": { 96 | "check-coverage": false 97 | }, 98 | "git": { 99 | "scripts": { 100 | "pre-push": "npx git-scripts-pre-push" 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /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 | trap "rm -fr '$TMPDIR'" EXIT 18 | 19 | mv ./*-*.*.*.tgz "$TMPDIR" 20 | cp tests/fixtures/smoke-testing.ts "$TMPDIR" 21 | 22 | cd $TMPDIR 23 | 24 | npm init -y 25 | npm install --production ./*-*.*.*.tgz \ 26 | @chatie/tsconfig@$NPM_TAG \ 27 | pkg-jq \ 28 | "wechaty-puppet@$NPM_TAG" \ 29 | "wechaty@$NPM_TAG" \ 30 | 31 | # 32 | # CommonJS 33 | # 34 | npx tsc \ 35 | --target es6 \ 36 | --module CommonJS \ 37 | \ 38 | --moduleResolution node \ 39 | --esModuleInterop \ 40 | --lib esnext \ 41 | --noEmitOnError \ 42 | --noImplicitAny \ 43 | --skipLibCheck \ 44 | smoke-testing.ts 45 | 46 | echo 47 | echo "CommonJS: pack testing..." 48 | node smoke-testing.js 49 | 50 | # 51 | # ES Modules 52 | # 53 | npx pkg-jq -i '.type="module"' 54 | 55 | npx tsc \ 56 | --target es2020 \ 57 | --module es2020 \ 58 | \ 59 | --moduleResolution node \ 60 | --esModuleInterop \ 61 | --lib esnext \ 62 | --noEmitOnError \ 63 | --noImplicitAny \ 64 | --skipLibCheck \ 65 | smoke-testing.ts 66 | 67 | echo 68 | echo "ES Module: pack testing..." 69 | node smoke-testing.js 70 | -------------------------------------------------------------------------------- /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/auth/README.md: -------------------------------------------------------------------------------- 1 | # Auth for Wechaty Puppet Service gRPC 2 | 3 | 1. OpenSSL CA generation script: 4 | 1. Common Name (CN) is very important because it will be used as Server Name Indication (SNI) in TLS handshake. 5 | 6 | ## Monkey Patch 7 | 8 | We need to copy http2 header `:authority` to metadata. 9 | 10 | See: 11 | -------------------------------------------------------------------------------- /src/auth/auth-impl-token.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { 4 | test, 5 | sinon, 6 | } from 'tstest' 7 | 8 | import { 9 | GrpcStatus, 10 | Metadata, 11 | UntypedServiceImplementation, 12 | } from './grpc-js.js' 13 | 14 | import { authImplToken } from './auth-impl-token.js' 15 | 16 | test('authImplToken()', async t => { 17 | const sandbox = sinon.createSandbox() 18 | const spy = sandbox.spy() 19 | 20 | const TOKEN = 'UUIDv4' 21 | const IMPL: UntypedServiceImplementation = { 22 | spy, 23 | } 24 | 25 | const implWithAuth = authImplToken(TOKEN)(IMPL) 26 | const validMetadata = new Metadata() 27 | validMetadata.add('Authorization', 'Wechaty ' + TOKEN) 28 | 29 | const TEST_LIST = [ 30 | { // Valid Token Request 31 | call: { 32 | metadata: validMetadata, 33 | } as any, 34 | callback: sandbox.spy(), 35 | ok: true, 36 | }, 37 | { // Invalid request for Callback 38 | call: { 39 | metadata: new Metadata(), 40 | } as any, 41 | callback: sandbox.spy(), 42 | ok: false, 43 | }, 44 | { // Invalid request for Stream 45 | call: { 46 | emit: sandbox.spy(), 47 | metadata: new Metadata(), 48 | } as any, 49 | callback: undefined, 50 | ok: false, 51 | }, 52 | ] as const 53 | 54 | const method = implWithAuth['spy']! 55 | 56 | for (const { call, callback, ok } of TEST_LIST) { 57 | spy.resetHistory() 58 | 59 | method(call, callback as any) 60 | 61 | if (ok) { 62 | t.ok(spy.calledOnce, 'should call IMPL handler') 63 | continue 64 | } 65 | 66 | /** 67 | * not ok 68 | */ 69 | t.ok(spy.notCalled, 'should not call IMPL handler') 70 | if (callback) { 71 | t.equal(callback.args[0]![0].code, GrpcStatus.UNAUTHENTICATED, 'should return UNAUTHENTICATED') 72 | } else { 73 | t.equal(call.emit.args[0][0], 'error', 'should emit error') 74 | t.equal(call.emit.args[0][1].code, GrpcStatus.UNAUTHENTICATED, 'should emit UNAUTHENTICATED') 75 | } 76 | 77 | // console.info(spy.args) 78 | // console.info(callback?.args) 79 | } 80 | sandbox.restore() 81 | }) 82 | -------------------------------------------------------------------------------- /src/auth/auth-impl-token.ts: -------------------------------------------------------------------------------- 1 | import { log } from 'wechaty-puppet' 2 | 3 | import { 4 | StatusBuilder, 5 | UntypedHandleCall, 6 | sendUnaryData, 7 | Metadata, 8 | ServerUnaryCall, 9 | GrpcStatus, 10 | UntypedServiceImplementation, 11 | } from './grpc-js.js' 12 | 13 | import { monkeyPatchMetadataFromHttp2Headers } from './mokey-patch-header-authorization.js' 14 | 15 | /** 16 | * Huan(202108): Monkey patch to support 17 | * copy `:authority` from header to metadata 18 | */ 19 | monkeyPatchMetadataFromHttp2Headers(Metadata) 20 | 21 | /** 22 | * Huan(202108): wrap handle calls with authorization 23 | * 24 | * See: 25 | * https://grpc.io/docs/guides/auth/#with-server-authentication-ssltls-and-a-custom-header-with-token 26 | */ 27 | const authWrapHandlerToken = (validToken: string) => (handler: UntypedHandleCall) => { 28 | log.verbose('wechaty-puppet-service', 29 | 'auth/auth-impl-token.ts authWrapHandlerToken(%s)(%s)', 30 | validToken, 31 | handler.name, 32 | ) 33 | 34 | return function ( 35 | call: ServerUnaryCall, 36 | cb?: sendUnaryData, 37 | ) { 38 | // console.info('wrapAuthHandler internal') 39 | 40 | const authorization = call.metadata.get('authorization')[0] 41 | // console.info('authorization', authorization) 42 | 43 | let errMsg = '' 44 | if (typeof authorization === 'string') { 45 | if (authorization.startsWith('Wechaty ')) { 46 | const token = authorization.substring(8 /* 'Wechaty '.length */) 47 | if (token === validToken) { 48 | 49 | return handler( 50 | call as any, 51 | cb as any, 52 | ) 53 | 54 | } else { 55 | errMsg = `Invalid Wechaty TOKEN "${token}"` 56 | } 57 | } else { 58 | const type = authorization.split(/\s+/)[0] 59 | errMsg = `Invalid authorization type: "${type}"` 60 | } 61 | } else { 62 | errMsg = 'No Authorization found.' 63 | } 64 | 65 | /** 66 | * Not authorized 67 | */ 68 | const error = new StatusBuilder() 69 | .withCode(GrpcStatus.UNAUTHENTICATED) 70 | .withDetails(errMsg) 71 | .withMetadata(call.metadata) 72 | .build() 73 | 74 | if (cb) { 75 | /** 76 | * Callback: 77 | * handleUnaryCall 78 | * handleClientStreamingCall 79 | */ 80 | cb(error) 81 | } else if ('emit' in call) { 82 | /** 83 | * Stream: 84 | * handleServerStreamingCall 85 | * handleBidiStreamingCall 86 | */ 87 | call.emit('error', error) 88 | } else { 89 | throw new Error('no callback and call is not emit-able') 90 | } 91 | } 92 | } 93 | 94 | const authImplToken = (validToken: string) => ( 95 | serviceImpl: T, 96 | ) => { 97 | log.verbose('wechaty-puppet-service', 'authImplToken()') 98 | 99 | for (const [ key, val ] of Object.entries(serviceImpl)) { 100 | // any: https://stackoverflow.com/q/59572522/1123955 101 | (serviceImpl as any)[key] = authWrapHandlerToken(validToken)(val) 102 | } 103 | return serviceImpl 104 | } 105 | 106 | export { 107 | authImplToken, 108 | } 109 | -------------------------------------------------------------------------------- /src/auth/ca.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { 4 | test, 5 | } from 'tstest' 6 | 7 | import https from 'https' 8 | 9 | import * as envVar from './env-vars.js' 10 | import type { AddressInfo } from 'ws' 11 | 12 | import { 13 | TLS_CA_CERT, 14 | TLS_INSECURE_SERVER_CERT_COMMON_NAME, 15 | TLS_INSECURE_SERVER_CERT, 16 | TLS_INSECURE_SERVER_KEY, 17 | } from './ca.js' 18 | 19 | test('CA smoke testing', async t => { 20 | 21 | const ca = envVar.WECHATY_PUPPET_SERVICE_TLS_CA_CERT() || TLS_CA_CERT 22 | const cert = envVar.WECHATY_PUPPET_SERVICE_TLS_SERVER_CERT() || TLS_INSECURE_SERVER_CERT 23 | const key = envVar.WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY() || TLS_INSECURE_SERVER_KEY 24 | 25 | const server = https.createServer({ 26 | cert, 27 | key, 28 | }) 29 | 30 | const ALIVE = 'Alive!\n' 31 | 32 | server.on('request', (_req, res) => { 33 | res.writeHead(200) 34 | res.end(ALIVE) 35 | }) 36 | 37 | server.listen() 38 | const port = (server.address() as AddressInfo).port 39 | 40 | const reply = await new Promise((resolve, reject) => { 41 | https.request({ 42 | ca, 43 | hostname: '127.0.0.1', 44 | method: 'GET', 45 | path: '/', 46 | port, 47 | servername: TLS_INSECURE_SERVER_CERT_COMMON_NAME, 48 | }, res => { 49 | res.on('data', chunk => resolve(chunk.toString())) 50 | res.on('error', reject) 51 | }).end() 52 | }) 53 | server.close() 54 | 55 | t.equal(reply, ALIVE, 'should get https server reply') 56 | }) 57 | 58 | test('CA SNI tests', async t => { 59 | 60 | const ca = envVar.WECHATY_PUPPET_SERVICE_TLS_CA_CERT() || TLS_CA_CERT 61 | const cert = envVar.WECHATY_PUPPET_SERVICE_TLS_SERVER_CERT() || TLS_INSECURE_SERVER_CERT 62 | const key = envVar.WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY() || TLS_INSECURE_SERVER_KEY 63 | 64 | const server = https.createServer({ 65 | cert, 66 | key, 67 | }) 68 | 69 | server.on('request', (_req, res) => { 70 | res.writeHead(200) 71 | res.end(ALIVE) 72 | }) 73 | 74 | server.listen() 75 | const port = (server.address() as AddressInfo).port 76 | 77 | const ALIVE = 'Alive!\n' 78 | const SNI_TEST_LIST = [ 79 | [ 80 | TLS_INSECURE_SERVER_CERT_COMMON_NAME, 81 | true, 82 | ], 83 | [ 84 | 'invalid-sni', 85 | false, 86 | "Hostname/IP does not match certificate's altnames: Host: invalid-sni. is not cert's CN: insecure", 87 | ], 88 | ] as const 89 | 90 | for (const [ SNI, EXPECT, MSG ] of SNI_TEST_LIST) { 91 | const result = await new Promise((resolve, reject) => { 92 | https.request({ 93 | ca, 94 | hostname: '127.0.0.1', 95 | method: 'GET', 96 | path: '/', 97 | port, 98 | servername: SNI, 99 | }, res => { 100 | res.on('data', chunk => resolve(chunk.toString() === ALIVE)) 101 | res.on('error', reject) 102 | }) 103 | .on('error', e => { 104 | // console.info(e.message) 105 | t.equal(e.message, MSG, 'should get the error for invalid SNI: ' + SNI) 106 | resolve(false) 107 | }) 108 | .end() 109 | 110 | }) 111 | 112 | t.equal(result, EXPECT, 'should check the SNI: ' + SNI) 113 | } 114 | 115 | server.close() 116 | }) 117 | -------------------------------------------------------------------------------- /src/auth/ca.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Certificate Authority Repo: 3 | * https://github.com/wechaty/dotenv/tree/main/ca 4 | * 5 | * The SSL_ROOT_CERT is a root certificate generated by and for wechaty community. 6 | * 7 | * Because it's the default root cert for the puppet service, 8 | * so all the Polyglot Wechaty SDK should set this cert to be trusted by default. 9 | * 10 | * Update: 11 | * - Huan(202108): init, expired in 3650 days (after 2031/07) 12 | */ 13 | const TLS_CA_CERT = `-----BEGIN CERTIFICATE----- 14 | MIIFxTCCA62gAwIBAgIUYddLAoa8JnLzJ80l2u5vGuFsaEIwDQYJKoZIhvcNAQEL 15 | BQAwcjELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEjAQBgNV 16 | BAcMCVBhbG8gQWx0bzEQMA4GA1UECgwHV2VjaGF0eTELMAkGA1UECwwCQ0ExGDAW 17 | BgNVBAMMD3dlY2hhdHktcm9vdC1jYTAeFw0yMTA4MDkxNTQ4NTJaFw0zMTA4MDcx 18 | NTQ4NTJaMHIxCzAJBgNVBAYTAlVTMRYwFAYDVQQIDA1TYW4gRnJhbmNpc2NvMRIw 19 | EAYDVQQHDAlQYWxvIEFsdG8xEDAOBgNVBAoMB1dlY2hhdHkxCzAJBgNVBAsMAkNB 20 | MRgwFgYDVQQDDA93ZWNoYXR5LXJvb3QtY2EwggIiMA0GCSqGSIb3DQEBAQUAA4IC 21 | DwAwggIKAoICAQDulLjOZhzQ58TSQ7TfWNYgdtWhlc+5L9MnKb1nznVRhzAkZo3Q 22 | rPLRW/HDjlv2OEbt4nFLaQgaMmc1oJTUVGDBDlrzesI/lJh7z4eA/B0z8eW7f6Cw 23 | /TGc8lgzHvq7UIE507QYPhvfSejfW4Prw+90HJnuodriPdMGS0n9AR37JPdQm6sD 24 | iMFeEvhHmM2SXRo/o7bll8UDZi81DoFu0XuTCx0esfCX1W5QWEmAJ5oAdjWxJ23C 25 | lxI1+EjwBQKXGqp147VP9+pwpYW5Xxpy870kctPBHKjCAti8Bfo+Y6dyWz2UAd4w 26 | 4BFRD+18C/TgX+ECl1s9fsHMY15JitcSGgAIz8gQX1OelECaTMRTQfNaSnNW4LdS 27 | sXMQEI9WxAU/W47GCQFmwcJeZvimqDF1QtflHSaARD3O8tlbduYqTR81LJ63bPoy 28 | 9e1pdB6w2bVOTlHunE0YaGSJERALVc1xz40QpPGcZ52mNCb3PBg462RQc77yv/QB 29 | x/P2RC1y0zDUF2tP9J29gTatWq6+D4MhfEk2flZNyzAgJbDuT6KAIJGzOB1ZJ/MG 30 | o1gS13eTuZYw24LElrhd1PrR6OHK+lkyYzqUPYMulUg4HzaZIDclfHKwAC4lecKm 31 | zC5q9jJB4m4SKMKdzxvpIOfdahoqsZMg34l4AavWRqPTpwEU0C0dboNA/QIDAQAB 32 | o1MwUTAdBgNVHQ4EFgQU0rey3QPklTOgdhMJ9VIA6KbZ5bAwHwYDVR0jBBgwFoAU 33 | 0rey3QPklTOgdhMJ9VIA6KbZ5bAwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B 34 | AQsFAAOCAgEAx2uyShx9kLoB1AJ8x7Vf95v6PX95L/4JkJ1WwzJ9Dlf3BcCI7VH7 35 | Fp1dnQ6Ig7mFqSBDBAUUBWAptAnuqIDcgehI6XAEKxW8ZZRxD877pUNwZ/45tSC4 36 | b5U5y9uaiNK7oC3LlDCsB0291b3KSOtevMeDFoh12LcliXAkdIGGTccUxrH+Cyij 37 | cBOc+EKGJFBdLqcjLDU4M6QdMMMFOdfXyAOSpYuWGYqrxqvxQjAjvianEyMpNZWM 38 | lajggJqiPhfF67sZTB2yzvRTmtHdUq7x+iNOVonOBcCHu31aGxa9Py91XEr9jaIQ 39 | EBdl6sycLxKo8mxF/5tyUOns9+919aWNqTOUBmI15D68bqhhOVNyvsb7aVURIt5y 40 | 6A7Sj4gSBR9P22Ba6iFZgbvfLn0zKLzjlBonUGlSPf3rSIYUkawICtDyYPvK5mi3 41 | mANgIChMiOw6LYCPmmUVVAWU/tDy36kr9ZV9YTIZRYAkWswsJB340whjuzvZUVaG 42 | DgW45GPR6bGIwlFZeqCwXLput8Z3C8Sw9bE9vjlB2ZCpjPLmWV/WbDlH3J3uDjgt 43 | 9PoALW0sOPhHfYklH4/rrmsSWMYTUuGS/HqxrEER1vpIOOb0hIiAWENDT/mruq22 44 | VqO8MHX9ebjInSxPmhYOlrSZrOgEcogyMB4Z0SOtKVqPnkWmdR5hatU= 45 | -----END CERTIFICATE-----` 46 | 47 | /** 48 | * Huan(202108): This private key is NOT SAFE! 49 | * 50 | * WARNING: This CA is not safe for production. 51 | * **use environment variables to set your safe CA data** 52 | * 53 | * Our system use this private key for server by default for convience. 54 | * However, everyone can get this key and use it to see the traffic between client and server. 55 | * 56 | * For security, we should not use this key in production 57 | * by setting it manually by 58 | * either the environment variable `WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY` 59 | * or `options.tlsServerKey` 60 | * 61 | * So does the below `TLS_SERVER_CERT_UNSAFE` 62 | */ 63 | const TLS_INSECURE_SERVER_KEY = `-----BEGIN RSA PRIVATE KEY----- 64 | MIIEpAIBAAKCAQEAtdFTXAKLW16uqNokJmSowbGtwnCvsPSqIHcdbKgdcuNpaJsZ 65 | DTeBP0/XHFvnXcekHOyzncYgluxijzMSD1S8AKo3c2fROgem+E+WMSLYAZSTV48p 66 | uzTRLoypvfhKfqxsrmpct2F6tRTIQ/EABOs0TYP0dY3Nd8NkCEWBmv7ioPDek/a4 67 | esdisN7R1Ea6jx7ToegSwjkP9aFr2XHxyqR5wjJn/Q6nYZC9A90CKdxJ2WpXtluT 68 | xFfFfqOhR/1te5/LpqXtqxo2yOwu8k67fHub1FyLu9sAYhcsuSjHVHxbK3nPf0mN 69 | Gt0RiSwRj84qzbpfwrjMYrAJ3EqKrlxZurmX0wIDAQABAoIBAAcG+SbUPligtzV1 70 | gPIu78rUuDeMrW20dyLcF7oMYV8AZSGS5Qv6ujcdOd4xuyaHwdMQXvzZHIdYyZJp 71 | UehfyQhpi80dFRweEZkFUnPBugGNoYg/00gWCYO4EhNylkaBGY5ANCcuUFTRYdAm 72 | b27BPHtGf1tPyMI5PhOHxDOeaFn6BKB1pcG4mQ+CNieadYxjgPcInh6mAqgJ40cR 73 | ncWgLgSdChijLlVLW9lFVA+OAqv57vT3xW+Op1r7nBiigj67tka57spZTIEhrLXI 74 | ZFMyRKQXlxh/l82vLmnYAhvSp/hHbARLwWfQ/znsFvTc/HXvXPocpZ2B9f0tlZ0W 75 | dqOHSwkCgYEA3zJYAC5Afw3UKuAyApWOyI3AX+noq6ze8B3jFWEPmNdJrZpjLpzp 76 | mntnWC8Wq0t821uiQXYlGUzF/pIg0rzVdPbc2VTdwKl+iptuCn3fC+LCTJKroRLq 77 | 2a6GDhtmV2g0SEaNdbJt1Zfwr0KyXLNwK+ZdJxhS44vfTCRB4YBsFw0CgYEA0Ioe 78 | pcRBEyCJud8ZJnSN/HiOQ9kCIsnd8Pk4D7q+DGWY6lLGQddhlkp9Ah7yRIGJVg91 79 | D6t5BfpiU8DRGFiEGMo+XWEKjLfRTxg03lBQYACJc2crgFRuG8GFuO/WQ1b9ihR5 80 | nsdLc9cGIm6rFXaUsnLIN9IJhJg4BmFD1U9usl8CgYEArIN+D02wnkOzDRzSqrqs 81 | bQlbewcRxrfMbS28moa2Bn3Ivf1J0fqIeNYPL9Ldo7KqI+Z0yEIoNKDpnHWYFyrL 82 | lidE1lrJN6QKYdn3OPbHUqmHYqYvMEWt7mj9xqOY+9BYMNEPf7xVNrXE28IimJI9 83 | DkF1GMWtM6GmC3Uu0rxvT3UCgYEAgroCylGDpbThAXa8cmHgXCNKs3eHIj2/dn8U 84 | SK/80RKjUEkBZWbaEvew87Jols9JQ3y/GkqYvEmgd/ZIXWWnsU6e17Ssg1f7ywRW 85 | qAJa0EOl5oUHPRQwTg/7ftpCS8Zte7CoKQOv5fcmLlGHyBWk01Sm9G8jbk5p2H4C 86 | ouZ/cysCgYBqZHm6eg0tjwFPJJWgmAMdNvBlnIuW1t5dwa7B2F6ONveUTBBAxGLc 87 | ZBVdEBseBPki5i7M7eNKNTEA3EM+Cfsfsp5U/S8ntDmzzaMoBhb+jBRor39l3+iG 88 | qXI72DDvrh802t6KO9W6CQIfpVcxLeOy82RfUP1pHQ/sMPkx89Fd5A== 89 | -----END RSA PRIVATE KEY-----` 90 | 91 | const TLS_INSECURE_SERVER_CERT = `-----BEGIN CERTIFICATE----- 92 | MIIEVTCCAj0CAQEwDQYJKoZIhvcNAQELBQAwcjELMAkGA1UEBhMCVVMxFjAUBgNV 93 | BAgMDVNhbiBGcmFuY2lzY28xEjAQBgNVBAcMCVBhbG8gQWx0bzEQMA4GA1UECgwH 94 | V2VjaGF0eTELMAkGA1UECwwCQ0ExGDAWBgNVBAMMD3dlY2hhdHktcm9vdC1jYTAe 95 | Fw0yMTA4MjQxODMwMjBaFw0zMTA4MjIxODMwMjBaMG8xCzAJBgNVBAYTAlVTMRYw 96 | FAYDVQQIDA1TYW4gRnJhbmNpc2NvMRIwEAYDVQQHDAlQYWxvIEFsdG8xEDAOBgNV 97 | BAoMB1dlY2hhdHkxDzANBgNVBAsMBlB1cHBldDERMA8GA1UEAwwIaW5zZWN1cmUw 98 | ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC10VNcAotbXq6o2iQmZKjB 99 | sa3CcK+w9Kogdx1sqB1y42lomxkNN4E/T9ccW+ddx6Qc7LOdxiCW7GKPMxIPVLwA 100 | qjdzZ9E6B6b4T5YxItgBlJNXjym7NNEujKm9+Ep+rGyualy3YXq1FMhD8QAE6zRN 101 | g/R1jc13w2QIRYGa/uKg8N6T9rh6x2Kw3tHURrqPHtOh6BLCOQ/1oWvZcfHKpHnC 102 | Mmf9DqdhkL0D3QIp3EnZale2W5PEV8V+o6FH/W17n8umpe2rGjbI7C7yTrt8e5vU 103 | XIu72wBiFyy5KMdUfFsrec9/SY0a3RGJLBGPzirNul/CuMxisAncSoquXFm6uZfT 104 | AgMBAAEwDQYJKoZIhvcNAQELBQADggIBALyPgW0VLlQfkgsNovyLg+zkF7oJZCvM 105 | HS7m43abZb1H1xUH6Kd/sUFTQCAAPop/n4773iH0KggWtoPjkid1G1s/UWK6A0F1 106 | IxRp0DYLgZfL/U+PQxe175ViYRLPUKj1YwagjX6HvM5bUMEYDnIypEH2UFIrD39J 107 | 69Q6M8hZ85oFDAo2hRqrjJo66c3+ygmXSCFIL64gsVLZkK3SHRAv3R90+blNgmo5 108 | Yvh2xqvGuspd1Y3yzeOQreimJkMeDr/t/xucws1TK7fqMjlk/36W4S95xT7EYykf 109 | rQ+1cDIJvGdVU/lod0/lWcOvqMtyf6wIjzFJaGAoqS5QT2IeeXQYbhq9bZIBQzth 110 | IfvfdHuijUqOhT8LX8TYXPWVR/UEKItqktdvA7PXuHUdDxU3ldcXsjA+m9jVO81i 111 | gIOUJQuBR/tImNnLFaTooO6RB71lBB1XCo8HvWPu47MPjxuf/Y+1frPzuP8LFMWj 112 | bjw0QcwFUik8v+mSiPHhOIfzp0EQlFtlncTr+k0MFuRKokl0Yrs8jXOt30JC4tKS 113 | GKXupLWnWE3Z15L9uk9zSAskL2T8LwnctaiMP0+mzf8gWchxUaHkk0yGj4gtNVyU 114 | iJZfrWYwBY9y4SUjp6A7pLspw+i+jIO/EmcX2jbFt1LaajRgEw+uGvNMXhHqtsHC 115 | WqE+fOGDBUET 116 | -----END CERTIFICATE-----` 117 | 118 | /** 119 | * Common Name: 120 | * - Server Name Indication (SNI) 121 | * - Case insensitive 122 | * 123 | * Wechaty Token format: `${SNI}/${UUIDv4}` 124 | * 125 | * Huan(202108): the CA generated by Wechaty Puppet Service 126 | * default set to this value: 127 | * 128 | * grpc.ssl_target_name_override: 'insecure' 129 | */ 130 | const TLS_INSECURE_SERVER_CERT_COMMON_NAME = 'insecure' // case insensitive 131 | 132 | export { 133 | TLS_CA_CERT, 134 | TLS_INSECURE_SERVER_CERT_COMMON_NAME, 135 | TLS_INSECURE_SERVER_CERT, 136 | TLS_INSECURE_SERVER_KEY, 137 | } 138 | -------------------------------------------------------------------------------- /src/auth/call-cred.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { 4 | test, 5 | sinon, 6 | } from 'tstest' 7 | 8 | import { metaGeneratorToken } from './call-cred.js' 9 | 10 | test('metaGeneratorToken()', async t => { 11 | const TOKEN = 'UUIDv4' 12 | const EXPECTED_AUTHORIZATION = `Wechaty ${TOKEN}` 13 | 14 | const sandbox = sinon.createSandbox() 15 | const spy = sandbox.spy() 16 | 17 | const metaGenerator = metaGeneratorToken(TOKEN) 18 | 19 | metaGenerator({} as any, spy) 20 | t.equal(spy.args[0]![0], null, 'should no error') 21 | 22 | const metadata = spy.args[0]![1] 23 | const authorization = metadata.get('Authorization')[0] 24 | t.equal(authorization, EXPECTED_AUTHORIZATION, 'should generate authorization in metadata') 25 | }) 26 | -------------------------------------------------------------------------------- /src/auth/call-cred.ts: -------------------------------------------------------------------------------- 1 | import { 2 | credentials, 3 | CallMetadataGenerator, 4 | Metadata, 5 | } from './grpc-js.js' 6 | 7 | /** 8 | * With server authentication SSL/TLS and a custom header with token 9 | * https://grpc.io/docs/guides/auth/#with-server-authentication-ssltls-and-a-custom-header-with-token-1 10 | */ 11 | const metaGeneratorToken: (token: string) => CallMetadataGenerator = token => (_params, callback) => { 12 | const meta = new Metadata() 13 | meta.add('authorization', `Wechaty ${token}`) 14 | callback(null, meta) 15 | } 16 | 17 | const callCredToken = (token: string) => credentials.createFromMetadataGenerator(metaGeneratorToken(token)) 18 | 19 | export { 20 | callCredToken, 21 | metaGeneratorToken, 22 | } 23 | -------------------------------------------------------------------------------- /src/auth/env-vars.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Environment variables containing newlines in Node? 3 | * `replace(/\\n/g, '\n')` 4 | * https://stackoverflow.com/a/36439803/1123955 5 | */ 6 | const WECHATY_PUPPET_SERVICE_TLS_CA_CERT = (v?: string) => v 7 | || process.env['WECHATY_PUPPET_SERVICE_TLS_CA_CERT']?.replace(/\\n/g, '\n') 8 | 9 | const WECHATY_PUPPET_SERVICE_TLS_SERVER_CERT = (v?: string) => v 10 | || process.env['WECHATY_PUPPET_SERVICE_TLS_SERVER_CERT']?.replace(/\\n/g, '\n') 11 | 12 | const WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY = (v?: string) => v 13 | || process.env['WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY']?.replace(/\\n/g, '\n') 14 | 15 | const WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME = (v?: string) => v 16 | || process.env['WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME']?.replace(/\\n/g, '\n') 17 | 18 | const WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_SERVER = (v?: boolean) => typeof v === 'undefined' 19 | ? process.env['WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_SERVER'] === 'true' 20 | : v 21 | const WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_CLIENT = (v?: boolean) => typeof v === 'undefined' 22 | ? process.env['WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_CLIENT'] === 'true' 23 | : v 24 | 25 | export { 26 | WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_CLIENT, 27 | WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_SERVER, 28 | WECHATY_PUPPET_SERVICE_TLS_CA_CERT, 29 | WECHATY_PUPPET_SERVICE_TLS_SERVER_CERT, 30 | WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY, 31 | WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME, 32 | } 33 | -------------------------------------------------------------------------------- /src/auth/grpc-js.ts: -------------------------------------------------------------------------------- 1 | import { 2 | credentials, 3 | StatusBuilder, 4 | UntypedHandleCall, 5 | Metadata, 6 | UntypedServiceImplementation, 7 | } from '@grpc/grpc-js' 8 | import type { 9 | sendUnaryData, 10 | ServerUnaryCall, 11 | } from '@grpc/grpc-js/build/src/server-call.js' 12 | import type { 13 | CallMetadataGenerator, 14 | } from '@grpc/grpc-js/build/src/call-credentials.js' 15 | import { Status as GrpcStatus } from '@grpc/grpc-js/build/src/constants.js' 16 | 17 | export type { 18 | UntypedServiceImplementation, 19 | UntypedHandleCall, 20 | sendUnaryData, 21 | ServerUnaryCall, 22 | CallMetadataGenerator, 23 | } 24 | export { 25 | credentials, 26 | GrpcStatus, 27 | Metadata, 28 | StatusBuilder, 29 | } 30 | -------------------------------------------------------------------------------- /src/auth/mod.ts: -------------------------------------------------------------------------------- 1 | import { authImplToken } from './auth-impl-token.js' 2 | import { monkeyPatchMetadataFromHttp2Headers } from './mokey-patch-header-authorization.js' 3 | import { callCredToken } from './call-cred.js' 4 | 5 | export { 6 | authImplToken, 7 | callCredToken, 8 | monkeyPatchMetadataFromHttp2Headers, 9 | } 10 | -------------------------------------------------------------------------------- /src/auth/mokey-patch-header-authorization.ts: -------------------------------------------------------------------------------- 1 | import type http2 from 'http2' 2 | 3 | import { log } from 'wechaty-puppet' 4 | 5 | import type { 6 | Metadata, 7 | } from './grpc-js.js' 8 | 9 | /** 10 | * Huan(202108): This is for compatible with old clients (version v0.26 or before) 11 | * for non-tls authorization: 12 | * use the HTTP2 header `:authority` as the token. 13 | * 14 | * This feature is planed to be removed after Dec 31, 2022 15 | */ 16 | function monkeyPatchMetadataFromHttp2Headers ( 17 | MetadataClass: typeof Metadata, 18 | ): () => void { 19 | log.verbose('wechaty-puppet-service', 'monkeyPatchMetadataFromHttp2Headers()') 20 | 21 | const fromHttp2Headers = MetadataClass.fromHttp2Headers 22 | MetadataClass.fromHttp2Headers = function ( 23 | headers: http2.IncomingHttpHeaders, 24 | ): Metadata { 25 | const metadata = fromHttp2Headers.call(MetadataClass, headers) 26 | 27 | if (metadata.get('authorization').length <= 0) { 28 | const authority = headers[':authority'] 29 | const authorization = `Wechaty ${authority}` 30 | metadata.set('authorization', authorization) 31 | } 32 | return metadata 33 | } 34 | 35 | /** 36 | * un-monkey-patch 37 | */ 38 | return () => { 39 | MetadataClass.fromHttp2Headers = fromHttp2Headers 40 | } 41 | } 42 | 43 | export { 44 | monkeyPatchMetadataFromHttp2Headers, 45 | } 46 | -------------------------------------------------------------------------------- /src/auth/monkey-patch-header-authorization.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | import type http2 from 'http2' 5 | 6 | import { 7 | Metadata, 8 | } from './grpc-js.js' 9 | 10 | import { monkeyPatchMetadataFromHttp2Headers } from './mokey-patch-header-authorization.js' 11 | 12 | test('monkeyPatchMetadataFromHttp2Headers', async t => { 13 | const AUTHORITY = '__authority__' 14 | const headers: http2.IncomingHttpHeaders = { 15 | ':authority': AUTHORITY, 16 | } 17 | 18 | const dispose = monkeyPatchMetadataFromHttp2Headers(Metadata) 19 | const meta = Metadata.fromHttp2Headers(headers) 20 | 21 | const authorization = meta.get('authorization')[0] 22 | const EXPECTED = `Wechaty ${AUTHORITY}` 23 | t.equal(authorization, EXPECTED, 'should get authority from metadata') 24 | 25 | dispose() 26 | }) 27 | -------------------------------------------------------------------------------- /src/client/duck/actions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 | /* eslint-disable sort-keys */ 21 | import { 22 | createAction, 23 | } from 'typesafe-actions' 24 | 25 | import * as types from './types.js' 26 | 27 | /** 28 | * Bug compatible & workaround for Ducks API 29 | * https://github.com/huan/ducks/issues/2 30 | */ 31 | export const nop = createAction(types.NOP)() 32 | -------------------------------------------------------------------------------- /src/client/duck/epic-recover.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /* eslint-disable func-call-spacing */ 3 | 4 | import { test } from 'tstest' 5 | 6 | import { 7 | TestScheduler, 8 | } from 'rxjs/testing' 9 | import { 10 | throttleTime, 11 | } from 'rxjs/operators' 12 | import PuppetMock from 'wechaty-puppet-mock' 13 | 14 | import { 15 | Duck as PuppetDuck, 16 | } from 'wechaty-redux' 17 | 18 | import { 19 | epicRecoverReset$, 20 | epicRecoverDing$, 21 | monitorHeartbeat$, 22 | } from './epic-recover.js' 23 | 24 | /** 25 | * RxJS Marble Testing 26 | * 27 | * - https://rxjs.dev/guide/testing/marble-testing 28 | * - https://github.com/ReactiveX/rxjs/blob/master/docs_app/content/guide/testing/marble-testing.md 29 | * 30 | */ 31 | test('Example: marble testing', async t => { 32 | const testScheduler = new TestScheduler(t.same) 33 | 34 | testScheduler.run(helpers => { 35 | const { cold, time, expectObservable, expectSubscriptions } = helpers 36 | const e1 = cold('-a--b--c---|') 37 | const e1subs = ' ^----------!' 38 | const t = time('---| ') // t = 3 39 | const expected = ' -a-----c---|' 40 | 41 | expectObservable(e1.pipe(throttleTime(t))).toBe(expected) 42 | expectSubscriptions(e1.subscriptions).toBe(e1subs) 43 | }) 44 | }) 45 | 46 | test('Example 2: marble subscribe time frame testing', async t => { 47 | const testScheduler = new TestScheduler(t.same) 48 | 49 | testScheduler.run(helpers => { 50 | const { hot, expectObservable, expectSubscriptions } = helpers 51 | const source = hot(' --a--b--c--d--e--f') 52 | const subscription = '-----^------!-' 53 | const expected = ' -----b--c--d--' 54 | expectObservable(source, subscription).toBe(expected) 55 | void expectSubscriptions 56 | // expectSubscriptions(source.subscriptions).toBe(subscription) 57 | }) 58 | }) 59 | 60 | test('Example 3: subscribe with unsubscribe / complete', async t => { 61 | const testScheduler = new TestScheduler(t.same) 62 | 63 | testScheduler.run(({ hot, expectObservable }) => { 64 | const values = { 65 | a: 0, 66 | b: 1, 67 | c: 2, 68 | } 69 | const source = hot(' 10ms a 9ms b 9ms c-|', values) 70 | const subscription1 = ' 20ms ^ 9ms -!-' 71 | const subscription2 = ' 30ms ^--' 72 | const expected1 = ' 20ms b 9ms c--' 73 | const expected2 = ' 30ms c-|' 74 | expectObservable(source, subscription1).toBe(expected1, values) 75 | expectObservable(source, subscription2).toBe(expected2, values) 76 | }) 77 | }) 78 | 79 | test('Example 4: play ground', async t => { 80 | const testScheduler = new TestScheduler(t.same) 81 | 82 | testScheduler.run(helpers => { 83 | const { hot, cold, expectObservable } = helpers 84 | void hot 85 | void cold 86 | const TIMEOUT = 15 87 | const source = hot(`-----a-----h------h ${TIMEOUT}ms --`) 88 | const sub = ` ------^-------!---- ${TIMEOUT}ms --` 89 | const expected = ` -----------h------- ${TIMEOUT}ms --` 90 | 91 | expectObservable(source, sub).toBe(expected) 92 | }) 93 | }) 94 | 95 | test('monitorHeartbeat$() emit once after lost heartbeat', async t => { 96 | const testScheduler = new TestScheduler(t.same) 97 | 98 | const puppet = new PuppetMock() 99 | 100 | const TIMEOUT = 15 101 | 102 | testScheduler.run(helpers => { 103 | const { hot, expectObservable, expectSubscriptions } = helpers 104 | 105 | const marble = { 106 | a: PuppetDuck.actions.STATE_ACTIVATED_EVENT (puppet.id, true), 107 | d: PuppetDuck.actions.DONG_RECEIVED_EVENT (puppet.id, { data: 'dong' }), 108 | e: PuppetDuck.actions.ERROR_RECEIVED_EVENT (puppet.id, { gerror: `monitorHeartbeat$() TIMEOUT(${TIMEOUT})` }), 109 | h: PuppetDuck.actions.HEARTBEAT_RECEIVED_EVENT(puppet.id, { data: 'heartbeat' }), 110 | } 111 | 112 | const puppet$ = hot(` -a----h----h ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT - 1}ms h ${TIMEOUT}ms ${TIMEOUT - 1}ms d ${TIMEOUT}ms ------`, marble) 113 | const subscription = `^----------- ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms -----!` 114 | const expected = ` ------------ ${TIMEOUT - 1}ms e ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT - 1}ms e ${TIMEOUT}ms ${TIMEOUT - 1}ms e ------` 115 | 116 | expectObservable( 117 | monitorHeartbeat$(TIMEOUT)(puppet$), 118 | subscription, 119 | ).toBe(expected, marble) 120 | 121 | void expectSubscriptions 122 | // expectSubscriptions(puppet$.subscriptions).toBe(sub) 123 | }) 124 | }) 125 | 126 | test('epicRecoverDing$() emit periodly', async t => { 127 | const testScheduler = new TestScheduler(t.same) 128 | 129 | const puppet = new PuppetMock() 130 | 131 | const TIMEOUT = 15 132 | 133 | testScheduler.run(helpers => { 134 | const { hot, expectObservable } = helpers 135 | 136 | const marble = { 137 | a: PuppetDuck.actions.STATE_ACTIVATED_EVENT (puppet.id, true), 138 | d: PuppetDuck.actions.DONG_RECEIVED_EVENT (puppet.id, { data: 'dong' }), 139 | h: PuppetDuck.actions.HEARTBEAT_RECEIVED_EVENT(puppet.id, { data: 'heartbeat' }), 140 | i: PuppetDuck.actions.DING_COMMAND (puppet.id, 'epicRecoverDing$'), 141 | } 142 | 143 | const puppet$ = hot(` -a----h----h ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT - 1}ms h ${TIMEOUT - 1}ms h ${TIMEOUT - 1}ms d ${TIMEOUT - 1}ms d ------`, marble) 144 | const subscription = `^----------- ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms -----!` 145 | const expected = ` ------------ ${TIMEOUT - 1}ms i ${TIMEOUT - 1}ms i ${TIMEOUT - 1}ms i ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ------` 146 | 147 | expectObservable( 148 | epicRecoverDing$(TIMEOUT)(puppet$), 149 | subscription, 150 | ).toBe(expected, marble) 151 | }) 152 | }) 153 | 154 | test('epicRecoverReset$() emit periodly', async t => { 155 | const testScheduler = new TestScheduler(t.same) 156 | 157 | const puppet = new PuppetMock() 158 | 159 | const TIMEOUT = 60 160 | 161 | testScheduler.run(helpers => { 162 | const { hot, expectObservable } = helpers 163 | 164 | const marble = { 165 | a: PuppetDuck.actions.STATE_ACTIVATED_EVENT (puppet.id, true), 166 | d: PuppetDuck.actions.DONG_RECEIVED_EVENT (puppet.id, { data: 'dong' }), 167 | h: PuppetDuck.actions.HEARTBEAT_RECEIVED_EVENT(puppet.id, { data: 'heartbeat' }), 168 | r: PuppetDuck.actions.RESET_COMMAND (puppet.id, 'epicRecoverReset$'), 169 | } 170 | 171 | const puppet$ = hot(` -a----h----h ${TIMEOUT}ms ${TIMEOUT * 2}ms ${TIMEOUT * 2}ms ${TIMEOUT * 2 - 1}ms h ${TIMEOUT - 1}ms h ${TIMEOUT - 1}ms d ${TIMEOUT - 1}ms d ------`, marble) 172 | const subscription = `^----------- ${TIMEOUT}ms ${TIMEOUT * 2}ms ${TIMEOUT * 2}ms ${TIMEOUT * 2}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms -----!` 173 | const expected = ` ------------ ${TIMEOUT - 1}ms r ${TIMEOUT * 2 - 1}ms r ${TIMEOUT * 2 - 1}ms r ${TIMEOUT * 2}ms ${TIMEOUT}ms ${TIMEOUT}ms ${TIMEOUT}ms ------` 174 | 175 | expectObservable( 176 | epicRecoverReset$(TIMEOUT)(puppet$), 177 | subscription, 178 | ).toBe(expected, marble) 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /src/client/duck/epic-recover.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 | Observable, 22 | interval, 23 | timer, 24 | } from 'rxjs' 25 | import { 26 | debounce, 27 | filter, 28 | map, 29 | switchMap, 30 | takeUntil, 31 | tap, 32 | } from 'rxjs/operators' 33 | import { 34 | isActionOf, 35 | } from 'typesafe-actions' 36 | import type { 37 | // type Action, 38 | AnyAction, 39 | } from 'redux' 40 | import { 41 | Duck as PuppetDuck, 42 | } from 'wechaty-redux' 43 | import { log } from 'wechaty-puppet' 44 | 45 | const stateActive$ = (action$: Observable) => action$.pipe( 46 | filter(isActionOf(PuppetDuck.actions.STATE_ACTIVATED_EVENT)), 47 | filter(action => action.payload.state === true), 48 | ) 49 | 50 | const stateInactive$ = (action$: Observable) => action$.pipe( 51 | filter(isActionOf(PuppetDuck.actions.STATE_INACTIVATED_EVENT)), 52 | ) 53 | 54 | const heartbeat$ = (action$: Observable) => action$.pipe( 55 | filter(isActionOf([ 56 | PuppetDuck.actions.HEARTBEAT_RECEIVED_EVENT, 57 | PuppetDuck.actions.DONG_RECEIVED_EVENT, 58 | ])), 59 | ) 60 | 61 | // Emit once when an active puppet lost heartbeat after a timeout period 62 | const monitorHeartbeat$ = (timeoutMilliseconds: number) => 63 | (action$: Observable) => 64 | stateActive$(action$).pipe( 65 | switchMap(action => heartbeat$(action$).pipe( 66 | debounce(() => interval(timeoutMilliseconds)), 67 | tap(() => log.verbose('PuppetService', 'monitorHeartbeat$() %d seconds TIMEOUT', 68 | Math.floor(timeoutMilliseconds / 1000), 69 | )), 70 | map(() => PuppetDuck.actions.ERROR_RECEIVED_EVENT( 71 | action.meta.puppetId, 72 | { gerror: `monitorHeartbeat$() TIMEOUT(${timeoutMilliseconds})` }, 73 | )), 74 | )), 75 | ) 76 | 77 | const epicRecoverDing$ = (timeoutMilliseconds: number) => 78 | (action$: Observable) => 79 | monitorHeartbeat$(timeoutMilliseconds)(action$).pipe( 80 | switchMap(action => timer(0, Math.floor(timeoutMilliseconds)).pipe( 81 | tap(n => log.verbose('PuppetService', 'epicRecoverDing$() actions.ding() emitted #%d', n)), 82 | map(() => PuppetDuck.actions.DING_COMMAND( 83 | action.meta.puppetId, 84 | 'epicRecoverDing$', 85 | )), 86 | takeUntil(heartbeat$(action$)), 87 | takeUntil(stateInactive$(action$)), 88 | )), 89 | ) 90 | 91 | const epicRecoverReset$ = (timeoutMilliseconds: number) => 92 | (action$: Observable) => 93 | monitorHeartbeat$(timeoutMilliseconds)(action$).pipe( 94 | switchMap(action => timer(0, timeoutMilliseconds * 2).pipe( 95 | tap(n => log.verbose('PuppetService', 'epicRecoverReset$() actions.reset() emitted #%d', n)), 96 | map(() => PuppetDuck.actions.RESET_COMMAND( 97 | action.meta.puppetId, 98 | 'epicRecoverReset$', 99 | )), 100 | takeUntil(heartbeat$(action$)), 101 | takeUntil(stateInactive$(action$)), 102 | )), 103 | ) 104 | 105 | export { 106 | monitorHeartbeat$, 107 | epicRecoverReset$, 108 | epicRecoverDing$, 109 | } 110 | -------------------------------------------------------------------------------- /src/client/duck/epics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 | Observable, 22 | merge, 23 | } from 'rxjs' 24 | import type { 25 | AnyAction, 26 | } from 'redux' 27 | import type { 28 | Epic, 29 | } from 'redux-observable' 30 | 31 | import { 32 | epicRecoverDing$, 33 | epicRecoverReset$, 34 | } from './epic-recover.js' 35 | 36 | /** 37 | * The GRPC keepalive timeout is 20 seconds 38 | * So we use 15 seconds to save the GRPC keepalive cost 39 | * 40 | * https://github.com/grpc/grpc/blob/master/doc/keepalive.md 41 | * GRPC_ARG_KEEPALIVE_TIMEOUT_MS 20000 (20 seconds) 20000 (20 seconds) 42 | */ 43 | const TIMEOUT_SOFT = 15 * 1000 44 | const TIMEOUT_HARD = Math.floor(4.5 * TIMEOUT_SOFT) 45 | 46 | const recoverEpic: Epic = (action$: Observable) => merge( 47 | epicRecoverDing$(TIMEOUT_SOFT)(action$), 48 | epicRecoverReset$(TIMEOUT_HARD)(action$), 49 | ) 50 | 51 | export { 52 | recoverEpic, 53 | } 54 | -------------------------------------------------------------------------------- /src/client/duck/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 reducer from './reducers.js' 21 | 22 | import * as actions from './actions.js' 23 | import * as epics from './epics.js' 24 | import * as operations from './operations.js' 25 | import * as selectors from './selectors.js' 26 | import * as types from './types.js' 27 | import * as utils from './utils.js' 28 | 29 | export { 30 | actions, 31 | epics, 32 | operations, 33 | selectors, 34 | types, 35 | utils, 36 | } 37 | 38 | export default reducer 39 | -------------------------------------------------------------------------------- /src/client/duck/operations.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 type { Dispatch } from 'redux' 21 | 22 | import * as actions from './actions.js' 23 | 24 | const nop = (dispatch: Dispatch) => () => dispatch(actions.nop()) 25 | 26 | export { 27 | nop, 28 | } 29 | -------------------------------------------------------------------------------- /src/client/duck/reducers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 | createReducer, 22 | type ActionType, 23 | } from 'typesafe-actions' 24 | import type { 25 | DeepReadonly, 26 | } from 'utility-types' 27 | 28 | import * as actions from './actions.js' 29 | 30 | const initialState: DeepReadonly<{}> = {} 31 | 32 | const reducer = createReducer>(initialState) 33 | .handleAction(actions.nop, (state, _action) => state) 34 | 35 | export type State = ReturnType 36 | export default reducer 37 | -------------------------------------------------------------------------------- /src/client/duck/selectors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 type { State } from './reducers.js' 21 | 22 | // const getQrCode = (state: State) => (wechatyId: string) => state[wechatyId]?.qrcode 23 | // const getCurrentUser = (state: State) => (wechatyId: string) => state[wechatyId]?.currentUser // PUPPET.payloads.Contact 24 | // const isLoggedIn = (state: State) => (wechatyId: string) => state[wechatyId]?.currentUser !== undefined 25 | 26 | export { 27 | } 28 | -------------------------------------------------------------------------------- /src/client/duck/tests.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * Wechaty Open Source Software - https://github.com/wechaty 4 | * 5 | * @copyright 2016 Huan LI (李卓桓) , and 6 | * Wechaty Contributors . 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 { validateDuck } from 'ducks' 22 | 23 | import * as Duck from './mod.js' 24 | 25 | validateDuck(Duck) 26 | -------------------------------------------------------------------------------- /src/client/duck/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 | export const NOP = 'wechaty-puppet-service/NOP' 21 | -------------------------------------------------------------------------------- /src/client/duck/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 | export {} 21 | -------------------------------------------------------------------------------- /src/client/grpc-manager.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { GrpcManager } from './grpc-manager.js' 6 | 7 | /** 8 | * Huan(202108): 9 | * the Server Name Identifier (SNI) in the token 10 | * is required by the gRPC client. 11 | */ 12 | test('GrpcManager smoke testing', async t => { 13 | t.throws(() => new GrpcManager({ 14 | token: 'UUIDv4', 15 | }), 'should throw if there is no SNI prefix in token') 16 | 17 | t.doesNotThrow(() => new GrpcManager({ 18 | token: 'SNI_UUIDv4', 19 | }), 'should not throw if there is SNI prefix in token') 20 | }) 21 | -------------------------------------------------------------------------------- /src/client/grpc-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 util from 'util' 21 | import EventEmitter from 'events' 22 | import crypto from 'crypto' 23 | 24 | import { log } from 'wechaty-puppet' 25 | import { 26 | grpc, 27 | puppet, 28 | } from 'wechaty-grpc' 29 | import { 30 | timeoutPromise, 31 | } from 'gerror' 32 | import { 33 | WechatyToken, 34 | WechatyResolver, 35 | } from 'wechaty-token' 36 | 37 | import { 38 | GRPC_OPTIONS, 39 | envVars, 40 | } from '../config.js' 41 | 42 | import { callCredToken } from '../auth/mod.js' 43 | import { GrpcStatus } from '../auth/grpc-js.js' 44 | import { 45 | TLS_CA_CERT, 46 | TLS_INSECURE_SERVER_CERT_COMMON_NAME, 47 | } from '../auth/ca.js' 48 | 49 | import type { PuppetServiceOptions } from './puppet-service.js' 50 | 51 | /** 52 | * Huan(202108): register `wechaty` schema for gRPC service discovery 53 | * so that we can use `wechaty:///SNI_UUIDv4` for gRPC address 54 | * 55 | * See: https://github.com/wechaty/wechaty-puppet-service/issues/155 56 | */ 57 | WechatyResolver.setup() 58 | 59 | class GrpcManager extends EventEmitter { 60 | 61 | protected _client? : puppet.PuppetClient 62 | get client () : puppet.PuppetClient { 63 | if (!this._client) { 64 | throw new Error('NO CLIENT') 65 | } 66 | return this._client 67 | } 68 | 69 | eventStream? : grpc.ClientReadableStream 70 | 71 | /** 72 | * gRPC settings 73 | */ 74 | caCert : Buffer 75 | disableTls : boolean 76 | endpoint : string 77 | serverName : string 78 | token : WechatyToken 79 | 80 | constructor (private options: PuppetServiceOptions) { 81 | super() 82 | log.verbose('GrpcManager', 'constructor(%s)', JSON.stringify(options)) 83 | 84 | this.caCert = Buffer.from( 85 | envVars.WECHATY_PUPPET_SERVICE_TLS_CA_CERT(this.options.tls?.caCert) || TLS_CA_CERT, 86 | ) 87 | log.verbose('GrpcManager', 'constructor() tlsRootCert(hash): "%s"', 88 | crypto.createHash('sha256') 89 | .update(this.caCert) 90 | .digest('hex'), 91 | ) 92 | 93 | /** 94 | * Token will be used in the gRPC resolver (in endpoint) 95 | */ 96 | this.token = new WechatyToken( 97 | envVars.WECHATY_PUPPET_SERVICE_TOKEN(this.options.token), 98 | ) 99 | log.verbose('GrpcManager', 'constructor() token: "%s"', this.token) 100 | 101 | this.endpoint = envVars.WECHATY_PUPPET_SERVICE_ENDPOINT(this.options.endpoint) 102 | /** 103 | * Wechaty Token Discovery-able URL 104 | * See: wechaty-token / https://github.com/wechaty/wechaty-puppet-service/issues/155 105 | */ 106 | || [ 107 | 'wechaty://', 108 | envVars.WECHATY_PUPPET_SERVICE_AUTHORITY(this.options.authority), 109 | '/', 110 | this.token, 111 | ].join('') 112 | log.verbose('GrpcManager', 'constructor() endpoint: "%s"', this.endpoint) 113 | 114 | /** 115 | * Disable TLS 116 | */ 117 | this.disableTls = envVars.WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_CLIENT(this.options.tls?.disable) 118 | log.verbose('GrpcManager', 'constructor() disableTls: "%s"', this.disableTls) 119 | 120 | /** 121 | * for Node.js TLS SNI 122 | * https://en.wikipedia.org/wiki/Server_Name_Indication 123 | */ 124 | const serverNameIndication = envVars.WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME(this.options.tls?.serverName) 125 | /** 126 | * Huan(202108): we use SNI from token 127 | * if there is 128 | * neither override from environment variable: `WECHATY_PUPPET_SERVICE_TLS_SERVER_NAME` 129 | * nor set from: `options.tls.serverName` 130 | */ 131 | || this.token.sni 132 | 133 | if (!serverNameIndication) { 134 | throw new Error([ 135 | 'Wechaty Puppet Service requires a SNI as prefix of the token from version 0.30 and later.', 136 | `You can add the "${TLS_INSECURE_SERVER_CERT_COMMON_NAME}_" prefix to your token`, 137 | `like: "${TLS_INSECURE_SERVER_CERT_COMMON_NAME}_${this.token}"`, 138 | 'and try again.', 139 | ].join('\n')) 140 | } 141 | 142 | this.serverName = serverNameIndication 143 | log.verbose('GrpcManager', 'constructor() serverName(SNI): "%s"', this.serverName) 144 | } 145 | 146 | async start (): Promise { 147 | log.verbose('GrpcManager', 'start()') 148 | 149 | /** 150 | * 1. Init grpc client 151 | */ 152 | log.verbose('GrpcManager', 'start() initializing client ...') 153 | await this.initClient() 154 | log.verbose('GrpcManager', 'start() initializing client ... done') 155 | 156 | /** 157 | * 2. Connect to stream 158 | */ 159 | log.verbose('GrpcManager', 'start() starting stream ...') 160 | await this.startStream() 161 | log.verbose('GrpcManager', 'start() starting stream ... done') 162 | 163 | /** 164 | * 3. Start the puppet 165 | */ 166 | log.verbose('GrpcManager', 'start() calling grpc server: start() ...') 167 | await util.promisify( 168 | this.client.start 169 | .bind(this.client), 170 | )(new puppet.StartRequest()) 171 | log.verbose('GrpcManager', 'start() calling grpc server: start() ... done') 172 | 173 | log.verbose('GrpcManager', 'start() ... done') 174 | } 175 | 176 | async stop (): Promise { 177 | log.verbose('GrpcManager', 'stop()') 178 | 179 | /** 180 | * 1. Disconnect from stream 181 | */ 182 | log.verbose('GrpcManager', 'stop() stop stream ...') 183 | this.stopStream() 184 | log.verbose('GrpcManager', 'stop() stop stream ... done') 185 | 186 | /** 187 | * 2. Stop the puppet 188 | */ 189 | try { 190 | log.verbose('GrpcManager', 'stop() stop client ...') 191 | await util.promisify( 192 | this.client.stop 193 | .bind(this.client), 194 | )(new puppet.StopRequest()) 195 | log.verbose('GrpcManager', 'stop() stop client ... done') 196 | } catch (e) { 197 | this.emit('error', e) 198 | } 199 | 200 | /** 201 | * 3. Destroy grpc client 202 | */ 203 | try { 204 | log.verbose('GrpcManager', 'stop() destroy client ...') 205 | this.destroyClient() 206 | log.verbose('GrpcManager', 'stop() destroy client ... done') 207 | } catch (e) { 208 | this.emit('error', e) 209 | } 210 | 211 | log.verbose('GrpcManager', 'stop() ... done') 212 | } 213 | 214 | protected async initClient (): Promise { 215 | log.verbose('GrpcManager', 'initClient()') 216 | 217 | /** 218 | * Huan(202108): for maximum compatible with the non-tls community servers/clients, 219 | * we introduced the WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_{SERVER,CLIENT} environment variables. 220 | * if it has been set, then we will run under HTTP instead of HTTPS 221 | */ 222 | let credential 223 | if (this.disableTls) { 224 | log.warn('GrpcManager', 'initClient() TLS: disabled (INSECURE)') 225 | credential = grpc.credentials.createInsecure() 226 | } else { 227 | log.verbose('GrpcManager', 'initClient() TLS: enabled') 228 | const callCred = callCredToken(this.token.token) 229 | const channelCred = grpc.credentials.createSsl(this.caCert) 230 | const combCreds = grpc.credentials.combineChannelCredentials(channelCred, callCred) 231 | 232 | credential = combCreds 233 | } 234 | 235 | const clientOptions: grpc.ChannelOptions = { 236 | ...GRPC_OPTIONS, 237 | 'grpc.ssl_target_name_override': this.serverName, 238 | } 239 | 240 | { 241 | // Deprecated: this block will be removed after Dec 21, 2022. 242 | 243 | /** 244 | * Huan(202108): `grpc.default_authority` is a workaround 245 | * for compatiblity with the non-tls community servers/clients. 246 | * 247 | * See: https://github.com/wechaty/wechaty-puppet-service/pull/78 248 | */ 249 | const grpcDefaultAuthority = this.token.token 250 | clientOptions['grpc.default_authority'] = grpcDefaultAuthority 251 | } 252 | 253 | if (this._client) { 254 | log.warn('GrpcManager', 'initClient() this.#client exists? Old client has been dropped.') 255 | this._client = undefined 256 | } 257 | 258 | this._client = new puppet.PuppetClient( 259 | this.endpoint, 260 | credential, 261 | clientOptions, 262 | ) 263 | 264 | log.verbose('GrpcManager', 'initClient() ... done') 265 | } 266 | 267 | protected destroyClient (): void { 268 | log.verbose('GrpcManager', 'destroyClient()') 269 | 270 | if (!this._client) { 271 | log.warn('GrpcManager', 'destroyClient() this.#client not exist') 272 | return 273 | } 274 | 275 | const client = this._client 276 | /** 277 | * Huan(202108): we should set `this.client` to `undefined` at the current event loop 278 | * to prevent the future usage of the old client. 279 | */ 280 | this._client = undefined 281 | 282 | try { 283 | client.close() 284 | } catch (e) { 285 | log.error('GrpcManager', 'destroyClient() client.close() rejection: %s\n%s', e && (e as Error).message, (e as Error).stack) 286 | } 287 | } 288 | 289 | protected async startStream (): Promise { 290 | log.verbose('GrpcManager', 'startStream()') 291 | 292 | if (this.eventStream) { 293 | log.verbose('GrpcManager', 'startStream() this.eventStream exists, dropped.') 294 | this.eventStream = undefined 295 | } 296 | 297 | log.verbose('GrpcManager', 'startStream() grpc -> event() ...') 298 | const eventStream = this.client.event(new puppet.EventRequest()) 299 | log.verbose('GrpcManager', 'startStream() grpc -> event() ... done') 300 | 301 | /** 302 | * Store the event data from the stream when we test connection, 303 | * and re-emit the event data when we have finished testing the connection 304 | */ 305 | let peekedData: undefined | puppet.EventResponse 306 | 307 | /** 308 | * Huan(202108): future must be placed before other listenser registration 309 | * because the on('data') will start drain the stream 310 | */ 311 | const future = new Promise((resolve, reject) => eventStream 312 | /** 313 | * Huan(202108): we need a `heartbeat` event to confirm the connection is alive 314 | * for our wechaty-puppet-service server code, when the gRPC event stream is opened, 315 | * it will emit a `heartbeat` event as early as possible. 316 | * 317 | * However, the `heartbeat` event is not guaranteed to be emitted, 318 | * if the puppet service provider is coming from the community, like: 319 | * - paimon 320 | * 321 | * So we also need a timeout for compatible with those providers 322 | * in case of they are not following this special protocol. 323 | */ 324 | .once('data', (resp: puppet.EventResponse) => { 325 | peekedData = resp 326 | resolve() 327 | }) 328 | /** 329 | * Any of the following events will be emitted means that there's a problem. 330 | */ 331 | .once('cancel', reject) 332 | .once('end', reject) 333 | .once('error', reject) 334 | /** 335 | * The `status` event is import when we connect a gRPC stream. 336 | * 337 | * Huan(202108): according to the unit tests (tests/grpc-client.spec.ts) 338 | * 1. If client TLS is not ok (client no-tls but server TLS is required) 339 | * then status will be: 340 | * { code: 14, details: 'Connection dropped' } 341 | * 2. If client TLS is ok but the client token is invalid, 342 | * then status will be: 343 | * { code: 16, details: 'Invalid Wechaty TOKEN "0.XXX"' } 344 | */ 345 | .once('status', status => { 346 | // console.info('once(status)', status) 347 | status.code === GrpcStatus.OK 348 | ? resolve() 349 | : reject(new Error('once(status)')) 350 | }), 351 | /** 352 | * Huan(202108): `metadata` event will be fired 353 | * when the TLS connection is OK 354 | * even if the token is invalid 355 | * 356 | * Conclusion: we MUST NOT listen on `metadata` for `resolve`. 357 | */ 358 | // .once('metadata', (...args) => console.info('once(metadata)', ...args)) 359 | ) 360 | 361 | /** 362 | * Huan(202108): the `heartbeat` event is not guaranteed to be emitted 363 | * if a puppet service provider is coming from the community, it might not follow the protocol specification. 364 | * So we need a timeout for compatible with those providers 365 | */ 366 | log.verbose('GrpcManager', 'startStream() grpc -> event peeking data or timeout ...') 367 | try { 368 | await timeoutPromise(future, 5 * 1000) // 5 seconds 369 | } catch (_) { 370 | log.verbose('GrpcManager', 'startStream() grpc -> event peeking data or timeout ... timeout') 371 | } 372 | log.verbose('GrpcManager', 'startStream() grpc -> event peeking data or timeout ... data peeked') 373 | 374 | /** 375 | * Bridge the events 376 | * Huan(202108): adding the below event listeners 377 | * must be after the `await future` above, 378 | * so that if there's any `error` event, 379 | * it will be triggered already. 380 | */ 381 | log.verbose('GrpcManager', 'startStream() initializing event stream ...') 382 | eventStream 383 | .on('cancel', (...args) => this.emit('cancel', ...args)) 384 | .on('data', (...args) => this.emit('data', ...args)) 385 | .on('end', (...args) => this.emit('end', ...args)) 386 | .on('error', (...args) => this.emit('error', ...args)) 387 | .on('metadata', (...args) => this.emit('metadata', ...args)) 388 | .on('status', (...args) => this.emit('status', ...args)) 389 | 390 | this.eventStream = eventStream 391 | log.verbose('GrpcManager', 'startStream() initializing event stream ... done') 392 | 393 | /** 394 | * Re-emit the peeked data if there's any 395 | */ 396 | if (peekedData) { 397 | log.verbose('GrpcManager', 'startStream() sending back the peeked data ...') 398 | this.emit('data', peekedData) 399 | peekedData = undefined 400 | log.verbose('GrpcManager', 'startStream() sending back the peeked data ... done') 401 | } 402 | 403 | log.verbose('GrpcManager', 'startStream() ... done') 404 | } 405 | 406 | protected stopStream (): void { 407 | log.verbose('GrpcManager', 'stopStream()') 408 | 409 | if (!this.eventStream) { 410 | log.verbose('GrpcManager', 'stopStream() no eventStream when stop, skip destroy.') 411 | return 412 | } 413 | /** 414 | * Huan(202108): we should set `this.eventStream` to `undefined` at the current event loop 415 | * to prevent the future usage of the old eventStream. 416 | */ 417 | const eventStream = this.eventStream 418 | this.eventStream = undefined 419 | 420 | /** 421 | * Huan(202003): 422 | * destroy() will be enough to terminate a stream call. 423 | * cancel() is not needed. 424 | */ 425 | // this.eventStream.cancel() 426 | 427 | try { 428 | log.verbose('GrpcManager', 'stopStream() destroying event stream ...') 429 | eventStream.destroy() 430 | log.verbose('GrpcManager', 'stopStream() destroying event stream ... done') 431 | } catch (e) { 432 | this.emit('error', e) 433 | } 434 | } 435 | 436 | } 437 | 438 | export { 439 | GrpcManager, 440 | } 441 | -------------------------------------------------------------------------------- /src/client/payload-store.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { PayloadStore } from './payload-store.js' 6 | 7 | test('PayloadStore perfect restart', async t => { 8 | const token = Math.random().toString(36) 9 | const store = new PayloadStore({ token }) 10 | 11 | for (let i = 0; i < 3; i++) { 12 | const accountId = Math.random().toString(36) 13 | await store.start(accountId) 14 | await store.stop() 15 | t.pass('start/stop-ed at #' + i) 16 | } 17 | 18 | await store.destroy() 19 | }) 20 | -------------------------------------------------------------------------------- /src/client/payload-store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 path from 'path' 21 | import os from 'os' 22 | import fs from 'fs' 23 | 24 | import semverPkg from 'semver' 25 | 26 | import type * as PUPPET from 'wechaty-puppet' 27 | 28 | import { FlashStore } from 'flash-store' 29 | 30 | import { 31 | VERSION, 32 | log, 33 | } from '../config.js' 34 | 35 | const { major, minor } = semverPkg 36 | 37 | interface PayloadStoreOptions { 38 | token: string 39 | } 40 | 41 | interface StoreRoomMemberPayload { 42 | [roomMemberContactId: string]: PUPPET.payloads.RoomMember 43 | } 44 | 45 | class PayloadStore { 46 | 47 | // public message? : LRU 48 | 49 | public contact? : FlashStore 50 | public roomMember? : FlashStore 51 | public room? : FlashStore 52 | 53 | protected storeDir: string 54 | protected accountId?: string 55 | 56 | constructor (private options: PayloadStoreOptions) { 57 | log.verbose('PayloadStore', 'constructor(%s)', JSON.stringify(options)) 58 | 59 | this.storeDir = path.join( 60 | os.homedir(), 61 | '.wechaty', 62 | 'wechaty-puppet-service', 63 | this.options.token, 64 | `v${major(VERSION)}.${minor(VERSION)}`, 65 | ) 66 | log.silly('PayloadStore', 'constructor() storeDir: "%s"', this.storeDir) 67 | } 68 | 69 | /** 70 | * When starting the store, we need to know the accountId 71 | * so that we can save the payloads under a specific account folder. 72 | */ 73 | async start (accountId: string): Promise { 74 | log.verbose('PayloadStore', 'start(%s)', accountId) 75 | 76 | if (this.accountId) { 77 | throw new Error('PayloadStore should be stop() before start() again.') 78 | } 79 | this.accountId = accountId 80 | 81 | const accountDir = path.join(this.storeDir, accountId) 82 | 83 | if (!fs.existsSync(accountDir)) { 84 | fs.mkdirSync(accountDir, { recursive: true }) 85 | } 86 | 87 | this.contact = new FlashStore(path.join(accountDir, 'contact-payload')) 88 | this.roomMember = new FlashStore(path.join(accountDir, 'room-member-payload')) 89 | this.room = new FlashStore(path.join(accountDir, 'room-payload')) 90 | 91 | /** 92 | * LRU 93 | * 94 | * Huan(202108): the Wechaty Puppet has LRU cache already, 95 | * there's no need to do it again. 96 | * 97 | * We can focus on providing a persistent store for the performance. 98 | */ 99 | // const lruOptions: LRU.Options = { 100 | // dispose (key, val) { 101 | // log.silly('PayloadStore', `constructor() lruOptions.dispose(${key}, ${JSON.stringify(val)})`) 102 | // }, 103 | // max : 1000, // 1000 messages 104 | // maxAge : 60 * 60 * 1000, // 1 hour 105 | // } 106 | // this.message = new LRU(lruOptions) 107 | } 108 | 109 | async stop (): Promise { 110 | log.verbose('PayloadStore', 'stop()') 111 | 112 | const contactStore = this.contact 113 | const roomMemberStore = this.roomMember 114 | const roomStore = this.room 115 | /** 116 | * Huan(202108): we must set all the instances of the store to underfined 117 | * in the current event loop as soon as possible 118 | * to prevent the future store calls. 119 | */ 120 | this.contact = undefined 121 | this.roomMember = undefined 122 | this.room = undefined 123 | 124 | // LRU 125 | // this.message = undefined 126 | 127 | // clear accountId 128 | this.accountId = undefined 129 | 130 | await contactStore?.close() 131 | await roomMemberStore?.close() 132 | await roomStore?.close() 133 | } 134 | 135 | async destroy (): Promise { 136 | log.verbose('PayloadStore', 'destroy()') 137 | if (this.accountId) { 138 | throw new Error('Can not destroy() a start()-ed store. Call stop() to stop it first') 139 | } 140 | 141 | /** 142 | * Huan(202108): `fs.rm` was introduced from Node.js v14.14 143 | * https://nodejs.org/api/fs.html#fs_fspromises_rm_path_options 144 | */ 145 | await fs.promises.rmdir(this.storeDir, { 146 | // force: true, 147 | recursive: true, 148 | }) 149 | } 150 | 151 | } 152 | 153 | export { PayloadStore } 154 | -------------------------------------------------------------------------------- /src/client/puppet-service.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | import getPort from 'get-port' 5 | 6 | import { PuppetMock } from 'wechaty-puppet-mock' 7 | 8 | import { PuppetService } from './puppet-service.js' 9 | import { PuppetServer } from '../mod.js' 10 | 11 | test('version()', async t => { 12 | const puppet = new PuppetService({ 13 | token: 'test', 14 | }) 15 | t.ok(puppet.version()) 16 | }) 17 | 18 | /** 19 | * Huan(202003): 20 | * need to setup a test server to provide test token for Puppet Service 21 | */ 22 | test('PuppetService restart without problem', async t => { 23 | const TOKEN = 'insecure_token' 24 | const PORT = await getPort() 25 | const ENDPOINT = '0.0.0.0:' + PORT 26 | 27 | const puppet = new PuppetMock() 28 | const serverOptions = { 29 | endpoint: ENDPOINT, 30 | puppet, 31 | token: TOKEN, 32 | } as const 33 | 34 | const puppetServer = new PuppetServer(serverOptions) 35 | await puppetServer.start() 36 | 37 | /** 38 | * Puppet Service Client 39 | */ 40 | const puppetOptions = { 41 | endpoint: ENDPOINT, 42 | token: TOKEN, 43 | } as const 44 | 45 | const puppetService = new PuppetService(puppetOptions) 46 | 47 | try { 48 | for (let i = 0; i < 3; i++) { 49 | await puppetService.start() 50 | await puppetService.stop() 51 | t.pass('start/stop-ed at #' + i) 52 | } 53 | t.pass('PuppetService() start/restart successed.') 54 | } catch (e) { 55 | t.fail(e as any) 56 | } 57 | 58 | await puppetServer.stop() 59 | }) 60 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { log } from 'wechaty-puppet' 4 | 5 | import { packageJson } from './package-json.js' 6 | 7 | import * as rootEnvVars from './env-vars.js' 8 | import * as authEnvVars from './auth/env-vars.js' 9 | 10 | const VERSION = packageJson.version || '0.0.0' 11 | 12 | const envVars = { 13 | ...rootEnvVars, 14 | ...authEnvVars, 15 | } 16 | 17 | /** 18 | * gRPC default options 19 | */ 20 | const GRPC_OPTIONS = { 21 | // https://github.com/wechaty/wechaty-puppet-service/issues/86 22 | // 'grpc.max_receive_message_length': 1024 * 1024 * 150, 23 | // 'grpc.max_send_message_length': 1024 * 1024 * 150, 24 | } 25 | 26 | export { 27 | envVars, 28 | log, 29 | GRPC_OPTIONS, 30 | VERSION, 31 | } 32 | -------------------------------------------------------------------------------- /src/deprecated/chunk-pb.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { PassThrough } from 'stream' 6 | 7 | import { FileBox } from 'file-box' 8 | 9 | import { 10 | puppet, 11 | } from 'wechaty-grpc' 12 | 13 | import { 14 | unpackFileBoxFromChunk, 15 | packFileBoxToChunk, 16 | } from './file-box-chunk.js' 17 | import { 18 | unpackFileBoxChunkFromPb, 19 | packFileBoxChunkToPb, 20 | } from './chunk-pb.js' 21 | 22 | test('packFileBoxChunk()', async t => { 23 | const FILE_BOX_DATA = 'test' 24 | const FILE_BOX_NAME = 'test.dat' 25 | 26 | const fileBox = FileBox.fromBuffer( 27 | Buffer.from(FILE_BOX_DATA), 28 | FILE_BOX_NAME, 29 | ) 30 | 31 | const chunkStream = await packFileBoxToChunk(fileBox) 32 | const pbStream = await packFileBoxChunkToPb(puppet.MessageFileStreamResponse)(chunkStream) 33 | 34 | let name = '' 35 | let buffer = '' 36 | pbStream.on('data', (data: puppet.MessageFileStreamResponse) => { 37 | if (data.hasFileBoxChunk()) { 38 | const fileBoxChunk = data.getFileBoxChunk() 39 | if (fileBoxChunk!.hasData()) { 40 | buffer += fileBoxChunk!.getData() 41 | } else if (fileBoxChunk!.hasName()) { 42 | name = fileBoxChunk!.getName() 43 | } 44 | } 45 | }) 46 | 47 | await new Promise(resolve => chunkStream.on('end', resolve)) 48 | t.equal(name, FILE_BOX_NAME, 'should get file box name') 49 | t.equal(buffer, FILE_BOX_DATA, 'should get file box data') 50 | 51 | }) 52 | 53 | test('unpackFileBoxChunkFromPb()', async t => { 54 | const FILE_BOX_DATA = 'test' 55 | const FILE_BOX_NAME = 'test.dat' 56 | 57 | const fileBox = FileBox.fromBuffer( 58 | Buffer.from(FILE_BOX_DATA), 59 | FILE_BOX_NAME, 60 | ) 61 | 62 | const chunkStream = await packFileBoxToChunk(fileBox) 63 | const request = new puppet.MessageSendFileStreamRequest() 64 | 65 | const packedStream = new PassThrough({ objectMode: true }) 66 | 67 | chunkStream.on('data', (data: puppet.FileBoxChunk) => { 68 | request.setFileBoxChunk(data) 69 | packedStream.write(request) 70 | }).on('end', () => { 71 | packedStream.end() 72 | }) 73 | 74 | const outputChunkStream = unpackFileBoxChunkFromPb(packedStream) 75 | const outputFileBox = await unpackFileBoxFromChunk(outputChunkStream) 76 | 77 | t.equal((await outputFileBox.toBuffer()).toString(), FILE_BOX_DATA, 'should get file box data') 78 | }) 79 | 80 | test('packFileBoxChunk() <-> unpackFileBoxChunkFromPb()', async t => { 81 | const FILE_BOX_DATA = 'test' 82 | const FILE_BOX_NAME = 'test.dat' 83 | 84 | const fileBox = FileBox.fromBuffer( 85 | Buffer.from(FILE_BOX_DATA), 86 | FILE_BOX_NAME, 87 | ) 88 | 89 | const chunkStream = await packFileBoxToChunk(fileBox) 90 | const packedStream = packFileBoxChunkToPb(puppet.MessageFileStreamResponse)(chunkStream) 91 | 92 | const unpackedStream = unpackFileBoxChunkFromPb(packedStream) 93 | const restoredBox = await unpackFileBoxFromChunk(unpackedStream) 94 | t.equal(fileBox.name, restoredBox.name, 'should be same name') 95 | 96 | const EXPECTED_BASE64 = await fileBox.toBase64() 97 | const actualBase64 = await restoredBox.toBase64() 98 | 99 | t.equal(EXPECTED_BASE64, actualBase64, 'should be same content') 100 | }) 101 | 102 | test('packFileBoxChunk(): should not throw if no read on the stream', async t => { 103 | 104 | t.plan(1) 105 | const stream = await getTestChunkStream({}) 106 | let outStream 107 | try { 108 | outStream = packFileBoxChunkToPb(puppet.MessageFileStreamResponse)(stream) 109 | } catch (e) { 110 | t.ok((e as Error).message) 111 | return 112 | } 113 | outStream.on('error', _ => { /* Do nothing */ }) 114 | t.pass() 115 | }) 116 | 117 | test('packFileBoxChunk(): should emit error in the output stream', async t => { 118 | const EXPECTED_MESSAGE = 'test emit error' 119 | 120 | const stream = await getTestChunkStream({ errorMessage: EXPECTED_MESSAGE }) 121 | const outStream = packFileBoxChunkToPb(puppet.MessageFileStreamResponse)(stream) 122 | 123 | const error = await new Promise(resolve => outStream.on('error', resolve)) 124 | // await new Promise(resolve => outStream.on('end', resolve)) 125 | t.equal(error.message, EXPECTED_MESSAGE, 'should emit error message') 126 | }) 127 | 128 | test('unpackFileBoxChunkFromPb(): should not throw if no read on the stream', async t => { 129 | 130 | t.plan(1) 131 | const stream = await getTestPackedStream({}) 132 | let outStream 133 | try { 134 | outStream = unpackFileBoxChunkFromPb(stream) 135 | t.pass('should no rejection') 136 | } catch (e) { 137 | t.fail((e as Error).message) 138 | return 139 | } 140 | outStream.on('error', _ => { /* Do nothing */ }) 141 | }) 142 | 143 | test('unpackFileBoxChunkFromPb(): should emit error in the output stream', async t => { 144 | 145 | const errorMessage = 'test emit error' 146 | const stream = await getTestPackedStream({ errorMessage }) 147 | 148 | const outStream = packFileBoxChunkToPb(puppet.MessageFileStreamResponse)(stream) 149 | 150 | try { 151 | await new Promise((resolve, reject) => { 152 | outStream.on('error', reject) 153 | outStream.on('end', resolve) 154 | }) 155 | t.fail('should reject the promise') 156 | } catch (e) { 157 | t.equal((e as Error).message, errorMessage, 'should get the expected rejection error message') 158 | } 159 | }) 160 | 161 | async function getTestChunkStream (options: { 162 | errorMessage?: string, 163 | }) { 164 | const { errorMessage } = options 165 | 166 | const FILE_BOX_DATA = 'test' 167 | const FILE_BOX_NAME = 'test.dat' 168 | 169 | const fileBox = FileBox.fromBuffer( 170 | Buffer.from(FILE_BOX_DATA), 171 | FILE_BOX_NAME, 172 | ) 173 | 174 | const chunkStream = await packFileBoxToChunk(fileBox) 175 | setImmediate(() => { 176 | chunkStream.emit('error', new Error(errorMessage)) 177 | }) 178 | 179 | return chunkStream 180 | } 181 | 182 | async function getTestPackedStream (options: { 183 | errorMessage?: string, 184 | }) { 185 | const { errorMessage } = options 186 | 187 | const FILE_BOX_DATA = 'test' 188 | const FILE_BOX_NAME = 'test.dat' 189 | 190 | const fileBox = FileBox.fromBuffer( 191 | Buffer.from(FILE_BOX_DATA), 192 | FILE_BOX_NAME, 193 | ) 194 | 195 | const chunkStream = await packFileBoxToChunk(fileBox) 196 | const packedStream = new PassThrough({ objectMode: true }) 197 | chunkStream.on('data', d => { 198 | const packedChunk = new puppet.MessageFileStreamResponse() 199 | packedChunk.setFileBoxChunk(d) 200 | packedStream.write(packedChunk) 201 | }).on('error', e => { 202 | packedStream.emit('error', e) 203 | }) 204 | 205 | setImmediate(() => { 206 | chunkStream.emit('error', new Error(errorMessage)) 207 | }) 208 | 209 | return packedStream 210 | } 211 | -------------------------------------------------------------------------------- /src/deprecated/chunk-pb.ts: -------------------------------------------------------------------------------- 1 | import type { puppet } from 'wechaty-grpc' 2 | import { Readable, Transform } from 'stronger-typed-streams' 3 | import { PassThrough } from 'stream' 4 | 5 | import type { FileBoxPb } from './file-box-pb.type.js' 6 | 7 | /** 8 | * Wrap FileBoxChunk 9 | * @deprecated Will be removed after Dec 31, 2022 10 | */ 11 | const encoder = ( 12 | PbConstructor: { new(): T }, 13 | ) => new Transform({ 14 | objectMode: true, 15 | transform: (chunk: puppet.FileBoxChunk, _: any, callback: (error: Error | null, data: T) => void) => { 16 | const message = new PbConstructor() 17 | message.setFileBoxChunk(chunk) 18 | callback(null, message) 19 | }, 20 | }) 21 | 22 | /** 23 | * @deprecated Will be removed after Dec 31, 2022 24 | */ 25 | function packFileBoxChunkToPb ( 26 | PbConstructor: { new(): T }, 27 | ) { 28 | return (stream: Readable): Readable => { 29 | const outStream = new PassThrough({ objectMode: true }) 30 | const encodedStream = stream.pipe(encoder(PbConstructor)) 31 | 32 | stream.on('error', e => outStream.emit('error', e)) 33 | encodedStream.on('error', e => outStream.emit('error', e)) 34 | 35 | encodedStream.pipe(outStream) 36 | return outStream 37 | } 38 | } 39 | 40 | /** 41 | * Unwrap FileBoxChunk 42 | * @deprecated Will be removed after Dec 31, 2022 43 | */ 44 | const decoder = () => new Transform({ 45 | objectMode: true, 46 | transform: (chunk: T, _: any, callback: (error: Error | null, data?: puppet.FileBoxChunk) => void) => { 47 | const fileBoxChunk = chunk.getFileBoxChunk() 48 | if (!fileBoxChunk) { 49 | callback(new Error('No FileBoxChunk')) 50 | } else { 51 | callback(null, fileBoxChunk) 52 | } 53 | }, 54 | }) 55 | 56 | /** 57 | * @deprecated Will be removed after Dec 31, 2022 58 | */ 59 | function unpackFileBoxChunkFromPb ( 60 | stream: Readable, 61 | ): Readable { 62 | const outStream = new PassThrough({ objectMode: true }) 63 | const decodedStream = stream.pipe(decoder()) 64 | 65 | stream.on('error', e => outStream.emit('error', e)) 66 | decodedStream.on('error', e => outStream.emit('error', e)) 67 | 68 | decodedStream.pipe(outStream) 69 | return outStream 70 | } 71 | 72 | export { 73 | packFileBoxChunkToPb, 74 | unpackFileBoxChunkFromPb, 75 | } 76 | -------------------------------------------------------------------------------- /src/deprecated/conversation-id-file-box.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Wechaty Chatbot SDK - https://github.com/wechaty/wechaty 5 | * 6 | * @copyright 2016 Huan LI (李卓桓) , and 7 | * Wechaty Contributors . 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * http://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | * 21 | */ 22 | import { 23 | puppet, 24 | } from 'wechaty-grpc' 25 | 26 | import { test } from 'tstest' 27 | import { PassThrough } from 'stream' 28 | import { FileBox } from 'file-box' 29 | 30 | import { nextData } from './next-data.js' 31 | import { 32 | packConversationIdFileBoxToPb, 33 | unpackConversationIdFileBoxArgsFromPb, 34 | } from './conversation-id-file-box.js' 35 | 36 | test('unpackConversationIdFileBoxArgsFromPb()', async t => { 37 | const FILE_BOX_DATA = 'test' 38 | const FILE_BOX_NAME = 'test.dat' 39 | const CONVERSATION_ID = 'conversation_id' 40 | 41 | const fileBox = FileBox.fromBuffer( 42 | Buffer.from(FILE_BOX_DATA), 43 | FILE_BOX_NAME, 44 | ) 45 | 46 | const stream = new PassThrough({ objectMode: true }) 47 | 48 | const req1 = new puppet.MessageSendFileStreamRequest() 49 | req1.setConversationId(CONVERSATION_ID) 50 | stream.write(req1) 51 | 52 | const req2 = new puppet.MessageSendFileStreamRequest() 53 | const chunk1 = new puppet.FileBoxChunk() 54 | chunk1.setName(fileBox.name) 55 | req2.setFileBoxChunk(chunk1) 56 | stream.write(req2) 57 | 58 | const fileBoxStream = await fileBox.toStream() 59 | fileBoxStream.on('data', chunk => { 60 | const fileBoxChunk = new puppet.FileBoxChunk() 61 | fileBoxChunk.setData(chunk) 62 | const req3 = new puppet.MessageSendFileStreamRequest() 63 | req3.setFileBoxChunk(fileBoxChunk) 64 | stream.write(req3) 65 | }) 66 | fileBoxStream.on('end', () => stream.end()) 67 | 68 | const args = await unpackConversationIdFileBoxArgsFromPb(stream) 69 | const data = (await args.fileBox.toBuffer()).toString() 70 | 71 | t.equal(args.conversationId, CONVERSATION_ID, 'should get conversation id') 72 | t.equal(args.fileBox.name, FILE_BOX_NAME, 'should get file box name') 73 | t.equal(data, FILE_BOX_DATA, 'should get file box data') 74 | }) 75 | 76 | test('packConversationIdFileBoxToPb()', async t => { 77 | const FILE_BOX_DATA = 'test' 78 | const FILE_BOX_NAME = 'test.dat' 79 | const CONVERSATION_ID = 'conv_id' 80 | 81 | const fileBox = FileBox.fromBuffer( 82 | Buffer.from(FILE_BOX_DATA), 83 | FILE_BOX_NAME, 84 | ) 85 | 86 | const stream = await packConversationIdFileBoxToPb(puppet.MessageSendFileStreamRequest)( 87 | CONVERSATION_ID, 88 | fileBox, 89 | ) 90 | 91 | const data1 = await nextData(stream) 92 | t.equal(data1.getConversationId(), CONVERSATION_ID, 'match conversation id') 93 | 94 | const data2 = await nextData(stream) 95 | t.ok(data2.hasFileBoxChunk(), 'has file box chunk') 96 | t.ok(data2.getFileBoxChunk()!.hasName(), 'has file box name') 97 | t.equal(data2.getFileBoxChunk()!.getName(), FILE_BOX_NAME, 'match file box name') 98 | 99 | let data = '' 100 | stream.on('data', (chunk: puppet.MessageSendFileStreamRequest) => { 101 | if (!chunk.hasFileBoxChunk()) { 102 | throw new Error('no file box chunk') 103 | } 104 | if (!chunk.getFileBoxChunk()!.hasData()) { 105 | throw new Error('no file box chunk data') 106 | } 107 | data += chunk.getFileBoxChunk()!.getData() 108 | }) 109 | 110 | await new Promise(resolve => stream.on('end', resolve)) 111 | 112 | t.equal(data.toString(), FILE_BOX_DATA, 'should get file box data') 113 | }) 114 | 115 | test('unpackConversationIdFileBoxArgsFromPb() <-> packConversationIdFileBoxToPb()', async t => { 116 | const FILE_BOX_DATA = 'test' 117 | const FILE_BOX_NAME = 'test.dat' 118 | const CONVERSATION_ID = 'conv_id' 119 | 120 | const fileBox = FileBox.fromBuffer( 121 | Buffer.from(FILE_BOX_DATA), 122 | FILE_BOX_NAME, 123 | ) 124 | 125 | const stream = await packConversationIdFileBoxToPb(puppet.MessageSendFileStreamRequest)(CONVERSATION_ID, fileBox) 126 | const args = await unpackConversationIdFileBoxArgsFromPb(stream) 127 | 128 | t.equal(args.conversationId, CONVERSATION_ID, 'should match conversation id') 129 | t.equal(args.fileBox.name, FILE_BOX_NAME, 'should be same name') 130 | t.equal((await args.fileBox.toBuffer()).toString(), FILE_BOX_DATA, 'should be same content') 131 | }) 132 | -------------------------------------------------------------------------------- /src/deprecated/conversation-id-file-box.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FileBoxInterface, 3 | } from 'file-box' 4 | import { PassThrough } from 'stream' 5 | import type { Readable } from 'stronger-typed-streams' 6 | 7 | import { nextData } from './next-data.js' 8 | import { 9 | packFileBoxToPb, 10 | unpackFileBoxFromPb, 11 | } from './file-box-pb.js' 12 | import type { ConversationIdFileBoxPb } from './file-box-pb.type.js' 13 | 14 | interface ConversationIdFileBoxArgs { 15 | conversationId: string, 16 | fileBox: FileBoxInterface, 17 | } 18 | 19 | /** 20 | * MessageSendFileStreamRequest to Args 21 | * @deprecated Will be removed after Dec 31, 2022 22 | */ 23 | async function unpackConversationIdFileBoxArgsFromPb ( 24 | stream: Readable, 25 | ): Promise { 26 | const chunk = await nextData(stream) 27 | if (!chunk.hasConversationId()) { 28 | throw new Error('no conversation id') 29 | } 30 | const conversationId = chunk.getConversationId() 31 | 32 | // unpackFileBoxFromChunk(unpackFileBoxChunkFromPb(stream)) 33 | const fileBox = await unpackFileBoxFromPb(stream) 34 | 35 | return { 36 | conversationId, 37 | fileBox, 38 | } 39 | } 40 | 41 | /** 42 | * Args to MessageSendFileStreamRequest 43 | * @deprecated Will be removed after Dec 31, 2022 44 | */ 45 | function packConversationIdFileBoxToPb ( 46 | PbConstructor: { new(): T }, 47 | ) { 48 | return async ( 49 | conversationId: string, 50 | fileBox: FileBoxInterface, 51 | ): Promise< 52 | Readable 53 | > => { 54 | const stream = new PassThrough({ objectMode: true }) 55 | 56 | const first = new PbConstructor() 57 | first.setConversationId(conversationId) 58 | stream.write(first) 59 | 60 | // const fileBoxChunkStream = await packFileBoxToChunk(fileBox) 61 | // packFileBoxChunkToPb(MessageSendFileStreamRequest)(fileBoxChunkStream) 62 | const pbStream = await packFileBoxToPb(PbConstructor)(fileBox) 63 | pbStream.pipe(stream) 64 | 65 | return stream 66 | } 67 | } 68 | 69 | export { 70 | unpackConversationIdFileBoxArgsFromPb, 71 | packConversationIdFileBoxToPb, 72 | } 73 | -------------------------------------------------------------------------------- /src/deprecated/file-box-chunk.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Wechaty Chatbot SDK - https://github.com/wechaty/wechaty 5 | * 6 | * @copyright 2016 Huan LI (李卓桓) , and 7 | * Wechaty Contributors . 8 | * 9 | * Licensed under the Apache License, Version 2.0 (the "License"); 10 | * you may not use this file except in compliance with the License. 11 | * You may obtain a copy of the License at 12 | * 13 | * http://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software 16 | * distributed under the License is distributed on an "AS IS" BASIS, 17 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | * See the License for the specific language governing permissions and 19 | * limitations under the License. 20 | * 21 | */ 22 | import { test } from 'tstest' 23 | 24 | import { PassThrough } from 'stream' 25 | import type { Duplex } from 'stronger-typed-streams' 26 | import { FileBox } from 'file-box' 27 | 28 | import { 29 | puppet, 30 | } from 'wechaty-grpc' 31 | 32 | import { 33 | unpackFileBoxFromChunk, 34 | packFileBoxToChunk, 35 | } from './file-box-chunk.js' 36 | import { nextData } from './next-data.js' 37 | 38 | test('unpackFileBoxFromChunk()', async t => { 39 | const FILE_BOX_DATA = 'test' 40 | const FILE_BOX_NAME = 'test.dat' 41 | 42 | const stream = await getFileBoxStreamStub(FILE_BOX_DATA, FILE_BOX_NAME) 43 | 44 | const decodedFileBox = await unpackFileBoxFromChunk(stream) 45 | const data = (await decodedFileBox.toBuffer()).toString() 46 | 47 | t.equal(decodedFileBox.name, FILE_BOX_NAME, 'should get file box name') 48 | t.equal(data, FILE_BOX_DATA, 'should get file box data') 49 | 50 | }) 51 | 52 | test('packFileBoxToChunk()', async t => { 53 | const FILE_BOX_DATA = 'test' 54 | const FILE_BOX_NAME = 'test.dat' 55 | 56 | const fileBox = FileBox.fromBuffer( 57 | Buffer.from(FILE_BOX_DATA), 58 | FILE_BOX_NAME, 59 | ) 60 | 61 | const stream = await packFileBoxToChunk(fileBox) 62 | 63 | const fileBoxChunk = await nextData(stream) 64 | t.ok(fileBoxChunk.hasName(), 'has name') 65 | 66 | const fileName = fileBoxChunk.getName() 67 | t.equal(fileName, FILE_BOX_NAME, 'should get name') 68 | 69 | let data = '' 70 | stream.on('data', (chunk: puppet.FileBoxChunk) => { 71 | if (!chunk.hasData()) { 72 | throw new Error('no data') 73 | } 74 | data += chunk.getData() 75 | }) 76 | 77 | await new Promise(resolve => stream.on('end', resolve)) 78 | 79 | t.equal(data, FILE_BOX_DATA, 'should get file box data') 80 | }) 81 | 82 | test('packFileBoxToChunk() <-> unpackFileBoxFromChunk()', async t => { 83 | const FILE_BOX_DATA = 'test' 84 | const FILE_BOX_NAME = 'test.dat' 85 | 86 | const fileBox = FileBox.fromBuffer( 87 | Buffer.from(FILE_BOX_DATA), 88 | FILE_BOX_NAME, 89 | ) 90 | 91 | const stream = await packFileBoxToChunk(fileBox) 92 | const restoredBox = await unpackFileBoxFromChunk(stream) 93 | 94 | t.equal(fileBox.name, restoredBox.name, 'should be same name') 95 | t.equal(await fileBox.toBase64(), await restoredBox.toBase64(), 'should be same content') 96 | }) 97 | 98 | test('should handle no name error in catch', async t => { 99 | t.plan(1) 100 | const FILE_BOX_DATA = 'test' 101 | const FILE_BOX_NAME = 'test.dat' 102 | 103 | const stream = await getFileBoxStreamStub(FILE_BOX_DATA, FILE_BOX_NAME, true) 104 | 105 | try { 106 | await unpackFileBoxFromChunk(stream) 107 | } catch (e) { 108 | t.equal((e as Error).message, 'no name') 109 | } 110 | }) 111 | 112 | test('should handle first error catch', async t => { 113 | t.plan(1) 114 | const FILE_BOX_DATA = 'test' 115 | const FILE_BOX_NAME = 'test.dat' 116 | 117 | const stream = await getFileBoxStreamStub(FILE_BOX_DATA, FILE_BOX_NAME, false, true) 118 | 119 | try { 120 | await unpackFileBoxFromChunk(stream) 121 | } catch (e) { 122 | t.equal((e as Error).message, 'first exception') 123 | } 124 | }) 125 | 126 | test('should handle middle error in further ops', async t => { 127 | t.plan(1) 128 | const FILE_BOX_DATA = 'test' 129 | const FILE_BOX_NAME = 'test.dat' 130 | 131 | const stream = await getFileBoxStreamStub(FILE_BOX_DATA, FILE_BOX_NAME, false, false, true) 132 | 133 | const fileBox = await unpackFileBoxFromChunk(stream) 134 | try { 135 | await fileBox.toBuffer() 136 | } catch (e) { 137 | t.equal((e as Error).message, 'middle exception') 138 | } 139 | }) 140 | 141 | async function getFileBoxStreamStub ( 142 | data: string, 143 | name: string, 144 | noname = false, 145 | firstException = false, 146 | middleException = false, 147 | ) { 148 | const fileBox = FileBox.fromBuffer( 149 | Buffer.from(data), 150 | name, 151 | ) 152 | 153 | const stream: Duplex = new PassThrough({ objectMode: true }) 154 | 155 | if (firstException) { 156 | stream.pause() 157 | setImmediate(() => { 158 | stream.emit('error', new Error('first exception')) 159 | stream.resume() 160 | }) 161 | } else { 162 | const chunk1 = new puppet.FileBoxChunk() 163 | if (!noname) { 164 | chunk1.setName(fileBox.name) 165 | } 166 | setTimeout(() => stream.write(chunk1), 10) 167 | } 168 | 169 | if (middleException) { 170 | setTimeout(() => { 171 | stream.emit('error', new Error('middle exception')) 172 | }, 100) 173 | } 174 | 175 | const fileBoxStream = await fileBox.toStream() 176 | fileBoxStream.on('data', chunk => { 177 | const fileBoxChunk = new puppet.FileBoxChunk() 178 | fileBoxChunk.setData(chunk) 179 | setTimeout(() => stream.write(fileBoxChunk), 200) 180 | }) 181 | fileBoxStream.on('end', () => setTimeout(() => stream.end(), 250)) 182 | 183 | return stream 184 | } 185 | -------------------------------------------------------------------------------- /src/deprecated/file-box-chunk.ts: -------------------------------------------------------------------------------- 1 | import { FileBox } from 'file-box' 2 | import type { FileBoxInterface } from 'file-box' 3 | import { PassThrough } from 'stream' 4 | import { 5 | Readable, 6 | Transform, 7 | } from 'stronger-typed-streams' 8 | 9 | import { puppet } from 'wechaty-grpc' 10 | 11 | import { nextData } from './next-data.js' 12 | 13 | /** 14 | * @deprecated Will be removed after Dec 31, 2022 15 | */ 16 | const decoder = () => new Transform({ 17 | objectMode: true, 18 | transform: (chunk: puppet.FileBoxChunk, _: any, callback: any) => { 19 | if (!chunk.hasData()) { 20 | callback(new Error('no data')) 21 | return 22 | } 23 | const data = chunk.getData() 24 | callback(null, data) 25 | }, 26 | }) 27 | 28 | /** 29 | * @deprecated Will be removed after Dec 31, 2022 30 | */ 31 | async function unpackFileBoxFromChunk ( 32 | stream: Readable, 33 | ): Promise { 34 | const chunk = await nextData(stream) 35 | if (!chunk.hasName()) { 36 | throw new Error('no name') 37 | } 38 | const fileName = chunk.getName() 39 | 40 | const fileStream = new PassThrough({ objectMode: true }) 41 | const transformedStream = stream.pipe(decoder()) 42 | transformedStream.pipe(fileStream) 43 | stream.on('error', e => fileStream.emit('error', e)) 44 | transformedStream.on('error', e => fileStream.emit('error', e)) 45 | 46 | const fileBox = FileBox.fromStream(fileStream, fileName) 47 | 48 | return fileBox 49 | } 50 | 51 | /** 52 | * @deprecated Will be removed after Dec 31, 2022 53 | */ 54 | const encoder = () => new Transform({ 55 | objectMode: true, 56 | transform: (chunk: any, _: any, callback: any) => { 57 | const fileBoxChunk = new puppet.FileBoxChunk() 58 | fileBoxChunk.setData(chunk) 59 | callback(null, fileBoxChunk) 60 | }, 61 | }) 62 | 63 | /** 64 | * @deprecated Will be removed after Dec 31, 2022 65 | */ 66 | async function packFileBoxToChunk ( 67 | fileBox: FileBoxInterface, 68 | ): Promise> { 69 | const stream = new PassThrough({ objectMode: true }) 70 | 71 | const chunk = new puppet.FileBoxChunk() 72 | chunk.setName(fileBox.name) 73 | 74 | // FIXME: Huan(202010) write might return false 75 | stream.write(chunk) 76 | 77 | fileBox 78 | .pipe(encoder()) 79 | .pipe(stream) 80 | 81 | return stream 82 | } 83 | 84 | export { 85 | unpackFileBoxFromChunk, 86 | packFileBoxToChunk, 87 | } 88 | -------------------------------------------------------------------------------- /src/deprecated/file-box-pb.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { FileBox } from 'file-box' 6 | 7 | import { 8 | puppet, 9 | } from 'wechaty-grpc' 10 | 11 | import { 12 | packFileBoxToPb, 13 | unpackFileBoxFromPb, 14 | } from './file-box-pb.js' 15 | 16 | test('packFileBoxToPb()', async t => { 17 | const FILE_BOX_DATA = 'test' 18 | const FILE_BOX_NAME = 'test.dat' 19 | 20 | const fileBox = FileBox.fromBuffer( 21 | Buffer.from(FILE_BOX_DATA), 22 | FILE_BOX_NAME, 23 | ) 24 | 25 | const pb = await packFileBoxToPb(puppet.MessageFileStreamResponse)(fileBox) 26 | const restoredFileBox = await unpackFileBoxFromPb(pb) 27 | t.ok(restoredFileBox instanceof FileBox, 'should get an instance of FileBOX') 28 | 29 | t.equal(restoredFileBox.name, fileBox.name, 'should get the right file box name') 30 | t.equal(await restoredFileBox.toBase64(), await fileBox.toBase64(), 'should get the right file box content') 31 | }) 32 | -------------------------------------------------------------------------------- /src/deprecated/file-box-pb.ts: -------------------------------------------------------------------------------- 1 | import type { FileBoxInterface } from 'file-box' 2 | import type { Readable } from 'stronger-typed-streams' 3 | 4 | import { 5 | packFileBoxToChunk, 6 | unpackFileBoxFromChunk, 7 | } from './file-box-chunk.js' 8 | import { 9 | packFileBoxChunkToPb, 10 | unpackFileBoxChunkFromPb, 11 | } from './chunk-pb.js' 12 | import type { FileBoxPb } from './file-box-pb.type.js' 13 | 14 | /** 15 | * @deprecated Will be removed after Dec 31, 2022 16 | */ 17 | function packFileBoxToPb ( 18 | PbConstructor: { new(): T }, 19 | ) { 20 | return async (fileBox: FileBoxInterface) => { 21 | const fileBoxChunkStream = await packFileBoxToChunk(fileBox) 22 | const pbFileBox = packFileBoxChunkToPb(PbConstructor)(fileBoxChunkStream) 23 | return pbFileBox 24 | } 25 | } 26 | 27 | /** 28 | * @deprecated Will be removed after Dec 31, 2022 29 | */ 30 | async function unpackFileBoxFromPb ( 31 | pbStream: Readable, 32 | ): Promise { 33 | const fileBoxChunkStream = unpackFileBoxChunkFromPb(pbStream) 34 | const fileBox = await unpackFileBoxFromChunk(fileBoxChunkStream) 35 | return fileBox 36 | } 37 | 38 | export { 39 | packFileBoxToPb, 40 | unpackFileBoxFromPb, 41 | } 42 | -------------------------------------------------------------------------------- /src/deprecated/file-box-pb.type.ts: -------------------------------------------------------------------------------- 1 | import type { puppet } from 'wechaty-grpc' 2 | 3 | /** 4 | * Any Protocol Buffer message that include a FileBoxChunk 5 | */ 6 | export interface FileBoxPb { 7 | hasFileBoxChunk(): boolean 8 | getFileBoxChunk(): puppet.FileBoxChunk | undefined 9 | setFileBoxChunk(value?: puppet.FileBoxChunk): void 10 | } 11 | 12 | export interface ConversationIdFileBoxPb extends FileBoxPb { 13 | hasConversationId(): boolean 14 | getConversationId(): string 15 | setConversationId(value: string): void 16 | } 17 | -------------------------------------------------------------------------------- /src/deprecated/mod.ts: -------------------------------------------------------------------------------- 1 | export { 2 | packConversationIdFileBoxToPb, 3 | unpackConversationIdFileBoxArgsFromPb, 4 | } from './conversation-id-file-box.js' 5 | export { 6 | packFileBoxToPb, 7 | unpackFileBoxFromPb, 8 | } from './file-box-pb.js' 9 | -------------------------------------------------------------------------------- /src/deprecated/next-data.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Readable, 3 | } from 'stronger-typed-streams' 4 | 5 | /** 6 | * windmemory(20201027): generating fileBox data in server side might take longer. 7 | * Set to 60 sec to avoid unexpected timeout. 8 | */ 9 | const TIMEOUT = 60 * 1000 10 | 11 | /** 12 | * @deprecated Will be removed after Dec 31, 2022 13 | */ 14 | async function nextData ( 15 | stream: Readable, 16 | ): Promise { 17 | const chunk = await new Promise((resolve, reject) => { 18 | const timer = setTimeout(reject, TIMEOUT) 19 | stream.once('data', chunk => { 20 | stream.pause() 21 | clearTimeout(timer) 22 | 23 | resolve(chunk) 24 | }) 25 | stream.once('error', reject) 26 | 27 | }) 28 | 29 | stream.resume() 30 | return chunk 31 | } 32 | 33 | export { 34 | nextData, 35 | } 36 | -------------------------------------------------------------------------------- /src/deprecated/serialize-file-box.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileBox, 3 | FileBoxType, 4 | } from 'file-box' 5 | import type { 6 | FileBoxInterface, 7 | } from 'file-box' 8 | 9 | /** 10 | * @deprecated Will be removed after Dec 31, 2022 11 | */ 12 | export const serializeFileBox = async (fileBox: FileBoxInterface): Promise => { 13 | const serializableFileBoxTypes = [ 14 | FileBoxType.Base64, 15 | FileBoxType.Url, 16 | FileBoxType.QRCode, 17 | ] 18 | if (serializableFileBoxTypes.includes(fileBox.type)) { 19 | return JSON.stringify(fileBox) 20 | } 21 | const base64 = await fileBox.toBase64() 22 | const name = fileBox.name 23 | return JSON.stringify(FileBox.fromBase64(base64, name)) 24 | } 25 | -------------------------------------------------------------------------------- /src/env-vars.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | // tslint:disable:no-shadowed-variable 4 | import { test } from 'tstest' 5 | 6 | import { WECHATY_PUPPET_SERVICE_AUTHORITY } from './env-vars.js' 7 | 8 | /** 9 | * Huan(202108): compatible with old env var 10 | * See: https://github.com/wechaty/wechaty-puppet-service/issues/156 11 | * 12 | * This feature will be removed after Dec 31, 2022 13 | */ 14 | test('WECHATY_PUPPET_SERVICE_AUTHORITY()', async t => { 15 | const EXPECTED_AUTHORITY = 'api.wechaty.io' 16 | const oldValue = process.env['WECHATY_SERVICE_DISCOVERY_ENDPOINT'] 17 | process.env['WECHATY_SERVICE_DISCOVERY_ENDPOINT'] = `https://${EXPECTED_AUTHORITY}` 18 | 19 | const result = WECHATY_PUPPET_SERVICE_AUTHORITY() 20 | t.equal(result, EXPECTED_AUTHORITY, 'should extract authority') 21 | 22 | process.env['WECHATY_SERVICE_DISCOVERY_ENDPOINT'] = oldValue 23 | }) 24 | -------------------------------------------------------------------------------- /src/env-vars.ts: -------------------------------------------------------------------------------- 1 | import { log } from 'wechaty-puppet' 2 | 3 | // Huan(202011): use a function to return the value in time. 4 | const WECHATY_PUPPET_SERVICE_TOKEN: (token?: string) => string = token => { 5 | if (token) { 6 | return token 7 | } 8 | 9 | if (process.env['WECHATY_PUPPET_SERVICE_TOKEN']) { 10 | return process.env['WECHATY_PUPPET_SERVICE_TOKEN'] 11 | } 12 | 13 | /** 14 | * Huan(202102): remove this deprecated warning after Dec 31, 2021 15 | */ 16 | if (process.env['WECHATY_PUPPET_HOSTIE_TOKEN']) { 17 | log.warn('wechaty-puppet-service', [ 18 | '', 19 | 'WECHATY_PUPPET_HOSTIE_TOKEN has been deprecated,', 20 | 'please use WECHATY_PUPPET_SERVICE_TOKEN instead.', 21 | 'See: https://github.com/wechaty/wechaty-puppet-service/issues/118', 22 | '', 23 | ].join(' ')) 24 | return process.env['WECHATY_PUPPET_HOSTIE_TOKEN'] 25 | } 26 | 27 | const tokenNotFoundError = 'wechaty-puppet-service: WECHATY_PUPPET_SERVICE_TOKEN not found' 28 | 29 | console.error([ 30 | '', 31 | tokenNotFoundError, 32 | '(save token to WECHATY_PUPPET_SERVICE_TOKEN env var or pass it to puppet options is required.).', 33 | '', 34 | 'To learn how to get Wechaty Puppet Service Token,', 35 | 'please visit ', 36 | 'to see our Wechaty Puppet Service Providers.', 37 | '', 38 | ].join('\n')) 39 | 40 | throw new Error(tokenNotFoundError) 41 | } 42 | 43 | const WECHATY_PUPPET_SERVICE_ENDPOINT = (endpoint?: string) => { 44 | if (endpoint) { 45 | return endpoint 46 | } 47 | 48 | if (process.env['WECHATY_PUPPET_SERVICE_ENDPOINT']) { 49 | return process.env['WECHATY_PUPPET_SERVICE_ENDPOINT'] 50 | } 51 | /** 52 | * Huan(202102): remove this deprecated warning after Dec 31, 2021 53 | */ 54 | if (process.env['WECHATY_PUPPET_HOSTIE_ENDPOINT']) { 55 | log.warn('wechaty-puppet-service', [ 56 | '', 57 | 'WECHATY_PUPPET_HOSTIE_ENDPOINT has been deprecated,', 58 | 'please use WECHATY_PUPPET_SERVICE_ENDPOINT instead.', 59 | 'See: https://github.com/wechaty/wechaty-puppet-service/issues/118', 60 | '', 61 | ].join(' ')) 62 | return process.env['WECHATY_PUPPET_HOSTIE_ENDPOINT'] 63 | } 64 | 65 | return undefined 66 | } 67 | 68 | const WECHATY_PUPPET_SERVICE_AUTHORITY = (authority?: string) => { 69 | if (authority) { 70 | return authority 71 | } 72 | 73 | authority = process.env['WECHATY_PUPPET_SERVICE_AUTHORITY'] 74 | if (authority) { 75 | return authority 76 | } 77 | 78 | const deprecatedDiscoveryEndpoint = process.env['WECHATY_SERVICE_DISCOVERY_ENDPOINT'] 79 | if (deprecatedDiscoveryEndpoint) { 80 | console.error([ 81 | 'Environment variable WECHATY_SERVICE_DISCOVERY_ENDPOINT is deprecated,', 82 | 'Use WECHATY_PUPPET_SERVICE_AUTHORITY instead.', 83 | 'See: https://github.com/wechaty/wechaty-puppet-service/issues/156', 84 | ].join('\n')) 85 | return deprecatedDiscoveryEndpoint 86 | .replace(/^https?:\/\//, '') 87 | .replace(/\/*$/, '') 88 | } 89 | 90 | /** 91 | * Huan(202108): Wechaty community default authority 92 | */ 93 | return 'api.chatie.io' 94 | } 95 | 96 | export { 97 | WECHATY_PUPPET_SERVICE_ENDPOINT, 98 | WECHATY_PUPPET_SERVICE_TOKEN, 99 | WECHATY_PUPPET_SERVICE_AUTHORITY, 100 | } 101 | -------------------------------------------------------------------------------- /src/event-type-rev.ts: -------------------------------------------------------------------------------- 1 | import { 2 | puppet, 3 | } from 'wechaty-grpc' 4 | 5 | /** 6 | * Huan(202003): 7 | * @chatie/GRPC proto gen TS does not generate the ENUM type with reverse mapping. 8 | * So we need to do it by ourselves: 9 | * 1. define the EventTypeRev, and 10 | * 2. loop EventType to fill it. 11 | */ 12 | export const EventTypeRev = {} as { 13 | [key: number]: string, 14 | } 15 | 16 | for (const key in puppet.EventType) { 17 | const val = puppet.EventType[key as keyof puppet.EventTypeMap] 18 | EventTypeRev[val] = key 19 | } 20 | -------------------------------------------------------------------------------- /src/file-box-helper/mod.ts: -------------------------------------------------------------------------------- 1 | import { 2 | uuidifyFileBoxGrpc, 3 | } from './uuidify-file-box-grpc.js' 4 | import { 5 | normalizeFileBoxUuid, 6 | } from './normalize-filebox.js' 7 | 8 | export { 9 | normalizeFileBoxUuid, 10 | uuidifyFileBoxGrpc, 11 | } 12 | -------------------------------------------------------------------------------- /src/file-box-helper/normalize-filebox.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | import { FileBox } from 'file-box' 5 | 6 | import { canPassthrough } from './normalize-filebox.js' 7 | 8 | const kbFileBox = (size: number) => FileBox.fromBuffer(Buffer.from( 9 | new Int8Array(size * 1024).fill(0), 10 | ), size + 'KB.txt') 11 | 12 | test('canPassthrough() size threshold', async t => { 13 | t.ok(canPassthrough(kbFileBox(16)), 'should passthrough 16KB') 14 | t.notOk(canPassthrough(kbFileBox(32)), 'should not passthrough 32KB') 15 | }) 16 | 17 | test('canPassthrough(): always true for green types', async t => { 18 | const URL = 'https://example.com' 19 | const QRCODE = 'qrcode' 20 | const UUID = '12345678-1234-5678-1234-567812345678' 21 | 22 | t.ok(canPassthrough(FileBox.fromUrl(URL)), 'should passthrough Url') 23 | t.ok(canPassthrough(FileBox.fromQRCode(QRCODE)), 'should passthrough QRCode') 24 | t.ok(canPassthrough(FileBox.fromUuid(UUID)), 'should passthrough UUID') 25 | }) 26 | 27 | test('canPassthrough(): always false for red types', async t => { 28 | const streamFileBox = FileBox.fromStream( 29 | await FileBox.fromQRCode('qr').toStream(), 30 | ) 31 | const localFileBox = FileBox.fromFile('tests/fixtures/smoke-testing.ts') 32 | 33 | t.notOk(canPassthrough(streamFileBox), 'should not passthrough Stream') 34 | t.notOk(canPassthrough(localFileBox), 'should not passthrough File') 35 | }) 36 | 37 | test('canPassthrough(): will depend on the size for yellow types', async t => { 38 | const bufferFileBox = FileBox.fromBuffer(Buffer.from('buf')) 39 | const base64FileBox = FileBox.fromBase64(Buffer.from('buf').toString('base64')) 40 | 41 | t.ok(canPassthrough(bufferFileBox), 'should not passthrough small Buffer') 42 | t.ok(canPassthrough(base64FileBox), 'should not passthrough small Base64') 43 | 44 | /** 45 | * TODO: add the large size test which will over the threshold 46 | */ 47 | }) 48 | -------------------------------------------------------------------------------- /src/file-box-helper/normalize-filebox.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FileBox, 3 | } from 'file-box' 4 | import { 5 | FileBoxType, 6 | FileBoxInterface, 7 | } from 'file-box' 8 | 9 | /** 10 | * Huan(202110): for testing propose, use 20KB as the threshold 11 | * after stable we should use a value between 64KB to 256KB as the threshold 12 | */ 13 | const PASS_THROUGH_THRESHOLD_BYTES = 20 * 1024 // 20KB 14 | 15 | /** 16 | * 1. Green: 17 | * Can be serialized directly 18 | */ 19 | const greenFileBoxTypes = [ 20 | FileBoxType.Url, 21 | FileBoxType.Uuid, 22 | FileBoxType.QRCode, 23 | ] 24 | /** 25 | * 2. Yellow: 26 | * Can be serialized directly, if the size is less than a threshold 27 | * if it's bigger than the threshold, 28 | * then it should be convert to a UUID file box before send out 29 | */ 30 | const yellowFileBoxTypes = [ 31 | FileBoxType.Buffer, 32 | FileBoxType.Base64, 33 | ] 34 | 35 | const canPassthrough = (fileBox: FileBoxInterface) => { 36 | /** 37 | * 1. Green types: YES 38 | */ 39 | if (greenFileBoxTypes.includes(fileBox.type)) { 40 | return true 41 | } 42 | 43 | /** 44 | * 2. Red types: NO 45 | */ 46 | if (!yellowFileBoxTypes.includes(fileBox.type)) { 47 | return false 48 | } 49 | 50 | /** 51 | * 3. Yellow types: CHECK size 52 | */ 53 | const size = fileBox.size 54 | if (size < 0) { 55 | // 1. Size unknown: NO 56 | return false 57 | } else if (size > PASS_THROUGH_THRESHOLD_BYTES) { 58 | // 2. Size: bigger than threshold: NO 59 | return false 60 | } else { 61 | // 3. Size: smaller than threshold: YES 62 | return true 63 | } 64 | 65 | } 66 | 67 | const normalizeFileBoxUuid = (FileBoxUuid: typeof FileBox) => async (fileBox: FileBoxInterface) => { 68 | if (canPassthrough(fileBox)) { 69 | return fileBox 70 | } 71 | 72 | const stream = await fileBox.toStream() 73 | 74 | const uuid = await FileBoxUuid 75 | .fromStream(stream, fileBox.name) 76 | .toUuid() 77 | 78 | const uuidFileBox = FileBoxUuid.fromUuid(uuid, { 79 | md5 : fileBox.md5, 80 | name : fileBox.name, 81 | size : fileBox.size, 82 | }) 83 | 84 | return uuidFileBox 85 | } 86 | 87 | export { 88 | canPassthrough, 89 | normalizeFileBoxUuid, 90 | } 91 | -------------------------------------------------------------------------------- /src/file-box-helper/uuidify-file-box-grpc.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Readable, 3 | Writable, 4 | } from 'stream' 5 | 6 | import { 7 | FileBox, 8 | UuidLoader, 9 | UuidSaver, 10 | } from 'file-box' 11 | import { 12 | chunkDecoder, 13 | chunkEncoder, 14 | puppet as pbPuppet, 15 | } from 'wechaty-grpc' 16 | import { 17 | cloneClass, 18 | Constructor, 19 | } from 'clone-class' 20 | 21 | const uuidResolverGrpc: (grpcClient: () => pbPuppet.PuppetClient) => UuidLoader = ( 22 | grpcClient, 23 | ) => async function uuidResolver ( 24 | this : FileBox, 25 | uuid : string, 26 | ) { 27 | const request = new pbPuppet.DownloadRequest() 28 | request.setId(uuid) 29 | 30 | const response = grpcClient().download(request) 31 | 32 | const stream = response 33 | .pipe(chunkDecoder()) 34 | 35 | return stream 36 | } 37 | 38 | const uuidRegisterGrpc: (grpcClient: () => pbPuppet.PuppetClient) => UuidSaver = ( 39 | grpcClient, 40 | ) => async function uuidRegister ( 41 | this : FileBox, 42 | stream : Readable, 43 | ) { 44 | const response = await new Promise((resolve, reject) => { 45 | const request = grpcClient().upload((err, response) => { 46 | if (err) { 47 | reject(err) 48 | } else { 49 | resolve(response) 50 | } 51 | }) as unknown as Writable // Huan(202203) FIXME: as unknown as 52 | 53 | stream 54 | .pipe(chunkEncoder(pbPuppet.UploadRequest)) 55 | .pipe(request) 56 | }) 57 | 58 | const uuid = response.getId() 59 | return uuid 60 | } 61 | 62 | type UuidifyFileBoxGrpcFactory = (grpcClient: () => pbPuppet.PuppetClient) => typeof FileBox 63 | 64 | const uuidifyFileBoxGrpc: UuidifyFileBoxGrpcFactory = ( 65 | grpcClient, 66 | ) => { 67 | /** 68 | * `as any`: 69 | * 70 | * Huan(202110): TypeError: Cannot read property 'valueDeclaration' of undefined #58 71 | * https://github.com/huan/clone-class/issues/58 72 | */ 73 | const FileBoxUuid: typeof FileBox = cloneClass(FileBox as any as Constructor) as any 74 | 75 | FileBoxUuid.setUuidLoader(uuidResolverGrpc(grpcClient)) 76 | FileBoxUuid.setUuidSaver(uuidRegisterGrpc(grpcClient)) 77 | 78 | return FileBoxUuid 79 | } 80 | 81 | export { 82 | uuidifyFileBoxGrpc, 83 | } 84 | -------------------------------------------------------------------------------- /src/file-box-helper/uuidify-file-box-local.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileBox, 3 | UniformResourceNameRegistry, 4 | } from 'file-box' 5 | import { 6 | cloneClass, 7 | Constructor, 8 | } from 'clone-class' 9 | 10 | type UuidifyFileBoxLocalFactory = (urnRegistry: UniformResourceNameRegistry) => typeof FileBox 11 | 12 | const uuidifyFileBoxLocal: UuidifyFileBoxLocalFactory = ( 13 | urnRegistry, 14 | ) => { 15 | /** 16 | * `as any`: 17 | * 18 | * Huan(202110): TypeError: Cannot read property 'valueDeclaration' of undefined #58 19 | * https://github.com/huan/clone-class/issues/58 20 | */ 21 | const FileBoxUuid: typeof FileBox = cloneClass(FileBox as any as Constructor) as any 22 | 23 | FileBoxUuid.setUuidLoader(uuid => urnRegistry.load(uuid)) 24 | FileBoxUuid.setUuidSaver(stream => urnRegistry.save(stream)) 25 | 26 | return FileBoxUuid 27 | } 28 | 29 | export { 30 | uuidifyFileBoxLocal, 31 | } 32 | -------------------------------------------------------------------------------- /src/mod.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import * as mod from './mod.js' 6 | 7 | test('default export', async t => { 8 | t.equal(typeof mod.default, 'function', 'should export Puppet class as default, which is required from PuppetManager of Wechaty') 9 | }) 10 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | import { 2 | log, 3 | } from 'wechaty-puppet' 4 | import { 5 | PuppetService, 6 | } from './client/puppet-service.js' 7 | import { 8 | VERSION, 9 | } from './config.js' 10 | import { 11 | PuppetServer, 12 | PuppetServerOptions, 13 | } from './server/puppet-server.js' 14 | 15 | export { 16 | log, 17 | PuppetServer, 18 | PuppetService, 19 | VERSION, 20 | } 21 | export type { 22 | PuppetServerOptions, 23 | } 24 | 25 | export default PuppetService 26 | -------------------------------------------------------------------------------- /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/pure-functions/timestamp.ts: -------------------------------------------------------------------------------- 1 | import { Timestamp } from 'wechaty-grpc' 2 | 3 | /** 4 | * https://github.com/protocolbuffers/protobuf/blob/b6993a90605cde15ba004e0287bcb078b0f3959d/src/google/protobuf/timestamp.proto#L86-L91 5 | */ 6 | 7 | function timestampFromMilliseconds (milliseconds: number) { 8 | const seconds = Math.floor(milliseconds / 1000) 9 | const nanos = (milliseconds % 1000) * 1000000 10 | 11 | const timestamp = new Timestamp() 12 | timestamp.setSeconds(seconds) 13 | timestamp.setNanos(nanos) 14 | 15 | return timestamp 16 | } 17 | 18 | function millisecondsFromTimestamp (timestamp: ReturnType) { 19 | const seconds = timestamp.getSeconds() 20 | const nanos = timestamp.getNanos() 21 | 22 | return seconds * 1000 + nanos / 1000000 23 | } 24 | 25 | export { 26 | millisecondsFromTimestamp, 27 | timestampFromMilliseconds, 28 | } 29 | -------------------------------------------------------------------------------- /src/server/event-stream-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 | puppet as grpcPuppet, 22 | grpc, 23 | } from 'wechaty-grpc' 24 | 25 | import * as PUPPET from 'wechaty-puppet' 26 | 27 | import { log } from '../config.js' 28 | import { 29 | EventTypeRev, 30 | } from '../event-type-rev.js' 31 | 32 | class EventStreamManager { 33 | 34 | protected eventStream: undefined | grpc.ServerWritableStream 35 | 36 | private puppetListening = false 37 | 38 | constructor ( 39 | public puppet: PUPPET.impls.PuppetInterface, 40 | ) { 41 | log.verbose('EventStreamManager', 'constructor(%s)', puppet) 42 | } 43 | 44 | public busy (): boolean { 45 | return !!this.eventStream 46 | } 47 | 48 | public start ( 49 | stream: grpc.ServerWritableStream, 50 | ): void { 51 | log.verbose('EventStreamManager', 'start(stream)') 52 | 53 | if (this.eventStream) { 54 | throw new Error('can not set twice') 55 | } 56 | this.eventStream = stream 57 | 58 | const removeAllListeners = this.connectPuppetEventToStreamingCall() 59 | this.onStreamingCallEnd(removeAllListeners) 60 | 61 | /** 62 | * Huan(202108): 63 | * We emit a hearbeat at the beginning of the connect 64 | * to identicate that the connection is successeed. 65 | * 66 | * Our client (wechaty-puppet-service client) will wait for the heartbeat 67 | * when it connect to the server. 68 | * 69 | * If the server does not send the heartbeat, 70 | * then the client will wait for a 5 seconds timeout 71 | * for compatible the community gRPC puppet service providers like paimon. 72 | */ 73 | const connectSuccessHeartbeatPayload = { 74 | data: 'Wechaty Puppet gRPC stream connect successfully', 75 | } as PUPPET.payloads.EventHeartbeat 76 | this.grpcEmit( 77 | grpcPuppet.EventType.EVENT_TYPE_HEARTBEAT, 78 | connectSuccessHeartbeatPayload, 79 | ) 80 | 81 | /** 82 | * We emit the login event if current the puppet is logged in. 83 | */ 84 | if (this.puppet.isLoggedIn) { 85 | log.verbose('EventStreamManager', 'start() puppet is logged in, emit a login event for downstream') 86 | 87 | const payload = { 88 | contactId: this.puppet.currentUserId, 89 | } as PUPPET.payloads.EventLogin 90 | 91 | this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_LOGIN, payload) 92 | } 93 | 94 | if (this.puppet.readyIndicator.value()) { 95 | log.verbose('EventStreamManager', 'start() puppet is ready, emit a ready event for downstream after 100ms delay') 96 | 97 | const payload = { 98 | data: 'ready', 99 | } as PUPPET.payloads.EventReady 100 | 101 | // no need to make this function async since it won't effect the start process of eventStreamManager 102 | setTimeout(() => { 103 | this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_READY, payload) 104 | }, 100) 105 | } 106 | } 107 | 108 | public stop (): void { 109 | log.verbose('EventStreamManager', 'stop()') 110 | 111 | if (!this.eventStream) { 112 | throw new Error('no this.eventStream') 113 | } 114 | 115 | this.eventStream.end() 116 | this.eventStream = undefined 117 | } 118 | 119 | public grpcEmit ( 120 | type : grpcPuppet.EventTypeMap[keyof grpcPuppet.EventTypeMap], // https://stackoverflow.com/a/49286056/1123955 121 | obj : object, 122 | ): void { 123 | log.verbose('EventStreamManager', 'grpcEmit(%s[%s], %s)', 124 | EventTypeRev[type], 125 | type, 126 | JSON.stringify(obj), 127 | ) 128 | 129 | const response = new grpcPuppet.EventResponse() 130 | 131 | response.setType(type) 132 | response.setPayload( 133 | JSON.stringify(obj), 134 | ) 135 | 136 | if (this.eventStream) { 137 | this.eventStream.write(response) 138 | } else { 139 | /** 140 | * Huan(202108): TODO: add a queue for store a maximum number of responses before the stream get connected 141 | */ 142 | log.warn('EventStreamManager', 'grpcEmit(%s, %s) this.eventStream is undefined.', 143 | type, 144 | JSON.stringify(obj), 145 | ) 146 | } 147 | } 148 | 149 | public connectPuppetEventToStreamingCall (): () => void { 150 | log.verbose('EventStreamManager', 'connectPuppetEventToStreamingCall() for %s', this.puppet) 151 | 152 | const offCallbackList = [] as (() => void)[] 153 | const offAll = () => { 154 | log.verbose('EventStreamManager', 155 | 'connectPuppetEventToStreamingCall() offAll() %s callbacks', 156 | offCallbackList.length, 157 | ) 158 | offCallbackList.forEach(off => off()) 159 | this.puppetListening = false 160 | } 161 | 162 | const eventNameList: PUPPET.types.PuppetEventName[] = Object.keys(PUPPET.types.PUPPET_EVENT_DICT) as PUPPET.types.PuppetEventName[] 163 | for (const eventName of eventNameList) { 164 | log.verbose('EventStreamManager', 165 | 'connectPuppetEventToStreamingCall() this.puppet.on(%s) (listenerCount:%s) registering...', 166 | eventName, 167 | this.puppet.listenerCount(eventName), 168 | ) 169 | 170 | switch (eventName) { 171 | case 'dong': { 172 | const listener = (payload: PUPPET.payloads.EventDong) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_DONG, payload) 173 | this.puppet.on('dong', listener) 174 | const off = () => this.puppet.off('dong', listener) 175 | offCallbackList.push(off) 176 | break 177 | } 178 | case 'dirty': { 179 | const listener = (payload: PUPPET.payloads.EventDirty) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_DIRTY, payload) 180 | this.puppet.on('dirty', listener) 181 | const off = () => this.puppet.off('dirty', listener) 182 | offCallbackList.push(off) 183 | break 184 | } 185 | case 'error': { 186 | const listener = (payload: PUPPET.payloads.EventError) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ERROR, payload) 187 | this.puppet.on('error', listener) 188 | const off = () => this.puppet.off('error', listener) 189 | offCallbackList.push(off) 190 | break 191 | } 192 | case 'heartbeat': { 193 | const listener = (payload: PUPPET.payloads.EventHeartbeat) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_HEARTBEAT, payload) 194 | this.puppet.on('heartbeat', listener) 195 | const off = () => this.puppet.off('heartbeat', listener) 196 | offCallbackList.push(off) 197 | break 198 | } 199 | case 'friendship': { 200 | const listener = (payload: PUPPET.payloads.EventFriendship) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_FRIENDSHIP, payload) 201 | this.puppet.on('friendship', listener) 202 | const off = () => this.puppet.off('friendship', listener) 203 | offCallbackList.push(off) 204 | break 205 | } 206 | case 'login': { 207 | const listener = (payload: PUPPET.payloads.EventLogin) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_LOGIN, payload) 208 | this.puppet.on('login', listener) 209 | const off = () => this.puppet.off('login', listener) 210 | offCallbackList.push(off) 211 | break 212 | } 213 | case 'logout': { 214 | const listener = (payload: PUPPET.payloads.EventLogout) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_LOGOUT, payload) 215 | this.puppet.on('logout', listener) 216 | const off = () => this.puppet.off('logout', listener) 217 | offCallbackList.push(off) 218 | break 219 | } 220 | case 'message': { 221 | const listener = (payload: PUPPET.payloads.EventMessage) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_MESSAGE, payload) 222 | this.puppet.on('message', listener) 223 | const off = () => this.puppet.off('message', listener) 224 | offCallbackList.push(off) 225 | break 226 | } 227 | case 'post': { 228 | const listener = (payload: PUPPET.payloads.EventPost) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_POST, payload) 229 | this.puppet.on('post', listener) 230 | const off = () => this.puppet.off('post', listener) 231 | offCallbackList.push(off) 232 | break 233 | } 234 | case 'ready': { 235 | const listener = (payload: PUPPET.payloads.EventReady) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_READY, payload) 236 | this.puppet.on('ready', listener) 237 | const off = () => this.puppet.off('ready', listener) 238 | offCallbackList.push(off) 239 | break 240 | } 241 | case 'room-invite': { 242 | const listener = (payload: PUPPET.payloads.EventRoomInvite) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_INVITE, payload) 243 | this.puppet.on('room-invite', listener) 244 | const off = () => this.puppet.off('room-invite', listener) 245 | offCallbackList.push(off) 246 | break 247 | } 248 | case 'room-join': { 249 | const listener = (payload: PUPPET.payloads.EventRoomJoin) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_JOIN, payload) 250 | this.puppet.on('room-join', listener) 251 | const off = () => this.puppet.off('room-join', listener) 252 | offCallbackList.push(off) 253 | break 254 | } 255 | case 'room-leave': { 256 | const listener = (payload: PUPPET.payloads.EventRoomLeave) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_LEAVE, payload) 257 | this.puppet.on('room-leave', listener) 258 | const off = () => this.puppet.off('room-leave', listener) 259 | offCallbackList.push(off) 260 | break 261 | } 262 | case 'room-topic': { 263 | const listener = (payload: PUPPET.payloads.EventRoomTopic) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_ROOM_TOPIC, payload) 264 | this.puppet.on('room-topic', listener) 265 | const off = () => this.puppet.off('room-topic', listener) 266 | offCallbackList.push(off) 267 | break 268 | } 269 | case 'scan': { 270 | const listener = (payload: PUPPET.payloads.EventScan) => this.grpcEmit(grpcPuppet.EventType.EVENT_TYPE_SCAN, payload) 271 | this.puppet.on('scan', listener) 272 | const off = () => this.puppet.off('scan', listener) 273 | offCallbackList.push(off) 274 | break 275 | } 276 | case 'reset': 277 | // the `reset` event should be dealed internally, should not send out 278 | break 279 | 280 | default: 281 | // Huan(202003): in default, the `eventName` type should be `never`, please check. 282 | throw new Error('eventName ' + eventName + ' unsupported!') 283 | } 284 | } 285 | 286 | this.puppetListening = true 287 | return offAll 288 | } 289 | 290 | /** 291 | * Detect if the streaming call was gone (GRPC disconnects) 292 | * https://github.com/grpc/grpc/issues/8117#issuecomment-362198092 293 | */ 294 | private onStreamingCallEnd ( 295 | removePuppetListeners: () => void, 296 | ): void { 297 | log.verbose('EventStreamManager', 'onStreamingCallEnd(callback)') 298 | 299 | if (!this.eventStream) { 300 | throw new Error('no this.eventStream found') 301 | } 302 | 303 | /** 304 | * Huan(202110): useful log messages 305 | * 306 | * ServiceCtl stop() super.stop() ... done 307 | * StateSwitch inactive(true) <- (pending) 308 | * EventStreamManager this.onStreamingCallEnd() this.eventStream.on(finish) fired 309 | * EventStreamManager connectPuppetEventToStreamingCall() offAll() 14 callbacks 310 | * EventStreamManager this.onStreamingCallEnd() this.eventStream.on(finish) eventStream is undefined 311 | * EventStreamManager this.onStreamingCallEnd() this.eventStream.on(close) fired 312 | * EventStreamManager this.onStreamingCallEnd() this.eventStream.on(close) eventStream is undefined 313 | * EventStreamManager this.onStreamingCallEnd() this.eventStream.on(cancelled) fired with arguments: {} 314 | * EventStreamManager this.onStreamingCallEnd() this.eventStream.on(cancelled) eventStream is undefined 315 | * GrpcClient stop() stop client ... done 316 | */ 317 | this.eventStream.on('cancelled', () => { 318 | log.verbose('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(cancelled) fired with arguments: %s', 319 | JSON.stringify(arguments), 320 | ) 321 | 322 | if (this.puppetListening) { 323 | removePuppetListeners() 324 | } 325 | if (this.eventStream) { 326 | this.eventStream = undefined 327 | } else { 328 | log.warn('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(cancelled) eventStream is undefined') 329 | } 330 | }) 331 | 332 | this.eventStream.on('error', err => { 333 | log.verbose('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(error) fired: %s', err) 334 | if (this.puppetListening) { 335 | removePuppetListeners() 336 | } 337 | if (this.eventStream) { 338 | this.eventStream = undefined 339 | } else { 340 | log.warn('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(error) eventStream is undefined') 341 | } 342 | }) 343 | 344 | this.eventStream.on('finish', () => { 345 | log.verbose('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(finish) fired') 346 | if (this.puppetListening) { 347 | removePuppetListeners() 348 | } 349 | if (this.eventStream) { 350 | this.eventStream = undefined 351 | } else { 352 | log.warn('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(finish) eventStream is undefined') 353 | } 354 | }) 355 | 356 | this.eventStream.on('end', () => { 357 | log.verbose('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(end) fired') 358 | if (this.puppetListening) { 359 | removePuppetListeners() 360 | } 361 | if (this.eventStream) { 362 | this.eventStream = undefined 363 | } else { 364 | log.warn('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(end) eventStream is undefined') 365 | } 366 | }) 367 | 368 | this.eventStream.on('close', () => { 369 | log.verbose('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(close) fired') 370 | if (this.puppetListening) { 371 | removePuppetListeners() 372 | } 373 | if (this.eventStream) { 374 | this.eventStream = undefined 375 | } else { 376 | log.warn('EventStreamManager', 'this.onStreamingCallEnd() this.eventStream.on(close) eventStream is undefined') 377 | } 378 | }) 379 | } 380 | 381 | } 382 | 383 | export { EventStreamManager } 384 | -------------------------------------------------------------------------------- /src/server/grpc-error.ts: -------------------------------------------------------------------------------- 1 | import type { grpc } from 'wechaty-grpc' 2 | import { log } from 'wechaty-puppet' 3 | import { GError } from 'gerror' 4 | 5 | type GErrorCallback = ( 6 | gerror: Partial, 7 | value: null, 8 | ) => void 9 | 10 | export function grpcError ( 11 | method : string, 12 | error : any, 13 | callback : GErrorCallback, 14 | ): void { 15 | const gerr = GError.from(error) 16 | 17 | log.error('PuppetServiceImpl', `grpcError() ${method}() rejection: %s\n%s`, 18 | gerr.message, 19 | gerr.stack, 20 | ) 21 | 22 | return callback(gerr, null) 23 | } 24 | -------------------------------------------------------------------------------- /src/server/health-implementation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | import { 3 | google as grpcGoogle, 4 | } from 'wechaty-grpc' 5 | import { 6 | log, 7 | Puppet, 8 | } from 'wechaty-puppet' 9 | 10 | const HEARTBEAT_TIMEOUT_SECONDS = 60 11 | 12 | function healthImplementation ( 13 | puppet: Puppet, 14 | ): grpcGoogle.IHealthServer { 15 | 16 | let lastHeartbeatTimestamp = -1 17 | 18 | const healthCheckResponse = () => { 19 | const response = new grpcGoogle.HealthCheckResponse() 20 | 21 | if (lastHeartbeatTimestamp < 0 || lastHeartbeatTimestamp > Date.now()) { 22 | response.setStatus(grpcGoogle.HealthCheckResponse.ServingStatus.SERVICE_UNKNOWN) 23 | 24 | } else if (Date.now() - lastHeartbeatTimestamp < HEARTBEAT_TIMEOUT_SECONDS * 1000) { 25 | response.setStatus(grpcGoogle.HealthCheckResponse.ServingStatus.SERVING) 26 | 27 | } else { 28 | response.setStatus(grpcGoogle.HealthCheckResponse.ServingStatus.NOT_SERVING) 29 | } 30 | 31 | return response 32 | } 33 | 34 | puppet.on('heartbeat', () => { 35 | lastHeartbeatTimestamp = Date.now() 36 | }) 37 | 38 | const healthServerImpl: grpcGoogle.IHealthServer = { 39 | 40 | check: async (call, callback) => { 41 | log.verbose('HealthServiceImpl', 'check()') 42 | 43 | const service = call.request.getService() 44 | log.verbose('HealServiceImpl', 'check() service="%s"', service) 45 | 46 | const response = healthCheckResponse() 47 | callback(null, response) 48 | }, 49 | 50 | watch: async (call) => { 51 | log.verbose('HealthServiceImpl', 'watch()') 52 | 53 | const firstResponse = healthCheckResponse() 54 | let currentStatus = firstResponse.getStatus() 55 | 56 | call.write(firstResponse) 57 | 58 | const timer = setInterval(() => { 59 | const nextResponse = healthCheckResponse() 60 | if (nextResponse.getStatus() !== currentStatus) { 61 | currentStatus = nextResponse.getStatus() 62 | call.write(nextResponse) 63 | } 64 | }, 5 * 1000) 65 | 66 | const clear = () => clearInterval(timer) 67 | 68 | call.on('end', clear) 69 | call.on('error', clear) 70 | call.on('close', clear) 71 | }, 72 | 73 | } 74 | 75 | return healthServerImpl 76 | } 77 | 78 | export { healthImplementation } 79 | -------------------------------------------------------------------------------- /src/server/puppet-server.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import type { Puppet } from 'wechaty-puppet' 6 | 7 | import { 8 | PuppetServer, 9 | PuppetServerOptions, 10 | } from './puppet-server.js' 11 | 12 | test('version()', async t => { 13 | const options: PuppetServerOptions = { 14 | endpoint : '127.0.0.1:8788', 15 | puppet : {} as Puppet, 16 | token : 'secret', 17 | } 18 | 19 | const puppet = new PuppetServer(options) 20 | t.ok(puppet.version(), 'should have version() method') 21 | }) 22 | -------------------------------------------------------------------------------- /src/server/puppet-server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty Open Source Software - https://github.com/wechaty 3 | * 4 | * @copyright 2016 Huan LI (李卓桓) , and 5 | * Wechaty Contributors . 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 util from 'util' 21 | 22 | import { 23 | Puppet, 24 | log, 25 | } from 'wechaty-puppet' 26 | import { 27 | grpc, 28 | puppet as grpcPuppet, 29 | google as grpcGoogle, 30 | } from 'wechaty-grpc' 31 | import { 32 | UniformResourceNameRegistry, 33 | } from 'file-box' 34 | 35 | import { 36 | envVars, 37 | VERSION, 38 | GRPC_OPTIONS, 39 | } from '../config.js' 40 | 41 | import { 42 | authImplToken, 43 | } from '../auth/mod.js' 44 | import { 45 | TLS_INSECURE_SERVER_CERT, 46 | TLS_INSECURE_SERVER_KEY, 47 | } from '../auth/ca.js' 48 | 49 | import { 50 | puppetImplementation, 51 | } from './puppet-implementation.js' 52 | import { 53 | healthImplementation, 54 | } from './health-implementation.js' 55 | import { 56 | uuidifyFileBoxLocal, 57 | } from '../file-box-helper/uuidify-file-box-local.js' 58 | 59 | export interface PuppetServerOptions { 60 | endpoint : string, 61 | puppet : Puppet, 62 | token : string, 63 | tls?: { 64 | serverCert? : string, 65 | serverKey? : string, 66 | disable? : boolean, 67 | } 68 | } 69 | 70 | export class PuppetServer { 71 | 72 | protected grpcServer? : grpc.Server 73 | protected urnRegistry? : UniformResourceNameRegistry 74 | 75 | constructor ( 76 | public readonly options: PuppetServerOptions, 77 | ) { 78 | log.verbose('PuppetServer', 79 | 'constructor({endpoint: "%s", puppet: "%s", token: "%s"})', 80 | options.endpoint, 81 | options.puppet, 82 | options.token, 83 | ) 84 | } 85 | 86 | public version (): string { 87 | return VERSION 88 | } 89 | 90 | public async start (): Promise { 91 | log.verbose('PuppetServer', 'start()') 92 | 93 | if (this.grpcServer) { 94 | throw new Error('grpc server existed!') 95 | } 96 | 97 | if (!this.urnRegistry) { 98 | log.verbose('PuppetServer', 'start() initializing FileBox UUID URN Registry ...') 99 | this.urnRegistry = new UniformResourceNameRegistry() 100 | await this.urnRegistry.init() 101 | log.verbose('PuppetServer', 'start() initializing FileBox UUID URN Registry ... done') 102 | } 103 | 104 | /** 105 | * Connect FileBox with UUID Manager 106 | */ 107 | const FileBoxUuid = uuidifyFileBoxLocal(this.urnRegistry) 108 | 109 | log.verbose('PuppetServer', 'start() initializing gRPC Server with options "%s"', JSON.stringify(GRPC_OPTIONS)) 110 | this.grpcServer = new grpc.Server(GRPC_OPTIONS) 111 | log.verbose('PuppetServer', 'start() initializing gRPC Server ... done', JSON.stringify(GRPC_OPTIONS)) 112 | 113 | log.verbose('PuppetServer', 'start() initializing puppet implementation with FileBoxUuid...') 114 | const puppetImpl = puppetImplementation( 115 | this.options.puppet, 116 | FileBoxUuid, 117 | ) 118 | log.verbose('PuppetServer', 'start() initializing puppet implementation with FileBoxUuid... done') 119 | 120 | log.verbose('PuppetServer', 'start() initializing authorization with token ...') 121 | const puppetImplAuth = authImplToken(this.options.token)(puppetImpl) 122 | this.grpcServer.addService( 123 | grpcPuppet.PuppetService, 124 | puppetImplAuth, 125 | ) 126 | log.verbose('PuppetServer', 'start() initializing authorization with token ... done') 127 | 128 | log.verbose('PuppetServer', 'start() initializing gRPC health service ...') 129 | const healthImpl = healthImplementation( 130 | this.options.puppet, 131 | ) 132 | this.grpcServer.addService( 133 | grpcGoogle.HealthService, 134 | healthImpl, 135 | ) 136 | log.verbose('PuppetServer', 'start() initializing gRPC health service ... done') 137 | 138 | log.verbose('PuppetServer', 'start() initializing TLS CA ...') 139 | const caCerts = envVars.WECHATY_PUPPET_SERVICE_TLS_CA_CERT() 140 | const caCertBuf = caCerts 141 | ? Buffer.from(caCerts) 142 | : null 143 | 144 | const certChain = Buffer.from( 145 | envVars.WECHATY_PUPPET_SERVICE_TLS_SERVER_CERT(this.options.tls?.serverCert) 146 | || TLS_INSECURE_SERVER_CERT, 147 | ) 148 | const privateKey = Buffer.from( 149 | envVars.WECHATY_PUPPET_SERVICE_TLS_SERVER_KEY(this.options.tls?.serverKey) 150 | || TLS_INSECURE_SERVER_KEY, 151 | ) 152 | log.verbose('PuppetServer', 'start() initializing TLS CA ... done') 153 | 154 | const keyCertPairs: grpc.KeyCertPair[] = [ { 155 | cert_chain : certChain, 156 | private_key : privateKey, 157 | } ] 158 | 159 | /** 160 | * Huan(202108): for maximum compatible with the non-tls community servers/clients, 161 | * we introduced the WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_{SERVER,CLIENT} environment variables. 162 | * if it has been set, then we will run under HTTP instead of HTTPS 163 | */ 164 | log.verbose('PuppetServer', 'start() initializing gRPC server credentials ...') 165 | let credential 166 | if (envVars.WECHATY_PUPPET_SERVICE_NO_TLS_INSECURE_SERVER(this.options.tls?.disable)) { 167 | log.warn('PuppetServer', 'start() TLS disabled: INSECURE!') 168 | credential = grpc.ServerCredentials.createInsecure() 169 | } else { 170 | log.verbose('PuppetServer', 'start() TLS enabled.') 171 | credential = grpc.ServerCredentials.createSsl(caCertBuf, keyCertPairs) 172 | } 173 | log.verbose('PuppetServer', 'start() initializing gRPC server credentials ... done') 174 | 175 | /*** 176 | * Start Grpc Server 177 | */ 178 | log.verbose('PuppetServer', 'start() gRPC server starting ...') 179 | const port = await util.promisify( 180 | this.grpcServer.bindAsync 181 | .bind(this.grpcServer), 182 | )( 183 | this.options.endpoint, 184 | credential, 185 | ) 186 | 187 | if (port === 0) { 188 | throw new Error('grpc server bind fail!') 189 | } 190 | 191 | this.grpcServer.start() 192 | log.verbose('PuppetServer', 'start() gRPC server starting ... done') 193 | } 194 | 195 | public async stop (): Promise { 196 | log.verbose('PuppetServer', 'stop()') 197 | 198 | if (this.grpcServer) { 199 | const grpcServer = this.grpcServer 200 | this.grpcServer = undefined 201 | 202 | log.verbose('PuppetServer', 'stop() shuting down gRPC server ...') 203 | // const future = await util.promisify( 204 | // grpcServer.tryShutdown 205 | // .bind(grpcServer), 206 | // )() 207 | 208 | try { 209 | await new Promise(resolve => setImmediate(resolve)) 210 | grpcServer.forceShutdown() 211 | /** 212 | * Huan(202110) grpc.tryShutdown() never return if client close the connection. #176 213 | * @see https://github.com/wechaty/puppet-service/issues/176 214 | * 215 | * FIXME: even after called `forceShutdown()`, the `tryShutdown()` can not resolved. 216 | * commented out the `await` for now to make it work temporary. 217 | */ 218 | // await future 219 | 220 | } catch (e) { 221 | log.warn('PuppetServer', 'stop() gRPC shutdown rejection: %s', (e as Error).message) 222 | } finally { 223 | log.verbose('PuppetServer', 'stop() shuting down gRPC server ... done') 224 | } 225 | 226 | } else { 227 | log.warn('PuppetServer', 'stop() no grpcServer exist') 228 | } 229 | 230 | if (this.urnRegistry) { 231 | log.verbose('PuppetServer', 'stop() destory URN Registry ...') 232 | await this.urnRegistry.destroy() 233 | this.urnRegistry = undefined 234 | log.verbose('PuppetServer', 'stop() destory URN Registry ... done') 235 | } 236 | 237 | } 238 | 239 | } 240 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Issue #7: https://github.com/Chatie/grpc/issues/7 3 | */ 4 | export type Callback = (err: E | null, reply: T) => void 5 | 6 | export type PromisifyOne = 7 | T extends [Callback?] ? () => Promise : 8 | T extends [infer T1, Callback?] ? (arg1: T1) => Promise

: 9 | T extends [infer T1, infer T2, Callback?] ? (arg1: T1, arg2: T2) => Promise : 10 | T extends [infer T1, infer T2, infer T3, Callback?]? (arg1: T1, arg2: T2, arg3: T3) => Promise : 11 | T extends [infer T1, infer T2, infer T3, infer T4, Callback?] ? (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Promise : 12 | never 13 | 14 | // prettier-ignore 15 | export type GetOverloadArgs = 16 | T extends { 17 | (...o: infer U) : void, 18 | (...o: infer U2) : void, 19 | (...o: infer U3) : void, 20 | (...o: infer U4) : void, 21 | (...o: infer U5) : void, 22 | (...o: infer U6) : void, 23 | (...o: infer U7) : void 24 | } ? U | U2 | U3 | U4 | U5 | U6 | U7: 25 | T extends { 26 | (...o: infer U) : void, 27 | (...o: infer U2) : void, 28 | (...o: infer U3) : void, 29 | (...o: infer U4) : void, 30 | (...o: infer U5) : void, 31 | (...o: infer U6) : void, 32 | } ? U | U2 | U3 | U4 | U5 | U6: 33 | T extends { 34 | (...o: infer U) : void, 35 | (...o: infer U2) : void, 36 | (...o: infer U3) : void, 37 | (...o: infer U4) : void, 38 | (...o: infer U5) : void, 39 | } ? U | U2 | U3 | U4 | U5: 40 | T extends { 41 | (...o: infer U) : void, 42 | (...o: infer U2) : void, 43 | (...o: infer U3) : void, 44 | (...o: infer U4) : void, 45 | } ? U | U2 | U3 | U4 : 46 | T extends { 47 | (...o: infer U) : void, 48 | (...o: infer U2) : void, 49 | (...o: infer U3) : void, 50 | } ? U | U2 | U3 : 51 | T extends { 52 | (...o: infer U) : void, 53 | (...o: infer U2) : void, 54 | } ? U | U2 : 55 | T extends { 56 | (...o: infer U) : void, 57 | } ? U : 58 | never 59 | 60 | export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never 61 | 62 | export type Promisify = UnionToIntersection< 63 | PromisifyOne> 64 | > 65 | 66 | declare module 'util' { 67 | function promisify (fn: T): Promisify 68 | } 69 | -------------------------------------------------------------------------------- /tests/fixtures/smoke-testing.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | import { 3 | VERSION, 4 | PuppetService, 5 | } from 'wechaty-puppet-service' 6 | 7 | async function main () { 8 | const puppetService = new PuppetService() 9 | 10 | // try { 11 | // await bot.start() 12 | // console.info(`Wechaty v${bot.version()} smoking test passed.`) 13 | // } catch (e) { 14 | // console.error(e) 15 | // // Error! 16 | // return 1 17 | // } finally { 18 | // await bot.stop() 19 | // } 20 | 21 | if (VERSION === '0.0.0') { 22 | throw new Error('version should be set before publishing') 23 | } 24 | 25 | console.info('Wechaty Puppet Service v' + puppetService.version() + ' 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/grpc-client.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | import { 5 | PuppetOptions, 6 | log, 7 | } from 'wechaty-puppet' 8 | import PuppetMock from 'wechaty-puppet-mock' 9 | import getPort from 'get-port' 10 | 11 | import { GrpcManager } from '../src/client/grpc-manager.js' 12 | import { 13 | PuppetServer, 14 | PuppetServerOptions, 15 | } from '../src/mod.js' 16 | 17 | const NIL_UUID_V4 = '00000000-0000-0000-0000-000000000000' 18 | 19 | test('GrpcClient with TLS and valid token', async t => { 20 | const PORT = await getPort() 21 | const TOKEN = `insecure_${NIL_UUID_V4}` 22 | const ENDPOINT = `0.0.0.0:${PORT}` 23 | 24 | /** 25 | * Puppet Server 26 | */ 27 | const serverOptions = { 28 | endpoint : ENDPOINT, 29 | puppet : new PuppetMock(), 30 | token : TOKEN, 31 | } as PuppetServerOptions 32 | 33 | const puppetServer = new PuppetServer(serverOptions) 34 | await puppetServer.start() 35 | 36 | /** 37 | * Puppet Service Client 38 | */ 39 | const puppetOptions = { 40 | endpoint : ENDPOINT, 41 | token : TOKEN, 42 | } as PuppetOptions 43 | 44 | const validTokenPuppet = new GrpcManager(puppetOptions) 45 | 46 | try { 47 | await validTokenPuppet.start() 48 | t.pass('should work with TLS and valid token') 49 | } catch (e) { 50 | console.error(e) 51 | t.fail('should not reject for a valid token & tls') 52 | } finally { 53 | try { await validTokenPuppet.stop() } catch (_) {} 54 | } 55 | 56 | await puppetServer.stop() 57 | }) 58 | 59 | test('GrpcClient with invalid TLS options', async t => { 60 | const PORT = await getPort() 61 | const TOKEN = `uuid_${NIL_UUID_V4}` 62 | const ENDPOINT = `0.0.0.0:${PORT}` 63 | 64 | /** 65 | * Puppet Server 66 | */ 67 | const serverOptions = { 68 | endpoint : ENDPOINT, 69 | puppet : new PuppetMock(), 70 | token : TOKEN, 71 | } as PuppetServerOptions 72 | 73 | const puppetServer = new PuppetServer(serverOptions) 74 | await puppetServer.start() 75 | 76 | /** 77 | * Grpc Client 78 | */ 79 | const puppetOptions: PuppetOptions = { 80 | endpoint : ENDPOINT, 81 | tls: { 82 | disable : true, 83 | }, 84 | token : TOKEN, 85 | } 86 | 87 | const grpcClient = new GrpcManager(puppetOptions) 88 | grpcClient.on('error', e => console.info('###noTlsPuppet.on(error):', e)) 89 | 90 | // Disable error log 91 | const level = log.level() 92 | log.level('silent') 93 | 94 | try { 95 | await grpcClient.start() 96 | t.fail('should throw for no-tls client to tls-server instead of not running to here') 97 | } catch (e) { 98 | t.pass('should throw for non-tls client to tls-server with noTlsInsecure: true') 99 | } finally { 100 | log.level(level) 101 | try { await grpcClient.stop() } catch (_) {} 102 | } 103 | 104 | await puppetServer.stop() 105 | }) 106 | 107 | test('GrpcClient with invalid token', async t => { 108 | const PORT = await getPort() 109 | const endpoint = `0.0.0.0:${PORT}` 110 | 111 | /** 112 | * Puppet Server 113 | */ 114 | const serverOptions = { 115 | endpoint, 116 | puppet: new PuppetMock(), 117 | token: 'insecure_UUIDv4', 118 | } as PuppetServerOptions 119 | 120 | const puppetServer = new PuppetServer(serverOptions) 121 | await puppetServer.start() 122 | 123 | /** 124 | * Puppet Service Client 125 | */ 126 | const puppetOptions = { 127 | endpoint, 128 | /** 129 | * Put a random token for invalid the client token 130 | * https://stackoverflow.com/a/8084248/1123955 131 | */ 132 | token: 'insecure_' + Math.random().toString(36), 133 | } as PuppetOptions 134 | 135 | const invalidTokenPuppet = new GrpcManager(puppetOptions) 136 | 137 | try { 138 | await invalidTokenPuppet.start() 139 | t.fail('should throw for invalid token instead of not running to here') 140 | } catch (e) { 141 | t.pass('should throw for invalid random token') 142 | } finally { 143 | try { await invalidTokenPuppet.stop() } catch (_) {} 144 | } 145 | 146 | await puppetServer.stop() 147 | }) 148 | -------------------------------------------------------------------------------- /tests/grpc-stream.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | /** 3 | * @hcfw007, https://wechaty.js.org/contributors/wang-nan/ 4 | * related issue: attempt to reconnect gRPC after disconnection 5 | * Scenario: the watchdog tries to restart the service but failed due to the existence of eventstream 6 | * Caused by the grpcClient set to undefined (still working on why this happens) while eventstream still working 7 | * issue: #172, https://github.com/wechaty/puppet-service/issues/172 8 | * 9 | * NodeJS: How Is Logging Enabled for the @grpc/grpc.js Package 10 | * https://stackoverflow.com/a/60935367/1123955 11 | * GRPC_VERBOSITY=DEBUG GRPC_TRACE=all 12 | */ 13 | /// 14 | 15 | import { 16 | test, 17 | sinon, 18 | } from 'tstest' 19 | import type { 20 | PuppetOptions, 21 | } from 'wechaty-puppet' 22 | import { 23 | PuppetMock, 24 | } from 'wechaty-puppet-mock' 25 | import getPort from 'get-port' 26 | // import whyIsNodeRunning from 'why-is-node-running' 27 | 28 | import { 29 | PuppetService, 30 | PuppetServer, 31 | PuppetServerOptions, 32 | } from '../src/mod.js' 33 | 34 | test('gRPC client breaks', async t => { 35 | /** 36 | * Huan(202110): 37 | * `insecure_` prefix is required for the TLS version of Puppet Service 38 | * because the `insecure` will be the SNI name of the Puppet Service 39 | * and it will be enforced for the security (required by TLS) 40 | */ 41 | const TOKEN = 'insecure_token' 42 | const PORT = await getPort() 43 | const ENDPOINT = '0.0.0.0:' + PORT 44 | 45 | const puppet = new PuppetMock() 46 | const spyOnStart = sinon.spy(puppet, 'onStart') 47 | /** 48 | * Puppet Server 49 | */ 50 | const serverOptions = { 51 | endpoint: ENDPOINT, 52 | puppet, 53 | token: TOKEN, 54 | } as PuppetServerOptions 55 | 56 | const puppetServer = new PuppetServer(serverOptions) 57 | await puppetServer.start() 58 | 59 | /** 60 | * Puppet Service Client 61 | */ 62 | const puppetOptions = { 63 | endpoint: ENDPOINT, 64 | token: TOKEN, 65 | } as PuppetOptions 66 | 67 | const puppetService = new PuppetService(puppetOptions) 68 | await puppetService.start() 69 | t.ok(spyOnStart.called, 'should called the puppet server onStart() function') 70 | 71 | puppetService.on('error', console.error) 72 | 73 | /** 74 | * mock grpcClient break 75 | */ 76 | await puppetService.grpcManager.client.close() 77 | 78 | await puppetService.stop() 79 | 80 | // get eventStream status 81 | t.throws(() => puppetService.grpcManager, 'should clean grpc after stop()') 82 | 83 | // setTimeout(() => whyIsNodeRunning(), 1000) 84 | await puppetServer.stop() 85 | }) 86 | -------------------------------------------------------------------------------- /tests/integration.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { 4 | test, 5 | sinon, 6 | } from 'tstest' 7 | 8 | import type { 9 | PuppetOptions, 10 | } from 'wechaty-puppet' 11 | import { 12 | PuppetMock, 13 | } from 'wechaty-puppet-mock' 14 | import getPort from 'get-port' 15 | 16 | import { 17 | PuppetService, 18 | PuppetServer, 19 | PuppetServerOptions, 20 | } from '../src/mod.js' 21 | 22 | const NIL_UUID_V4 = '00000000-0000-0000-0000-000000000000' 23 | 24 | test('Integration testing', async t => { 25 | const PORT = await getPort() 26 | const TOKEN = `insecure_${NIL_UUID_V4}` 27 | const ENDPOINT = `0.0.0.0:${PORT}` 28 | 29 | const DING = '__ding_data__' 30 | 31 | /** 32 | * Puppet in Service 33 | */ 34 | const puppet = new PuppetMock() 35 | const spyStart = sinon.spy(puppet, 'start') 36 | const spyOn = sinon.spy(puppet, 'on') 37 | const spyDing = sinon.spy(puppet, 'ding') 38 | 39 | /** 40 | * Puppet Server 41 | */ 42 | const serverOptions = { 43 | endpoint : ENDPOINT, 44 | puppet, 45 | token : TOKEN, 46 | } as PuppetServerOptions 47 | 48 | const puppetServer = new PuppetServer(serverOptions) 49 | await puppetServer.start() 50 | 51 | /** 52 | * Puppet Service Client 53 | */ 54 | const puppetOptions = { 55 | endpoint : ENDPOINT, 56 | token : TOKEN, 57 | } as PuppetOptions 58 | 59 | const puppetService = new PuppetService(puppetOptions) 60 | await puppetService.start() 61 | 62 | t.ok(spyStart.called, 'should called the puppet server start() function') 63 | 64 | const future = new Promise((resolve, reject) => { 65 | const offError = () => puppetService.off('error', reject) 66 | 67 | puppetService.once('dong', payload => { 68 | resolve(payload.data || '') 69 | offError() 70 | }) 71 | puppetService.once('error', e => { 72 | reject(e) 73 | offError() 74 | }) 75 | }) 76 | 77 | puppetService.ding(DING) 78 | const result = await future 79 | 80 | t.ok(spyOn.called, 'should called the puppet server on() function') 81 | t.ok(spyDing.called, 'should called the puppet server ding() function') 82 | 83 | t.equal(result, DING, 'should get a successful roundtrip for ding') 84 | 85 | /** 86 | * Stop 87 | * 1. Puppet in Service 88 | * 2. Puppet Service Server 89 | * 3. Puppet Service Client 90 | * 91 | */ 92 | await puppetService.stop() 93 | await puppetServer.stop() 94 | }) 95 | -------------------------------------------------------------------------------- /tests/performance.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | /** 4 | * Software performance testing 5 | * https://en.wikipedia.org/wiki/Software_performance_testing#Testing_types 6 | * 7 | * 负载测试,并发测试和压力测试,这三者之前的区别和联系? 8 | * https://www.zhihu.com/question/269215477/answer/350162604 9 | * 10 | * NodeJS: How Is Logging Enabled for the @grpc/grpc.js Package 11 | * https://stackoverflow.com/a/60935367/1123955 12 | * GRPC_VERBOSITY=DEBUG GRPC_TRACE=all 13 | */ 14 | 15 | import { 16 | test, 17 | sinon, 18 | } from 'tstest' 19 | 20 | import * as PUPPET from 'wechaty-puppet' 21 | 22 | import { 23 | PuppetMock, 24 | } from 'wechaty-puppet-mock' 25 | 26 | import { 27 | PuppetService, 28 | PuppetServer, 29 | PuppetServerOptions, 30 | } from '../src/mod.js' 31 | 32 | import { log } from '../src/config.js' 33 | 34 | const idToName = (id: string) => { 35 | return `name of ${id}` 36 | } 37 | 38 | class PuppetTest extends PuppetMock { 39 | 40 | constructor (...args: any[]) { 41 | super(...args) 42 | } 43 | 44 | override async contactRawPayload (id: string): Promise { 45 | log.verbose('PuppetTest', 'contactRawPayload(%s)', id) 46 | const rawPayload: PUPPET.payloads.Contact = { 47 | avatar : '', 48 | gender : PUPPET.types.ContactGender.Male, 49 | id, 50 | name : idToName(id), 51 | phone: [], 52 | type : PUPPET.types.Contact.Individual, 53 | } 54 | 55 | await new Promise(resolve => { 56 | process.stdout.write(',') 57 | setTimeout(() => { 58 | process.stdout.write('.') 59 | resolve() 60 | }, 1000) 61 | }) 62 | 63 | return rawPayload 64 | } 65 | 66 | } 67 | 68 | test.skip('stress testing', async t => { 69 | const TOKEN = 'test_token' 70 | const ENDPOINT = '0.0.0.0:8788' 71 | // const DING = 'ding_data' 72 | 73 | /** 74 | * Puppet in Service 75 | */ 76 | const puppet = new PuppetTest() 77 | const spy = sinon.spy(puppet, 'contactRawPayload') 78 | 79 | /** 80 | * Puppet Server 81 | */ 82 | const serverOptions = { 83 | endpoint : ENDPOINT, 84 | puppet, 85 | token : TOKEN, 86 | } as PuppetServerOptions 87 | 88 | const puppetServer = new PuppetServer(serverOptions) 89 | await puppetServer.start() 90 | 91 | /** 92 | * Puppet Service Client 93 | */ 94 | const puppetOptions = { 95 | endpoint : ENDPOINT, 96 | token : TOKEN, 97 | } as PUPPET.PuppetOptions 98 | 99 | const puppetService = new PuppetService(puppetOptions) 100 | await puppetService.start() 101 | 102 | let COUNTER = 0 103 | const dongList: string[] = [] 104 | puppetService.on('dong', payload => { 105 | dongList.push(payload.data || '') 106 | }) 107 | 108 | const timer = setInterval(() => { 109 | puppetService.ding(`interval ${COUNTER++}`) 110 | }, 10) 111 | 112 | const CONCURRENCY = 1000 113 | const concurrencyList = [ 114 | ...Array(CONCURRENCY).keys(), 115 | ].map(String) 116 | 117 | const resultList = await Promise.all( 118 | concurrencyList.map( 119 | id => puppetService.contactPayload(id), 120 | ), 121 | ) 122 | console.info() 123 | 124 | clearInterval(timer) 125 | 126 | const actualNameList = resultList.map(payload => payload.name) 127 | const EXPECTED_RESULT_LIST = concurrencyList.map(idToName) 128 | 129 | t.equal(spy.callCount, CONCURRENCY, `should be called ${CONCURRENCY} times`) 130 | t.same(actualNameList, EXPECTED_RESULT_LIST, `should get the right result with a huge concurrency ${CONCURRENCY}`) 131 | 132 | t.ok(dongList.length > 10, `dongList should receive many dong data (actual: ${dongList.length})`) 133 | t.equal(dongList[0], 'interval 0', 'dongList should get the first response from counter 0') 134 | 135 | /** 136 | * Stop 137 | * 1. Puppet in Service 138 | * 2. Puppet Service Server 139 | * 3. Puppet Service Client 140 | * 141 | */ 142 | await puppetService.stop() 143 | await puppetServer.stop() 144 | }) 145 | -------------------------------------------------------------------------------- /tests/ready-event.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | import type { 5 | PuppetOptions, 6 | } from 'wechaty-puppet' 7 | import PuppetMock from 'wechaty-puppet-mock' 8 | import getPort from 'get-port' 9 | 10 | import PuppetService, { 11 | PuppetServer, 12 | PuppetServerOptions, 13 | } from '../src/mod.js' 14 | 15 | const NIL_UUID_V4 = '00000000-0000-0000-0000-000000000000' 16 | 17 | test('ready event test', async t => { 18 | const PORT = await getPort() 19 | const TOKEN = `insecure_${NIL_UUID_V4}` 20 | const ENDPOINT = `0.0.0.0:${PORT}` 21 | 22 | /** 23 | * Puppet Server 24 | */ 25 | const puppet = new PuppetMock() 26 | 27 | // set ready to true before service starts 28 | puppet.readyIndicator.value(true) 29 | ;(puppet as any).__currentUserId = 'logged in' 30 | const serverOptions = { 31 | endpoint: ENDPOINT, 32 | puppet, 33 | token: TOKEN, 34 | } as PuppetServerOptions 35 | 36 | const puppetServer = new PuppetServer(serverOptions) 37 | 38 | await puppetServer.start() 39 | 40 | /** 41 | * Puppet Service Client 42 | */ 43 | const puppetOptions = { 44 | endpoint: ENDPOINT, 45 | token: TOKEN, 46 | } as PuppetOptions 47 | 48 | // check if ready event is emited on this ready-ed puppet 49 | const puppetService = new PuppetService(puppetOptions) 50 | const eventList: any[] = [] 51 | 52 | const loginFuture = new Promise(resolve => puppetService.once('login', () => { 53 | eventList.push('login') 54 | resolve() 55 | })) 56 | const readyFuture = new Promise(resolve => puppetService.once('ready', () => { 57 | eventList.push('ready') 58 | resolve() 59 | })) 60 | 61 | await Promise.all([ 62 | puppetService.start(), 63 | loginFuture, 64 | readyFuture, 65 | ]) 66 | 67 | t.same(eventList, [ 'login', 'ready' ], 'should have `login` event first then `ready`') 68 | 69 | await puppetService.stop() 70 | await puppetServer.stop() 71 | }) 72 | -------------------------------------------------------------------------------- /tests/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'why-is-node-running' 2 | -------------------------------------------------------------------------------- /tests/uuid-file-box.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | import * as path from 'path' 5 | import * as fs from 'fs' 6 | import type { PuppetOptions } from 'wechaty-puppet' 7 | import PuppetMock from 'wechaty-puppet-mock' 8 | import getPort from 'get-port' 9 | import temp from 'temp' 10 | import { FileBox } from 'file-box' 11 | 12 | import PuppetService, { PuppetServer, PuppetServerOptions } from '../src/mod.js' 13 | 14 | const NIL_UUID_V4 = '00000000-0000-0000-0000-000000000000' 15 | const __dirname = path.resolve() 16 | 17 | test('message file test', async t => { 18 | const PORT = await getPort() 19 | const TOKEN = `insecure_${NIL_UUID_V4}` 20 | const ENDPOINT = `0.0.0.0:${PORT}` 21 | 22 | const FILE = path.join(__dirname, 'tests', 'fixtures', 'smoke-testing.ts') 23 | const EXPECTED_SIZE = fs.statSync(FILE).size 24 | 25 | /** 26 | * Puppet Server 27 | */ 28 | const puppet = new PuppetMock() 29 | puppet.messageFile = async () => FileBox.fromFile(FILE) 30 | 31 | const serverOptions = { 32 | endpoint: ENDPOINT, 33 | puppet, 34 | token: TOKEN, 35 | } as PuppetServerOptions 36 | 37 | const puppetServer = new PuppetServer(serverOptions) 38 | await puppetServer.start() 39 | 40 | /** 41 | * Puppet Service Client 42 | */ 43 | const puppetOptions = { 44 | endpoint: ENDPOINT, 45 | token: TOKEN, 46 | } as PuppetOptions 47 | 48 | const puppetService = new PuppetService(puppetOptions) 49 | await puppetService.start() 50 | 51 | const file = await puppetService.messageFile('sb') 52 | 53 | const tmpFolder = temp.mkdirSync('test') 54 | const tmpFile = path.join(tmpFolder, 'uuid-file.dat') 55 | 56 | await file.toFile(tmpFile) 57 | const fileSize = fs.statSync(tmpFile).size 58 | 59 | t.equal(fileSize, EXPECTED_SIZE, 'should save file with the correct size') 60 | 61 | fs.rmSync(tmpFile) 62 | await puppetService.stop() 63 | await puppetServer.stop() 64 | }) 65 | -------------------------------------------------------------------------------- /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 | }, 6 | "exclude": [ 7 | "node_modules/", 8 | "dist/", 9 | "tests/fixtures/", 10 | ], 11 | "include": [ 12 | "bin/*.ts", 13 | "examples/**/*.ts", 14 | "scripts/**/*.ts", 15 | "src/**/*.ts", 16 | "tests/**/*.spec.ts", 17 | "tests/*.d.ts", 18 | ], 19 | } 20 | --------------------------------------------------------------------------------