├── Media ├── .gitignore ├── cat.jpeg ├── icon.png ├── meme.jpeg ├── ma_gif.mp4 ├── octopus.webp └── sonata.mp3 ├── proto-extract ├── .gitignore ├── package.json └── README.md ├── .eslintrc.json ├── src ├── Defaults │ ├── baileys-version.json │ └── index.ts ├── Store │ ├── index.ts │ └── make-ordered-dictionary.ts ├── Utils │ ├── logger.ts │ ├── index.ts │ ├── make-mutex.ts │ ├── lt-hash.ts │ ├── baileys-event-stream.ts │ ├── link-preview.ts │ ├── use-multi-file-auth-state.ts │ ├── history.ts │ ├── crypto.ts │ ├── decode-wa-message.ts │ ├── noise-handler.ts │ ├── auth-utils.ts │ ├── validate-connection.ts │ ├── business.ts │ ├── process-message.ts │ ├── signal.ts │ └── generics.ts ├── WABinary │ ├── index.ts │ ├── types.ts │ ├── jid-utils.ts │ ├── generic-utils.ts │ ├── encode.ts │ └── decode.ts ├── Tests │ ├── utils.ts │ ├── test.media-download.ts │ ├── test.app-state-sync.ts │ └── test.event-buffer.ts ├── index.ts ├── Types │ ├── Call.ts │ ├── Contact.ts │ ├── State.ts │ ├── GroupMetadata.ts │ ├── index.ts │ ├── Product.ts │ ├── Auth.ts │ ├── Chat.ts │ ├── Events.ts │ ├── Socket.ts │ └── Message.ts └── Socket │ ├── index.ts │ ├── business.ts │ └── groups.ts ├── .gitattributes ├── WASignalGroup ├── protobufs.js ├── generate-proto.sh ├── readme.md ├── ciphertext_message.js ├── index.js ├── keyhelper.js ├── sender_message_key.js ├── group.proto ├── sender_chain_key.js ├── sender_key_name.js ├── sender_key_record.js ├── group_session_builder.js ├── sender_key_distribution_message.js ├── sender_key_message.js ├── group_cipher.js └── sender_key_state.js ├── .eslintignore ├── typedoc.json ├── jest.config.js ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-requests.md │ └── bug_report.md └── workflows │ ├── lint.yml │ ├── stale.yml │ ├── update-docs.yml │ └── publish-release.yml ├── .gitignore ├── WAProto └── GenerateStatics.sh ├── tsconfig.json ├── package.json └── Example └── example.ts /Media/.gitignore: -------------------------------------------------------------------------------- 1 | received_* 2 | media_* -------------------------------------------------------------------------------- /proto-extract/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adiwajshing" 3 | } -------------------------------------------------------------------------------- /src/Defaults/baileys-version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": [2, 2243, 7] 3 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /Media/cat.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sen-Takatsuki/Baileys/HEAD/Media/cat.jpeg -------------------------------------------------------------------------------- /Media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sen-Takatsuki/Baileys/HEAD/Media/icon.png -------------------------------------------------------------------------------- /Media/meme.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sen-Takatsuki/Baileys/HEAD/Media/meme.jpeg -------------------------------------------------------------------------------- /Media/ma_gif.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sen-Takatsuki/Baileys/HEAD/Media/ma_gif.mp4 -------------------------------------------------------------------------------- /Media/octopus.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sen-Takatsuki/Baileys/HEAD/Media/octopus.webp -------------------------------------------------------------------------------- /Media/sonata.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sen-Takatsuki/Baileys/HEAD/Media/sonata.mp3 -------------------------------------------------------------------------------- /src/Store/index.ts: -------------------------------------------------------------------------------- 1 | import makeInMemoryStore from './make-in-memory-store' 2 | export { makeInMemoryStore } -------------------------------------------------------------------------------- /WASignalGroup/protobufs.js: -------------------------------------------------------------------------------- 1 | const { groupproto } = require('./GroupProtocol') 2 | 3 | module.exports = groupproto -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | lib 3 | coverage 4 | *.lock 5 | .eslintrc.json 6 | src/WABinary/index.ts 7 | WAProto -------------------------------------------------------------------------------- /src/Utils/logger.ts: -------------------------------------------------------------------------------- 1 | import P from 'pino' 2 | 3 | export default P({ timestamp: () => `,"time":"${new Date().toJSON()}"` }) -------------------------------------------------------------------------------- /WASignalGroup/generate-proto.sh: -------------------------------------------------------------------------------- 1 | yarn pbjs -t static-module -w commonjs -o ./WASignalGroup/GroupProtocol.js ./WASignalGroup/group.proto -------------------------------------------------------------------------------- /src/WABinary/index.ts: -------------------------------------------------------------------------------- 1 | export * from './encode' 2 | export * from './decode' 3 | export * from './generic-utils' 4 | export * from './jid-utils' 5 | export * from './types' -------------------------------------------------------------------------------- /src/Tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { jidEncode } from '../WABinary' 2 | 3 | 4 | export function randomJid() { 5 | return jidEncode(Math.floor(Math.random() * 1000000), Math.random() < 0.5 ? 's.whatsapp.net' : 'g.us') 6 | } -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "excludePrivate": true, 4 | "excludeProtected": true, 5 | "excludeExternals": true, 6 | "includeVersion": false, 7 | "out": "docs" 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src" 4 | ], 5 | "testMatch": [ 6 | "**/Tests/test.*.+(ts|tsx|js)", 7 | ], 8 | "transform": { 9 | "^.+\\.(ts|tsx)$": "ts-jest" 10 | }, 11 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Discussion 3 | about: Discuss your problem here 4 | url: https://github.com/adiwajshing/Baileys/discussions/new 5 | - name: Discord 6 | about: Join our discord to discuss together 7 | url: https://discord.gg/WeJM5FP9GG 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | auth_info*.json 3 | baileys_auth_info* 4 | baileys_store*.json 5 | output.csv 6 | */.DS_Store 7 | .DS_Store 8 | .env 9 | browser-messages.json 10 | decoded-ws.json 11 | lib 12 | docs 13 | browser-token.json 14 | Proxy 15 | messages*.json 16 | test.ts 17 | TestData -------------------------------------------------------------------------------- /WAProto/GenerateStatics.sh: -------------------------------------------------------------------------------- 1 | yarn pbjs -t static-module -w commonjs -o ./WAProto/index.js ./WAProto/WAProto.proto; 2 | yarn pbts -o ./WAProto/index.d.ts ./WAProto/index.js; 3 | 4 | #protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt=env=node,useOptionals=true,forceLong=long --ts_proto_out=. ./src/Binary/WAMessage.proto; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import makeWASocket from './Socket' 2 | 3 | export * from '../WAProto' 4 | export * from './Utils' 5 | export * from './Types' 6 | export * from './Store' 7 | export * from './Defaults' 8 | export * from './WABinary' 9 | 10 | export type WASocket = ReturnType 11 | 12 | export default makeWASocket -------------------------------------------------------------------------------- /src/Types/Call.ts: -------------------------------------------------------------------------------- 1 | 2 | export type WACallUpdateType = 'offer' | 'ringing' | 'timeout' | 'reject' | 'accept' 3 | 4 | export type WACallEvent = { 5 | chatId: string 6 | from: string 7 | isGroup?: boolean 8 | id: string 9 | date: Date 10 | isVideo?: boolean 11 | status: WACallUpdateType 12 | offline: boolean 13 | latencyMs?: number 14 | } -------------------------------------------------------------------------------- /WASignalGroup/readme.md: -------------------------------------------------------------------------------- 1 | # Signal-Group 2 | 3 | This contains the code to decrypt/encrypt WA group messages. 4 | Originally from [pokearaujo/libsignal-node](https://github.com/pokearaujo/libsignal-node) 5 | 6 | The code has been moved outside the signal package as I felt it didn't belong in ths signal package, as it isn't inherently a part of signal but of WA. -------------------------------------------------------------------------------- /WASignalGroup/ciphertext_message.js: -------------------------------------------------------------------------------- 1 | class CiphertextMessage { 2 | UNSUPPORTED_VERSION = 1; 3 | 4 | CURRENT_VERSION = 3; 5 | 6 | WHISPER_TYPE = 2; 7 | 8 | PREKEY_TYPE = 3; 9 | 10 | SENDERKEY_TYPE = 4; 11 | 12 | SENDERKEY_DISTRIBUTION_TYPE = 5; 13 | 14 | ENCRYPTED_MESSAGE_OVERHEAD = 53; 15 | } 16 | module.exports = CiphertextMessage; -------------------------------------------------------------------------------- /WASignalGroup/index.js: -------------------------------------------------------------------------------- 1 | module.exports.GroupSessionBuilder = require('./group_session_builder') 2 | module.exports.SenderKeyDistributionMessage = require('./sender_key_distribution_message') 3 | module.exports.SenderKeyRecord = require('./sender_key_record') 4 | module.exports.SenderKeyName = require('./sender_key_name') 5 | module.exports.GroupCipher = require('./group_cipher') -------------------------------------------------------------------------------- /proto-extract/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whatsapp-web-protobuf-extractor", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "node index.js" 7 | }, 8 | "dependencies": { 9 | "acorn": "^6.4.1", 10 | "acorn-walk": "^6.1.1", 11 | "request": "^2.88.0", 12 | "request-promise-core": "^1.1.2", 13 | "request-promise-native": "^1.0.7" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Socket/index.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CONNECTION_CONFIG } from '../Defaults' 2 | import { UserFacingSocketConfig } from '../Types' 3 | import { makeBusinessSocket as _makeSocket } from './business' 4 | 5 | // export the last socket layer 6 | const makeWASocket = (config: UserFacingSocketConfig) => ( 7 | _makeSocket({ 8 | ...DEFAULT_CONNECTION_CONFIG, 9 | ...config 10 | }) 11 | ) 12 | 13 | export default makeWASocket -------------------------------------------------------------------------------- /proto-extract/README.md: -------------------------------------------------------------------------------- 1 | # Proto Extract 2 | 3 | Derived initially from `whatseow`'s proto extract, this version generates a predictable diff friendly protobuf. It also does not rely on a hardcoded set of modules to look for but finds all proto modules on its own and extracts the proto from there. 4 | 5 | ## Usage 6 | 1. Install dependencies with `yarn` (or `npm install`) 7 | 2. `yarn start` 8 | 3. The script will update `../WAProto/WAProto.proto` (except if something is broken) 9 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Check PR health 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | check-lint: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 10 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Install Node 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 16.x 17 | 18 | - name: Install packages 19 | run: yarn 20 | 21 | - name: Check linting 22 | run: yarn lint 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-requests.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Requests 3 | about: Template for general issues/feature requests 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Before adding this issue, make sure you do the following to make sure this is not a duplicate:** 11 | 1. Search through the repo's previous issues 12 | 2. Read through the readme at least once 13 | 3. Search the docs for the feature you're looking for 14 | 15 | **Just describe the feature** 16 | -------------------------------------------------------------------------------- /WASignalGroup/keyhelper.js: -------------------------------------------------------------------------------- 1 | const curve = require('libsignal/src/curve'); 2 | const nodeCrypto = require('crypto'); 3 | 4 | exports.generateSenderKey = function() { 5 | return nodeCrypto.randomBytes(32); 6 | } 7 | 8 | exports.generateSenderKeyId = function() { 9 | return nodeCrypto.randomInt(2147483647); 10 | } 11 | 12 | exports.generateSenderSigningKey = function(key) { 13 | if (!key) { 14 | key = curve.generateKeyPair(); 15 | } 16 | 17 | return { 18 | public: key.pubKey, 19 | private: key.privKey, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/Utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generics' 2 | export * from './decode-wa-message' 3 | export * from './messages' 4 | export * from './messages-media' 5 | export * from './validate-connection' 6 | export * from './crypto' 7 | export * from './signal' 8 | export * from './noise-handler' 9 | export * from './history' 10 | export * from './chat-utils' 11 | export * from './lt-hash' 12 | export * from './auth-utils' 13 | export * from './baileys-event-stream' 14 | export * from './use-multi-file-auth-state' 15 | export * from './link-preview' 16 | export * from './event-buffer' 17 | export * from './process-message' -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "experimentalDecorators": true, 6 | "allowJs": false, 7 | "checkJs": false, 8 | "outDir": "lib", 9 | "strict": false, 10 | "strictNullChecks": true, 11 | "skipLibCheck": true, 12 | "noImplicitThis": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "declaration": true, 17 | "lib": ["es2020", "esnext.array", "DOM"] 18 | }, 19 | "include": ["./src/**/*.ts"], 20 | "exclude": ["node_modules", "src/Tests/*", "src/Binary/GenerateStatics.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /src/WABinary/types.ts: -------------------------------------------------------------------------------- 1 | import * as constants from './constants' 2 | /** 3 | * the binary node WA uses internally for communication 4 | * 5 | * this is manipulated soley as an object and it does not have any functions. 6 | * This is done for easy serialization, to prevent running into issues with prototypes & 7 | * to maintain functional code structure 8 | * */ 9 | export type BinaryNode = { 10 | tag: string 11 | attrs: { [key: string]: string } 12 | content?: BinaryNode[] | string | Uint8Array 13 | } 14 | export type BinaryNodeAttributes = BinaryNode['attrs'] 15 | export type BinaryNodeData = BinaryNode['content'] 16 | 17 | export type BinaryNodeCodingOptions = typeof constants -------------------------------------------------------------------------------- /src/Types/Contact.ts: -------------------------------------------------------------------------------- 1 | export interface Contact { 2 | id: string 3 | /** name of the contact, you have saved on your WA */ 4 | name?: string 5 | /** name of the contact, the contact has set on their own on WA */ 6 | notify?: string 7 | /** I have no idea */ 8 | verifiedName?: string 9 | // Baileys Added 10 | /** 11 | * Url of the profile picture of the contact 12 | * 13 | * 'changed' => if the profile picture has changed 14 | * null => if the profile picture has not been set (default profile picture) 15 | * any other string => url of the profile picture 16 | */ 17 | imgUrl?: string | null | 'changed' 18 | status?: string 19 | } -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v3 12 | with: 13 | repo-token: ${{ secrets.GITHUB_TOKEN }} 14 | stale-issue-message: 'This issue is stale because it has been open 6 days with no activity. Remove the stale label or comment or this will be closed in 2 days' 15 | stale-pr-message: 'This PR is stale because it has been open 6 days with no activity. Remove the stale label or comment or this will be closed in 2 days' 16 | days-before-stale: 6 17 | days-before-close: 2 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve the library 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Created a new connection 16 | 2. Closed & used saved credentials to log back in 17 | 3. Etc. 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Environment (please complete the following information):** 23 | - Is this on a server? 24 | - What do your `connectOptions` look like? 25 | - Do you have multiple clients on the same IP? 26 | - Are you using a proxy? 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /src/Types/State.ts: -------------------------------------------------------------------------------- 1 | import { Contact } from './Contact' 2 | 3 | export type WAConnectionState = 'open' | 'connecting' | 'close' 4 | 5 | export type ConnectionState = { 6 | /** connection is now open, connecting or closed */ 7 | connection: WAConnectionState 8 | /** the error that caused the connection to close */ 9 | lastDisconnect?: { 10 | error: Error | undefined 11 | date: Date 12 | } 13 | /** is this a new login */ 14 | isNewLogin?: boolean 15 | /** the current QR code */ 16 | qr?: string 17 | /** has the device received all pending notifications while it was offline */ 18 | receivedPendingNotifications?: boolean 19 | /** legacy connection options */ 20 | legacy?: { 21 | phoneConnected: boolean 22 | user?: Contact 23 | } 24 | /** 25 | * if the client is shown as an active, online client. 26 | * If this is false, the primary phone and other devices will receive notifs 27 | * */ 28 | isOnline?: boolean 29 | } -------------------------------------------------------------------------------- /WASignalGroup/sender_message_key.js: -------------------------------------------------------------------------------- 1 | const { deriveSecrets } = require('libsignal/src/crypto'); 2 | class SenderMessageKey { 3 | iteration = 0; 4 | 5 | iv = Buffer.alloc(0); 6 | 7 | cipherKey = Buffer.alloc(0); 8 | 9 | seed = Buffer.alloc(0); 10 | 11 | constructor(iteration, seed) { 12 | const derivative = deriveSecrets(seed, Buffer.alloc(32), Buffer.from('WhisperGroup')); 13 | const keys = new Uint8Array(32); 14 | keys.set(new Uint8Array(derivative[0].slice(16))); 15 | keys.set(new Uint8Array(derivative[1].slice(0, 16)), 16); 16 | this.iv = Buffer.from(derivative[0].slice(0, 16)); 17 | this.cipherKey = Buffer.from(keys.buffer); 18 | 19 | this.iteration = iteration; 20 | this.seed = seed; 21 | } 22 | 23 | getIteration() { 24 | return this.iteration; 25 | } 26 | 27 | getIv() { 28 | return this.iv; 29 | } 30 | 31 | getCipherKey() { 32 | return this.cipherKey; 33 | } 34 | 35 | getSeed() { 36 | return this.seed; 37 | } 38 | } 39 | module.exports = SenderMessageKey; -------------------------------------------------------------------------------- /WASignalGroup/group.proto: -------------------------------------------------------------------------------- 1 | package groupproto; 2 | 3 | message SenderKeyMessage { 4 | optional uint32 id = 1; 5 | optional uint32 iteration = 2; 6 | optional bytes ciphertext = 3; 7 | } 8 | 9 | message SenderKeyDistributionMessage { 10 | optional uint32 id = 1; 11 | optional uint32 iteration = 2; 12 | optional bytes chainKey = 3; 13 | optional bytes signingKey = 4; 14 | } 15 | 16 | message SenderChainKey { 17 | optional uint32 iteration = 1; 18 | optional bytes seed = 2; 19 | } 20 | 21 | message SenderMessageKey { 22 | optional uint32 iteration = 1; 23 | optional bytes seed = 2; 24 | } 25 | 26 | message SenderSigningKey { 27 | optional bytes public = 1; 28 | optional bytes private = 2; 29 | } 30 | 31 | message SenderKeyStateStructure { 32 | 33 | 34 | optional uint32 senderKeyId = 1; 35 | optional SenderChainKey senderChainKey = 2; 36 | optional SenderSigningKey senderSigningKey = 3; 37 | repeated SenderMessageKey senderMessageKeys = 4; 38 | } 39 | 40 | message SenderKeyRecordStructure { 41 | repeated SenderKeyStateStructure senderKeyStates = 1; 42 | } -------------------------------------------------------------------------------- /src/Types/GroupMetadata.ts: -------------------------------------------------------------------------------- 1 | import { Contact } from './Contact' 2 | 3 | export type GroupParticipant = (Contact & { isAdmin?: boolean; isSuperAdmin?: boolean, admin?: 'admin' | 'superadmin' | null }) 4 | 5 | export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote' 6 | 7 | export interface GroupMetadata { 8 | id: string 9 | owner: string | undefined 10 | subject: string 11 | /** group subject owner */ 12 | subjectOwner?: string 13 | /** group subject modification date */ 14 | subjectTime?: number 15 | creation?: number 16 | desc?: string 17 | descOwner?: string 18 | descId?: string 19 | /** is set when the group only allows admins to change group settings */ 20 | restrict?: boolean 21 | /** is set when the group only allows admins to write messages */ 22 | announce?: boolean 23 | /** number of group participants */ 24 | size?: number 25 | // Baileys modified array 26 | participants: GroupParticipant[] 27 | ephemeralDuration?: number 28 | inviteCode?: string 29 | } 30 | 31 | 32 | export interface WAGroupCreateResponse { 33 | status: number 34 | gid?: string 35 | participants?: [{ [key: string]: any }] 36 | } 37 | 38 | export interface GroupModificationResponse { 39 | status: number 40 | participants?: { [key: string]: any } 41 | } -------------------------------------------------------------------------------- /.github/workflows/update-docs.yml: -------------------------------------------------------------------------------- 1 | name: Update Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | Build: 9 | runs-on: ubuntu-latest 10 | outputs: 11 | package-name: ${{ steps.packageInfo.outputs.package-name }} 12 | package-version: ${{ steps.packageInfo.outputs.package-version }} 13 | commit-msg: ${{ steps.packageInfo.outputs.commit-msg }} 14 | # Steps represent a sequence of tasks that will be executed as part of the job 15 | steps: 16 | - name: Checkout Commit 17 | uses: actions/checkout@v2 18 | 19 | - name: Parsing Package Info 20 | id: packageInfo 21 | run: | 22 | echo "::set-output name=package-name::$(jq -r .name package.json)" 23 | echo "::set-output name=package-version::$(jq -r .version package.json)" 24 | echo "::set-output name=commit-msg::$(git log -1 --pretty=%B)" 25 | 26 | - name: Setup Node.js environment 27 | uses: actions/setup-node@v2.1.1 28 | 29 | - name: Install Dependencies 30 | run: yarn 31 | 32 | - name: Build 33 | run: yarn run build:all 34 | 35 | - name: Publish to Pages 36 | uses: crazy-max/ghaction-github-pages@v2 37 | with: 38 | target_branch: gh-pages 39 | build_dir: docs 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /WASignalGroup/sender_chain_key.js: -------------------------------------------------------------------------------- 1 | const SenderMessageKey = require('./sender_message_key'); 2 | //const HKDF = require('./hkdf'); 3 | const crypto = require('libsignal/src/crypto'); 4 | 5 | class SenderChainKey { 6 | MESSAGE_KEY_SEED = Buffer.from([0x01]); 7 | 8 | CHAIN_KEY_SEED = Buffer.from([0x02]); 9 | 10 | iteration = 0; 11 | 12 | chainKey = Buffer.alloc(0); 13 | 14 | constructor(iteration, chainKey) { 15 | this.iteration = iteration; 16 | this.chainKey = chainKey; 17 | } 18 | 19 | getIteration() { 20 | return this.iteration; 21 | } 22 | 23 | getSenderMessageKey() { 24 | return new SenderMessageKey( 25 | this.iteration, 26 | this.getDerivative(this.MESSAGE_KEY_SEED, this.chainKey) 27 | ); 28 | } 29 | 30 | getNext() { 31 | return new SenderChainKey( 32 | this.iteration + 1, 33 | this.getDerivative(this.CHAIN_KEY_SEED, this.chainKey) 34 | ); 35 | } 36 | 37 | getSeed() { 38 | return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey; 39 | } 40 | 41 | getDerivative(seed, key) { 42 | key = typeof key === 'string' ? Buffer.from(key, 'base64') : key; 43 | const hash = crypto.calculateMAC(key, seed); 44 | //const hash = new Hash().hmac_hash(key, seed, 'sha256', ''); 45 | 46 | return hash; 47 | } 48 | } 49 | 50 | module.exports = SenderChainKey; -------------------------------------------------------------------------------- /src/Types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Auth' 2 | export * from './GroupMetadata' 3 | export * from './Chat' 4 | export * from './Contact' 5 | export * from './State' 6 | export * from './Message' 7 | export * from './Socket' 8 | export * from './Events' 9 | export * from './Product' 10 | export * from './Call' 11 | 12 | import { AuthenticationState } from './Auth' 13 | import { SocketConfig } from './Socket' 14 | 15 | export type UserFacingSocketConfig = Partial & { auth: AuthenticationState } 16 | 17 | export enum DisconnectReason { 18 | connectionClosed = 428, 19 | connectionLost = 408, 20 | connectionReplaced = 440, 21 | timedOut = 408, 22 | loggedOut = 401, 23 | badSession = 500, 24 | restartRequired = 515, 25 | multideviceMismatch = 411 26 | } 27 | 28 | export type WAInitResponse = { 29 | ref: string 30 | ttl: number 31 | status: 200 32 | } 33 | 34 | export type WABusinessHoursConfig = { 35 | day_of_week: string 36 | mode: string 37 | open_time?: number 38 | close_time?: number 39 | } 40 | 41 | export type WABusinessProfile = { 42 | description: string 43 | email: string | undefined 44 | business_hours: { 45 | timezone?: string 46 | config?: WABusinessHoursConfig[] 47 | business_config?: WABusinessHoursConfig[] 48 | } 49 | website: string[] 50 | category?: string 51 | wid?: string 52 | address?: string 53 | } 54 | 55 | export type CurveKeyPair = { private: Uint8Array; public: Uint8Array } -------------------------------------------------------------------------------- /src/Utils/make-mutex.ts: -------------------------------------------------------------------------------- 1 | import logger from './logger' 2 | 3 | const MUTEX_TIMEOUT_MS = 60_000 4 | 5 | export const makeMutex = () => { 6 | let task = Promise.resolve() as Promise 7 | 8 | let taskTimeout: NodeJS.Timeout | undefined 9 | 10 | return { 11 | mutex(code: () => Promise | T): Promise { 12 | task = (async() => { 13 | const stack = new Error('mutex start').stack 14 | let waitOver = false 15 | taskTimeout = setTimeout(() => { 16 | logger.warn({ stack, waitOver }, 'possible mutex deadlock') 17 | }, MUTEX_TIMEOUT_MS) 18 | // wait for the previous task to complete 19 | // if there is an error, we swallow so as to not block the queue 20 | try { 21 | await task 22 | } catch{ } 23 | 24 | waitOver = true 25 | 26 | try { 27 | // execute the current task 28 | const result = await code() 29 | return result 30 | } finally { 31 | clearTimeout(taskTimeout) 32 | } 33 | })() 34 | // we replace the existing task, appending the new piece of execution to it 35 | // so the next task will have to wait for this one to finish 36 | return task 37 | }, 38 | } 39 | } 40 | 41 | export type Mutex = ReturnType 42 | 43 | export const makeKeyedMutex = () => { 44 | const map: { [id: string]: Mutex } = {} 45 | 46 | return { 47 | mutex(key: string, task: () => Promise | T): Promise { 48 | if(!map[key]) { 49 | map[key] = makeMutex() 50 | } 51 | 52 | return map[key].mutex(task) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Utils/lt-hash.ts: -------------------------------------------------------------------------------- 1 | import { hkdf } from './crypto' 2 | 3 | /** 4 | * LT Hash is a summation based hash algorithm that maintains the integrity of a piece of data 5 | * over a series of mutations. You can add/remove mutations and it'll return a hash equal to 6 | * if the same series of mutations was made sequentially. 7 | */ 8 | 9 | const o = 128 10 | 11 | class d { 12 | 13 | salt: string 14 | 15 | constructor(e: string) { 16 | this.salt = e 17 | } 18 | add(e, t) { 19 | var r = this 20 | for(const item of t) { 21 | e = r._addSingle(e, item) 22 | } 23 | 24 | return e 25 | } 26 | subtract(e, t) { 27 | var r = this 28 | for(const item of t) { 29 | e = r._subtractSingle(e, item) 30 | } 31 | 32 | return e 33 | } 34 | subtractThenAdd(e, t, r) { 35 | var n = this 36 | return n.add(n.subtract(e, r), t) 37 | } 38 | _addSingle(e, t) { 39 | var r = this 40 | const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer 41 | return r.performPointwiseWithOverflow(e, n, ((e, t) => e + t)) 42 | } 43 | _subtractSingle(e, t) { 44 | var r = this 45 | 46 | const n = new Uint8Array(hkdf(Buffer.from(t), o, { info: r.salt })).buffer 47 | return r.performPointwiseWithOverflow(e, n, ((e, t) => e - t)) 48 | } 49 | performPointwiseWithOverflow(e, t, r) { 50 | const n = new DataView(e) 51 | , i = new DataView(t) 52 | , a = new ArrayBuffer(n.byteLength) 53 | , s = new DataView(a) 54 | for(let e = 0; e < n.byteLength; e += 2) { 55 | s.setUint16(e, r(n.getUint16(e, !0), i.getUint16(e, !0)), !0) 56 | } 57 | 58 | return a 59 | } 60 | } 61 | export const LT_HASH_ANTI_TAMPERING = new d('WhatsApp Patch Integrity') -------------------------------------------------------------------------------- /WASignalGroup/sender_key_name.js: -------------------------------------------------------------------------------- 1 | function isNull(str) { 2 | return str === null || str.value === ''; 3 | } 4 | 5 | /** 6 | * java String hashCode 的实现 7 | * @param strKey 8 | * @return intValue 9 | */ 10 | function intValue(num) { 11 | const MAX_VALUE = 0x7fffffff; 12 | const MIN_VALUE = -0x80000000; 13 | if (num > MAX_VALUE || num < MIN_VALUE) { 14 | // eslint-disable-next-line 15 | return (num &= 0xffffffff); 16 | } 17 | return num; 18 | } 19 | 20 | function hashCode(strKey) { 21 | let hash = 0; 22 | if (!isNull(strKey)) { 23 | for (let i = 0; i < strKey.length; i++) { 24 | hash = hash * 31 + strKey.charCodeAt(i); 25 | hash = intValue(hash); 26 | } 27 | } 28 | return hash; 29 | } 30 | 31 | /** 32 | * 将js页面的number类型转换为java的int类型 33 | * @param num 34 | * @return intValue 35 | */ 36 | 37 | class SenderKeyName { 38 | constructor(groupId, sender) { 39 | this.groupId = groupId; 40 | this.sender = sender; 41 | } 42 | 43 | getGroupId() { 44 | return this.groupId; 45 | } 46 | 47 | getSender() { 48 | return this.sender; 49 | } 50 | 51 | serialize() { 52 | return `${this.groupId}::${this.sender.id}::${this.sender.deviceId}`; 53 | } 54 | 55 | toString() { 56 | return this.serialize(); 57 | } 58 | 59 | equals(other) { 60 | if (other === null) return false; 61 | if (!(other instanceof SenderKeyName)) return false; 62 | return this.groupId === other.groupId && this.sender.toString() === other.sender.toString(); 63 | } 64 | 65 | hashCode() { 66 | return hashCode(this.groupId) ^ hashCode(this.sender.toString()); 67 | } 68 | } 69 | 70 | module.exports = SenderKeyName; -------------------------------------------------------------------------------- /WASignalGroup/sender_key_record.js: -------------------------------------------------------------------------------- 1 | const SenderKeyState = require('./sender_key_state'); 2 | 3 | class SenderKeyRecord { 4 | MAX_STATES = 5; 5 | 6 | constructor(serialized) { 7 | this.senderKeyStates = []; 8 | 9 | if (serialized) { 10 | const list = serialized; 11 | for (let i = 0; i < list.length; i++) { 12 | const structure = list[i]; 13 | this.senderKeyStates.push( 14 | new SenderKeyState(null, null, null, null, null, null, structure) 15 | ); 16 | } 17 | } 18 | } 19 | 20 | isEmpty() { 21 | return this.senderKeyStates.length === 0; 22 | } 23 | 24 | getSenderKeyState(keyId) { 25 | if (!keyId && this.senderKeyStates.length) return this.senderKeyStates[0]; 26 | for (let i = 0; i < this.senderKeyStates.length; i++) { 27 | const state = this.senderKeyStates[i]; 28 | if (state.getKeyId() === keyId) { 29 | return state; 30 | } 31 | } 32 | throw new Error(`No keys for: ${keyId}`); 33 | } 34 | 35 | addSenderKeyState(id, iteration, chainKey, signatureKey) { 36 | this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, null, signatureKey)); 37 | } 38 | 39 | setSenderKeyState(id, iteration, chainKey, keyPair) { 40 | this.senderKeyStates.length = 0; 41 | this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, keyPair)); 42 | } 43 | 44 | serialize() { 45 | const recordStructure = []; 46 | for (let i = 0; i < this.senderKeyStates.length; i++) { 47 | const senderKeyState = this.senderKeyStates[i]; 48 | recordStructure.push(senderKeyState.getStructure()); 49 | } 50 | return recordStructure; 51 | } 52 | } 53 | 54 | module.exports = SenderKeyRecord; -------------------------------------------------------------------------------- /src/Types/Product.ts: -------------------------------------------------------------------------------- 1 | import { WAMediaUpload } from './Message' 2 | 3 | export type CatalogResult = { 4 | data: { 5 | paging: { cursors: { before: string, after: string } }, 6 | data: any[] 7 | } 8 | } 9 | 10 | export type ProductCreateResult = { 11 | data: { product: any } 12 | } 13 | 14 | export type CatalogStatus = { 15 | status: string 16 | canAppeal: boolean 17 | } 18 | 19 | export type CatalogCollection = { 20 | id: string 21 | name: string 22 | products: Product[] 23 | 24 | status: CatalogStatus 25 | } 26 | 27 | export type ProductAvailability = 'in stock' 28 | 29 | export type ProductBase = { 30 | name: string 31 | retailerId?: string 32 | url?: string 33 | description: string 34 | price: number 35 | currency: string 36 | isHidden?: boolean 37 | } 38 | 39 | export type ProductCreate = ProductBase & { 40 | /** ISO country code for product origin. Set to undefined for no country */ 41 | originCountryCode: string | undefined 42 | /** images of the product */ 43 | images: WAMediaUpload[] 44 | } 45 | 46 | export type ProductUpdate = Omit 47 | 48 | export type Product = ProductBase & { 49 | id: string 50 | imageUrls: { [_: string]: string } 51 | reviewStatus: { [_: string]: string } 52 | availability: ProductAvailability 53 | } 54 | 55 | export type OrderPrice = { 56 | currency: string 57 | total: number 58 | } 59 | 60 | export type OrderProduct = { 61 | id: string 62 | imageUrl: string 63 | name: string 64 | quantity: number 65 | 66 | currency: string 67 | price: number 68 | } 69 | 70 | export type OrderDetails = { 71 | price: OrderPrice 72 | products: OrderProduct[] 73 | } 74 | 75 | export type CatalogCursor = string 76 | 77 | export type GetCatalogOptions = { 78 | /** cursor to start from */ 79 | cursor?: CatalogCursor 80 | /** number of products to fetch */ 81 | limit?: number 82 | 83 | jid?: string 84 | } -------------------------------------------------------------------------------- /src/Store/make-ordered-dictionary.ts: -------------------------------------------------------------------------------- 1 | function makeOrderedDictionary(idGetter: (item: T) => string) { 2 | const array: T[] = [] 3 | const dict: { [_: string]: T } = { } 4 | 5 | const get = (id: string): T | undefined => dict[id] 6 | 7 | const update = (item: T) => { 8 | const id = idGetter(item) 9 | const idx = array.findIndex(i => idGetter(i) === id) 10 | if(idx >= 0) { 11 | array[idx] = item 12 | dict[id] = item 13 | } 14 | 15 | return false 16 | } 17 | 18 | const upsert = (item: T, mode: 'append' | 'prepend') => { 19 | const id = idGetter(item) 20 | if(get(id)) { 21 | update(item) 22 | } else { 23 | if(mode === 'append') { 24 | array.push(item) 25 | } else { 26 | array.splice(0, 0, item) 27 | } 28 | 29 | dict[id] = item 30 | } 31 | } 32 | 33 | const remove = (item: T) => { 34 | const id = idGetter(item) 35 | const idx = array.findIndex(i => idGetter(i) === id) 36 | if(idx >= 0) { 37 | array.splice(idx, 1) 38 | delete dict[id] 39 | return true 40 | } 41 | 42 | return false 43 | } 44 | 45 | return { 46 | array, 47 | get, 48 | upsert, 49 | update, 50 | remove, 51 | updateAssign: (id: string, update: Partial) => { 52 | const item = get(id) 53 | if(item) { 54 | Object.assign(item, update) 55 | delete dict[id] 56 | dict[idGetter(item)] = item 57 | return true 58 | } 59 | 60 | return false 61 | }, 62 | clear: () => { 63 | array.splice(0, array.length) 64 | Object.keys(dict).forEach(key => { 65 | delete dict[key] 66 | }) 67 | }, 68 | filter: (contain: (item: T) => boolean) => { 69 | let i = 0 70 | while(i < array.length) { 71 | if(!contain(array[i])) { 72 | delete dict[idGetter(array[i])] 73 | array.splice(i, 1) 74 | } else { 75 | i += 1 76 | } 77 | } 78 | }, 79 | toJSON: () => array, 80 | fromJSON: (newItems: T[]) => { 81 | array.splice(0, array.length, ...newItems) 82 | } 83 | } 84 | } 85 | 86 | export default makeOrderedDictionary -------------------------------------------------------------------------------- /WASignalGroup/group_session_builder.js: -------------------------------------------------------------------------------- 1 | //const utils = require('../../common/utils'); 2 | const SenderKeyDistributionMessage = require('./sender_key_distribution_message'); 3 | 4 | const keyhelper = require("./keyhelper"); 5 | class GroupSessionBuilder { 6 | constructor(senderKeyStore) { 7 | this.senderKeyStore = senderKeyStore; 8 | } 9 | 10 | async process(senderKeyName, senderKeyDistributionMessage) { 11 | //console.log('GroupSessionBuilder process', senderKeyName, senderKeyDistributionMessage); 12 | const senderKeyRecord = await this.senderKeyStore.loadSenderKey(senderKeyName); 13 | senderKeyRecord.addSenderKeyState( 14 | senderKeyDistributionMessage.getId(), 15 | senderKeyDistributionMessage.getIteration(), 16 | senderKeyDistributionMessage.getChainKey(), 17 | senderKeyDistributionMessage.getSignatureKey() 18 | ); 19 | await this.senderKeyStore.storeSenderKey(senderKeyName, senderKeyRecord); 20 | } 21 | 22 | // [{"senderKeyId":1742199468,"senderChainKey":{"iteration":0,"seed":"yxMY9VFQcXEP34olRAcGCtsgx1XoKsHfDIh+1ea4HAQ="},"senderSigningKey":{"public":""}}] 23 | async create(senderKeyName) { 24 | const senderKeyRecord = await this.senderKeyStore.loadSenderKey(senderKeyName); 25 | //console.log('GroupSessionBuilder create session', senderKeyName, senderKeyRecord); 26 | 27 | if (senderKeyRecord.isEmpty()) { 28 | const keyId = keyhelper.generateSenderKeyId(); 29 | const senderKey = keyhelper.generateSenderKey(); 30 | const signingKey = keyhelper.generateSenderSigningKey(); 31 | 32 | senderKeyRecord.setSenderKeyState(keyId, 0, senderKey, signingKey); 33 | await this.senderKeyStore.storeSenderKey(senderKeyName, senderKeyRecord); 34 | } 35 | 36 | const state = senderKeyRecord.getSenderKeyState(); 37 | 38 | return new SenderKeyDistributionMessage( 39 | state.getKeyId(), 40 | state.getSenderChainKey().getIteration(), 41 | state.getSenderChainKey().getSeed(), 42 | state.getSigningKeyPublic() 43 | ); 44 | } 45 | } 46 | module.exports = GroupSessionBuilder; -------------------------------------------------------------------------------- /src/Utils/baileys-event-stream.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | import { createReadStream } from 'fs' 3 | import { writeFile } from 'fs/promises' 4 | import { createInterface } from 'readline' 5 | import type { BaileysEventEmitter } from '../Types' 6 | import { delay } from './generics' 7 | import { makeMutex } from './make-mutex' 8 | 9 | /** 10 | * Captures events from a baileys event emitter & stores them in a file 11 | * @param ev The event emitter to read events from 12 | * @param filename File to save to 13 | */ 14 | export const captureEventStream = (ev: BaileysEventEmitter, filename: string) => { 15 | const oldEmit = ev.emit 16 | // write mutex so data is appended in order 17 | const writeMutex = makeMutex() 18 | // monkey patch eventemitter to capture all events 19 | ev.emit = function(...args: any[]) { 20 | const content = JSON.stringify({ timestamp: Date.now(), event: args[0], data: args[1] }) + '\n' 21 | const result = oldEmit.apply(ev, args) 22 | 23 | writeMutex.mutex( 24 | async() => { 25 | await writeFile(filename, content, { flag: 'a' }) 26 | } 27 | ) 28 | 29 | return result 30 | } 31 | } 32 | 33 | /** 34 | * Read event file and emit events from there 35 | * @param filename filename containing event data 36 | * @param delayIntervalMs delay between each event emit 37 | */ 38 | export const readAndEmitEventStream = (filename: string, delayIntervalMs: number = 0) => { 39 | const ev = new EventEmitter() as BaileysEventEmitter 40 | 41 | const fireEvents = async() => { 42 | // from: https://stackoverflow.com/questions/6156501/read-a-file-one-line-at-a-time-in-node-js 43 | const fileStream = createReadStream(filename) 44 | 45 | const rl = createInterface({ 46 | input: fileStream, 47 | crlfDelay: Infinity 48 | }) 49 | // Note: we use the crlfDelay option to recognize all instances of CR LF 50 | // ('\r\n') in input.txt as a single line break. 51 | for await (const line of rl) { 52 | if(line) { 53 | const { event, data } = JSON.parse(line) 54 | ev.emit(event, data) 55 | delayIntervalMs && await delay(delayIntervalMs) 56 | } 57 | } 58 | 59 | fileStream.close() 60 | } 61 | 62 | return { 63 | ev, 64 | task: fireEvents() 65 | } 66 | } -------------------------------------------------------------------------------- /src/WABinary/jid-utils.ts: -------------------------------------------------------------------------------- 1 | export const S_WHATSAPP_NET = '@s.whatsapp.net' 2 | export const OFFICIAL_BIZ_JID = '16505361212@c.us' 3 | export const SERVER_JID = 'server@c.us' 4 | export const PSA_WID = '0@c.us' 5 | export const STORIES_JID = 'status@broadcast' 6 | 7 | export type JidServer = 'c.us' | 'g.us' | 'broadcast' | 's.whatsapp.net' | 'call' 8 | 9 | export type JidWithDevice = { 10 | user: string 11 | device?: number 12 | } 13 | 14 | export type FullJid = JidWithDevice & { 15 | server: JidServer | string 16 | agent?: number 17 | } 18 | 19 | export const jidEncode = (user: string | number | null, server: JidServer, device?: number, agent?: number) => { 20 | return `${user || ''}${!!agent ? `_${agent}` : ''}${!!device ? `:${device}` : ''}@${server}` 21 | } 22 | 23 | export const jidDecode = (jid: string | undefined): FullJid | undefined => { 24 | const sepIdx = typeof jid === 'string' ? jid.indexOf('@') : -1 25 | if(sepIdx < 0) { 26 | return undefined 27 | } 28 | 29 | const server = jid!.slice(sepIdx + 1) 30 | const userCombined = jid!.slice(0, sepIdx) 31 | 32 | const [userAgent, device] = userCombined.split(':') 33 | const [user, agent] = userAgent.split('_') 34 | 35 | return { 36 | server, 37 | user, 38 | agent: agent ? +agent : undefined, 39 | device: device ? +device : undefined 40 | } 41 | } 42 | 43 | /** is the jid a user */ 44 | export const areJidsSameUser = (jid1: string | undefined, jid2: string | undefined) => ( 45 | jidDecode(jid1)?.user === jidDecode(jid2)?.user 46 | ) 47 | /** is the jid a user */ 48 | export const isJidUser = (jid: string | undefined) => (jid?.endsWith('@s.whatsapp.net')) 49 | /** is the jid a broadcast */ 50 | export const isJidBroadcast = (jid: string | undefined) => (jid?.endsWith('@broadcast')) 51 | /** is the jid a group */ 52 | export const isJidGroup = (jid: string | undefined) => (jid?.endsWith('@g.us')) 53 | /** is the jid the status broadcast */ 54 | export const isJidStatusBroadcast = (jid: string) => jid === 'status@broadcast' 55 | 56 | export const jidNormalizedUser = (jid: string | undefined) => { 57 | const result = jidDecode(jid) 58 | if(!result) { 59 | return '' 60 | } 61 | 62 | const { user, server } = result 63 | return jidEncode(user, server === 'c.us' ? 's.whatsapp.net' : server as JidServer) 64 | } -------------------------------------------------------------------------------- /WASignalGroup/sender_key_distribution_message.js: -------------------------------------------------------------------------------- 1 | const CiphertextMessage = require('./ciphertext_message'); 2 | const protobufs = require('./protobufs'); 3 | 4 | class SenderKeyDistributionMessage extends CiphertextMessage { 5 | constructor( 6 | id = null, 7 | iteration = null, 8 | chainKey = null, 9 | signatureKey = null, 10 | serialized = null 11 | ) { 12 | super(); 13 | if (serialized) { 14 | try { 15 | const version = serialized[0]; 16 | const message = serialized.slice(1); 17 | 18 | const distributionMessage = protobufs.SenderKeyDistributionMessage.decode( 19 | message 20 | ).toJSON(); 21 | this.serialized = serialized; 22 | this.id = distributionMessage.id; 23 | this.iteration = distributionMessage.iteration; 24 | this.chainKey = distributionMessage.chainKey; 25 | this.signatureKey = distributionMessage.signingKey; 26 | } catch (e) { 27 | throw new Error(e); 28 | } 29 | } else { 30 | const version = this.intsToByteHighAndLow(this.CURRENT_VERSION, this.CURRENT_VERSION); 31 | this.id = id; 32 | this.iteration = iteration; 33 | this.chainKey = chainKey; 34 | this.signatureKey = signatureKey; 35 | const message = protobufs.SenderKeyDistributionMessage.encode( 36 | protobufs.SenderKeyDistributionMessage.create({ 37 | id, 38 | iteration, 39 | chainKey, 40 | signingKey: this.signatureKey, 41 | }) 42 | ).finish(); 43 | this.serialized = Buffer.concat([Buffer.from([version]), message]); 44 | } 45 | } 46 | 47 | intsToByteHighAndLow(highValue, lowValue) { 48 | return (((highValue << 4) | lowValue) & 0xff) % 256; 49 | } 50 | 51 | serialize() { 52 | return this.serialized; 53 | } 54 | 55 | getType() { 56 | return this.SENDERKEY_DISTRIBUTION_TYPE; 57 | } 58 | 59 | getIteration() { 60 | return this.iteration; 61 | } 62 | 63 | getChainKey() { 64 | return typeof this.chainKey === 'string' ? Buffer.from(this.chainKey, 'base64') : this.chainKey; 65 | } 66 | 67 | getSignatureKey() { 68 | return typeof this.signatureKey === 'string' 69 | ? Buffer.from(this.signatureKey, 'base64') 70 | : this.signatureKey; 71 | } 72 | 73 | getId() { 74 | return this.id; 75 | } 76 | } 77 | 78 | module.exports = SenderKeyDistributionMessage; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adiwajshing/baileys", 3 | "version": "5.0.0", 4 | "description": "WhatsApp API", 5 | "homepage": "https://github.com/adiwajshing/Baileys", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "keywords": [ 9 | "whatsapp", 10 | "js-whatsapp", 11 | "whatsapp-api", 12 | "whatsapp-web", 13 | "whatsapp", 14 | "whatsapp-chat", 15 | "whatsapp-group", 16 | "automation", 17 | "multi-device" 18 | ], 19 | "scripts": { 20 | "test": "jest", 21 | "prepare": "tsc", 22 | "build:all": "tsc && typedoc", 23 | "build:docs": "typedoc", 24 | "build:tsc": "tsc", 25 | "example": "node --inspect -r ts-node/register Example/example.ts", 26 | "gen:protobuf": "sh WAProto/GenerateStatics.sh", 27 | "lint": "eslint ./src --ext .js,.ts,.jsx,.tsx", 28 | "lint:fix": "eslint ./src --fix --ext .js,.ts,.jsx,.tsx" 29 | }, 30 | "author": "Adhiraj Singh", 31 | "license": "MIT", 32 | "repository": { 33 | "url": "git@github.com:adiwajshing/baileys.git" 34 | }, 35 | "dependencies": { 36 | "@hapi/boom": "^9.1.3", 37 | "axios": "^0.24.0", 38 | "futoin-hkdf": "^1.5.1", 39 | "libsignal": "git+https://github.com/adiwajshing/libsignal-node", 40 | "music-metadata": "^7.12.3", 41 | "node-cache": "^5.1.2", 42 | "pino": "^7.0.0", 43 | "protobufjs": "^6.11.3", 44 | "ws": "^8.0.0" 45 | }, 46 | "peerDependencies": { 47 | "@adiwajshing/keyed-db": "^0.2.4", 48 | "jimp": "^0.16.1", 49 | "link-preview-js": "^3.0.0", 50 | "qrcode-terminal": "^0.12.0", 51 | "sharp": "^0.30.5" 52 | }, 53 | "peerDependenciesMeta": { 54 | "@adiwajshing/keyed-db": { 55 | "optional": true 56 | }, 57 | "jimp": { 58 | "optional": true 59 | }, 60 | "qrcode-terminal": { 61 | "optional": true 62 | }, 63 | "sharp": { 64 | "optional": true 65 | }, 66 | "link-preview-js": { 67 | "optional": true 68 | } 69 | }, 70 | "files": [ 71 | "lib/*", 72 | "WAProto/*", 73 | "WASignalGroup/*.js" 74 | ], 75 | "devDependencies": { 76 | "@adiwajshing/eslint-config": "git+https://github.com/adiwajshing/eslint-config", 77 | "@adiwajshing/keyed-db": "^0.2.4", 78 | "@types/got": "^9.6.11", 79 | "@types/jest": "^27.5.1", 80 | "@types/node": "^16.0.0", 81 | "@types/sharp": "^0.29.4", 82 | "@types/ws": "^8.0.0", 83 | "eslint": "^8.0.0", 84 | "jest": "^27.0.6", 85 | "jimp": "^0.16.1", 86 | "link-preview-js": "^3.0.0", 87 | "qrcode-terminal": "^0.12.0", 88 | "sharp": "^0.30.5", 89 | "ts-jest": "^27.0.3", 90 | "ts-node": "^10.8.1", 91 | "typedoc": "^0.22.0", 92 | "typescript": "^4.0.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Tests/test.media-download.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import { proto } from '../../WAProto' 3 | import { DownloadableMessage, MediaType } from '../Types' 4 | import { downloadContentFromMessage } from '../Utils' 5 | 6 | jest.setTimeout(20_000) 7 | 8 | type TestVector = { 9 | type: MediaType 10 | message: DownloadableMessage 11 | plaintext: Buffer 12 | } 13 | 14 | const TEST_VECTORS: TestVector[] = [ 15 | { 16 | type: 'image', 17 | message: proto.Message.ImageMessage.decode( 18 | Buffer.from( 19 | 'Ck1odHRwczovL21tZy53aGF0c2FwcC5uZXQvZC9mL0FwaHR4WG9fWXZZcDZlUVNSa0tjOHE5d2ozVUpleWdoY3poM3ExX3I0ektnLmVuYxIKaW1hZ2UvanBlZyIgKTuVFyxDc6mTm4GXPlO3Z911Wd8RBeTrPLSWAEdqW8MomcUBQiB7wH5a4nXMKyLOT0A2nFgnnM/DUH8YjQf8QtkCIekaSkogTB+BXKCWDFrmNzozY0DCPn0L4VKd7yG1ZbZwbgRhzVc=', 20 | 'base64' 21 | ) 22 | ), 23 | plaintext: readFileSync('./Media/cat.jpeg') 24 | }, 25 | { 26 | type: 'image', 27 | message: proto.Message.ImageMessage.decode( 28 | Buffer.from( 29 | 'Ck1odHRwczovL21tZy53aGF0c2FwcC5uZXQvZC9mL0Ftb2tnWkphNWF6QWZxa3dVRzc0eUNUdTlGeWpjMmd5akpqcXNmMUFpZEU5LmVuYxIKaW1hZ2UvanBlZyIg8IS5TQzdzcuvcR7F8HMhWnXmlsV+GOo9JE1/t2k+o9Yoz6o6QiA7kDk8j5KOEQC0kDFE1qW7lBBDYhm5z06N3SirfUj3CUog/CjYF8e670D5wUJwWv2B2mKzDEo8IJLStDv76YmtPfs=', 30 | 'base64' 31 | ) 32 | ), 33 | plaintext: readFileSync('./Media/icon.png') 34 | }, 35 | ] 36 | 37 | describe('Media Download Tests', () => { 38 | 39 | it('should download a full encrypted media correctly', async() => { 40 | for(const { type, message, plaintext } of TEST_VECTORS) { 41 | const readPipe = await downloadContentFromMessage(message, type) 42 | 43 | let buffer = Buffer.alloc(0) 44 | for await (const read of readPipe) { 45 | buffer = Buffer.concat([ buffer, read ]) 46 | } 47 | 48 | expect(buffer).toEqual(plaintext) 49 | } 50 | }) 51 | 52 | it('should download an encrypted media correctly piece', async() => { 53 | for(const { type, message, plaintext } of TEST_VECTORS) { 54 | // check all edge cases 55 | const ranges = [ 56 | { startByte: 51, endByte: plaintext.length - 100 }, // random numbers 57 | { startByte: 1024, endByte: 2038 }, // larger random multiples of 16 58 | { startByte: 1, endByte: plaintext.length - 1 } // borders 59 | ] 60 | for(const range of ranges) { 61 | const readPipe = await downloadContentFromMessage(message, type, range) 62 | 63 | let buffer = Buffer.alloc(0) 64 | for await (const read of readPipe) { 65 | buffer = Buffer.concat([ buffer, read ]) 66 | } 67 | 68 | const hex = buffer.toString('hex') 69 | const expectedHex = plaintext.slice(range.startByte || 0, range.endByte || undefined).toString('hex') 70 | expect(hex).toBe(expectedHex) 71 | 72 | console.log('success on ', range) 73 | } 74 | } 75 | }) 76 | }) -------------------------------------------------------------------------------- /src/WABinary/generic-utils.ts: -------------------------------------------------------------------------------- 1 | import { Boom } from '@hapi/boom' 2 | import { proto } from '../../WAProto' 3 | import { BinaryNode } from './types' 4 | 5 | // some extra useful utilities 6 | 7 | export const getBinaryNodeChildren = (node: BinaryNode | undefined, childTag: string) => { 8 | if(Array.isArray(node?.content)) { 9 | return node!.content.filter(item => item.tag === childTag) 10 | } 11 | 12 | return [] 13 | } 14 | 15 | export const getAllBinaryNodeChildren = ({ content }: BinaryNode) => { 16 | if(Array.isArray(content)) { 17 | return content 18 | } 19 | 20 | return [] 21 | } 22 | 23 | export const getBinaryNodeChild = (node: BinaryNode | undefined, childTag: string) => { 24 | if(Array.isArray(node?.content)) { 25 | return node?.content.find(item => item.tag === childTag) 26 | } 27 | } 28 | 29 | export const getBinaryNodeChildBuffer = (node: BinaryNode | undefined, childTag: string) => { 30 | const child = getBinaryNodeChild(node, childTag)?.content 31 | if(Buffer.isBuffer(child) || child instanceof Uint8Array) { 32 | return child 33 | } 34 | } 35 | 36 | export const getBinaryNodeChildString = (node: BinaryNode | undefined, childTag: string) => { 37 | const child = getBinaryNodeChild(node, childTag)?.content 38 | if(Buffer.isBuffer(child) || child instanceof Uint8Array) { 39 | return Buffer.from(child).toString('utf-8') 40 | } else if(typeof child === 'string') { 41 | return child 42 | } 43 | } 44 | 45 | export const getBinaryNodeChildUInt = (node: BinaryNode, childTag: string, length: number) => { 46 | const buff = getBinaryNodeChildBuffer(node, childTag) 47 | if(buff) { 48 | return bufferToUInt(buff, length) 49 | } 50 | } 51 | 52 | export const assertNodeErrorFree = (node: BinaryNode) => { 53 | const errNode = getBinaryNodeChild(node, 'error') 54 | if(errNode) { 55 | throw new Boom(errNode.attrs.text || 'Unknown error', { data: +errNode.attrs.code }) 56 | } 57 | } 58 | 59 | export const reduceBinaryNodeToDictionary = (node: BinaryNode, tag: string) => { 60 | const nodes = getBinaryNodeChildren(node, tag) 61 | const dict = nodes.reduce( 62 | (dict, { attrs }) => { 63 | dict[attrs.name || attrs.config_code] = attrs.value || attrs.config_value 64 | return dict 65 | }, { } as { [_: string]: string } 66 | ) 67 | return dict 68 | } 69 | 70 | export const getBinaryNodeMessages = ({ content }: BinaryNode) => { 71 | const msgs: proto.WebMessageInfo[] = [] 72 | if(Array.isArray(content)) { 73 | for(const item of content) { 74 | if(item.tag === 'message') { 75 | msgs.push(proto.WebMessageInfo.decode(item.content as Buffer)) 76 | } 77 | } 78 | } 79 | 80 | return msgs 81 | } 82 | 83 | function bufferToUInt(e: Uint8Array | Buffer, t: number) { 84 | let a = 0 85 | for(let i = 0; i < t; i++) { 86 | a = 256 * a + e[i] 87 | } 88 | 89 | return a 90 | } -------------------------------------------------------------------------------- /src/Utils/link-preview.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from 'pino' 2 | import { WAMediaUploadFunction, WAUrlInfo } from '../Types' 3 | import { prepareWAMessageMedia } from './messages' 4 | import { extractImageThumb, getHttpStream } from './messages-media' 5 | 6 | const THUMBNAIL_WIDTH_PX = 192 7 | 8 | /** Fetches an image and generates a thumbnail for it */ 9 | const getCompressedJpegThumbnail = async( 10 | url: string, 11 | { thumbnailWidth, fetchOpts }: URLGenerationOptions 12 | ) => { 13 | const stream = await getHttpStream(url, fetchOpts) 14 | const result = await extractImageThumb(stream, thumbnailWidth) 15 | return result 16 | } 17 | 18 | export type URLGenerationOptions = { 19 | thumbnailWidth: number 20 | fetchOpts: { 21 | /** Timeout in ms */ 22 | timeout: number 23 | proxyUrl?: string 24 | headers?: { [key: string]: string } 25 | } 26 | uploadImage?: WAMediaUploadFunction 27 | logger?: Logger 28 | } 29 | 30 | /** 31 | * Given a piece of text, checks for any URL present, generates link preview for the same and returns it 32 | * Return undefined if the fetch failed or no URL was found 33 | * @param text first matched URL in text 34 | * @returns the URL info required to generate link preview 35 | */ 36 | export const getUrlInfo = async( 37 | text: string, 38 | opts: URLGenerationOptions = { 39 | thumbnailWidth: THUMBNAIL_WIDTH_PX, 40 | fetchOpts: { timeout: 3000 } 41 | }, 42 | ): Promise => { 43 | try { 44 | const { getLinkPreview } = await import('link-preview-js') 45 | let previewLink = text 46 | if(!text.startsWith('https://') && !text.startsWith('http://')) { 47 | previewLink = 'https://' + previewLink 48 | } 49 | 50 | const info = await getLinkPreview(previewLink, opts.fetchOpts) 51 | if(info && 'title' in info && info.title) { 52 | const [image] = info.images 53 | 54 | const urlInfo: WAUrlInfo = { 55 | 'canonical-url': info.url, 56 | 'matched-text': text, 57 | title: info.title, 58 | description: info.description, 59 | originalThumbnailUrl: image 60 | } 61 | 62 | if(opts.uploadImage) { 63 | const { imageMessage } = await prepareWAMessageMedia( 64 | { image: { url: image } }, 65 | { upload: opts.uploadImage, mediaTypeOverride: 'thumbnail-link' } 66 | ) 67 | urlInfo.jpegThumbnail = imageMessage?.jpegThumbnail 68 | ? Buffer.from(imageMessage.jpegThumbnail) 69 | : undefined 70 | urlInfo.highQualityThumbnail = imageMessage || undefined 71 | } else { 72 | try { 73 | urlInfo.jpegThumbnail = image 74 | ? (await getCompressedJpegThumbnail(image, opts)).buffer 75 | : undefined 76 | } catch(error) { 77 | opts.logger?.debug( 78 | { err: error.stack, url: previewLink }, 79 | 'error in generating thumbnail' 80 | ) 81 | } 82 | } 83 | 84 | return urlInfo 85 | } 86 | } catch(error) { 87 | if(!error.message.includes('receive a valid')) { 88 | throw error 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /WASignalGroup/sender_key_message.js: -------------------------------------------------------------------------------- 1 | const CiphertextMessage = require('./ciphertext_message'); 2 | const curve = require('libsignal/src/curve'); 3 | const protobufs = require('./protobufs'); 4 | 5 | class SenderKeyMessage extends CiphertextMessage { 6 | SIGNATURE_LENGTH = 64; 7 | 8 | constructor( 9 | keyId = null, 10 | iteration = null, 11 | ciphertext = null, 12 | signatureKey = null, 13 | serialized = null 14 | ) { 15 | super(); 16 | if (serialized) { 17 | const version = serialized[0]; 18 | const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH); 19 | const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH); 20 | const senderKeyMessage = protobufs.SenderKeyMessage.decode(message).toJSON(); 21 | senderKeyMessage.ciphertext = Buffer.from(senderKeyMessage.ciphertext, 'base64'); 22 | 23 | this.serialized = serialized; 24 | this.messageVersion = (version & 0xff) >> 4; 25 | 26 | this.keyId = senderKeyMessage.id; 27 | this.iteration = senderKeyMessage.iteration; 28 | this.ciphertext = senderKeyMessage.ciphertext; 29 | this.signature = signature; 30 | } else { 31 | const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256; 32 | ciphertext = Buffer.from(ciphertext); // .toString('base64'); 33 | const message = protobufs.SenderKeyMessage.encode( 34 | protobufs.SenderKeyMessage.create({ 35 | id: keyId, 36 | iteration, 37 | ciphertext, 38 | }) 39 | ).finish(); 40 | 41 | const signature = this.getSignature( 42 | signatureKey, 43 | Buffer.concat([Buffer.from([version]), message]) 44 | ); 45 | this.serialized = Buffer.concat([Buffer.from([version]), message, Buffer.from(signature)]); 46 | this.messageVersion = this.CURRENT_VERSION; 47 | this.keyId = keyId; 48 | this.iteration = iteration; 49 | this.ciphertext = ciphertext; 50 | this.signature = signature; 51 | } 52 | } 53 | 54 | getKeyId() { 55 | return this.keyId; 56 | } 57 | 58 | getIteration() { 59 | return this.iteration; 60 | } 61 | 62 | getCipherText() { 63 | return this.ciphertext; 64 | } 65 | 66 | verifySignature(signatureKey) { 67 | const part1 = this.serialized.slice(0, this.serialized.length - this.SIGNATURE_LENGTH + 1); 68 | const part2 = this.serialized.slice(-1 * this.SIGNATURE_LENGTH); 69 | const res = curve.verifySignature(signatureKey, part1, part2); 70 | if (!res) throw new Error('Invalid signature!'); 71 | } 72 | 73 | getSignature(signatureKey, serialized) { 74 | const signature = Buffer.from( 75 | curve.calculateSignature( 76 | signatureKey, 77 | serialized 78 | ) 79 | ); 80 | return signature; 81 | } 82 | 83 | serialize() { 84 | return this.serialized; 85 | } 86 | 87 | getType() { 88 | return 4; 89 | } 90 | } 91 | 92 | module.exports = SenderKeyMessage; -------------------------------------------------------------------------------- /src/Utils/use-multi-file-auth-state.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises' 2 | import { join } from 'path' 3 | import { proto } from '../../WAProto' 4 | import { AuthenticationCreds, AuthenticationState, SignalDataTypeMap } from '../Types' 5 | import { initAuthCreds } from './auth-utils' 6 | import { BufferJSON } from './generics' 7 | 8 | /** 9 | * stores the full authentication state in a single folder. 10 | * Far more efficient than singlefileauthstate 11 | * 12 | * Again, I wouldn't endorse this for any production level use other than perhaps a bot. 13 | * Would recommend writing an auth state for use with a proper SQL or No-SQL DB 14 | * */ 15 | export const useMultiFileAuthState = async(folder: string): Promise<{ state: AuthenticationState, saveCreds: () => Promise }> => { 16 | 17 | const writeData = (data: any, file: string) => { 18 | return writeFile(join(folder, fixFileName(file)!), JSON.stringify(data, BufferJSON.replacer)) 19 | } 20 | 21 | const readData = async(file: string) => { 22 | try { 23 | const data = await readFile(join(folder, fixFileName(file)!), { encoding: 'utf-8' }) 24 | return JSON.parse(data, BufferJSON.reviver) 25 | } catch(error) { 26 | return null 27 | } 28 | } 29 | 30 | const removeData = async(file: string) => { 31 | try { 32 | await unlink(join(folder, fixFileName(file)!)) 33 | } catch{ 34 | 35 | } 36 | } 37 | 38 | const folderInfo = await stat(folder).catch(() => { }) 39 | if(folderInfo) { 40 | if(!folderInfo.isDirectory()) { 41 | throw new Error(`found something that is not a directory at ${folder}, either delete it or specify a different location`) 42 | } 43 | } else { 44 | await mkdir(folder, { recursive: true }) 45 | } 46 | 47 | const fixFileName = (file?: string) => file?.replace(/\//g, '__')?.replace(/:/g, '-') 48 | 49 | const creds: AuthenticationCreds = await readData('creds.json') || initAuthCreds() 50 | 51 | return { 52 | state: { 53 | creds, 54 | keys: { 55 | get: async(type, ids) => { 56 | const data: { [_: string]: SignalDataTypeMap[typeof type] } = { } 57 | await Promise.all( 58 | ids.map( 59 | async id => { 60 | let value = await readData(`${type}-${id}.json`) 61 | if(type === 'app-state-sync-key' && value) { 62 | value = proto.Message.AppStateSyncKeyData.fromObject(value) 63 | } 64 | 65 | data[id] = value 66 | } 67 | ) 68 | ) 69 | 70 | return data 71 | }, 72 | set: async(data) => { 73 | const tasks: Promise[] = [] 74 | for(const category in data) { 75 | for(const id in data[category]) { 76 | const value = data[category][id] 77 | const file = `${category}-${id}.json` 78 | tasks.push(value ? writeData(value, file) : removeData(file)) 79 | } 80 | } 81 | 82 | await Promise.all(tasks) 83 | } 84 | } 85 | }, 86 | saveCreds: () => { 87 | return writeData(creds, 'creds.json') 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /src/Types/Auth.ts: -------------------------------------------------------------------------------- 1 | import type { proto } from '../../WAProto' 2 | import type { Contact } from './Contact' 3 | import type { MinimalMessage } from './Message' 4 | 5 | export type KeyPair = { public: Uint8Array, private: Uint8Array } 6 | export type SignedKeyPair = { keyPair: KeyPair, signature: Uint8Array, keyId: number } 7 | 8 | export type ProtocolAddress = { 9 | name: string // jid 10 | deviceId: number 11 | } 12 | export type SignalIdentity = { 13 | identifier: ProtocolAddress 14 | identifierKey: Uint8Array 15 | } 16 | 17 | export type LTHashState = { 18 | version: number 19 | hash: Buffer 20 | indexValueMap: { 21 | [indexMacBase64: string]: { valueMac: Uint8Array | Buffer } 22 | } 23 | } 24 | 25 | export type SignalCreds = { 26 | readonly signedIdentityKey: KeyPair 27 | readonly signedPreKey: SignedKeyPair 28 | readonly registrationId: number 29 | } 30 | 31 | export type AccountSettings = { 32 | /** unarchive chats when a new message is received */ 33 | unarchiveChats: boolean 34 | /** the default mode to start new conversations with */ 35 | defaultDisappearingMode?: Pick 36 | } 37 | 38 | export type AuthenticationCreds = SignalCreds & { 39 | readonly noiseKey: KeyPair 40 | readonly advSecretKey: string 41 | 42 | me?: Contact 43 | account?: proto.IADVSignedDeviceIdentity 44 | signalIdentities?: SignalIdentity[] 45 | myAppStateKeyId?: string 46 | firstUnuploadedPreKeyId: number 47 | nextPreKeyId: number 48 | 49 | lastAccountSyncTimestamp?: number 50 | platform?: string 51 | 52 | processedHistoryMessages: MinimalMessage[] 53 | /** number of times history & app state has been synced */ 54 | accountSyncCounter: number 55 | accountSettings: AccountSettings 56 | } 57 | 58 | export type SignalDataTypeMap = { 59 | 'pre-key': KeyPair 60 | 'session': any 61 | 'sender-key': any 62 | 'sender-key-memory': { [jid: string]: boolean } 63 | 'app-state-sync-key': proto.Message.IAppStateSyncKeyData 64 | 'app-state-sync-version': LTHashState 65 | } 66 | 67 | export type SignalDataSet = { [T in keyof SignalDataTypeMap]?: { [id: string]: SignalDataTypeMap[T] | null } } 68 | 69 | type Awaitable = T | Promise 70 | 71 | export type SignalKeyStore = { 72 | get(type: T, ids: string[]): Awaitable<{ [id: string]: SignalDataTypeMap[T] }> 73 | set(data: SignalDataSet): Awaitable 74 | /** clear all the data in the store */ 75 | clear?(): Awaitable 76 | } 77 | 78 | export type SignalKeyStoreWithTransaction = SignalKeyStore & { 79 | isInTransaction: () => boolean 80 | transaction(exec: () => Promise): Promise 81 | } 82 | 83 | export type TransactionCapabilityOptions = { 84 | maxCommitRetries: number 85 | delayBetweenTriesMs: number 86 | } 87 | 88 | export type SignalAuthState = { 89 | creds: SignalCreds 90 | keys: SignalKeyStore 91 | } 92 | 93 | export type AuthenticationState = { 94 | creds: AuthenticationCreds 95 | keys: SignalKeyStore 96 | } -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: "workflow_dispatch" 4 | 5 | jobs: 6 | 7 | # Test: 8 | # runs-on: ubuntu-latest 9 | # steps: 10 | # - name: Checkout Commit 11 | # uses: actions/checkout@v2 12 | # 13 | # - name: Setup Node.js environment 14 | # uses: actions/setup-node@v2.1.1 15 | # 16 | # - name: Install Dependencies 17 | # run: npm install 18 | # 19 | # - name: Run Tests 20 | # run: npm run test 21 | Build: 22 | runs-on: ubuntu-latest 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | - name: Checkout Commit 26 | uses: actions/checkout@v2 27 | 28 | - name: Parsing Package Info 29 | id: packageInfo 30 | run: | 31 | echo "::set-output name=package-name::$(jq -r .name package.json)" 32 | echo "::set-output name=package-version::$(jq -r .version package.json)" 33 | echo "::set-output name=commit-msg::$(git log -1 --pretty=%B)" 34 | 35 | - name: Setup Node.js environment 36 | uses: actions/setup-node@v2.1.1 37 | 38 | - name: Install Dependencies 39 | run: yarn 40 | 41 | - name: Build 42 | run: yarn run build:tsc 43 | 44 | - name: Create Release 45 | id: releaseCreate 46 | uses: actions/create-release@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | # The name of the tag. This should come from the webhook payload, `github.GITHUB_REF` when a user pushes a new tag 51 | tag_name: v${{ steps.packageInfo.outputs.package-version }} 52 | # The name of the release. For example, `Release v1.0.1` 53 | release_name: v${{ steps.packageInfo.outputs.package-version }} 54 | # Text describing the contents of the tag. 55 | body: ${{steps.packageInfo.outputs.commit-msg}} 56 | # `true` to create a draft (unpublished) release, `false` to create a published one. Default: `false` 57 | draft: false 58 | # `true` to identify the release as a prerelease. `false` to identify the release as a full release. Default: `false` 59 | prerelease: false 60 | 61 | - name: Make Package 62 | run: npm pack 63 | 64 | - name: Rename Pack 65 | run: mv *.tgz npmPackage.tgz 66 | 67 | - name: Git Release 68 | uses: actions/upload-release-asset@v1.0.2 69 | env: 70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | with: 72 | # The URL for uploading assets to the release 73 | upload_url: ${{steps.releaseCreate.outputs.upload_url}} 74 | # The path to the asset you want to upload 75 | asset_path: npmPackage.tgz 76 | asset_name: npmPackage.tgz 77 | # The content-type of the asset you want to upload. See the supported Media Types here: https://www.iana.org/assignments/media-types/media-types.xhtml for more information 78 | asset_content_type: application/x-compressed-tar 79 | 80 | - name: NPM Publish 81 | uses: JS-DevTools/npm-publish@v1 82 | with: 83 | token: ${{ secrets.NPM_TOKEN }} 84 | -------------------------------------------------------------------------------- /src/Types/Chat.ts: -------------------------------------------------------------------------------- 1 | import type { proto } from '../../WAProto' 2 | import type { AccountSettings } from './Auth' 3 | import type { BufferedEventData } from './Events' 4 | import type { MinimalMessage } from './Message' 5 | 6 | /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ 7 | export type WAPresence = 'unavailable' | 'available' | 'composing' | 'recording' | 'paused' 8 | 9 | export const ALL_WA_PATCH_NAMES = [ 10 | 'critical_block', 11 | 'critical_unblock_low', 12 | 'regular_high', 13 | 'regular_low', 14 | 'regular' 15 | ] as const 16 | 17 | export type WAPatchName = typeof ALL_WA_PATCH_NAMES[number] 18 | 19 | export interface PresenceData { 20 | lastKnownPresence: WAPresence 21 | lastSeen?: number 22 | } 23 | 24 | export type ChatMutation = { 25 | syncAction: proto.ISyncActionData 26 | index: string[] 27 | } 28 | 29 | export type WAPatchCreate = { 30 | syncAction: proto.ISyncActionValue 31 | index: string[] 32 | type: WAPatchName 33 | apiVersion: number 34 | operation: proto.SyncdMutation.SyncdOperation 35 | } 36 | 37 | export type Chat = proto.IConversation & { 38 | /** unix timestamp of when the last message was received in the chat */ 39 | lastMessageRecvTimestamp?: number 40 | } 41 | 42 | export type ChatUpdate = Partial boolean | undefined 53 | }> 54 | 55 | /** 56 | * the last messages in a chat, sorted reverse-chronologically. That is, the latest message should be first in the chat 57 | * for MD modifications, the last message in the array (i.e. the earlist message) must be the last message recv in the chat 58 | * */ 59 | export type LastMessageList = MinimalMessage[] | proto.SyncActionValue.ISyncActionMessageRange 60 | 61 | export type ChatModification = 62 | { 63 | archive: boolean 64 | lastMessages: LastMessageList 65 | } 66 | | { pushNameSetting: string } 67 | | { pin: boolean } 68 | | { 69 | /** mute for duration, or provide timestamp of mute to remove*/ 70 | mute: number | null 71 | } 72 | | { 73 | clear: 'all' | { messages: {id: string, fromMe?: boolean, timestamp: number}[] } 74 | } 75 | | { 76 | star: { 77 | messages: { id: string, fromMe?: boolean }[], 78 | star: boolean 79 | } 80 | } 81 | | { 82 | markRead: boolean 83 | lastMessages: LastMessageList 84 | } 85 | | { delete: true, lastMessages: LastMessageList } 86 | 87 | export type InitialReceivedChatsState = { 88 | [jid: string]: { 89 | /** the last message received from the other party */ 90 | lastMsgRecvTimestamp?: number 91 | /** the absolute last message in the chat */ 92 | lastMsgTimestamp: number 93 | } 94 | } 95 | 96 | export type InitialAppStateSyncOptions = { 97 | accountSettings: AccountSettings 98 | } -------------------------------------------------------------------------------- /src/Utils/history.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios' 2 | import { promisify } from 'util' 3 | import { inflate } from 'zlib' 4 | import { proto } from '../../WAProto' 5 | import { Chat, Contact, WAMessageStubType } from '../Types' 6 | import { isJidUser } from '../WABinary' 7 | import { toNumber } from './generics' 8 | import { normalizeMessageContent } from './messages' 9 | import { downloadContentFromMessage } from './messages-media' 10 | 11 | const inflatePromise = promisify(inflate) 12 | 13 | export const downloadHistory = async( 14 | msg: proto.Message.IHistorySyncNotification, 15 | options: AxiosRequestConfig 16 | ) => { 17 | const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options }) 18 | const bufferArray: Buffer[] = [] 19 | for await (const chunk of stream) { 20 | bufferArray.push(chunk) 21 | } 22 | 23 | let buffer = Buffer.concat(bufferArray) 24 | 25 | // decompress buffer 26 | buffer = await inflatePromise(buffer) 27 | 28 | const syncData = proto.HistorySync.decode(buffer) 29 | return syncData 30 | } 31 | 32 | export const processHistoryMessage = (item: proto.IHistorySync) => { 33 | const messages: proto.IWebMessageInfo[] = [] 34 | const contacts: Contact[] = [] 35 | const chats: Chat[] = [] 36 | 37 | switch (item.syncType) { 38 | case proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP: 39 | case proto.HistorySync.HistorySyncType.RECENT: 40 | case proto.HistorySync.HistorySyncType.FULL: 41 | for(const chat of item.conversations! as Chat[]) { 42 | contacts.push({ id: chat.id, name: chat.name || undefined }) 43 | 44 | const msgs = chat.messages || [] 45 | delete chat.messages 46 | delete chat.archived 47 | delete chat.muteEndTime 48 | delete chat.pinned 49 | 50 | for(const item of msgs) { 51 | const message = item.message! 52 | messages.push(message) 53 | 54 | if(!chat.messages?.length) { 55 | // keep only the most recent message in the chat array 56 | chat.messages = [{ message }] 57 | } 58 | 59 | if(!message.key.fromMe && !chat.lastMessageRecvTimestamp) { 60 | chat.lastMessageRecvTimestamp = toNumber(message.messageTimestamp) 61 | } 62 | 63 | if( 64 | (message.messageStubType === WAMessageStubType.BIZ_PRIVACY_MODE_TO_BSP 65 | || message.messageStubType === WAMessageStubType.BIZ_PRIVACY_MODE_TO_FB 66 | ) 67 | && message.messageStubParameters?.[0] 68 | ) { 69 | contacts.push({ 70 | id: message.key.participant || message.key.remoteJid!, 71 | verifiedName: message.messageStubParameters?.[0], 72 | }) 73 | } 74 | } 75 | 76 | if(isJidUser(chat.id) && chat.readOnly && chat.archived) { 77 | delete chat.readOnly 78 | } 79 | 80 | chats.push({ ...chat }) 81 | } 82 | 83 | break 84 | case proto.HistorySync.HistorySyncType.PUSH_NAME: 85 | for(const c of item.pushnames!) { 86 | contacts.push({ id: c.id!, notify: c.pushname! }) 87 | } 88 | 89 | break 90 | } 91 | 92 | return { 93 | chats, 94 | contacts, 95 | messages, 96 | } 97 | } 98 | 99 | export const downloadAndProcessHistorySyncNotification = async( 100 | msg: proto.Message.IHistorySyncNotification, 101 | options: AxiosRequestConfig 102 | ) => { 103 | const historyMsg = await downloadHistory(msg, options) 104 | return processHistoryMessage(historyMsg) 105 | } 106 | 107 | export const getHistoryMsg = (message: proto.IMessage) => { 108 | const normalizedContent = !!message ? normalizeMessageContent(message) : undefined 109 | const anyHistoryMsg = normalizedContent?.protocolMessage?.historySyncNotification 110 | 111 | return anyHistoryMsg 112 | } -------------------------------------------------------------------------------- /src/Defaults/index.ts: -------------------------------------------------------------------------------- 1 | import { proto } from '../../WAProto' 2 | import type { MediaType, SocketConfig } from '../Types' 3 | import { Browsers } from '../Utils' 4 | import logger from '../Utils/logger' 5 | import { version } from './baileys-version.json' 6 | 7 | export const UNAUTHORIZED_CODES = [401, 403, 419] 8 | 9 | export const DEFAULT_ORIGIN = 'https://web.whatsapp.com' 10 | export const DEF_CALLBACK_PREFIX = 'CB:' 11 | export const DEF_TAG_PREFIX = 'TAG:' 12 | export const PHONE_CONNECTION_CB = 'CB:Pong' 13 | 14 | export const WA_DEFAULT_EPHEMERAL = 7 * 24 * 60 * 60 15 | 16 | export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0' 17 | export const DICT_VERSION = 2 18 | export const KEY_BUNDLE_TYPE = Buffer.from([5]) 19 | export const NOISE_WA_HEADER = Buffer.from( 20 | [ 87, 65, 6, DICT_VERSION ] 21 | ) // last is "DICT_VERSION" 22 | /** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */ 23 | export const URL_REGEX = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/ 24 | export const URL_EXCLUDE_REGEX = /.*@.*/ 25 | 26 | export const WA_CERT_DETAILS = { 27 | SERIAL: 0, 28 | } 29 | 30 | export const PROCESSABLE_HISTORY_TYPES = [ 31 | proto.Message.HistorySyncNotification.HistorySyncType.INITIAL_BOOTSTRAP, 32 | proto.Message.HistorySyncNotification.HistorySyncType.PUSH_NAME, 33 | proto.Message.HistorySyncNotification.HistorySyncType.RECENT, 34 | proto.Message.HistorySyncNotification.HistorySyncType.FULL 35 | ] 36 | 37 | export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { 38 | version: version as any, 39 | browser: Browsers.baileys('Chrome'), 40 | waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', 41 | connectTimeoutMs: 20_000, 42 | keepAliveIntervalMs: 15_000, 43 | logger: logger.child({ class: 'baileys' }), 44 | printQRInTerminal: false, 45 | emitOwnEvents: true, 46 | defaultQueryTimeoutMs: 60_000, 47 | customUploadHosts: [], 48 | retryRequestDelayMs: 250, 49 | fireInitQueries: true, 50 | auth: undefined as any, 51 | markOnlineOnConnect: true, 52 | syncFullHistory: false, 53 | patchMessageBeforeSending: msg => msg, 54 | shouldSyncHistoryMessage: () => true, 55 | shouldIgnoreJid: () => false, 56 | linkPreviewImageThumbnailWidth: 192, 57 | transactionOpts: { maxCommitRetries: 10, delayBetweenTriesMs: 3000 }, 58 | generateHighQualityLinkPreview: false, 59 | options: { }, 60 | appStateMacVerification: { 61 | patch: false, 62 | snapshot: false, 63 | }, 64 | getMessage: async() => undefined 65 | } 66 | 67 | export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { 68 | image: '/mms/image', 69 | video: '/mms/video', 70 | document: '/mms/document', 71 | audio: '/mms/audio', 72 | sticker: '/mms/image', 73 | 'thumbnail-link': '/mms/image', 74 | 'product-catalog-image': '/product/image', 75 | 'md-app-state': '' 76 | } 77 | 78 | export const MEDIA_HKDF_KEY_MAPPING = { 79 | 'audio': 'Audio', 80 | 'document': 'Document', 81 | 'gif': 'Video', 82 | 'image': 'Image', 83 | 'ppic': '', 84 | 'product': 'Image', 85 | 'ptt': 'Audio', 86 | 'sticker': 'Image', 87 | 'video': 'Video', 88 | 'thumbnail-document': 'Document Thumbnail', 89 | 'thumbnail-image': 'Image Thumbnail', 90 | 'thumbnail-video': 'Video Thumbnail', 91 | 'thumbnail-link': 'Link Thumbnail', 92 | 'md-msg-hist': 'History', 93 | 'md-app-state': 'App State', 94 | 'product-catalog-image': '', 95 | 'payment-bg-image': 'Payment Background', 96 | } 97 | 98 | export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[] 99 | 100 | export const MIN_PREKEY_COUNT = 5 101 | 102 | export const INITIAL_PREKEY_COUNT = 30 -------------------------------------------------------------------------------- /WASignalGroup/group_cipher.js: -------------------------------------------------------------------------------- 1 | const SenderKeyMessage = require('./sender_key_message'); 2 | const crypto = require('libsignal/src/crypto'); 3 | 4 | class GroupCipher { 5 | constructor(senderKeyStore, senderKeyName) { 6 | this.senderKeyStore = senderKeyStore; 7 | this.senderKeyName = senderKeyName; 8 | } 9 | 10 | async encrypt(paddedPlaintext) { 11 | try { 12 | const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName); 13 | const senderKeyState = record.getSenderKeyState(); 14 | const senderKey = senderKeyState.getSenderChainKey().getSenderMessageKey(); 15 | 16 | const ciphertext = await this.getCipherText( 17 | senderKey.getIv(), 18 | senderKey.getCipherKey(), 19 | paddedPlaintext 20 | ); 21 | 22 | const senderKeyMessage = new SenderKeyMessage( 23 | senderKeyState.getKeyId(), 24 | senderKey.getIteration(), 25 | ciphertext, 26 | senderKeyState.getSigningKeyPrivate() 27 | ); 28 | senderKeyState.setSenderChainKey(senderKeyState.getSenderChainKey().getNext()); 29 | await this.senderKeyStore.storeSenderKey(this.senderKeyName, record); 30 | return senderKeyMessage.serialize(); 31 | } catch (e) { 32 | //console.log(e.stack); 33 | throw new Error('NoSessionException'); 34 | } 35 | } 36 | 37 | async decrypt(senderKeyMessageBytes) { 38 | const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName); 39 | if (!record) throw new Error(`No sender key for: ${this.senderKeyName}`); 40 | 41 | const senderKeyMessage = new SenderKeyMessage(null, null, null, null, senderKeyMessageBytes); 42 | 43 | const senderKeyState = record.getSenderKeyState(senderKeyMessage.getKeyId()); 44 | //senderKeyMessage.verifySignature(senderKeyState.getSigningKeyPublic()); 45 | const senderKey = this.getSenderKey(senderKeyState, senderKeyMessage.getIteration()); 46 | // senderKeyState.senderKeyStateStructure.senderSigningKey.private = 47 | 48 | const plaintext = await this.getPlainText( 49 | senderKey.getIv(), 50 | senderKey.getCipherKey(), 51 | senderKeyMessage.getCipherText() 52 | ); 53 | 54 | await this.senderKeyStore.storeSenderKey(this.senderKeyName, record); 55 | 56 | return plaintext; 57 | } 58 | 59 | getSenderKey(senderKeyState, iteration) { 60 | let senderChainKey = senderKeyState.getSenderChainKey(); 61 | if (senderChainKey.getIteration() > iteration) { 62 | if (senderKeyState.hasSenderMessageKey(iteration)) { 63 | return senderKeyState.removeSenderMessageKey(iteration); 64 | } 65 | throw new Error( 66 | `Received message with old counter: ${senderChainKey.getIteration()}, ${iteration}` 67 | ); 68 | } 69 | 70 | if (senderChainKey.getIteration() - iteration > 2000) { 71 | throw new Error('Over 2000 messages into the future!'); 72 | } 73 | 74 | while (senderChainKey.getIteration() < iteration) { 75 | senderKeyState.addSenderMessageKey(senderChainKey.getSenderMessageKey()); 76 | senderChainKey = senderChainKey.getNext(); 77 | } 78 | 79 | senderKeyState.setSenderChainKey(senderChainKey.getNext()); 80 | return senderChainKey.getSenderMessageKey(); 81 | } 82 | 83 | getPlainText(iv, key, ciphertext) { 84 | try { 85 | const plaintext = crypto.decrypt(key, ciphertext, iv); 86 | return plaintext; 87 | } catch (e) { 88 | //console.log(e.stack); 89 | throw new Error('InvalidMessageException'); 90 | } 91 | } 92 | 93 | getCipherText(iv, key, plaintext) { 94 | try { 95 | iv = typeof iv === 'string' ? Buffer.from(iv, 'base64') : iv; 96 | key = typeof key === 'string' ? Buffer.from(key, 'base64') : key; 97 | const crypted = crypto.encrypt(key, Buffer.from(plaintext), iv); 98 | return crypted; 99 | } catch (e) { 100 | //console.log(e.stack); 101 | throw new Error('InvalidMessageException'); 102 | } 103 | } 104 | } 105 | 106 | module.exports = GroupCipher; -------------------------------------------------------------------------------- /src/Types/Events.ts: -------------------------------------------------------------------------------- 1 | import type { Boom } from '@hapi/boom' 2 | import { proto } from '../../WAProto' 3 | import { AuthenticationCreds } from './Auth' 4 | import { WACallEvent } from './Call' 5 | import { Chat, ChatUpdate, PresenceData } from './Chat' 6 | import { Contact } from './Contact' 7 | import { GroupMetadata, ParticipantAction } from './GroupMetadata' 8 | import { MessageUpsertType, MessageUserReceiptUpdate, WAMessage, WAMessageKey, WAMessageUpdate } from './Message' 9 | import { ConnectionState } from './State' 10 | 11 | export type BaileysEventMap = { 12 | /** connection state has been updated -- WS closed, opened, connecting etc. */ 13 | 'connection.update': Partial 14 | /** credentials updated -- some metadata, keys or something */ 15 | 'creds.update': Partial 16 | /** set chats (history sync), everything is reverse chronologically sorted */ 17 | 'messaging-history.set': { 18 | chats: Chat[] 19 | contacts: Contact[] 20 | messages: WAMessage[] 21 | isLatest: boolean 22 | } 23 | /** upsert chats */ 24 | 'chats.upsert': Chat[] 25 | /** update the given chats */ 26 | 'chats.update': ChatUpdate[] 27 | /** delete chats with given ID */ 28 | 'chats.delete': string[] 29 | /** presence of contact in a chat updated */ 30 | 'presence.update': { id: string, presences: { [participant: string]: PresenceData } } 31 | 32 | 'contacts.upsert': Contact[] 33 | 'contacts.update': Partial[] 34 | 35 | 'messages.delete': { keys: WAMessageKey[] } | { jid: string, all: true } 36 | 'messages.update': WAMessageUpdate[] 37 | 'messages.media-update': { key: WAMessageKey, media?: { ciphertext: Uint8Array, iv: Uint8Array }, error?: Boom }[] 38 | /** 39 | * add/update the given messages. If they were received while the connection was online, 40 | * the update will have type: "notify" 41 | * */ 42 | 'messages.upsert': { messages: WAMessage[], type: MessageUpsertType } 43 | /** message was reacted to. If reaction was removed -- then "reaction.text" will be falsey */ 44 | 'messages.reaction': { key: WAMessageKey, reaction: proto.IReaction }[] 45 | 46 | 'message-receipt.update': MessageUserReceiptUpdate[] 47 | 48 | 'groups.upsert': GroupMetadata[] 49 | 'groups.update': Partial[] 50 | /** apply an action to participants in a group */ 51 | 'group-participants.update': { id: string, participants: string[], action: ParticipantAction } 52 | 53 | 'blocklist.set': { blocklist: string[] } 54 | 'blocklist.update': { blocklist: string[], type: 'add' | 'remove' } 55 | /** Receive an update on a call, including when the call was received, rejected, accepted */ 56 | 'call': WACallEvent[] 57 | } 58 | 59 | export type BufferedEventData = { 60 | historySets: { 61 | chats: { [jid: string]: Chat } 62 | contacts: { [jid: string]: Contact } 63 | messages: { [uqId: string]: WAMessage } 64 | empty: boolean 65 | isLatest: boolean 66 | } 67 | chatUpserts: { [jid: string]: Chat } 68 | chatUpdates: { [jid: string]: ChatUpdate } 69 | chatDeletes: Set 70 | contactUpserts: { [jid: string]: Contact } 71 | contactUpdates: { [jid: string]: Partial } 72 | messageUpserts: { [key: string]: { type: MessageUpsertType, message: WAMessage } } 73 | messageUpdates: { [key: string]: WAMessageUpdate } 74 | messageDeletes: { [key: string]: WAMessageKey } 75 | messageReactions: { [key: string]: { key: WAMessageKey, reactions: proto.IReaction[] } } 76 | messageReceipts: { [key: string]: { key: WAMessageKey, userReceipt: proto.IUserReceipt[] } }, 77 | groupUpdates: { [jid: string]: Partial } 78 | } 79 | 80 | export type BaileysEvent = keyof BaileysEventMap 81 | 82 | export interface BaileysEventEmitter { 83 | on(event: T, listener: (arg: BaileysEventMap[T]) => void): void 84 | off(event: T, listener: (arg: BaileysEventMap[T]) => void): void 85 | removeAllListeners(event: T): void 86 | emit(event: T, arg: BaileysEventMap[T]): boolean 87 | } -------------------------------------------------------------------------------- /src/Types/Socket.ts: -------------------------------------------------------------------------------- 1 | 2 | import { AxiosRequestConfig } from 'axios' 3 | import type { Agent } from 'https' 4 | import type NodeCache from 'node-cache' 5 | import type { Logger } from 'pino' 6 | import type { URL } from 'url' 7 | import { proto } from '../../WAProto' 8 | import { AuthenticationState, TransactionCapabilityOptions } from './Auth' 9 | import { MediaConnInfo } from './Message' 10 | 11 | export type WAVersion = [number, number, number] 12 | export type WABrowserDescription = [string, string, string] 13 | 14 | export type MessageRetryMap = { [msgId: string]: number } 15 | 16 | export type SocketConfig = { 17 | /** the WS url to connect to WA */ 18 | waWebSocketUrl: string | URL 19 | /** Fails the connection if the socket times out in this interval */ 20 | connectTimeoutMs: number 21 | /** Default timeout for queries, undefined for no timeout */ 22 | defaultQueryTimeoutMs: number | undefined 23 | /** ping-pong interval for WS connection */ 24 | keepAliveIntervalMs: number 25 | /** proxy agent */ 26 | agent?: Agent 27 | /** pino logger */ 28 | logger: Logger 29 | /** version to connect with */ 30 | version: WAVersion 31 | /** override browser config */ 32 | browser: WABrowserDescription 33 | /** agent used for fetch requests -- uploading/downloading media */ 34 | fetchAgent?: Agent 35 | /** should the QR be printed in the terminal */ 36 | printQRInTerminal: boolean 37 | /** should events be emitted for actions done by this socket connection */ 38 | emitOwnEvents: boolean 39 | /** provide a cache to store media, so does not have to be re-uploaded */ 40 | mediaCache?: NodeCache 41 | /** custom upload hosts to upload media to */ 42 | customUploadHosts: MediaConnInfo['hosts'] 43 | /** time to wait between sending new retry requests */ 44 | retryRequestDelayMs: number 45 | /** time to wait for the generation of the next QR in ms */ 46 | qrTimeout?: number; 47 | /** provide an auth state object to maintain the auth state */ 48 | auth: AuthenticationState 49 | /** manage history processing with this control; by default will sync up everything */ 50 | shouldSyncHistoryMessage: (msg: proto.Message.IHistorySyncNotification) => boolean 51 | /** transaction capability options for SignalKeyStore */ 52 | transactionOpts: TransactionCapabilityOptions 53 | /** provide a cache to store a user's device list */ 54 | userDevicesCache?: NodeCache 55 | /** marks the client as online whenever the socket successfully connects */ 56 | markOnlineOnConnect: boolean 57 | /** 58 | * map to store the retry counts for failed messages; 59 | * used to determine whether to retry a message or not */ 60 | msgRetryCounterMap?: MessageRetryMap 61 | /** width for link preview images */ 62 | linkPreviewImageThumbnailWidth: number 63 | /** Should Baileys ask the phone for full history, will be received async */ 64 | syncFullHistory: boolean 65 | /** Should baileys fire init queries automatically, default true */ 66 | fireInitQueries: boolean 67 | /** 68 | * generate a high quality link preview, 69 | * entails uploading the jpegThumbnail to WA 70 | * */ 71 | generateHighQualityLinkPreview: boolean 72 | 73 | /** 74 | * Returns if a jid should be ignored, 75 | * no event for that jid will be triggered. 76 | * Messages from that jid will also not be decrypted 77 | * */ 78 | shouldIgnoreJid: (jid: string) => boolean | undefined 79 | 80 | /** 81 | * Optionally patch the message before sending out 82 | * */ 83 | patchMessageBeforeSending: ( 84 | msg: proto.IMessage, 85 | recipientJids: string[], 86 | ) => Promise | proto.IMessage 87 | 88 | /** verify app state MACs */ 89 | appStateMacVerification: { 90 | patch: boolean 91 | snapshot: boolean 92 | } 93 | 94 | /** options for axios */ 95 | options: AxiosRequestConfig 96 | /** 97 | * fetch a message from your store 98 | * implement this so that messages failed to send (solves the "this message can take a while" issue) can be retried 99 | * */ 100 | getMessage: (key: proto.IMessageKey) => Promise 101 | } 102 | -------------------------------------------------------------------------------- /src/Utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto' 2 | import HKDF from 'futoin-hkdf' 3 | import * as libsignal from 'libsignal' 4 | import { KEY_BUNDLE_TYPE } from '../Defaults' 5 | import { KeyPair } from '../Types' 6 | 7 | /** prefix version byte to the pub keys, required for some curve crypto functions */ 8 | export const generateSignalPubKey = (pubKey: Uint8Array | Buffer) => ( 9 | pubKey.length === 33 10 | ? pubKey 11 | : Buffer.concat([ KEY_BUNDLE_TYPE, pubKey ]) 12 | ) 13 | 14 | export const Curve = { 15 | generateKeyPair: (): KeyPair => { 16 | const { pubKey, privKey } = libsignal.curve.generateKeyPair() 17 | return { 18 | private: Buffer.from(privKey), 19 | // remove version byte 20 | public: Buffer.from((pubKey as Uint8Array).slice(1)) 21 | } 22 | }, 23 | sharedKey: (privateKey: Uint8Array, publicKey: Uint8Array) => { 24 | const shared = libsignal.curve.calculateAgreement(generateSignalPubKey(publicKey), privateKey) 25 | return Buffer.from(shared) 26 | }, 27 | sign: (privateKey: Uint8Array, buf: Uint8Array) => ( 28 | libsignal.curve.calculateSignature(privateKey, buf) 29 | ), 30 | verify: (pubKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => { 31 | try { 32 | libsignal.curve.verifySignature(generateSignalPubKey(pubKey), message, signature) 33 | return true 34 | } catch(error) { 35 | return false 36 | } 37 | } 38 | } 39 | 40 | export const signedKeyPair = (identityKeyPair: KeyPair, keyId: number) => { 41 | const preKey = Curve.generateKeyPair() 42 | const pubKey = generateSignalPubKey(preKey.public) 43 | 44 | const signature = Curve.sign(identityKeyPair.private, pubKey) 45 | 46 | return { keyPair: preKey, signature, keyId } 47 | } 48 | 49 | const GCM_TAG_LENGTH = 128 >> 3 50 | 51 | /** 52 | * encrypt AES 256 GCM; 53 | * where the tag tag is suffixed to the ciphertext 54 | * */ 55 | export function aesEncryptGCM(plaintext: Uint8Array, key: Uint8Array, iv: Uint8Array, additionalData: Uint8Array) { 56 | const cipher = createCipheriv('aes-256-gcm', key, iv) 57 | cipher.setAAD(additionalData) 58 | return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]) 59 | } 60 | 61 | /** 62 | * decrypt AES 256 GCM; 63 | * where the auth tag is suffixed to the ciphertext 64 | * */ 65 | export function aesDecryptGCM(ciphertext: Uint8Array, key: Uint8Array, iv: Uint8Array, additionalData: Uint8Array) { 66 | const decipher = createDecipheriv('aes-256-gcm', key, iv) 67 | // decrypt additional adata 68 | const enc = ciphertext.slice(0, ciphertext.length - GCM_TAG_LENGTH) 69 | const tag = ciphertext.slice(ciphertext.length - GCM_TAG_LENGTH) 70 | // set additional data 71 | decipher.setAAD(additionalData) 72 | decipher.setAuthTag(tag) 73 | 74 | return Buffer.concat([ decipher.update(enc), decipher.final() ]) 75 | } 76 | 77 | /** decrypt AES 256 CBC; where the IV is prefixed to the buffer */ 78 | export function aesDecrypt(buffer: Buffer, key: Buffer) { 79 | return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16)) 80 | } 81 | 82 | /** decrypt AES 256 CBC */ 83 | export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { 84 | const aes = createDecipheriv('aes-256-cbc', key, IV) 85 | return Buffer.concat([aes.update(buffer), aes.final()]) 86 | } 87 | 88 | // encrypt AES 256 CBC; where a random IV is prefixed to the buffer 89 | export function aesEncrypt(buffer: Buffer | Uint8Array, key: Buffer) { 90 | const IV = randomBytes(16) 91 | const aes = createCipheriv('aes-256-cbc', key, IV) 92 | return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer 93 | } 94 | 95 | // encrypt AES 256 CBC with a given IV 96 | export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { 97 | const aes = createCipheriv('aes-256-cbc', key, IV) 98 | return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer 99 | } 100 | 101 | // sign HMAC using SHA 256 102 | export function hmacSign(buffer: Buffer | Uint8Array, key: Buffer | Uint8Array, variant: 'sha256' | 'sha512' = 'sha256') { 103 | return createHmac(variant, key).update(buffer).digest() 104 | } 105 | 106 | export function sha256(buffer: Buffer) { 107 | return createHash('sha256').update(buffer).digest() 108 | } 109 | 110 | // HKDF key expansion 111 | export function hkdf(buffer: Uint8Array | Buffer, expandedLength: number, info: { salt?: Buffer, info?: string }) { 112 | return HKDF(!Buffer.isBuffer(buffer) ? Buffer.from(buffer) : buffer, expandedLength, info) 113 | } -------------------------------------------------------------------------------- /src/Utils/decode-wa-message.ts: -------------------------------------------------------------------------------- 1 | import { Boom } from '@hapi/boom' 2 | import { proto } from '../../WAProto' 3 | import { AuthenticationState, WAMessageKey } from '../Types' 4 | import { areJidsSameUser, BinaryNode, isJidBroadcast, isJidGroup, isJidStatusBroadcast, isJidUser } from '../WABinary' 5 | import { unpadRandomMax16 } from './generics' 6 | import { decryptGroupSignalProto, decryptSignalProto, processSenderKeyMessage } from './signal' 7 | 8 | const NO_MESSAGE_FOUND_ERROR_TEXT = 'Message absent from node' 9 | 10 | type MessageType = 'chat' | 'peer_broadcast' | 'other_broadcast' | 'group' | 'direct_peer_status' | 'other_status' 11 | 12 | export const decodeMessageStanza = (stanza: BinaryNode, auth: AuthenticationState) => { 13 | let msgType: MessageType 14 | let chatId: string 15 | let author: string 16 | 17 | const msgId = stanza.attrs.id 18 | const from = stanza.attrs.from 19 | const participant: string | undefined = stanza.attrs.participant 20 | const recipient: string | undefined = stanza.attrs.recipient 21 | 22 | const isMe = (jid: string) => areJidsSameUser(jid, auth.creds.me!.id) 23 | 24 | if(isJidUser(from)) { 25 | if(recipient) { 26 | if(!isMe(from)) { 27 | throw new Boom('receipient present, but msg not from me', { data: stanza }) 28 | } 29 | 30 | chatId = recipient 31 | } else { 32 | chatId = from 33 | } 34 | 35 | msgType = 'chat' 36 | author = from 37 | } else if(isJidGroup(from)) { 38 | if(!participant) { 39 | throw new Boom('No participant in group message') 40 | } 41 | 42 | msgType = 'group' 43 | author = participant 44 | chatId = from 45 | } else if(isJidBroadcast(from)) { 46 | if(!participant) { 47 | throw new Boom('No participant in group message') 48 | } 49 | 50 | const isParticipantMe = isMe(participant) 51 | if(isJidStatusBroadcast(from)) { 52 | msgType = isParticipantMe ? 'direct_peer_status' : 'other_status' 53 | } else { 54 | msgType = isParticipantMe ? 'peer_broadcast' : 'other_broadcast' 55 | } 56 | 57 | chatId = from 58 | author = participant 59 | } else { 60 | throw new Boom('Unknown message type', { data: stanza }) 61 | } 62 | 63 | const sender = msgType === 'chat' ? author : chatId 64 | 65 | const fromMe = isMe(stanza.attrs.participant || stanza.attrs.from) 66 | const pushname = stanza.attrs.notify 67 | 68 | const key: WAMessageKey = { 69 | remoteJid: chatId, 70 | fromMe, 71 | id: msgId, 72 | participant 73 | } 74 | 75 | const fullMessage: proto.IWebMessageInfo = { 76 | key, 77 | messageTimestamp: +stanza.attrs.t, 78 | pushName: pushname 79 | } 80 | 81 | if(key.fromMe) { 82 | fullMessage.status = proto.WebMessageInfo.Status.SERVER_ACK 83 | } 84 | 85 | return { 86 | fullMessage, 87 | category: stanza.attrs.category, 88 | author, 89 | async decrypt() { 90 | let decryptables = 0 91 | if(Array.isArray(stanza.content)) { 92 | for(const { tag, attrs, content } of stanza.content) { 93 | if(tag === 'verified_name' && content instanceof Uint8Array) { 94 | const cert = proto.VerifiedNameCertificate.decode(content) 95 | const details = proto.VerifiedNameCertificate.Details.decode(cert.details) 96 | fullMessage.verifiedBizName = details.verifiedName 97 | } 98 | 99 | if(tag !== 'enc') { 100 | continue 101 | } 102 | 103 | if(!(content instanceof Uint8Array)) { 104 | continue 105 | } 106 | 107 | decryptables += 1 108 | 109 | let msgBuffer: Buffer 110 | 111 | try { 112 | const e2eType = attrs.type 113 | switch (e2eType) { 114 | case 'skmsg': 115 | msgBuffer = await decryptGroupSignalProto(sender, author, content, auth) 116 | break 117 | case 'pkmsg': 118 | case 'msg': 119 | const user = isJidUser(sender) ? sender : author 120 | msgBuffer = await decryptSignalProto(user, e2eType, content as Buffer, auth) 121 | break 122 | default: 123 | throw new Error(`Unknown e2e type: ${e2eType}`) 124 | } 125 | 126 | let msg: proto.IMessage = proto.Message.decode(unpadRandomMax16(msgBuffer)) 127 | msg = msg.deviceSentMessage?.message || msg 128 | if(msg.senderKeyDistributionMessage) { 129 | await processSenderKeyMessage(author, msg.senderKeyDistributionMessage, auth) 130 | } 131 | 132 | if(fullMessage.message) { 133 | Object.assign(fullMessage.message, msg) 134 | } else { 135 | fullMessage.message = msg 136 | } 137 | } catch(error) { 138 | fullMessage.messageStubType = proto.WebMessageInfo.StubType.CIPHERTEXT 139 | fullMessage.messageStubParameters = [error.message] 140 | } 141 | } 142 | } 143 | 144 | // if nothing was found to decrypt 145 | if(!decryptables) { 146 | fullMessage.messageStubType = proto.WebMessageInfo.StubType.CIPHERTEXT 147 | fullMessage.messageStubParameters = [NO_MESSAGE_FOUND_ERROR_TEXT] 148 | } 149 | } 150 | } 151 | } -------------------------------------------------------------------------------- /src/Tests/test.app-state-sync.ts: -------------------------------------------------------------------------------- 1 | import { AccountSettings, ChatMutation, Contact, InitialAppStateSyncOptions } from '../Types' 2 | import { unixTimestampSeconds } from '../Utils' 3 | import { processSyncAction } from '../Utils/chat-utils' 4 | import logger from '../Utils/logger' 5 | 6 | describe('App State Sync Tests', () => { 7 | 8 | const me: Contact = { id: randomJid() } 9 | // case when initial sync is off 10 | it('should return archive=false event', () => { 11 | const jid = randomJid() 12 | const index = ['archive', jid] 13 | 14 | const CASES: ChatMutation[][] = [ 15 | [ 16 | { 17 | index, 18 | syncAction: { 19 | value: { 20 | archiveChatAction: { 21 | archived: false, 22 | messageRange: { 23 | lastMessageTimestamp: unixTimestampSeconds() 24 | } 25 | } 26 | } 27 | } 28 | } 29 | ], 30 | [ 31 | { 32 | index, 33 | syncAction: { 34 | value: { 35 | archiveChatAction: { 36 | archived: true, 37 | messageRange: { 38 | lastMessageTimestamp: unixTimestampSeconds() 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | { 45 | index, 46 | syncAction: { 47 | value: { 48 | archiveChatAction: { 49 | archived: false, 50 | messageRange: { 51 | lastMessageTimestamp: unixTimestampSeconds() 52 | } 53 | } 54 | } 55 | } 56 | } 57 | ] 58 | ] 59 | 60 | for(const mutations of CASES) { 61 | const events = processSyncAction(mutations, me, undefined, logger) 62 | expect(events['chats.update']).toHaveLength(1) 63 | const event = events['chats.update']?.[0] 64 | expect(event.archive).toEqual(false) 65 | } 66 | }) 67 | // case when initial sync is on 68 | // and unarchiveChats = true 69 | it('should not fire any archive event', () => { 70 | const jid = randomJid() 71 | const index = ['archive', jid] 72 | const now = unixTimestampSeconds() 73 | 74 | const CASES: ChatMutation[][] = [ 75 | [ 76 | { 77 | index, 78 | syncAction: { 79 | value: { 80 | archiveChatAction: { 81 | archived: true, 82 | messageRange: { 83 | lastMessageTimestamp: now - 1 84 | } 85 | } 86 | } 87 | } 88 | } 89 | ], 90 | [ 91 | { 92 | index, 93 | syncAction: { 94 | value: { 95 | archiveChatAction: { 96 | archived: false, 97 | messageRange: { 98 | lastMessageTimestamp: now + 10 99 | } 100 | } 101 | } 102 | } 103 | } 104 | ], 105 | [ 106 | { 107 | index, 108 | syncAction: { 109 | value: { 110 | archiveChatAction: { 111 | archived: true, 112 | messageRange: { 113 | lastMessageTimestamp: now + 10 114 | } 115 | } 116 | } 117 | } 118 | }, 119 | { 120 | index, 121 | syncAction: { 122 | value: { 123 | archiveChatAction: { 124 | archived: false, 125 | messageRange: { 126 | lastMessageTimestamp: now + 11 127 | } 128 | } 129 | } 130 | } 131 | } 132 | ], 133 | ] 134 | 135 | const ctx: InitialAppStateSyncOptions = { 136 | recvChats: { 137 | [jid]: { lastMsgRecvTimestamp: now } 138 | }, 139 | accountSettings: { unarchiveChats: true } 140 | } 141 | 142 | for(const mutations of CASES) { 143 | const events = processSyncActions(mutations, me, ctx, logger) 144 | expect(events['chats.update']?.length).toBeFalsy() 145 | } 146 | }) 147 | 148 | // case when initial sync is on 149 | // with unarchiveChats = true & unarchiveChats = false 150 | it('should fire archive=true events', () => { 151 | const jid = randomJid() 152 | const index = ['archive', jid] 153 | const now = unixTimestampSeconds() 154 | 155 | const CASES: { settings: AccountSettings, mutations: ChatMutation[] }[] = [ 156 | { 157 | settings: { unarchiveChats: true }, 158 | mutations: [ 159 | { 160 | index, 161 | syncAction: { 162 | value: { 163 | archiveChatAction: { 164 | archived: true, 165 | messageRange: { 166 | lastMessageTimestamp: now 167 | } 168 | } 169 | } 170 | } 171 | } 172 | ], 173 | }, 174 | { 175 | settings: { unarchiveChats: false }, 176 | mutations: [ 177 | { 178 | index, 179 | syncAction: { 180 | value: { 181 | archiveChatAction: { 182 | archived: true, 183 | messageRange: { 184 | lastMessageTimestamp: now - 10 185 | } 186 | } 187 | } 188 | } 189 | } 190 | ], 191 | } 192 | ] 193 | 194 | for(const { mutations, settings } of CASES) { 195 | const ctx: InitialAppStateSyncOptions = { 196 | recvChats: { 197 | [jid]: { lastMsgRecvTimestamp: now } 198 | }, 199 | accountSettings: settings 200 | } 201 | const events = processSyncActions(mutations, me, ctx, logger) 202 | expect(events['chats.update']).toHaveLength(1) 203 | const event = events['chats.update']?.[0] 204 | expect(event.archive).toEqual(true) 205 | } 206 | }) 207 | }) -------------------------------------------------------------------------------- /src/Utils/noise-handler.ts: -------------------------------------------------------------------------------- 1 | import { Boom } from '@hapi/boom' 2 | import { Logger } from 'pino' 3 | import { proto } from '../../WAProto' 4 | import { NOISE_MODE, NOISE_WA_HEADER, WA_CERT_DETAILS } from '../Defaults' 5 | import { KeyPair } from '../Types' 6 | import { BinaryNode, decodeBinaryNode } from '../WABinary' 7 | import { aesDecryptGCM, aesEncryptGCM, Curve, hkdf, sha256 } from './crypto' 8 | 9 | const generateIV = (counter: number) => { 10 | const iv = new ArrayBuffer(12) 11 | new DataView(iv).setUint32(8, counter) 12 | 13 | return new Uint8Array(iv) 14 | } 15 | 16 | export const makeNoiseHandler = ( 17 | { public: publicKey, private: privateKey }: KeyPair, 18 | logger: Logger 19 | ) => { 20 | logger = logger.child({ class: 'ns' }) 21 | 22 | const authenticate = (data: Uint8Array) => { 23 | if(!isFinished) { 24 | hash = sha256(Buffer.concat([hash, data])) 25 | } 26 | } 27 | 28 | const encrypt = (plaintext: Uint8Array) => { 29 | const result = aesEncryptGCM(plaintext, encKey, generateIV(writeCounter), hash) 30 | 31 | writeCounter += 1 32 | 33 | authenticate(result) 34 | return result 35 | } 36 | 37 | const decrypt = (ciphertext: Uint8Array) => { 38 | // before the handshake is finished, we use the same counter 39 | // after handshake, the counters are different 40 | const iv = generateIV(isFinished ? readCounter : writeCounter) 41 | const result = aesDecryptGCM(ciphertext, decKey, iv, hash) 42 | 43 | if(isFinished) { 44 | readCounter += 1 45 | } else { 46 | writeCounter += 1 47 | } 48 | 49 | authenticate(ciphertext) 50 | return result 51 | } 52 | 53 | const localHKDF = (data: Uint8Array) => { 54 | const key = hkdf(Buffer.from(data), 64, { salt, info: '' }) 55 | return [key.slice(0, 32), key.slice(32)] 56 | } 57 | 58 | const mixIntoKey = (data: Uint8Array) => { 59 | const [write, read] = localHKDF(data) 60 | salt = write 61 | encKey = read 62 | decKey = read 63 | readCounter = 0 64 | writeCounter = 0 65 | } 66 | 67 | const finishInit = () => { 68 | const [write, read] = localHKDF(new Uint8Array(0)) 69 | encKey = write 70 | decKey = read 71 | hash = Buffer.from([]) 72 | readCounter = 0 73 | writeCounter = 0 74 | isFinished = true 75 | } 76 | 77 | const data = Buffer.from(NOISE_MODE) 78 | let hash = Buffer.from(data.byteLength === 32 ? data : sha256(data)) 79 | let salt = hash 80 | let encKey = hash 81 | let decKey = hash 82 | let readCounter = 0 83 | let writeCounter = 0 84 | let isFinished = false 85 | let sentIntro = false 86 | 87 | let inBytes = Buffer.alloc(0) 88 | 89 | authenticate(NOISE_WA_HEADER) 90 | authenticate(publicKey) 91 | 92 | return { 93 | encrypt, 94 | decrypt, 95 | authenticate, 96 | mixIntoKey, 97 | finishInit, 98 | processHandshake: ({ serverHello }: proto.HandshakeMessage, noiseKey: KeyPair) => { 99 | authenticate(serverHello!.ephemeral!) 100 | mixIntoKey(Curve.sharedKey(privateKey, serverHello!.ephemeral!)) 101 | 102 | const decStaticContent = decrypt(serverHello!.static!) 103 | mixIntoKey(Curve.sharedKey(privateKey, decStaticContent)) 104 | 105 | const certDecoded = decrypt(serverHello!.payload!) 106 | const { intermediate: certIntermediate } = proto.CertChain.decode(certDecoded) 107 | 108 | const { issuerSerial } = proto.CertChain.NoiseCertificate.Details.decode(certIntermediate!.details!) 109 | 110 | if(issuerSerial !== WA_CERT_DETAILS.SERIAL) { 111 | throw new Boom('certification match failed', { statusCode: 400 }) 112 | } 113 | 114 | const keyEnc = encrypt(noiseKey.public) 115 | mixIntoKey(Curve.sharedKey(noiseKey.private, serverHello!.ephemeral!)) 116 | 117 | return keyEnc 118 | }, 119 | encodeFrame: (data: Buffer | Uint8Array) => { 120 | if(isFinished) { 121 | data = encrypt(data) 122 | } 123 | 124 | const introSize = sentIntro ? 0 : NOISE_WA_HEADER.length 125 | const frame = Buffer.alloc(introSize + 3 + data.byteLength) 126 | 127 | if(!sentIntro) { 128 | frame.set(NOISE_WA_HEADER) 129 | sentIntro = true 130 | } 131 | 132 | frame.writeUInt8(data.byteLength >> 16, introSize) 133 | frame.writeUInt16BE(65535 & data.byteLength, introSize + 1) 134 | frame.set(data, introSize + 3) 135 | 136 | return frame 137 | }, 138 | decodeFrame: (newData: Buffer | Uint8Array, onFrame: (buff: Uint8Array | BinaryNode) => void) => { 139 | // the binary protocol uses its own framing mechanism 140 | // on top of the WS frames 141 | // so we get this data and separate out the frames 142 | const getBytesSize = () => { 143 | if(inBytes.length >= 3) { 144 | return (inBytes.readUInt8() << 16) | inBytes.readUInt16BE(1) 145 | } 146 | } 147 | 148 | inBytes = Buffer.concat([ inBytes, newData ]) 149 | 150 | logger.trace(`recv ${newData.length} bytes, total recv ${inBytes.length} bytes`) 151 | 152 | let size = getBytesSize() 153 | while(size && inBytes.length >= size + 3) { 154 | let frame: Uint8Array | BinaryNode = inBytes.slice(3, size + 3) 155 | inBytes = inBytes.slice(size + 3) 156 | 157 | if(isFinished) { 158 | const result = decrypt(frame as Uint8Array) 159 | frame = decodeBinaryNode(result) 160 | } 161 | 162 | logger.trace({ msg: (frame as any)?.attrs?.id }, 'recv frame') 163 | 164 | onFrame(frame) 165 | size = getBytesSize() 166 | } 167 | } 168 | } 169 | } -------------------------------------------------------------------------------- /WASignalGroup/sender_key_state.js: -------------------------------------------------------------------------------- 1 | const SenderChainKey = require('./sender_chain_key'); 2 | const SenderMessageKey = require('./sender_message_key'); 3 | 4 | const protobufs = require('./protobufs'); 5 | 6 | class SenderKeyState { 7 | MAX_MESSAGE_KEYS = 2000; 8 | 9 | constructor( 10 | id = null, 11 | iteration = null, 12 | chainKey = null, 13 | signatureKeyPair = null, 14 | signatureKeyPublic = null, 15 | signatureKeyPrivate = null, 16 | senderKeyStateStructure = null 17 | ) { 18 | if (senderKeyStateStructure) { 19 | this.senderKeyStateStructure = senderKeyStateStructure; 20 | } else { 21 | if (signatureKeyPair) { 22 | signatureKeyPublic = signatureKeyPair.public; 23 | signatureKeyPrivate = signatureKeyPair.private; 24 | } 25 | 26 | chainKey = typeof chainKey === 'string' ? Buffer.from(chainKey, 'base64') : chainKey; 27 | this.senderKeyStateStructure = protobufs.SenderKeyStateStructure.create(); 28 | const senderChainKeyStructure = protobufs.SenderChainKey.create(); 29 | senderChainKeyStructure.iteration = iteration; 30 | senderChainKeyStructure.seed = chainKey; 31 | this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure; 32 | 33 | const signingKeyStructure = protobufs.SenderSigningKey.create(); 34 | signingKeyStructure.public = 35 | typeof signatureKeyPublic === 'string' ? 36 | Buffer.from(signatureKeyPublic, 'base64') : 37 | signatureKeyPublic; 38 | if (signatureKeyPrivate) { 39 | signingKeyStructure.private = 40 | typeof signatureKeyPrivate === 'string' ? 41 | Buffer.from(signatureKeyPrivate, 'base64') : 42 | signatureKeyPrivate; 43 | } 44 | this.senderKeyStateStructure.senderKeyId = id; 45 | this.senderChainKey = senderChainKeyStructure; 46 | this.senderKeyStateStructure.senderSigningKey = signingKeyStructure; 47 | } 48 | this.senderKeyStateStructure.senderMessageKeys = 49 | this.senderKeyStateStructure.senderMessageKeys || []; 50 | } 51 | 52 | SenderKeyState(senderKeyStateStructure) { 53 | this.senderKeyStateStructure = senderKeyStateStructure; 54 | } 55 | 56 | getKeyId() { 57 | return this.senderKeyStateStructure.senderKeyId; 58 | } 59 | 60 | getSenderChainKey() { 61 | return new SenderChainKey( 62 | this.senderKeyStateStructure.senderChainKey.iteration, 63 | this.senderKeyStateStructure.senderChainKey.seed 64 | ); 65 | } 66 | 67 | setSenderChainKey(chainKey) { 68 | const senderChainKeyStructure = protobufs.SenderChainKey.create({ 69 | iteration: chainKey.getIteration(), 70 | seed: chainKey.getSeed(), 71 | }); 72 | this.senderKeyStateStructure.senderChainKey = senderChainKeyStructure; 73 | } 74 | 75 | getSigningKeyPublic() { 76 | return typeof this.senderKeyStateStructure.senderSigningKey.public === 'string' ? 77 | Buffer.from(this.senderKeyStateStructure.senderSigningKey.public, 'base64') : 78 | this.senderKeyStateStructure.senderSigningKey.public; 79 | } 80 | 81 | getSigningKeyPrivate() { 82 | return typeof this.senderKeyStateStructure.senderSigningKey.private === 'string' ? 83 | Buffer.from(this.senderKeyStateStructure.senderSigningKey.private, 'base64') : 84 | this.senderKeyStateStructure.senderSigningKey.private; 85 | } 86 | 87 | hasSenderMessageKey(iteration) { 88 | const list = this.senderKeyStateStructure.senderMessageKeys; 89 | for (let o = 0; o < list.length; o++) { 90 | const senderMessageKey = list[o]; 91 | if (senderMessageKey.iteration === iteration) return true; 92 | } 93 | return false; 94 | } 95 | 96 | addSenderMessageKey(senderMessageKey) { 97 | const senderMessageKeyStructure = protobufs.SenderKeyStateStructure.create({ 98 | iteration: senderMessageKey.getIteration(), 99 | seed: senderMessageKey.getSeed(), 100 | }); 101 | this.senderKeyStateStructure.senderMessageKeys.push(senderMessageKeyStructure); 102 | 103 | if (this.senderKeyStateStructure.senderMessageKeys.length > this.MAX_MESSAGE_KEYS) { 104 | this.senderKeyStateStructure.senderMessageKeys.shift(); 105 | } 106 | } 107 | 108 | removeSenderMessageKey(iteration) { 109 | let result = null; 110 | 111 | this.senderKeyStateStructure.senderMessageKeys = this.senderKeyStateStructure.senderMessageKeys.filter( 112 | senderMessageKey => { 113 | if (senderMessageKey.iteration === iteration) result = senderMessageKey; 114 | return senderMessageKey.iteration !== iteration; 115 | } 116 | ); 117 | 118 | if (result != null) { 119 | return new SenderMessageKey(result.iteration, result.seed); 120 | } 121 | return null; 122 | } 123 | 124 | getStructure() { 125 | return this.senderKeyStateStructure; 126 | } 127 | } 128 | 129 | module.exports = SenderKeyState; -------------------------------------------------------------------------------- /Example/example.ts: -------------------------------------------------------------------------------- 1 | import { Boom } from '@hapi/boom' 2 | import makeWASocket, { AnyMessageContent, delay, DisconnectReason, fetchLatestBaileysVersion, isJidBroadcast, makeCacheableSignalKeyStore, makeInMemoryStore, MessageRetryMap, useMultiFileAuthState } from '../src' 3 | import MAIN_LOGGER from '../src/Utils/logger' 4 | 5 | const logger = MAIN_LOGGER.child({ }) 6 | logger.level = 'trace' 7 | 8 | const useStore = !process.argv.includes('--no-store') 9 | const doReplies = !process.argv.includes('--no-reply') 10 | 11 | // external map to store retry counts of messages when decryption/encryption fails 12 | // keep this out of the socket itself, so as to prevent a message decryption/encryption loop across socket restarts 13 | const msgRetryCounterMap: MessageRetryMap = { } 14 | 15 | // the store maintains the data of the WA connection in memory 16 | // can be written out to a file & read from it 17 | const store = useStore ? makeInMemoryStore({ logger }) : undefined 18 | store?.readFromFile('./baileys_store_multi.json') 19 | // save every 10s 20 | setInterval(() => { 21 | store?.writeToFile('./baileys_store_multi.json') 22 | }, 10_000) 23 | 24 | // start a connection 25 | const startSock = async() => { 26 | const { state, saveCreds } = await useMultiFileAuthState('baileys_auth_info') 27 | // fetch latest version of WA Web 28 | const { version, isLatest } = await fetchLatestBaileysVersion() 29 | console.log(`using WA v${version.join('.')}, isLatest: ${isLatest}`) 30 | 31 | const sock = makeWASocket({ 32 | version, 33 | logger, 34 | printQRInTerminal: true, 35 | auth: { 36 | creds: state.creds, 37 | /** caching makes the store faster to send/recv messages */ 38 | keys: makeCacheableSignalKeyStore(state.keys, logger), 39 | }, 40 | msgRetryCounterMap, 41 | generateHighQualityLinkPreview: true, 42 | // ignore all broadcast messages -- to receive the same 43 | // comment the line below out 44 | shouldIgnoreJid: jid => isJidBroadcast(jid), 45 | // implement to handle retries 46 | getMessage: async key => { 47 | if(store) { 48 | const msg = await store.loadMessage(key.remoteJid!, key.id!) 49 | return msg?.message || undefined 50 | } 51 | 52 | // only if store is present 53 | return { 54 | conversation: 'hello' 55 | } 56 | } 57 | }) 58 | 59 | store?.bind(sock.ev) 60 | 61 | const sendMessageWTyping = async(msg: AnyMessageContent, jid: string) => { 62 | await sock.presenceSubscribe(jid) 63 | await delay(500) 64 | 65 | await sock.sendPresenceUpdate('composing', jid) 66 | await delay(2000) 67 | 68 | await sock.sendPresenceUpdate('paused', jid) 69 | 70 | await sock.sendMessage(jid, msg) 71 | } 72 | 73 | // the process function lets you process all events that just occurred 74 | // efficiently in a batch 75 | sock.ev.process( 76 | // events is a map for event name => event data 77 | async(events) => { 78 | // something about the connection changed 79 | // maybe it closed, or we received all offline message or connection opened 80 | if(events['connection.update']) { 81 | const update = events['connection.update'] 82 | const { connection, lastDisconnect } = update 83 | if(connection === 'close') { 84 | // reconnect if not logged out 85 | if((lastDisconnect?.error as Boom)?.output?.statusCode !== DisconnectReason.loggedOut) { 86 | startSock() 87 | } else { 88 | console.log('Connection closed. You are logged out.') 89 | } 90 | } 91 | 92 | console.log('connection update', update) 93 | } 94 | 95 | // credentials updated -- save them 96 | if(events['creds.update']) { 97 | await saveCreds() 98 | } 99 | 100 | if(events.call) { 101 | console.log('recv call event', events.call) 102 | } 103 | 104 | // history received 105 | if(events['messaging-history.set']) { 106 | const { chats, contacts, messages, isLatest } = events['messaging-history.set'] 107 | console.log(`recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest})`) 108 | } 109 | 110 | // received a new message 111 | if(events['messages.upsert']) { 112 | const upsert = events['messages.upsert'] 113 | console.log('recv messages ', JSON.stringify(upsert, undefined, 2)) 114 | 115 | if(upsert.type === 'notify') { 116 | for(const msg of upsert.messages) { 117 | if(!msg.key.fromMe && doReplies) { 118 | console.log('replying to', msg.key.remoteJid) 119 | await sock!.readMessages([msg.key]) 120 | await sendMessageWTyping({ text: 'Hello there!' }, msg.key.remoteJid!) 121 | } 122 | } 123 | } 124 | } 125 | 126 | // messages updated like status delivered, message deleted etc. 127 | if(events['messages.update']) { 128 | console.log(events['messages.update']) 129 | } 130 | 131 | if(events['message-receipt.update']) { 132 | console.log(events['message-receipt.update']) 133 | } 134 | 135 | if(events['messages.reaction']) { 136 | console.log(events['messages.reaction']) 137 | } 138 | 139 | if(events['presence.update']) { 140 | console.log(events['presence.update']) 141 | } 142 | 143 | if(events['chats.update']) { 144 | console.log(events['chats.update']) 145 | } 146 | 147 | if(events['contacts.update']) { 148 | for(const contact of events['contacts.update']) { 149 | if(typeof contact.imgUrl !== 'undefined') { 150 | const newUrl = contact.imgUrl === null 151 | ? null 152 | : await sock!.profilePictureUrl(contact.id!).catch(() => null) 153 | console.log( 154 | `contact ${contact.id} has a new profile pic: ${newUrl}`, 155 | ) 156 | } 157 | } 158 | } 159 | 160 | if(events['chats.delete']) { 161 | console.log('chats deleted ', events['chats.delete']) 162 | } 163 | } 164 | ) 165 | 166 | return sock 167 | } 168 | 169 | startSock() -------------------------------------------------------------------------------- /src/Utils/auth-utils.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto' 2 | import NodeCache from 'node-cache' 3 | import type { Logger } from 'pino' 4 | import type { AuthenticationCreds, SignalDataSet, SignalDataTypeMap, SignalKeyStore, SignalKeyStoreWithTransaction, TransactionCapabilityOptions } from '../Types' 5 | import { Curve, signedKeyPair } from './crypto' 6 | import { delay, generateRegistrationId } from './generics' 7 | 8 | /** 9 | * Adds caching capability to a SignalKeyStore 10 | * @param store the store to add caching to 11 | * @param logger to log trace events 12 | * @param opts NodeCache options 13 | */ 14 | export function makeCacheableSignalKeyStore( 15 | store: SignalKeyStore, 16 | logger: Logger, 17 | opts?: NodeCache.Options 18 | ): SignalKeyStore { 19 | const cache = new NodeCache({ 20 | ...opts || { }, 21 | useClones: false, 22 | }) 23 | 24 | function getUniqueId(type: string, id: string) { 25 | return `${type}.${id}` 26 | } 27 | 28 | return { 29 | async get(type, ids) { 30 | const data: { [_: string]: SignalDataTypeMap[typeof type] } = { } 31 | const idsToFetch: string[] = [] 32 | for(const id of ids) { 33 | const item = cache.get(getUniqueId(type, id)) 34 | if(typeof item !== 'undefined') { 35 | data[id] = item 36 | } else { 37 | idsToFetch.push(id) 38 | } 39 | } 40 | 41 | if(idsToFetch.length) { 42 | logger.trace({ items: idsToFetch.length }, 'loading from store') 43 | const fetched = await store.get(type, idsToFetch) 44 | for(const id of idsToFetch) { 45 | const item = fetched[id] 46 | if(item) { 47 | data[id] = item 48 | cache.set(getUniqueId(type, id), item) 49 | } 50 | } 51 | } 52 | 53 | return data 54 | }, 55 | async set(data) { 56 | let keys = 0 57 | for(const type in data) { 58 | for(const id in data[type]) { 59 | cache.set(getUniqueId(type, id), data[type][id]) 60 | keys += 1 61 | } 62 | } 63 | 64 | logger.trace({ keys }, 'updated cache') 65 | 66 | await store.set(data) 67 | }, 68 | async clear() { 69 | cache.flushAll() 70 | await store.clear?.() 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Adds DB like transaction capability (https://en.wikipedia.org/wiki/Database_transaction) to the SignalKeyStore, 77 | * this allows batch read & write operations & improves the performance of the lib 78 | * @param state the key store to apply this capability to 79 | * @param logger logger to log events 80 | * @returns SignalKeyStore with transaction capability 81 | */ 82 | export const addTransactionCapability = ( 83 | state: SignalKeyStore, 84 | logger: Logger, 85 | { maxCommitRetries, delayBetweenTriesMs }: TransactionCapabilityOptions 86 | ): SignalKeyStoreWithTransaction => { 87 | let inTransaction = false 88 | // number of queries made to the DB during the transaction 89 | // only there for logging purposes 90 | let dbQueriesInTransaction = 0 91 | let transactionCache: SignalDataSet = { } 92 | let mutations: SignalDataSet = { } 93 | 94 | /** 95 | * prefetches some data and stores in memory, 96 | * useful if these data points will be used together often 97 | * */ 98 | const prefetch = async(type: keyof SignalDataTypeMap, ids: string[]) => { 99 | const dict = transactionCache[type] 100 | const idsRequiringFetch = dict ? ids.filter(item => !(item in dict)) : ids 101 | // only fetch if there are any items to fetch 102 | if(idsRequiringFetch.length) { 103 | dbQueriesInTransaction += 1 104 | const result = await state.get(type, idsRequiringFetch) 105 | 106 | transactionCache[type] = Object.assign(transactionCache[type] || { }, result) 107 | } 108 | } 109 | 110 | return { 111 | get: async(type, ids) => { 112 | if(inTransaction) { 113 | await prefetch(type, ids) 114 | return ids.reduce( 115 | (dict, id) => { 116 | const value = transactionCache[type]?.[id] 117 | if(value) { 118 | dict[id] = value 119 | } 120 | 121 | return dict 122 | }, { } 123 | ) 124 | } else { 125 | return state.get(type, ids) 126 | } 127 | }, 128 | set: data => { 129 | if(inTransaction) { 130 | logger.trace({ types: Object.keys(data) }, 'caching in transaction') 131 | for(const key in data) { 132 | transactionCache[key] = transactionCache[key] || { } 133 | Object.assign(transactionCache[key], data[key]) 134 | 135 | mutations[key] = mutations[key] || { } 136 | Object.assign(mutations[key], data[key]) 137 | } 138 | } else { 139 | return state.set(data) 140 | } 141 | }, 142 | isInTransaction: () => inTransaction, 143 | transaction: async(work) => { 144 | // if we're already in a transaction, 145 | // just execute what needs to be executed -- no commit required 146 | if(inTransaction) { 147 | await work() 148 | } else { 149 | logger.trace('entering transaction') 150 | inTransaction = true 151 | try { 152 | await work() 153 | if(Object.keys(mutations).length) { 154 | logger.trace('committing transaction') 155 | // retry mechanism to ensure we've some recovery 156 | // in case a transaction fails in the first attempt 157 | let tries = maxCommitRetries 158 | while(tries) { 159 | tries -= 1 160 | try { 161 | await state.set(mutations) 162 | logger.trace({ dbQueriesInTransaction }, 'committed transaction') 163 | break 164 | } catch(error) { 165 | logger.warn(`failed to commit ${Object.keys(mutations).length} mutations, tries left=${tries}`) 166 | await delay(delayBetweenTriesMs) 167 | } 168 | } 169 | } else { 170 | logger.trace('no mutations in transaction') 171 | } 172 | } finally { 173 | inTransaction = false 174 | transactionCache = { } 175 | mutations = { } 176 | dbQueriesInTransaction = 0 177 | } 178 | } 179 | } 180 | } 181 | } 182 | 183 | export const initAuthCreds = (): AuthenticationCreds => { 184 | const identityKey = Curve.generateKeyPair() 185 | return { 186 | noiseKey: Curve.generateKeyPair(), 187 | signedIdentityKey: identityKey, 188 | signedPreKey: signedKeyPair(identityKey, 1), 189 | registrationId: generateRegistrationId(), 190 | advSecretKey: randomBytes(32).toString('base64'), 191 | processedHistoryMessages: [], 192 | nextPreKeyId: 1, 193 | firstUnuploadedPreKeyId: 1, 194 | accountSyncCounter: 0, 195 | accountSettings: { 196 | unarchiveChats: false 197 | } 198 | } 199 | } -------------------------------------------------------------------------------- /src/WABinary/encode.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as constants from './constants' 3 | import { FullJid, jidDecode } from './jid-utils' 4 | import type { BinaryNode, BinaryNodeCodingOptions } from './types' 5 | 6 | export const encodeBinaryNode = ( 7 | { tag, attrs, content }: BinaryNode, 8 | opts: Pick = constants, 9 | buffer: number[] = [0] 10 | ) => { 11 | const { TAGS, TOKEN_MAP } = opts 12 | 13 | const pushByte = (value: number) => buffer.push(value & 0xff) 14 | 15 | const pushInt = (value: number, n: number, littleEndian = false) => { 16 | for(let i = 0; i < n; i++) { 17 | const curShift = littleEndian ? i : n - 1 - i 18 | buffer.push((value >> (curShift * 8)) & 0xff) 19 | } 20 | } 21 | 22 | const pushBytes = (bytes: Uint8Array | Buffer | number[]) => ( 23 | bytes.forEach (b => buffer.push(b)) 24 | ) 25 | const pushInt16 = (value: number) => { 26 | pushBytes([(value >> 8) & 0xff, value & 0xff]) 27 | } 28 | 29 | const pushInt20 = (value: number) => ( 30 | pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff]) 31 | ) 32 | const writeByteLength = (length: number) => { 33 | if(length >= 4294967296) { 34 | throw new Error('string too large to encode: ' + length) 35 | } 36 | 37 | if(length >= 1 << 20) { 38 | pushByte(TAGS.BINARY_32) 39 | pushInt(length, 4) // 32 bit integer 40 | } else if(length >= 256) { 41 | pushByte(TAGS.BINARY_20) 42 | pushInt20(length) 43 | } else { 44 | pushByte(TAGS.BINARY_8) 45 | pushByte(length) 46 | } 47 | } 48 | 49 | const writeStringRaw = (str: string) => { 50 | const bytes = Buffer.from (str, 'utf-8') 51 | writeByteLength(bytes.length) 52 | pushBytes(bytes) 53 | } 54 | 55 | const writeJid = ({ agent, device, user, server }: FullJid) => { 56 | if(typeof agent !== 'undefined' || typeof device !== 'undefined') { 57 | pushByte(TAGS.AD_JID) 58 | pushByte(agent || 0) 59 | pushByte(device || 0) 60 | writeString(user) 61 | } else { 62 | pushByte(TAGS.JID_PAIR) 63 | if(user.length) { 64 | writeString(user) 65 | } else { 66 | pushByte(TAGS.LIST_EMPTY) 67 | } 68 | 69 | writeString(server) 70 | } 71 | } 72 | 73 | const packNibble = (char: string) => { 74 | switch (char) { 75 | case '-': 76 | return 10 77 | case '.': 78 | return 11 79 | case '\0': 80 | return 15 81 | default: 82 | if(char >= '0' && char <= '9') { 83 | return char.charCodeAt(0) - '0'.charCodeAt(0) 84 | } 85 | 86 | throw new Error(`invalid byte for nibble "${char}"`) 87 | } 88 | } 89 | 90 | const packHex = (char: string) => { 91 | if(char >= '0' && char <= '9') { 92 | return char.charCodeAt(0) - '0'.charCodeAt(0) 93 | } 94 | 95 | if(char >= 'A' && char <= 'F') { 96 | return 10 + char.charCodeAt(0) - 'A'.charCodeAt(0) 97 | } 98 | 99 | if(char >= 'a' && char <= 'f') { 100 | return 10 + char.charCodeAt(0) - 'a'.charCodeAt(0) 101 | } 102 | 103 | if(char === '\0') { 104 | return 15 105 | } 106 | 107 | throw new Error(`Invalid hex char "${char}"`) 108 | } 109 | 110 | const writePackedBytes = (str: string, type: 'nibble' | 'hex') => { 111 | if(str.length > TAGS.PACKED_MAX) { 112 | throw new Error('Too many bytes to pack') 113 | } 114 | 115 | pushByte(type === 'nibble' ? TAGS.NIBBLE_8 : TAGS.HEX_8) 116 | 117 | let roundedLength = Math.ceil(str.length / 2.0) 118 | if(str.length % 2 !== 0) { 119 | roundedLength |= 128 120 | } 121 | 122 | pushByte(roundedLength) 123 | const packFunction = type === 'nibble' ? packNibble : packHex 124 | 125 | const packBytePair = (v1: string, v2: string) => { 126 | const result = (packFunction(v1) << 4) | packFunction(v2) 127 | return result 128 | } 129 | 130 | const strLengthHalf = Math.floor(str.length / 2) 131 | for(let i = 0; i < strLengthHalf;i++) { 132 | pushByte(packBytePair(str[2 * i], str[2 * i + 1])) 133 | } 134 | 135 | if(str.length % 2 !== 0) { 136 | pushByte(packBytePair(str[str.length - 1], '\x00')) 137 | } 138 | } 139 | 140 | const isNibble = (str: string) => { 141 | if(str.length > TAGS.PACKED_MAX) { 142 | return false 143 | } 144 | 145 | for(let i = 0;i < str.length;i++) { 146 | const char = str[i] 147 | const isInNibbleRange = char >= '0' && char <= '9' 148 | if(!isInNibbleRange && char !== '-' && char !== '.') { 149 | return false 150 | } 151 | } 152 | 153 | return true 154 | } 155 | 156 | const isHex = (str: string) => { 157 | if(str.length > TAGS.PACKED_MAX) { 158 | return false 159 | } 160 | 161 | for(let i = 0;i < str.length;i++) { 162 | const char = str[i] 163 | const isInNibbleRange = char >= '0' && char <= '9' 164 | if(!isInNibbleRange && !(char >= 'A' && char <= 'F') && !(char >= 'a' && char <= 'f')) { 165 | return false 166 | } 167 | } 168 | 169 | return true 170 | } 171 | 172 | const writeString = (str: string) => { 173 | const tokenIndex = TOKEN_MAP[str] 174 | if(tokenIndex) { 175 | if(typeof tokenIndex.dict === 'number') { 176 | pushByte(TAGS.DICTIONARY_0 + tokenIndex.dict) 177 | } 178 | 179 | pushByte(tokenIndex.index) 180 | } else if(isNibble(str)) { 181 | writePackedBytes(str, 'nibble') 182 | } else if(isHex(str)) { 183 | writePackedBytes(str, 'hex') 184 | } else if(str) { 185 | const decodedJid = jidDecode(str) 186 | if(decodedJid) { 187 | writeJid(decodedJid) 188 | } else { 189 | writeStringRaw(str) 190 | } 191 | } 192 | } 193 | 194 | const writeListStart = (listSize: number) => { 195 | if(listSize === 0) { 196 | pushByte(TAGS.LIST_EMPTY) 197 | } else if(listSize < 256) { 198 | pushBytes([TAGS.LIST_8, listSize]) 199 | } else { 200 | pushByte(TAGS.LIST_16) 201 | pushInt16(listSize) 202 | } 203 | } 204 | 205 | const validAttributes = Object.keys(attrs).filter(k => ( 206 | typeof attrs[k] !== 'undefined' && attrs[k] !== null 207 | )) 208 | 209 | writeListStart(2 * validAttributes.length + 1 + (typeof content !== 'undefined' ? 1 : 0)) 210 | writeString(tag) 211 | 212 | for(const key of validAttributes) { 213 | if(typeof attrs[key] === 'string') { 214 | writeString(key) 215 | writeString(attrs[key]) 216 | } 217 | } 218 | 219 | if(typeof content === 'string') { 220 | writeString(content) 221 | } else if(Buffer.isBuffer(content) || content instanceof Uint8Array) { 222 | writeByteLength(content.length) 223 | pushBytes(content) 224 | } else if(Array.isArray(content)) { 225 | writeListStart(content.length) 226 | for(const item of content) { 227 | encodeBinaryNode(item, opts, buffer) 228 | } 229 | } else if(typeof content === 'undefined') { 230 | // do nothing 231 | } else { 232 | throw new Error(`invalid children for header "${tag}": ${content} (${typeof content})`) 233 | } 234 | 235 | return Buffer.from(buffer) 236 | } -------------------------------------------------------------------------------- /src/Socket/business.ts: -------------------------------------------------------------------------------- 1 | import { GetCatalogOptions, ProductCreate, ProductUpdate, SocketConfig } from '../Types' 2 | import { parseCatalogNode, parseCollectionsNode, parseOrderDetailsNode, parseProductNode, toProductNode, uploadingNecessaryImagesOfProduct } from '../Utils/business' 3 | import { BinaryNode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary' 4 | import { getBinaryNodeChild } from '../WABinary/generic-utils' 5 | import { makeMessagesRecvSocket } from './messages-recv' 6 | 7 | export const makeBusinessSocket = (config: SocketConfig) => { 8 | const sock = makeMessagesRecvSocket(config) 9 | const { 10 | authState, 11 | query, 12 | waUploadToServer 13 | } = sock 14 | 15 | const getCatalog = async({ jid, limit, cursor }: GetCatalogOptions) => { 16 | jid = jid || authState.creds.me?.id 17 | jid = jidNormalizedUser(jid!) 18 | 19 | const queryParamNodes: BinaryNode[] = [ 20 | { 21 | tag: 'limit', 22 | attrs: { }, 23 | content: Buffer.from((limit || 10).toString()) 24 | }, 25 | { 26 | tag: 'width', 27 | attrs: { }, 28 | content: Buffer.from('100') 29 | }, 30 | { 31 | tag: 'height', 32 | attrs: { }, 33 | content: Buffer.from('100') 34 | }, 35 | ] 36 | 37 | if(cursor) { 38 | queryParamNodes.push({ 39 | tag: 'after', 40 | attrs: { }, 41 | content: cursor 42 | }) 43 | } 44 | 45 | const result = await query({ 46 | tag: 'iq', 47 | attrs: { 48 | to: S_WHATSAPP_NET, 49 | type: 'get', 50 | xmlns: 'w:biz:catalog' 51 | }, 52 | content: [ 53 | { 54 | tag: 'product_catalog', 55 | attrs: { 56 | jid, 57 | allow_shop_source: 'true' 58 | }, 59 | content: queryParamNodes 60 | } 61 | ] 62 | }) 63 | return parseCatalogNode(result) 64 | } 65 | 66 | const getCollections = async(jid?: string, limit = 51) => { 67 | jid = jid || authState.creds.me?.id 68 | jid = jidNormalizedUser(jid!) 69 | const result = await query({ 70 | tag: 'iq', 71 | attrs: { 72 | to: S_WHATSAPP_NET, 73 | type: 'get', 74 | xmlns: 'w:biz:catalog', 75 | smax_id: '35' 76 | }, 77 | content: [ 78 | { 79 | tag: 'collections', 80 | attrs: { 81 | biz_jid: jid, 82 | }, 83 | content: [ 84 | { 85 | tag: 'collection_limit', 86 | attrs: { }, 87 | content: Buffer.from(limit.toString()) 88 | }, 89 | { 90 | tag: 'item_limit', 91 | attrs: { }, 92 | content: Buffer.from(limit.toString()) 93 | }, 94 | { 95 | tag: 'width', 96 | attrs: { }, 97 | content: Buffer.from('100') 98 | }, 99 | { 100 | tag: 'height', 101 | attrs: { }, 102 | content: Buffer.from('100') 103 | } 104 | ] 105 | } 106 | ] 107 | }) 108 | 109 | return parseCollectionsNode(result) 110 | } 111 | 112 | const getOrderDetails = async(orderId: string, tokenBase64: string) => { 113 | const result = await query({ 114 | tag: 'iq', 115 | attrs: { 116 | to: S_WHATSAPP_NET, 117 | type: 'get', 118 | xmlns: 'fb:thrift_iq', 119 | smax_id: '5' 120 | }, 121 | content: [ 122 | { 123 | tag: 'order', 124 | attrs: { 125 | op: 'get', 126 | id: orderId 127 | }, 128 | content: [ 129 | { 130 | tag: 'image_dimensions', 131 | attrs: { }, 132 | content: [ 133 | { 134 | tag: 'width', 135 | attrs: { }, 136 | content: Buffer.from('100') 137 | }, 138 | { 139 | tag: 'height', 140 | attrs: { }, 141 | content: Buffer.from('100') 142 | } 143 | ] 144 | }, 145 | { 146 | tag: 'token', 147 | attrs: { }, 148 | content: Buffer.from(tokenBase64) 149 | } 150 | ] 151 | } 152 | ] 153 | }) 154 | 155 | return parseOrderDetailsNode(result) 156 | } 157 | 158 | const productUpdate = async(productId: string, update: ProductUpdate) => { 159 | update = await uploadingNecessaryImagesOfProduct(update, waUploadToServer) 160 | const editNode = toProductNode(productId, update) 161 | 162 | const result = await query({ 163 | tag: 'iq', 164 | attrs: { 165 | to: S_WHATSAPP_NET, 166 | type: 'set', 167 | xmlns: 'w:biz:catalog' 168 | }, 169 | content: [ 170 | { 171 | tag: 'product_catalog_edit', 172 | attrs: { v: '1' }, 173 | content: [ 174 | editNode, 175 | { 176 | tag: 'width', 177 | attrs: { }, 178 | content: '100' 179 | }, 180 | { 181 | tag: 'height', 182 | attrs: { }, 183 | content: '100' 184 | } 185 | ] 186 | } 187 | ] 188 | }) 189 | 190 | const productCatalogEditNode = getBinaryNodeChild(result, 'product_catalog_edit') 191 | const productNode = getBinaryNodeChild(productCatalogEditNode, 'product') 192 | 193 | return parseProductNode(productNode!) 194 | } 195 | 196 | const productCreate = async(create: ProductCreate) => { 197 | // ensure isHidden is defined 198 | create.isHidden = !!create.isHidden 199 | create = await uploadingNecessaryImagesOfProduct(create, waUploadToServer) 200 | const createNode = toProductNode(undefined, create) 201 | 202 | const result = await query({ 203 | tag: 'iq', 204 | attrs: { 205 | to: S_WHATSAPP_NET, 206 | type: 'set', 207 | xmlns: 'w:biz:catalog' 208 | }, 209 | content: [ 210 | { 211 | tag: 'product_catalog_add', 212 | attrs: { v: '1' }, 213 | content: [ 214 | createNode, 215 | { 216 | tag: 'width', 217 | attrs: { }, 218 | content: '100' 219 | }, 220 | { 221 | tag: 'height', 222 | attrs: { }, 223 | content: '100' 224 | } 225 | ] 226 | } 227 | ] 228 | }) 229 | 230 | const productCatalogAddNode = getBinaryNodeChild(result, 'product_catalog_add') 231 | const productNode = getBinaryNodeChild(productCatalogAddNode, 'product') 232 | 233 | return parseProductNode(productNode!) 234 | } 235 | 236 | const productDelete = async(productIds: string[]) => { 237 | const result = await query({ 238 | tag: 'iq', 239 | attrs: { 240 | to: S_WHATSAPP_NET, 241 | type: 'set', 242 | xmlns: 'w:biz:catalog' 243 | }, 244 | content: [ 245 | { 246 | tag: 'product_catalog_delete', 247 | attrs: { v: '1' }, 248 | content: productIds.map( 249 | id => ({ 250 | tag: 'product', 251 | attrs: { }, 252 | content: [ 253 | { 254 | tag: 'id', 255 | attrs: { }, 256 | content: Buffer.from(id) 257 | } 258 | ] 259 | }) 260 | ) 261 | } 262 | ] 263 | }) 264 | 265 | const productCatalogDelNode = getBinaryNodeChild(result, 'product_catalog_delete') 266 | return { 267 | deleted: +(productCatalogDelNode?.attrs.deleted_count || 0) 268 | } 269 | } 270 | 271 | return { 272 | ...sock, 273 | getOrderDetails, 274 | getCatalog, 275 | getCollections, 276 | productCreate, 277 | productDelete, 278 | productUpdate 279 | } 280 | } -------------------------------------------------------------------------------- /src/WABinary/decode.ts: -------------------------------------------------------------------------------- 1 | import { inflateSync } from 'zlib' 2 | import * as constants from './constants' 3 | import { jidEncode } from './jid-utils' 4 | import type { BinaryNode, BinaryNodeCodingOptions } from './types' 5 | 6 | export const decompressingIfRequired = (buffer: Buffer) => { 7 | if(2 & buffer.readUInt8()) { 8 | buffer = inflateSync(buffer.slice(1)) 9 | } else { // nodes with no compression have a 0x00 prefix, we remove that 10 | buffer = buffer.slice(1) 11 | } 12 | 13 | return buffer 14 | } 15 | 16 | export const decodeDecompressedBinaryNode = ( 17 | buffer: Buffer, 18 | opts: Pick, 19 | indexRef: { index: number } = { index: 0 } 20 | ): BinaryNode => { 21 | const { DOUBLE_BYTE_TOKENS, SINGLE_BYTE_TOKENS, TAGS } = opts 22 | 23 | const checkEOS = (length: number) => { 24 | if(indexRef.index + length > buffer.length) { 25 | throw new Error('end of stream') 26 | } 27 | } 28 | 29 | const next = () => { 30 | const value = buffer[indexRef.index] 31 | indexRef.index += 1 32 | return value 33 | } 34 | 35 | const readByte = () => { 36 | checkEOS(1) 37 | return next() 38 | } 39 | 40 | const readBytes = (n: number) => { 41 | checkEOS(n) 42 | const value = buffer.slice(indexRef.index, indexRef.index + n) 43 | indexRef.index += n 44 | return value 45 | } 46 | 47 | const readStringFromChars = (length: number) => { 48 | return readBytes(length).toString('utf-8') 49 | } 50 | 51 | const readInt = (n: number, littleEndian = false) => { 52 | checkEOS(n) 53 | let val = 0 54 | for(let i = 0; i < n; i++) { 55 | const shift = littleEndian ? i : n - 1 - i 56 | val |= next() << (shift * 8) 57 | } 58 | 59 | return val 60 | } 61 | 62 | const readInt20 = () => { 63 | checkEOS(3) 64 | return ((next() & 15) << 16) + (next() << 8) + next() 65 | } 66 | 67 | const unpackHex = (value: number) => { 68 | if(value >= 0 && value < 16) { 69 | return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10 70 | } 71 | 72 | throw new Error('invalid hex: ' + value) 73 | } 74 | 75 | const unpackNibble = (value: number) => { 76 | if(value >= 0 && value <= 9) { 77 | return '0'.charCodeAt(0) + value 78 | } 79 | 80 | switch (value) { 81 | case 10: 82 | return '-'.charCodeAt(0) 83 | case 11: 84 | return '.'.charCodeAt(0) 85 | case 15: 86 | return '\0'.charCodeAt(0) 87 | default: 88 | throw new Error('invalid nibble: ' + value) 89 | } 90 | } 91 | 92 | const unpackByte = (tag: number, value: number) => { 93 | if(tag === TAGS.NIBBLE_8) { 94 | return unpackNibble(value) 95 | } else if(tag === TAGS.HEX_8) { 96 | return unpackHex(value) 97 | } else { 98 | throw new Error('unknown tag: ' + tag) 99 | } 100 | } 101 | 102 | const readPacked8 = (tag: number) => { 103 | const startByte = readByte() 104 | let value = '' 105 | 106 | for(let i = 0; i < (startByte & 127); i++) { 107 | const curByte = readByte() 108 | value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4)) 109 | value += String.fromCharCode(unpackByte(tag, curByte & 0x0f)) 110 | } 111 | 112 | if(startByte >> 7 !== 0) { 113 | value = value.slice(0, -1) 114 | } 115 | 116 | return value 117 | } 118 | 119 | const isListTag = (tag: number) => { 120 | return tag === TAGS.LIST_EMPTY || tag === TAGS.LIST_8 || tag === TAGS.LIST_16 121 | } 122 | 123 | const readListSize = (tag: number) => { 124 | switch (tag) { 125 | case TAGS.LIST_EMPTY: 126 | return 0 127 | case TAGS.LIST_8: 128 | return readByte() 129 | case TAGS.LIST_16: 130 | return readInt(2) 131 | default: 132 | throw new Error('invalid tag for list size: ' + tag) 133 | } 134 | } 135 | 136 | const readJidPair = () => { 137 | const i = readString(readByte()) 138 | const j = readString(readByte()) 139 | if(j) { 140 | return (i || '') + '@' + j 141 | } 142 | 143 | throw new Error('invalid jid pair: ' + i + ', ' + j) 144 | } 145 | 146 | const readAdJid = () => { 147 | const agent = readByte() 148 | const device = readByte() 149 | const user = readString(readByte()) 150 | 151 | return jidEncode(user, 's.whatsapp.net', device, agent) 152 | } 153 | 154 | const readString = (tag: number): string => { 155 | if(tag >= 1 && tag < SINGLE_BYTE_TOKENS.length) { 156 | return SINGLE_BYTE_TOKENS[tag] || '' 157 | } 158 | 159 | switch (tag) { 160 | case TAGS.DICTIONARY_0: 161 | case TAGS.DICTIONARY_1: 162 | case TAGS.DICTIONARY_2: 163 | case TAGS.DICTIONARY_3: 164 | return getTokenDouble(tag - TAGS.DICTIONARY_0, readByte()) 165 | case TAGS.LIST_EMPTY: 166 | return '' 167 | case TAGS.BINARY_8: 168 | return readStringFromChars(readByte()) 169 | case TAGS.BINARY_20: 170 | return readStringFromChars(readInt20()) 171 | case TAGS.BINARY_32: 172 | return readStringFromChars(readInt(4)) 173 | case TAGS.JID_PAIR: 174 | return readJidPair() 175 | case TAGS.AD_JID: 176 | return readAdJid() 177 | case TAGS.HEX_8: 178 | case TAGS.NIBBLE_8: 179 | return readPacked8(tag) 180 | default: 181 | throw new Error('invalid string with tag: ' + tag) 182 | } 183 | } 184 | 185 | const readList = (tag: number) => { 186 | const items: BinaryNode[] = [] 187 | const size = readListSize(tag) 188 | for(let i = 0;i < size;i++) { 189 | items.push(decodeDecompressedBinaryNode(buffer, opts, indexRef)) 190 | } 191 | 192 | return items 193 | } 194 | 195 | const getTokenDouble = (index1: number, index2: number) => { 196 | const dict = DOUBLE_BYTE_TOKENS[index1] 197 | if(!dict) { 198 | throw new Error(`Invalid double token dict (${index1})`) 199 | } 200 | 201 | const value = dict[index2] 202 | if(typeof value === 'undefined') { 203 | throw new Error(`Invalid double token (${index2})`) 204 | } 205 | 206 | return value 207 | } 208 | 209 | const listSize = readListSize(readByte()) 210 | const header = readString(readByte()) 211 | if(!listSize || !header.length) { 212 | throw new Error('invalid node') 213 | } 214 | 215 | const attrs: BinaryNode['attrs'] = { } 216 | let data: BinaryNode['content'] 217 | if(listSize === 0 || !header) { 218 | throw new Error('invalid node') 219 | } 220 | 221 | // read the attributes in 222 | const attributesLength = (listSize - 1) >> 1 223 | for(let i = 0; i < attributesLength; i++) { 224 | const key = readString(readByte()) 225 | const value = readString(readByte()) 226 | 227 | attrs[key] = value 228 | } 229 | 230 | if(listSize % 2 === 0) { 231 | const tag = readByte() 232 | if(isListTag(tag)) { 233 | data = readList(tag) 234 | } else { 235 | let decoded: Buffer | string 236 | switch (tag) { 237 | case TAGS.BINARY_8: 238 | decoded = readBytes(readByte()) 239 | break 240 | case TAGS.BINARY_20: 241 | decoded = readBytes(readInt20()) 242 | break 243 | case TAGS.BINARY_32: 244 | decoded = readBytes(readInt(4)) 245 | break 246 | default: 247 | decoded = readString(tag) 248 | break 249 | } 250 | 251 | data = decoded 252 | } 253 | } 254 | 255 | return { 256 | tag: header, 257 | attrs, 258 | content: data 259 | } 260 | } 261 | 262 | export const decodeBinaryNode = (buff: Buffer): BinaryNode => { 263 | const decompBuff = decompressingIfRequired(buff) 264 | return decodeDecompressedBinaryNode(decompBuff, constants) 265 | } -------------------------------------------------------------------------------- /src/Utils/validate-connection.ts: -------------------------------------------------------------------------------- 1 | import { Boom } from '@hapi/boom' 2 | import { createHash } from 'crypto' 3 | import { proto } from '../../WAProto' 4 | import { KEY_BUNDLE_TYPE } from '../Defaults' 5 | import type { AuthenticationCreds, SignalCreds, SocketConfig } from '../Types' 6 | import { BinaryNode, getBinaryNodeChild, jidDecode, S_WHATSAPP_NET } from '../WABinary' 7 | import { Curve, hmacSign } from './crypto' 8 | import { encodeBigEndian } from './generics' 9 | import { createSignalIdentity } from './signal' 10 | 11 | type ClientPayloadConfig = Pick 12 | 13 | const getUserAgent = ({ version }: ClientPayloadConfig): proto.ClientPayload.IUserAgent => { 14 | const osVersion = '0.1' 15 | return { 16 | appVersion: { 17 | primary: version[0], 18 | secondary: version[1], 19 | tertiary: version[2], 20 | }, 21 | platform: proto.ClientPayload.UserAgent.Platform.WEB, 22 | releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, 23 | mcc: '000', 24 | mnc: '000', 25 | osVersion: osVersion, 26 | manufacturer: '', 27 | device: 'Desktop', 28 | osBuildNumber: osVersion, 29 | localeLanguageIso6391: 'en', 30 | localeCountryIso31661Alpha2: 'US', 31 | } 32 | } 33 | 34 | const PLATFORM_MAP = { 35 | 'Mac OS': proto.ClientPayload.WebInfo.WebSubPlatform.DARWIN, 36 | 'Windows': proto.ClientPayload.WebInfo.WebSubPlatform.WIN32 37 | } 38 | 39 | const getWebInfo = (config: ClientPayloadConfig): proto.ClientPayload.IWebInfo => { 40 | let webSubPlatform = proto.ClientPayload.WebInfo.WebSubPlatform.WEB_BROWSER 41 | if(config.syncFullHistory && PLATFORM_MAP[config.browser[0]]) { 42 | webSubPlatform = PLATFORM_MAP[config.browser[0]] 43 | } 44 | 45 | return { webSubPlatform } 46 | } 47 | 48 | const getClientPayload = (config: ClientPayloadConfig): proto.IClientPayload => { 49 | return { 50 | connectType: proto.ClientPayload.ConnectType.WIFI_UNKNOWN, 51 | connectReason: proto.ClientPayload.ConnectReason.USER_ACTIVATED, 52 | userAgent: getUserAgent(config), 53 | webInfo: getWebInfo(config), 54 | } 55 | } 56 | 57 | export const generateLoginNode = (userJid: string, config: ClientPayloadConfig): proto.IClientPayload => { 58 | const { user, device } = jidDecode(userJid)! 59 | const payload: proto.IClientPayload = { 60 | ...getClientPayload(config), 61 | passive: true, 62 | username: +user, 63 | device: device, 64 | } 65 | return proto.ClientPayload.fromObject(payload) 66 | } 67 | 68 | export const generateRegistrationNode = ( 69 | { registrationId, signedPreKey, signedIdentityKey }: SignalCreds, 70 | config: ClientPayloadConfig 71 | ) => { 72 | // the app version needs to be md5 hashed 73 | // and passed in 74 | const appVersionBuf = createHash('md5') 75 | .update(config.version.join('.')) // join as string 76 | .digest() 77 | const browserVersion = config.browser[2].split('.') 78 | 79 | const companion: proto.IDeviceProps = { 80 | os: config.browser[0], 81 | version: { 82 | primary: +(browserVersion[0] || 0), 83 | secondary: +(browserVersion[1] || 1), 84 | tertiary: +(browserVersion[2] || 0), 85 | }, 86 | platformType: proto.DeviceProps.PlatformType[config.browser[1].toUpperCase()] 87 | || proto.DeviceProps.PlatformType.UNKNOWN, 88 | requireFullSync: config.syncFullHistory, 89 | } 90 | 91 | const companionProto = proto.DeviceProps.encode(companion).finish() 92 | 93 | const registerPayload: proto.IClientPayload = { 94 | ...getClientPayload(config), 95 | passive: false, 96 | devicePairingData: { 97 | buildHash: appVersionBuf, 98 | deviceProps: companionProto, 99 | eRegid: encodeBigEndian(registrationId), 100 | eKeytype: KEY_BUNDLE_TYPE, 101 | eIdent: signedIdentityKey.public, 102 | eSkeyId: encodeBigEndian(signedPreKey.keyId, 3), 103 | eSkeyVal: signedPreKey.keyPair.public, 104 | eSkeySig: signedPreKey.signature, 105 | }, 106 | } 107 | 108 | return proto.ClientPayload.fromObject(registerPayload) 109 | } 110 | 111 | export const configureSuccessfulPairing = ( 112 | stanza: BinaryNode, 113 | { advSecretKey, signedIdentityKey, signalIdentities }: Pick 114 | ) => { 115 | const msgId = stanza.attrs.id 116 | 117 | const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success') 118 | 119 | const deviceIdentityNode = getBinaryNodeChild(pairSuccessNode, 'device-identity') 120 | const platformNode = getBinaryNodeChild(pairSuccessNode, 'platform') 121 | const deviceNode = getBinaryNodeChild(pairSuccessNode, 'device') 122 | const businessNode = getBinaryNodeChild(pairSuccessNode, 'biz') 123 | 124 | if(!deviceIdentityNode || !deviceNode) { 125 | throw new Boom('Missing device-identity or device in pair success node', { data: stanza }) 126 | } 127 | 128 | const bizName = businessNode?.attrs.name 129 | const jid = deviceNode.attrs.jid 130 | 131 | const { details, hmac } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer) 132 | // check HMAC matches 133 | const advSign = hmacSign(details, Buffer.from(advSecretKey, 'base64')) 134 | if(Buffer.compare(hmac, advSign) !== 0) { 135 | throw new Boom('Invalid account signature') 136 | } 137 | 138 | const account = proto.ADVSignedDeviceIdentity.decode(details) 139 | const { accountSignatureKey, accountSignature, details: deviceDetails } = account 140 | // verify the device signature matches 141 | const accountMsg = Buffer.concat([ Buffer.from([6, 0]), deviceDetails, signedIdentityKey.public ]) 142 | if(!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) { 143 | throw new Boom('Failed to verify account signature') 144 | } 145 | 146 | // sign the details with our identity key 147 | const deviceMsg = Buffer.concat([ Buffer.from([6, 1]), deviceDetails, signedIdentityKey.public, accountSignatureKey ]) 148 | account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg) 149 | 150 | const identity = createSignalIdentity(jid, accountSignatureKey) 151 | const accountEnc = encodeSignedDeviceIdentity(account, false) 152 | 153 | const deviceIdentity = proto.ADVDeviceIdentity.decode(account.details) 154 | 155 | const reply: BinaryNode = { 156 | tag: 'iq', 157 | attrs: { 158 | to: S_WHATSAPP_NET, 159 | type: 'result', 160 | id: msgId, 161 | }, 162 | content: [ 163 | { 164 | tag: 'pair-device-sign', 165 | attrs: { }, 166 | content: [ 167 | { 168 | tag: 'device-identity', 169 | attrs: { 'key-index': deviceIdentity.keyIndex.toString() }, 170 | content: accountEnc 171 | } 172 | ] 173 | } 174 | ] 175 | } 176 | 177 | const authUpdate: Partial = { 178 | account, 179 | me: { id: jid, name: bizName }, 180 | signalIdentities: [ 181 | ...(signalIdentities || []), 182 | identity 183 | ], 184 | platform: platformNode?.attrs.name 185 | } 186 | 187 | return { 188 | creds: authUpdate, 189 | reply 190 | } 191 | } 192 | 193 | export const encodeSignedDeviceIdentity = ( 194 | account: proto.IADVSignedDeviceIdentity, 195 | includeSignatureKey: boolean 196 | ) => { 197 | account = { ...account } 198 | // set to null if we are not to include the signature key 199 | // or if we are including the signature key but it is empty 200 | if(!includeSignatureKey || !account.accountSignatureKey?.length) { 201 | account.accountSignatureKey = null 202 | } 203 | 204 | const accountEnc = proto.ADVSignedDeviceIdentity 205 | .encode(account) 206 | .finish() 207 | return accountEnc 208 | } -------------------------------------------------------------------------------- /src/Utils/business.ts: -------------------------------------------------------------------------------- 1 | import { Boom } from '@hapi/boom' 2 | import { createHash } from 'crypto' 3 | import { CatalogCollection, CatalogStatus, OrderDetails, OrderProduct, Product, ProductCreate, ProductUpdate, WAMediaUpload, WAMediaUploadFunction } from '../Types' 4 | import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, getBinaryNodeChildString } from '../WABinary' 5 | import { getStream, getUrlFromDirectPath, toReadable } from './messages-media' 6 | 7 | export const parseCatalogNode = (node: BinaryNode) => { 8 | const catalogNode = getBinaryNodeChild(node, 'product_catalog') 9 | const products = getBinaryNodeChildren(catalogNode, 'product').map(parseProductNode) 10 | const paging = getBinaryNodeChild(catalogNode, 'paging') 11 | 12 | return { 13 | products, 14 | nextPageCursor: paging 15 | ? getBinaryNodeChildString(paging, 'after') 16 | : undefined 17 | } 18 | } 19 | 20 | export const parseCollectionsNode = (node: BinaryNode) => { 21 | const collectionsNode = getBinaryNodeChild(node, 'collections') 22 | const collections = getBinaryNodeChildren(collectionsNode, 'collection').map( 23 | collectionNode => { 24 | const id = getBinaryNodeChildString(collectionNode, 'id')! 25 | const name = getBinaryNodeChildString(collectionNode, 'name')! 26 | 27 | const products = getBinaryNodeChildren(collectionNode, 'product').map(parseProductNode) 28 | return { 29 | id, 30 | name, 31 | products, 32 | status: parseStatusInfo(collectionNode) 33 | } 34 | } 35 | ) 36 | 37 | return { 38 | collections 39 | } 40 | } 41 | 42 | export const parseOrderDetailsNode = (node: BinaryNode) => { 43 | const orderNode = getBinaryNodeChild(node, 'order') 44 | const products = getBinaryNodeChildren(orderNode, 'product').map( 45 | productNode => { 46 | const imageNode = getBinaryNodeChild(productNode, 'image')! 47 | return { 48 | id: getBinaryNodeChildString(productNode, 'id')!, 49 | name: getBinaryNodeChildString(productNode, 'name')!, 50 | imageUrl: getBinaryNodeChildString(imageNode, 'url')!, 51 | price: +getBinaryNodeChildString(productNode, 'price')!, 52 | currency: getBinaryNodeChildString(productNode, 'currency')!, 53 | quantity: +getBinaryNodeChildString(productNode, 'quantity')! 54 | } 55 | } 56 | ) 57 | 58 | const priceNode = getBinaryNodeChild(orderNode, 'price') 59 | 60 | const orderDetails: OrderDetails = { 61 | price: { 62 | total: +getBinaryNodeChildString(priceNode, 'total')!, 63 | currency: getBinaryNodeChildString(priceNode, 'currency')!, 64 | }, 65 | products 66 | } 67 | 68 | return orderDetails 69 | } 70 | 71 | export const toProductNode = (productId: string | undefined, product: ProductCreate | ProductUpdate) => { 72 | const attrs: BinaryNode['attrs'] = { } 73 | const content: BinaryNode[] = [ ] 74 | 75 | if(typeof productId !== 'undefined') { 76 | content.push({ 77 | tag: 'id', 78 | attrs: { }, 79 | content: Buffer.from(productId) 80 | }) 81 | } 82 | 83 | if(typeof product.name !== 'undefined') { 84 | content.push({ 85 | tag: 'name', 86 | attrs: { }, 87 | content: Buffer.from(product.name) 88 | }) 89 | } 90 | 91 | if(typeof product.description !== 'undefined') { 92 | content.push({ 93 | tag: 'description', 94 | attrs: { }, 95 | content: Buffer.from(product.description) 96 | }) 97 | } 98 | 99 | if(typeof product.retailerId !== 'undefined') { 100 | content.push({ 101 | tag: 'retailer_id', 102 | attrs: { }, 103 | content: Buffer.from(product.retailerId) 104 | }) 105 | } 106 | 107 | if(product.images.length) { 108 | content.push({ 109 | tag: 'media', 110 | attrs: { }, 111 | content: product.images.map( 112 | img => { 113 | if(!('url' in img)) { 114 | throw new Boom('Expected img for product to already be uploaded', { statusCode: 400 }) 115 | } 116 | 117 | return { 118 | tag: 'image', 119 | attrs: { }, 120 | content: [ 121 | { 122 | tag: 'url', 123 | attrs: { }, 124 | content: Buffer.from(img.url.toString()) 125 | } 126 | ] 127 | } 128 | } 129 | ) 130 | }) 131 | } 132 | 133 | if(typeof product.price !== 'undefined') { 134 | content.push({ 135 | tag: 'price', 136 | attrs: { }, 137 | content: Buffer.from(product.price.toString()) 138 | }) 139 | } 140 | 141 | if(typeof product.currency !== 'undefined') { 142 | content.push({ 143 | tag: 'currency', 144 | attrs: { }, 145 | content: Buffer.from(product.currency) 146 | }) 147 | } 148 | 149 | if('originCountryCode' in product) { 150 | if(typeof product.originCountryCode === 'undefined') { 151 | attrs.compliance_category = 'COUNTRY_ORIGIN_EXEMPT' 152 | } else { 153 | content.push({ 154 | tag: 'compliance_info', 155 | attrs: { }, 156 | content: [ 157 | { 158 | tag: 'country_code_origin', 159 | attrs: { }, 160 | content: Buffer.from(product.originCountryCode) 161 | } 162 | ] 163 | }) 164 | } 165 | } 166 | 167 | 168 | if(typeof product.isHidden !== 'undefined') { 169 | attrs.is_hidden = product.isHidden.toString() 170 | } 171 | 172 | const node: BinaryNode = { 173 | tag: 'product', 174 | attrs, 175 | content 176 | } 177 | return node 178 | } 179 | 180 | export const parseProductNode = (productNode: BinaryNode) => { 181 | const isHidden = productNode.attrs.is_hidden === 'true' 182 | const id = getBinaryNodeChildString(productNode, 'id')! 183 | 184 | const mediaNode = getBinaryNodeChild(productNode, 'media')! 185 | const statusInfoNode = getBinaryNodeChild(productNode, 'status_info')! 186 | 187 | const product: Product = { 188 | id, 189 | imageUrls: parseImageUrls(mediaNode), 190 | reviewStatus: { 191 | whatsapp: getBinaryNodeChildString(statusInfoNode, 'status')!, 192 | }, 193 | availability: 'in stock', 194 | name: getBinaryNodeChildString(productNode, 'name')!, 195 | retailerId: getBinaryNodeChildString(productNode, 'retailer_id'), 196 | url: getBinaryNodeChildString(productNode, 'url'), 197 | description: getBinaryNodeChildString(productNode, 'description')!, 198 | price: +getBinaryNodeChildString(productNode, 'price')!, 199 | currency: getBinaryNodeChildString(productNode, 'currency')!, 200 | isHidden, 201 | } 202 | 203 | return product 204 | } 205 | 206 | /** 207 | * Uploads images not already uploaded to WA's servers 208 | */ 209 | export async function uploadingNecessaryImagesOfProduct(product: T, waUploadToServer: WAMediaUploadFunction, timeoutMs = 30_000) { 210 | product = { 211 | ...product, 212 | images: product.images ? await uploadingNecessaryImages(product.images, waUploadToServer, timeoutMs) : product.images 213 | } 214 | return product 215 | } 216 | 217 | /** 218 | * Uploads images not already uploaded to WA's servers 219 | */ 220 | export const uploadingNecessaryImages = async( 221 | images: WAMediaUpload[], 222 | waUploadToServer: WAMediaUploadFunction, 223 | timeoutMs = 30_000 224 | ) => { 225 | const results = await Promise.all( 226 | images.map>( 227 | async img => { 228 | 229 | if('url' in img) { 230 | const url = img.url.toString() 231 | if(url.includes('.whatsapp.net')) { 232 | return { url } 233 | } 234 | } 235 | 236 | const { stream } = await getStream(img) 237 | const hasher = createHash('sha256') 238 | const contentBlocks: Buffer[] = [] 239 | for await (const block of stream) { 240 | hasher.update(block) 241 | contentBlocks.push(block) 242 | } 243 | 244 | const sha = hasher.digest('base64') 245 | 246 | const { directPath } = await waUploadToServer( 247 | toReadable(Buffer.concat(contentBlocks)), 248 | { 249 | mediaType: 'product-catalog-image', 250 | fileEncSha256B64: sha, 251 | timeoutMs 252 | } 253 | ) 254 | return { url: getUrlFromDirectPath(directPath) } 255 | } 256 | ) 257 | ) 258 | return results 259 | } 260 | 261 | const parseImageUrls = (mediaNode: BinaryNode) => { 262 | const imgNode = getBinaryNodeChild(mediaNode, 'image') 263 | return { 264 | requested: getBinaryNodeChildString(imgNode, 'request_image_url')!, 265 | original: getBinaryNodeChildString(imgNode, 'original_image_url')! 266 | } 267 | } 268 | 269 | const parseStatusInfo = (mediaNode: BinaryNode): CatalogStatus => { 270 | const node = getBinaryNodeChild(mediaNode, 'status_info') 271 | return { 272 | status: getBinaryNodeChildString(node, 'status')!, 273 | canAppeal: getBinaryNodeChildString(node, 'can_appeal') === 'true', 274 | } 275 | } -------------------------------------------------------------------------------- /src/Types/Message.ts: -------------------------------------------------------------------------------- 1 | import type NodeCache from 'node-cache' 2 | import type { Logger } from 'pino' 3 | import type { Readable } from 'stream' 4 | import type { URL } from 'url' 5 | import { proto } from '../../WAProto' 6 | import { MEDIA_HKDF_KEY_MAPPING } from '../Defaults' 7 | import type { GroupMetadata } from './GroupMetadata' 8 | 9 | // export the WAMessage Prototypes 10 | export { proto as WAProto } 11 | export type WAMessage = proto.IWebMessageInfo 12 | export type WAMessageContent = proto.IMessage 13 | export type WAContactMessage = proto.Message.IContactMessage 14 | export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage 15 | export type WAMessageKey = proto.IMessageKey 16 | export type WATextMessage = proto.Message.IExtendedTextMessage 17 | export type WAContextInfo = proto.IContextInfo 18 | export type WALocationMessage = proto.Message.ILocationMessage 19 | export type WAGenericMediaMessage = proto.Message.IVideoMessage | proto.Message.IImageMessage | proto.Message.IAudioMessage | proto.Message.IDocumentMessage | proto.Message.IStickerMessage 20 | // eslint-disable-next-line no-unused-vars 21 | export import WAMessageStubType = proto.WebMessageInfo.StubType 22 | // eslint-disable-next-line no-unused-vars 23 | export import WAMessageStatus = proto.WebMessageInfo.Status 24 | export type WAMediaUpload = Buffer | { url: URL | string } | { stream: Readable } 25 | /** Set of message types that are supported by the library */ 26 | export type MessageType = keyof proto.Message 27 | 28 | export type DownloadableMessage = { mediaKey?: Uint8Array | null, directPath?: string | null, url?: string | null } 29 | 30 | export type MessageReceiptType = 'read' | 'read-self' | 'hist_sync' | 'peer_msg' | 'sender' | 'inactive' | 'played' | undefined 31 | 32 | export type MediaConnInfo = { 33 | auth: string 34 | ttl: number 35 | hosts: { hostname: string, maxContentLengthBytes: number }[] 36 | fetchDate: Date 37 | } 38 | 39 | export interface WAUrlInfo { 40 | 'canonical-url': string 41 | 'matched-text': string 42 | title: string 43 | description?: string 44 | jpegThumbnail?: Buffer 45 | highQualityThumbnail?: proto.Message.IImageMessage 46 | originalThumbnailUrl?: string 47 | } 48 | 49 | // types to generate WA messages 50 | type Mentionable = { 51 | /** list of jids that are mentioned in the accompanying text */ 52 | mentions?: string[] 53 | } 54 | type ViewOnce = { 55 | viewOnce?: boolean 56 | } 57 | type Buttonable = { 58 | /** add buttons to the message */ 59 | buttons?: proto.Message.ButtonsMessage.IButton[] 60 | } 61 | type Templatable = { 62 | /** add buttons to the message (conflicts with normal buttons)*/ 63 | templateButtons?: proto.IHydratedTemplateButton[] 64 | 65 | footer?: string 66 | } 67 | type Listable = { 68 | /** Sections of the List */ 69 | sections?: proto.Message.ListMessage.ISection[] 70 | 71 | /** Title of a List Message only */ 72 | title?: string 73 | 74 | /** Text of the bnutton on the list (required) */ 75 | buttonText?: string 76 | } 77 | type WithDimensions = { 78 | width?: number 79 | height?: number 80 | } 81 | 82 | export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING 83 | export type AnyMediaMessageContent = ( 84 | ({ 85 | image: WAMediaUpload 86 | caption?: string 87 | jpegThumbnail?: string 88 | } & Mentionable & Buttonable & Templatable & WithDimensions) 89 | | ({ 90 | video: WAMediaUpload 91 | caption?: string 92 | gifPlayback?: boolean 93 | jpegThumbnail?: string 94 | } & Mentionable & Buttonable & Templatable & WithDimensions) 95 | | { 96 | audio: WAMediaUpload 97 | /** if set to true, will send as a `voice note` */ 98 | ptt?: boolean 99 | /** optionally tell the duration of the audio */ 100 | seconds?: number 101 | } 102 | | ({ 103 | sticker: WAMediaUpload 104 | isAnimated?: boolean 105 | } & WithDimensions) | ({ 106 | document: WAMediaUpload 107 | mimetype: string 108 | fileName?: string 109 | caption?: string 110 | } & Buttonable & Templatable)) 111 | & { mimetype?: string } 112 | 113 | export type ButtonReplyInfo = { 114 | displayText: string 115 | id: string 116 | index: number 117 | } 118 | 119 | export type WASendableProduct = Omit & { 120 | productImage: WAMediaUpload 121 | } 122 | 123 | export type AnyRegularMessageContent = ( 124 | ({ 125 | text: string 126 | linkPreview?: WAUrlInfo | null 127 | } 128 | & Mentionable & Buttonable & Templatable & Listable) 129 | | AnyMediaMessageContent 130 | | { 131 | contacts: { 132 | displayName?: string 133 | contacts: proto.Message.IContactMessage[] 134 | } 135 | } 136 | | { 137 | location: WALocationMessage 138 | } 139 | | { react: proto.Message.IReactionMessage } 140 | | { 141 | buttonReply: ButtonReplyInfo 142 | type: 'template' | 'plain' 143 | } 144 | | { 145 | listReply: Omit 146 | } 147 | | { 148 | product: WASendableProduct, 149 | businessOwnerJid?: string 150 | body?: string 151 | footer?: string 152 | } 153 | ) & ViewOnce 154 | 155 | export type AnyMessageContent = AnyRegularMessageContent | { 156 | forward: WAMessage 157 | force?: boolean 158 | } | { 159 | /** Delete your message or anyone's message in a group (admin required) */ 160 | delete: WAMessageKey 161 | } | { 162 | disappearingMessagesInChat: boolean | number 163 | } 164 | 165 | export type GroupMetadataParticipants = Pick 166 | 167 | type MinimalRelayOptions = { 168 | /** override the message ID with a custom provided string */ 169 | messageId?: string 170 | /** cached group metadata, use to prevent redundant requests to WA & speed up msg sending */ 171 | cachedGroupMetadata?: (jid: string) => Promise 172 | } 173 | 174 | export type MessageRelayOptions = MinimalRelayOptions & { 175 | /** only send to a specific participant; used when a message decryption fails for a single user */ 176 | participant?: { jid: string, count: number } 177 | /** additional attributes to add to the WA binary node */ 178 | additionalAttributes?: { [_: string]: string } 179 | /** should we use the devices cache, or fetch afresh from the server; default assumed to be "true" */ 180 | useUserDevicesCache?: boolean 181 | } 182 | 183 | export type MiscMessageGenerationOptions = MinimalRelayOptions & { 184 | /** optional, if you want to manually set the timestamp of the message */ 185 | timestamp?: Date 186 | /** the message you want to quote */ 187 | quoted?: WAMessage 188 | /** disappearing messages settings */ 189 | ephemeralExpiration?: number | string 190 | /** timeout for media upload to WA server */ 191 | mediaUploadTimeoutMs?: number 192 | } 193 | export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions & { 194 | userJid: string 195 | } 196 | 197 | export type WAMediaUploadFunction = (readStream: Readable, opts: { fileEncSha256B64: string, mediaType: MediaType, timeoutMs?: number }) => Promise<{ mediaUrl: string, directPath: string }> 198 | 199 | export type MediaGenerationOptions = { 200 | logger?: Logger 201 | mediaTypeOverride?: MediaType 202 | upload: WAMediaUploadFunction 203 | /** cache media so it does not have to be uploaded again */ 204 | mediaCache?: NodeCache 205 | 206 | mediaUploadTimeoutMs?: number 207 | } 208 | export type MessageContentGenerationOptions = MediaGenerationOptions & { 209 | getUrlInfo?: (text: string) => Promise 210 | } 211 | export type MessageGenerationOptions = MessageContentGenerationOptions & MessageGenerationOptionsFromContent 212 | 213 | /** 214 | * Type of message upsert 215 | * 1. notify => notify the user, this message was just received 216 | * 2. append => append the message to the chat history, no notification required 217 | */ 218 | export type MessageUpsertType = 'append' | 'notify' 219 | 220 | export type MessageUserReceipt = proto.IUserReceipt 221 | 222 | export type WAMessageUpdate = { update: Partial, key: proto.IMessageKey } 223 | 224 | export type WAMessageCursor = { before: WAMessageKey | undefined } | { after: WAMessageKey | undefined } 225 | 226 | export type MessageUserReceiptUpdate = { key: proto.IMessageKey, receipt: MessageUserReceipt } 227 | 228 | export type MediaDecryptionKeyInfo = { 229 | iv: Buffer 230 | cipherKey: Buffer 231 | macKey?: Buffer 232 | } 233 | 234 | export type MinimalMessage = Pick 235 | -------------------------------------------------------------------------------- /src/Socket/groups.ts: -------------------------------------------------------------------------------- 1 | import { proto } from '../../WAProto' 2 | import { GroupMetadata, ParticipantAction, SocketConfig, WAMessageKey, WAMessageStubType } from '../Types' 3 | import { generateMessageID, unixTimestampSeconds } from '../Utils' 4 | import { BinaryNode, getBinaryNodeChild, getBinaryNodeChildren, getBinaryNodeChildString, jidEncode, jidNormalizedUser } from '../WABinary' 5 | import { makeChatsSocket } from './chats' 6 | 7 | export const makeGroupsSocket = (config: SocketConfig) => { 8 | const sock = makeChatsSocket(config) 9 | const { authState, ev, query, upsertMessage } = sock 10 | 11 | const groupQuery = async(jid: string, type: 'get' | 'set', content: BinaryNode[]) => ( 12 | query({ 13 | tag: 'iq', 14 | attrs: { 15 | type, 16 | xmlns: 'w:g2', 17 | to: jid, 18 | }, 19 | content 20 | }) 21 | ) 22 | 23 | const groupMetadata = async(jid: string) => { 24 | const result = await groupQuery( 25 | jid, 26 | 'get', 27 | [ { tag: 'query', attrs: { request: 'interactive' } } ] 28 | ) 29 | return extractGroupMetadata(result) 30 | } 31 | 32 | return { 33 | ...sock, 34 | groupMetadata, 35 | groupCreate: async(subject: string, participants: string[]) => { 36 | const key = generateMessageID() 37 | const result = await groupQuery( 38 | '@g.us', 39 | 'set', 40 | [ 41 | { 42 | tag: 'create', 43 | attrs: { 44 | subject, 45 | key 46 | }, 47 | content: participants.map(jid => ({ 48 | tag: 'participant', 49 | attrs: { jid } 50 | })) 51 | } 52 | ] 53 | ) 54 | return extractGroupMetadata(result) 55 | }, 56 | groupLeave: async(id: string) => { 57 | await groupQuery( 58 | '@g.us', 59 | 'set', 60 | [ 61 | { 62 | tag: 'leave', 63 | attrs: { }, 64 | content: [ 65 | { tag: 'group', attrs: { id } } 66 | ] 67 | } 68 | ] 69 | ) 70 | }, 71 | groupUpdateSubject: async(jid: string, subject: string) => { 72 | await groupQuery( 73 | jid, 74 | 'set', 75 | [ 76 | { 77 | tag: 'subject', 78 | attrs: { }, 79 | content: Buffer.from(subject, 'utf-8') 80 | } 81 | ] 82 | ) 83 | }, 84 | groupParticipantsUpdate: async( 85 | jid: string, 86 | participants: string[], 87 | action: ParticipantAction 88 | ) => { 89 | const result = await groupQuery( 90 | jid, 91 | 'set', 92 | [ 93 | { 94 | tag: action, 95 | attrs: { }, 96 | content: participants.map(jid => ({ 97 | tag: 'participant', 98 | attrs: { jid } 99 | })) 100 | } 101 | ] 102 | ) 103 | const node = getBinaryNodeChild(result, action) 104 | const participantsAffected = getBinaryNodeChildren(node!, 'participant') 105 | return participantsAffected.map(p => { 106 | return { status: p.attrs.error || '200', jid: p.attrs.jid } 107 | }) 108 | }, 109 | groupUpdateDescription: async(jid: string, description?: string) => { 110 | const metadata = await groupMetadata(jid) 111 | const prev = metadata.descId ?? null 112 | 113 | await groupQuery( 114 | jid, 115 | 'set', 116 | [ 117 | { 118 | tag: 'description', 119 | attrs: { 120 | ...(description ? { id: generateMessageID() } : { delete: 'true' }), 121 | ...(prev ? { prev } : {}) 122 | }, 123 | content: description ? [ 124 | { tag: 'body', attrs: {}, content: Buffer.from(description, 'utf-8') } 125 | ] : undefined 126 | } 127 | ] 128 | ) 129 | }, 130 | groupInviteCode: async(jid: string) => { 131 | const result = await groupQuery(jid, 'get', [{ tag: 'invite', attrs: {} }]) 132 | const inviteNode = getBinaryNodeChild(result, 'invite') 133 | return inviteNode?.attrs.code 134 | }, 135 | groupRevokeInvite: async(jid: string) => { 136 | const result = await groupQuery(jid, 'set', [{ tag: 'invite', attrs: {} }]) 137 | const inviteNode = getBinaryNodeChild(result, 'invite') 138 | return inviteNode?.attrs.code 139 | }, 140 | groupAcceptInvite: async(code: string) => { 141 | const results = await groupQuery('@g.us', 'set', [{ tag: 'invite', attrs: { code } }]) 142 | const result = getBinaryNodeChild(results, 'group') 143 | return result?.attrs.jid 144 | }, 145 | /** 146 | * accept a GroupInviteMessage 147 | * @param key the key of the invite message, or optionally only provide the jid of the person who sent the invite 148 | * @param inviteMessage the message to accept 149 | */ 150 | groupAcceptInviteV4: ev.createBufferedFunction(async(key: string | WAMessageKey, inviteMessage: proto.Message.IGroupInviteMessage) => { 151 | key = typeof key === 'string' ? { remoteJid: key } : key 152 | const results = await groupQuery(inviteMessage.groupJid!, 'set', [{ 153 | tag: 'accept', 154 | attrs: { 155 | code: inviteMessage.inviteCode!, 156 | expiration: inviteMessage.inviteExpiration!.toString(), 157 | admin: key.remoteJid! 158 | } 159 | }]) 160 | 161 | // if we have the full message key 162 | // update the invite message to be expired 163 | if(key.id) { 164 | // create new invite message that is expired 165 | inviteMessage = proto.Message.GroupInviteMessage.fromObject(inviteMessage) 166 | inviteMessage.inviteExpiration = 0 167 | inviteMessage.inviteCode = '' 168 | ev.emit('messages.update', [ 169 | { 170 | key, 171 | update: { 172 | message: { 173 | groupInviteMessage: inviteMessage 174 | } 175 | } 176 | } 177 | ]) 178 | } 179 | 180 | // generate the group add message 181 | await upsertMessage( 182 | { 183 | key: { 184 | remoteJid: inviteMessage.groupJid, 185 | id: generateMessageID(), 186 | fromMe: false, 187 | participant: key.remoteJid, 188 | }, 189 | messageStubType: WAMessageStubType.GROUP_PARTICIPANT_ADD, 190 | messageStubParameters: [ 191 | authState.creds.me!.id 192 | ], 193 | participant: key.remoteJid, 194 | messageTimestamp: unixTimestampSeconds() 195 | }, 196 | 'notify' 197 | ) 198 | 199 | return results.attrs.from 200 | }), 201 | groupGetInviteInfo: async(code: string) => { 202 | const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }]) 203 | return extractGroupMetadata(results) 204 | }, 205 | groupToggleEphemeral: async(jid: string, ephemeralExpiration: number) => { 206 | const content: BinaryNode = ephemeralExpiration ? 207 | { tag: 'ephemeral', attrs: { expiration: ephemeralExpiration.toString() } } : 208 | { tag: 'not_ephemeral', attrs: { } } 209 | await groupQuery(jid, 'set', [content]) 210 | }, 211 | groupSettingUpdate: async(jid: string, setting: 'announcement' | 'not_announcement' | 'locked' | 'unlocked') => { 212 | await groupQuery(jid, 'set', [ { tag: setting, attrs: { } } ]) 213 | }, 214 | groupFetchAllParticipating: async() => { 215 | const result = await query({ 216 | tag: 'iq', 217 | attrs: { 218 | to: '@g.us', 219 | xmlns: 'w:g2', 220 | type: 'get', 221 | }, 222 | content: [ 223 | { 224 | tag: 'participating', 225 | attrs: { }, 226 | content: [ 227 | { tag: 'participants', attrs: { } }, 228 | { tag: 'description', attrs: { } } 229 | ] 230 | } 231 | ] 232 | }) 233 | const data: { [_: string]: GroupMetadata } = { } 234 | const groupsChild = getBinaryNodeChild(result, 'groups') 235 | if(groupsChild) { 236 | const groups = getBinaryNodeChildren(groupsChild, 'group') 237 | for(const groupNode of groups) { 238 | const meta = extractGroupMetadata({ 239 | tag: 'result', 240 | attrs: { }, 241 | content: [groupNode] 242 | }) 243 | data[meta.id] = meta 244 | } 245 | } 246 | 247 | return data 248 | } 249 | } 250 | } 251 | 252 | 253 | export const extractGroupMetadata = (result: BinaryNode) => { 254 | const group = getBinaryNodeChild(result, 'group')! 255 | const descChild = getBinaryNodeChild(group, 'description') 256 | let desc: string | undefined 257 | let descId: string | undefined 258 | if(descChild) { 259 | desc = getBinaryNodeChildString(descChild, 'body') 260 | descId = descChild.attrs.id 261 | } 262 | 263 | const groupId = group.attrs.id.includes('@') ? group.attrs.id : jidEncode(group.attrs.id, 'g.us') 264 | const eph = getBinaryNodeChild(group, 'ephemeral')?.attrs.expiration 265 | const metadata: GroupMetadata = { 266 | id: groupId, 267 | subject: group.attrs.subject, 268 | subjectOwner: group.attrs.s_o, 269 | subjectTime: +group.attrs.s_t, 270 | size: +group.attrs.size, 271 | creation: +group.attrs.creation, 272 | owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined, 273 | desc, 274 | descId, 275 | restrict: !!getBinaryNodeChild(group, 'locked'), 276 | announce: !!getBinaryNodeChild(group, 'announcement'), 277 | participants: getBinaryNodeChildren(group, 'participant').map( 278 | ({ attrs }) => { 279 | return { 280 | id: attrs.jid, 281 | admin: attrs.type || null as any, 282 | } 283 | } 284 | ), 285 | ephemeralDuration: eph ? +eph : undefined 286 | } 287 | return metadata 288 | } 289 | -------------------------------------------------------------------------------- /src/Tests/test.event-buffer.ts: -------------------------------------------------------------------------------- 1 | import { proto } from '../../WAProto' 2 | import { Chat, WAMessageKey, WAMessageStatus, WAMessageStubType, WAMessageUpdate } from '../Types' 3 | import { delay, generateMessageID, makeEventBuffer, toNumber, unixTimestampSeconds } from '../Utils' 4 | import logger from '../Utils/logger' 5 | import { randomJid } from './utils' 6 | 7 | describe('Event Buffer Tests', () => { 8 | 9 | let ev: ReturnType 10 | beforeEach(() => { 11 | const _logger = logger.child({ }) 12 | _logger.level = 'trace' 13 | ev = makeEventBuffer(_logger) 14 | }) 15 | 16 | it('should buffer a chat upsert & update event', async() => { 17 | const chatId = randomJid() 18 | 19 | const chats: Chat[] = [] 20 | 21 | ev.on('chats.upsert', c => chats.push(...c)) 22 | ev.on('chats.update', () => fail('should not emit update event')) 23 | 24 | ev.buffer() 25 | await Promise.all([ 26 | (async() => { 27 | ev.buffer() 28 | await delay(100) 29 | ev.emit('chats.upsert', [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }]) 30 | const flushed = ev.flush() 31 | expect(flushed).toBeFalsy() 32 | })(), 33 | (async() => { 34 | ev.buffer() 35 | await delay(200) 36 | ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 124, unreadCount: 1 }]) 37 | const flushed = ev.flush() 38 | expect(flushed).toBeFalsy() 39 | })() 40 | ]) 41 | 42 | const flushed = ev.flush() 43 | expect(flushed).toBeTruthy() 44 | 45 | expect(chats).toHaveLength(1) 46 | expect(chats[0].conversationTimestamp).toEqual(124) 47 | expect(chats[0].unreadCount).toEqual(2) 48 | }) 49 | 50 | it('should overwrite a chats.delete event', async() => { 51 | const chatId = randomJid() 52 | const chats: Partial[] = [] 53 | 54 | ev.on('chats.update', c => chats.push(...c)) 55 | ev.on('chats.delete', () => fail('not should have emitted')) 56 | 57 | ev.buffer() 58 | 59 | ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }]) 60 | ev.emit('chats.delete', [chatId]) 61 | ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 124, unreadCount: 1 }]) 62 | 63 | ev.flush() 64 | 65 | expect(chats).toHaveLength(1) 66 | }) 67 | 68 | it('should overwrite a chats.update event', async() => { 69 | const chatId = randomJid() 70 | const chatsDeleted: string[] = [] 71 | 72 | ev.on('chats.delete', c => chatsDeleted.push(...c)) 73 | ev.on('chats.update', () => fail('not should have emitted')) 74 | 75 | ev.buffer() 76 | 77 | ev.emit('chats.update', [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }]) 78 | ev.emit('chats.delete', [chatId]) 79 | 80 | ev.flush() 81 | 82 | expect(chatsDeleted).toHaveLength(1) 83 | }) 84 | 85 | it('should release a conditional update at the right time', async() => { 86 | const chatId = randomJid() 87 | const chatId2 = randomJid() 88 | const chatsUpserted: Chat[] = [] 89 | const chatsSynced: Chat[] = [] 90 | 91 | ev.on('chats.upsert', c => chatsUpserted.push(...c)) 92 | ev.on('messaging-history.set', c => chatsSynced.push(...c.chats)) 93 | ev.on('chats.update', () => fail('not should have emitted')) 94 | 95 | ev.buffer() 96 | ev.emit('chats.update', [{ 97 | id: chatId, 98 | archived: true, 99 | conditional(buff) { 100 | if(buff.chatUpserts[chatId]) { 101 | return true 102 | } 103 | } 104 | }]) 105 | ev.emit('chats.update', [{ 106 | id: chatId2, 107 | archived: true, 108 | conditional(buff) { 109 | if(buff.historySets.chats[chatId2]) { 110 | return true 111 | } 112 | } 113 | }]) 114 | 115 | ev.flush() 116 | 117 | ev.buffer() 118 | ev.emit('chats.upsert', [{ 119 | id: chatId, 120 | conversationTimestamp: 123, 121 | unreadCount: 1, 122 | muteEndTime: 123 123 | }]) 124 | ev.emit('messaging-history.set', { 125 | chats: [{ 126 | id: chatId2, 127 | conversationTimestamp: 123, 128 | unreadCount: 1, 129 | muteEndTime: 123 130 | }], 131 | contacts: [], 132 | messages: [], 133 | isLatest: false 134 | }) 135 | ev.flush() 136 | 137 | expect(chatsUpserted).toHaveLength(1) 138 | expect(chatsUpserted[0].id).toEqual(chatId) 139 | expect(chatsUpserted[0].archived).toEqual(true) 140 | expect(chatsUpserted[0].muteEndTime).toEqual(123) 141 | 142 | expect(chatsSynced).toHaveLength(1) 143 | expect(chatsSynced[0].id).toEqual(chatId2) 144 | expect(chatsSynced[0].archived).toEqual(true) 145 | }) 146 | 147 | it('should discard a conditional update', async() => { 148 | const chatId = randomJid() 149 | const chatsUpserted: Chat[] = [] 150 | 151 | ev.on('chats.upsert', c => chatsUpserted.push(...c)) 152 | ev.on('chats.update', () => fail('not should have emitted')) 153 | 154 | ev.buffer() 155 | ev.emit('chats.update', [{ 156 | id: chatId, 157 | archived: true, 158 | conditional(buff) { 159 | if(buff.chatUpserts[chatId]) { 160 | return false 161 | } 162 | } 163 | }]) 164 | ev.emit('chats.upsert', [{ 165 | id: chatId, 166 | conversationTimestamp: 123, 167 | unreadCount: 1, 168 | muteEndTime: 123 169 | }]) 170 | 171 | ev.flush() 172 | 173 | expect(chatsUpserted).toHaveLength(1) 174 | expect(chatsUpserted[0].archived).toBeUndefined() 175 | }) 176 | 177 | it('should overwrite a chats.update event with a history event', async() => { 178 | const chatId = randomJid() 179 | let chatRecv: Chat | undefined 180 | 181 | ev.on('messaging-history.set', ({ chats }) => { 182 | chatRecv = chats[0] 183 | }) 184 | ev.on('chats.update', () => fail('not should have emitted')) 185 | 186 | ev.buffer() 187 | 188 | ev.emit('messaging-history.set', { 189 | chats: [{ id: chatId, conversationTimestamp: 123, unreadCount: 1 }], 190 | messages: [], 191 | contacts: [], 192 | isLatest: true 193 | }) 194 | ev.emit('chats.update', [{ id: chatId, archived: true }]) 195 | 196 | ev.flush() 197 | 198 | expect(chatRecv).toBeDefined() 199 | expect(chatRecv?.archived).toBeTruthy() 200 | }) 201 | 202 | it('should buffer message upsert events', async() => { 203 | const messageTimestamp = unixTimestampSeconds() 204 | const msg: proto.IWebMessageInfo = { 205 | key: { 206 | remoteJid: randomJid(), 207 | id: generateMessageID(), 208 | fromMe: false 209 | }, 210 | messageStubType: WAMessageStubType.CIPHERTEXT, 211 | messageTimestamp 212 | } 213 | 214 | const msgs: proto.IWebMessageInfo[] = [] 215 | 216 | ev.on('messages.upsert', c => { 217 | msgs.push(...c.messages) 218 | expect(c.type).toEqual('notify') 219 | }) 220 | 221 | ev.buffer() 222 | ev.emit('messages.upsert', { messages: [proto.WebMessageInfo.fromObject(msg)], type: 'notify' }) 223 | 224 | msg.messageTimestamp = unixTimestampSeconds() + 1 225 | msg.messageStubType = undefined 226 | msg.message = { conversation: 'Test' } 227 | ev.emit('messages.upsert', { messages: [proto.WebMessageInfo.fromObject(msg)], type: 'notify' }) 228 | ev.emit('messages.update', [{ key: msg.key, update: { status: WAMessageStatus.READ } }]) 229 | 230 | ev.flush() 231 | 232 | expect(msgs).toHaveLength(1) 233 | expect(msgs[0].message).toBeTruthy() 234 | expect(toNumber(msgs[0].messageTimestamp!)).toEqual(messageTimestamp) 235 | expect(msgs[0].status).toEqual(WAMessageStatus.READ) 236 | }) 237 | 238 | it('should buffer a message receipt update', async() => { 239 | const msg: proto.IWebMessageInfo = { 240 | key: { 241 | remoteJid: randomJid(), 242 | id: generateMessageID(), 243 | fromMe: false 244 | }, 245 | messageStubType: WAMessageStubType.CIPHERTEXT, 246 | messageTimestamp: unixTimestampSeconds() 247 | } 248 | 249 | const msgs: proto.IWebMessageInfo[] = [] 250 | 251 | ev.on('messages.upsert', c => msgs.push(...c.messages)) 252 | ev.on('message-receipt.update', () => fail('should not emit')) 253 | 254 | ev.buffer() 255 | ev.emit('messages.upsert', { messages: [proto.WebMessageInfo.fromObject(msg)], type: 'notify' }) 256 | ev.emit('message-receipt.update', [ 257 | { 258 | key: msg.key, 259 | receipt: { 260 | userJid: randomJid(), 261 | readTimestamp: unixTimestampSeconds() 262 | } 263 | } 264 | ]) 265 | 266 | ev.flush() 267 | 268 | expect(msgs).toHaveLength(1) 269 | expect(msgs[0].userReceipt).toHaveLength(1) 270 | }) 271 | 272 | it('should buffer multiple status updates', async() => { 273 | const key: WAMessageKey = { 274 | remoteJid: randomJid(), 275 | id: generateMessageID(), 276 | fromMe: false 277 | } 278 | 279 | const msgs: WAMessageUpdate[] = [] 280 | 281 | ev.on('messages.update', c => msgs.push(...c)) 282 | 283 | ev.buffer() 284 | ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.DELIVERY_ACK } }]) 285 | ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.READ } }]) 286 | 287 | ev.flush() 288 | 289 | expect(msgs).toHaveLength(1) 290 | expect(msgs[0].update.status).toEqual(WAMessageStatus.READ) 291 | }) 292 | 293 | it('should remove chat unread counter', async() => { 294 | const msg: proto.IWebMessageInfo = { 295 | key: { 296 | remoteJid: '12345@s.whatsapp.net', 297 | id: generateMessageID(), 298 | fromMe: false 299 | }, 300 | message: { 301 | conversation: 'abcd' 302 | }, 303 | messageTimestamp: unixTimestampSeconds() 304 | } 305 | 306 | const chats: Partial[] = [] 307 | 308 | ev.on('chats.update', c => chats.push(...c)) 309 | 310 | ev.buffer() 311 | ev.emit('messages.upsert', { messages: [proto.WebMessageInfo.fromObject(msg)], type: 'notify' }) 312 | ev.emit('chats.update', [{ id: msg.key.remoteJid!, unreadCount: 1, conversationTimestamp: msg.messageTimestamp }]) 313 | ev.emit('messages.update', [{ key: msg.key, update: { status: WAMessageStatus.READ } }]) 314 | 315 | ev.flush() 316 | 317 | expect(chats[0].unreadCount).toBeUndefined() 318 | }) 319 | }) -------------------------------------------------------------------------------- /src/Utils/process-message.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios' 2 | import type { Logger } from 'pino' 3 | import { proto } from '../../WAProto' 4 | import { AuthenticationCreds, BaileysEventEmitter, Chat, GroupMetadata, ParticipantAction, SignalKeyStoreWithTransaction, WAMessageStubType } from '../Types' 5 | import { downloadAndProcessHistorySyncNotification, getContentType, normalizeMessageContent, toNumber } from '../Utils' 6 | import { areJidsSameUser, jidNormalizedUser } from '../WABinary' 7 | 8 | type ProcessMessageContext = { 9 | shouldProcessHistoryMsg: boolean 10 | creds: AuthenticationCreds 11 | keyStore: SignalKeyStoreWithTransaction 12 | ev: BaileysEventEmitter 13 | logger?: Logger 14 | options: AxiosRequestConfig 15 | } 16 | 17 | const REAL_MSG_STUB_TYPES = new Set([ 18 | WAMessageStubType.CALL_MISSED_GROUP_VIDEO, 19 | WAMessageStubType.CALL_MISSED_GROUP_VOICE, 20 | WAMessageStubType.CALL_MISSED_VIDEO, 21 | WAMessageStubType.CALL_MISSED_VOICE 22 | ]) 23 | 24 | const REAL_MSG_REQ_ME_STUB_TYPES = new Set([ 25 | WAMessageStubType.GROUP_PARTICIPANT_ADD 26 | ]) 27 | 28 | /** Cleans a received message to further processing */ 29 | export const cleanMessage = (message: proto.IWebMessageInfo, meId: string) => { 30 | // ensure remoteJid and participant doesn't have device or agent in it 31 | message.key.remoteJid = jidNormalizedUser(message.key.remoteJid!) 32 | message.key.participant = message.key.participant ? jidNormalizedUser(message.key.participant!) : undefined 33 | const content = normalizeMessageContent(message.message) 34 | // if the message has a reaction, ensure fromMe & remoteJid are from our perspective 35 | if(content?.reactionMessage) { 36 | const msgKey = content.reactionMessage.key! 37 | // if the reaction is from another user 38 | // we've to correctly map the key to this user's perspective 39 | if(!message.key.fromMe) { 40 | // if the sender believed the message being reacted to is not from them 41 | // we've to correct the key to be from them, or some other participant 42 | msgKey.fromMe = !msgKey.fromMe 43 | ? areJidsSameUser(msgKey.participant || msgKey.remoteJid!, meId) 44 | // if the message being reacted to, was from them 45 | // fromMe automatically becomes false 46 | : false 47 | // set the remoteJid to being the same as the chat the message came from 48 | msgKey.remoteJid = message.key.remoteJid 49 | // set participant of the message 50 | msgKey.participant = msgKey.participant || message.key.participant 51 | } 52 | } 53 | } 54 | 55 | export const isRealMessage = (message: proto.IWebMessageInfo, meId: string) => { 56 | const normalizedContent = normalizeMessageContent(message.message) 57 | const hasSomeContent = !!getContentType(normalizedContent) 58 | return ( 59 | !!normalizedContent 60 | || REAL_MSG_STUB_TYPES.has(message.messageStubType!) 61 | || ( 62 | REAL_MSG_REQ_ME_STUB_TYPES.has(message.messageStubType!) 63 | && message.messageStubParameters?.some(p => areJidsSameUser(meId, p)) 64 | ) 65 | ) 66 | && hasSomeContent 67 | && !normalizedContent?.protocolMessage 68 | && !normalizedContent?.reactionMessage 69 | } 70 | 71 | export const shouldIncrementChatUnread = (message: proto.IWebMessageInfo) => ( 72 | !message.key.fromMe && !message.messageStubType 73 | ) 74 | 75 | const processMessage = async( 76 | message: proto.IWebMessageInfo, 77 | { 78 | shouldProcessHistoryMsg, 79 | ev, 80 | creds, 81 | keyStore, 82 | logger, 83 | options 84 | }: ProcessMessageContext 85 | ) => { 86 | const meId = creds.me!.id 87 | const { accountSettings } = creds 88 | 89 | const chat: Partial = { id: jidNormalizedUser(message.key.remoteJid!) } 90 | const isRealMsg = isRealMessage(message, meId) 91 | 92 | if(isRealMsg) { 93 | chat.conversationTimestamp = toNumber(message.messageTimestamp) 94 | // only increment unread count if not CIPHERTEXT and from another person 95 | if(shouldIncrementChatUnread(message)) { 96 | chat.unreadCount = (chat.unreadCount || 0) + 1 97 | } 98 | } 99 | 100 | const content = normalizeMessageContent(message.message) 101 | 102 | // unarchive chat if it's a real message, or someone reacted to our message 103 | // and we've the unarchive chats setting on 104 | if( 105 | (isRealMsg || content?.reactionMessage?.key?.fromMe) 106 | && accountSettings?.unarchiveChats 107 | ) { 108 | chat.archived = false 109 | chat.readOnly = false 110 | } 111 | 112 | const protocolMsg = content?.protocolMessage 113 | if(protocolMsg) { 114 | switch (protocolMsg.type) { 115 | case proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION: 116 | const histNotification = protocolMsg!.historySyncNotification! 117 | const process = shouldProcessHistoryMsg 118 | const isLatest = !creds.processedHistoryMessages?.length 119 | 120 | logger?.info({ 121 | histNotification, 122 | process, 123 | id: message.key.id, 124 | isLatest, 125 | }, 'got history notification') 126 | 127 | if(process) { 128 | ev.emit('creds.update', { 129 | processedHistoryMessages: [ 130 | ...(creds.processedHistoryMessages || []), 131 | { key: message.key, messageTimestamp: message.messageTimestamp } 132 | ] 133 | }) 134 | 135 | const data = await downloadAndProcessHistorySyncNotification( 136 | histNotification, 137 | options 138 | ) 139 | 140 | ev.emit('messaging-history.set', { ...data, isLatest }) 141 | } 142 | 143 | break 144 | case proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_SHARE: 145 | const keys = protocolMsg.appStateSyncKeyShare!.keys 146 | if(keys?.length) { 147 | let newAppStateSyncKeyId = '' 148 | await keyStore.transaction( 149 | async() => { 150 | const newKeys: string[] = [] 151 | for(const { keyData, keyId } of keys) { 152 | const strKeyId = Buffer.from(keyId!.keyId!).toString('base64') 153 | newKeys.push(strKeyId) 154 | 155 | await keyStore.set({ 'app-state-sync-key': { [strKeyId]: keyData! } }) 156 | 157 | newAppStateSyncKeyId = strKeyId 158 | } 159 | 160 | logger?.info( 161 | { newAppStateSyncKeyId, newKeys }, 162 | 'injecting new app state sync keys' 163 | ) 164 | } 165 | ) 166 | 167 | ev.emit('creds.update', { myAppStateKeyId: newAppStateSyncKeyId }) 168 | } else { 169 | logger?.info({ protocolMsg }, 'recv app state sync with 0 keys') 170 | } 171 | 172 | break 173 | case proto.Message.ProtocolMessage.Type.REVOKE: 174 | ev.emit('messages.update', [ 175 | { 176 | key: { 177 | ...message.key, 178 | id: protocolMsg.key!.id 179 | }, 180 | update: { message: null, messageStubType: WAMessageStubType.REVOKE, key: message.key } 181 | } 182 | ]) 183 | break 184 | case proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING: 185 | Object.assign(chat, { 186 | ephemeralSettingTimestamp: toNumber(message.messageTimestamp), 187 | ephemeralExpiration: protocolMsg.ephemeralExpiration || null 188 | }) 189 | break 190 | } 191 | } else if(content?.reactionMessage) { 192 | const reaction: proto.IReaction = { 193 | ...content.reactionMessage, 194 | key: message.key, 195 | } 196 | ev.emit('messages.reaction', [{ 197 | reaction, 198 | key: content.reactionMessage!.key!, 199 | }]) 200 | } else if(message.messageStubType) { 201 | const jid = message.key!.remoteJid! 202 | //let actor = whatsappID (message.participant) 203 | let participants: string[] 204 | const emitParticipantsUpdate = (action: ParticipantAction) => ( 205 | ev.emit('group-participants.update', { id: jid, participants, action }) 206 | ) 207 | const emitGroupUpdate = (update: Partial) => { 208 | ev.emit('groups.update', [{ id: jid, ...update }]) 209 | } 210 | 211 | const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid)) 212 | 213 | switch (message.messageStubType) { 214 | case WAMessageStubType.GROUP_PARTICIPANT_LEAVE: 215 | case WAMessageStubType.GROUP_PARTICIPANT_REMOVE: 216 | participants = message.messageStubParameters || [] 217 | emitParticipantsUpdate('remove') 218 | // mark the chat read only if you left the group 219 | if(participantsIncludesMe()) { 220 | chat.readOnly = true 221 | } 222 | 223 | break 224 | case WAMessageStubType.GROUP_PARTICIPANT_ADD: 225 | case WAMessageStubType.GROUP_PARTICIPANT_INVITE: 226 | case WAMessageStubType.GROUP_PARTICIPANT_ADD_REQUEST_JOIN: 227 | participants = message.messageStubParameters || [] 228 | if(participantsIncludesMe()) { 229 | chat.readOnly = false 230 | } 231 | 232 | emitParticipantsUpdate('add') 233 | break 234 | case WAMessageStubType.GROUP_PARTICIPANT_DEMOTE: 235 | participants = message.messageStubParameters || [] 236 | emitParticipantsUpdate('demote') 237 | break 238 | case WAMessageStubType.GROUP_PARTICIPANT_PROMOTE: 239 | participants = message.messageStubParameters || [] 240 | emitParticipantsUpdate('promote') 241 | break 242 | case WAMessageStubType.GROUP_CHANGE_ANNOUNCE: 243 | const announceValue = message.messageStubParameters?.[0] 244 | emitGroupUpdate({ announce: announceValue === 'true' || announceValue === 'on' }) 245 | break 246 | case WAMessageStubType.GROUP_CHANGE_RESTRICT: 247 | const restrictValue = message.messageStubParameters?.[0] 248 | emitGroupUpdate({ restrict: restrictValue === 'true' || restrictValue === 'on' }) 249 | break 250 | case WAMessageStubType.GROUP_CHANGE_SUBJECT: 251 | const name = message.messageStubParameters?.[0] 252 | chat.name = name 253 | emitGroupUpdate({ subject: name }) 254 | break 255 | case WAMessageStubType.GROUP_CHANGE_INVITE_LINK: 256 | const code = message.messageStubParameters?.[0] 257 | emitGroupUpdate({ inviteCode: code }) 258 | break 259 | } 260 | } 261 | 262 | if(Object.keys(chat).length > 1) { 263 | ev.emit('chats.update', [chat]) 264 | } 265 | } 266 | 267 | export default processMessage -------------------------------------------------------------------------------- /src/Utils/signal.ts: -------------------------------------------------------------------------------- 1 | import * as libsignal from 'libsignal' 2 | import { proto } from '../../WAProto' 3 | import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage, SenderKeyName, SenderKeyRecord } from '../../WASignalGroup' 4 | import { KEY_BUNDLE_TYPE } from '../Defaults' 5 | import { AuthenticationCreds, AuthenticationState, KeyPair, SignalAuthState, SignalIdentity, SignalKeyStore, SignedKeyPair } from '../Types/Auth' 6 | import { assertNodeErrorFree, BinaryNode, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildUInt, jidDecode, JidWithDevice, S_WHATSAPP_NET } from '../WABinary' 7 | import { Curve, generateSignalPubKey } from './crypto' 8 | import { encodeBigEndian } from './generics' 9 | 10 | const jidToSignalAddress = (jid: string) => jid.split('@')[0] 11 | 12 | export const jidToSignalProtocolAddress = (jid: string) => { 13 | return new libsignal.ProtocolAddress(jidToSignalAddress(jid), 0) 14 | } 15 | 16 | export const jidToSignalSenderKeyName = (group: string, user: string): string => { 17 | return new SenderKeyName(group, jidToSignalProtocolAddress(user)).toString() 18 | } 19 | 20 | export const createSignalIdentity = ( 21 | wid: string, 22 | accountSignatureKey: Uint8Array 23 | ): SignalIdentity => { 24 | return { 25 | identifier: { name: wid, deviceId: 0 }, 26 | identifierKey: generateSignalPubKey(accountSignatureKey) 27 | } 28 | } 29 | 30 | export const getPreKeys = async({ get }: SignalKeyStore, min: number, limit: number) => { 31 | const idList: string[] = [] 32 | for(let id = min; id < limit;id++) { 33 | idList.push(id.toString()) 34 | } 35 | 36 | return get('pre-key', idList) 37 | } 38 | 39 | export const generateOrGetPreKeys = (creds: AuthenticationCreds, range: number) => { 40 | const avaliable = creds.nextPreKeyId - creds.firstUnuploadedPreKeyId 41 | const remaining = range - avaliable 42 | const lastPreKeyId = creds.nextPreKeyId + remaining - 1 43 | const newPreKeys: { [id: number]: KeyPair } = { } 44 | if(remaining > 0) { 45 | for(let i = creds.nextPreKeyId;i <= lastPreKeyId;i++) { 46 | newPreKeys[i] = Curve.generateKeyPair() 47 | } 48 | } 49 | 50 | return { 51 | newPreKeys, 52 | lastPreKeyId, 53 | preKeysRange: [creds.firstUnuploadedPreKeyId, range] as const, 54 | } 55 | } 56 | 57 | export const xmppSignedPreKey = (key: SignedKeyPair): BinaryNode => ( 58 | { 59 | tag: 'skey', 60 | attrs: { }, 61 | content: [ 62 | { tag: 'id', attrs: { }, content: encodeBigEndian(key.keyId, 3) }, 63 | { tag: 'value', attrs: { }, content: key.keyPair.public }, 64 | { tag: 'signature', attrs: { }, content: key.signature } 65 | ] 66 | } 67 | ) 68 | 69 | export const xmppPreKey = (pair: KeyPair, id: number): BinaryNode => ( 70 | { 71 | tag: 'key', 72 | attrs: { }, 73 | content: [ 74 | { tag: 'id', attrs: { }, content: encodeBigEndian(id, 3) }, 75 | { tag: 'value', attrs: { }, content: pair.public } 76 | ] 77 | } 78 | ) 79 | 80 | export const signalStorage = ({ creds, keys }: SignalAuthState) => ({ 81 | loadSession: async(id: string) => { 82 | const { [id]: sess } = await keys.get('session', [id]) 83 | if(sess) { 84 | return libsignal.SessionRecord.deserialize(sess) 85 | } 86 | }, 87 | storeSession: async(id, session) => { 88 | await keys.set({ 'session': { [id]: session.serialize() } }) 89 | }, 90 | isTrustedIdentity: () => { 91 | return true 92 | }, 93 | loadPreKey: async(id: number | string) => { 94 | const keyId = id.toString() 95 | const { [keyId]: key } = await keys.get('pre-key', [keyId]) 96 | if(key) { 97 | return { 98 | privKey: Buffer.from(key.private), 99 | pubKey: Buffer.from(key.public) 100 | } 101 | } 102 | }, 103 | removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }), 104 | loadSignedPreKey: () => { 105 | const key = creds.signedPreKey 106 | return { 107 | privKey: Buffer.from(key.keyPair.private), 108 | pubKey: Buffer.from(key.keyPair.public) 109 | } 110 | }, 111 | loadSenderKey: async(keyId: string) => { 112 | const { [keyId]: key } = await keys.get('sender-key', [keyId]) 113 | if(key) { 114 | return new SenderKeyRecord(key) 115 | } 116 | }, 117 | storeSenderKey: async(keyId, key) => { 118 | await keys.set({ 'sender-key': { [keyId]: key.serialize() } }) 119 | }, 120 | getOurRegistrationId: () => ( 121 | creds.registrationId 122 | ), 123 | getOurIdentity: () => { 124 | const { signedIdentityKey } = creds 125 | return { 126 | privKey: Buffer.from(signedIdentityKey.private), 127 | pubKey: generateSignalPubKey(signedIdentityKey.public), 128 | } 129 | } 130 | }) 131 | 132 | export const decryptGroupSignalProto = (group: string, user: string, msg: Buffer | Uint8Array, auth: SignalAuthState) => { 133 | const senderName = jidToSignalSenderKeyName(group, user) 134 | const cipher = new GroupCipher(signalStorage(auth), senderName) 135 | 136 | return cipher.decrypt(Buffer.from(msg)) 137 | } 138 | 139 | export const processSenderKeyMessage = async( 140 | authorJid: string, 141 | item: proto.Message.ISenderKeyDistributionMessage, 142 | auth: SignalAuthState 143 | ) => { 144 | const builder = new GroupSessionBuilder(signalStorage(auth)) 145 | const senderName = jidToSignalSenderKeyName(item.groupId!, authorJid) 146 | 147 | const senderMsg = new SenderKeyDistributionMessage(null, null, null, null, item.axolotlSenderKeyDistributionMessage) 148 | const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName]) 149 | if(!senderKey) { 150 | const record = new SenderKeyRecord() 151 | await auth.keys.set({ 'sender-key': { [senderName]: record } }) 152 | } 153 | 154 | await builder.process(senderName, senderMsg) 155 | } 156 | 157 | export const decryptSignalProto = async(user: string, type: 'pkmsg' | 'msg', msg: Buffer | Uint8Array, auth: SignalAuthState) => { 158 | const addr = jidToSignalProtocolAddress(user) 159 | const session = new libsignal.SessionCipher(signalStorage(auth), addr) 160 | let result: Buffer 161 | switch (type) { 162 | case 'pkmsg': 163 | result = await session.decryptPreKeyWhisperMessage(msg) 164 | break 165 | case 'msg': 166 | result = await session.decryptWhisperMessage(msg) 167 | break 168 | } 169 | 170 | return result 171 | } 172 | 173 | 174 | export const encryptSignalProto = async(user: string, buffer: Buffer, auth: SignalAuthState) => { 175 | const addr = jidToSignalProtocolAddress(user) 176 | const cipher = new libsignal.SessionCipher(signalStorage(auth), addr) 177 | 178 | const { type: sigType, body } = await cipher.encrypt(buffer) 179 | const type = sigType === 3 ? 'pkmsg' : 'msg' 180 | return { type, ciphertext: Buffer.from(body, 'binary') } 181 | } 182 | 183 | export const encryptSenderKeyMsgSignalProto = async(group: string, data: Uint8Array | Buffer, meId: string, auth: SignalAuthState) => { 184 | const storage = signalStorage(auth) 185 | const senderName = jidToSignalSenderKeyName(group, meId) 186 | const builder = new GroupSessionBuilder(storage) 187 | 188 | const { [senderName]: senderKey } = await auth.keys.get('sender-key', [senderName]) 189 | if(!senderKey) { 190 | const record = new SenderKeyRecord() 191 | await auth.keys.set({ 'sender-key': { [senderName]: record } }) 192 | } 193 | 194 | const senderKeyDistributionMessage = await builder.create(senderName) 195 | const session = new GroupCipher(storage, senderName) 196 | return { 197 | ciphertext: await session.encrypt(data) as Uint8Array, 198 | senderKeyDistributionMessageKey: senderKeyDistributionMessage.serialize() as Buffer, 199 | } 200 | } 201 | 202 | export const parseAndInjectE2ESessions = async(node: BinaryNode, auth: SignalAuthState) => { 203 | const extractKey = (key: BinaryNode) => ( 204 | key ? ({ 205 | keyId: getBinaryNodeChildUInt(key, 'id', 3), 206 | publicKey: generateSignalPubKey(getBinaryNodeChildBuffer(key, 'value')!), 207 | signature: getBinaryNodeChildBuffer(key, 'signature'), 208 | }) : undefined 209 | ) 210 | const nodes = getBinaryNodeChildren(getBinaryNodeChild(node, 'list'), 'user') 211 | for(const node of nodes) { 212 | assertNodeErrorFree(node) 213 | } 214 | 215 | await Promise.all( 216 | nodes.map( 217 | async node => { 218 | const signedKey = getBinaryNodeChild(node, 'skey')! 219 | const key = getBinaryNodeChild(node, 'key')! 220 | const identity = getBinaryNodeChildBuffer(node, 'identity')! 221 | const jid = node.attrs.jid 222 | const registrationId = getBinaryNodeChildUInt(node, 'registration', 4) 223 | 224 | const device = { 225 | registrationId, 226 | identityKey: generateSignalPubKey(identity), 227 | signedPreKey: extractKey(signedKey), 228 | preKey: extractKey(key) 229 | } 230 | const cipher = new libsignal.SessionBuilder(signalStorage(auth), jidToSignalProtocolAddress(jid)) 231 | await cipher.initOutgoing(device) 232 | } 233 | ) 234 | ) 235 | } 236 | 237 | export const extractDeviceJids = (result: BinaryNode, myJid: string, excludeZeroDevices: boolean) => { 238 | const { user: myUser, device: myDevice } = jidDecode(myJid)! 239 | const extracted: JidWithDevice[] = [] 240 | for(const node of result.content as BinaryNode[]) { 241 | const list = getBinaryNodeChild(node, 'list')?.content 242 | if(list && Array.isArray(list)) { 243 | for(const item of list) { 244 | const { user } = jidDecode(item.attrs.jid)! 245 | const devicesNode = getBinaryNodeChild(item, 'devices') 246 | const deviceListNode = getBinaryNodeChild(devicesNode, 'device-list') 247 | if(Array.isArray(deviceListNode?.content)) { 248 | for(const { tag, attrs } of deviceListNode!.content) { 249 | const device = +attrs.id 250 | if( 251 | tag === 'device' && // ensure the "device" tag 252 | (!excludeZeroDevices || device !== 0) && // if zero devices are not-excluded, or device is non zero 253 | (myUser !== user || myDevice !== device) && // either different user or if me user, not this device 254 | (device === 0 || !!attrs['key-index']) // ensure that "key-index" is specified for "non-zero" devices, produces a bad req otherwise 255 | ) { 256 | extracted.push({ user, device }) 257 | } 258 | } 259 | } 260 | } 261 | } 262 | } 263 | 264 | return extracted 265 | } 266 | 267 | /** 268 | * get the next N keys for upload or processing 269 | * @param count number of pre-keys to get or generate 270 | */ 271 | export const getNextPreKeys = async({ creds, keys }: AuthenticationState, count: number) => { 272 | const { newPreKeys, lastPreKeyId, preKeysRange } = generateOrGetPreKeys(creds, count) 273 | 274 | const update: Partial = { 275 | nextPreKeyId: Math.max(lastPreKeyId + 1, creds.nextPreKeyId), 276 | firstUnuploadedPreKeyId: Math.max(creds.firstUnuploadedPreKeyId, lastPreKeyId + 1) 277 | } 278 | 279 | await keys.set({ 'pre-key': newPreKeys }) 280 | 281 | const preKeys = await getPreKeys(keys, preKeysRange[0], preKeysRange[0] + preKeysRange[1]) 282 | 283 | return { update, preKeys } 284 | } 285 | 286 | export const getNextPreKeysNode = async(state: AuthenticationState, count: number) => { 287 | const { creds } = state 288 | const { update, preKeys } = await getNextPreKeys(state, count) 289 | 290 | const node: BinaryNode = { 291 | tag: 'iq', 292 | attrs: { 293 | xmlns: 'encrypt', 294 | type: 'set', 295 | to: S_WHATSAPP_NET, 296 | }, 297 | content: [ 298 | { tag: 'registration', attrs: { }, content: encodeBigEndian(creds.registrationId) }, 299 | { tag: 'type', attrs: { }, content: KEY_BUNDLE_TYPE }, 300 | { tag: 'identity', attrs: { }, content: creds.signedIdentityKey.public }, 301 | { tag: 'list', attrs: { }, content: Object.keys(preKeys).map(k => xmppPreKey(preKeys[+k], +k)) }, 302 | xmppSignedPreKey(creds.signedPreKey) 303 | ] 304 | } 305 | 306 | return { update, node } 307 | } -------------------------------------------------------------------------------- /src/Utils/generics.ts: -------------------------------------------------------------------------------- 1 | import { Boom } from '@hapi/boom' 2 | import axios, { AxiosRequestConfig } from 'axios' 3 | import { randomBytes } from 'crypto' 4 | import { platform, release } from 'os' 5 | import { Logger } from 'pino' 6 | import { proto } from '../../WAProto' 7 | import { version as baileysVersion } from '../Defaults/baileys-version.json' 8 | import { BaileysEventEmitter, BaileysEventMap, DisconnectReason, WACallUpdateType, WAVersion } from '../Types' 9 | import { BinaryNode, getAllBinaryNodeChildren } from '../WABinary' 10 | 11 | const PLATFORM_MAP = { 12 | 'aix': 'AIX', 13 | 'darwin': 'Mac OS', 14 | 'win32': 'Windows', 15 | 'android': 'Android' 16 | } 17 | 18 | export const Browsers = { 19 | ubuntu: browser => ['Ubuntu', browser, '20.0.04'] as [string, string, string], 20 | macOS: browser => ['Mac OS', browser, '10.15.7'] as [string, string, string], 21 | baileys: browser => ['Baileys', browser, '4.0.0'] as [string, string, string], 22 | /** The appropriate browser based on your OS & release */ 23 | appropriate: browser => [ PLATFORM_MAP[platform()] || 'Ubuntu', browser, release() ] as [string, string, string] 24 | } 25 | 26 | export const BufferJSON = { 27 | replacer: (k, value: any) => { 28 | if(Buffer.isBuffer(value) || value instanceof Uint8Array || value?.type === 'Buffer') { 29 | return { type: 'Buffer', data: Buffer.from(value?.data || value).toString('base64') } 30 | } 31 | 32 | return value 33 | }, 34 | reviver: (_, value: any) => { 35 | if(typeof value === 'object' && !!value && (value.buffer === true || value.type === 'Buffer')) { 36 | const val = value.data || value.value 37 | return typeof val === 'string' ? Buffer.from(val, 'base64') : Buffer.from(val || []) 38 | } 39 | 40 | return value 41 | } 42 | } 43 | 44 | export const writeRandomPadMax16 = (msg: Uint8Array) => { 45 | const pad = randomBytes(1) 46 | pad[0] &= 0xf 47 | if(!pad[0]) { 48 | pad[0] = 0xf 49 | } 50 | 51 | return Buffer.concat([msg, Buffer.alloc(pad[0], pad[0])]) 52 | } 53 | 54 | export const unpadRandomMax16 = (e: Uint8Array | Buffer) => { 55 | const t = new Uint8Array(e) 56 | if(0 === t.length) { 57 | throw new Error('unpadPkcs7 given empty bytes') 58 | } 59 | 60 | var r = t[t.length - 1] 61 | if(r > t.length) { 62 | throw new Error(`unpad given ${t.length} bytes, but pad is ${r}`) 63 | } 64 | 65 | return new Uint8Array(t.buffer, t.byteOffset, t.length - r) 66 | } 67 | 68 | export const encodeWAMessage = (message: proto.IMessage) => ( 69 | writeRandomPadMax16( 70 | proto.Message.encode(message).finish() 71 | ) 72 | ) 73 | 74 | export const generateRegistrationId = (): number => { 75 | return Uint16Array.from(randomBytes(2))[0] & 16383 76 | } 77 | 78 | export const encodeBigEndian = (e: number, t = 4) => { 79 | let r = e 80 | const a = new Uint8Array(t) 81 | for(let i = t - 1; i >= 0; i--) { 82 | a[i] = 255 & r 83 | r >>>= 8 84 | } 85 | 86 | return a 87 | } 88 | 89 | export const toNumber = (t: Long | number | null | undefined): number => ((typeof t === 'object' && t) ? ('toNumber' in t ? t.toNumber() : (t as any).low) : t) 90 | 91 | /** unix timestamp of a date in seconds */ 92 | export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime() / 1000) 93 | 94 | export type DebouncedTimeout = ReturnType 95 | 96 | export const debouncedTimeout = (intervalMs: number = 1000, task?: () => void) => { 97 | let timeout: NodeJS.Timeout | undefined 98 | return { 99 | start: (newIntervalMs?: number, newTask?: () => void) => { 100 | task = newTask || task 101 | intervalMs = newIntervalMs || intervalMs 102 | timeout && clearTimeout(timeout) 103 | timeout = setTimeout(() => task?.(), intervalMs) 104 | }, 105 | cancel: () => { 106 | timeout && clearTimeout(timeout) 107 | timeout = undefined 108 | }, 109 | setTask: (newTask: () => void) => task = newTask, 110 | setInterval: (newInterval: number) => intervalMs = newInterval 111 | } 112 | } 113 | 114 | export const delay = (ms: number) => delayCancellable (ms).delay 115 | 116 | export const delayCancellable = (ms: number) => { 117 | const stack = new Error().stack 118 | let timeout: NodeJS.Timeout 119 | let reject: (error) => void 120 | const delay: Promise = new Promise((resolve, _reject) => { 121 | timeout = setTimeout(resolve, ms) 122 | reject = _reject 123 | }) 124 | const cancel = () => { 125 | clearTimeout (timeout) 126 | reject( 127 | new Boom('Cancelled', { 128 | statusCode: 500, 129 | data: { 130 | stack 131 | } 132 | }) 133 | ) 134 | } 135 | 136 | return { delay, cancel } 137 | } 138 | 139 | export async function promiseTimeout(ms: number | undefined, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) { 140 | if(!ms) { 141 | return new Promise (promise) 142 | } 143 | 144 | const stack = new Error().stack 145 | // Create a promise that rejects in milliseconds 146 | const { delay, cancel } = delayCancellable (ms) 147 | const p = new Promise ((resolve, reject) => { 148 | delay 149 | .then(() => reject( 150 | new Boom('Timed Out', { 151 | statusCode: DisconnectReason.timedOut, 152 | data: { 153 | stack 154 | } 155 | }) 156 | )) 157 | .catch (err => reject(err)) 158 | 159 | promise (resolve, reject) 160 | }) 161 | .finally (cancel) 162 | return p as Promise 163 | } 164 | 165 | // generate a random ID to attach to a message 166 | export const generateMessageID = () => 'BAE5' + randomBytes(6).toString('hex').toUpperCase() 167 | 168 | export function bindWaitForEvent(ev: BaileysEventEmitter, event: T) { 169 | return async(check: (u: BaileysEventMap[T]) => boolean | undefined, timeoutMs?: number) => { 170 | let listener: (item: BaileysEventMap[T]) => void 171 | let closeListener: any 172 | await ( 173 | promiseTimeout( 174 | timeoutMs, 175 | (resolve, reject) => { 176 | closeListener = ({ connection, lastDisconnect }) => { 177 | if(connection === 'close') { 178 | reject( 179 | lastDisconnect?.error 180 | || new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) 181 | ) 182 | } 183 | } 184 | 185 | ev.on('connection.update', closeListener) 186 | listener = (update) => { 187 | if(check(update)) { 188 | resolve() 189 | } 190 | } 191 | 192 | ev.on(event, listener) 193 | } 194 | ) 195 | .finally(() => { 196 | ev.off(event, listener) 197 | ev.off('connection.update', closeListener) 198 | }) 199 | ) 200 | } 201 | } 202 | 203 | export const bindWaitForConnectionUpdate = (ev: BaileysEventEmitter) => bindWaitForEvent(ev, 'connection.update') 204 | 205 | export const printQRIfNecessaryListener = (ev: BaileysEventEmitter, logger: Logger) => { 206 | ev.on('connection.update', async({ qr }) => { 207 | if(qr) { 208 | const QR = await import('qrcode-terminal') 209 | .catch(() => { 210 | logger.error('QR code terminal not added as dependency') 211 | }) 212 | QR?.generate(qr, { small: true }) 213 | } 214 | }) 215 | } 216 | 217 | /** 218 | * utility that fetches latest baileys version from the master branch. 219 | * Use to ensure your WA connection is always on the latest version 220 | */ 221 | export const fetchLatestBaileysVersion = async(options: AxiosRequestConfig = { }) => { 222 | const URL = 'https://raw.githubusercontent.com/adiwajshing/Baileys/master/src/Defaults/baileys-version.json' 223 | try { 224 | const result = await axios.get<{ version: WAVersion }>( 225 | URL, 226 | { 227 | ...options, 228 | responseType: 'json' 229 | } 230 | ) 231 | return { 232 | version: result.data.version, 233 | isLatest: true 234 | } 235 | } catch(error) { 236 | return { 237 | version: baileysVersion as WAVersion, 238 | isLatest: false, 239 | error 240 | } 241 | } 242 | } 243 | 244 | /** 245 | * A utility that fetches the latest web version of whatsapp. 246 | * Use to ensure your WA connection is always on the latest version 247 | */ 248 | export const fetchLatestWaWebVersion = async(options: AxiosRequestConfig) => { 249 | try { 250 | const result = await axios.get( 251 | 'https://web.whatsapp.com/check-update?version=1&platform=web', 252 | { 253 | ...options, 254 | responseType: 'json' 255 | } 256 | ) 257 | const version = result.data.currentVersion.split('.') 258 | return { 259 | version: [+version[0], +version[1], +version[2]] as WAVersion, 260 | isLatest: true 261 | } 262 | } catch(error) { 263 | return { 264 | version: baileysVersion as WAVersion, 265 | isLatest: false, 266 | error 267 | } 268 | } 269 | } 270 | 271 | /** unique message tag prefix for MD clients */ 272 | export const generateMdTagPrefix = () => { 273 | const bytes = randomBytes(4) 274 | return `${bytes.readUInt16BE()}.${bytes.readUInt16BE(2)}-` 275 | } 276 | 277 | const STATUS_MAP: { [_: string]: proto.WebMessageInfo.Status } = { 278 | 'played': proto.WebMessageInfo.Status.PLAYED, 279 | 'read': proto.WebMessageInfo.Status.READ, 280 | 'read-self': proto.WebMessageInfo.Status.READ 281 | } 282 | /** 283 | * Given a type of receipt, returns what the new status of the message should be 284 | * @param type type from receipt 285 | */ 286 | export const getStatusFromReceiptType = (type: string | undefined) => { 287 | const status = STATUS_MAP[type!] 288 | if(typeof type === 'undefined') { 289 | return proto.WebMessageInfo.Status.DELIVERY_ACK 290 | } 291 | 292 | return status 293 | } 294 | 295 | const CODE_MAP: { [_: string]: DisconnectReason } = { 296 | conflict: DisconnectReason.connectionReplaced 297 | } 298 | 299 | /** 300 | * Stream errors generally provide a reason, map that to a baileys DisconnectReason 301 | * @param reason the string reason given, eg. "conflict" 302 | */ 303 | export const getErrorCodeFromStreamError = (node: BinaryNode) => { 304 | const [reasonNode] = getAllBinaryNodeChildren(node) 305 | let reason = reasonNode?.tag || 'unknown' 306 | const statusCode = +(node.attrs.code || CODE_MAP[reason] || DisconnectReason.badSession) 307 | 308 | if(statusCode === DisconnectReason.restartRequired) { 309 | reason = 'restart required' 310 | } 311 | 312 | return { 313 | reason, 314 | statusCode 315 | } 316 | } 317 | 318 | export const getCallStatusFromNode = ({ tag, attrs }: BinaryNode) => { 319 | let status: WACallUpdateType 320 | switch (tag) { 321 | case 'offer': 322 | case 'offer_notice': 323 | status = 'offer' 324 | break 325 | case 'terminate': 326 | if(attrs.reason === 'timeout') { 327 | status = 'timeout' 328 | } else { 329 | status = 'reject' 330 | } 331 | 332 | break 333 | case 'reject': 334 | status = 'reject' 335 | break 336 | case 'accept': 337 | status = 'accept' 338 | break 339 | default: 340 | status = 'ringing' 341 | break 342 | } 343 | 344 | return status 345 | } 346 | 347 | const UNEXPECTED_SERVER_CODE_TEXT = 'Unexpected server response: ' 348 | 349 | export const getCodeFromWSError = (error: Error) => { 350 | let statusCode = 500 351 | if(error.message.includes(UNEXPECTED_SERVER_CODE_TEXT)) { 352 | const code = +error.message.slice(UNEXPECTED_SERVER_CODE_TEXT.length) 353 | if(!Number.isNaN(code) && code >= 400) { 354 | statusCode = code 355 | } 356 | } else if((error as any).code?.startsWith('E')) { // handle ETIMEOUT, ENOTFOUND etc 357 | statusCode = 408 358 | } 359 | 360 | return statusCode 361 | } 362 | 363 | /** 364 | * Is the given platform WA business 365 | * @param platform AuthenticationCreds.platform 366 | */ 367 | export const isWABusinessPlatform = (platform: string) => { 368 | return platform === 'smbi' || platform === 'smba' 369 | } 370 | 371 | export function trimUndefineds(obj: any) { 372 | for(const key in obj) { 373 | if(typeof obj[key] === 'undefined') { 374 | delete obj[key] 375 | } 376 | } 377 | 378 | return obj 379 | } --------------------------------------------------------------------------------