├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.yml ├── .vscode ├── extensions.json └── launch.json ├── README.md ├── logs └── wcf.txt ├── nx.json ├── package.json ├── packages ├── core │ ├── .eslintrc.json │ ├── .gitignore │ ├── .proto │ │ ├── extrabyte.proto │ │ ├── roomdata.proto │ │ └── wcf.proto │ ├── .swcrc │ ├── README.md │ ├── fixtures │ │ └── test_image.jpeg │ ├── package.json │ ├── project.json │ ├── scripts │ │ ├── gen-proto.ps1 │ │ ├── get-release.ps1 │ │ └── wcferry.ps1 │ ├── src │ │ ├── index.ts │ │ └── lib │ │ │ ├── client.spec.ts │ │ │ ├── client.ts │ │ │ ├── file-ref.spec.ts │ │ │ ├── file-ref.ts │ │ │ ├── message.ts │ │ │ ├── proto-generated │ │ │ ├── extrabyte.ts │ │ │ ├── extrabyte_grpc_pb.js │ │ │ ├── roomdata.ts │ │ │ ├── roomdata_grpc_pb.js │ │ │ ├── wcf.ts │ │ │ └── wcf_grpc_pb.js │ │ │ └── utils.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ └── vite.config.ts └── ws │ ├── .eslintrc.json │ ├── .swcrc │ ├── README.md │ ├── package.json │ ├── project.json │ ├── src │ ├── cmd.ts │ ├── index.ts │ └── lib │ │ ├── ws.spec.ts │ │ └── ws.ts │ ├── static │ └── websocket.png │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tools └── scripts │ └── publish.mjs ├── tsconfig.base.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nx/javascript"], 32 | "rules": {} 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | permissions: 3 | id-token: write 4 | pull-requests: read 5 | actions: read 6 | statuses: read 7 | contents: read 8 | packages: read 9 | checks: write 10 | 11 | on: 12 | push: 13 | branches: 14 | # Change this if your primary branch is not main 15 | - main 16 | pull_request: 17 | 18 | jobs: 19 | main: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - name: Install pnpm 26 | run: corepack enable pnpm 27 | # Cache node_modules 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: 'pnpm' 32 | - run: pnpm i --frozen-lockfile 33 | # - uses: nrwl/nx-set-shas@v4 34 | # - run: | 35 | # echo "BASE: ${{ env.NX_BASE }}" 36 | # echo "HEAD: ${{ env.NX_HEAD }}" 37 | # This line is needed for nx affected to work when CI is running on a PR 38 | # - run: git branch --track main origin/main 39 | 40 | - run: pnpm nx format:check 41 | # - run: pnpm nx affected -t lint,test,build --parallel=3 --exclude='tag:wip' 42 | - run: pnpm nx run-many -t lint,test,build --parallel=3 43 | - name: Publish 44 | shell: pwsh 45 | run: | 46 | npm config set provenance true 47 | $commit = "$(git log -1 --pretty=%B)".Trim() 48 | if (!($commit -match "^((core|ws)@[0-9]+\.[0-9]+\.[0-9]+\+?)+$")) { 49 | Write-Host "Not a release, skipping publish" 50 | exit 0 51 | } 52 | 53 | echo "//registry.npmjs.org/:_authToken=$env:NPM_TOKEN" >> ~/.npmrc 54 | 55 | if ($commit -match "core@[0-9]+\.[0-9]+\.[0-9]+") { 56 | pnpm nx run core:publish --ver $($Matches[0]) 57 | } 58 | if ($commit -match "ws@[0-9]+\.[0-9]+\.[0-9]+") { 59 | pnpm nx run ws:publish --ver $($Matches[0]) 60 | } 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .nx/cache 42 | # *.proto 43 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache 5 | pnpm-lock.yaml 6 | proto-generated 7 | *.proto -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | useTabs: false 2 | tabWidth: 4 3 | singleQuote: true 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["nrwl.angular-console", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "tsx", 9 | "type": "node", 10 | "request": "launch", 11 | 12 | // Debug current file in VSCode 13 | "program": "${workspaceRoot}/packages/ws/src/cmd.ts", 14 | 15 | /* 16 | Path to tsx binary 17 | Assuming locally installed 18 | */ 19 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/tsx", 20 | 21 | /* 22 | Open terminal when debugging starts (Optional) 23 | Useful to see console.logs 24 | */ 25 | "console": "integratedTerminal", 26 | "internalConsoleOptions": "neverOpen", 27 | 28 | // Files to exclude from debugger (e.g. call stack) 29 | "skipFiles": [ 30 | // Node.js internal core modules 31 | "/**", 32 | 33 | // Ignore all dependencies (optional) 34 | "${workspaceFolder}/node_modules/**" 35 | ], 36 | "env": { 37 | "DEBUG": "wcferry:*" 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wcferry 2 | 3 | [![CI](https://github.com/stkevintan/node-wcferry/actions/workflows/ci.yml/badge.svg)](https://github.com/stkevintan/node-wcferry/actions/workflows/ci.yml) 4 | [![npm version](https://badge.fury.io/js/@wcferry%2Fcore.svg)](https://badge.fury.io/js/@wcferry%2Fcore) 5 | 6 | ## Packages 7 | 8 | 1. [@wcferry/core](./packages/core): The native wcferry RPC client 9 | 2. [@wcferry/ws](./packages/ws): A tiny websocket server built upon the core lib 10 | 11 | ### Debug 12 | 13 | Set environment `DEBUG="wcferry:*"` will enable debugging logs (powered by https://www.npmjs.com/package/debug) 14 | 15 | ## Running tasks 16 | 17 | To execute tasks with Nx use the following syntax: 18 | 19 | ``` 20 | nx <...options> 21 | ``` 22 | 23 | You can also run multiple targets: 24 | 25 | ``` 26 | nx run-many -t 27 | ``` 28 | 29 | ..or add `-p` to filter specific projects 30 | 31 | ``` 32 | nx run-many -t -p 33 | ``` 34 | 35 | Targets can be defined in the `package.json` or `projects.json`. Learn more [in the docs](https://nx.dev/core-features/run-tasks). 36 | -------------------------------------------------------------------------------- /logs/wcf.txt: -------------------------------------------------------------------------------- 1 | [2024-01-13 15:03:45.364] [debug] [WCF] [log.cpp::33::InitLogger] InitLogger with debug level 2 | [2024-01-13 15:03:45.366] [debug] [WCF] [spy.cpp::30::InitSpy] WeChat version: 3.9.2.23 3 | [2024-01-13 15:03:45.407] [error] [WCF] [rpc_server.cpp::966::RunServer] nng_listen error Address in use 4 | [2024-01-13 15:04:59.033] [debug] [WCF] [spy.cpp::30::InitSpy] WeChat version: 3.9.2.23 5 | [2024-01-13 15:04:59.034] [error] [WCF] [rpc_server.cpp::966::RunServer] nng_listen error Address in use 6 | [2024-01-13 15:05:35.074] [debug] [WCF] [spy.cpp::30::InitSpy] WeChat version: 3.9.2.23 7 | [2024-01-13 15:05:35.076] [error] [WCF] [rpc_server.cpp::966::RunServer] nng_listen error Address in use 8 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "generators": { 3 | "@nx/js:library": { 4 | "bundler": "swc" 5 | } 6 | }, 7 | "targetDefaults": { 8 | "build": { 9 | "cache": true, 10 | "dependsOn": ["^build"] 11 | }, 12 | "test": { 13 | "cache": true 14 | }, 15 | "lint": { 16 | "cache": true, 17 | "inputs": [ 18 | "default", 19 | "{workspaceRoot}/.eslintrc.json", 20 | "{workspaceRoot}/.eslintignore", 21 | "{workspaceRoot}/eslint.config.js" 22 | ] 23 | }, 24 | "@nx/vite:test": { 25 | "cache": true, 26 | "inputs": ["default", "^default"] 27 | } 28 | }, 29 | "affected": { 30 | "defaultBase": "main" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wcferry/root", 3 | "version": "0.0.2", 4 | "license": "MIT", 5 | "scripts": {}, 6 | "private": true, 7 | "dependencies": { 8 | "@swc/helpers": "~0.5.3", 9 | "debug": "^4.3.4" 10 | }, 11 | "devDependencies": { 12 | "@nx/devkit": "^17.2.8", 13 | "@nx/eslint": "17.2.8", 14 | "@nx/eslint-plugin": "17.2.8", 15 | "@nx/js": "17.2.8", 16 | "@nx/vite": "17.2.8", 17 | "@nx/workspace": "^17.2.8", 18 | "@swc-node/register": "~1.6.8", 19 | "@swc/cli": "~0.1.63", 20 | "@swc/core": "~1.3.102", 21 | "@types/debug": "^4.1.12", 22 | "@types/node": "18.7.1", 23 | "@typescript-eslint/eslint-plugin": "^6.18.1", 24 | "@vitest/coverage-v8": "~0.34.6", 25 | "@vitest/ui": "~0.34.7", 26 | "nx": "17.2.8", 27 | "prettier": "^2.8.8", 28 | "tslib": "^2.6.2", 29 | "tsx": "^4.7.0", 30 | "typescript": "~5.2.2", 31 | "vite": "^5.0.11", 32 | "vitest": "~0.34.6" 33 | }, 34 | "workspaces": [ 35 | "packages/*" 36 | ], 37 | "nx": { 38 | "includedScripts": [] 39 | }, 40 | "publishConfig": { 41 | "registry": "https://registry.npmjs.org/" 42 | }, 43 | "repository": { 44 | "url": "git+https://github.com/stkevintan/node-wcferry.git", 45 | "type": "git" 46 | }, 47 | "author": { 48 | "name": "stkevintan" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/core/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["*.json"], 19 | "parser": "jsonc-eslint-parser", 20 | "rules": { 21 | "@nx/dependency-checks": [ 22 | "error", 23 | { 24 | "ignoredFiles": [ 25 | "{projectRoot}/vite.config.{js,ts,mjs,mts}" 26 | ] 27 | } 28 | ] 29 | } 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /packages/core/.gitignore: -------------------------------------------------------------------------------- 1 | .binary -------------------------------------------------------------------------------- /packages/core/.proto/extrabyte.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package com.iamteer.wcf; 3 | 4 | message Extra { 5 | enum PropertyKey { 6 | Field_0 = 0; 7 | Sign = 2; 8 | Thumb = 3; 9 | Extra = 4; 10 | Xml = 7; 11 | } 12 | 13 | message Property { 14 | PropertyKey type = 1; 15 | string value = 2; 16 | } 17 | 18 | repeated Property properties = 3; 19 | } -------------------------------------------------------------------------------- /packages/core/.proto/roomdata.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package com.iamteer.wcf; 3 | 4 | message RoomData { 5 | 6 | message RoomMember { 7 | string wxid = 1; 8 | string name = 2; 9 | int32 state = 3; 10 | } 11 | 12 | repeated RoomMember members = 1; 13 | 14 | int32 field_2 = 2; 15 | int32 field_3 = 3; 16 | int32 field_4 = 4; 17 | int32 room_capacity = 5; 18 | int32 field_6 = 6; 19 | int64 field_7 = 7; 20 | int64 field_8 = 8; 21 | } 22 | 23 | -------------------------------------------------------------------------------- /packages/core/.proto/wcf.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package wcf; 4 | option java_package = "com.iamteer"; 5 | 6 | enum Functions { 7 | FUNC_RESERVED = 0x00; 8 | FUNC_IS_LOGIN = 0x01; 9 | FUNC_GET_SELF_WXID = 0x10; 10 | FUNC_GET_MSG_TYPES = 0x11; 11 | FUNC_GET_CONTACTS = 0x12; 12 | FUNC_GET_DB_NAMES = 0x13; 13 | FUNC_GET_DB_TABLES = 0x14; 14 | FUNC_GET_USER_INFO = 0x15; 15 | FUNC_GET_AUDIO_MSG = 0x16; 16 | FUNC_SEND_TXT = 0x20; 17 | FUNC_SEND_IMG = 0x21; 18 | FUNC_SEND_FILE = 0x22; 19 | FUNC_SEND_XML = 0x23; 20 | FUNC_SEND_EMOTION = 0x24; 21 | FUNC_SEND_RICH_TXT = 0x25; 22 | FUNC_SEND_PAT_MSG = 0x26; 23 | FUNC_FORWARD_MSG = 0x27; 24 | FUNC_ENABLE_RECV_TXT = 0x30; 25 | FUNC_DISABLE_RECV_TXT = 0x40; 26 | FUNC_EXEC_DB_QUERY = 0x50; 27 | FUNC_ACCEPT_FRIEND = 0x51; 28 | FUNC_RECV_TRANSFER = 0x52; 29 | FUNC_REFRESH_PYQ = 0x53; 30 | FUNC_DOWNLOAD_ATTACH = 0x54; 31 | FUNC_GET_CONTACT_INFO = 0x55; 32 | FUNC_REVOKE_MSG = 0x56; 33 | FUNC_DECRYPT_IMAGE = 0x60; 34 | FUNC_EXEC_OCR = 0x61; 35 | FUNC_ADD_ROOM_MEMBERS = 0x70; 36 | FUNC_DEL_ROOM_MEMBERS = 0x71; 37 | FUNC_INV_ROOM_MEMBERS = 0x72; 38 | } 39 | 40 | message Request 41 | { 42 | Functions func = 1; 43 | oneof msg 44 | { 45 | Empty empty = 2; 46 | string str = 3; 47 | TextMsg txt = 4; 48 | PathMsg file = 5; 49 | DbQuery query = 6; 50 | Verification v = 7; 51 | MemberMgmt m = 8; // 群成员管理,添加、删除、邀请 52 | XmlMsg xml = 9; 53 | DecPath dec = 10; 54 | Transfer tf = 11; 55 | uint64 ui64 = 12 [jstype = JS_STRING]; // 64 位整数,通用 56 | bool flag = 13; 57 | AttachMsg att = 14; 58 | AudioMsg am = 15; 59 | RichText rt = 16; 60 | PatMsg pm = 17; 61 | ForwardMsg fm = 18; 62 | } 63 | } 64 | 65 | message Response 66 | { 67 | Functions func = 1; 68 | oneof msg 69 | { 70 | int32 status = 2; // Int 状态,通用 71 | string str = 3; // 字符串 72 | WxMsg wxmsg = 4; // 微信消息 73 | MsgTypes types = 5; // 消息类型 74 | RpcContacts contacts = 6; // 联系人 75 | DbNames dbs = 7; // 数据库列表 76 | DbTables tables = 8; // 表列表 77 | DbRows rows = 9; // 行列表 78 | UserInfo ui = 10; // 个人信息 79 | OcrMsg ocr = 11; // OCR 结果 80 | }; 81 | } 82 | 83 | message Empty { } 84 | 85 | message WxMsg 86 | { 87 | bool is_self = 1; // 是否自己发送的 88 | bool is_group = 2; // 是否群消息 89 | uint64 id = 3 [jstype = JS_STRING]; // 消息 id 90 | uint32 type = 4; // 消息类型 91 | uint32 ts = 5; // 消息类型 92 | string roomid = 6; // 群 id(如果是群消息的话) 93 | string content = 7; // 消息内容 94 | string sender = 8; // 消息发送者 95 | string sign = 9; // Sign 96 | string thumb = 10; // 缩略图 97 | string extra = 11; // 附加内容 98 | string xml = 12; // 消息 xml 99 | } 100 | 101 | message TextMsg 102 | { 103 | string msg = 1; // 要发送的消息内容 104 | string receiver = 2; // 消息接收人,当为群时可@ 105 | string aters = 3; // 要@的人列表,逗号分隔 106 | } 107 | 108 | message PathMsg 109 | { 110 | string path = 1; // 要发送的图片的路径 111 | string receiver = 2; // 消息接收人 112 | } 113 | 114 | message XmlMsg 115 | { 116 | string receiver = 1; // 消息接收人 117 | string content = 2; // xml 内容 118 | string path = 3; // 图片路径 119 | int32 type = 4; // 消息类型 120 | } 121 | 122 | message MsgTypes { map types = 1; } 123 | 124 | message RpcContact 125 | { 126 | string wxid = 1; // 微信 id 127 | string code = 2; // 微信号 128 | string remark = 3; // 备注 129 | string name = 4; // 微信昵称 130 | string country = 5; // 国家 131 | string province = 6; // 省/州 132 | string city = 7; // 城市 133 | int32 gender = 8; // 性别 134 | } 135 | message RpcContacts { repeated RpcContact contacts = 1; } 136 | 137 | message DbNames { repeated string names = 1; } 138 | 139 | message DbTable 140 | { 141 | string name = 1; // 表名 142 | string sql = 2; // 建表 SQL 143 | } 144 | message DbTables { repeated DbTable tables = 1; } 145 | 146 | message DbQuery 147 | { 148 | string db = 1; // 目标数据库 149 | string sql = 2; // 查询 SQL 150 | } 151 | 152 | message DbField 153 | { 154 | int32 type = 1; // 字段类型 155 | string column = 2; // 字段名称 156 | bytes content = 3; // 字段内容 157 | } 158 | message DbRow { repeated DbField fields = 1; } 159 | message DbRows { repeated DbRow rows = 1; } 160 | 161 | message Verification 162 | { 163 | string v3 = 1; // 加密的用户名 164 | string v4 = 2; // Ticket 165 | int32 scene = 3; // 添加方式:17 名片,30 扫码 166 | } 167 | 168 | message MemberMgmt 169 | { 170 | string roomid = 1; // 要加的群ID 171 | string wxids = 2; // 要加群的人列表,逗号分隔 172 | } 173 | 174 | message UserInfo 175 | { 176 | string wxid = 1; // 微信ID 177 | string name = 2; // 昵称 178 | string mobile = 3; // 手机号 179 | string home = 4; // 文件/图片等父路径 180 | } 181 | 182 | message DecPath 183 | { 184 | string src = 1; // 源路径 185 | string dst = 2; // 目标路径 186 | } 187 | 188 | message Transfer 189 | { 190 | string wxid = 1; // 转账人 191 | string tfid = 2; // 转账id transferid 192 | string taid = 3; // Transaction id 193 | } 194 | 195 | message AttachMsg 196 | { 197 | uint64 id = 1 [jstype = JS_STRING]; // 消息 id 198 | string thumb = 2; // 消息中的 thumb 199 | string extra = 3; // 消息中的 extra 200 | } 201 | 202 | message AudioMsg 203 | { 204 | uint64 id = 1 [jstype = JS_STRING]; // 语音消息 id 205 | string dir = 2; // 存放目录 206 | } 207 | 208 | message RichText 209 | { 210 | string name = 1; // 显示名字 211 | string account = 2; // 公众号 id 212 | string title = 3; // 标题 213 | string digest = 4; // 摘要 214 | string url = 5; // 链接 215 | string thumburl = 6; // 缩略图 216 | string receiver = 7; // 接收人 217 | } 218 | 219 | message PatMsg 220 | { 221 | string roomid = 1; // 群 id 222 | string wxid = 2; // wxid 223 | } 224 | 225 | message OcrMsg 226 | { 227 | int32 status = 1; // 状态 228 | string result = 2; // 结果 229 | } 230 | 231 | message ForwardMsg 232 | { 233 | uint64 id = 1 [jstype = JS_STRING]; // 待转发消息 ID 234 | string receiver = 2; // 转发接收目标,群为 roomId,个人为 wxid 235 | } 236 | 237 | -------------------------------------------------------------------------------- /packages/core/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "target": "es2017", 4 | "parser": { 5 | "syntax": "typescript", 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "transform": { 10 | "decoratorMetadata": true, 11 | "legacyDecorator": true 12 | }, 13 | "keepClassNames": true, 14 | "externalHelpers": true, 15 | "loose": true 16 | }, 17 | "module": { 18 | "type": "commonjs" 19 | }, 20 | "sourceMaps": true, 21 | "exclude": [ 22 | "jest.config.ts", 23 | ".*\\.spec.tsx?$", 24 | ".*\\.test.tsx?$", 25 | "./src/jest-setup.ts$", 26 | "./**/jest-setup.ts$", 27 | ".*.js$" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @wcferry/core 2 | 3 | A node impl of wcferry nanomsg clients 4 | 5 | ## Install 6 | 7 | ``` 8 | npm i @wcferry/core 9 | ``` 10 | 11 | ### Usage 12 | 13 | ```ts 14 | import { Wcferry } from '@wcferry/core'; 15 | 16 | const client = new Wcferry({ port: 10086 }); 17 | client.start(); 18 | 19 | const isLogin = client.isLogin(); 20 | 21 | // start receiving message 22 | const off = client.on((msg) => { 23 | console.log('received message:', msg); 24 | }); 25 | 26 | // stop reciving message 27 | off(); 28 | 29 | // close 30 | client.stop(); 31 | ``` 32 | 33 | ## Building 34 | 35 | Run `nx build core` to build the library. 36 | 37 | ## Get latest wcferry release (wcf.exe, \*.dll) 38 | 39 | Run `nx build:dll core` to download and unzip latest wcferry components into `.binary/` 40 | 41 | ## Regenerate protobuf files 42 | 43 | Run `nx build:proto core` to build latest pb to `src/lib/proto-generated/` 44 | 45 | ## Running unit tests 46 | 47 | Run `nx test core` to execute the unit tests via [Vitest](https://vitest.dev/). 48 | -------------------------------------------------------------------------------- /packages/core/fixtures/test_image.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stkevintan/node-wcferry/3e580e86d8d2983aa448570321fc44f64728460c/packages/core/fixtures/test_image.jpeg -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wcferry/core", 3 | "version": "0.1.6", 4 | "scripts": { 5 | "build:proto": "powershell ./gen-proto.ps1" 6 | }, 7 | "type": "commonjs", 8 | "main": "./src/index.js", 9 | "typings": "./src/index.d.ts", 10 | "dependencies": { 11 | "@rustup/nng": "^0.1.2", 12 | "@swc/helpers": "~0.5.2", 13 | "debug": "^4.3.4", 14 | "google-protobuf": "^3.21.2", 15 | "mime": "^3.0.0" 16 | }, 17 | "repository": { 18 | "url": "git+https://github.com/stkevintan/node-wcferry.git", 19 | "type": "git" 20 | }, 21 | "author": { 22 | "name": "stkevintan" 23 | }, 24 | "devDependencies": { 25 | "@types/google-protobuf": "^3.15.12", 26 | "@types/mime": "^3.0.4", 27 | "grpc-tools": "^1.12.4", 28 | "protoc-gen-ts": "^0.8.7" 29 | }, 30 | "publishConfig": { 31 | "registry": "https://registry.npmjs.org/", 32 | "directory": "../../dist/packages/core" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/core/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "core", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/core/src", 5 | "projectType": "library", 6 | "targets": { 7 | "build": { 8 | "executor": "@nx/js:swc", 9 | "outputs": ["{options.outputPath}"], 10 | "options": { 11 | "outputPath": "dist/packages/core", 12 | "main": "packages/core/src/index.ts", 13 | "tsConfig": "packages/core/tsconfig.lib.json", 14 | "assets": ["packages/core/*.md", "packages/core/scripts/*.ps1"] 15 | } 16 | }, 17 | "build:proto": { 18 | "executor": "nx:run-commands", 19 | "inputs": ["{projectRoot}/proto/*"], 20 | "outputs": ["{projectRoot}/src/lib/proto-generated/*"], 21 | "options": { 22 | "command": "powershell ./scripts/gen-proto.ps1", 23 | "cwd": "{projectRoot}" 24 | } 25 | }, 26 | "build:dll": { 27 | "executor": "nx:run-commands", 28 | "outputs": ["{projectRoot}/.binary/*"], 29 | "options": { 30 | "command": "powershell ./scripts/get-release.ps1", 31 | "cwd": "{projectRoot}" 32 | } 33 | }, 34 | "lint": { 35 | "executor": "@nx/eslint:lint", 36 | "outputs": ["{options.outputFile}"] 37 | }, 38 | "test": { 39 | "executor": "@nx/vite:test", 40 | "outputs": ["{options.reportsDirectory}"], 41 | "options": { 42 | "reportsDirectory": "../../coverage/packages/core" 43 | } 44 | }, 45 | "publish": { 46 | "command": "node tools/scripts/publish.mjs core {args.ver} {args.tag}", 47 | "dependsOn": ["build"] 48 | } 49 | }, 50 | "tags": [] 51 | } 52 | -------------------------------------------------------------------------------- /packages/core/scripts/gen-proto.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "stop" 2 | 3 | function New-ProtoDir([string]$p) { 4 | if (!(Test-Path $p)) { 5 | New-Item -ItemType Directory -Force -Path $p 6 | } 7 | return $p | Resolve-Path 8 | } 9 | 10 | $PROTO_DIR = New-ProtoDir "$PSScriptRoot/../.proto" 11 | $PROTO_GENERATED = New-ProtoDir "$PSScriptRoot/../src/lib/proto-generated" 12 | 13 | # if powershell 5 or pwsh with platform Win32NT 14 | if (!$PSVersionTable.Platform -or ($PSVersionTable.Platform -eq "Win32NT")) { 15 | $ext = ".cmd" 16 | $eol = "`r`n" 17 | } 18 | else { 19 | $ext = "" 20 | $eol = "`n" 21 | } 22 | 23 | function Get-Pbs { 24 | Write-Host Downloading latest pb files 25 | $PROTO_LINKS = @( 26 | 'https://raw.githubusercontent.com/lich0821/WeChatFerry/master/WeChatFerry/rpc/proto/wcf.proto', 27 | 'https://raw.githubusercontent.com/lich0821/WeChatFerry/master/clients/python/roomdata.proto' 28 | ) 29 | 30 | $PROTO_LINKS | ForEach-Object { 31 | # Download the content 32 | $response = Invoke-WebRequest -Uri $_ 33 | 34 | # Get the filename from the URL 35 | $filename = [System.IO.Path]::GetFileName($_) 36 | 37 | $updatedContent = $response.Content -Replace '(uint64.*?);', '${1} [jstype = JS_STRING];' 38 | 39 | # Save the updated content to a file, using the filename from the URL 40 | $updatedContent | Set-Content -Path "$PROTO_DIR/$filename" 41 | } 42 | } 43 | 44 | function Invoke-ProtoGen { 45 | Write-Host Compiling pb into ts files 46 | $PROTOC_GEN_TS_PATH = "$PSScriptRoot/../node_modules/.bin/protoc-gen-ts$ext" | Resolve-Path 47 | $GRPC_TOOLS_NODE_PROTOC_PLUGIN = "$PSScriptRoot/../node_modules/.bin/grpc_tools_node_protoc_plugin$ext" | Resolve-Path 48 | $GRPC_TOOLS_NODE_PROTOC = "$PSScriptRoot/../node_modules/.bin/grpc_tools_node_protoc$ext" | Resolve-Path 49 | 50 | # Generate ts codes for each .proto file using the grpc-tools for Node. 51 | $arguments = @( 52 | "--plugin=protoc-gen-grpc=$GRPC_TOOLS_NODE_PROTOC_PLUGIN", 53 | "--plugin=protoc-gen-ts=$PROTOC_GEN_TS_PATH", 54 | # "--js_out=import_style=commonjs,binary:$PROTO_DIR", 55 | "--ts_out=$PROTO_GENERATED", 56 | "--grpc_out=grpc_js:$PROTO_GENERATED", 57 | "--proto_path=$PROTO_DIR", 58 | "$PROTO_DIR/*.proto" 59 | ) 60 | 61 | $proc = Start-Process $GRPC_TOOLS_NODE_PROTOC -ArgumentList $arguments -WorkingDirectory $PSScriptRoot -NoNewWindow -PassThru 62 | $proc.WaitForExit() 63 | } 64 | 65 | function Set-LintIgnore { 66 | Write-Host "Prepending eslint-disable and @ts-nocheck" 67 | 68 | # add @ts-nocheck to the generated files 69 | Get-ChildItem -Path $PROTO_GENERATED -Filter *.ts -Recurse | ForEach-Object { 70 | # Read the current contents of the file 71 | $contents = Get-Content $_.FullName -Raw 72 | 73 | # Prepend //@ts-nocheck to the file 74 | $contents = "/* eslint-disable */ $eol //@ts-nocheck $eol" + $contents 75 | 76 | # Write the new contents to the file 77 | Set-Content -Path $_.FullName -Value $contents 78 | } 79 | } 80 | Get-Pbs 81 | Invoke-ProtoGen 82 | Set-LintIgnore 83 | 84 | Write-Host "Done" -f Green -------------------------------------------------------------------------------- /packages/core/scripts/get-release.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter()] 4 | [string] 5 | $folder = "$PSScriptRoot\..\.binary" 6 | ) 7 | 8 | function New-Dir([string]$p) { 9 | if (!(Test-Path $p)) { 10 | New-Item -ItemType Directory -Force -Path $p 11 | } 12 | return $p | Resolve-Path 13 | } 14 | 15 | $folder = New-Dir $folder 16 | 17 | 18 | $ErrorActionPreference = "stop" 19 | 20 | function DownloadLatest() { 21 | $latest = Invoke-RestMethod -Uri "https://api.github.com/repos/lich0821/WeChatFerry/releases/latest" 22 | $turl = $latest.assets[0].browser_download_url 23 | Write-Host Get latest release download link: $turl -f Cyan 24 | # Get the filename from the URL 25 | $filename = [System.IO.Path]::GetFileName($turl) 26 | $output = "$folder\$filename" 27 | if (Test-Path $output) { 28 | Write-Host "File already exists." -f Yellow 29 | } 30 | else { 31 | Invoke-WebRequest -Uri $turl -OutFile $output 32 | } 33 | Write-Host "Downloaded to $output" -f Cyan 34 | return $output 35 | } 36 | 37 | function Unzip([string] $zip, [string] $dest = $folder) { 38 | Expand-Archive -Force -Path $zip -DestinationPath $dest 39 | } 40 | 41 | $out = DownloadLatest 42 | Unzip $out 43 | Remove-Item -Force $out 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /packages/core/scripts/wcferry.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [Parameter(Mandatory = $true)] 4 | [ValidateSet('start', 'stop')] 5 | [string] 6 | $Verb, 7 | [Parameter(Mandatory = $false)] 8 | [int] 9 | $Port = 10086 10 | ) 11 | 12 | $ErrorActionPreference = "stop" 13 | 14 | $Debug = "" 15 | if ($env:DEBUG) { 16 | $Debug = "debug" 17 | } 18 | 19 | Write-Host "Checking existence of wcf.exe ..." -f Cyan 20 | $binary = "$PSScriptRoot\..\.binary\wcf.exe" 21 | $dir = Split-Path $binary 22 | 23 | if (!(Test-Path $binary)) { 24 | Write-Host $dir 25 | & "$PSScriptRoot\get-release.ps1" $dir 26 | } 27 | 28 | $dir = $dir | Resolve-Path 29 | $binary = $binary | Resolve-Path 30 | 31 | Write-Host "wcf.exe is located in $binary" -f Green 32 | 33 | if ($Verb -eq 'start') { 34 | $arguments = @("start", "$Port", "$Debug") 35 | } 36 | else { 37 | $arguments = "stop" 38 | } 39 | 40 | Write-Host "try to $Verb wcf.exe as administrator" -f Cyan 41 | $proc = Start-Process -FilePath "$binary" -ArgumentList $arguments -Verb RunAs -Wait -PassThru -WorkingDirectory $dir 42 | 43 | if ( $proc.ExitCode -ne 0) { 44 | Write-Host "wcferry.exe exited abnormally" -f Red 45 | exit $proc.ExitCode 46 | } -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/client'; 2 | export * from './lib/file-ref'; 3 | export * from './lib/message'; 4 | -------------------------------------------------------------------------------- /packages/core/src/lib/client.spec.ts: -------------------------------------------------------------------------------- 1 | import { Wcferry } from './client'; 2 | 3 | describe.skip('wcf client e2e', () => { 4 | let client: Wcferry; 5 | beforeEach(async () => { 6 | client = new Wcferry(); 7 | await client.start(); 8 | expect(client.connected).toBe(true); 9 | }); 10 | 11 | afterEach(async () => { 12 | await client.stop(); 13 | }); 14 | 15 | it('isLogin', async () => { 16 | expect(client.isLogin()).toBeDefined(); 17 | }); 18 | 19 | it('getSelfWxid', async () => { 20 | expect(client.getSelfWxid()).toBeDefined(); 21 | }); 22 | 23 | it('getUserInfo', async () => { 24 | expect(client.getUserInfo()).toHaveProperty('wxid'); 25 | }); 26 | 27 | it('getContact', async () => { 28 | expect(client.getContact('filehelper')).toHaveProperty('wxid'); 29 | }); 30 | 31 | it('dbSqlQuery', async () => { 32 | const ret = client.dbSqlQuery( 33 | 'MicroMsg.db', 34 | `SELECT UserName From Contact WHERE UserName LIKE "wxid_%" LIMIT 1;` 35 | ); 36 | expect(ret).toHaveLength(1); 37 | 38 | expect(ret[0]).toHaveProperty('UserName'); 39 | expect(ret[0]['UserName'] as string).toMatch(/wxid_.*/); 40 | }); 41 | 42 | it('getChatRoomMembers(happy path)', async () => { 43 | const chatrooms = client.getChatRooms(); 44 | const ret = client.getChatRoomMembers(chatrooms[0].wxid); 45 | expect(Object.keys(ret).length).toBeTruthy(); 46 | }); 47 | 48 | it('getChatRoomMembers(bad path)', async () => { 49 | const ret = client.getChatRoomMembers('not existed'); 50 | expect(Object.keys(ret)).toHaveLength(0); 51 | }); 52 | 53 | it.skip( 54 | 'enableMsgReceiving', 55 | async () => { 56 | let times = 3; 57 | await new Promise((res) => 58 | client.on((msg) => { 59 | expect(msg).toBeDefined(); 60 | times--; 61 | if (times <= 0) { 62 | res(); 63 | } 64 | }) 65 | ); 66 | }, 67 | 1 * 60 * 1000 68 | ); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/core/src/lib/client.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import { Socket, SocketOptions } from '@rustup/nng'; 3 | import * as cp from 'child_process'; 4 | import debug from 'debug'; 5 | import { wcf } from './proto-generated/wcf'; 6 | import * as rd from './proto-generated/roomdata'; 7 | import * as eb from './proto-generated/extrabyte'; 8 | import { EventEmitter } from 'events'; 9 | import { 10 | createTmpDir, 11 | ensureDirSync, 12 | sleep, 13 | uint8Array2str, 14 | type ToPlainType, 15 | } from './utils'; 16 | import { FileRef, FileSavableInterface } from './file-ref'; 17 | import { Message } from './message'; 18 | import path from 'path'; 19 | export type UserInfo = ToPlainType; 20 | export type Contact = ToPlainType; 21 | export type DbTable = ToPlainType; 22 | 23 | export interface WcferryOptions { 24 | port?: number; 25 | /** if host is empty, the program will try to load wcferry.exe and *.dll */ 26 | host?: string; 27 | socketOptions?: SocketOptions; 28 | /** the cache dir to hold temp files, defaults to `os.tmpdir()/wcferry` */ 29 | cacheDir?: string; 30 | // 当使用wcferry.on(...)监听消息时,是否接受朋友圈消息 31 | recvPyq?: boolean; 32 | } 33 | 34 | const logger = debug('wcferry:client'); 35 | 36 | export class Wcferry { 37 | readonly NotFriend = { 38 | fmessage: '朋友推荐消息', 39 | medianote: '语音记事本', 40 | floatbottle: '漂流瓶', 41 | filehelper: '文件传输助手', 42 | newsapp: '新闻', 43 | }; 44 | 45 | private isMsgReceiving = false; 46 | private msgDispose?: () => void; 47 | private socket: Socket; 48 | private readonly localMode; 49 | private readonly msgEventSub = new EventEmitter(); 50 | private options: Required; 51 | constructor(options?: WcferryOptions) { 52 | this.localMode = !options?.host; 53 | this.options = { 54 | port: options?.port || 10086, 55 | host: options?.host || '127.0.0.1', 56 | socketOptions: options?.socketOptions ?? {}, 57 | cacheDir: options?.cacheDir || createTmpDir(), 58 | recvPyq: !!options?.recvPyq, 59 | }; 60 | 61 | ensureDirSync(this.options.cacheDir); 62 | 63 | this.msgEventSub.setMaxListeners(0); 64 | this.socket = new Socket(this.options.socketOptions); 65 | } 66 | 67 | private trapOnExit() { 68 | process.on('exit', () => this.stop()); 69 | } 70 | 71 | get connected() { 72 | return this.socket.connected(); 73 | } 74 | get msgReceiving() { 75 | return this.isMsgReceiving; 76 | } 77 | 78 | private createUrl(channel: 'cmd' | 'msg' = 'cmd') { 79 | const url = `tcp://${this.options.host}:${ 80 | this.options.port + (channel === 'cmd' ? 0 : 1) 81 | }`; 82 | logger(`wcf ${channel} url: %s`, url); 83 | return url; 84 | } 85 | 86 | /** 87 | * 设置是否接受朋友圈消息 88 | */ 89 | set recvPyq(pyq: boolean) { 90 | if (this.options.recvPyq === pyq) { 91 | return; 92 | } 93 | this.options.recvPyq = pyq; 94 | if (this.connected) { 95 | this.disableMsgReceiving(); 96 | this.enableMsgReceiving(); 97 | } 98 | } 99 | 100 | get recvPyq(): boolean { 101 | return this.options.recvPyq; 102 | } 103 | 104 | private get msgListenerCount() { 105 | return this.msgEventSub.listenerCount('wxmsg'); 106 | } 107 | 108 | start() { 109 | try { 110 | this.execDLL('start'); 111 | this.socket.connect(this.createUrl()); 112 | this.trapOnExit(); 113 | if (this.msgListenerCount > 0) { 114 | this.enableMsgReceiving(); 115 | } 116 | } catch (err) { 117 | logger('cannot connect to wcf RPC server, did wcf.exe started?'); 118 | throw err; 119 | } 120 | } 121 | 122 | execDLL(verb: 'start' | 'stop') { 123 | if (!this.localMode) { 124 | return; 125 | } 126 | const scriptPath = path.resolve(__dirname, '../../scripts/wcferry.ps1'); 127 | const process = cp.spawnSync( 128 | 'powershell', 129 | [ 130 | // '-NonInteractive', 131 | '-ExecutionPolicy Unrestricted', 132 | `-File ${scriptPath} -Verb ${verb} -Port ${this.options.port}`, 133 | ], 134 | { shell: true, stdio: 'inherit' } 135 | ); 136 | if (process.error || process.status !== 0) { 137 | throw new Error( 138 | `Cannot ${verb} wcferry DLL: ${ 139 | process.error || `exit ${process.status}` 140 | }` 141 | ); 142 | } 143 | } 144 | 145 | stop() { 146 | logger('Closing conneciton...'); 147 | this.disableMsgReceiving(); 148 | this.socket.close(); 149 | this.execDLL('stop'); 150 | } 151 | 152 | private sendRequest(req: wcf.Request): wcf.Response { 153 | const data = req.serialize(); 154 | const buf = this.socket.send(Buffer.from(data)); 155 | const res = wcf.Response.deserialize(buf); 156 | return res; 157 | } 158 | 159 | /** 是否已经登录 */ 160 | isLogin(): boolean { 161 | const req = new wcf.Request({ 162 | func: wcf.Functions.FUNC_IS_LOGIN, 163 | }); 164 | const rsp = this.sendRequest(req); 165 | return rsp.status == 1; 166 | } 167 | 168 | /**获取登录账号wxid */ 169 | getSelfWxid(): string { 170 | const req = new wcf.Request({ 171 | func: wcf.Functions.FUNC_GET_SELF_WXID, 172 | }); 173 | const rsp = this.sendRequest(req); 174 | return rsp.str; 175 | } 176 | 177 | /** 获取登录账号个人信息 */ 178 | getUserInfo(): UserInfo { 179 | const req = new wcf.Request({ 180 | func: wcf.Functions.FUNC_GET_USER_INFO, 181 | }); 182 | const rsp = this.sendRequest(req); 183 | return rsp.ui; 184 | } 185 | 186 | /** 获取完整通讯录 */ 187 | getContacts(): Contact[] { 188 | const req = new wcf.Request({ 189 | func: wcf.Functions.FUNC_GET_CONTACTS, 190 | }); 191 | const rsp = this.sendRequest(req); 192 | return rsp.contacts.contacts.map((c) => c.toObject() as Contact); 193 | } 194 | 195 | /** 通过 wxid 查询微信号昵称等信息 */ 196 | getContact(wxid: string): Contact | undefined { 197 | const req = new wcf.Request({ 198 | func: wcf.Functions.FUNC_GET_CONTACT_INFO, 199 | str: wxid, 200 | }); 201 | const rsp = this.sendRequest(req); 202 | return rsp.contacts.contacts[0].toObject() as Contact; 203 | } 204 | 205 | /** 获取所有数据库 */ 206 | getDbNames(): string[] { 207 | const req = new wcf.Request({ 208 | func: wcf.Functions.FUNC_GET_DB_NAMES, 209 | }); 210 | const rsp = this.sendRequest(req); 211 | return rsp.dbs.names; 212 | } 213 | 214 | /** 获取数据库中所有表 */ 215 | getDbTables(db: string): DbTable[] { 216 | const req = new wcf.Request({ 217 | func: wcf.Functions.FUNC_GET_DB_TABLES, 218 | str: db, 219 | }); 220 | const rsp = this.sendRequest(req); 221 | return rsp.tables.tables.map((t) => t.toObject() as DbTable); 222 | } 223 | 224 | /** 225 | * 执行 SQL 查询,如果数据量大注意分页 226 | * @param db 227 | * @param sql 228 | */ 229 | dbSqlQuery( 230 | db: string, 231 | sql: string 232 | ): Record[] { 233 | const req = new wcf.Request({ 234 | func: wcf.Functions.FUNC_EXEC_DB_QUERY, 235 | query: new wcf.DbQuery({ db, sql }), 236 | }); 237 | const rsp = this.sendRequest(req); 238 | const rows = rsp.rows.rows; 239 | return rows.map((r) => 240 | Object.fromEntries( 241 | r.fields.map((f) => [f.column, parseDbField(f.type, f.content)]) 242 | ) 243 | ); 244 | } 245 | 246 | /** 247 | * 获取消息类型 248 | * {"47": "石头剪刀布 | 表情图片", "62": "小视频", "43": "视频", "1": "文字", "10002": "撤回消息", "40": "POSSIBLEFRIEND_MSG", "10000": "红包、系统消息", "37": "好友确认", "48": "位置", "42": "名片", "49": "共享实时位置、文件、转账、链接", "3": "图片", "34": "语音", "9999": "SYSNOTICE", "52": "VOIPNOTIFY", "53": "VOIPINVITE", "51": "微信初始化", "50": "VOIPMSG"} 249 | */ 250 | getMsgTypes(): Map { 251 | const req = new wcf.Request({ 252 | func: wcf.Functions.FUNC_GET_MSG_TYPES, 253 | }); 254 | const rsp = this.sendRequest(req); 255 | return rsp.types.types; 256 | } 257 | 258 | /** 259 | * 刷新朋友圈 260 | * @param id 开始 id,0 为最新页 (string based uint64) 261 | * @returns 1 为成功,其他失败 262 | */ 263 | refreshPyq(id: string): number { 264 | const req = new wcf.Request({ 265 | func: wcf.Functions.FUNC_REFRESH_PYQ, 266 | ui64: id, 267 | }); 268 | const rsp = this.sendRequest(req); 269 | return rsp.status; 270 | } 271 | 272 | /** 获取群聊列表 */ 273 | getChatRooms(): Contact[] { 274 | const contacts = this.getContacts(); 275 | return contacts.filter((c) => c.wxid.endsWith('@chatroom')); 276 | } 277 | 278 | /** 279 | * 获取好友列表 280 | * @returns 281 | */ 282 | getFriends() { 283 | const contacts = this.getContacts(); 284 | return contacts.filter( 285 | (c) => 286 | !c.wxid.endsWith('@chatroom') && 287 | !c.wxid.startsWith('gh_') && 288 | !Object.hasOwn(this.NotFriend, c.wxid) 289 | ); 290 | } 291 | 292 | /** 293 | * 获取群成员 294 | * @param roomid 群的 id 295 | * @param times 重试次数 296 | * @returns 群成员列表: {wxid1: 昵称1, wxid2: 昵称2, ...} 297 | */ 298 | async getChatRoomMembers( 299 | roomid: string, 300 | times = 5 301 | ): Promise> { 302 | if (times === 0) { 303 | return {}; 304 | } 305 | const [room] = this.dbSqlQuery( 306 | 'MicroMsg.db', 307 | `SELECT RoomData FROM ChatRoom WHERE ChatRoomName = '${roomid}';` 308 | ); 309 | if (!room) { 310 | await sleep(); 311 | return this.getChatRoomMembers(roomid, times - 1) ?? {}; 312 | } 313 | 314 | const r = rd.com.iamteer.wcf.RoomData.deserialize( 315 | room['RoomData'] as Buffer 316 | ); 317 | 318 | const userRds = this.dbSqlQuery( 319 | 'MicroMsg.db', 320 | 'SELECT UserName, NickName FROM Contact;' 321 | ); 322 | 323 | const userDict = Object.fromEntries( 324 | userRds.map((u) => [u['UserName'], u['NickName']] as const) 325 | ); 326 | 327 | return Object.fromEntries( 328 | r.members.map((member) => [ 329 | member.wxid, 330 | member.name || userDict[member.wxid], 331 | ]) 332 | ); 333 | } 334 | 335 | /** 336 | * 获取群成员昵称 337 | * @param wxid 338 | * @param roomid 339 | * @returns 群名片 340 | */ 341 | getAliasInChatRoom(wxid: string, roomid: string): string | undefined { 342 | const [room] = this.dbSqlQuery( 343 | 'MicroMsg.db', 344 | `SELECT RoomData FROM ChatRoom WHERE ChatRoomName = '${roomid}';` 345 | ); 346 | if (!room) { 347 | return undefined; 348 | } 349 | 350 | const roomData = rd.com.iamteer.wcf.RoomData.deserialize( 351 | room['RoomData'] as Buffer 352 | ); 353 | return ( 354 | roomData.members.find((m) => m.wxid === wxid)?.name || 355 | this.getNickName(wxid)?.[0] 356 | ); 357 | } 358 | 359 | /** 360 | * be careful to SQL injection 361 | * @param wxids wxids 362 | */ 363 | getNickName(...wxids: string[]): Array { 364 | const rows = this.dbSqlQuery( 365 | 'MicroMsg.db', 366 | `SELECT NickName FROM Contact WHERE UserName in (${wxids 367 | .map((id) => `'${id}'`) 368 | .join(',')});` 369 | ); 370 | return rows.map((row) => row['NickName'] as string | undefined); 371 | } 372 | 373 | /** 374 | * 邀请群成员 375 | * @param roomid 376 | * @param wxids 377 | * @returns int32 1 为成功,其他失败 378 | */ 379 | inviteChatroomMembers(roomid: string, wxids: string[]): number { 380 | const req = new wcf.Request({ 381 | func: wcf.Functions.FUNC_INV_ROOM_MEMBERS, 382 | m: new wcf.MemberMgmt({ 383 | roomid, 384 | wxids: wxids.join(',').replaceAll(' ', ''), 385 | }), 386 | }); 387 | const rsp = this.sendRequest(req); 388 | return rsp.status; 389 | } 390 | 391 | /** 392 | * 添加群成员 393 | * @param roomid 394 | * @param wxids 395 | * @returns int32 1 为成功,其他失败 396 | */ 397 | addChatRoomMembers(roomid: string, wxids: string[]): number { 398 | const req = new wcf.Request({ 399 | func: wcf.Functions.FUNC_ADD_ROOM_MEMBERS, 400 | m: new wcf.MemberMgmt({ 401 | roomid, 402 | wxids: wxids.join(',').replaceAll(' ', ''), 403 | }), 404 | }); 405 | const rsp = this.sendRequest(req); 406 | return rsp.status; 407 | } 408 | 409 | /** 410 | * 删除群成员 411 | * @param roomid 412 | * @param wxids 413 | * @returns int32 1 为成功,其他失败 414 | */ 415 | delChatRoomMembers(roomid: string, wxids: string[]): number { 416 | const req = new wcf.Request({ 417 | func: wcf.Functions.FUNC_DEL_ROOM_MEMBERS, 418 | m: new wcf.MemberMgmt({ 419 | roomid, 420 | wxids: wxids.join(',').replaceAll(' ', ''), 421 | }), 422 | }); 423 | const rsp = this.sendRequest(req); 424 | return rsp.status; 425 | } 426 | 427 | /** 428 | * 撤回消息 429 | * @param msgid (uint64 in string format): 消息 id 430 | * @returns int: 1 为成功,其他失败 431 | */ 432 | revokeMsg(msgid: string): number { 433 | const req = new wcf.Request({ 434 | func: wcf.Functions.FUNC_REVOKE_MSG, 435 | ui64: msgid, 436 | }); 437 | const rsp = this.sendRequest(req); 438 | return rsp.status; 439 | } 440 | 441 | /** 442 | * 转发消息。可以转发文本、图片、表情、甚至各种 XML;语音也行,不过效果嘛,自己验证吧。 443 | * @param msgid (uint64 in string format): 消息 id 444 | * @param receiver string 消息接收人,wxid 或者 roomid 445 | * @returns int: 1 为成功,其他失败 446 | */ 447 | forwardMsg(msgid: string, receiver: string): number { 448 | const req = new wcf.Request({ 449 | func: wcf.Functions.FUNC_FORWARD_MSG, 450 | fm: new wcf.ForwardMsg({ 451 | id: msgid, 452 | receiver, 453 | }), 454 | }); 455 | const rsp = this.sendRequest(req); 456 | return rsp.status; 457 | } 458 | 459 | /** 460 | * 发送文本消息 461 | * @param msg 要发送的消息,换行使用 `\n` (单杠);如果 @ 人的话,需要带上跟 `aters` 里数量相同的 @ 462 | * @param receiver 消息接收人,wxid 或者 roomid 463 | * @param aters 要 @ 的 wxid,多个用逗号分隔;`@所有人` 只需要 `notify@all` 464 | * @returns 0 为成功,其他失败 465 | */ 466 | sendTxt(msg: string, receiver: string, aters?: string): number { 467 | const req = new wcf.Request({ 468 | func: wcf.Functions.FUNC_SEND_TXT, 469 | txt: new wcf.TextMsg({ 470 | msg, 471 | receiver, 472 | aters, 473 | }), 474 | }); 475 | const rsp = this.sendRequest(req); 476 | return rsp.status; 477 | } 478 | 479 | /** 480 | * @param image location of the resource, can be: 481 | * - a local path (`C:\\Users` or `/home/user`), 482 | * - a link starts with `http(s)://`, 483 | * - a buffer (base64 string can be convert to buffer by `Buffer.from(, 'base64')`) 484 | * - an object { type: 'Buffer', data: number[] } which can convert to Buffer 485 | * - a FileSavableInterface instance 486 | * @param receiver 消息接收人,wxid 或者 roomid 487 | * @returns 0 为成功,其他失败 488 | */ 489 | async sendImage( 490 | image: 491 | | string 492 | | Buffer 493 | | { type: 'Buffer'; data: number[] } 494 | | FileSavableInterface, 495 | receiver: string 496 | ): Promise { 497 | const fileRef = toRef(image); 498 | const { path, discard } = await fileRef.save(this.options.cacheDir); 499 | const req = new wcf.Request({ 500 | func: wcf.Functions.FUNC_SEND_IMG, 501 | file: new wcf.PathMsg({ 502 | path, 503 | receiver, 504 | }), 505 | }); 506 | const rsp = this.sendRequest(req); 507 | void discard(); 508 | return rsp.status; 509 | } 510 | 511 | /** 512 | * @param file location of the resource, can be: 513 | * - a local path (`C:\\Users` or `/home/user`), 514 | * - a link starts with `http(s)://`, 515 | * - a buffer (base64 string can be convert to buffer by `Buffer.from(, 'base64')`) 516 | * - an object { type: 'Buffer', data: number[] } which can convert to Buffer 517 | * - a FileSavableInterface instance 518 | * @param receiver 消息接收人,wxid 或者 roomid 519 | * @returns 0 为成功,其他失败 520 | */ 521 | async sendFile( 522 | file: 523 | | string 524 | | Buffer 525 | | { type: 'Buffer'; data: number[] } 526 | | FileSavableInterface, 527 | receiver: string 528 | ): Promise { 529 | const fileRef = toRef(file); 530 | const { path, discard } = await fileRef.save(this.options.cacheDir); 531 | const req = new wcf.Request({ 532 | func: wcf.Functions.FUNC_SEND_FILE, 533 | file: new wcf.PathMsg({ 534 | path, 535 | receiver, 536 | }), 537 | }); 538 | const rsp = this.sendRequest(req); 539 | void discard(); 540 | return rsp.status; 541 | } 542 | 543 | /** 544 | * @deprecated Not supported 545 | * 发送XML 546 | * @param xml.content xml 内容 547 | * @param xml.path 封面图片路径 548 | * @param receiver xml 类型,如:0x21 为小程序 549 | * @returns 0 为成功,其他失败 550 | */ 551 | sendXML( 552 | xml: { content: string; path?: string; type: number }, 553 | receiver: string 554 | ): number { 555 | const req = new wcf.Request({ 556 | func: wcf.Functions.FUNC_SEND_XML, 557 | xml: new wcf.XmlMsg({ 558 | receiver, 559 | content: xml.content, 560 | type: xml.type, 561 | path: xml.path, 562 | }), 563 | }); 564 | const rsp = this.sendRequest(req); 565 | return rsp.status; 566 | } 567 | 568 | /** 569 | * @deprecated Not supported 570 | * 发送表情 571 | * @param path 本地表情路径,如:`C:/Projs/WeChatRobot/emo.gif` 572 | * @param receiver 消息接收人,wxid 或者 roomid 573 | * @returns 0 为成功,其他失败 574 | */ 575 | sendEmotion(path: string, receiver: string): number { 576 | const req = new wcf.Request({ 577 | func: wcf.Functions.FUNC_SEND_EMOTION, 578 | file: new wcf.PathMsg({ 579 | path, 580 | receiver, 581 | }), 582 | }); 583 | const rsp = this.sendRequest(req); 584 | return rsp.status; 585 | } 586 | 587 | /** 588 | * 发送富文本消息 589 | * 卡片样式: 590 | * |-------------------------------------| 591 | * |title, 最长两行 592 | * |(长标题, 标题短的话这行没有) 593 | * |digest, 最多三行,会占位 |--------| 594 | * |digest, 最多三行,会占位 |thumburl| 595 | * |digest, 最多三行,会占位 |--------| 596 | * |(account logo) name 597 | * |-------------------------------------| 598 | * @param desc.name 左下显示的名字 599 | * @param desc.account 填公众号 id 可以显示对应的头像(gh_ 开头的) 600 | * @param desc.title 标题,最多两行 601 | * @param desc.digest 摘要,三行 602 | * @param desc.url 点击后跳转的链接 603 | * @param desc.thumburl 缩略图的链接 604 | * @param receiver 接收人, wxid 或者 roomid 605 | * @returns 0 为成功,其他失败 606 | */ 607 | sendRichText( 608 | desc: Omit, 'receiver'>, 609 | receiver: string 610 | ): number { 611 | const req = new wcf.Request({ 612 | func: wcf.Functions.FUNC_SEND_RICH_TXT, 613 | rt: new wcf.RichText({ 614 | ...desc, 615 | receiver, 616 | }), 617 | }); 618 | const rsp = this.sendRequest(req); 619 | return rsp.status; 620 | } 621 | 622 | /** 623 | * 拍一拍群友 624 | * @param roomid 群 id 625 | * @param wxid 要拍的群友的 wxid 626 | * @returns 1 为成功,其他失败 627 | */ 628 | sendPat(roomid: string, wxid: string): number { 629 | const req = new wcf.Request({ 630 | func: wcf.Functions.FUNC_SEND_PAT_MSG, 631 | pm: new wcf.PatMsg({ 632 | roomid, 633 | wxid, 634 | }), 635 | }); 636 | const rsp = this.sendRequest(req); 637 | return rsp.status; 638 | } 639 | 640 | /** 641 | * 获取语音消息并转成 MP3 642 | * @param msgid 语音消息 id 643 | * @param dir MP3 保存目录(目录不存在会出错) 644 | * @param times 超时时间(秒) 645 | * @returns 成功返回存储路径;空字符串为失败,原因见日志。 646 | */ 647 | async getAudioMsg(msgid: string, dir: string, times = 3): Promise { 648 | const req = new wcf.Request({ 649 | func: wcf.Functions.FUNC_GET_AUDIO_MSG, 650 | am: new wcf.AudioMsg({ 651 | id: msgid, 652 | dir, 653 | }), 654 | }); 655 | const rsp = this.sendRequest(req); 656 | if (rsp.str) { 657 | return rsp.str; 658 | } 659 | if (times > 0) { 660 | await sleep(); 661 | return this.getAudioMsg(msgid, dir, times - 1); 662 | } 663 | throw new Error('Timeout: get audio msg'); 664 | } 665 | 666 | /** 667 | * 获取 OCR 结果。鸡肋,需要图片能自动下载;通过下载接口下载的图片无法识别。 668 | * @param extra 待识别的图片路径,消息里的 extra 669 | * @param times OCR 结果 670 | * @returns 671 | */ 672 | async getOCRResult(extra: string, times = 2): Promise { 673 | const req = new wcf.Request({ 674 | func: wcf.Functions.FUNC_EXEC_OCR, 675 | str: extra, 676 | }); 677 | const rsp = this.sendRequest(req); 678 | if (rsp.ocr.status === 0 && rsp.ocr.result) { 679 | return rsp.ocr.result; 680 | } 681 | 682 | if (times > 0) { 683 | await sleep(); 684 | return this.getOCRResult(extra, times - 1); 685 | } 686 | throw new Error('Timeout: get ocr result'); 687 | } 688 | 689 | /** 690 | * @deprecated 下载附件(图片、视频、文件)。这方法别直接调用,下载图片使用 `download_image` 691 | * @param msgid 消息中 id 692 | * @param thumb 消息中的 thumb 693 | * @param extra 消息中的 extra 694 | * @returns 0 为成功, 其他失败。 695 | */ 696 | downloadAttach( 697 | msgid: string, 698 | thumb: string = '', 699 | extra: string = '' 700 | ): number { 701 | const req = new wcf.Request({ 702 | func: wcf.Functions.FUNC_DOWNLOAD_ATTACH, 703 | att: new wcf.AttachMsg({ 704 | id: msgid, 705 | thumb, 706 | extra, 707 | }), 708 | }); 709 | const rsp = this.sendRequest(req); 710 | return rsp.status; 711 | } 712 | 713 | // TODO: get correct wechat files directory somewhere? 714 | private readonly UserDir = path.join( 715 | os.homedir(), 716 | 'Documents', 717 | 'WeChat Files' 718 | ); 719 | 720 | private getMsgAttachments(msgid: string): { 721 | extra?: string; 722 | thumb?: string; 723 | } { 724 | const messages = this.dbSqlQuery( 725 | 'MSG0.db', 726 | `Select * from MSG WHERE MsgSvrID = "${msgid}"` 727 | ); 728 | const buf = messages?.[0]?.['BytesExtra']; 729 | if (!Buffer.isBuffer(buf)) { 730 | return {}; 731 | } 732 | const extraData = eb.com.iamteer.wcf.Extra.deserialize(buf); 733 | const { properties } = extraData.toObject(); 734 | if (!properties) { 735 | return {}; 736 | } 737 | const propertyMap: Partial< 738 | Record 739 | > = Object.fromEntries(properties.map((p) => [p.type, p.value])); 740 | const extra = propertyMap[eb.com.iamteer.wcf.Extra.PropertyKey.Extra]; 741 | const thumb = propertyMap[eb.com.iamteer.wcf.Extra.PropertyKey.Thumb]; 742 | 743 | return { 744 | extra: extra ? path.resolve(this.UserDir, extra) : '', 745 | thumb: thumb ? path.resolve(this.UserDir, thumb) : '', 746 | }; 747 | } 748 | 749 | /** 750 | * @deprecated 解密图片。这方法别直接调用,下载图片使用 `download_image`。 751 | * @param src 加密的图片路径 752 | * @param dir 保存图片的目录 753 | * @returns 754 | */ 755 | decryptImage(src: string, dir: string): string { 756 | const req = new wcf.Request({ 757 | func: wcf.Functions.FUNC_DECRYPT_IMAGE, 758 | dec: new wcf.DecPath({ 759 | src, 760 | dst: dir, 761 | }), 762 | }); 763 | const rsp = this.sendRequest(req); 764 | return rsp.str; 765 | } 766 | 767 | /** 768 | * 下载图片 769 | * @param msgid 消息中 id 770 | * @param dir 存放图片的目录(目录不存在会出错) 771 | * @param extra 消息中的 extra, 如果为空,自动通过msgid获取 772 | * @param times 超时时间(秒) 773 | * @returns 成功返回存储路径;空字符串为失败,原因见日志。 774 | */ 775 | async downloadImage( 776 | msgid: string, 777 | dir: string, 778 | extra?: string, 779 | thumb?: string, 780 | times = 30 781 | ): Promise { 782 | const msgAttachments = extra 783 | ? { extra, thumb } 784 | : this.getMsgAttachments(msgid); 785 | if ( 786 | this.downloadAttach( 787 | msgid, 788 | msgAttachments.thumb, 789 | msgAttachments.extra 790 | ) !== 0 791 | ) { 792 | return Promise.reject('Failed to download attach'); 793 | } 794 | for (let cnt = 0; cnt < times; cnt++) { 795 | const path = this.decryptImage(msgAttachments.extra || '', dir); 796 | if (path) { 797 | return path; 798 | } 799 | await sleep(); 800 | } 801 | return Promise.reject('Failed to decrypt image'); 802 | } 803 | 804 | /** 805 | * 通过好友申请 806 | * @param v3 加密用户名 (好友申请消息里 v3 开头的字符串) 807 | * @param v4 Ticket (好友申请消息里 v4 开头的字符串) 808 | * @param scene 申请方式 (好友申请消息里的 scene); 为了兼容旧接口,默认为扫码添加 (30) 809 | * @returns 1 为成功,其他失败 810 | */ 811 | acceptNewFriend(v3: string, v4: string, scene = 30): number { 812 | const req = new wcf.Request({ 813 | func: wcf.Functions.FUNC_ACCEPT_FRIEND, 814 | v: new wcf.Verification({ 815 | v3, 816 | v4, 817 | scene, 818 | }), 819 | }); 820 | const rsp = this.sendRequest(req); 821 | return rsp.status; 822 | } 823 | 824 | /** 825 | * 接收转账 826 | * @param wxid 转账消息里的发送人 wxid 827 | * @param transferid 转账消息里的 transferid 828 | * @param transactionid 转账消息里的 transactionid 829 | * @returns 1 为成功,其他失败 830 | */ 831 | receiveTransfer( 832 | wxid: string, 833 | transferid: string, 834 | transactionid: string 835 | ): number { 836 | const req = new wcf.Request({ 837 | func: wcf.Functions.FUNC_RECV_TRANSFER, 838 | tf: new wcf.Transfer({ 839 | wxid, 840 | tfid: transferid, 841 | taid: transactionid, 842 | }), 843 | }); 844 | const rsp = this.sendRequest(req); 845 | return rsp.status; 846 | } 847 | 848 | /** 849 | * @internal 允许接收消息,自动根据on(...)注册的listener调用 850 | * @param pyq 851 | * @returns 852 | */ 853 | private enableMsgReceiving(): boolean { 854 | if (this.isMsgReceiving) { 855 | return true; 856 | } 857 | const req = new wcf.Request({ 858 | func: wcf.Functions.FUNC_ENABLE_RECV_TXT, 859 | flag: this.options.recvPyq, 860 | }); 861 | const rsp = this.sendRequest(req); 862 | if (rsp.status !== 0) { 863 | this.isMsgReceiving = false; 864 | return false; 865 | } 866 | try { 867 | this.msgDispose = this.receiveMessage(); 868 | this.isMsgReceiving = true; 869 | return true; 870 | } catch (err) { 871 | this.msgDispose?.(); 872 | this.isMsgReceiving = false; 873 | logger('enable message receiving error: %O', err); 874 | return false; 875 | } 876 | } 877 | 878 | /** 879 | * @internal 停止接收消息,自动根据on(...)注册/注销的listener 调用 880 | * @param force 881 | * @returns 882 | */ 883 | private disableMsgReceiving(force = false): number { 884 | if (!force && !this.isMsgReceiving) { 885 | return 0; 886 | } 887 | const req = new wcf.Request({ 888 | func: wcf.Functions.FUNC_DISABLE_RECV_TXT, 889 | }); 890 | const rsp = this.sendRequest(req); 891 | this.isMsgReceiving = false; 892 | this.msgDispose?.(); 893 | this.msgDispose = undefined; 894 | return rsp.status; 895 | } 896 | 897 | private receiveMessage() { 898 | const disposable = Socket.recvMessage( 899 | this.createUrl('msg'), 900 | null, 901 | this.messageCallback.bind(this) 902 | ); 903 | return () => disposable.dispose(); 904 | } 905 | 906 | private messageCallback(err: unknown | undefined, buf: Buffer) { 907 | if (err) { 908 | logger('error while receiving message: %O', err); 909 | return; 910 | } 911 | const rsp = wcf.Response.deserialize(buf); 912 | this.msgEventSub.emit('wxmsg', new Message(rsp.wxmsg)); 913 | } 914 | 915 | /** 916 | * 注册消息回调监听函数(listener), 通过call返回的函数注销 917 | * 当注册的监听函数数量大于0是自动调用enableMsgReceiving,否则自动调用disableMsgReceiving 918 | * 设置wcferry.recvPyq = true/false 来开启关闭接受朋友圈消息 919 | * @param callback 监听函数 920 | * @returns 注销监听函数 921 | */ 922 | on(callback: (msg: Message) => void): () => void { 923 | this.msgEventSub.on('wxmsg', callback); 924 | if (this.connected && this.msgEventSub.listenerCount('wxmsg') === 1) { 925 | this.enableMsgReceiving(); 926 | } 927 | return () => { 928 | if ( 929 | this.connected && 930 | this.msgEventSub.listenerCount('wxmsg') === 1 931 | ) { 932 | this.disableMsgReceiving(); 933 | } 934 | this.msgEventSub.off('wxmsg', callback); 935 | }; 936 | } 937 | } 938 | 939 | function toRef( 940 | file: 941 | | string 942 | | Buffer 943 | | { type: 'Buffer'; data: number[] } 944 | | FileSavableInterface 945 | ): FileSavableInterface { 946 | if (typeof file === 'string' || Buffer.isBuffer(file)) { 947 | return new FileRef(file); 948 | } 949 | if ('save' in file) { 950 | return file; 951 | } 952 | return new FileRef(Buffer.from(file.data)); 953 | } 954 | 955 | function parseDbField(type: number, content: Uint8Array) { 956 | // self._SQL_TYPES = {1: int, 2: float, 3: lambda x: x.decode("utf-8"), 4: bytes, 5: lambda x: None} 957 | switch (type) { 958 | case 1: 959 | const strContent = uint8Array2str(content); 960 | const bigIntContent = BigInt(strContent); 961 | if (bigIntContent > Number.MAX_SAFE_INTEGER) { 962 | // bigInt 在JSON.stringify时会出问题,还是返回字符串吧 963 | // TypeError: Do not know how to serialize a BigInt 964 | return strContent; 965 | } 966 | return Number.parseInt(strContent, 10); 967 | case 2: 968 | return Number.parseFloat(uint8Array2str(content)); 969 | case 3: 970 | default: 971 | return uint8Array2str(content); 972 | case 4: 973 | return Buffer.from(content); 974 | case 5: 975 | return undefined; 976 | } 977 | } 978 | -------------------------------------------------------------------------------- /packages/core/src/lib/file-ref.spec.ts: -------------------------------------------------------------------------------- 1 | import { rm } from 'fs/promises'; 2 | import { createTmpDir, ensureDirSync } from './utils'; 3 | import path from 'path'; 4 | import { FileRef } from './file-ref'; 5 | import { existsSync } from 'fs'; 6 | import { randomUUID } from 'crypto'; 7 | 8 | describe('file-ref', () => { 9 | const cacheDir = createTmpDir('wcferry-test'); 10 | const testImagePath = path.join( 11 | __dirname, 12 | '../../fixtures/test_image.jpeg' 13 | ); 14 | const testUrl = `https://avatars.githubusercontent.com/u/5887203`; 15 | const testBase64 = Buffer.from(`R0lGODlhAQABAAAAACw=`, 'base64'); 16 | beforeEach(() => { 17 | ensureDirSync(cacheDir); 18 | }); 19 | afterEach(async () => { 20 | await rm(cacheDir, { recursive: true, force: true }); 21 | }); 22 | 23 | describe('save local file', () => { 24 | it('no copy (default))', async () => { 25 | const ref = new FileRef(testImagePath); 26 | const p = await ref.save(cacheDir); 27 | const copied = path.join(cacheDir, path.basename(testImagePath)); 28 | expect(p.path).not.toBe(copied); 29 | expect(existsSync(copied)).toBeFalsy(); 30 | await p.discard(); 31 | expect(existsSync(p.path)).toBeTruthy(); 32 | }); 33 | 34 | it('copy', async () => { 35 | const ref = new FileRef(testImagePath); 36 | const p = await ref.save(cacheDir, true); 37 | const copied = path.join(cacheDir, path.basename(testImagePath)); 38 | expect(p.path).toBe(copied); 39 | expect(existsSync(copied)).toBeTruthy(); 40 | await p.discard(); 41 | expect(existsSync(p.path)).toBeFalsy(); 42 | }); 43 | }); 44 | 45 | it('save url', async () => { 46 | const ref = new FileRef(testUrl); 47 | const p = await ref.save(cacheDir); 48 | expect(path.dirname(p.path)).toBe(cacheDir); 49 | expect(path.basename(p.path)).toBe(`5887203.jpeg`); 50 | expect(existsSync(p.path)).toBeTruthy(); 51 | }); 52 | 53 | it('save base64', async () => { 54 | const ref = new FileRef(testBase64); 55 | const ref2 = new FileRef(testBase64, { 56 | name: 'image.gif', 57 | }); 58 | const p = await ref.save(cacheDir); 59 | const p2 = await ref2.save(cacheDir); 60 | expect(path.dirname(p.path)).toBe(cacheDir); 61 | expect(path.basename(p.path)).toMatch(/.dat$/); 62 | expect(path.extname(p2.path)).toBe('.gif'); 63 | expect(existsSync(p.path)).toBeTruthy(); 64 | }); 65 | 66 | it('can auto resolve conflicts', async () => { 67 | const name = randomUUID(); 68 | const ref = new FileRef(testBase64, { name }); 69 | const p1 = await ref.save(cacheDir); 70 | const p2 = await ref.save(cacheDir); 71 | expect(path.basename(p1.path, '.dat')).toBe(name); 72 | expect(path.basename(p2.path, '.dat')).toBe(`${name}-1`); 73 | }); 74 | 75 | it('should error when location is invalid', async () => { 76 | const ref = new FileRef(randomUUID()); 77 | await expect(ref.save(cacheDir)).resolves.toBeDefined(); 78 | await expect(ref.save(cacheDir, true)).rejects.toThrow(); 79 | const ref2 = new FileRef(`http://${randomUUID()}`); 80 | await expect(ref2.save(cacheDir)).rejects.toThrow(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /packages/core/src/lib/file-ref.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import { randomUUID } from 'crypto'; 3 | import { cp, rm, writeFile } from 'fs/promises'; 4 | import mime from 'mime'; 5 | import path from 'path'; 6 | import { URL } from 'url'; 7 | import { ensureDirSync } from './utils'; 8 | import { createWriteStream, existsSync } from 'fs'; 9 | import assert from 'assert'; 10 | import type { OutgoingHttpHeaders } from 'http'; 11 | 12 | const headers = { 13 | 'User-Agent': 14 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36', 15 | }; 16 | 17 | export interface FileSavableInterface { 18 | save(dir?: string): Promise<{ path: string; discard: () => Promise }>; 19 | } 20 | 21 | export class FileRef implements FileSavableInterface { 22 | /** 23 | * @param location location of the resource. can be 24 | * - a local path 25 | * - a link starts with `http(s)://` 26 | * - a buffer 27 | * 28 | * Note: base64 string can be convert to buffer by: `Buffer.from('content', 'base64')` 29 | * Note: if input is a Buffer, it would be nice to have a name with correct extension in the options, 30 | * or a common name `.dat` will be used 31 | */ 32 | constructor( 33 | private readonly location: string | Buffer, 34 | private readonly options: { 35 | name?: string; 36 | headers?: OutgoingHttpHeaders; 37 | } = {} 38 | ) {} 39 | 40 | private isUrl(loc: string) { 41 | return /^https?:\/\//.test(loc); 42 | } 43 | 44 | /** 45 | * save the file into dir with name and extension inferred 46 | * @param dir the saving directory, defaults to `os.tmpdir()` 47 | * @param cpLocal when the source is local file, if we copy it to dir or directly return the source path 48 | * @returns 49 | */ 50 | async save( 51 | dir = os.tmpdir(), 52 | cpLocal = false 53 | ): Promise<{ path: string; discard: () => Promise }> { 54 | ensureDirSync(dir); 55 | if (Buffer.isBuffer(this.location)) { 56 | return this.wrapWithDiscard( 57 | await this.saveFromBase64(dir, this.location) 58 | ); 59 | } 60 | if (this.isUrl(this.location)) { 61 | const p = await this.saveFromUrl(dir, new URL(this.location)); 62 | return this.wrapWithDiscard(p); 63 | } 64 | 65 | if (cpLocal) { 66 | return this.wrapWithDiscard(await this.saveFromFile(dir)); 67 | } 68 | // if file existed in local, we direct use it 69 | return { 70 | path: this.location, 71 | discard: () => Promise.resolve(), 72 | }; 73 | } 74 | 75 | wrapWithDiscard(p: string) { 76 | return { 77 | path: p, 78 | discard: () => rm(p, { force: true }), 79 | }; 80 | } 81 | 82 | private getName(opt?: { mimeType?: string; inferredName?: string }) { 83 | const basename = this.options.name ?? opt?.inferredName ?? randomUUID(); 84 | let ext = path.extname(basename); 85 | if (ext) { 86 | return basename; 87 | } 88 | ext = 'dat'; 89 | if (opt?.mimeType) { 90 | ext = mime.getExtension(opt.mimeType) ?? ext; 91 | } 92 | return `${basename}.${ext}`; 93 | } 94 | 95 | private getSavingPath(dir: string, name: string): string { 96 | const extname = path.extname(name); 97 | const basename = path.basename(name, extname); 98 | for (let i = 0; ; i++) { 99 | const suffix = i === 0 ? '' : `-${i}`; 100 | const p = path.join(dir, `${basename}${suffix}${extname}`); 101 | if (!existsSync(p)) { 102 | return p; 103 | } 104 | } 105 | } 106 | 107 | private async saveFromBase64(dir: string, buffer: Buffer): Promise { 108 | const binary = buffer.toString('binary'); 109 | const name = this.getName(); 110 | const fullpath = this.getSavingPath(dir, name); 111 | await writeFile(fullpath, binary, 'binary'); 112 | return fullpath; 113 | } 114 | 115 | private async saveFromUrl(dir: string, url: URL): Promise { 116 | const basename = path.basename(url.pathname); 117 | let fullpath: string | undefined; 118 | const http = 119 | url.protocol === 'https:' 120 | ? await import('https') 121 | : await import('http'); 122 | return await new Promise((resolve, reject) => { 123 | http.get(url, { headers }, (response) => { 124 | const probeName = 125 | response.headers['content-disposition']?.match( 126 | /attachment; filename="?(.+[^"])"?$/i 127 | )?.[1] ?? basename; 128 | 129 | const mimeType = response.headers['content-type']; 130 | const name = this.getName({ 131 | mimeType, 132 | inferredName: probeName, 133 | }); 134 | fullpath = this.getSavingPath(dir, name); 135 | const file = createWriteStream(fullpath); 136 | response.pipe(file); 137 | 138 | file.on('finish', () => { 139 | file.close(); 140 | resolve(fullpath!); 141 | }); 142 | }).on('error', (error) => { 143 | if (fullpath) { 144 | rm(fullpath, { force: true }).finally(() => { 145 | reject(error.message); 146 | }); 147 | } else { 148 | reject(error.message); 149 | } 150 | }); 151 | }); 152 | } 153 | 154 | private async saveFromFile(dir: string): Promise { 155 | assert(typeof this.location === 'string', 'impossible'); 156 | if (!existsSync(this.location)) { 157 | return Promise.reject( 158 | new Error(`Source file ${this.location} doesn't exist`) 159 | ); 160 | } 161 | const name = this.getName({ 162 | inferredName: path.basename(this.location), 163 | }); 164 | const saved = this.getSavingPath(dir, name); 165 | 166 | await cp(this.location, saved, { force: true }); 167 | return saved; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /packages/core/src/lib/message.ts: -------------------------------------------------------------------------------- 1 | import { wcf } from './proto-generated/wcf'; 2 | import { ToPlainType } from './utils'; 3 | 4 | export type RawMessage = ToPlainType; 5 | 6 | export class Message { 7 | constructor(private readonly message: wcf.WxMsg) {} 8 | 9 | get raw(): RawMessage { 10 | return this.message.toObject() as RawMessage; 11 | } 12 | 13 | get id() { 14 | return this.message.id; 15 | } 16 | 17 | get type() { 18 | return this.message.type; 19 | } 20 | 21 | get isSelf() { 22 | return this.message.is_self; 23 | } 24 | 25 | isAt(wxid: string) { 26 | if (!this.isGroup) { 27 | return false; 28 | } 29 | if ( 30 | !new RegExp(`.*(${wxid}).*`).test( 31 | this.xml 32 | ) 33 | ) { 34 | return false; 35 | } 36 | if (/@(?:所有人|all|All)/.test(this.message.content)) { 37 | return false; 38 | } 39 | 40 | return true; 41 | } 42 | 43 | get xml() { 44 | return this.message.xml; 45 | } 46 | 47 | get isGroup() { 48 | return this.message.is_group; 49 | } 50 | 51 | get roomId() { 52 | return this.message.roomid; 53 | } 54 | 55 | get content() { 56 | return this.message.content; 57 | } 58 | 59 | get sender() { 60 | return this.message.sender; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/core/src/lib/proto-generated/extrabyte.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //@ts-nocheck 3 | /** 4 | * Generated by the protoc-gen-ts. DO NOT EDIT! 5 | * compiler version: 3.19.1 6 | * source: extrabyte.proto 7 | * git: https://github.com/thesayyn/protoc-gen-ts */ 8 | import * as pb_1 from "google-protobuf"; 9 | export namespace com.iamteer.wcf { 10 | export class Extra extends pb_1.Message { 11 | #one_of_decls: number[][] = []; 12 | constructor(data?: any[] | { 13 | properties?: Extra.Property[]; 14 | }) { 15 | super(); 16 | pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [3], this.#one_of_decls); 17 | if (!Array.isArray(data) && typeof data == "object") { 18 | if ("properties" in data && data.properties != undefined) { 19 | this.properties = data.properties; 20 | } 21 | } 22 | } 23 | get properties() { 24 | return pb_1.Message.getRepeatedWrapperField(this, Extra.Property, 3) as Extra.Property[]; 25 | } 26 | set properties(value: Extra.Property[]) { 27 | pb_1.Message.setRepeatedWrapperField(this, 3, value); 28 | } 29 | static fromObject(data: { 30 | properties?: ReturnType[]; 31 | }): Extra { 32 | const message = new Extra({}); 33 | if (data.properties != null) { 34 | message.properties = data.properties.map(item => Extra.Property.fromObject(item)); 35 | } 36 | return message; 37 | } 38 | toObject() { 39 | const data: { 40 | properties?: ReturnType[]; 41 | } = {}; 42 | if (this.properties != null) { 43 | data.properties = this.properties.map((item: Extra.Property) => item.toObject()); 44 | } 45 | return data; 46 | } 47 | serialize(): Uint8Array; 48 | serialize(w: pb_1.BinaryWriter): void; 49 | serialize(w?: pb_1.BinaryWriter): Uint8Array | void { 50 | const writer = w || new pb_1.BinaryWriter(); 51 | if (this.properties.length) 52 | writer.writeRepeatedMessage(3, this.properties, (item: Extra.Property) => item.serialize(writer)); 53 | if (!w) 54 | return writer.getResultBuffer(); 55 | } 56 | static deserialize(bytes: Uint8Array | pb_1.BinaryReader): Extra { 57 | const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new Extra(); 58 | while (reader.nextField()) { 59 | if (reader.isEndGroup()) 60 | break; 61 | switch (reader.getFieldNumber()) { 62 | case 3: 63 | reader.readMessage(message.properties, () => pb_1.Message.addToRepeatedWrapperField(message, 3, Extra.Property.deserialize(reader), Extra.Property)); 64 | break; 65 | default: reader.skipField(); 66 | } 67 | } 68 | return message; 69 | } 70 | serializeBinary(): Uint8Array { 71 | return this.serialize(); 72 | } 73 | static deserializeBinary(bytes: Uint8Array): Extra { 74 | return Extra.deserialize(bytes); 75 | } 76 | } 77 | export namespace Extra { 78 | export enum PropertyKey { 79 | Field_0 = 0, 80 | Sign = 2, 81 | Thumb = 3, 82 | Extra = 4, 83 | Xml = 7 84 | } 85 | export class Property extends pb_1.Message { 86 | #one_of_decls: number[][] = []; 87 | constructor(data?: any[] | { 88 | type?: Extra.PropertyKey; 89 | value?: string; 90 | }) { 91 | super(); 92 | pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls); 93 | if (!Array.isArray(data) && typeof data == "object") { 94 | if ("type" in data && data.type != undefined) { 95 | this.type = data.type; 96 | } 97 | if ("value" in data && data.value != undefined) { 98 | this.value = data.value; 99 | } 100 | } 101 | } 102 | get type() { 103 | return pb_1.Message.getFieldWithDefault(this, 1, Extra.PropertyKey.Field_0) as Extra.PropertyKey; 104 | } 105 | set type(value: Extra.PropertyKey) { 106 | pb_1.Message.setField(this, 1, value); 107 | } 108 | get value() { 109 | return pb_1.Message.getFieldWithDefault(this, 2, "") as string; 110 | } 111 | set value(value: string) { 112 | pb_1.Message.setField(this, 2, value); 113 | } 114 | static fromObject(data: { 115 | type?: Extra.PropertyKey; 116 | value?: string; 117 | }): Property { 118 | const message = new Property({}); 119 | if (data.type != null) { 120 | message.type = data.type; 121 | } 122 | if (data.value != null) { 123 | message.value = data.value; 124 | } 125 | return message; 126 | } 127 | toObject() { 128 | const data: { 129 | type?: Extra.PropertyKey; 130 | value?: string; 131 | } = {}; 132 | if (this.type != null) { 133 | data.type = this.type; 134 | } 135 | if (this.value != null) { 136 | data.value = this.value; 137 | } 138 | return data; 139 | } 140 | serialize(): Uint8Array; 141 | serialize(w: pb_1.BinaryWriter): void; 142 | serialize(w?: pb_1.BinaryWriter): Uint8Array | void { 143 | const writer = w || new pb_1.BinaryWriter(); 144 | if (this.type != Extra.PropertyKey.Field_0) 145 | writer.writeEnum(1, this.type); 146 | if (this.value.length) 147 | writer.writeString(2, this.value); 148 | if (!w) 149 | return writer.getResultBuffer(); 150 | } 151 | static deserialize(bytes: Uint8Array | pb_1.BinaryReader): Property { 152 | const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new Property(); 153 | while (reader.nextField()) { 154 | if (reader.isEndGroup()) 155 | break; 156 | switch (reader.getFieldNumber()) { 157 | case 1: 158 | message.type = reader.readEnum(); 159 | break; 160 | case 2: 161 | message.value = reader.readString(); 162 | break; 163 | default: reader.skipField(); 164 | } 165 | } 166 | return message; 167 | } 168 | serializeBinary(): Uint8Array { 169 | return this.serialize(); 170 | } 171 | static deserializeBinary(bytes: Uint8Array): Property { 172 | return Property.deserialize(bytes); 173 | } 174 | } 175 | } 176 | } 177 | 178 | -------------------------------------------------------------------------------- /packages/core/src/lib/proto-generated/extrabyte_grpc_pb.js: -------------------------------------------------------------------------------- 1 | // GENERATED CODE -- NO SERVICES IN PROTO -------------------------------------------------------------------------------- /packages/core/src/lib/proto-generated/roomdata.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //@ts-nocheck 3 | /** 4 | * Generated by the protoc-gen-ts. DO NOT EDIT! 5 | * compiler version: 3.19.1 6 | * source: roomdata.proto 7 | * git: https://github.com/thesayyn/protoc-gen-ts */ 8 | import * as pb_1 from "google-protobuf"; 9 | export namespace com.iamteer.wcf { 10 | export class RoomData extends pb_1.Message { 11 | #one_of_decls: number[][] = []; 12 | constructor(data?: any[] | { 13 | members?: RoomData.RoomMember[]; 14 | field_2?: number; 15 | field_3?: number; 16 | field_4?: number; 17 | room_capacity?: number; 18 | field_6?: number; 19 | field_7?: number; 20 | field_8?: number; 21 | }) { 22 | super(); 23 | pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [1], this.#one_of_decls); 24 | if (!Array.isArray(data) && typeof data == "object") { 25 | if ("members" in data && data.members != undefined) { 26 | this.members = data.members; 27 | } 28 | if ("field_2" in data && data.field_2 != undefined) { 29 | this.field_2 = data.field_2; 30 | } 31 | if ("field_3" in data && data.field_3 != undefined) { 32 | this.field_3 = data.field_3; 33 | } 34 | if ("field_4" in data && data.field_4 != undefined) { 35 | this.field_4 = data.field_4; 36 | } 37 | if ("room_capacity" in data && data.room_capacity != undefined) { 38 | this.room_capacity = data.room_capacity; 39 | } 40 | if ("field_6" in data && data.field_6 != undefined) { 41 | this.field_6 = data.field_6; 42 | } 43 | if ("field_7" in data && data.field_7 != undefined) { 44 | this.field_7 = data.field_7; 45 | } 46 | if ("field_8" in data && data.field_8 != undefined) { 47 | this.field_8 = data.field_8; 48 | } 49 | } 50 | } 51 | get members() { 52 | return pb_1.Message.getRepeatedWrapperField(this, RoomData.RoomMember, 1) as RoomData.RoomMember[]; 53 | } 54 | set members(value: RoomData.RoomMember[]) { 55 | pb_1.Message.setRepeatedWrapperField(this, 1, value); 56 | } 57 | get field_2() { 58 | return pb_1.Message.getFieldWithDefault(this, 2, 0) as number; 59 | } 60 | set field_2(value: number) { 61 | pb_1.Message.setField(this, 2, value); 62 | } 63 | get field_3() { 64 | return pb_1.Message.getFieldWithDefault(this, 3, 0) as number; 65 | } 66 | set field_3(value: number) { 67 | pb_1.Message.setField(this, 3, value); 68 | } 69 | get field_4() { 70 | return pb_1.Message.getFieldWithDefault(this, 4, 0) as number; 71 | } 72 | set field_4(value: number) { 73 | pb_1.Message.setField(this, 4, value); 74 | } 75 | get room_capacity() { 76 | return pb_1.Message.getFieldWithDefault(this, 5, 0) as number; 77 | } 78 | set room_capacity(value: number) { 79 | pb_1.Message.setField(this, 5, value); 80 | } 81 | get field_6() { 82 | return pb_1.Message.getFieldWithDefault(this, 6, 0) as number; 83 | } 84 | set field_6(value: number) { 85 | pb_1.Message.setField(this, 6, value); 86 | } 87 | get field_7() { 88 | return pb_1.Message.getFieldWithDefault(this, 7, 0) as number; 89 | } 90 | set field_7(value: number) { 91 | pb_1.Message.setField(this, 7, value); 92 | } 93 | get field_8() { 94 | return pb_1.Message.getFieldWithDefault(this, 8, 0) as number; 95 | } 96 | set field_8(value: number) { 97 | pb_1.Message.setField(this, 8, value); 98 | } 99 | static fromObject(data: { 100 | members?: ReturnType[]; 101 | field_2?: number; 102 | field_3?: number; 103 | field_4?: number; 104 | room_capacity?: number; 105 | field_6?: number; 106 | field_7?: number; 107 | field_8?: number; 108 | }): RoomData { 109 | const message = new RoomData({}); 110 | if (data.members != null) { 111 | message.members = data.members.map(item => RoomData.RoomMember.fromObject(item)); 112 | } 113 | if (data.field_2 != null) { 114 | message.field_2 = data.field_2; 115 | } 116 | if (data.field_3 != null) { 117 | message.field_3 = data.field_3; 118 | } 119 | if (data.field_4 != null) { 120 | message.field_4 = data.field_4; 121 | } 122 | if (data.room_capacity != null) { 123 | message.room_capacity = data.room_capacity; 124 | } 125 | if (data.field_6 != null) { 126 | message.field_6 = data.field_6; 127 | } 128 | if (data.field_7 != null) { 129 | message.field_7 = data.field_7; 130 | } 131 | if (data.field_8 != null) { 132 | message.field_8 = data.field_8; 133 | } 134 | return message; 135 | } 136 | toObject() { 137 | const data: { 138 | members?: ReturnType[]; 139 | field_2?: number; 140 | field_3?: number; 141 | field_4?: number; 142 | room_capacity?: number; 143 | field_6?: number; 144 | field_7?: number; 145 | field_8?: number; 146 | } = {}; 147 | if (this.members != null) { 148 | data.members = this.members.map((item: RoomData.RoomMember) => item.toObject()); 149 | } 150 | if (this.field_2 != null) { 151 | data.field_2 = this.field_2; 152 | } 153 | if (this.field_3 != null) { 154 | data.field_3 = this.field_3; 155 | } 156 | if (this.field_4 != null) { 157 | data.field_4 = this.field_4; 158 | } 159 | if (this.room_capacity != null) { 160 | data.room_capacity = this.room_capacity; 161 | } 162 | if (this.field_6 != null) { 163 | data.field_6 = this.field_6; 164 | } 165 | if (this.field_7 != null) { 166 | data.field_7 = this.field_7; 167 | } 168 | if (this.field_8 != null) { 169 | data.field_8 = this.field_8; 170 | } 171 | return data; 172 | } 173 | serialize(): Uint8Array; 174 | serialize(w: pb_1.BinaryWriter): void; 175 | serialize(w?: pb_1.BinaryWriter): Uint8Array | void { 176 | const writer = w || new pb_1.BinaryWriter(); 177 | if (this.members.length) 178 | writer.writeRepeatedMessage(1, this.members, (item: RoomData.RoomMember) => item.serialize(writer)); 179 | if (this.field_2 != 0) 180 | writer.writeInt32(2, this.field_2); 181 | if (this.field_3 != 0) 182 | writer.writeInt32(3, this.field_3); 183 | if (this.field_4 != 0) 184 | writer.writeInt32(4, this.field_4); 185 | if (this.room_capacity != 0) 186 | writer.writeInt32(5, this.room_capacity); 187 | if (this.field_6 != 0) 188 | writer.writeInt32(6, this.field_6); 189 | if (this.field_7 != 0) 190 | writer.writeInt64(7, this.field_7); 191 | if (this.field_8 != 0) 192 | writer.writeInt64(8, this.field_8); 193 | if (!w) 194 | return writer.getResultBuffer(); 195 | } 196 | static deserialize(bytes: Uint8Array | pb_1.BinaryReader): RoomData { 197 | const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new RoomData(); 198 | while (reader.nextField()) { 199 | if (reader.isEndGroup()) 200 | break; 201 | switch (reader.getFieldNumber()) { 202 | case 1: 203 | reader.readMessage(message.members, () => pb_1.Message.addToRepeatedWrapperField(message, 1, RoomData.RoomMember.deserialize(reader), RoomData.RoomMember)); 204 | break; 205 | case 2: 206 | message.field_2 = reader.readInt32(); 207 | break; 208 | case 3: 209 | message.field_3 = reader.readInt32(); 210 | break; 211 | case 4: 212 | message.field_4 = reader.readInt32(); 213 | break; 214 | case 5: 215 | message.room_capacity = reader.readInt32(); 216 | break; 217 | case 6: 218 | message.field_6 = reader.readInt32(); 219 | break; 220 | case 7: 221 | message.field_7 = reader.readInt64(); 222 | break; 223 | case 8: 224 | message.field_8 = reader.readInt64(); 225 | break; 226 | default: reader.skipField(); 227 | } 228 | } 229 | return message; 230 | } 231 | serializeBinary(): Uint8Array { 232 | return this.serialize(); 233 | } 234 | static deserializeBinary(bytes: Uint8Array): RoomData { 235 | return RoomData.deserialize(bytes); 236 | } 237 | } 238 | export namespace RoomData { 239 | export class RoomMember extends pb_1.Message { 240 | #one_of_decls: number[][] = []; 241 | constructor(data?: any[] | { 242 | wxid?: string; 243 | name?: string; 244 | state?: number; 245 | }) { 246 | super(); 247 | pb_1.Message.initialize(this, Array.isArray(data) ? data : [], 0, -1, [], this.#one_of_decls); 248 | if (!Array.isArray(data) && typeof data == "object") { 249 | if ("wxid" in data && data.wxid != undefined) { 250 | this.wxid = data.wxid; 251 | } 252 | if ("name" in data && data.name != undefined) { 253 | this.name = data.name; 254 | } 255 | if ("state" in data && data.state != undefined) { 256 | this.state = data.state; 257 | } 258 | } 259 | } 260 | get wxid() { 261 | return pb_1.Message.getFieldWithDefault(this, 1, "") as string; 262 | } 263 | set wxid(value: string) { 264 | pb_1.Message.setField(this, 1, value); 265 | } 266 | get name() { 267 | return pb_1.Message.getFieldWithDefault(this, 2, "") as string; 268 | } 269 | set name(value: string) { 270 | pb_1.Message.setField(this, 2, value); 271 | } 272 | get state() { 273 | return pb_1.Message.getFieldWithDefault(this, 3, 0) as number; 274 | } 275 | set state(value: number) { 276 | pb_1.Message.setField(this, 3, value); 277 | } 278 | static fromObject(data: { 279 | wxid?: string; 280 | name?: string; 281 | state?: number; 282 | }): RoomMember { 283 | const message = new RoomMember({}); 284 | if (data.wxid != null) { 285 | message.wxid = data.wxid; 286 | } 287 | if (data.name != null) { 288 | message.name = data.name; 289 | } 290 | if (data.state != null) { 291 | message.state = data.state; 292 | } 293 | return message; 294 | } 295 | toObject() { 296 | const data: { 297 | wxid?: string; 298 | name?: string; 299 | state?: number; 300 | } = {}; 301 | if (this.wxid != null) { 302 | data.wxid = this.wxid; 303 | } 304 | if (this.name != null) { 305 | data.name = this.name; 306 | } 307 | if (this.state != null) { 308 | data.state = this.state; 309 | } 310 | return data; 311 | } 312 | serialize(): Uint8Array; 313 | serialize(w: pb_1.BinaryWriter): void; 314 | serialize(w?: pb_1.BinaryWriter): Uint8Array | void { 315 | const writer = w || new pb_1.BinaryWriter(); 316 | if (this.wxid.length) 317 | writer.writeString(1, this.wxid); 318 | if (this.name.length) 319 | writer.writeString(2, this.name); 320 | if (this.state != 0) 321 | writer.writeInt32(3, this.state); 322 | if (!w) 323 | return writer.getResultBuffer(); 324 | } 325 | static deserialize(bytes: Uint8Array | pb_1.BinaryReader): RoomMember { 326 | const reader = bytes instanceof pb_1.BinaryReader ? bytes : new pb_1.BinaryReader(bytes), message = new RoomMember(); 327 | while (reader.nextField()) { 328 | if (reader.isEndGroup()) 329 | break; 330 | switch (reader.getFieldNumber()) { 331 | case 1: 332 | message.wxid = reader.readString(); 333 | break; 334 | case 2: 335 | message.name = reader.readString(); 336 | break; 337 | case 3: 338 | message.state = reader.readInt32(); 339 | break; 340 | default: reader.skipField(); 341 | } 342 | } 343 | return message; 344 | } 345 | serializeBinary(): Uint8Array { 346 | return this.serialize(); 347 | } 348 | static deserializeBinary(bytes: Uint8Array): RoomMember { 349 | return RoomMember.deserialize(bytes); 350 | } 351 | } 352 | } 353 | } 354 | 355 | -------------------------------------------------------------------------------- /packages/core/src/lib/proto-generated/roomdata_grpc_pb.js: -------------------------------------------------------------------------------- 1 | // GENERATED CODE -- NO SERVICES IN PROTO -------------------------------------------------------------------------------- /packages/core/src/lib/proto-generated/wcf_grpc_pb.js: -------------------------------------------------------------------------------- 1 | // GENERATED CODE -- NO SERVICES IN PROTO -------------------------------------------------------------------------------- /packages/core/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { mkdirSync } from 'fs'; 2 | import { tmpdir } from 'os'; 3 | import path from 'path'; 4 | 5 | export function sleep(ms = 1000) { 6 | return new Promise((res) => setTimeout(() => res(), ms)); 7 | } 8 | 9 | export function ensureDirSync(dir: string) { 10 | try { 11 | mkdirSync(dir, { recursive: true }); 12 | } catch { 13 | // noop 14 | } 15 | } 16 | 17 | export function createTmpDir(name = 'wcferry') { 18 | return path.join(tmpdir(), name); 19 | } 20 | 21 | export function uint8Array2str(arr: Uint8Array) { 22 | return Buffer.from(arr).toString(); 23 | } 24 | 25 | // because all the fields are get by getFieldWithDefault, so every fields should have a default value 26 | export type ToPlainType unknown }> = Required< 27 | ReturnType 28 | >; 29 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true 11 | }, 12 | "files": [], 13 | "include": [], 14 | "references": [ 15 | { 16 | "path": "./tsconfig.lib.json" 17 | }, 18 | { 19 | "path": "./tsconfig.spec.json" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"] 7 | }, 8 | "include": ["src/**/*.ts"], 9 | "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/core/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "vitest/globals", 7 | "vitest/importMeta", 8 | "vite/client", 9 | "node", 10 | "vitest" 11 | ] 12 | }, 13 | "include": [ 14 | "vite.config.ts", 15 | "vitest.config.ts", 16 | "src/**/*.test.ts", 17 | "src/**/*.spec.ts", 18 | "src/**/*.test.tsx", 19 | "src/**/*.spec.tsx", 20 | "src/**/*.test.js", 21 | "src/**/*.spec.js", 22 | "src/**/*.test.jsx", 23 | "src/**/*.spec.jsx", 24 | "src/**/*.d.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/core/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; 4 | 5 | export default defineConfig({ 6 | root: __dirname, 7 | cacheDir: '../../node_modules/.vite/core', 8 | 9 | plugins: [nxViteTsPaths()], 10 | 11 | // Uncomment this if you are using workers. 12 | // worker: { 13 | // plugins: [ nxViteTsPaths() ], 14 | // }, 15 | 16 | test: { 17 | globals: true, 18 | cache: { dir: '../../node_modules/.vitest' }, 19 | environment: 'node', 20 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 21 | reporters: ['default'], 22 | coverage: { reportsDirectory: '../../coverage/core', provider: 'v8' }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/ws/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["*.json"], 19 | "parser": "jsonc-eslint-parser", 20 | "rules": { 21 | "@nx/dependency-checks": [ 22 | "error", 23 | { 24 | "ignoredFiles": [ 25 | "{projectRoot}/vite.config.{js,ts,mjs,mts}" 26 | ] 27 | } 28 | ] 29 | } 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /packages/ws/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "target": "es2017", 4 | "parser": { 5 | "syntax": "typescript", 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "transform": { 10 | "decoratorMetadata": true, 11 | "legacyDecorator": true 12 | }, 13 | "keepClassNames": true, 14 | "externalHelpers": true, 15 | "loose": true 16 | }, 17 | "module": { 18 | "type": "commonjs" 19 | }, 20 | "sourceMaps": true, 21 | "exclude": [ 22 | "jest.config.ts", 23 | ".*\\.spec.tsx?$", 24 | ".*\\.test.tsx?$", 25 | "./src/jest-setup.ts$", 26 | "./**/jest-setup.ts$", 27 | ".*.js$" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /packages/ws/README.md: -------------------------------------------------------------------------------- 1 | # @wcferry/ws 2 | 3 | A Wcferry websocket server 4 | 5 | ![websocket](./static/websocket.png) 6 | 7 | ## Usage 8 | 9 | ### Standalone 10 | 11 | Start a standalone websocket server at 8000: 12 | 13 | ```ts 14 | await WcfWSServer.start({ port: 8000 }); 15 | ``` 16 | 17 | ### Attach to existing Wcferry instance 18 | 19 | ```ts 20 | const wcferry = new Wcferry(); 21 | new WcfWSServer(wcferry, { port: 8000 }); 22 | // impl your bot logic ... 23 | ``` 24 | 25 | ## Message definiton 26 | 27 | ### General commands: 28 | 29 | ```ts 30 | interface Command { 31 | id: number; // message unique id (server will response the command with the same id) 32 | method: string; // the allowed method name, to invoke wcferry.[method](...params) 33 | params?: any[]; // the parameters of the method call 34 | } 35 | ``` 36 | 37 | methods and params are same as methods in [@wcferry/core](../core/src/lib/client.ts) 38 | 39 | All the supported commands: 40 | 41 | ```ts 42 | [ 43 | 'acceptNewFriend', 44 | 'addChatRoomMembers', 45 | 'dbSqlQuery', 46 | 'decryptImage', 47 | 'delChatRoomMembers', 48 | 'downloadAttach', 49 | 'downloadImage', 50 | 'forwardMsg', 51 | 'getAliasInChatRoom', 52 | 'getAudioMsg', 53 | 'getChatRoomMembers', 54 | 'getChatRooms', 55 | 'getContact', 56 | 'getContacts', 57 | 'getDbNames', 58 | 'getFriends', 59 | 'getMsgTypes', 60 | 'getOCRResult', 61 | 'getSelfWxid', 62 | 'getUserInfo', 63 | 'inviteChatroomMembers', 64 | 'isLogin', 65 | 'receiveTransfer', 66 | 'refreshPyq', 67 | 'revokeMsg', 68 | 'sendFile', 69 | 'sendImage', 70 | 'sendPat', 71 | 'sendRichText', 72 | 'sendTxt', 73 | ]; 74 | ``` 75 | 76 | ### Special commands 77 | 78 | Following commands can control whether receving messages including pyq messages: 79 | 80 | ```ts 81 | // enable receiving message: 82 | interface EnableMessage { 83 | id: number; 84 | method: 'message.enable'; 85 | } 86 | 87 | // disable receiving message: 88 | interface DisableMessage { 89 | id: number; 90 | method: 'message.disable'; 91 | } 92 | 93 | // set if receiving pyq message: 94 | interface SetRecvPyq { 95 | id: number; 96 | method: 'recvPyq'; 97 | params: [boolean]; 98 | } 99 | ``` 100 | 101 | ## Server response 102 | 103 | ### Command response 104 | 105 | 1. ok: 106 | 107 | ```ts 108 | interface OKResp { 109 | id: number; 110 | result: any; 111 | } 112 | ``` 113 | 114 | 2. error: 115 | 116 | ```ts 117 | interface ErrorResp { 118 | id: number; 119 | error: { message: string; code?: number }; 120 | } 121 | ``` 122 | 123 | ### Message response 124 | 125 | Once we enable receiving message, the websocket server will start to push the chat messages back: 126 | 127 | ```ts 128 | interface Message { 129 | type: 'message'; 130 | data: WxMsg; 131 | } 132 | ``` 133 | 134 | ## Building 135 | 136 | Run `nx build ws` to build the library. 137 | 138 | ## Running unit tests 139 | 140 | Run `nx test ws` to execute the unit tests via [Vitest](https://vitest.dev/). 141 | -------------------------------------------------------------------------------- /packages/ws/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wcferry/ws", 3 | "version": "0.0.1", 4 | "dependencies": { 5 | "@swc/helpers": "~0.5.2", 6 | "commander": "^11.1.0", 7 | "debug": "^4.3.4", 8 | "ws": "^8.16.0" 9 | }, 10 | "peerDependencies": { 11 | "@wcferry/core": "workspace:^*" 12 | }, 13 | "bin": { 14 | "wcfwebsocket": "./src/cmd.js" 15 | }, 16 | "type": "commonjs", 17 | "main": "./src/index.js", 18 | "typings": "./src/index.d.ts", 19 | "publishConfig": { 20 | "registry": "https://registry.npmjs.org/", 21 | "directory": "../../dist/packages/ws" 22 | }, 23 | "repository": { 24 | "url": "git+https://github.com/stkevintan/node-wcferry.git", 25 | "type": "git" 26 | }, 27 | "author": { 28 | "name": "stkevintan" 29 | }, 30 | "devDependencies": { 31 | "@types/ws": "^8.5.10" 32 | } 33 | } -------------------------------------------------------------------------------- /packages/ws/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ws", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/ws/src", 5 | "projectType": "library", 6 | "targets": { 7 | "build": { 8 | "executor": "@nx/js:swc", 9 | "outputs": ["{options.outputPath}"], 10 | "options": { 11 | "additionalEntryPoints": ["packages/ws/src/cmd.ts"], 12 | "outputPath": "dist/packages/ws", 13 | "main": "packages/ws/src/index.ts", 14 | "tsConfig": "packages/ws/tsconfig.lib.json", 15 | "assets": ["packages/ws/*.md", "packages/ws/static/*"] 16 | } 17 | }, 18 | "publish": { 19 | "command": "node tools/scripts/publish.mjs ws {args.ver} {args.tag}", 20 | "dependsOn": ["build"] 21 | }, 22 | "lint": { 23 | "executor": "@nx/eslint:lint", 24 | "outputs": ["{options.outputFile}"] 25 | }, 26 | "test": { 27 | "executor": "@nx/vite:test", 28 | "outputs": ["{options.reportsDirectory}"], 29 | "options": { 30 | "reportsDirectory": "../../coverage/packages/ws" 31 | } 32 | }, 33 | "tsx": { 34 | "executor": "nx:run-commands", 35 | "options": { 36 | "command": "pnpm tsx ./src/cmd.ts", 37 | "cwd": "{projectRoot}" 38 | } 39 | } 40 | }, 41 | "tags": [] 42 | } 43 | -------------------------------------------------------------------------------- /packages/ws/src/cmd.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander'; 2 | import pkg from '../package.json'; 3 | import { WcfWSServer } from './lib/ws'; 4 | 5 | function main() { 6 | program 7 | .name('wcfwebsocket') 8 | .version(pkg.version) 9 | .description('start a wcferry websocket server') 10 | .option('-p,--port ', 'websocket port', '8000') 11 | .option('-h,--host ', 'websocket host', '127.0.0.1') 12 | .option('-P,--rpc-port ', 'wcferry rpc endpoint', '10086') 13 | .option( 14 | '-H, --rpc-host ', 15 | 'wcferry rpc host. if empty, program will try to execute wcferry.exe', 16 | '' 17 | ) 18 | .action((options) => { 19 | WcfWSServer.start({ 20 | wcferry: { 21 | port: Number.parseInt(options.rpcPort, 10) || 10086, 22 | host: options.rpcHost, 23 | }, 24 | ws: { 25 | port: Number.parseInt(options.port, 10) || 8000, 26 | host: options.host, 27 | }, 28 | }); 29 | }); 30 | program.parse(); 31 | } 32 | 33 | void main(); 34 | -------------------------------------------------------------------------------- /packages/ws/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/ws'; 2 | -------------------------------------------------------------------------------- /packages/ws/src/lib/ws.spec.ts: -------------------------------------------------------------------------------- 1 | describe.skip('ws', () => { 2 | it('should work', () => {}); 3 | }); 4 | -------------------------------------------------------------------------------- /packages/ws/src/lib/ws.ts: -------------------------------------------------------------------------------- 1 | import { Wcferry, WcferryOptions } from '@wcferry/core'; 2 | import debug from 'debug'; 3 | import ws from 'ws'; 4 | 5 | const logger = debug('wcferry:ws'); 6 | 7 | export interface IncomingMessage { 8 | id: string; 9 | method: string; 10 | params?: unknown[]; 11 | } 12 | 13 | type CallableMethod = { 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; 16 | }[keyof T]; 17 | const AllowedBuiltinMethods: Array> = [ 18 | 'acceptNewFriend', 19 | 'addChatRoomMembers', 20 | 'dbSqlQuery', 21 | 'decryptImage', 22 | 'delChatRoomMembers', 23 | 'downloadAttach', 24 | 'downloadImage', 25 | 'forwardMsg', 26 | 'getAliasInChatRoom', 27 | 'getAudioMsg', 28 | 'getChatRoomMembers', 29 | 'getChatRooms', 30 | 'getContact', 31 | 'getContacts', 32 | 'getDbNames', 33 | 'getFriends', 34 | 'getMsgTypes', 35 | 'getOCRResult', 36 | 'getSelfWxid', 37 | 'getUserInfo', 38 | 'inviteChatroomMembers', 39 | 'isLogin', 40 | 'receiveTransfer', 41 | 'refreshPyq', 42 | 'revokeMsg', 43 | 'sendFile', 44 | 'sendImage', 45 | 'sendPat', 46 | 'sendRichText', 47 | 'sendTxt', 48 | ]; 49 | 50 | export class WcfWSServer { 51 | private wss: ws.WebSocketServer; 52 | constructor(private wcferry: Wcferry, options?: ws.ServerOptions) { 53 | this.wss = new ws.WebSocketServer({ 54 | port: 8000, 55 | ...options, 56 | }); 57 | this.listen(); 58 | } 59 | 60 | static start(options?: { wcferry: WcferryOptions; ws: ws.ServerOptions }) { 61 | const wcferry = new Wcferry(options?.wcferry); 62 | wcferry.start(); 63 | logger('new websocket server: %O', options?.ws); 64 | return new WcfWSServer(wcferry, options?.ws); 65 | } 66 | 67 | private off?: () => void; 68 | 69 | private listen() { 70 | this.wss.on('error', (err) => { 71 | logger(`Websocket server error: %O`, err); 72 | }); 73 | 74 | this.wss.on('connection', (ws) => { 75 | logger('Wcferry websocket server is started...'); 76 | ws.on('error', (err) => { 77 | logger(`Websokcet server error: %O`, err); 78 | }); 79 | ws.on('message', async (data) => { 80 | const req = this.parseReq(data.toString('utf8')); 81 | if (req) { 82 | logger( 83 | '-> recv %s [%s]: %o', 84 | req.id, 85 | req.method, 86 | req.params 87 | ); 88 | if (AllowedBuiltinMethods.some((m) => m === req.method)) { 89 | const ret = await this.executeCommand( 90 | req.method as CallableMethod, 91 | req.params 92 | ); 93 | this.send(ws, req.id, ret); 94 | } 95 | switch (req.method) { 96 | case 'recvPyq': 97 | this.wcferry.recvPyq = !!req.params?.[0]; 98 | this.send(ws, req.id, { result: true }); 99 | return; 100 | case 'message.enable': 101 | try { 102 | this.off ??= this.wcferry.on((msg) => { 103 | logger('<- msg %o', msg.raw); 104 | ws.send( 105 | JSON.stringify({ 106 | type: 'message', 107 | data: msg.raw, 108 | }) 109 | ); 110 | }); 111 | this.send(ws, req.id, { result: true }); 112 | } catch (err) { 113 | this.send(ws, req.id, { 114 | error: { 115 | message: this.formatError(err), 116 | code: -2, 117 | }, 118 | }); 119 | } 120 | return; 121 | case 'message.disable': 122 | try { 123 | this.off?.(); 124 | this.send(ws, req.id, { result: true }); 125 | } catch (err) { 126 | this.send(ws, req.id, { 127 | error: { 128 | message: this.formatError(err), 129 | code: -2, 130 | }, 131 | }); 132 | } 133 | } 134 | } 135 | }); 136 | }); 137 | } 138 | 139 | send( 140 | ws: ws.WebSocket, 141 | id: string, 142 | payload: 143 | | { result: unknown } 144 | | { error: { message: string; code?: number } } 145 | ) { 146 | const resp = { 147 | id, 148 | ...payload, 149 | }; 150 | logger('<- resp %s %o', id, payload); 151 | ws.send(JSON.stringify(resp)); 152 | } 153 | 154 | private async executeCommand( 155 | method: CallableMethod, 156 | params: unknown[] = [] 157 | ) { 158 | try { 159 | // eslint-disable-next-line prefer-spread 160 | const ret = await ( 161 | this.wcferry[method] as (...args: unknown[]) => unknown 162 | )(...params); 163 | return { result: ret }; 164 | } catch (err) { 165 | return { 166 | error: { 167 | message: `Execute ${method} failed: ${this.formatError( 168 | err 169 | )}`, 170 | code: -1, 171 | }, 172 | }; 173 | } 174 | } 175 | 176 | private formatError(err: unknown) { 177 | return err instanceof Error ? err.message : `${err}`; 178 | } 179 | 180 | private parseReq(data: string): IncomingMessage | undefined { 181 | try { 182 | const json = JSON.parse(data); 183 | if ( 184 | typeof json.id === 'number' && 185 | typeof json.method === 'string' 186 | ) { 187 | return json as IncomingMessage; 188 | } 189 | return undefined; 190 | } catch { 191 | return undefined; 192 | } 193 | } 194 | 195 | close() { 196 | this.wss.close(); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /packages/ws/static/websocket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stkevintan/node-wcferry/3e580e86d8d2983aa448570321fc44f64728460c/packages/ws/static/websocket.png -------------------------------------------------------------------------------- /packages/ws/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "useDefineForClassFields": false 12 | }, 13 | "files": [], 14 | "include": [], 15 | "references": [ 16 | { 17 | "path": "./tsconfig.lib.json" 18 | }, 19 | { 20 | "path": "./tsconfig.spec.json" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /packages/ws/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"] 7 | }, 8 | "include": ["src/**/*.ts"], 9 | "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/ws/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "vitest/globals", 7 | "vitest/importMeta", 8 | "vite/client", 9 | "node", 10 | "vitest" 11 | ] 12 | }, 13 | "include": [ 14 | "vite.config.ts", 15 | "vitest.config.ts", 16 | "src/**/*.test.ts", 17 | "src/**/*.spec.ts", 18 | "src/**/*.test.tsx", 19 | "src/**/*.spec.tsx", 20 | "src/**/*.test.js", 21 | "src/**/*.spec.js", 22 | "src/**/*.test.jsx", 23 | "src/**/*.spec.jsx", 24 | "src/**/*.d.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/ws/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; 4 | 5 | export default defineConfig({ 6 | root: __dirname, 7 | cacheDir: '../../node_modules/.vite/packages/ws', 8 | 9 | plugins: [nxViteTsPaths()], 10 | 11 | // Uncomment this if you are using workers. 12 | // worker: { 13 | // plugins: [ nxViteTsPaths() ], 14 | // }, 15 | 16 | test: { 17 | globals: true, 18 | cache: { dir: '../../node_modules/.vitest' }, 19 | environment: 'node', 20 | include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 21 | reporters: ['default'], 22 | coverage: { 23 | reportsDirectory: '../../coverage/packages/ws', 24 | provider: 'v8', 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | -------------------------------------------------------------------------------- /tools/scripts/publish.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a minimal script to publish your package to "npm". 3 | * This is meant to be used as-is or customize as you see fit. 4 | * 5 | * This script is executed on "dist/path/to/library" as "cwd" by default. 6 | * 7 | * You might need to authenticate with NPM before running this script. 8 | */ 9 | 10 | import { execSync } from 'child_process'; 11 | import { readFileSync, writeFileSync } from 'fs'; 12 | 13 | import devkit from '@nx/devkit'; 14 | const { readCachedProjectGraph } = devkit; 15 | 16 | function invariant(condition, message) { 17 | if (!condition) { 18 | console.error(message); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | // Executing publish script: node path/to/publish.mjs {name} --version {version} --tag {tag} 24 | // Default "tag" to "next" so we won't publish the "latest" tag by accident. 25 | let [, , name, version, tag] = process.argv; 26 | console.log('get version: %s, tag: %s', version, tag); 27 | 28 | version = version.replace(new RegExp(`^${name}@`), ''); 29 | 30 | // A simple SemVer validation to validate the version 31 | const validVersion = /^\d+\.\d+\.\d+(-\w+\.\d+)?/; 32 | invariant( 33 | !version || validVersion.test(version), 34 | `version did not match Semantic Versioning, expected: #.#.#-tag.# or #.#.#, got ${version}.` 35 | ); 36 | 37 | const graph = readCachedProjectGraph(); 38 | const project = graph.nodes[name]; 39 | 40 | invariant( 41 | project, 42 | `Could not find project "${name}" in the workspace. Is the project.json configured correctly?` 43 | ); 44 | 45 | const outputPath = project.data?.targets?.build?.options?.outputPath; 46 | invariant( 47 | outputPath, 48 | `Could not find "build.options.outputPath" of project "${name}". Is project.json configured correctly?` 49 | ); 50 | 51 | // Updating the version in "package.json" before publishing 52 | if (version) { 53 | try { 54 | const pkg = path.join(outputPath, `package.json`); 55 | const json = JSON.parse(readFileSync(pkg).toString()); 56 | json.version = version; 57 | writeFileSync(pkg, JSON.stringify(json, null, 2)); 58 | } catch (e) { 59 | console.error( 60 | `Error reading package.json file from library build output.` 61 | ); 62 | } 63 | } 64 | 65 | // Execute "npm publish" to publish 66 | const tagArgs = tag ? `--tag ${tag}` : ''; 67 | execSync( 68 | `pnpm publish --filter ${name} --no-git-checks --access public ${tagArgs}` 69 | ); 70 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "module": "CommonJS", 7 | "resolveJsonModule": true, 8 | "moduleResolution": "node", 9 | "baseUrl": "./", 10 | "declaration": true, 11 | "declarationMap": true, 12 | "outDir": "./dist", 13 | "importHelpers": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "paths": { 19 | "@wcferry/core": ["packages/core/src/index.ts"], 20 | "@wcferry/ws": ["packages/ws/src/index.ts"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noEmit": true 12 | } 13 | } 14 | --------------------------------------------------------------------------------