├── .editorconfig ├── .eslintrc.cjs ├── .github └── workflows │ └── npm.yml ├── .gitignore ├── .markdownlintrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs └── images │ └── wechat4u-logo.png ├── examples ├── ding-dong-bot.ts ├── file │ └── test.txt ├── media │ ├── test.gif │ ├── test.mp4 │ └── test.txt └── ripe-wechaty.ts ├── package.json ├── scripts ├── generate-package-json.sh ├── npm-pack-testing.sh └── package-publish-config-tag.sh ├── src ├── README.md ├── config.ts ├── mod.ts ├── package-json.spec.ts ├── package-json.ts ├── puppet-wechat4u.spec.ts ├── puppet-wechat4u.ts ├── types.d.ts ├── web-schemas.ts └── wechat4u │ ├── events │ ├── event-friendship.ts │ ├── event-message.ts │ ├── event-room-invite.ts │ ├── event-room-join.ts │ ├── event-room-leave.ts │ ├── event-room-topic.ts │ ├── event.ts │ └── mod.ts │ ├── messages │ ├── message-appmsg.ts │ ├── message-emotion.ts │ ├── message-miniprogram.ts │ ├── message-sysmsg.ts │ └── sysmsg │ │ ├── message-pat.ts │ │ ├── message-revokemsg.ts │ │ ├── message-sysmsgtemplate.ts │ │ └── message-todo.ts │ ├── schema-mapper │ ├── contact.ts │ ├── message.ts │ ├── message │ │ ├── message-parser-appmsg.ts │ │ ├── message-parser-refermsg.ts │ │ ├── message-parser-room.ts │ │ ├── message-parser-single-chat.ts │ │ ├── message-parser-sysmsg.ts │ │ ├── message-parser-type.ts │ │ ├── message-parser.ts │ │ └── mod.ts │ └── room.ts │ └── utils │ ├── is-type.ts │ ├── parse-mention-id-list.ts │ ├── regex.ts │ ├── runner.ts │ ├── xml-to-json.ts │ └── xml.ts ├── tests ├── fixtures │ └── smoke-testing.ts └── integration.spec.ts ├── tsconfig.cjs.json ├── tsconfig.json └── tslint.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 | 2 | const rules = { 3 | } 4 | 5 | module.exports = { 6 | extends: '@chatie', 7 | rules, 8 | "globals": { 9 | "NodeJS": true 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/npm.yml: -------------------------------------------------------------------------------- 1 | name: NPM 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | strategy: 9 | matrix: 10 | os: 11 | - ubuntu-latest 12 | - windows-latest 13 | - macos-latest 14 | node-version: 15 | - 16 16 | 17 | runs-on: ${{ matrix.os }} 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: npm 25 | cache-dependency-path: package.json 26 | 27 | - name: Install Dependencies 28 | run: npm install 29 | 30 | - name: Test 31 | run: npm test 32 | 33 | pack: 34 | name: Pack 35 | needs: build 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | - uses: actions/setup-node@v2 40 | with: 41 | node-version: 16 42 | cache: npm 43 | cache-dependency-path: package.json 44 | 45 | - name: Install Dependencies 46 | run: npm install 47 | 48 | - name: Generate Package JSON 49 | run: ./scripts/generate-package-json.sh 50 | 51 | - name: Pack Testing 52 | run: ./scripts/npm-pack-testing.sh 53 | env: 54 | WECHATY_PUPPET_SERVICE_TOKEN: ${{ secrets.WECHATY_PUPPET_SERVICE_TOKEN }} 55 | 56 | publish: 57 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v')) 58 | name: Publish 59 | needs: 60 | - build 61 | - pack 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v2 65 | - uses: actions/setup-node@v2 66 | with: 67 | node-version: 16 68 | registry-url: https://registry.npmjs.org/ 69 | cache: npm 70 | cache-dependency-path: package.json 71 | 72 | - name: Install Dependencies 73 | run: npm install 74 | 75 | - name: Generate Package JSON 76 | run: ./scripts/generate-package-json.sh 77 | 78 | - name: Set Publish Config 79 | run: ./scripts/package-publish-config-tag.sh 80 | 81 | - name: Build Dist 82 | run: npm run dist 83 | 84 | - name: Check Branch 85 | id: check-branch 86 | run: | 87 | if [[ ${{ github.ref }} =~ ^refs/heads/(main|v[0-9]+\.[0-9]+.*)$ ]]; then 88 | echo ::set-output name=match::true 89 | fi # See: https://stackoverflow.com/a/58869470/1123955 90 | - name: Is A Publish Branch 91 | if: steps.check-branch.outputs.match == 'true' 92 | run: | 93 | NAME=$(npx pkg-jq -r .name) 94 | VERSION=$(npx pkg-jq -r .version) 95 | if npx version-exists "$NAME" "$VERSION" 96 | then echo "$NAME@$VERSION exists on NPM, skipped." 97 | else npm publish 98 | fi 99 | env: 100 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 101 | - name: Is Not A Publish Branch 102 | if: steps.check-branch.outputs.match != 'true' 103 | run: echo 'Not A Publish Branch' 104 | -------------------------------------------------------------------------------- /.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 | .DS_Store 65 | 66 | # .idea 67 | .idea 68 | -------------------------------------------------------------------------------- /.markdownlintrc: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "no-trailing-punctuation": { 4 | "punctuation": ".,;:!" 5 | }, 6 | "MD013": false, 7 | "MD033": false, 8 | "first-line-h1": false, 9 | "no-hard-tabs": true, 10 | "no-trailing-spaces": { 11 | "br_spaces": 2 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | 4 | "editor.fontFamily": "'Fira Code iScript', Consolas, 'Courier New', monospace", 5 | "editor.fontLigatures": true, 6 | 7 | "editor.tokenColorCustomizations": { 8 | "textMateRules": [ 9 | { 10 | "scope": [ 11 | //following will be in italics (=Pacifico) 12 | "comment", 13 | // "entity.name.type.class", //class names 14 | "keyword", //import, export, return… 15 | "support.class.builtin.js", //String, Number, Boolean…, this, super 16 | "storage.modifier", //static keyword 17 | "storage.type.class.js", //class keyword 18 | "storage.type.function.js", // function keyword 19 | "storage.type.js", // Variable declarations 20 | "keyword.control.import.js", // Imports 21 | "keyword.control.from.js", // From-Keyword 22 | "entity.name.type.js", // new … Expression 23 | "keyword.control.flow.js", // await 24 | "keyword.control.conditional.js", // if 25 | "keyword.control.loop.js", // for 26 | "keyword.operator.new.js", // new 27 | ], 28 | "settings": { 29 | "fontStyle": "italic", 30 | }, 31 | }, 32 | { 33 | "scope": [ 34 | //following will be excluded from italics (My theme (Monokai dark) has some defaults I don't want to be in italics) 35 | "invalid", 36 | "keyword.operator", 37 | "constant.numeric.css", 38 | "keyword.other.unit.px.css", 39 | "constant.numeric.decimal.js", 40 | "constant.numeric.json", 41 | "entity.name.type.class.js" 42 | ], 43 | "settings": { 44 | "fontStyle": "", 45 | }, 46 | } 47 | ] 48 | }, 49 | 50 | "files.exclude": { 51 | "dist/": true, 52 | "doc/": true, 53 | "node_modules/": true, 54 | "package/": true, 55 | }, 56 | "alignment": { 57 | "operatorPadding": "right", 58 | "indentBase": "firstline", 59 | "surroundSpace": { 60 | "colon": [1, 1], // The first number specify how much space to add to the left, can be negative. The second number is how much space to the right, can be negative. 61 | "assignment": [1, 1], // The same as above. 62 | "arrow": [1, 1], // The same as above. 63 | "comment": 2, // Special how much space to add between the trailing comment and the code. 64 | // If this value is negative, it means don't align the trailing comment. 65 | } 66 | }, 67 | "editor.formatOnSave": false, 68 | "python.pythonPath": "python3", 69 | "eslint.validate": [ 70 | "javascript", 71 | "typescript", 72 | ], 73 | } 74 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WECHATY-PUPPET-WECHAT4U [![NPM](https://github.com/wechaty/wechaty-puppet-wechat4u/actions/workflows/npm.yml/badge.svg)](https://github.com/wechaty/wechaty-puppet-wechat4u/actions/workflows/npm.yml) 2 | 3 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-blue.svg)](https://github.com/chatie/wechaty) 4 | [![NPM Version](https://badge.fury.io/js/wechaty-puppet-wechat4u.svg)](https://badge.fury.io/js/wechaty-puppet-wechat4u) 5 | [![npm (tag)](https://img.shields.io/npm/v/wechaty-puppet-wechat4u/next.svg)](https://www.npmjs.com/package/wechaty-puppet-wechat4u?activeTab=versions) 6 | [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-blue.svg)](https://www.typescriptlang.org/) 7 | [![ES Modules](https://img.shields.io/badge/ES-Modules-brightgreen)](https://github.com/Chatie/tsconfig/issues/16) 8 | 9 | ![wechaty puppet wechat4u](https://wechaty.github.io/puppet-wechat4u/images/wechat4u-logo.png) 10 | 11 | Wechat4u Puppet for Wechaty 12 | 13 | See: [New Puppet - Plan to support `WECHATY_HEAD=WECHAT4U` #69](https://github.com/Chatie/wechaty/issues/69) 14 | 15 | ## ABOUT WECHAT4U 16 | 17 | [Wechat4U](https://github.com/nodeWechat/wechat4u) is an excellent wechat bot framework that supports both Node.js & Browser, with rich features and an active community of experienced contributors. 18 | 19 | Learn more about the Puppet at [Wechaty wiki: Puppet](https://github.com/Chatie/wechaty/wiki/Puppet) 20 | 21 | ## HISTORY 22 | 23 | ### v1.14.0 (April 21, 2023) 24 | 25 | 目前使用 1.13.14 大多数使用者可能出现微信被官方封禁提醒,从已知收集的封禁情况,暂未有可解决方案。 26 | 27 | 猜测可能与近期ChatGPT结合本仓库实现个性化机器人导致相关封禁,请合理,谨慎使用本仓库。 28 | 29 | 1. Stable version 30 | 2. Fix Contact isFriend 31 | 32 | ### v1.13.8 (Nov 22, 2022) 33 | 34 | 1. Fix Contact update 35 | 36 | ### v1.13.1 (Nov 18, 2022) 37 | 38 | 1. Support uos login 39 | 2. Refactor the code to support more event 40 | 41 | UOS support solved the following two problems of the naive Web API: 42 | 43 | ~~1. WeChat Accounts that registered after 2017 might be unable to log in to Web Wechat, so they can not use PuppetPuppeteer with Wechaty. Please make sure your WeChat Account can be able to login by visiting ~~ 44 | ~~1. Web API has not been able to create rooms and invite members to rooms since 2018.~~ 45 | 46 | If you want to break more limitations, please consider using a Wechaty Puppet rather than a Web API. See all Wechaty Puppet Provider (WPS) at and Wechaty Puppet Service (WPS) at 47 | 48 | ### master v1.0 (Oct 30, 2021) 49 | 50 | Release 1.0 of Wechaty Puppet for Wechat4u 51 | 52 | ### v0.20 (Sep 14, 2021) 53 | 54 | 1. ES Modules support 55 | 56 | ### v0.18 (Feb 20, 2021) 57 | 58 | Fix `wechaty-puppet` dependencies. 59 | 60 | ### v0.0.1 (Jun 30, 2018) 61 | 62 | Init version 63 | 64 | ## MAINTAINERS 65 | 66 | - [Leo Chen](https://wechaty.js.org/contributors/leochen-g/), [Project Admin](https://github.com/wechaty/puppet-wechat4u/pull/42#issuecomment-1324436596) 67 | - [Huan](https://wechaty.js.org/contributors/huan/), Author of Puppet WeChat4U 68 | 69 | ## AUTHOR 70 | 71 | [Huan Li](http://linkedin.com/in/huan42) \ 72 | 73 | 74 | 75 | Profile for Huan on Stack Exchange, a network of free, community-driven Q & A sites 76 | 77 | 78 | ## COPYRIGHT & LICENSE 79 | 80 | - Code & Docs © 2018 Huan Li \ 81 | - Code released under the Apache-2.0 License 82 | - Docs released under Creative Commons 83 | -------------------------------------------------------------------------------- /docs/images/wechat4u-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/puppet-wechat4u/b9a21f0b985b69810a909bdafdd2bdef54e38d0e/docs/images/wechat4u-logo.png -------------------------------------------------------------------------------- /examples/ding-dong-bot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import * as PUPPET from 'wechaty-puppet' 20 | import { 21 | FileBox, 22 | } from 'file-box' 23 | 24 | import { PuppetWechat4u } from '../src/mod.js' 25 | 26 | /** 27 | * 28 | * 1. Declare your Bot! 29 | * 30 | */ 31 | const puppet = new PuppetWechat4u() 32 | 33 | /** 34 | * 35 | * 2. Register event handlers for Bot 36 | * 37 | */ 38 | puppet 39 | .on('logout', onLogout) 40 | .on('login', onLogin) 41 | .on('scan', onScan) 42 | .on('error', onError) 43 | .on('message', onMessage) 44 | 45 | /** 46 | * 47 | * 3. Start the bot! 48 | * 49 | */ 50 | puppet.start() 51 | .catch(async e => { 52 | console.error('Bot start() fail:', e as Error) 53 | await puppet.stop() 54 | process.exit(-1) 55 | }) 56 | 57 | /** 58 | * 59 | * 4. You are all set. ;-] 60 | * 61 | */ 62 | 63 | /** 64 | * 65 | * 5. Define Event Handler Functions for: 66 | * `scan`, `login`, `logout`, `error`, and `message` 67 | * 68 | */ 69 | function onScan (payload: PUPPET.payloads.EventScan) { 70 | if (payload.qrcode) { 71 | // Generate a QR Code online via 72 | // http://goqr.me/api/doc/create-qr-code/ 73 | const qrcodeImageUrl = [ 74 | 'https://wechaty.js.org/qrcode/', 75 | encodeURIComponent(payload.qrcode), 76 | ].join('') 77 | 78 | console.info(`[${payload.status}] ${qrcodeImageUrl}\nScan QR Code above to log in: `) 79 | } else { 80 | console.info(`[${payload.status}]`) 81 | } 82 | } 83 | 84 | function onLogin (payload: PUPPET.payloads.EventLogin) { 85 | console.info(`${payload.contactId} login`) 86 | puppet.messageSendText(payload.contactId, 'Wechaty login').catch(console.error) 87 | } 88 | 89 | function onLogout (payload: PUPPET.payloads.EventLogout) { 90 | console.info(`${payload.contactId} logouted`) 91 | } 92 | 93 | function onError (payload: PUPPET.payloads.EventError) { 94 | console.error('Bot error:', payload.data) 95 | /* 96 | if (bot.logonoff()) { 97 | bot.say('Wechaty error: ' + (e as Error).message).catch(console.error) 98 | } 99 | */ 100 | } 101 | 102 | /** 103 | * 104 | * 6. The most important handler is for: 105 | * dealing with Messages. 106 | * 107 | */ 108 | async function onMessage (payload: PUPPET.payloads.EventMessage) { 109 | const messagePayload = await puppet.messagePayload(payload.messageId) 110 | console.info(JSON.stringify(messagePayload)) 111 | 112 | if (messagePayload.type === PUPPET.types.Message.Text 113 | && /^ding$/i.test(messagePayload.text || '') 114 | ) { 115 | const conversationId = messagePayload.roomId || messagePayload.talkerId 116 | 117 | if (!conversationId) { 118 | throw new Error('no conversation id') 119 | } 120 | await puppet.messageSendText(conversationId, 'dong') 121 | 122 | const fileBox = FileBox.fromUrl('https://wechaty.github.io/wechaty/images/bot-qr-code.png') 123 | await puppet.messageSendFile(conversationId, fileBox) 124 | } 125 | } 126 | 127 | /** 128 | * 129 | * 7. Output the Welcome Message 130 | * 131 | */ 132 | const welcome = ` 133 | Puppet Version: ${puppet.version()} 134 | 135 | Please wait... I'm trying to login in... 136 | 137 | ` 138 | console.info(welcome) 139 | -------------------------------------------------------------------------------- /examples/file/test.txt: -------------------------------------------------------------------------------- 1 | text -------------------------------------------------------------------------------- /examples/media/test.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/puppet-wechat4u/b9a21f0b985b69810a909bdafdd2bdef54e38d0e/examples/media/test.gif -------------------------------------------------------------------------------- /examples/media/test.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/puppet-wechat4u/b9a21f0b985b69810a909bdafdd2bdef54e38d0e/examples/media/test.mp4 -------------------------------------------------------------------------------- /examples/media/test.txt: -------------------------------------------------------------------------------- 1 | # WECHATY-PUPPET-WECHAT4U [![NPM](https://github.com/wechaty/wechaty-puppet-wechat4u/actions/workflows/npm.yml/badge.svg)](https://github.com/wechaty/wechaty-puppet-wechat4u/actions/workflows/npm.yml) 2 | 3 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-blue.svg)](https://github.com/chatie/wechaty) 4 | [![NPM Version](https://badge.fury.io/js/wechaty-puppet-wechat4u.svg)](https://badge.fury.io/js/wechaty-puppet-wechat4u) 5 | [![npm (tag)](https://img.shields.io/npm/v/wechaty-puppet-wechat4u/next.svg)](https://www.npmjs.com/package/wechaty-puppet-wechat4u?activeTab=versions) 6 | [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-blue.svg)](https://www.typescriptlang.org/) 7 | [![ES Modules](https://img.shields.io/badge/ES-Modules-brightgreen)](https://github.com/Chatie/tsconfig/issues/16) 8 | 9 | ![wechaty puppet wechat4u](https://wechaty.github.io/puppet-wechat4u/images/wechat4u-logo.png) 10 | 11 | Wechat4u Puppet for Wechaty 12 | 13 | See: [New Puppet - Plan to support `WECHATY_HEAD=WECHAT4U` #69](https://github.com/Chatie/wechaty/issues/69) 14 | 15 | ## ABOUT WECHAT4U 16 | 17 | [Wechat4U](https://github.com/nodeWechat/wechat4u) is an excellent wechat bot framework that supports both Node.js & Browser, with rich features and an active community of experienced contributors. 18 | 19 | ## KNOWN LIMITATIONS 20 | 21 | ~~1. WeChat Account that registered after 2017 mignt not be able to login Web Wechat, so it can not use PuppetPuppeteer with Wechaty. Please make sure your WeChat Account can be able to login by visiting ~~ 22 | ~~1. Web API can not create room and invite members to room since 2018.~~ 23 | 24 | UOS has support 25 | 26 | 更新 - 2023/02/10 27 | 目前使用 1.13.14 大多数使用者可能出现微信被官方封禁提醒,从已知收集的封禁情况,暂未有可解决方案。 28 | 29 | 猜测可能与近期ChatGPT结合本仓库实现个性化机器人导致相关封禁,请合理,谨慎使用本仓库。 30 | 31 | If you want to break the above limitations, please consider to use a Wechaty Puppet other than using Web API, like [wechaty-puppet-padchat](https://github.com/lijiarui/wechaty-puppet-padchat). 32 | 33 | Learn more about the Puppet at [Wechaty wiki: Puppet](https://github.com/Chatie/wechaty/wiki/Puppet) 34 | 35 | ## HISTORY 36 | 37 | ### v1.14.0 (April 21, 2023) 38 | 39 | 1.Stable version 40 | 41 | 2.Fix Contact isFriend 42 | 43 | ### v1.13.8 (Nov 22, 2022) 44 | 45 | 1.Fix Contact update 46 | 47 | ### v1.13.1 (Nov 18, 2022) 48 | 49 | 1.Support uos login 50 | 51 | 2.Refactor the code to support more event 52 | 53 | ### master v1.0 (Oct 30, 2021) 54 | 55 | Release 1.0 of Wechaty Puppet for Wechat4u 56 | 57 | ### v0.20 (Sep 14, 2021) 58 | 59 | 1. ES Modules support 60 | 61 | ### v0.18 (Feb 20, 2021) 62 | 63 | Fix `wechaty-puppet` dependencies 64 | 65 | ### v0.0.1 (Jun 30, 2018) 66 | 67 | Init version 68 | 69 | ## MAINTAINERS 70 | 71 | - [Leo Chen](https://wechaty.js.org/contributors/leochen-g/), [Project Admin](https://github.com/wechaty/puppet-wechat4u/pull/42#issuecomment-1324436596) 72 | - [Huan](https://wechaty.js.org/contributors/huan/), Author of Puppet WeChat4U 73 | 74 | ## AUTHOR 75 | 76 | [Huan LI](http://linkedin.com/in/zixia) \ 77 | 78 | 79 | 80 | profile for zixia on Stack Exchange, a network of free, community-driven Q&A sites 81 | 82 | 83 | ## COPYRIGHT & LICENSE 84 | 85 | - Code & Docs © 2018 Huan LI \ 86 | - Code released under the Apache-2.0 License 87 | - Docs released under Creative Commons 88 | -------------------------------------------------------------------------------- /examples/ripe-wechaty.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - Conversational RPA SDK for Chatbot Makers. 3 | * - https://github.com/wechaty/wechaty 4 | */ 5 | import { 6 | Contact, 7 | Message, 8 | ScanStatus, 9 | WechatyBuilder, 10 | log, 11 | types, 12 | } from 'wechaty' 13 | import { FileBox } from 'file-box' 14 | 15 | import { PuppetWechat4u } from '../src/puppet-wechat4u.js' 16 | import qrcodeTerminal from 'qrcode-terminal' 17 | import fs from 'fs' 18 | 19 | function onScan (qrcode: string, status: ScanStatus) { 20 | if (qrcode) { 21 | const qrcodeImageUrl = [ 22 | 'https://wechaty.js.org/qrcode/', 23 | encodeURIComponent(qrcode), 24 | ].join('') 25 | console.info('StarterBot', 'onScan: %s(%s) - %s', status, qrcodeImageUrl) 26 | 27 | qrcodeTerminal.generate(qrcode, { small: true }) // show qrcode on console 28 | console.info(`[${status}] ${qrcode}\nScan QR Code above to log in: `) 29 | } else { 30 | console.info(`[${status}]`) 31 | } 32 | } 33 | 34 | async function onLogin (user: Contact) { 35 | log.info('StarterBot', '%s login', user) 36 | const roomList = await bot.Room.findAll() 37 | console.info(roomList.length) 38 | const contactList = await bot.Contact.findAll() 39 | console.info(contactList.length) 40 | } 41 | 42 | function onLogout (user: Contact) { 43 | log.info('StarterBot', '%s logout', user) 44 | } 45 | 46 | async function onMessage (msg: Message) { 47 | log.info('StarterBot', msg.toString()) 48 | if (msg.text() === 'ding') { 49 | await msg.say('dong') 50 | } 51 | 52 | const basepath = 'examples/media/' 53 | /** 54 | * 发送文件 55 | */ 56 | if (msg.text() === 'txt') { 57 | const newpath = basepath + 'test.txt' 58 | const fileBox = FileBox.fromFile(newpath) 59 | await msg.say(fileBox) 60 | } 61 | 62 | /** 63 | * 发送图片 64 | */ 65 | if (msg.text() === 'jpg') { 66 | const newpath = 'https://github.com/wechaty/wechaty/blob/main/docs/images/bot-qr-code.png' 67 | const fileBox = FileBox.fromUrl(newpath) 68 | await msg.say(fileBox) 69 | } 70 | 71 | /** 72 | * 发送表情 73 | */ 74 | if (msg.text() === 'gif') { 75 | const newpath = basepath + 'test.gif' 76 | const fileBox = FileBox.fromFile(newpath) 77 | await msg.say(fileBox) 78 | } 79 | 80 | /** 81 | * 发送视频 82 | */ 83 | if (msg.text() === 'mp4') { 84 | const newpath = basepath + 'test.mp4' 85 | const fileBox = FileBox.fromFile(newpath) 86 | await msg.say(fileBox) 87 | } 88 | 89 | try { 90 | if (msg.type() === types.Message.Image || msg.type() === types.Message.Attachment || msg.type() === types.Message.Video || msg.type() === types.Message.Audio || msg.type() === types.Message.Emoticon) { 91 | const file = await msg.toFileBox() // Save the media message as a FileBox 92 | const filePath = 'examples/file/' + file.name 93 | file.toFile(filePath) 94 | log.info(`Saved file: ${filePath}`) 95 | } else { 96 | // Log other non-text messages 97 | const logData = { 98 | date: new Date(), 99 | from: msg.talker().name(), 100 | text: msg.text(), 101 | type: msg.type(), 102 | } 103 | const logPath = 'examples/log/message.log' 104 | fs.appendFileSync(logPath, JSON.stringify(logData, null, 2) + '\n') 105 | log.info(`Logged message data to ${logPath}`) 106 | } 107 | } catch (e) { 108 | console.error(`Error handling message: ${e}`) 109 | } 110 | 111 | } 112 | 113 | const puppet = new PuppetWechat4u() 114 | const bot = WechatyBuilder.build({ 115 | name: 'ding-dong-bot', 116 | puppet, 117 | }) 118 | 119 | bot.on('scan', onScan) 120 | bot.on('login', onLogin) 121 | bot.on('logout', onLogout) 122 | bot.on('message', onMessage) 123 | bot.on('room-join', async (room, inviteeList, inviter) => { 124 | const nameList = inviteeList.map(c => c.name()).join(',') 125 | log.info(`Room ${await room.topic()} got new member ${nameList}, invited by ${inviter}`) 126 | }) 127 | bot.on('room-leave', async (room, leaverList, remover) => { 128 | const nameList = leaverList.map(c => c.name()).join(',') 129 | log.info(`Room ${await room.topic()} lost member ${nameList}, the remover is: ${remover}`) 130 | }) 131 | bot.on('room-topic', async (room, topic, oldTopic, changer) => { 132 | log.info(`Room ${await room.topic()} topic changed from ${oldTopic} to ${topic} by ${changer.name()}`) 133 | }) 134 | bot.on('room-invite', async roomInvitation => { 135 | log.info(JSON.stringify(roomInvitation)) 136 | try { 137 | log.info('received room-invite event.') 138 | await roomInvitation.accept() 139 | } catch (e) { 140 | console.error(e) 141 | } 142 | }) 143 | 144 | bot.start() 145 | .then(() => { 146 | return log.info('StarterBot', 'Starter Bot Started.') 147 | }) 148 | .catch(console.error) 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechaty-puppet-wechat4u", 3 | "version": "1.14.14", 4 | "description": "Wechat4u Puppet 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 | "types": "./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 | "lint:es": "eslint \"src/**/*.ts\" \"tests/**/*.spec.ts\" --ignore-pattern tests/fixtures/", 26 | "start": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" node examples/ding-dong-bot.ts", 27 | "start:ripe": "cross-env WECHATY_LOG=verbose NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" node examples/ripe-wechaty.ts", 28 | "start:ripe:nolog": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" node examples/ripe-wechaty.ts", 29 | "test": "npm run lint && npm run test:unit", 30 | "test:pack": "bash -x scripts/npm-pack-testing.sh", 31 | "test:unit": "cross-env NODE_OPTIONS=\"--no-warnings --loader=ts-node/esm\" tap \"src/**/*.spec.ts\" \"tests/**/*.spec.ts\"" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "git+https://github.com/wechaty/wechaty-puppet-wechat4u.git" 36 | }, 37 | "keywords": [ 38 | "chatie", 39 | "wechaty", 40 | "wechat", 41 | "chatbot", 42 | "bot", 43 | "sdk", 44 | "puppet", 45 | "wechat4u" 46 | ], 47 | "author": "Huan LI ", 48 | "license": "Apache-2.0", 49 | "bugs": { 50 | "url": "https://github.com/wechaty/wechaty-puppet-wechat4u/issues" 51 | }, 52 | "devDependencies": { 53 | "@chatie/eslint-config": "^1.0.4", 54 | "@chatie/git-scripts": "^0.6.2", 55 | "@chatie/semver": "^0.4.7", 56 | "@chatie/tsconfig": "^4.6.2", 57 | "@types/promise-retry": "^1.1.3", 58 | "@types/qrcode-terminal": "^0.12.0", 59 | "@types/xml2js": "^0.4.11", 60 | "memory-card": "^1.0.3", 61 | "qrcode-terminal": "^0.12.0", 62 | "wechaty": "^1.20.2" 63 | }, 64 | "peerDependencies": { 65 | "@swc/core": "^1.3.100", 66 | "wechaty-puppet": "^1.20.2" 67 | }, 68 | "homepage": "https://github.com/wechaty/wechaty-puppet-wechat4u#readme", 69 | "dependencies": { 70 | "@alloc/quick-lru": "^5.2.0", 71 | "fast-xml-parser": "^3.21.1", 72 | "promise-retry": "^2.0.1", 73 | "wechat4u": "^0.7.14", 74 | "xml2js": "^0.4.23" 75 | }, 76 | "publishConfig": { 77 | "access": "public", 78 | "tag": "next" 79 | }, 80 | "files": [ 81 | "bin/", 82 | "dist/", 83 | "src/" 84 | ], 85 | "tap": { 86 | "check-coverage": false 87 | }, 88 | "git": { 89 | "scripts": { 90 | "pre-push": "npx git-scripts-pre-push" 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /scripts/generate-package-json.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | SRC_PACKAGE_JSON_TS_FILE='src/package-json.ts' 5 | 6 | [ -f ${SRC_PACKAGE_JSON_TS_FILE} ] || { 7 | echo ${SRC_PACKAGE_JSON_TS_FILE}" not found" 8 | exit 1 9 | } 10 | 11 | cat <<_SRC_ > ${SRC_PACKAGE_JSON_TS_FILE} 12 | /** 13 | * This file was auto generated from scripts/generate-version.sh 14 | */ 15 | import type { PackageJson } from 'type-fest' 16 | export const packageJson: PackageJson = $(cat package.json) as any 17 | _SRC_ 18 | -------------------------------------------------------------------------------- /scripts/npm-pack-testing.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | VERSION=$(npx pkg-jq -r .version) 5 | 6 | if npx --package @chatie/semver semver-is-prod "$VERSION"; then 7 | NPM_TAG=latest 8 | else 9 | NPM_TAG=next 10 | fi 11 | 12 | npm run dist 13 | npm pack 14 | 15 | TMPDIR="/tmp/npm-pack-testing.$$" 16 | mkdir "$TMPDIR" 17 | mv ./*-*.*.*.tgz "$TMPDIR" 18 | cp tests/fixtures/smoke-testing.ts "$TMPDIR" 19 | 20 | cd $TMPDIR 21 | 22 | npm init -y 23 | npm install --production *-*.*.*.tgz \ 24 | @types/node \ 25 | @chatie/tsconfig@$NPM_TAG \ 26 | pkg-jq \ 27 | "wechaty-puppet@$NPM_TAG" \ 28 | "wechaty@$NPM_TAG" \ 29 | 30 | # 31 | # CommonJS 32 | # 33 | ./node_modules/.bin/tsc \ 34 | --target es6 \ 35 | --module CommonJS \ 36 | \ 37 | --moduleResolution node \ 38 | --esModuleInterop \ 39 | --lib esnext \ 40 | --noEmitOnError \ 41 | --noImplicitAny \ 42 | --skipLibCheck \ 43 | smoke-testing.ts 44 | 45 | echo 46 | echo "CommonJS: pack testing..." 47 | node smoke-testing.js 48 | 49 | # 50 | # ES Modules 51 | # 52 | npx pkg-jq -i '.type="module"' 53 | 54 | 55 | ./node_modules/.bin/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/README.md: -------------------------------------------------------------------------------- 1 | # WECHATY-PUPPET-WECHAT4U 2 | 3 | 4 | 5 | ## NOTES 6 | 7 | ```js 8 | /** 9 | * 发送撤回消息请求 10 | */ 11 | bot.sendMsg('测试撤回', ToUserName) 12 | .then(res => { 13 | // 需要取得待撤回消息的MsgID 14 | return bot.revokeMsg(res.MsgID, ToUserName) 15 | }) 16 | .catch(err => { 17 | console.log(err) 18 | }) 19 | ``` 20 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-reference 2 | 3 | /// 4 | 5 | import { 6 | log, 7 | } from 'wechaty-puppet' 8 | import { 9 | FileBox, 10 | } from 'file-box' 11 | 12 | import type { OperationOptions } from 'retry' 13 | import promiseRetry from 'promise-retry' 14 | 15 | import { packageJson } from './package-json.js' 16 | 17 | const VERSION = packageJson.version || '0.0.0' 18 | const NAME = packageJson.name || 'NONAME' 19 | 20 | export function qrCodeForChatie (): FileBox { 21 | const CHATIE_OFFICIAL_ACCOUNT_QRCODE = 'http://weixin.qq.com/r/qymXj7DEO_1ErfTs93y5' 22 | return FileBox.fromQRCode(CHATIE_OFFICIAL_ACCOUNT_QRCODE) 23 | } 24 | 25 | export async function retry ( 26 | retryableFn: ( 27 | retry: (error: Error) => never, 28 | attempt: number, 29 | ) => Promise, 30 | ): Promise { 31 | /** 32 | * 60 seconds: (to be confirmed) 33 | * factor: 3 34 | * minTimeout: 10 35 | * maxTimeout: 20 * 1000 36 | * retries: 9 37 | */ 38 | const factor = 3 39 | const minTimeout = 10 40 | const maxTimeout = 20 * 1000 41 | const retries = 9 42 | // const unref = true 43 | 44 | const retryOptions: OperationOptions = { 45 | factor, 46 | maxTimeout, 47 | minTimeout, 48 | retries, 49 | } 50 | return promiseRetry(retryOptions, retryableFn) 51 | } 52 | 53 | export { 54 | VERSION, 55 | NAME, 56 | log, 57 | } 58 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import { PuppetWechat4u } from './puppet-wechat4u.js' 20 | export { 21 | log, 22 | VERSION, 23 | } from './config.js' 24 | 25 | export { 26 | PuppetWechat4u, 27 | } 28 | 29 | export default PuppetWechat4u 30 | -------------------------------------------------------------------------------- /src/package-json.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { packageJson } from './package-json.js' 6 | 7 | test('Make sure the packageJson is fresh in source code', async t => { 8 | const keyNum = Object.keys(packageJson).length 9 | t.equal(keyNum, 0, 'packageJson should be empty in source code, only updated before publish to NPM') 10 | }) 11 | -------------------------------------------------------------------------------- /src/package-json.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file will be overwrite when we publish NPM module 3 | * by scripts/generate_version.ts 4 | */ 5 | import type { PackageJson } from 'type-fest' 6 | 7 | /** 8 | * Huan(202108): 9 | * The below default values is only for unit testing 10 | */ 11 | export const packageJson: PackageJson = {} 12 | -------------------------------------------------------------------------------- /src/puppet-wechat4u.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | import { PuppetWechat4u } from './puppet-wechat4u.js' 6 | 7 | class PuppetWechat4uTest extends PuppetWechat4u { 8 | } 9 | 10 | /** 11 | * Huan(202110): skip this test for now 12 | */ 13 | test.skip('PuppetWechat4u restart without problem', async t => { 14 | const puppet = new PuppetWechat4uTest() 15 | try { 16 | for (let i = 0; i < 3; i++) { 17 | await puppet.start() 18 | await puppet.stop() 19 | t.pass('start/stop-ed at #' + i) 20 | } 21 | t.pass('PuppetWechat4u() start/restart successed.') 22 | } catch (e) { 23 | t.fail(e as any) 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /src/puppet-wechat4u.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | import Wechat4u from 'wechat4u' 20 | import QuickLru from '@alloc/quick-lru' 21 | import * as PUPPET from 'wechaty-puppet' 22 | import { log } from 'wechaty-puppet' 23 | import type { FileBoxInterface } from 'file-box' 24 | import { FileBox } from 'file-box' 25 | import { GError } from 'gerror' 26 | 27 | import { 28 | qrCodeForChatie, 29 | retry, 30 | VERSION, 31 | NAME, 32 | } from './config.js' 33 | 34 | import { 35 | WebContactRawPayload, 36 | WebMessageRawPayload, 37 | WebMessageType, 38 | WebRoomRawMember, 39 | WebRoomRawPayload, 40 | } from './web-schemas.js' 41 | import { parseEvent, EventType } from './wechat4u/events/mod.js' 42 | 43 | import { webMessageToWechaty } from './wechat4u/schema-mapper/message.js' 44 | // 解析小程序数据格式 45 | import { parseMiniProgramMessagePayload } from './wechat4u/messages/message-miniprogram.js' 46 | // 解析appmsg 数据格式 47 | import { parseAppmsgMessagePayload } from './wechat4u/messages/message-appmsg.js' 48 | // 解析表情数据格式 49 | import { parseEmotionMessagePayload } from './wechat4u/messages/message-emotion.js' 50 | 51 | import { wechat4uContactToWechaty } from './wechat4u/schema-mapper/contact.js' 52 | import { wechat4uRoomMemberToWechaty, wechat4uRoomToWechaty } from './wechat4u/schema-mapper/room.js' 53 | import { isRoomId } from './wechat4u/utils/is-type.js' 54 | 55 | const MEMORY_SLOT_NAME = 'PUPPET-WECHAT4U' 56 | 57 | export class PuppetWechat4u extends PUPPET.Puppet { 58 | 59 | static override readonly VERSION = VERSION 60 | 61 | /** 62 | * Wecaht4u 63 | * 64 | * Code from: 65 | * https://github.com/nodeWechat/wechat4u/blob/46931e78bcb56899b8d2a42a37b919e7feaebbef/run-core.js 66 | * 67 | */ 68 | private wechat4u?: any 69 | 70 | private scanQrCode?: string 71 | // 启动时间 为了处理历史消息 72 | 73 | private startTime: number = 0 74 | 75 | private unknownContactId: string[][] = [] 76 | private getContactInterval: undefined | NodeJS.Timeout 77 | private _heartBeatTimer?: ReturnType 78 | 79 | private readonly cacheMessageRawPayload: QuickLru 80 | 81 | constructor ( 82 | override options: PUPPET.PuppetOptions = {}, 83 | ) { 84 | super(options) 85 | 86 | const lruOptions: QuickLru.Options = { 87 | maxAge: 1000 * 60 * 60, 88 | maxSize: 10000, 89 | onEviction (key: string, val: object) { 90 | log.silly('PuppetWechat4u', 'constructor() lruOptions.dispose(%s, %s)', key, JSON.stringify(val)) 91 | }, 92 | } 93 | 94 | this.cacheMessageRawPayload = new QuickLru(lruOptions) 95 | } 96 | 97 | override version () { return `${VERSION}<${super.version()}>` } 98 | override name () { return `${NAME}<${super.name()}>` } 99 | 100 | override async onStart (): Promise { 101 | log.verbose('PuppetWechat4u', 'onStart() with %s', this.memory.name || 'NONAME') 102 | 103 | if (this.wechat4u) { 104 | log.warn('PuppetWechat4u', 'onStart() wechat4u exist, will be overwrited') 105 | } 106 | this.startTime = parseInt(String(new Date().getTime() / 1000)) 107 | /** 108 | * Huan(202110): rename `onStart()` to `tryStart()` 109 | * then we will be able to use `MemoryMixin` 110 | * to init MemoryCard for the child puppet 111 | */ 112 | try { 113 | await this.memory.load() 114 | } catch (_) {} 115 | 116 | // console.info('faint 1') 117 | const syncData = await this.memory.get(MEMORY_SLOT_NAME) 118 | // console.info('faint 2') 119 | 120 | if (syncData) { 121 | this.wechat4u = new Wechat4u(syncData) 122 | } else { 123 | this.wechat4u = new Wechat4u() 124 | } 125 | 126 | this.monkeyPatch(this.wechat4u) 127 | 128 | this.initHookEvents(this.wechat4u) 129 | 130 | /** 131 | * Should not `await` onStart/restart for wechat4u 132 | * because it will blocks... 133 | */ 134 | if (this.wechat4u.PROP.uin) { 135 | // 存在登录数据时,可以随时调用restart进行重启 136 | this.wechat4u.restart() 137 | } else { 138 | this.wechat4u.start() 139 | } 140 | } 141 | 142 | /** 143 | * At present, if a user information that does not exist is found, it will be called once. 144 | * If it is a group message, it will send a lot of information requests, and finally most of the interface requests will fail. 145 | * At present, the method of timer is used to regularly obtain user information 146 | * 1、A timer is started when the search request is triggered for the first time 147 | * 2、All requested unknown user ids will be stored in unknownContactId 148 | * 3、The timer will be executed once every 500ms, each time fetching 50 pieces of data in unknownContactId 149 | * 4、If the data of unknownContactId is empty, the timer will be cleared and wait for the next establishment 150 | * @private 151 | */ 152 | 153 | private getContactsInfo () { 154 | const tempArray: string[][] = this.unknownContactId.splice(0, 40) 155 | if (tempArray.length === 0 && this.getContactInterval) { 156 | clearInterval(this.getContactInterval) 157 | this.getContactInterval = undefined 158 | } 159 | if (tempArray.length) { 160 | const userDataList = tempArray.map(contact => { 161 | return { 162 | EncryChatRoomId : contact[1], 163 | UserName : contact[0], 164 | } 165 | }) 166 | this.wechat4u.batchGetContact(userDataList).then((result: any[]) => { 167 | result.forEach((item) => { 168 | if (isRoomId(item.UserName)) { 169 | const membersList = item.MemberList.map((mItem: any) => { 170 | return { 171 | ...mItem, 172 | EncryChatRoomId: item.UserName, 173 | } 174 | }) 175 | this.wechat4u.updateContacts(membersList) 176 | } 177 | }) 178 | this.wechat4u.updateContacts(result) 179 | return null 180 | }).catch((e: any) => { 181 | log.warn('PuppetWechat4u', 'contactRawPayload(%s) wechat4u.batchGetContact() exception: %s', e) 182 | }) 183 | } 184 | } 185 | 186 | private monkeyPatch (wechat4u: any) { 187 | log.silly('PuppetWechat4u', 'monkeyPatch()') 188 | 189 | // fake wechat4u to think as we had logined.) 190 | this.monkeyPatchOffState(wechat4u, 'checkLogin', Promise.resolve({ code: 200 })) 191 | this.monkeyPatchOffState(wechat4u, 'login', Promise.resolve()) 192 | this.monkeyPatchOffState(wechat4u, '_init', Promise.resolve()) 193 | 194 | this._startPuppetHeart(true, wechat4u) 195 | 196 | /** 197 | * Disable Wechat4u for Sending Message to Filehelper when Heartbeat. 198 | */ 199 | // tslint:disable-next-line 200 | wechat4u.setPollingTargetGetter(() => { 201 | return '' 202 | }) 203 | 204 | wechat4u.setPollingMessageGetter(() => { 205 | return '' 206 | }) 207 | // 自定义心跳间隔(以毫秒为单位) 208 | // 25 days: https://stackoverflow.com/a/12633556/1123955 209 | // this.wechat4u.setPollingIntervalGetter(() => Math.pow(2,31) - 1) 210 | 211 | } 212 | 213 | // 开始监听心跳 214 | private _startPuppetHeart (firstTime: boolean = true, wechat4u: any) { 215 | if (firstTime && this._heartBeatTimer) { 216 | return 217 | } 218 | let status: string|undefined = wechat4u.state 219 | 220 | if (status === wechat4u.CONF.STATE.login) { 221 | status = 'normal' 222 | } else if (status === wechat4u.CONF.STATE.logout) { 223 | status = 'logout' 224 | } else if (status === wechat4u.CONF.STATE.init) { 225 | status = 'init' 226 | } else if (status === wechat4u.CONF.STATE.uuid) { 227 | status = 'uuid' 228 | } 229 | 230 | this.emit('heartbeat', { data: `heartbeat@puppet-wechat4u:${status}` }) 231 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 232 | this._heartBeatTimer = setTimeout(async (): Promise => { 233 | await this._startPuppetHeart(false, wechat4u) 234 | return undefined 235 | }, 15 * 1000) // 15s 236 | } 237 | 238 | /** 239 | * Monkey Patch for Wechat4u 240 | * - https://www.audero.it/blog/2016/12/05/monkey-patching-javascript/#what-is-monkey-patching 241 | * 242 | * What is Monkey patching? 243 | * Monkey patching is a technique to add, modify, or suppress 244 | * the default behavior of a piece of code at runtime 245 | * without changing its original source code. 246 | */ 247 | private monkeyPatchOffState (wechat4u: any, func: string, valueWhenLogouted: any): void { 248 | log.verbose('PuppetWechat4u', 'monkeyPatchOffState(wechat4u, %s)', func) 249 | 250 | const puppetThis = this 251 | 252 | const funcOrig = wechat4u[func] 253 | function funcNew (this: any) { 254 | log.verbose('PuppetWechat4u', 'monkeyPatchOffState(%s) funcNew()', func) 255 | 256 | if (puppetThis.state.inactive()) { 257 | log.verbose('PuppetWechat4u', 'monkeyPatchOffState(%s) funcNew() state.off() is true, return', func) 258 | return valueWhenLogouted 259 | } 260 | return funcOrig.call(this) 261 | } 262 | wechat4u[func] = funcNew 263 | } 264 | 265 | /** 266 | * @private 267 | * For issue https://github.com/wechaty/puppet-wechat/issues/107 268 | */ 269 | private async waitStable (): Promise { 270 | log.verbose('PuppetWeChat', 'waitStable()') 271 | 272 | let maxNum = 0 273 | let curNum = 0 274 | let unchangedNum = 0 275 | 276 | const SLEEP_SECOND = 4 277 | const STABLE_CHECK_NUM = 5 278 | 279 | while (unchangedNum < STABLE_CHECK_NUM) { 280 | 281 | // wait 1 second 282 | await new Promise(resolve => setTimeout(resolve, SLEEP_SECOND * 1000)) 283 | 284 | const contactList = await this.contactList() 285 | curNum = contactList.length 286 | 287 | if (curNum > 0 && curNum === maxNum) { 288 | unchangedNum++ 289 | } else /* curNum < maxNum */ { 290 | unchangedNum = 0 291 | } 292 | 293 | if (curNum > maxNum) { 294 | maxNum = curNum 295 | } 296 | 297 | log.silly('PuppetWeChat', 'readyStable() while() curNum=%s, maxNum=%s, unchangedNum=%s', 298 | curNum, maxNum, unchangedNum, 299 | ) 300 | 301 | } 302 | 303 | log.verbose('PuppetWeChat', 'readyStable() emit(ready)') 304 | this.emit('ready', { data: 'stable' }) 305 | } 306 | 307 | private initHookEvents (wechat4u: any) { 308 | log.verbose('PuppetWechat4u', 'initHookEvents()') 309 | /** 310 | * uuid事件,参数为uuid,根据uuid生成二维码 311 | */ 312 | this.wechat4u.on('uuid', (uuid: string) => { 313 | log.silly('PuppetWechat4u', 'initHookEvents() wechat4u.on(uuid)') 314 | 315 | this.scanQrCode = 'https://login.weixin.qq.com/l/' + uuid 316 | this.emit('scan', { qrcode: this.scanQrCode, status: PUPPET.types.ScanStatus.Waiting }) 317 | }) 318 | /** 319 | * 登录用户头像事件,手机扫描后可以得到登录用户头像的Data URL 320 | */ 321 | wechat4u.on('user-avatar', (avatarDataUrl: string) => { 322 | this.emit('scan', { 323 | data: avatarDataUrl, 324 | qrcode: this.scanQrCode || '', 325 | status: PUPPET.types.ScanStatus.Scanned, 326 | }) 327 | }) 328 | /** 329 | * 登录成功事件 330 | */ 331 | wechat4u.on('login', async () => { 332 | // 由于初始好友列表没有存储当前bot的基础信息,所以在登录后直接更新当前机器人的信息 333 | this.wechat4u.updateContacts([ this.wechat4u.user ]) 334 | this.startTime = parseInt(String(new Date().getTime() / 1000)) 335 | // FIXME: where's the logined user id? 336 | const userId = this.wechat4u.user.UserName 337 | if (!userId) { 338 | this.emit('error', { 339 | data: GError.stringify( 340 | new Error('login event can not found selfId'), 341 | ), 342 | }) 343 | return 344 | } 345 | // we do not wait `ready` before emit `login` 346 | this.waitStable().catch(e => { 347 | log.error('PuppetWeChatEvent', 'onLogin() this.waitStable() rejection: %s', e && (e as Error).message) 348 | }) 349 | await this.login(userId) 350 | // 保存数据,将数据序列化之后保存到任意位置 351 | await this.memory.set(MEMORY_SLOT_NAME, wechat4u.botData) 352 | await this.memory.save() 353 | }) 354 | /** 355 | * 登出成功事件 356 | */ 357 | wechat4u.on('logout', async () => { 358 | log.silly('PuppetWechat4u', 'initHookEvents() wechat4u.on(logout)') 359 | if (this.isLoggedIn) { 360 | await this.logout() 361 | } 362 | // 清除数据 363 | await this.memory.delete(MEMORY_SLOT_NAME) 364 | await this.memory.save() 365 | this.wechat4u.start() 366 | }) 367 | /** 368 | * 联系人更新事件,参数为被更新的联系人列表 369 | */ 370 | wechat4u.on('contacts-updated', (contacts: WebContactRawPayload[]) => { 371 | log.silly('PuppetWechat4u', 'initHookEvents() wechat4u.on(contacts-updated) new/total contacts.length=%d/%d', 372 | contacts.length, 373 | Object.keys(wechat4u.contacts).length, 374 | ) 375 | contacts.forEach((item: any) => { 376 | if (isRoomId(item.UserName)) { 377 | const membersList = item.MemberList.map((mItem: any) => { 378 | this.unknownContactId.push([ mItem.UserName, item.UserName ]) 379 | return { 380 | ...mItem, 381 | EncryChatRoomId: item.UserName, 382 | } 383 | }) 384 | this.wechat4u.updateContacts(membersList) 385 | } 386 | }) 387 | if (!this.getContactInterval) { 388 | this.getContactsInfo() 389 | this.getContactInterval = setInterval(() => { 390 | this.getContactsInfo() 391 | }, 2000) 392 | } 393 | }) 394 | /** 395 | * 错误事件,参数一般为Error对象 396 | */ 397 | wechat4u.on('error', (err: Error) => { 398 | this.emit('error', { 399 | data: GError.stringify(err), 400 | }) 401 | }) 402 | 403 | /** 404 | * 如何处理会话消息 405 | */ 406 | wechat4u.on('message', async (msg: WebMessageRawPayload) => { 407 | 408 | if (!msg.MsgId) { 409 | log.warn('PuppetWechat4u', 'initHookEvents() wechat4u.on(message) no message id: %s', JSON.stringify(msg)) 410 | throw new Error('no id') 411 | } 412 | // 如果是消息的创建时间小于机器人启动的时间 直接丢弃 413 | if (msg.CreateTime < this.startTime) { 414 | // log.warn('PuppetWechat4u', 'initHookEvents() wechat4u.on(message) is history message: %s', JSON.stringify(msg)) 415 | return 416 | } 417 | this.cacheMessageRawPayload.set(msg.MsgId, msg) 418 | const event = await parseEvent(this, msg) 419 | switch (event.type) { 420 | case EventType.Message: 421 | this.emit('message', { messageId: msg.MsgId }) 422 | break 423 | 424 | case EventType.Friendship: { 425 | this.emit('friendship', { 426 | friendshipId: msg.MsgId, 427 | }) 428 | break 429 | } 430 | 431 | case EventType.RoomInvite: { 432 | this.emit('room-invite', { 433 | roomInvitationId: msg.MsgId, 434 | }) 435 | break 436 | } 437 | case EventType.RoomJoin: { 438 | const roomJoin: PUPPET.payloads.EventRoomJoin = event.payload 439 | this.emit('room-join', roomJoin) 440 | break 441 | } 442 | case EventType.RoomLeave: { 443 | const roomLeave: PUPPET.payloads.EventRoomLeave = event.payload 444 | this.emit('room-leave', roomLeave) 445 | break 446 | } 447 | case EventType.RoomTopic: { 448 | const roomTopic: PUPPET.payloads.EventRoomTopic = event.payload 449 | this.emit('room-topic', roomTopic) 450 | break 451 | } 452 | } 453 | }) 454 | } 455 | 456 | override async onStop (): Promise { 457 | log.verbose('PuppetWechat4u', 'onStop()') 458 | 459 | this.wechat4u.stop() 460 | this.wechat4u = undefined 461 | if (this._heartBeatTimer) { 462 | clearTimeout(this._heartBeatTimer) 463 | this._heartBeatTimer = undefined 464 | } 465 | } 466 | 467 | override async ding (data: string): Promise { 468 | log.silly('PuppetWechat4u', 'ding(%s)', data || '') 469 | 470 | this.emit('dong', { data }) 471 | } 472 | 473 | /** 474 | * 475 | * ContactSelf 476 | * 477 | * 478 | */ 479 | override async contactSelfQRCode (): Promise { 480 | return PUPPET.throwUnsupportedError() 481 | } 482 | 483 | override async contactSelfName (name: string): Promise { 484 | return PUPPET.throwUnsupportedError(name) 485 | } 486 | 487 | override async contactSelfSignature (signature: string): Promise { 488 | return PUPPET.throwUnsupportedError(signature) 489 | } 490 | 491 | /** 492 | * 493 | * Contact 494 | * 495 | */ 496 | override contactAlias (contactId: string) : Promise 497 | override contactAlias (contactId: string, alias: null | string): Promise 498 | 499 | override async contactAlias (contactId: string, alias?: null | string): Promise { 500 | log.verbose('PuppetWechat4u', 'contactAlias(%s, %s)', contactId, alias) 501 | 502 | if (typeof alias === 'undefined') { 503 | const payload = await this.contactPayload(contactId) 504 | return payload.alias 505 | } 506 | 507 | await this.wechat4u.updateRemarkName(contactId, alias) 508 | } 509 | 510 | override async contactList (): Promise { 511 | log.verbose('PuppetWechat4u', 'contactList()') 512 | 513 | const idList = Object.keys(this.wechat4u.contacts) 514 | .filter((contact: any) => !this.wechat4u.Contact.isRoomContact(this.wechat4u.contacts[contact])) 515 | 516 | return idList 517 | } 518 | 519 | // override async contactQrCode (contactId: string): Promise { 520 | // return PUPPET.throwUnsupportedError(contactId) 521 | // } 522 | 523 | override async contactAvatar (contactId: string) : Promise 524 | override async contactAvatar (contactId: string, file: FileBoxInterface) : Promise 525 | 526 | override async contactAvatar (contactId: string, file?: FileBoxInterface): Promise { 527 | log.verbose('PuppetWechat4u', 'contactAvatar(%s)', contactId) 528 | 529 | if (file) { 530 | return PUPPET.throwUnsupportedError() 531 | } 532 | 533 | const rawPayload = await this.contactRawPayload(contactId) 534 | const payload = await this.contactPayload(contactId) 535 | 536 | const name = payload.name 537 | // add '&type=big' to get big image 538 | if (rawPayload.HeadImgUrl) { 539 | const res = await this.wechat4u.getHeadImg(rawPayload.HeadImgUrl + '&type=big') 540 | /** 541 | * 如何获取联系人头像 542 | */ 543 | return FileBox.fromBuffer( 544 | res.data, 545 | `wechaty-contact-avatar-${name}.jpg`, // FIXME 546 | ) 547 | } 548 | 549 | } 550 | 551 | override async contactRawPayload (contactId: string): Promise { 552 | log.verbose('PuppetWechat4u', 'contactRawPayload(%s) with contacts.length=%d', 553 | contactId, 554 | Object.keys(this.wechat4u.contacts).length, 555 | ) 556 | 557 | if (!(contactId in this.wechat4u.contacts)) { 558 | this.unknownContactId.push([ contactId, '' ]) 559 | if (!this.getContactInterval) { 560 | this.getContactsInfo() 561 | this.getContactInterval = setInterval(() => { 562 | this.getContactsInfo() 563 | }, 2000) 564 | } 565 | } 566 | 567 | const rawPayload: WebContactRawPayload = await retry(async (retryException, attempt) => { 568 | log.verbose('PuppetWechat4u', 'contactRawPayload(%s) retry() attempt=%d', contactId, attempt) 569 | 570 | if (contactId in this.wechat4u.contacts) { 571 | return this.wechat4u.contacts[contactId] 572 | } 573 | 574 | retryException(new Error('no this.wechat4u.contacts[' + contactId + ']')) 575 | }) 576 | 577 | return rawPayload 578 | } 579 | 580 | override async contactRawPayloadParser (rawPayload: WebContactRawPayload): Promise { 581 | return wechat4uContactToWechaty(rawPayload) 582 | } 583 | 584 | /** 585 | * 586 | * Message 587 | * 588 | */ 589 | override async messageContact ( 590 | messageId: string, 591 | ): Promise { 592 | log.verbose('PuppetWechat4u', 'messageContact(%s)', messageId) 593 | return PUPPET.throwUnsupportedError() 594 | } 595 | 596 | // web支持撤回消息 https://github.com/nodeWechat/wechat4u/blob/8e20b34507dbe783ada8c769b72ef1792f33c94a/src/core.js#L1219 597 | override async messageRecall ( 598 | messageId: string, 599 | ): Promise { 600 | log.verbose('PuppetWechat4u', 'messageRecall(%s)', messageId) 601 | const rawPayload = await this.messageRawPayload(messageId) 602 | this.wechat4u.revokeMsg(messageId, rawPayload.ToUserName) 603 | return true 604 | } 605 | 606 | override async messageImage ( 607 | messageId: string, 608 | imageType: PUPPET.types.Image, 609 | ) : Promise { 610 | log.verbose('PuppetWechat4u', 'messageImage(%s, %s[%s])', 611 | messageId, 612 | imageType, 613 | PUPPET.types.Image[imageType], 614 | ) 615 | /** 616 | * 图片消息 617 | */ 618 | // console.log('图片消息,保存到本地') 619 | const filename = `${messageId}.jpg` 620 | const msg = await this.wechat4u.getMsgImg(messageId) 621 | const file = FileBox.fromStream( 622 | msg.data, 623 | filename, 624 | ) 625 | 626 | return file 627 | } 628 | 629 | override async messageFile (id: string): Promise { 630 | log.verbose('PuppetWechat4u', 'messageFile(%s)', id) 631 | 632 | const payload = await this.messagePayload(id) 633 | const rawPayload = await this.messageRawPayload(id) 634 | 635 | let filename = payload.filename || 'unknown.txt' 636 | 637 | /** 638 | * 判断消息类型 639 | */ 640 | switch (payload.type) { 641 | case PUPPET.types.Message.Text: 642 | /** 643 | * 文本消息 644 | */ 645 | throw new Error('msg type is text') 646 | 647 | case PUPPET.types.Message.Emoticon: { 648 | /** 649 | * 表情消息 650 | */ 651 | const emotionPayload = await parseEmotionMessagePayload(rawPayload) 652 | const emoticonBox = FileBox.fromUrl(emotionPayload.cdnurl, { name: `message-${id}-emoticon.jpg` }) 653 | 654 | emoticonBox.metadata = { 655 | payload: emotionPayload, 656 | type: 'emoticon', 657 | } 658 | 659 | return emoticonBox 660 | } 661 | // eslint-disable-next-lint no-fallthrough 662 | case PUPPET.types.Message.Image:{ 663 | /** 664 | * 图片消息 665 | */ 666 | // console.log('图片消息,保存到本地') 667 | filename = `${rawPayload.MsgId}.jpg` 668 | const msg = await this.wechat4u.getMsgImg(rawPayload.MsgId) 669 | 670 | const file = FileBox.fromBuffer( 671 | msg.data, 672 | filename, 673 | ) 674 | return file 675 | } 676 | case PUPPET.types.Message.Audio: { 677 | /** 678 | * 语音消息 679 | */ 680 | const audioFileBox = FileBox.fromBuffer( 681 | (await this.wechat4u.getVoice(rawPayload.MsgId)).data, 682 | `message-${id}-audio.sil`, 683 | ) 684 | const voiceLength = rawPayload.VoiceLength 685 | audioFileBox.metadata = { 686 | voiceLength, 687 | } 688 | // console.log('语音消息,保存到本地') 689 | return audioFileBox 690 | } 691 | case PUPPET.types.Message.Video: 692 | /** 693 | * 视频消息 694 | */ 695 | // console.log('视频消息,保存到本地') 696 | return FileBox.fromBuffer( 697 | (await this.wechat4u.getVideo(rawPayload.MsgId)).data, 698 | `message-${id}-video.mp4`, 699 | ) 700 | 701 | case PUPPET.types.Message.Attachment: 702 | if (rawPayload.AppMsgType === 6) { 703 | /** 704 | * 文件消息 705 | */ 706 | // console.log('文件消息,保存到本地') 707 | filename = rawPayload.FileName 708 | return FileBox.fromBuffer( 709 | (await this.wechat4u.getDoc(rawPayload.FromUserName, rawPayload.MediaId, rawPayload.FileName)).data, 710 | filename, 711 | ) 712 | } 713 | break 714 | default: 715 | break 716 | } 717 | 718 | throw new Error('unsupported message. id: ' + id) 719 | } 720 | 721 | override async messageUrl (messageId: string) : Promise { 722 | log.verbose('PuppetWechat4u', 'messageUrl(%s)', messageId) 723 | const rawPayload: WebMessageRawPayload | undefined = this.cacheMessageRawPayload.get(messageId) 724 | if (!rawPayload) { 725 | throw new Error('id not found') 726 | } 727 | const message = await this.messageRawPayloadParser(rawPayload) 728 | if (message.type !== PUPPET.types.Message.Url) { 729 | throw new Error('Can not get url from non url payload') 730 | } 731 | const appPayload = await parseAppmsgMessagePayload(rawPayload.Content) 732 | return { 733 | description: appPayload.des, 734 | thumbnailUrl: appPayload.thumburl, 735 | title: appPayload.title, 736 | url: appPayload.url, 737 | } 738 | } 739 | 740 | override async messageMiniProgram (messageId: string): Promise { 741 | log.verbose('PuppetWechat4u', 'messageMiniProgram(%s)', messageId) 742 | const rawPayload: WebMessageRawPayload | undefined = this.cacheMessageRawPayload.get(messageId) 743 | if (!rawPayload) { 744 | throw new Error('id not found') 745 | } 746 | const message = await this.messageRawPayloadParser(rawPayload) 747 | if (message.type !== PUPPET.types.Message.MiniProgram) { 748 | throw new Error('message is not mini program, can not get MiniProgramPayload') 749 | } 750 | return parseMiniProgramMessagePayload(rawPayload) 751 | } 752 | 753 | override async messageRawPayload (id: string): Promise { 754 | log.verbose('PuppetWechat4u', 'messageRawPayload(%s)', id) 755 | 756 | const rawPayload = this.cacheMessageRawPayload.get(id) 757 | 758 | if (!rawPayload) { 759 | throw new Error('id not found') 760 | } 761 | return rawPayload 762 | } 763 | 764 | override async messageRawPayloadParser ( 765 | rawPayload: WebMessageRawPayload, 766 | ): Promise { 767 | log.verbose('PuppetWechat4u', 'messageRawPayloadParser(%s) @ %s', rawPayload, this) 768 | 769 | // console.log(rawPayload) 770 | const payload = webMessageToWechaty(this, rawPayload) 771 | return payload 772 | } 773 | 774 | override async messageSendText ( 775 | conversationId : string, 776 | text : string, 777 | ): Promise { 778 | log.verbose('PuppetWechat4u', 'messageSend(%s, %s)', conversationId, text) 779 | 780 | /** 781 | * 发送文本消息,可以包含emoji(😒)和QQ表情([坏笑]) 782 | */ 783 | await this.wechat4u.sendMsg(text, conversationId) 784 | /** 785 | * { BaseResponse: { Ret: 0, ErrMsg: '' }, 786 | * MsgID: '830582407297708303', 787 | * LocalID: '15279119663740094' } 788 | */ 789 | } 790 | 791 | override async messageSendFile ( 792 | conversationId : string, 793 | file : FileBox, 794 | ): Promise { 795 | log.verbose('PuppetWechat4u', 'messageSend(%s, %s)', conversationId, file) 796 | 797 | /** 798 | * 通过表情MD5发送表情 799 | */ 800 | // wechat4u.sendMsg({ 801 | // emoticonMd5: '00c801cdf69127550d93ca52c3f853ff' 802 | // }, ToUserName) 803 | // .catch(err => { 804 | // bot.emit('error', err) 805 | // }) 806 | 807 | /** 808 | * 以下通过上传文件发送图片,视频,附件等 809 | * 通用方法为入下 810 | * file为多种类型 811 | * filename必填,主要为了判断文件类型 812 | */ 813 | await this.wechat4u.sendMsg({ 814 | file : await file.toStream(), 815 | filename : file.name, 816 | }, conversationId) 817 | } 818 | 819 | override async messageSendContact ( 820 | conversationId : string, 821 | contactId : string, 822 | ): Promise { 823 | log.verbose('PuppetWechat4u', 'messageSend("%s", %s)', conversationId, contactId) 824 | PUPPET.throwUnsupportedError() 825 | } 826 | 827 | override async messageSendUrl (conversationId: string, urlLinkPayload: PUPPET.payloads.UrlLink) : Promise { 828 | PUPPET.throwUnsupportedError(conversationId, urlLinkPayload) 829 | } 830 | 831 | override async messageSendMiniProgram (conversationId: string, miniProgramPayload: PUPPET.payloads.MiniProgram): Promise { 832 | log.verbose('PuppetWechat4u', 'messageSendMiniProgram("%s", %s)', 833 | JSON.stringify(conversationId), 834 | JSON.stringify(miniProgramPayload), 835 | ) 836 | PUPPET.throwUnsupportedError(conversationId, miniProgramPayload) 837 | } 838 | 839 | override async messageForward ( 840 | conversationid : string, 841 | messageId : string, 842 | ): Promise { 843 | log.verbose('PuppetWechat4u', 'messageForward(%s, %s)', 844 | conversationid, 845 | messageId, 846 | ) 847 | const rawPayload = await this.messageRawPayload(messageId) 848 | 849 | /** 850 | * 如何直接转发消息 851 | */ 852 | await this.wechat4u.forwardMsg(rawPayload, conversationid) 853 | } 854 | 855 | override async conversationReadMark ( 856 | conversationId: string, 857 | hasRead?: boolean, 858 | ) : Promise { 859 | return PUPPET.throwUnsupportedError(conversationId, hasRead) 860 | } 861 | 862 | /** 863 | * 864 | * Room Invitation 865 | * 866 | */ 867 | override async roomInvitationAccept (roomInvitationId: string): Promise { 868 | return PUPPET.throwUnsupportedError(roomInvitationId) 869 | } 870 | 871 | override async roomInvitationRawPayload (roomInvitationId: string): Promise { 872 | return PUPPET.throwUnsupportedError(roomInvitationId) 873 | } 874 | 875 | override async roomInvitationRawPayloadParser (rawPayload: any): Promise { 876 | return PUPPET.throwUnsupportedError(rawPayload) 877 | } 878 | 879 | /** 880 | * 881 | * Room 882 | * 883 | */ 884 | override async roomRawPayload ( 885 | id: string, 886 | ): Promise { 887 | log.verbose('PuppetWechat4u', 'roomRawPayload(%s)', id) 888 | 889 | const rawPayload: WebRoomRawPayload = await retry((retryException, attempt) => { 890 | log.verbose('PuppetWechat4u', 'contactRawPayload(%s) retry() attempt=%d', id, attempt) 891 | 892 | if (!this.wechat4u.contacts[id]) { 893 | retryException(new Error('no this.wechat4u.contacts[' + id + ']')) 894 | } 895 | 896 | return this.wechat4u.contacts[id] 897 | }) 898 | 899 | return rawPayload 900 | } 901 | 902 | override async roomRawPayloadParser ( 903 | rawPayload: WebRoomRawPayload, 904 | ): Promise { 905 | return wechat4uRoomToWechaty(rawPayload) 906 | } 907 | 908 | override async roomList (): Promise { 909 | log.verbose('PuppetWechat4u', 'roomList()') 910 | 911 | const idList = Object.keys(this.wechat4u.contacts) 912 | .filter((contact: any) => this.wechat4u.Contact.isRoomContact(this.wechat4u.contacts[contact])) 913 | 914 | return idList 915 | } 916 | 917 | override async roomDel ( 918 | roomId : string, 919 | contactId : string, 920 | ): Promise { 921 | log.verbose('PuppetWechat4u', 'roomDel(%s, %s)', roomId, contactId) 922 | 923 | const type = 'delmember' 924 | // XXX: [contactId] or [{ UserName: id }, ...] ? 925 | await this.wechat4u.updateChatroom(roomId, [ contactId ], type) 926 | } 927 | 928 | override async roomAvatar (roomId: string): Promise { 929 | log.verbose('PuppetWechat4u', 'roomAvatar(%s)', roomId) 930 | 931 | const payload = await this.roomPayload(roomId) 932 | 933 | if (payload.avatar) { 934 | // FIXME: set http headers with cookies 935 | return FileBox.fromUrl(payload.avatar) 936 | } 937 | log.warn('PuppetWechat4u', 'roomAvatar() avatar not found, use the chatie default.') 938 | return qrCodeForChatie() 939 | } 940 | 941 | override async roomAdd ( 942 | roomId : string, 943 | contactId : string, 944 | ): Promise { 945 | log.verbose('PuppetWechat4u', 'roomAdd(%s, %s)', roomId, contactId) 946 | 947 | const roomPayload = await this.roomPayload(roomId) 948 | 949 | // TODO: if the room owner enabled "invite only?" 950 | let type = 'addmember' // invitemember ??? 951 | if (roomPayload.memberIdList.length > 40) { 952 | type = 'invitemember' 953 | } 954 | 955 | // https://github.com/nodeWechat/wechat4u/tree/46931e78bcb56899b8d2a42a37b919e7feaebbef#botupdatechatroomchatroomusername-memberlist-fun 956 | const ret = await this.wechat4u.updateChatroom(roomId, [ contactId ], type) 957 | log.verbose('PuppetWechat4u', 'roomAdd(%s, %s) ret: %s', roomId, contactId, JSON.stringify(ret)) 958 | } 959 | 960 | override async roomTopic (roomId: string) : Promise 961 | override async roomTopic (roomId: string, topic: string) : Promise 962 | 963 | override async roomTopic ( 964 | roomId: string, 965 | topic?: string, 966 | ): Promise { 967 | log.verbose('PuppetWechat4u', 'roomTopic(%s, %s)', roomId, topic) 968 | 969 | const roomPayload = await this.roomPayload(roomId) 970 | 971 | if (typeof topic === 'undefined') { 972 | return roomPayload.topic 973 | } 974 | 975 | await this.wechat4u.updateChatRoomName(roomId, topic) 976 | } 977 | 978 | override async roomCreate ( 979 | contactIdList : string[], 980 | topic : string, 981 | ): Promise { 982 | log.verbose('PuppetWechat4u', 'roomCreate(%s, %s)', contactIdList, topic) 983 | 984 | const memberList = contactIdList.map(id => ({ UserName: id })) 985 | 986 | const roomId = await this.wechat4u.createChatroom(topic, memberList) 987 | return roomId 988 | } 989 | 990 | override async roomAnnounce (roomId: string) : Promise 991 | override async roomAnnounce (roomId: string, text: string) : Promise 992 | 993 | override async roomAnnounce (roomId: string, text?: string) : Promise { 994 | return PUPPET.throwUnsupportedError(roomId, text) 995 | } 996 | 997 | override async roomQuit (roomId: string): Promise { 998 | return PUPPET.throwUnsupportedError(roomId) 999 | } 1000 | 1001 | override async roomQRCode (roomId: string): Promise { 1002 | return PUPPET.throwUnsupportedError(roomId) 1003 | } 1004 | 1005 | override async roomMemberList (roomId: string) : Promise { 1006 | log.verbose('PuppetWechat4u', 'roommemberList(%s)', roomId) 1007 | const rawPayload = await this.roomRawPayload(roomId) 1008 | 1009 | const memberIdList = (rawPayload.MemberList || []) 1010 | .map(member => member.UserName) 1011 | return memberIdList 1012 | } 1013 | 1014 | override async roomMemberRawPayload (roomId: string, contactId: string): Promise { 1015 | log.verbose('PuppetWechat4u', 'roomMemberRawPayload(%s, %s)', roomId, contactId) 1016 | const rawPayload = await this.roomRawPayload(roomId) 1017 | 1018 | const memberPayloadList = rawPayload.MemberList || [] 1019 | const memberPayloadResult = memberPayloadList.filter(payload => payload.UserName === contactId) 1020 | if (memberPayloadResult.length > 0) { 1021 | return memberPayloadResult[0]! 1022 | } else { 1023 | throw new Error('not found') 1024 | } 1025 | } 1026 | 1027 | override async roomMemberRawPayloadParser (rawPayload: WebRoomRawMember): Promise { 1028 | return wechat4uRoomMemberToWechaty(rawPayload) 1029 | } 1030 | 1031 | /** 1032 | * 1033 | * Friendship 1034 | * 1035 | */ 1036 | override async friendshipSearchPhone ( 1037 | phone: string, 1038 | ): Promise { 1039 | log.verbose('PuppetWechat4u', 'friendshipSearchPhone(%s)', phone) 1040 | return PUPPET.throwUnsupportedError() 1041 | } 1042 | 1043 | override async friendshipSearchWeixin ( 1044 | weixin: string, 1045 | ): Promise { 1046 | log.verbose('PuppetWechat4u', 'friendshipSearchWeixin(%s)', weixin) 1047 | return PUPPET.throwUnsupportedError() 1048 | } 1049 | 1050 | override async friendshipAdd ( 1051 | contactId : string, 1052 | hello : string, 1053 | ): Promise { 1054 | log.verbose('PuppetWechat4u', 'friendshipAdd(%s, %s)', contactId, hello) 1055 | return PUPPET.throwUnsupportedError() 1056 | // await this.wechat4u.addFriend(contactId, hello) 1057 | } 1058 | 1059 | override async friendshipAccept ( 1060 | friendshipId : string, 1061 | ): Promise { 1062 | log.verbose('PuppetWechat4u', 'friendshipAccept(%s)', friendshipId) 1063 | 1064 | const payload = await this.friendshipPayload(friendshipId) as any as PUPPET.payloads.FriendshipReceive 1065 | await this.wechat4u.verifyUser(payload.contactId, payload.ticket) 1066 | } 1067 | 1068 | override async friendshipRawPayload (id: string): Promise { 1069 | log.verbose('PuppetWechat4u', 'friendshipRawPayload(%s)', id) 1070 | 1071 | const rawPayload = this.cacheMessageRawPayload.get(id) 1072 | if (!rawPayload) { 1073 | throw new Error('no rawPayload') 1074 | } 1075 | 1076 | return rawPayload 1077 | } 1078 | 1079 | override async friendshipRawPayloadParser (rawPayload: any) : Promise { 1080 | log.verbose('PuppetWechat4u', 'friendshipRawPayloadParser(%s)', rawPayload) 1081 | 1082 | const timestamp = Math.floor(Date.now() / 1000) // in seconds 1083 | 1084 | switch (rawPayload.MsgType) { 1085 | case WebMessageType.VERIFYMSG: { 1086 | const recommendInfo = rawPayload.RecommendInfo 1087 | if (!recommendInfo) { 1088 | throw new Error('no recommendInfo') 1089 | } 1090 | 1091 | const payloadReceive: PUPPET.payloads.FriendshipReceive = { 1092 | contactId : recommendInfo.UserName, 1093 | hello : recommendInfo.Content, 1094 | id : rawPayload.MsgId, 1095 | ticket : recommendInfo.Ticket, 1096 | timestamp, 1097 | type : PUPPET.types.Friendship.Receive, 1098 | } 1099 | return payloadReceive 1100 | } 1101 | case WebMessageType.SYS: { 1102 | const payloadConfirm: PUPPET.payloads.FriendshipConfirm = { 1103 | contactId : rawPayload.FromUserName, 1104 | id : rawPayload.MsgId, 1105 | timestamp, 1106 | type : PUPPET.types.Friendship.Confirm, 1107 | } 1108 | return payloadConfirm 1109 | } 1110 | default: 1111 | throw new Error('not supported friend request message raw payload') 1112 | } 1113 | } 1114 | 1115 | /** 1116 | * 1117 | * Tag 1118 | * 1119 | */ 1120 | override async tagContactAdd ( 1121 | tagId: string, 1122 | contactId: string, 1123 | ): Promise { 1124 | log.verbose('PuppetWechat4u', 'tagContactAdd(%s)', tagId, contactId) 1125 | } 1126 | 1127 | override async tagContactRemove ( 1128 | tagId: string, 1129 | contactId: string, 1130 | ): Promise { 1131 | log.verbose('PuppetWechat4u', 'tagContactRemove(%s)', tagId, contactId) 1132 | } 1133 | 1134 | override async tagContactDelete ( 1135 | tagId: string, 1136 | ): Promise { 1137 | log.verbose('PuppetWechat4u', 'tagContactDelete(%s)', tagId) 1138 | } 1139 | 1140 | override async tagContactList ( 1141 | contactId?: string, 1142 | ): Promise { 1143 | log.verbose('PuppetWechat4u', 'tagContactList(%s)', contactId) 1144 | return [] 1145 | } 1146 | 1147 | override contactCorporationRemark (..._: any[]) { 1148 | return PUPPET.throwUnsupportedError() 1149 | } 1150 | 1151 | override contactDescription (..._: any[]) { 1152 | return PUPPET.throwUnsupportedError() 1153 | } 1154 | 1155 | override contactPhone (..._: any[]) { 1156 | return PUPPET.throwUnsupportedError() 1157 | } 1158 | 1159 | override async messageLocation (messageId: string): Promise { 1160 | return PUPPET.throwUnsupportedError(messageId) 1161 | } 1162 | 1163 | override async messageSendLocation ( 1164 | conversationId: string, 1165 | locationPayload: PUPPET.payloads.Location, 1166 | ): Promise { 1167 | return PUPPET.throwUnsupportedError(conversationId, locationPayload) 1168 | } 1169 | 1170 | } 1171 | 1172 | export default PuppetWechat4u 1173 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * June 2018, Huan: Learned from https://github.com/krisk/Fuse/pull/129 3 | */ 4 | declare module 'wechat4u' 5 | declare module 'promise-retry' 6 | -------------------------------------------------------------------------------- /src/web-schemas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wechaty - https://github.com/chatie/wechaty 3 | * 4 | * @copyright 2016-2018 Huan LI 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | * 18 | */ 19 | // tslint:disable:max-line-length 20 | 21 | export interface WebContactRawPayload { 22 | Alias: string, 23 | City: string, 24 | NickName: string, 25 | Province: string, 26 | RemarkName: string, 27 | Sex: number, 28 | Signature: string, 29 | StarFriend: string, 30 | Uin: string, 31 | UserName: string, 32 | HeadImgUrl: string, 33 | VerifyFlag: number, 34 | ContactFlag: number, 35 | } 36 | 37 | export interface WebMessageMediaPayload { 38 | ToUserName: string, 39 | MsgType: number, 40 | MediaId: string, 41 | FileName: string, 42 | FileSize: number, 43 | FileMd5?: string, 44 | FileType?: number, 45 | MMFileExt?: string, 46 | Signature?: string, 47 | } 48 | 49 | /** 50 | * 51 | * Enum for AppMsgType values. 52 | * 53 | * @enum {number} 54 | * @property {number} TEXT - AppMsgType.TEXT (1) for TEXT 55 | * @property {number} IMG - AppMsgType.IMG (2) for IMG 56 | * @property {number} AUDIO - AppMsgType.AUDIO (3) for AUDIO 57 | * @property {number} VIDEO - AppMsgType.VIDEO (4) for VIDEO 58 | * @property {number} URL - AppMsgType.URL (5) for URL 59 | * @property {number} ATTACH - AppMsgType.ATTACH (6) for ATTACH 60 | * @property {number} OPEN - AppMsgType.OPEN (7) for OPEN 61 | * @property {number} EMOJI - AppMsgType.EMOJI (8) for EMOJI 62 | * @property {number} VOICE_REMIND - AppMsgType.VOICE_REMIND (9) for VOICE_REMIND 63 | * @property {number} SCAN_GOOD - AppMsgType.SCAN_GOOD (10) for SCAN_GOOD 64 | * @property {number} GOOD - AppMsgType.GOOD (13) for GOOD 65 | * @property {number} EMOTION - AppMsgType.EMOTION (15) for EMOTION 66 | * @property {number} CARD_TICKET - AppMsgType.CARD_TICKET (16) for CARD_TICKET 67 | * @property {number} REALTIME_SHARE_LOCATION - AppMsgType.REALTIME_SHARE_LOCATION (17) for REALTIME_SHARE_LOCATION 68 | * @property {number} TRANSFERS - AppMsgType.TRANSFERS (2e3) for TRANSFERS 69 | * @property {number} RED_ENVELOPES - AppMsgType.RED_ENVELOPES (2001) for RED_ENVELOPES 70 | * @property {number} READER_TYPE - AppMsgType.READER_TYPE (100001) for READER_TYPE 71 | */ 72 | export enum WebAppMsgType { 73 | TEXT = 1, 74 | IMG = 2, 75 | AUDIO = 3, 76 | VIDEO = 4, 77 | URL = 5, 78 | ATTACH = 6, 79 | OPEN = 7, 80 | EMOJI = 8, 81 | VOICE_REMIND = 9, 82 | SCAN_GOOD = 10, 83 | GOOD = 13, 84 | EMOTION = 15, 85 | CARD_TICKET = 16, 86 | REALTIME_SHARE_LOCATION = 17, 87 | // web 协议可以接收到小程序的数据格式,但是无法展示出来,可以用于识别小程序的参数 88 | MINIPROGRAM = 33, 89 | MINIPROGRAMAPP = 36, 90 | TRANSFERS = 2e3, 91 | RED_ENVELOPES = 2001, 92 | READER_TYPE = 100001, 93 | } 94 | 95 | /** 96 | * 97 | * Enum for MsgType values. 98 | * @enum {number} 99 | * @property {number} TEXT - MsgType.TEXT (1) for TEXT 100 | * @property {number} IMAGE - MsgType.IMAGE (3) for IMAGE 101 | * @property {number} VOICE - MsgType.VOICE (34) for VOICE 102 | * @property {number} VERIFYMSG - MsgType.VERIFYMSG (37) for VERIFYMSG 103 | * @property {number} POSSIBLEFRIEND_MSG - MsgType.POSSIBLEFRIEND_MSG (40) for POSSIBLEFRIEND_MSG 104 | * @property {number} SHARECARD - MsgType.SHARECARD (42) for SHARECARD 105 | * @property {number} VIDEO - MsgType.VIDEO (43) for VIDEO 106 | * @property {number} EMOTICON - MsgType.EMOTICON (47) for EMOTICON 107 | * @property {number} LOCATION - MsgType.LOCATION (48) for LOCATION 108 | * @property {number} APP - MsgType.APP (49) for APP 109 | * @property {number} VOIPMSG - MsgType.VOIPMSG (50) for VOIPMSG 110 | * @property {number} STATUSNOTIFY - MsgType.STATUSNOTIFY (51) for STATUSNOTIFY 111 | * @property {number} VOIPNOTIFY - MsgType.VOIPNOTIFY (52) for VOIPNOTIFY 112 | * @property {number} VOIPINVITE - MsgType.VOIPINVITE (53) for VOIPINVITE 113 | * @property {number} MICROVIDEO - MsgType.MICROVIDEO (62) for MICROVIDEO 114 | * @property {number} SYSNOTICE - MsgType.SYSNOTICE (9999) for SYSNOTICE 115 | * @property {number} SYS - MsgType.SYS (10000) for SYS 116 | * @property {number} RECALLED - MsgType.RECALLED (10002) for RECALLED 117 | */ 118 | export enum WebMessageType { 119 | TEXT = 1, 120 | IMAGE = 3, 121 | VOICE = 34, 122 | VERIFYMSG = 37, 123 | POSSIBLEFRIEND_MSG = 40, 124 | SHARECARD = 42, 125 | VIDEO = 43, 126 | EMOTICON = 47, 127 | LOCATION = 48, 128 | APP = 49, 129 | VOIPMSG = 50, 130 | STATUSNOTIFY = 51, 131 | VOIPNOTIFY = 52, 132 | VOIPINVITE = 53, 133 | MICROVIDEO = 62, 134 | SYSNOTICE = 9999, 135 | SYS = 10000, 136 | RECALLED = 10002, 137 | } 138 | 139 | /** 140 | * from Message 141 | */ 142 | export interface WebRecomendInfo { 143 | UserName : string, 144 | NickName : string, // display_name 145 | Content : string, // request message 146 | HeadImgUrl : string, // message.RecommendInfo.HeadImgUrl 147 | 148 | Ticket : string, // a pass token 149 | VerifyFlag : number, 150 | } 151 | 152 | export const enum WebMediaType { 153 | Image = 1, 154 | Video = 2, 155 | Audio = 3, 156 | Attachment = 4, 157 | } 158 | 159 | export interface WebMessageRawPayload { 160 | MsgId: string, 161 | 162 | MMActualSender: string, // getUserContact(message.MMActualSender,message.MMPeerUserName).isContact() 163 | MMPeerUserName: string, // message.MsgType == CONF.MSGTYPE_TEXT && message.MMPeerUserName == 'newsapp' 164 | ToUserName: string, 165 | FromUserName: string, 166 | MMActualContent: string, // Content has @id prefix added by wx 167 | Content: string, 168 | 169 | MMDigest: string, 170 | MMDisplayTime: number, // Javascript timestamp of milliseconds 171 | CreateTime: number, 172 | 173 | /** 174 | * MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL 175 | * class="cover" mm-src="{{getMsgImg(message.MsgId,'slave')}}" 176 | */ 177 | Url: string, 178 | MMAppMsgDesc: string, // class="desc" ng-bind="message.MMAppMsgDesc" 179 | 180 | /** 181 | * Attachment 182 | * 183 | * MsgType == MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_ATTACH 184 | */ 185 | FileName: string, // FileName: '钢甲互联项目BP1108.pdf', 186 | FileSize: number, // FileSize: '2845701', 187 | MediaId: string, // MediaId: '@crypt_b1a45e3f_c21dceb3ac01349... 188 | MMFileExt: string, // doc, docx ... 'undefined'? 189 | Signature: string, // checkUpload return the signature used to upload large files 190 | 191 | MMAppMsgFileExt: string, // doc, docx ... 'undefined'? 192 | MMAppMsgFileSize: string, // '2.7MB', 193 | MMAppMsgDownloadUrl: string, // 'https://file.wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetmedia?... 194 | // 下载 197 | MMUploadProgress: number, // < 100 198 | 199 | /** 200 | * 模板消息 201 | * MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_READER_TYPE 202 | * item.url 203 | * item.title 204 | * item.pub_time 205 | * item.cover 206 | * item.digest 207 | */ 208 | MMCategory: any[], // item in message.MMCategory 209 | 210 | /** 211 | * Type 212 | * 213 | * MsgType == CONF.MSGTYPE_VOICE : ng-style="{'width':40 + 7*message.VoiceLength/1000} 214 | */ 215 | MsgType: number, 216 | AppMsgType: WebAppMsgType, // message.MsgType == CONF.MSGTYPE_APP && message.AppMsgType == CONF.APPMSGTYPE_URL 217 | // message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType != CONF.MSGTYPE_LOCATION 218 | 219 | SubMsgType: WebMessageType, // "msgType":"{{message.MsgType}}", 220 | // "subType":{{message.SubMsgType||0}},"msgId":"{{message.MsgId}}" 221 | VoiceLength: number, 222 | /** 223 | * Status-es 224 | */ 225 | Status: string, 226 | MMStatus: number, // img ng-show="message.MMStatus == 1" class="ico_loading" 227 | // ng-click="resendMsg(message)" ng-show="message.MMStatus == 5" title="重新发送" 228 | MMFileStatus: number, //

229 | // CONF.MM_SEND_FILE_STATUS_QUEUED, MM_SEND_FILE_STATUS_SENDING 230 | 231 | /** 232 | * Location 233 | */ 234 | MMLocationUrl: string, // ng-if="message.MsgType == CONF.MSGTYPE_TEXT && message.SubMsgType == CONF.MSGTYPE_LOCATION" 235 | // 236 | // 'http://apis.map.qq.com/uri/v1/geocoder?coord=40.075041,116.338994' 237 | MMLocationDesc: string, // MMLocationDesc: '北京市昌平区回龙观龙腾苑(五区)内(龙腾街南)', 238 | 239 | /** 240 | * MsgType == CONF.MSGTYPE_EMOTICON 241 | * 242 | * getMsgImg(message.MsgId,'big',message) 243 | */ 244 | 245 | /** 246 | * Image 247 | * 248 | * getMsgImg(message.MsgId,'slave') 249 | */ 250 | MMImgStyle: string, // ng-style="message.MMImgStyle" 251 | MMPreviewSrc: string, // message.MMPreviewSrc || message.MMThumbSrc || getMsgImg(message.MsgId,'slave') 252 | MMThumbSrc: string, 253 | 254 | /** 255 | * Friend Request & ShareCard ? 256 | * 257 | * MsgType == CONF.MSGTYPE_SHARECARD" ng-click="showProfile($event,message.RecommendInfo.UserName) 258 | * MsgType == CONF.MSGTYPE_VERIFYMSG 259 | */ 260 | RecommendInfo? : WebRecomendInfo, 261 | 262 | /** 263 | * Transpond Message 264 | */ 265 | MsgIdBeforeTranspond? : string, // oldMsg.MsgIdBeforeTranspond || oldMsg.MsgId, 266 | isTranspond? : boolean, 267 | MMSourceMsgId? : string, 268 | MMSendContent? : string, 269 | 270 | MMIsChatRoom? : boolean, 271 | 272 | OriginalContent: string 273 | } 274 | 275 | // export type MessageTypeName = 'TEXT' | 'IMAGE' | 'VOICE' | 'VERIFYMSG' | 'POSSIBLEFRIEND_MSG' 276 | // | 'SHARECARD' | 'VIDEO' | 'EMOTICON' | 'LOCATION' | 'APP' | 'VOIPMSG' | 'STATUSNOTIFY' 277 | // | 'VOIPNOTIFY' | 'VOIPINVITE' | 'MICROVIDEO' | 'SYSNOTICE' | 'SYS' | 'RECALLED' 278 | 279 | // export type MessageTypeValue = 1 | 3 | 34 | 37 | 40 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 9999 | 10000 | 10002 280 | 281 | // export interface WebMsgTypeDict { 282 | // [index: string]: string|number, 283 | // // MessageTypeName: MessageTypeValue 284 | // // , MessageTypeValue: MessageTypeName 285 | // } 286 | 287 | export interface WebRoomRawMember { 288 | UserName : string, 289 | NickName : string, 290 | DisplayName : string, 291 | HeadImgUrl : string, 292 | } 293 | 294 | export interface WebRoomRawPayload { 295 | UserName: string, 296 | EncryChatRoomId: string, 297 | NickName: string, 298 | OwnerUin: number, 299 | ChatRoomOwner: string, 300 | HeadImgUrl?: string, 301 | MemberList?: WebRoomRawMember[], 302 | } 303 | -------------------------------------------------------------------------------- /src/wechat4u/events/event-friendship.ts: -------------------------------------------------------------------------------- 1 | import { WebMessageRawPayload, WebMessageType } from '../../web-schemas.js' 2 | import * as PUPPET from 'wechaty-puppet' 3 | import { isContactId } from '../utils/is-type.js' 4 | import { xmlToJson } from '../utils/xml-to-json.js' 5 | import type { EventPayload } from './event.js' 6 | 7 | const FRIENDSHIP_CONFIRM_REGEX_LIST = [ 8 | /^You have added (.+) as your WeChat contact. Start chatting!$/, 9 | /^你已添加了(.+),现在可以开始聊天了。$/, 10 | /I've accepted your friend request. Now let's chat!$/, 11 | /^(.+) just added you to his\/her contacts list. Send a message to him\/her now!$/, 12 | /^(.+)刚刚把你添加到通讯录,现在可以开始聊天了。$/, 13 | /^我通过了你的朋友验证请求,现在我们可以开始聊天了$/, 14 | ] 15 | 16 | const FRIENDSHIP_VERIFY_REGEX_LIST = [ 17 | /^(.+) has enabled Friend Confirmation/, 18 | /^(.+)开启了朋友验证,你还不是他(她)朋友。请先发送朋友验证请求,对方验证通过后,才能聊天。/, 19 | ] 20 | 21 | interface ReceiveXmlSchema { 22 | msg: { 23 | $: { 24 | fromusername: string; 25 | encryptusername: string; 26 | content: string; 27 | scene: string; 28 | ticket: string; 29 | sourcenickname?: string; 30 | sourceusername?: string; 31 | sharecardnickname?: string; 32 | sharecardusername?: string; 33 | }; 34 | }; 35 | } 36 | 37 | const isConfirm = (message: WebMessageRawPayload): boolean => { 38 | return FRIENDSHIP_CONFIRM_REGEX_LIST.some((regexp) => { 39 | return !!message.Content.match(regexp) 40 | }) 41 | } 42 | 43 | const isNeedVerify = (message: WebMessageRawPayload): boolean => { 44 | return FRIENDSHIP_VERIFY_REGEX_LIST.some((regexp) => { 45 | return !!message.Content.match(regexp) 46 | }) 47 | } 48 | 49 | const isReceive = async (message: WebMessageRawPayload): Promise => { 50 | if (message.MsgType !== WebMessageType.VERIFYMSG) { 51 | return null 52 | } 53 | 54 | try { 55 | const verifyXml: ReceiveXmlSchema = await xmlToJson(message.Content) 56 | const contactId = verifyXml.msg.$.fromusername 57 | if (isContactId(contactId) && verifyXml.msg.$.encryptusername) { 58 | return verifyXml 59 | } 60 | } catch (e) { 61 | // not receive event 62 | } 63 | 64 | return null 65 | } 66 | 67 | export default async (_puppet: PUPPET.Puppet, message: WebMessageRawPayload): Promise => { 68 | if (isConfirm(message)) { 69 | return { 70 | contactId: message.FromUserName, 71 | id: message.MsgId, 72 | timestamp: message.CreateTime, 73 | type: PUPPET.types.Friendship.Confirm, 74 | } as PUPPET.payloads.FriendshipConfirm 75 | } else if (isNeedVerify(message)) { 76 | return { 77 | contactId: message.FromUserName, 78 | id: message.MsgId, 79 | timestamp: message.CreateTime, 80 | type: PUPPET.types.Friendship.Verify, 81 | } as PUPPET.payloads.FriendshipVerify 82 | } else { 83 | const verifyXml = await isReceive(message) 84 | if (verifyXml) { 85 | return { 86 | contactId: verifyXml.msg.$.fromusername, 87 | hello: verifyXml.msg.$.content, 88 | id: message.MsgId, 89 | scene: parseInt(verifyXml.msg.$.scene, 10), 90 | shareCardContactId: verifyXml.msg.$.sharecardusername, 91 | shareCardNickName: verifyXml.msg.$.sharecardnickname, 92 | sourceContactId: verifyXml.msg.$.sourceusername, 93 | sourceNickName: verifyXml.msg.$.sourcenickname, 94 | stranger: verifyXml.msg.$.encryptusername, 95 | ticket: verifyXml.msg.$.ticket, 96 | timestamp: message.CreateTime, 97 | type: PUPPET.types.Friendship.Receive, 98 | } as PUPPET.payloads.FriendshipReceive 99 | } 100 | 101 | return null 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/wechat4u/events/event-message.ts: -------------------------------------------------------------------------------- 1 | import type * as PUPPET from 'wechaty-puppet' 2 | import type { WebMessageRawPayload } from '../../web-schemas.js' 3 | import type { EventPayload } from './event.js' 4 | 5 | export default async (_puppet: PUPPET.Puppet, message: WebMessageRawPayload): Promise => { 6 | return message 7 | } 8 | -------------------------------------------------------------------------------- /src/wechat4u/events/event-room-invite.ts: -------------------------------------------------------------------------------- 1 | import { parseAppmsgMessagePayload, AppMessagePayload, AppMessageType } from '../messages/message-appmsg.js' 2 | import type * as PUPPET from 'wechaty-puppet' 3 | import type { WebMessageRawPayload } from '../../web-schemas.js' 4 | import { isRoomId } from '../utils/is-type.js' 5 | import type { EventPayload } from './event.js' 6 | 7 | const ROOM_OTHER_INVITE_TITLE_ZH = [ /邀请你加入群聊/ ] 8 | const ROOM_OTHER_INVITE_TITLE_EN = [ /Group Chat Invitation/ ] 9 | const ROOM_OTHER_INVITE_LIST_ZH = [ /^"(.+)"邀请你加入群聊(.*),进入可查看详情。/ ] 10 | const ROOM_OTHER_INVITE_LIST_EN = [ /"(.+)" invited you to join the group chat "(.+)"\. Enter to view details\./ ] 11 | 12 | export default async (_puppet: PUPPET.Puppet, message: WebMessageRawPayload): Promise => { 13 | let appMsgPayload: AppMessagePayload 14 | try { 15 | appMsgPayload = await parseAppmsgMessagePayload(message.Content) 16 | } catch (e) { 17 | return null 18 | } 19 | 20 | if (appMsgPayload.type !== AppMessageType.Url) { 21 | return null 22 | } 23 | 24 | if (!appMsgPayload.title || !appMsgPayload.des) { 25 | return null 26 | } 27 | 28 | let matchesForOtherInviteTitleEn = null as null | string[] 29 | let matchesForOtherInviteTitleZh = null as null | string[] 30 | let matchesForOtherInviteEn = null as null | string[] 31 | let matchesForOtherInviteZh = null as null | string[] 32 | 33 | ROOM_OTHER_INVITE_TITLE_EN.some((regex) => !!(matchesForOtherInviteTitleEn = appMsgPayload.title.match(regex))) 34 | ROOM_OTHER_INVITE_TITLE_ZH.some((regex) => !!(matchesForOtherInviteTitleZh = appMsgPayload.title.match(regex))) 35 | ROOM_OTHER_INVITE_LIST_EN.some((regex) => !!(matchesForOtherInviteEn = appMsgPayload.des!.match(regex))) 36 | ROOM_OTHER_INVITE_LIST_ZH.some((regex) => !!(matchesForOtherInviteZh = appMsgPayload.des!.match(regex))) 37 | 38 | const titleMatch = matchesForOtherInviteTitleEn || matchesForOtherInviteTitleZh 39 | const matchInviteEvent = matchesForOtherInviteEn || matchesForOtherInviteZh 40 | const matches = !!titleMatch && !!matchInviteEvent 41 | 42 | if (!matches) { 43 | return null 44 | } 45 | 46 | let receiverId = '' 47 | // 如果不是群,则接收人是机器人 是群的话,接收人为群 48 | if (!isRoomId(message.FromUserName)) { 49 | receiverId = _puppet.currentUserId 50 | } else { 51 | receiverId = message.FromUserName 52 | } 53 | 54 | return { 55 | avatar: appMsgPayload.thumburl, 56 | id: message.MsgId, 57 | invitation: appMsgPayload.url, 58 | inviterId: message.FromUserName, 59 | memberCount: 0, 60 | memberIdList: [], 61 | receiverId, 62 | timestamp: message.CreateTime, 63 | topic: matchInviteEvent![2], 64 | } as PUPPET.payloads.RoomInvitation 65 | } 66 | -------------------------------------------------------------------------------- /src/wechat4u/events/event-room-join.ts: -------------------------------------------------------------------------------- 1 | import type { WebMessageRawPayload } from '../../web-schemas.js' 2 | 3 | import type * as PUPPET from 'wechaty-puppet' 4 | import { isRoomId } from '../utils/is-type.js' 5 | import type { EventPayload } from './event.js' 6 | import { removeRoomLeaveDebounce } from './event-room-leave.js' 7 | import { executeRunners } from '../utils/runner.js' 8 | import { WebMessageType } from '../../web-schemas.js' 9 | 10 | const YOU_INVITE_OTHER_REGEX_LIST = [ 11 | /^你邀请"(.+)"加入了群聊 {2}/, 12 | /^You invited (.+) to the group chat/, 13 | ] 14 | const OTHER_INVITE_YOU_REGEX_LIST = [ 15 | /^"([^"]+?)"邀请你加入了群聊,群聊参与人还有:(.+)/, 16 | /^(.+) invited you to a group chat with (.+)/, 17 | ] 18 | const OTHER_INVITE_YOU_AND_OTHER_REGEX_LIST = [ 19 | /^"([^"]+?)"邀请你和"(.+?)"加入了群聊/, 20 | /^(.+?) invited you and (.+?) to (the|a) group chat/, 21 | ] 22 | const OTHER_INVITE_OTHER_REGEX_LIST = [ 23 | /^"(.+)"邀请"(.+)"加入了群聊/, 24 | /^(.+?) invited (.+?) to (the|a) group chat/, 25 | ] 26 | const OTHER_JOIN_VIA_YOUR_QRCODE_REGEX_LIST = [ 27 | /^" ?(.+)"通过扫描你分享的二维码加入群聊/, 28 | /^" ?(.+)" joined group chat via the QR code you shared/, 29 | ] 30 | const OTHER_JOIN_VIA_OTHER_QRCODE_REGEX_LIST = [ 31 | /^" (.+)"通过扫描"(.+)"分享的二维码加入群聊/, 32 | /^"(.+)" joined the group chat via the QR Code shared by "(.+)"/, 33 | ] 34 | 35 | export default async (puppet: PUPPET.Puppet, message: WebMessageRawPayload): Promise => { 36 | const roomId = message.FromUserName 37 | if (!isRoomId(roomId)) { 38 | return null 39 | } 40 | 41 | const timestamp = message.CreateTime 42 | 43 | if (![ WebMessageType.SYS ].includes(message.MsgType)) { 44 | return null 45 | } 46 | 47 | /** 48 | * 1. You Invite Other to join the Room 49 | * (including other join var qr code you shared) 50 | * /^你邀请"(.+)"加入了群聊 {2}\$revoke\$/, 51 | * /^" ?(.+)"通过扫描你分享的二维码加入群聊/, 52 | */ 53 | const youInviteOther = async () => { 54 | let matches: null | string[] = null; 55 | [ ...YOU_INVITE_OTHER_REGEX_LIST, ...OTHER_JOIN_VIA_YOUR_QRCODE_REGEX_LIST ].some((re) => !!(matches = message.Content.match(re))) 56 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 57 | if (matches) { 58 | const inviteName = matches[1]! 59 | const inviteeId = (await puppet.roomMemberSearch(roomId, inviteName))[0]! 60 | 61 | return { 62 | inviteeIdList: [ inviteeId ], 63 | inviterId: puppet.currentUserId, 64 | roomId, 65 | timestamp, 66 | } as PUPPET.payloads.EventRoomJoin 67 | } 68 | return null 69 | } 70 | 71 | /** 72 | * 2. Other Invite you to join the Room 73 | * /^"([^"]+?)"邀请你加入了群聊/, 74 | */ 75 | const otherInviteYou = async () => { 76 | let matches: null | string[] = null 77 | OTHER_INVITE_YOU_REGEX_LIST.some((re) => !!(matches = message.Content.match(re))) 78 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 79 | if (matches) { 80 | const inviteName = matches[1]! 81 | const inviterId = (await puppet.roomMemberSearch(roomId, inviteName))[0]! 82 | 83 | return { 84 | inviteeIdList: [ puppet.currentUserId ], 85 | inviterId, 86 | roomId, 87 | timestamp, 88 | } as PUPPET.payloads.EventRoomJoin 89 | } 90 | return null 91 | } 92 | 93 | /** 94 | * 3. Other invite you and others to join the room 95 | * /^"([^"]+?)"邀请你和"(.+?)"加入了群聊/, 96 | * /^"(.+)"邀请"(.+)"加入了群聊/, 97 | */ 98 | const otherInviteOther = async () => { 99 | let matches: null | string[] = null; 100 | [ ...OTHER_INVITE_YOU_AND_OTHER_REGEX_LIST, ...OTHER_INVITE_OTHER_REGEX_LIST ].some((re) => !!(matches = message.Content.match(re))) 101 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 102 | if (matches) { 103 | const inviteeIdList = [] 104 | const inviterName = matches[1] 105 | const inviterId = (await puppet.roomMemberSearch(roomId, inviterName))[0] 106 | const inviteeName = matches[2] 107 | const inviteeId = (await puppet.roomMemberSearch(roomId, inviteeName))[0] 108 | // 如果包含ni则把机器人的id放进去 109 | if (message.Content.includes('你')) { 110 | inviteeIdList.push(puppet.currentUserId) 111 | } 112 | inviteeIdList.push(inviteeId) 113 | return { 114 | inviteeIdList, 115 | inviterId, 116 | roomId, 117 | timestamp, 118 | } as PUPPET.payloads.EventRoomJoin 119 | } 120 | return null 121 | } 122 | 123 | /** 124 | * 4. Other Invite Other via Qrcode to join a Room 125 | * /^" (.+)"通过扫描"(.+)"分享的二维码加入群聊/, 126 | */ 127 | const otherJoinViaQrCode = async () => { 128 | let matches: null | string[] = null 129 | OTHER_JOIN_VIA_OTHER_QRCODE_REGEX_LIST.some((re) => !!(matches = message.Content.match(re))) 130 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 131 | if (matches) { 132 | const inviteeIdList = [] 133 | 134 | const inviteeName = matches[1]! 135 | const inviteeId = (await puppet.roomMemberSearch(roomId, inviteeName))[0]! 136 | inviteeIdList.push(inviteeId) 137 | 138 | const inviterName = matches[2] 139 | const inviterId = (await puppet.roomMemberSearch(roomId, inviterName))[0]! 140 | return { 141 | inviteeIdList, 142 | inviterId, 143 | roomId, 144 | timestamp, 145 | } as PUPPET.payloads.EventRoomJoin 146 | } 147 | return null 148 | } 149 | 150 | const ret = await executeRunners([ youInviteOther, otherInviteYou, otherInviteOther, otherJoinViaQrCode ]) 151 | if (ret) { 152 | ret.inviteeIdList.forEach((inviteeId) => { 153 | removeRoomLeaveDebounce(ret!.roomId, inviteeId) 154 | }) 155 | } 156 | return ret 157 | } 158 | -------------------------------------------------------------------------------- /src/wechat4u/events/event-room-leave.ts: -------------------------------------------------------------------------------- 1 | import { WebMessageRawPayload, WebMessageType } from '../../web-schemas.js' 2 | 3 | import type * as PUPPET from 'wechaty-puppet' 4 | import { isRoomId } from '../utils/is-type.js' 5 | import type { EventPayload } from './event.js' 6 | import { executeRunners } from '../utils/runner.js' 7 | 8 | const YOU_REMOVE_OTHER_REGEX_LIST = [ 9 | /^(你)将"(.+)"移出了群聊/, 10 | /^(You) removed "(.+)" from the group chat/, 11 | ] 12 | const OTHER_REMOVE_YOU_REGEX_LIST = [ 13 | /^(你)被"([^"]+?)"移出群聊/, 14 | /^(You) were removed from the group chat by "([^"]+)"/, 15 | ] 16 | 17 | const roomLeaveDebounceMap: Map> = new Map() 18 | const DEBOUNCE_TIMEOUT = 3600 * 1000 // 1 hour 19 | 20 | function roomLeaveDebounceKey (roomId: string, removeeId: string) { 21 | return `${roomId}:${removeeId}` 22 | } 23 | 24 | function roomLeaveAddDebounce (roomId: string, removeeId: string) { 25 | const key = roomLeaveDebounceKey(roomId, removeeId) 26 | const oldTimeout = roomLeaveDebounceMap.get(key) 27 | if (oldTimeout) { 28 | clearTimeout(oldTimeout) 29 | } 30 | 31 | const timeout = setTimeout(() => { 32 | roomLeaveDebounceMap.delete(key) 33 | }, DEBOUNCE_TIMEOUT) 34 | roomLeaveDebounceMap.set(key, timeout) 35 | } 36 | 37 | // to fix: https://github.com/padlocal/wechaty-puppet-padlocal/issues/43 38 | export function removeRoomLeaveDebounce (roomId: string, removeeId: string) { 39 | const key = roomLeaveDebounceKey(roomId, removeeId) 40 | roomLeaveDebounceMap.delete(key) 41 | } 42 | 43 | export function isRoomLeaveDebouncing (roomId: string, removeeId: string): boolean { 44 | const key = roomLeaveDebounceKey(roomId, removeeId) 45 | return roomLeaveDebounceMap.get(key) !== undefined 46 | } 47 | 48 | export default async (puppet: PUPPET.Puppet, message: WebMessageRawPayload): Promise => { 49 | const roomId = message.FromUserName 50 | if (!isRoomId(roomId) || ![ WebMessageType.SYS ].includes(message.MsgType)) { 51 | return null 52 | } 53 | 54 | /** 55 | * 1. 我将别人移除 56 | * /^(你)将"(.+)"移出了群聊/, 57 | * 我移除别人是 10002: https://gist.github.com/padlocal/5676b96ad0ca918fdd53849417eff422 58 | */ 59 | const youRemoveOther = async () => { 60 | let matches: null | string[] = null 61 | YOU_REMOVE_OTHER_REGEX_LIST.some((re) => !!(matches = message.Content.match(re))) 62 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 63 | if (matches) { 64 | const removerName = matches[2]! 65 | const removerId = (await puppet.roomMemberSearch(roomId, removerName))[0]! 66 | 67 | return { 68 | removeeIdList: [ removerId ], 69 | removerId: puppet.currentUserId, 70 | roomId, 71 | timestamp: message.CreateTime, 72 | } as PUPPET.payloads.EventRoomLeave 73 | } 74 | return null 75 | } 76 | 77 | /** 78 | * 2. 别人移除我 79 | * /^(你)被"([^"]+?)"移出群聊/, 80 | * // 我被别人移除是 10000:https://gist.github.com/padlocal/60be89334d4d743937f07023da20291e 81 | */ 82 | const otherRemoveYou = async () => { 83 | let matches: null | string[] = null 84 | OTHER_REMOVE_YOU_REGEX_LIST.some((re) => !!(matches = message.Content.match(re))) 85 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 86 | if (matches) { 87 | const removerName = matches[2]! 88 | const removerId = (await puppet.roomMemberSearch(roomId, removerName))[0]! 89 | 90 | return { 91 | removeeIdList: [ puppet.currentUserId ], 92 | removerId, 93 | roomId, 94 | timestamp: message.CreateTime, 95 | } as PUPPET.payloads.EventRoomLeave 96 | } 97 | 98 | return null 99 | } 100 | 101 | const ret = await executeRunners([ youRemoveOther, otherRemoveYou ]) 102 | if (ret) { 103 | ret.removeeIdList.forEach((leaverId) => { 104 | roomLeaveAddDebounce(roomId, leaverId) 105 | }) 106 | } 107 | return ret 108 | } 109 | -------------------------------------------------------------------------------- /src/wechat4u/events/event-room-topic.ts: -------------------------------------------------------------------------------- 1 | import { WebMessageRawPayload, WebMessageType } from '../../web-schemas.js' 2 | import type * as PUPPET from 'wechaty-puppet' 3 | import { isRoomId } from '../utils/is-type.js' 4 | import type { EventPayload } from './event.js' 5 | import { parseTextWithRegexList } from '../utils/regex.js' 6 | import { executeRunners } from '../utils/runner.js' 7 | 8 | const OTHER_CHANGE_TOPIC_REGEX_LIST = [ 9 | /^"(.+)"修改群名为“(.+)”$/, 10 | /^"(.+)" changed the group name to "(.+)"$/, 11 | ] 12 | const YOU_CHANGE_TOPIC_REGEX_LIST = [ 13 | /^(你)修改群名为“(.+)”$/, 14 | /^(You) changed the group name to "(.+)"$/, 15 | ] 16 | 17 | type TopicChange = {changerId: string, newTopic: string}; 18 | 19 | export default async (puppet: PUPPET.Puppet, message: WebMessageRawPayload): Promise => { 20 | const roomId = message.FromUserName 21 | if (!isRoomId(roomId)) { 22 | return null 23 | } 24 | 25 | /** 26 | * 1. Message payload "you change the room topic" is plain text with type 10000 : https://gist.github.com/padlocal/0c7bb4f5d51e7e94a0efa108bebb4645 27 | */ 28 | const youChangeTopic = async () => { 29 | if (message.MsgType !== WebMessageType.SYS) { 30 | return null 31 | } 32 | 33 | return parseTextWithRegexList(message.Content, YOU_CHANGE_TOPIC_REGEX_LIST, async (_, match) => { 34 | const newTopic = match[2] 35 | 36 | return { 37 | changerId: puppet.currentUserId, 38 | newTopic, 39 | } as TopicChange 40 | }) 41 | } 42 | 43 | /** 44 | * 2. Message payload "others change room topic" is xml text with type 10002: https://gist.github.com/padlocal/3480ada677839c8c11578d47e820e893 45 | */ 46 | const otherChangeTopic = async () => { 47 | return parseTextWithRegexList(message.Content, OTHER_CHANGE_TOPIC_REGEX_LIST, async (_, match) => { 48 | const newTopic = match[2] 49 | const changeName = match[1] 50 | let changeId = '' 51 | if (changeName) { 52 | changeId = (await puppet.roomMemberSearch(roomId, changeName))[0]! 53 | } 54 | return { 55 | changerId: changeId, 56 | newTopic, 57 | } as TopicChange 58 | }) 59 | } 60 | 61 | const topicChange = await executeRunners([ youChangeTopic, otherChangeTopic ]) 62 | if (topicChange) { 63 | const room = await puppet.roomPayload(roomId) 64 | const oldTopic = room.topic 65 | 66 | return { 67 | changerId: topicChange.changerId, 68 | newTopic: topicChange.newTopic, 69 | oldTopic, 70 | roomId, 71 | timestamp: message.CreateTime, 72 | } as PUPPET.payloads.EventRoomTopic 73 | } 74 | 75 | return null 76 | } 77 | -------------------------------------------------------------------------------- /src/wechat4u/events/event.ts: -------------------------------------------------------------------------------- 1 | import { Puppet, log } from 'wechaty-puppet' 2 | import type * as PUPPET from 'wechaty-puppet' 3 | import type { WebMessageRawPayload } from '../../web-schemas.js' 4 | 5 | export enum EventType { 6 | Message, 7 | Friendship, 8 | RoomInvite, 9 | RoomJoin, 10 | RoomLeave, 11 | RoomTopic, 12 | } 13 | 14 | export interface EventPayloadSpec { 15 | [EventType.Message]: WebMessageRawPayload; 16 | [EventType.Friendship]: PUPPET.payloads.Friendship; 17 | [EventType.RoomInvite]: PUPPET.payloads.RoomInvitation; 18 | [EventType.RoomJoin]: PUPPET.payloads.EventRoomJoin; 19 | [EventType.RoomLeave]: PUPPET.payloads.EventRoomLeave; 20 | [EventType.RoomTopic]: PUPPET.payloads.EventRoomTopic; 21 | } 22 | 23 | export interface Event { 24 | type: T; 25 | payload: EventPayloadSpec[T]; 26 | } 27 | 28 | export type EventPayload = EventPayloadSpec[keyof EventPayloadSpec] | null; 29 | export type EventParserHandler = (puppet: Puppet, message: WebMessageRawPayload) => Promise; 30 | type EventParser = { type: EventType, handler: EventParserHandler, }; 31 | 32 | const EventParserList: Array = [] 33 | export function addEventParser (eventType: EventType, parser: EventParserHandler): void { 34 | EventParserList.push({ 35 | handler: parser, 36 | type: eventType, 37 | }) 38 | } 39 | 40 | export async function parseEvent (puppet: Puppet, message: WebMessageRawPayload): Promise> { 41 | for (const parser of EventParserList) { 42 | try { 43 | const parsedPayload = await parser.handler(puppet, message) 44 | if (parsedPayload) { 45 | return { 46 | payload: parsedPayload, 47 | type: parser.type, 48 | } 49 | } 50 | } catch (e) { 51 | log.error('[Event]', `parse message error: ${(e as Error).stack}`) 52 | } 53 | } 54 | 55 | // return normal as message bvy default 56 | return { 57 | payload: message, 58 | type: EventType.Message, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/wechat4u/events/mod.ts: -------------------------------------------------------------------------------- 1 | import friendShipParser from './event-friendship.js' 2 | import roomInviteParser from './event-room-invite.js' 3 | import roomJoinParser from './event-room-join.js' 4 | import roomLeaveParser from './event-room-leave.js' 5 | import roomTopicParser from './event-room-topic.js' 6 | import messageParser from './event-message.js' 7 | import { addEventParser, EventType, parseEvent } from './event.js' 8 | 9 | addEventParser(EventType.Friendship, friendShipParser) 10 | addEventParser(EventType.RoomInvite, roomInviteParser) 11 | addEventParser(EventType.RoomJoin, roomJoinParser) 12 | addEventParser(EventType.RoomLeave, roomLeaveParser) 13 | addEventParser(EventType.RoomTopic, roomTopicParser) 14 | addEventParser(EventType.Message, messageParser) 15 | 16 | export { parseEvent, EventType } 17 | -------------------------------------------------------------------------------- /src/wechat4u/messages/message-appmsg.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */ 2 | import { xmlToJson } from '../utils/xml-to-json.js' 3 | 4 | interface AppMsgXmlSchema { 5 | msg: { 6 | appmsg: { 7 | title: string; 8 | des: string; 9 | type: string; 10 | url: string; 11 | appattach: { 12 | totallen: string; 13 | attachid: string; 14 | emoticonmd5: string; 15 | fileext: string; 16 | cdnattachurl: string; 17 | cdnthumbaeskey: string; 18 | aeskey: string; 19 | encryver: string; 20 | islargefilemsg: string; 21 | }; 22 | thumburl: string; 23 | md5: any; 24 | recorditem?: string; 25 | weappinfo?: { 26 | username: string; 27 | appid: string; 28 | pagepath: string; 29 | weappiconurl: string; 30 | shareId: string; 31 | }; 32 | refermsg?: { 33 | type: string; 34 | svrid: string; 35 | fromusr: string; 36 | chatusr: string; 37 | displayname: string; 38 | content: string; 39 | }; 40 | finderFeed?: { 41 | objectId: string; 42 | feedType: string; 43 | nickname: string; 44 | avatar: string; 45 | desc: string; 46 | mediaCount: string; 47 | objectNonceId: string; 48 | liveId: string; 49 | username: string; 50 | authIconUrl: string; 51 | authIconType: string; 52 | mediaList?: { 53 | media?: { 54 | thumbUrl: string, 55 | fullCoverUrl: string, 56 | videoPlayDuration: string, 57 | url: string, 58 | height: string, 59 | mediaType: string, 60 | width: string 61 | } 62 | }, 63 | megaVideo?: {}, 64 | bizAuthIconType: string 65 | } 66 | }; 67 | fromusername: string; 68 | appinfo: { 69 | appname: any; 70 | }; 71 | }; 72 | } 73 | 74 | export enum AppMessageType { 75 | Text = 1, 76 | Img = 2, 77 | Audio = 3, 78 | Video = 4, 79 | Url = 5, 80 | Attach = 6, 81 | Open = 7, 82 | Emoji = 8, 83 | VoiceRemind = 9, 84 | ScanGood = 10, 85 | Good = 13, 86 | Emotion = 15, 87 | CardTicket = 16, 88 | RealtimeShareLocation = 17, 89 | ChatHistory = 19, 90 | MiniProgram = 33, 91 | MiniProgramApp = 36, // this is forwardable mini program 92 | Channels = 51, // 视频号 93 | GroupNote = 53, 94 | ReferMsg = 57, 95 | Transfers = 2000, 96 | RedEnvelopes = 2001, 97 | ReaderType = 100001, 98 | } 99 | 100 | export interface AppAttachPayload { 101 | totallen?: number; 102 | attachid?: string; 103 | emoticonmd5?: string; 104 | fileext?: string; 105 | cdnattachurl?: string; 106 | aeskey?: string; 107 | cdnthumbaeskey?: string; 108 | encryver?: number; 109 | islargefilemsg: number; 110 | } 111 | 112 | export interface ReferMsgPayload { 113 | type: string; 114 | svrid: string; 115 | fromusr: string; 116 | chatusr: string; 117 | displayname: string; 118 | content: string; 119 | } 120 | 121 | export interface ChannelsMsgPayload { 122 | objectId: string; 123 | feedType: string; 124 | nickname: string; 125 | avatar: string; 126 | desc: string; 127 | mediaCount: string; 128 | objectNonceId: string; 129 | liveId: string; 130 | username: string; 131 | authIconUrl: string; 132 | authIconType: string; 133 | mediaList?: { 134 | media?: { 135 | thumbUrl: string, 136 | fullCoverUrl: string, 137 | videoPlayDuration: string, 138 | url: string, 139 | height: string, 140 | mediaType: string, 141 | width: string 142 | } 143 | }, 144 | megaVideo?: {}, 145 | bizAuthIconType?: string 146 | } 147 | 148 | export interface MiniAppMsgPayload { 149 | username: string; 150 | appid: string; 151 | pagepath: string; 152 | weappiconurl: string; 153 | shareId: string; 154 | } 155 | 156 | export interface AppMessagePayload { 157 | des?: string; 158 | thumburl?: string; 159 | title: string; 160 | url: string; 161 | appattach?: AppAttachPayload; 162 | channel?: ChannelsMsgPayload; 163 | miniApp?: MiniAppMsgPayload; 164 | type: AppMessageType; 165 | md5?: string; 166 | fromusername?: string; 167 | recorditem?: string; 168 | refermsg?: ReferMsgPayload; 169 | } 170 | 171 | export async function parseAppmsgMessagePayload (messageContent: string): Promise { 172 | const appMsgXml: AppMsgXmlSchema = await xmlToJson(messageContent) 173 | const { title, des, url, thumburl, type, md5, recorditem } = appMsgXml.msg.appmsg 174 | 175 | let appattach: AppAttachPayload | undefined 176 | let channel: ChannelsMsgPayload | undefined 177 | let miniApp: MiniAppMsgPayload | undefined 178 | const tmp = appMsgXml.msg.appmsg.appattach 179 | const channeltmp = appMsgXml.msg.appmsg.finderFeed 180 | const minitmp = appMsgXml.msg.appmsg.weappinfo 181 | if (tmp) { 182 | appattach = { 183 | aeskey: tmp.aeskey, 184 | attachid: tmp.attachid, 185 | cdnattachurl: tmp.cdnattachurl, 186 | cdnthumbaeskey: tmp.cdnthumbaeskey, 187 | emoticonmd5: tmp.emoticonmd5, 188 | encryver: (tmp.encryver && parseInt(tmp.encryver, 10)) || 0, 189 | fileext: tmp.fileext, 190 | islargefilemsg: (tmp.islargefilemsg && parseInt(tmp.islargefilemsg, 10)) || 0, 191 | totallen: (tmp.totallen && parseInt(tmp.totallen, 10)) || 0, 192 | } 193 | } 194 | if (channeltmp) { 195 | channel = { 196 | authIconType: channeltmp.authIconType, 197 | authIconUrl: channeltmp.authIconUrl, 198 | avatar: channeltmp.avatar, 199 | desc: channeltmp.desc, 200 | feedType: channeltmp.feedType, 201 | liveId: channeltmp.liveId, 202 | mediaCount: channeltmp.mediaCount, 203 | nickname: channeltmp.nickname, 204 | objectId: channeltmp.objectId, 205 | objectNonceId: channeltmp.objectNonceId, 206 | username: channeltmp.username, 207 | } 208 | } 209 | if (minitmp) { 210 | miniApp = { 211 | appid: minitmp.appid, 212 | pagepath: minitmp.pagepath, 213 | shareId: minitmp.shareId, 214 | username: minitmp.username, 215 | weappiconurl: minitmp.weappiconurl, 216 | } 217 | } 218 | 219 | return { 220 | appattach, 221 | channel, 222 | des, 223 | md5, 224 | miniApp, 225 | recorditem, 226 | refermsg: appMsgXml.msg.appmsg.refermsg, 227 | thumburl, 228 | title, 229 | type: parseInt(type, 10), 230 | url, 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/wechat4u/messages/message-emotion.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unnecessary-condition */ 2 | import { xmlToJson } from '../utils/xml-to-json.js' 3 | import type { WebMessageRawPayload } from '../../web-schemas.js' 4 | 5 | interface EmotionXmlSchema { 6 | msg: { 7 | emoji: { 8 | $: { 9 | type: string; 10 | len: string; 11 | cdnurl: string; 12 | width: string; 13 | height: string; 14 | md5: string; 15 | }; 16 | }; 17 | gameext: { 18 | $: { 19 | content: string; 20 | type: string; 21 | }; 22 | }; 23 | }; 24 | } 25 | 26 | export interface EmojiMessagePayload { 27 | type: number; 28 | len: number; 29 | md5: string; 30 | cdnurl: string; 31 | width: number; 32 | height: number; 33 | gameext?: string; 34 | } 35 | 36 | export async function parseEmotionMessagePayload (message: WebMessageRawPayload): Promise { 37 | const jsonPayload: EmotionXmlSchema = await xmlToJson(message.Content) 38 | 39 | const len = parseInt(jsonPayload.msg.emoji.$.len, 10) || 0 40 | const width = parseInt(jsonPayload.msg.emoji.$.width, 10) || 0 41 | const height = parseInt(jsonPayload.msg.emoji.$.height, 10) || 0 42 | const cdnurl = jsonPayload.msg.emoji.$.cdnurl 43 | const type = parseInt(jsonPayload.msg.emoji.$.type, 10) || 0 44 | const md5 = jsonPayload.msg.emoji.$.md5 45 | 46 | let gameext 47 | if (jsonPayload.msg.gameext) { 48 | const gameextType = parseInt(jsonPayload.msg.gameext.$.type, 10) || 0 49 | const gameextContent = parseInt(jsonPayload.msg.gameext.$.content, 10) || 0 50 | gameext = `` 51 | } 52 | 53 | return { 54 | cdnurl, 55 | gameext, 56 | height, 57 | len, 58 | md5, 59 | type, 60 | width, 61 | } 62 | } 63 | 64 | export function generateEmotionPayload (emojiMessagePayload: EmojiMessagePayload): string { 65 | return `${emojiMessagePayload.gameext || ''}` 68 | } 69 | -------------------------------------------------------------------------------- /src/wechat4u/messages/message-miniprogram.ts: -------------------------------------------------------------------------------- 1 | import type * as PUPPET from 'wechaty-puppet' 2 | import type { WebMessageRawPayload } from '../../web-schemas.js' 3 | import { xmlToJson } from '../utils/xml-to-json.js' 4 | 5 | interface MiniProgramXmlSchema { 6 | msg: { 7 | appmsg: { 8 | title: string; 9 | sourcedisplayname: string; 10 | url: string; 11 | appattach: { 12 | cdnthumbaeskey: string; 13 | cdnthumburl: string; 14 | }; 15 | weappinfo: { 16 | username: string; 17 | appid: string; 18 | pagepath: string; 19 | weappiconurl: string; 20 | shareId: string; 21 | }; 22 | thumburl: string; 23 | md5: any; 24 | }; 25 | fromusername: string; 26 | }; 27 | } 28 | 29 | export async function parseMiniProgramMessagePayload (rawPayload: WebMessageRawPayload): Promise { 30 | const miniProgramXml: MiniProgramXmlSchema = await xmlToJson(rawPayload.Content) 31 | const appmsg = miniProgramXml.msg.appmsg 32 | const weappinfo = appmsg.weappinfo 33 | const appattach = appmsg.appattach 34 | 35 | return { 36 | appid: weappinfo.appid, 37 | description: appmsg.sourcedisplayname, 38 | iconUrl: weappinfo.weappiconurl, 39 | pagePath: weappinfo.pagepath, 40 | shareId: weappinfo.shareId, 41 | thumbKey: appattach.cdnthumbaeskey, 42 | thumbUrl: appattach.cdnthumburl, 43 | title: appmsg.title, 44 | username: weappinfo.username, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/wechat4u/messages/message-sysmsg.ts: -------------------------------------------------------------------------------- 1 | import { xmlToJson } from '../utils/xml-to-json.js' 2 | import type { PatMessagePayload, PatXmlSchema } from './sysmsg/message-pat' 3 | import type { SysmsgTemplateMessagePayload, SysmsgTemplateXmlSchema } from './sysmsg/message-sysmsgtemplate' 4 | import { parsePatMessagePayload } from './sysmsg/message-pat.js' 5 | import { parseSysmsgTemplateMessagePayload } from './sysmsg/message-sysmsgtemplate.js' 6 | import type { TodoMessagePayload, TodoXmlSchema } from './sysmsg/message-todo.js' 7 | import { parseTodoMessagePayload } from './sysmsg/message-todo.js' 8 | import type { RevokeMsgMessagePayload, RevokeMsgXmlSchema } from './sysmsg/message-revokemsg' 9 | import { parseRevokeMsgMessagePayload } from './sysmsg/message-revokemsg.js' 10 | import { WebMessageRawPayload, WebMessageType } from '../../web-schemas.js' 11 | 12 | interface SysmsgXmlSchema { 13 | sysmsg: { 14 | $: { 15 | type: string; 16 | }, 17 | pat?: PatXmlSchema, 18 | sysmsgtemplate?: SysmsgTemplateXmlSchema, 19 | todo?: TodoXmlSchema, 20 | revokemsg?: RevokeMsgXmlSchema, 21 | }; 22 | } 23 | 24 | export interface RoomTipsPayload { 25 | content: string; 26 | } 27 | type SysMsgType = 'pat' | 'sysmsgtemplate' | 'roomtoolstips' | 'revokemsg' | 'roomtips'; 28 | type SysMsgPayload = PatMessagePayload | SysmsgTemplateMessagePayload | TodoMessagePayload | RevokeMsgMessagePayload | RoomTipsPayload; 29 | 30 | export interface SysmsgMessagePayload { 31 | type: SysMsgType; 32 | payload: SysMsgPayload 33 | } 34 | 35 | export async function parseSysmsgMessagePayload (message: WebMessageRawPayload): Promise { 36 | if (![ WebMessageType.SYS, WebMessageType.RECALLED ].includes(message.MsgType)) { 37 | return null 38 | } 39 | 40 | const content = message.Content.trim() 41 | const sysmsgIndex = content.indexOf(' { 78 | const sysmsgPayload = await parseSysmsgMessagePayload(message) 79 | if (!sysmsgPayload || sysmsgPayload.type !== 'pat') { 80 | return null 81 | } 82 | 83 | return sysmsgPayload.payload as PatMessagePayload 84 | } 85 | 86 | export async function parseSysmsgSysmsgTemplateMessagePayload (message: WebMessageRawPayload) : Promise { 87 | const sysmsgPayload = await parseSysmsgMessagePayload(message) 88 | if (!sysmsgPayload || sysmsgPayload.type !== 'sysmsgtemplate') { 89 | return null 90 | } 91 | 92 | return sysmsgPayload.payload as SysmsgTemplateMessagePayload 93 | } 94 | 95 | export async function parseSysmsgTodoMessagePayload (message: WebMessageRawPayload) : Promise { 96 | const sysmsgPayload = await parseSysmsgMessagePayload(message) 97 | if (!sysmsgPayload || sysmsgPayload.type !== 'roomtoolstips') { 98 | return null 99 | } 100 | 101 | return sysmsgPayload.payload as TodoMessagePayload 102 | } 103 | 104 | export async function parseSysmsgRevokeMsgMessagePayload (message: WebMessageRawPayload) : Promise { 105 | const sysmsgPayload = await parseSysmsgMessagePayload(message) 106 | if (!sysmsgPayload || sysmsgPayload.type !== 'revokemsg') { 107 | return null 108 | } 109 | 110 | return sysmsgPayload.payload as RevokeMsgMessagePayload 111 | } 112 | -------------------------------------------------------------------------------- /src/wechat4u/messages/sysmsg/message-pat.ts: -------------------------------------------------------------------------------- 1 | export interface PatXmlSchema { 2 | fromusername: string; 3 | chatusername: string; 4 | pattedusername: string; 5 | template: string; 6 | } 7 | 8 | export interface PatMessagePayload { 9 | fromUserName: string; 10 | chatUserName: string; 11 | pattedUserName: string; 12 | template: string; 13 | } 14 | 15 | export async function parsePatMessagePayload (patXml: PatXmlSchema): Promise { 16 | return { 17 | chatUserName: patXml.chatusername, 18 | fromUserName: patXml.fromusername, 19 | pattedUserName: patXml.pattedusername, 20 | template: patXml.template, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/wechat4u/messages/sysmsg/message-revokemsg.ts: -------------------------------------------------------------------------------- 1 | import { parseTextWithRegexList } from '../../utils/regex.js' 2 | import { executeRunners } from '../../utils/runner.js' 3 | import type * as PUPPET from 'wechaty-puppet' 4 | import { isRoomId } from '../../utils/is-type.js' 5 | 6 | export interface RevokeMsgXmlSchema { 7 | session: string; 8 | msgid: string; 9 | newmsgid: string; 10 | replacemsg: string; 11 | } 12 | 13 | export type RevokeMsgType = 'You' | 'Other'; 14 | 15 | export interface RevokeMsgMessagePayload { 16 | content: string; 17 | operatorNickName?: string, 18 | originalMessageId: string; 19 | session: string; 20 | type: RevokeMsgType; 21 | } 22 | 23 | const YOU_REVOKE_REGEX_LIST = [ 24 | /你撤回了一条消息/, 25 | /You recalled a message/, 26 | ] 27 | const OTHER_REVOKE_REGEX_LIST = [ 28 | /"(.+)" 撤回了一条消息/, 29 | /"(.+)" has recalled a message./, 30 | ] 31 | 32 | export async function parseRevokeMsgMessagePayload (revokeMsgXmlSchema: RevokeMsgXmlSchema): Promise { 33 | let nickName: string | undefined 34 | 35 | const youRevoke = async () => parseTextWithRegexList(revokeMsgXmlSchema.replacemsg, YOU_REVOKE_REGEX_LIST, async () => 'You') 36 | const otherRevoke = async () => parseTextWithRegexList(revokeMsgXmlSchema.replacemsg, OTHER_REVOKE_REGEX_LIST, async (_, match) => { 37 | nickName = match[1] 38 | return 'Other' 39 | }) 40 | 41 | const type = (await executeRunners([ youRevoke, otherRevoke ]))! 42 | 43 | return { 44 | content: revokeMsgXmlSchema.replacemsg, 45 | operatorNickName: nickName, 46 | originalMessageId: revokeMsgXmlSchema.newmsgid, 47 | session: revokeMsgXmlSchema.session, 48 | type, 49 | } 50 | } 51 | 52 | export async function getRevokeOriginalMessage (puppet: PUPPET.Puppet, revokemsgPayload:RevokeMsgMessagePayload): Promise { 53 | const messageIdList = await puppet.messageSearch({ id: revokemsgPayload.originalMessageId }) 54 | if (messageIdList.length) { 55 | return puppet.messagePayload(messageIdList[0]!) 56 | } 57 | 58 | return null 59 | } 60 | 61 | export async function getRevokeOperatorIdForRoomMessage (puppet: PUPPET.Puppet, revokemsgPayload:RevokeMsgMessagePayload) : Promise { 62 | if (isRoomId(revokemsgPayload.session)) { 63 | const contactIdList = await puppet.roomMemberSearch(revokemsgPayload.session, revokemsgPayload.operatorNickName!) 64 | if (contactIdList.length) { 65 | return contactIdList[0]! 66 | } 67 | } 68 | 69 | return null 70 | } 71 | -------------------------------------------------------------------------------- /src/wechat4u/messages/sysmsg/message-sysmsgtemplate.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { parseTextWithRegexList } from '../../utils/regex.js' 3 | import type { Runner } from '../../utils/runner.js' 4 | 5 | export interface SysmsgTemplateXmlSchema { 6 | content_template: { 7 | $: { 8 | type: string; 9 | }; 10 | plain: string; 11 | template: string; 12 | link_list: { 13 | link: [ 14 | { 15 | $: { 16 | name: string; 17 | type: string; 18 | hidden?: string; 19 | }; 20 | memberlist?: { 21 | member: [ 22 | { 23 | username?: string; 24 | nickname: string; 25 | } 26 | ]; 27 | }; 28 | separator?: string; 29 | title?: string; 30 | usernamelist?: { 31 | username: string[]; 32 | }; 33 | } 34 | ]; 35 | }; 36 | }; 37 | } 38 | 39 | export interface SysmsgTemplateLinkMember { 40 | userName?: string, 41 | nickName: string, 42 | } 43 | 44 | export type SysmsgTemplateLinkProfile = Array; 45 | 46 | export interface SysmsgTemplateLinkRevoke { 47 | title: string, 48 | userNameList: string[], 49 | } 50 | 51 | export type SysmsgTemplateLinkType = 'link_profile' | 'link_revoke'; 52 | 53 | export type SysmsgTemplateLinkPayload = SysmsgTemplateLinkProfile | SysmsgTemplateLinkRevoke; 54 | 55 | export interface SysmsgTemplateLink { 56 | name: string, 57 | payload: SysmsgTemplateLinkPayload, 58 | type: SysmsgTemplateLinkType, 59 | } 60 | 61 | export interface SysmsgTemplateMessagePayload { 62 | template: string; 63 | templateLinkList: Array; // link list is sorted by template variable name order 64 | } 65 | 66 | /** 67 | * xmlToJson will return element instead of array if xml node only contains one child. 68 | * @param list 69 | */ 70 | function toList (list: any): any[] { 71 | if (!Array.isArray(list)) { 72 | return [ list ] 73 | } else { 74 | return list 75 | } 76 | } 77 | 78 | export async function parseSysmsgTemplateMessagePayload (sysmsgTemplateXml: SysmsgTemplateXmlSchema): Promise { 79 | const linkList = toList(sysmsgTemplateXml.content_template.link_list.link) 80 | 81 | const allLinkList = linkList.map((link): SysmsgTemplateLink => { 82 | const type = link.$.type as SysmsgTemplateLinkType 83 | let payload: SysmsgTemplateLinkPayload | undefined 84 | 85 | if (type === 'link_profile') { 86 | const memberList = toList(link.memberlist!.member) 87 | payload = memberList.map((member: { nickname: string; username?: string; }): SysmsgTemplateLinkMember => { 88 | return { 89 | nickName: member.nickname, 90 | userName: member.username, 91 | } 92 | }) 93 | } else if (link.$.type === 'link_revoke') { 94 | payload = { 95 | title: link.title!, 96 | userNameList: toList(link.usernamelist!.username), 97 | } 98 | } else { 99 | // handle more link type here 100 | } 101 | 102 | return { 103 | name: link.$.name, 104 | payload: payload!, 105 | type, 106 | } 107 | }) 108 | 109 | const template = sysmsgTemplateXml.content_template.template 110 | const matches = [ ...template.matchAll(/\$(.+?)\$/g) ] 111 | 112 | const templateLinkList = matches.map(match => { 113 | const linkName = match[1] 114 | return allLinkList.filter((link) => link.name === linkName)[0]! 115 | }) 116 | 117 | return { 118 | template, 119 | templateLinkList, 120 | } 121 | } 122 | 123 | export type SysmsgTemplateHandler = (templateLinkList: SysmsgTemplateLink[], matchedRegexIndex: number) => Promise; 124 | 125 | export async function parseSysmsgTemplate (sysmsgTemplatePayload: SysmsgTemplateMessagePayload, regexList: RegExp[], handler: SysmsgTemplateHandler) : Promise { 126 | return parseTextWithRegexList(sysmsgTemplatePayload.template, regexList, async (matchedRegexIndex) => { 127 | return handler(sysmsgTemplatePayload.templateLinkList, matchedRegexIndex) 128 | }) 129 | } 130 | 131 | export function createSysmsgTemplateRunner (sysmsgTemplatePayload: SysmsgTemplateMessagePayload, regexList: RegExp[], handler: SysmsgTemplateHandler): Runner { 132 | return async () => parseSysmsgTemplate(sysmsgTemplatePayload, regexList, handler) 133 | } 134 | -------------------------------------------------------------------------------- /src/wechat4u/messages/sysmsg/message-todo.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | export interface TodoXmlSchema { 3 | op: string; 4 | todoid: string; 5 | username: string; 6 | path: string; 7 | time: number; 8 | custominfo: string; 9 | title: string; 10 | creator: string; 11 | related_msgid: string; 12 | manager: string; 13 | nreply: number; 14 | scene: string; 15 | oper: string; 16 | sharekey: string; 17 | sharename: string; 18 | template?: string; 19 | } 20 | 21 | export interface TodoMessagePayload { 22 | id: string; 23 | creatorUserName: string; 24 | operatorUserName: string; 25 | numberOfReply: number; 26 | appId: string; 27 | path: string; 28 | relatedMessageId: string; 29 | title: string; 30 | template?: string; 31 | } 32 | 33 | export async function parseTodoMessagePayload (todoXml: TodoXmlSchema): Promise { 34 | return { 35 | appId: todoXml.username, 36 | creatorUserName: todoXml.creator, 37 | id: todoXml.todoid, 38 | numberOfReply: todoXml.nreply, 39 | operatorUserName: todoXml.oper, 40 | path: todoXml.path, 41 | relatedMessageId: todoXml.related_msgid, 42 | template: todoXml.template, 43 | title: todoXml.title, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/contact.ts: -------------------------------------------------------------------------------- 1 | import * as PUPPET from 'wechaty-puppet' 2 | import type { WebContactRawPayload } from '../../web-schemas.js' 3 | import { plainText } from '../utils/xml.js' 4 | import { log } from 'wechaty-puppet' 5 | 6 | export function wechat4uContactToWechaty (rawPayload: WebContactRawPayload): PUPPET.payloads.Contact { 7 | log.silly('PuppetWechat4u', 'contactParseRawPayload(Object.keys(payload).length=%d)', 8 | Object.keys(rawPayload).length, 9 | ) 10 | if (!Object.keys(rawPayload).length) { 11 | log.error('PuppetWechat4u', 'contactParseRawPayload(Object.keys(payload).length=%d)', 12 | Object.keys(rawPayload).length, 13 | ) 14 | log.error('PuppetWechat4u', 'contactParseRawPayload() got empty rawPayload!') 15 | throw new Error('empty raw payload') 16 | // return { 17 | // gender: Gender.Unknown, 18 | // type: Contact.Type.Unknown, 19 | // } 20 | } 21 | 22 | // this.id = rawPayload.UserName // MMActualSender??? MMPeerUserName??? 23 | // `getUserContact(message.MMActualSender,message.MMPeerUserName).HeadImgUrl` 24 | // uin: rawPayload.Uin, // stable id: 4763975 || getCookie("wxuin") 25 | 26 | return { 27 | address: rawPayload.Alias, // XXX: need a stable address for user 28 | alias: plainText(rawPayload.RemarkName), 29 | avatar: rawPayload.HeadImgUrl, 30 | city: rawPayload.City, 31 | friend: !!(rawPayload.ContactFlag & 1), 32 | gender: rawPayload.Sex, 33 | id: rawPayload.UserName, 34 | name: plainText(rawPayload.NickName) || '', 35 | phone: [], 36 | province: rawPayload.Province, 37 | signature: rawPayload.Signature, 38 | star: !!rawPayload.StarFriend, 39 | weixin: rawPayload.Alias, // Wechat ID 40 | 41 | // tslint:disable:max-line-length 42 | /** 43 | * @see 1. https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3243 44 | * @see 2. https://github.com/Urinx/WeixinBot/blob/master/README.md 45 | * @ignore 46 | */ 47 | // eslint-disable-next-line sort-keys 48 | type: (!!rawPayload.UserName && !rawPayload.UserName.startsWith('@@') && !!(rawPayload.VerifyFlag & 8)) 49 | ? PUPPET.types.Contact.Official 50 | : PUPPET.types.Contact.Individual, 51 | /** 52 | * @see 1. https://github.com/Chatie/webwx-app-tracker/blob/7c59d35c6ea0cff38426a4c5c912a086c4c512b2/formatted/webwxApp.js#L3246 53 | * @ignore 54 | */ 55 | // special: specialContactList.indexOf(rawPayload.UserName) > -1 || /@qqim$/.test(rawPayload.UserName), 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message.ts: -------------------------------------------------------------------------------- 1 | import type { WebMessageRawPayload } from '../../web-schemas.js' 2 | import * as PUPPET from 'wechaty-puppet' 3 | import { executeMessageParsers } from './message/mod.js' 4 | import { isContactId, isRoomId } from '../utils/is-type.js' 5 | 6 | export async function webMessageToWechaty (puppet: PUPPET.Puppet, webMessageRawPayload: WebMessageRawPayload): Promise { 7 | let talkerId: undefined | string 8 | let text: undefined | string 9 | /** 10 | * 1. Set From Contact Id 11 | */ 12 | if (isContactId(webMessageRawPayload.FromUserName)) { 13 | 14 | talkerId = webMessageRawPayload.FromUserName 15 | 16 | } else { 17 | 18 | const array: string[] = webMessageRawPayload.OriginalContent.match(/^(@[a-zA-Z0-9]+|[a-zA-Z0-9_-]+):/) || [] 19 | 20 | talkerId = array[1] 21 | if (!talkerId) { 22 | talkerId = undefined 23 | } 24 | } 25 | /** 26 | * 27 | * 2. Set Text 28 | */ 29 | if (isRoomId(webMessageRawPayload.FromUserName)) { 30 | 31 | const parts = webMessageRawPayload.Content.split(':\n') 32 | if (parts.length > 1) { 33 | 34 | text = parts[1] 35 | 36 | } else { 37 | 38 | text = webMessageRawPayload.Content 39 | 40 | } 41 | 42 | } else { 43 | 44 | text = webMessageRawPayload.Content 45 | 46 | } 47 | // set default value for MessagePayloadBase, other fields will be fulfilled or updated var MessageParers 48 | const ret: PUPPET.payloads.Message = { 49 | id: webMessageRawPayload.MsgId, 50 | talkerId, 51 | text, 52 | timestamp: webMessageRawPayload.CreateTime, 53 | type: PUPPET.types.Message.Unknown, 54 | } as PUPPET.payloads.Message 55 | 56 | await executeMessageParsers(puppet, webMessageRawPayload, ret) 57 | // validate the return value 58 | if (!(ret.roomId || ret.listenerId)) { 59 | throw new Error('neither roomId nor listenerId') 60 | } 61 | 62 | return ret 63 | } 64 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/message-parser-appmsg.ts: -------------------------------------------------------------------------------- 1 | import * as PUPPET from 'wechaty-puppet' 2 | import { log } from 'wechaty-puppet' 3 | import { LOGPRE, MessageParser, MessageParserContext } from './message-parser.js' 4 | import { AppMessageType, parseAppmsgMessagePayload } from '../../messages/message-appmsg.js' 5 | import type { WebMessageRawPayload } from '../../../web-schemas' 6 | 7 | export const appMsgParser: MessageParser = async (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message, context: MessageParserContext) => { 8 | if (ret.type !== PUPPET.types.Message.Attachment) { 9 | return ret 10 | } 11 | 12 | try { 13 | const appPayload = await parseAppmsgMessagePayload(webMessageRawPayload.Content) 14 | context.appMessagePayload = appPayload 15 | switch (appPayload.type) { 16 | case AppMessageType.Text: 17 | ret.type = PUPPET.types.Message.Text 18 | ret.text = appPayload.title 19 | break 20 | case AppMessageType.Audio: 21 | ret.type = PUPPET.types.Message.Url 22 | break 23 | case AppMessageType.Video: 24 | ret.type = PUPPET.types.Message.Url 25 | break 26 | case AppMessageType.Url: 27 | ret.type = PUPPET.types.Message.Url 28 | break 29 | case AppMessageType.Attach: 30 | ret.type = PUPPET.types.Message.Attachment 31 | ret.filename = appPayload.title 32 | break 33 | case AppMessageType.ChatHistory: 34 | ret.type = PUPPET.types.Message.ChatHistory 35 | break 36 | case AppMessageType.MiniProgram: 37 | case AppMessageType.MiniProgramApp: 38 | ret.type = PUPPET.types.Message.MiniProgram 39 | break 40 | case AppMessageType.RedEnvelopes: 41 | ret.type = PUPPET.types.Message.RedEnvelope 42 | break 43 | case AppMessageType.Transfers: 44 | ret.type = PUPPET.types.Message.Transfer 45 | break 46 | case AppMessageType.RealtimeShareLocation: 47 | ret.type = PUPPET.types.Message.Location 48 | break 49 | case AppMessageType.Channels: 50 | ret.type = PUPPET.types.Message.Post 51 | ret.text = appPayload.title 52 | break 53 | case AppMessageType.GroupNote: 54 | ret.type = PUPPET.types.Message.GroupNote 55 | ret.text = appPayload.title 56 | break 57 | default: 58 | ret.type = PUPPET.types.Message.Unknown 59 | break 60 | } 61 | } catch (e) { 62 | log.warn(LOGPRE, `Error occurred while parse message attachment: ${JSON.stringify(webMessageRawPayload)} , ${(e as Error).stack}`) 63 | } 64 | 65 | return ret 66 | } 67 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/message-parser-refermsg.ts: -------------------------------------------------------------------------------- 1 | import * as PUPPET from 'wechaty-puppet' 2 | import { AppMessageType, parseAppmsgMessagePayload, ReferMsgPayload } from '../../messages/message-appmsg.js' 3 | import type { MessageParser, MessageParserContext } from './message-parser.js' 4 | import { WebMessageRawPayload, WebMessageType } from '../../../web-schemas.js' 5 | 6 | export const referMsgParser: MessageParser = async (_webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message, context: MessageParserContext) => { 7 | if (!context.appMessagePayload || context.appMessagePayload.type !== AppMessageType.ReferMsg) { 8 | return ret 9 | } 10 | 11 | const appPayload = context.appMessagePayload 12 | 13 | let referMessageContent: string 14 | 15 | const referMessagePayload: ReferMsgPayload = appPayload.refermsg! 16 | const referMessageType = parseInt(referMessagePayload.type) as WebMessageType 17 | switch (referMessageType) { 18 | case WebMessageType.TEXT: 19 | referMessageContent = referMessagePayload.content 20 | break 21 | case WebMessageType.IMAGE: 22 | referMessageContent = '图片' 23 | break 24 | 25 | case WebMessageType.VIDEO: 26 | referMessageContent = '视频' 27 | break 28 | 29 | case WebMessageType.EMOTICON: 30 | referMessageContent = '动画表情' 31 | break 32 | 33 | case WebMessageType.LOCATION: 34 | referMessageContent = '位置' 35 | break 36 | 37 | case WebMessageType.APP: { 38 | const referMessageAppPayload = await parseAppmsgMessagePayload(referMessagePayload.content) 39 | referMessageContent = referMessageAppPayload.title 40 | break 41 | } 42 | 43 | default: 44 | referMessageContent = '未知消息' 45 | break 46 | } 47 | 48 | ret.type = PUPPET.types.Message.Text 49 | ret.text = `「${referMessagePayload.displayname}:${referMessageContent}」\n- - - - - - - - - - - - - - -\n${appPayload.title}` 50 | 51 | return ret 52 | } 53 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/message-parser-room.ts: -------------------------------------------------------------------------------- 1 | import type * as PUPPET from 'wechaty-puppet' 2 | import { isRoomId, isContactId } from '../../utils/is-type.js' 3 | import type { MessageParser, MessageParserContext } from './message-parser.js' 4 | import type { WebMessageRawPayload } from '../../../web-schemas' 5 | import { parseMentionIdList } from '../../utils/parse-mention-id-list.js' 6 | 7 | async function roomMessageSentByOthers (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message) { 8 | let roomId: string | undefined 9 | if (isRoomId(webMessageRawPayload.FromUserName)) { 10 | roomId = webMessageRawPayload.FromUserName 11 | } else if (isRoomId(webMessageRawPayload.ToUserName)) { 12 | roomId = webMessageRawPayload.ToUserName 13 | } else { 14 | roomId = undefined 15 | } 16 | 17 | if (roomId) { 18 | ret.roomId = roomId 19 | 20 | /** 21 | * separator of talkerId and content: 22 | * 23 | * text: "wxid_xxxx:\nnihao" 24 | * appmsg: "wxid_xxxx:\n..." 25 | * pat: "19850419xxx@chatroom:\nxxx19850419xxx@chatroomwxid_xxx..." 26 | */ 27 | const separatorIndex = webMessageRawPayload.OriginalContent.indexOf(':
') 28 | 29 | if (separatorIndex !== -1) { 30 | const takerIdPrefix = webMessageRawPayload.OriginalContent.slice(0, separatorIndex) 31 | ret.talkerId = takerIdPrefix 32 | let text: string|undefined = '' 33 | const parts = webMessageRawPayload.Content.split(':\n') 34 | if (parts.length > 1) { 35 | text = parts[1] 36 | } else { 37 | text = webMessageRawPayload.Content 38 | } 39 | ret.text = text 40 | } else { 41 | /** 42 | * Message that can not get talkerId from payload: 43 | * 1. Create room with users that have deleted you: https://gist.github.com/padlocal/e95f8e05eb00556317991964eecfd150 44 | * 45 | * But talkerId is required by Wechaty, or exception will be raised: 46 | * https://github.com/wechaty/wechaty/blob/435cefd90baf7f2a0c801010132e74f9e0575fc2/src/user-modules/message.ts#L813 47 | * Solution: we set talkerId to fromusername, treating these kinds of messages are sent by self. 48 | */ 49 | ret.talkerId = webMessageRawPayload.ToUserName 50 | } 51 | } 52 | } 53 | 54 | async function roomMessageSentBySelf (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message) { 55 | let talkerId: string | undefined 56 | let roomId: string | undefined 57 | 58 | if (isRoomId(webMessageRawPayload.FromUserName)) { 59 | roomId = webMessageRawPayload.FromUserName 60 | } else if (isRoomId(webMessageRawPayload.ToUserName)) { 61 | roomId = webMessageRawPayload.ToUserName 62 | } else { 63 | roomId = undefined 64 | } 65 | 66 | if (isContactId(webMessageRawPayload.FromUserName)) { 67 | talkerId = webMessageRawPayload.FromUserName 68 | } else { 69 | const array: string[] = webMessageRawPayload.OriginalContent.match(/^(@[a-zA-Z0-9]+|[a-zA-Z0-9_-]+):/) || [] 70 | 71 | talkerId = array[1] 72 | if (!talkerId) { 73 | talkerId = '' 74 | } 75 | } 76 | 77 | if (roomId) { 78 | // room message sent by self 79 | ret.roomId = roomId 80 | ret.talkerId = talkerId 81 | 82 | let text: string|undefined = '' 83 | const parts = webMessageRawPayload.Content.split(':\n') 84 | if (parts.length > 1) { 85 | text = parts[1] 86 | } else { 87 | text = webMessageRawPayload.Content 88 | } 89 | ret.text = text 90 | } 91 | } 92 | 93 | /** 94 | * try to parse talkerId and content for generic room messages 95 | * @param padLocalMessage 96 | * @param ret 97 | * @param context 98 | */ 99 | export const roomParser: MessageParser = async (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message, context: MessageParserContext) => { 100 | await roomMessageSentByOthers(webMessageRawPayload, ret) 101 | await roomMessageSentBySelf(webMessageRawPayload, ret) 102 | 103 | if (ret.roomId) { 104 | context.isRoomMessage = true 105 | 106 | const mentionIdList: string[] = await parseMentionIdList(context.puppet, ret.roomId, ret.text || '') 107 | const room = ret as PUPPET.payloads.MessageRoom 108 | room.mentionIdList = mentionIdList 109 | } 110 | 111 | return ret 112 | } 113 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/message-parser-single-chat.ts: -------------------------------------------------------------------------------- 1 | import type * as PUPPET from 'wechaty-puppet' 2 | import type { MessageParser, MessageParserContext } from './message-parser' 3 | import type { WebMessageRawPayload } from '../../../web-schemas' 4 | 5 | export const singleChatParser: MessageParser = async (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message, context: MessageParserContext) => { 6 | if (!context.isRoomMessage) { 7 | ret.talkerId = webMessageRawPayload.FromUserName 8 | ret.listenerId = webMessageRawPayload.ToUserName 9 | } 10 | 11 | return ret 12 | } 13 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/message-parser-sysmsg.ts: -------------------------------------------------------------------------------- 1 | import type { MessageParser, MessageParserContext } from './message-parser.js' 2 | import type * as PUPPET from 'wechaty-puppet' 3 | import { parseSysmsgMessagePayload } from '../../messages/message-sysmsg.js' 4 | import type { PatMessagePayload } from '../../messages/sysmsg/message-pat.js' 5 | import type { TodoMessagePayload } from '../../messages/sysmsg/message-todo.js' 6 | import type { RevokeMsgMessagePayload } from '../../messages/sysmsg/message-revokemsg.js' 7 | import type { WebMessageRawPayload } from '../../../web-schemas' 8 | 9 | /** 10 | * try to parse talker and listenerId from sysmsg for room messages 11 | * @param padLocalMessage 12 | * @param ret 13 | * @param context 14 | */ 15 | export const sysmsgParser: MessageParser = async (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message, context: MessageParserContext) => { 16 | const sysmsgPayload = await parseSysmsgMessagePayload(webMessageRawPayload) 17 | if (!sysmsgPayload) { 18 | return ret 19 | } 20 | switch (sysmsgPayload.type) { 21 | case 'pat': { 22 | const patMessagePayload: PatMessagePayload = sysmsgPayload.payload as PatMessagePayload 23 | 24 | if (context.isRoomMessage) { 25 | ret.talkerId = patMessagePayload.pattedUserName 26 | ret.listenerId = patMessagePayload.fromUserName 27 | } 28 | 29 | break 30 | } 31 | 32 | case 'roomtoolstips': { 33 | const todoMessagePayload: TodoMessagePayload = sysmsgPayload.payload as TodoMessagePayload 34 | 35 | if (context.isRoomMessage) { 36 | ret.talkerId = todoMessagePayload.operatorUserName 37 | } 38 | 39 | break 40 | } 41 | 42 | case 'revokemsg': { 43 | const revokeMsgPayload: RevokeMsgMessagePayload = sysmsgPayload.payload as RevokeMsgMessagePayload 44 | 45 | if (context.isRoomMessage) { 46 | // Generic room message logic can get the right talkerId for revoke message 47 | } else { 48 | // Fix talkerId for single chat revoke message that sent by you 49 | // talkerId and listenerId for revoke message sent by others is right already 50 | if (revokeMsgPayload.type === 'You') { 51 | ret.listenerId = ret.talkerId 52 | ret.talkerId = context.puppet.currentUserId 53 | } 54 | } 55 | 56 | break 57 | } 58 | case 'roomtips': { 59 | if (context.isRoomMessage) { 60 | ret.talkerId = webMessageRawPayload.FromUserName 61 | } 62 | 63 | break 64 | } 65 | } 66 | 67 | return ret 68 | } 69 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/message-parser-type.ts: -------------------------------------------------------------------------------- 1 | import * as PUPPET from 'wechaty-puppet' 2 | import { log } from 'wechaty-puppet' 3 | import type { MessageParser, MessageParserContext } from './message-parser.js' 4 | import { LOGPRE } from './message-parser.js' 5 | import { WebMessageRawPayload, WebMessageType } from '../../../web-schemas.js' 6 | 7 | const TypeMappings: { [key: number]: PUPPET.types.Message; } = { 8 | [WebMessageType.TEXT]: PUPPET.types.Message.Text, 9 | [WebMessageType.IMAGE]: PUPPET.types.Message.Image, 10 | [WebMessageType.VOICE]: PUPPET.types.Message.Audio, 11 | [WebMessageType.EMOTICON]: PUPPET.types.Message.Emoticon, 12 | [WebMessageType.APP]: PUPPET.types.Message.Attachment, 13 | [WebMessageType.LOCATION]: PUPPET.types.Message.Location, 14 | [WebMessageType.MICROVIDEO]: PUPPET.types.Message.Video, 15 | [WebMessageType.VIDEO]: PUPPET.types.Message.Video, 16 | [WebMessageType.SYS]: PUPPET.types.Message.Unknown, 17 | [WebMessageType.SHARECARD]: PUPPET.types.Message.Contact, 18 | [WebMessageType.RECALLED]: PUPPET.types.Message.Recalled, 19 | [WebMessageType.STATUSNOTIFY]: PUPPET.types.Message.Unknown, 20 | [WebMessageType.SYSNOTICE]: PUPPET.types.Message.Unknown, 21 | } 22 | 23 | export const typeParser: MessageParser = async (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message, _context: MessageParserContext) => { 24 | const wechatMessageType = webMessageRawPayload.MsgType as WebMessageType 25 | 26 | let type: PUPPET.types.Message | undefined = TypeMappings[wechatMessageType] 27 | 28 | if (!type) { 29 | log.verbose(LOGPRE, `unsupported type: ${JSON.stringify(webMessageRawPayload)}`) 30 | 31 | type = PUPPET.types.Message.Unknown 32 | } 33 | 34 | ret.type = type 35 | 36 | return ret 37 | } 38 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/message-parser.ts: -------------------------------------------------------------------------------- 1 | import type * as PUPPET from 'wechaty-puppet' 2 | import type { AppMessagePayload } from '../../messages/message-appmsg.js' 3 | import type { WebMessageRawPayload } from '../../../web-schemas.js' 4 | 5 | /** 6 | * Add customized message parser context info here 7 | */ 8 | export type MessageParserContext = { 9 | puppet: PUPPET.Puppet, 10 | isRoomMessage: boolean, 11 | appMessagePayload?: AppMessagePayload, 12 | }; 13 | 14 | export type MessageParser = (webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message, context: MessageParserContext) => Promise; 15 | 16 | const messageParserList: Array = [] 17 | 18 | export function addMessageParser (parser: MessageParser) { 19 | messageParserList.push(parser) 20 | } 21 | 22 | export async function executeMessageParsers (puppet: PUPPET.Puppet, webMessageRawPayload: WebMessageRawPayload, ret: PUPPET.payloads.Message): Promise { 23 | const context: MessageParserContext = { 24 | isRoomMessage: false, 25 | puppet, 26 | } 27 | 28 | for (const parser of messageParserList) { 29 | ret = await parser(webMessageRawPayload, ret, context) 30 | } 31 | 32 | return ret 33 | } 34 | 35 | export const LOGPRE = 'message-parser' 36 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/message/mod.ts: -------------------------------------------------------------------------------- 1 | import { addMessageParser, executeMessageParsers } from './message-parser.js' 2 | import { typeParser } from './message-parser-type.js' 3 | import { roomParser } from './message-parser-room.js' 4 | import { singleChatParser } from './message-parser-single-chat.js' 5 | import { appMsgParser } from './message-parser-appmsg.js' 6 | import { referMsgParser } from './message-parser-refermsg.js' 7 | import { sysmsgParser } from './message-parser-sysmsg.js' 8 | 9 | // The order of message parser is important 10 | addMessageParser(typeParser) 11 | addMessageParser(roomParser) 12 | addMessageParser(singleChatParser) 13 | addMessageParser(appMsgParser) 14 | addMessageParser(referMsgParser) 15 | addMessageParser(sysmsgParser) 16 | 17 | export { executeMessageParsers } 18 | -------------------------------------------------------------------------------- /src/wechat4u/schema-mapper/room.ts: -------------------------------------------------------------------------------- 1 | import type { WebRoomRawPayload, WebRoomRawMember } from '../../web-schemas.js' 2 | 3 | import type * as PUPPET from 'wechaty-puppet' 4 | import { log } from 'wechaty-puppet' 5 | import { plainText } from '../utils/xml.js' 6 | 7 | export function wechat4uRoomToWechaty (rawPayload: WebRoomRawPayload): PUPPET.payloads.Room { 8 | log.verbose('PuppetWechat4u', 'roomRawPayloadParser(%s)', rawPayload) 9 | 10 | const id = rawPayload.UserName 11 | // const rawMemberList = rawPayload.MemberList || [] 12 | // const memberIdList = rawMemberList.map(rawMember => rawMember.UserName) 13 | 14 | // const aliasDict = {} as { [id: string]: string | undefined } 15 | 16 | // if (Array.isArray(rawPayload.MemberList)) { 17 | // rawPayload.MemberList.forEach(rawMember => { 18 | // aliasDict[rawMember.UserName] = rawMember.DisplayName 19 | // }) 20 | // } 21 | 22 | const memberIdList = rawPayload.MemberList 23 | ? rawPayload.MemberList.map(m => m.UserName) 24 | : [] 25 | 26 | const roomPayload: PUPPET.payloads.Room = { 27 | adminIdList: [], 28 | avatar: rawPayload.HeadImgUrl, 29 | id, 30 | memberIdList, 31 | topic : plainText(rawPayload.NickName) || '', 32 | // aliasDict, 33 | } 34 | return roomPayload 35 | } 36 | 37 | export function wechat4uRoomMemberToWechaty (rawPayload: WebRoomRawMember): PUPPET.payloads.RoomMember { 38 | log.verbose('PuppetWechat4u', 'roomMemberRawPayloadParser(%s)', rawPayload) 39 | 40 | const payload: PUPPET.payloads.RoomMember = { 41 | avatar : rawPayload.HeadImgUrl, 42 | id : rawPayload.UserName, 43 | name : rawPayload.NickName, 44 | roomAlias : rawPayload.DisplayName, 45 | } 46 | return payload 47 | } 48 | -------------------------------------------------------------------------------- /src/wechat4u/utils/is-type.ts: -------------------------------------------------------------------------------- 1 | export function isRoomId (id: string): boolean { 2 | if (!id) return false 3 | return /^@@|@chatroom$/.test(id) // 以@@开头或者@chatroom结尾 4 | } 5 | 6 | export function isContactId (id: string): boolean { 7 | return !isRoomId(id) 8 | } 9 | -------------------------------------------------------------------------------- /src/wechat4u/utils/parse-mention-id-list.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Huan(202109): from Add mention - wechaty/wechaty#362 3 | * https://github.com/wechaty/wechaty/pull/362/files 4 | */ 5 | import { 6 | log, 7 | Puppet, 8 | } from 'wechaty-puppet' 9 | 10 | /** 11 | * mobile: \u2005 12 | * PC、mac: \u0020 13 | * Web: \s 14 | */ 15 | const AT_SEPRATOR_REGEX = /[\u2005\u0020\s]+/ 16 | 17 | /** 18 | * 19 | * Get message mentioned contactList. 20 | * 21 | * Message event table as follows 22 | * 23 | * | | Web | Mac PC Client | iOS Mobile | android Mobile | 24 | * | :--- | :--: | :----: | :---: | :---: | 25 | * | [You were mentioned] tip ([有人@我]的提示) | ✘ | √ | √ | √ | 26 | * | Identify magic code (8197) by copy & paste in mobile | ✘ | √ | √ | ✘ | 27 | * | Identify magic code (8197) by programming | ✘ | ✘ | ✘ | ✘ | 28 | * | Identify two contacts with the same roomAlias by [You were mentioned] tip | ✘ | ✘ | √ | √ | 29 | * 30 | * @returns {Promise} - Return message mentioned contactList 31 | * 32 | * @example 33 | * const contactList = await message.mention() 34 | * console.log(contactList) 35 | */ 36 | async function parseMentionIdList ( 37 | puppet: Puppet, 38 | roomId: string, 39 | text: string, 40 | ): Promise { 41 | log.verbose('Message', 'mention()') 42 | 43 | const atList = text.split(AT_SEPRATOR_REGEX) 44 | // console.log('atList: ', atList) 45 | if (atList.length === 0) return [] 46 | 47 | // Using `filter(e => e.indexOf('@') > -1)` to filter the string without `@` 48 | const mentionNameList = atList 49 | .filter(str => str.includes('@')) 50 | .map(str => multipleAt(str)) 51 | .flat() 52 | .filter(name => !!name) 53 | 54 | // convert 'hello@a@b@c' to [ 'c', 'b@c', 'a@b@c' ] 55 | function multipleAt (str: string) { 56 | str = str.replace(/^.*?@/, '@') 57 | let name = '' 58 | const nameList: string[] = [] 59 | str.split('@') 60 | .filter(mentionName => !!mentionName) 61 | .reverse() 62 | .forEach(mentionName => { 63 | // console.log('mentionName: ', mentionName) 64 | name = mentionName + '@' + name 65 | nameList.push(name.slice(0, -1)) // get rid of the `@` at beginning 66 | }) 67 | return nameList 68 | } 69 | 70 | log.silly('wechaty-puppet-wechat', 'mentionIdList(%s), mentionNameList = "%s"', 71 | text, 72 | JSON.stringify(mentionNameList), 73 | ) 74 | 75 | const contactIdListNested = await Promise.all( 76 | mentionNameList.map( 77 | name => puppet.roomMemberSearch(roomId, name), 78 | ), 79 | ) 80 | 81 | const contactIdList = contactIdListNested.flat() 82 | 83 | if (contactIdList.length === 0) { 84 | log.silly('wechaty-puppet-wechat', 85 | [ 86 | 'mentionIdList() contactIdList can not found member', 87 | 'using roomMemberSearch() from mentionNameList:', 88 | mentionNameList.join(', '), 89 | ].join(''), 90 | ) 91 | } 92 | return contactIdList 93 | } 94 | 95 | export { parseMentionIdList } 96 | -------------------------------------------------------------------------------- /src/wechat4u/utils/regex.ts: -------------------------------------------------------------------------------- 1 | export type RegexHandler = (matchedRegexIndex: number, match: RegExpMatchArray) => Promise; 2 | 3 | export async function parseTextWithRegexList (text: string, regexList: RegExp[], handler: RegexHandler) : Promise { 4 | for (let i = 0; i < regexList.length; ++i) { 5 | const regex = regexList[i]! 6 | const match = text.match(regex) 7 | if (!match) { 8 | continue 9 | } 10 | 11 | return await handler(i, match) 12 | } 13 | 14 | return null 15 | } 16 | -------------------------------------------------------------------------------- /src/wechat4u/utils/runner.ts: -------------------------------------------------------------------------------- 1 | export type Runner = () => Promise; 2 | 3 | export async function executeRunners (runners: Runner[]): Promise { 4 | for (const runner of runners) { 5 | const ret = await runner() 6 | if (ret) { 7 | return ret 8 | } 9 | } 10 | 11 | return null 12 | } 13 | -------------------------------------------------------------------------------- /src/wechat4u/utils/xml-to-json.ts: -------------------------------------------------------------------------------- 1 | import { parseString } from 'xml2js' 2 | import { log } from 'wechaty-puppet' 3 | 4 | export async function xmlToJson (xml: string): Promise { 5 | const firstIndex = xml.indexOf('<') 6 | if (firstIndex !== 0) { 7 | xml = xml.substring(firstIndex, xml.length) 8 | } 9 | 10 | return new Promise((resolve) => { 11 | parseString(xml, { explicitArray: false }, (err, result) => { 12 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 13 | if (err && Object.keys(err).length !== 0) { 14 | log.warn(JSON.stringify(err)) 15 | } 16 | return resolve(result) 17 | }) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/wechat4u/utils/xml.ts: -------------------------------------------------------------------------------- 1 | export function stripHtml (html?: string): string { 2 | if (!html) { 3 | return '' 4 | } 5 | return html.replace(/(<([^>]+)>)/ig, '') 6 | } 7 | 8 | export function unescapeHtml (str?: string): string { 9 | if (!str) { 10 | return '' 11 | } 12 | return str 13 | .replace(/'/g, "'") 14 | .replace(/"/g, '"') 15 | .replace(/>/g, '>') 16 | .replace(/</g, '<') 17 | .replace(/&/g, '&') 18 | } 19 | 20 | export function digestEmoji (html?: string): string { 21 | if (!html) { 22 | return '' 23 | } 24 | return html 25 | .replace(/]+>/g, 26 | '$3', 27 | ) // 28 | .replace(/<\/span>/g, 29 | '[$2]', 30 | ) // '' 31 | } 32 | 33 | /** 34 | * unifyEmoji: the same emoji will be encoded as different xml code in browser. unify them. 35 | * 36 | * from: 37 | * to: 38 | * 39 | */ 40 | export function unifyEmoji (html?: string): string { 41 | if (!html) { 42 | return '' 43 | } 44 | return html 45 | .replace(/]+>/g, 46 | '', 47 | ) // 48 | .replace(/<\/span>/g, 49 | '', 50 | ) // '' 51 | } 52 | 53 | export function stripEmoji (html?: string): string { 54 | if (!html) { 55 | return '' 56 | } 57 | return html 58 | .replace(/]+>/g, 59 | '', 60 | ) // 61 | .replace(/<\/span>/g, 62 | '', 63 | ) // '' 64 | } 65 | 66 | export function plainText (html?: string): string { 67 | if (!html) { 68 | return '' 69 | } 70 | return stripHtml( 71 | unescapeHtml( 72 | stripHtml( 73 | digestEmoji( 74 | html, 75 | ), 76 | ), 77 | ), 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /tests/fixtures/smoke-testing.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { 4 | PuppetWechat4u, 5 | log, 6 | VERSION, 7 | } from 'wechaty-puppet-wechat4u' 8 | 9 | log.level('silly') 10 | 11 | async function main () { 12 | const puppet = new PuppetWechat4u() 13 | const future = new Promise(resolve => puppet.once('scan', resolve)) 14 | 15 | await puppet.start() 16 | await future 17 | 18 | log.info('SmokeTesting', 'main() event `scan` received!') 19 | 20 | await puppet.stop() 21 | 22 | if (VERSION === '0.0.0') { 23 | throw new Error('should set VERSION to real before publishing') 24 | } 25 | 26 | log.info('SmokeTesting', `Puppet v${puppet.version()} smoke testing passed.`) 27 | return 0 28 | } 29 | 30 | main() 31 | .then(process.exit) 32 | .catch(e => { 33 | console.error(e) 34 | process.exit(1) 35 | }) 36 | -------------------------------------------------------------------------------- /tests/integration.spec.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --no-warnings --loader ts-node/esm 2 | 3 | import { test } from 'tstest' 4 | 5 | test('integration testing', async t => { 6 | t.pass('ok') 7 | }) 8 | -------------------------------------------------------------------------------- /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 | "esModuleInterop": true, 5 | "outDir": "dist/esm", 6 | }, 7 | "exclude": [ 8 | "node_modules/", 9 | "dist/", 10 | "tests/fixtures/", 11 | ], 12 | "include": [ 13 | "bin/*.ts", 14 | "examples/**/*.ts", 15 | "scripts/**/*.ts", 16 | "src/**/*.ts", 17 | "tests/**/*.spec.ts", 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-standard"], 3 | "rules": { 4 | "interface-name": [true, "never-prefix"], 5 | "trailing-comma": true, 6 | "import-spacing": false, 7 | "no-multi-spaces": false, 8 | "typedef-whitespace": false 9 | } 10 | } 11 | --------------------------------------------------------------------------------