diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 10a24e6..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - parser: "@typescript-eslint/parser", // Specifies the ESLint parser - parserOptions: { - ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features - sourceType: "module" // Allows for the use of imports - }, - extends: [ - "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin - "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier - "plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. - ], - rules: { - // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs - // e.g. "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/no-namespace": "off", - "@typescript-eslint/ban-types": "off" - } -} \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index d1ca80a..0000000 --- a/.prettierrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - semi: false, - trailingComma: "all", - singleQuote: true, - printWidth: 120, - tabWidth: 4 -} \ No newline at end of file diff --git a/src/Binary/Constants.ts b/src/Binary/Constants.ts deleted file mode 100644 index d26fd00..0000000 --- a/src/Binary/Constants.ts +++ /dev/null @@ -1,205 +0,0 @@ -import {proto} from '../../WAMessage/WAMessage' - -export namespace WA { - export const Tags = { - LIST_EMPTY: 0, - STREAM_END: 2, - DICTIONARY_0: 236, - DICTIONARY_1: 237, - DICTIONARY_2: 238, - DICTIONARY_3: 239, - LIST_8: 248, - LIST_16: 249, - JID_PAIR: 250, - HEX_8: 251, - BINARY_8: 252, - BINARY_20: 253, - BINARY_32: 254, - NIBBLE_8: 255, - SINGLE_BYTE_MAX: 256, - PACKED_MAX: 254, - } - export const DoubleByteTokens = [] - export const SingleByteTokens = [ - null, - null, - null, - '200', - '400', - '404', - '500', - '501', - '502', - 'action', - 'add', - 'after', - 'archive', - 'author', - 'available', - 'battery', - 'before', - 'body', - 'broadcast', - 'chat', - 'clear', - 'code', - 'composing', - 'contacts', - 'count', - 'create', - 'debug', - 'delete', - 'demote', - 'duplicate', - 'encoding', - 'error', - 'false', - 'filehash', - 'from', - 'g.us', - 'group', - 'groups_v2', - 'height', - 'id', - 'image', - 'in', - 'index', - 'invis', - 'item', - 'jid', - 'kind', - 'last', - 'leave', - 'live', - 'log', - 'media', - 'message', - 'mimetype', - 'missing', - 'modify', - 'name', - 'notification', - 'notify', - 'out', - 'owner', - 'participant', - 'paused', - 'picture', - 'played', - 'presence', - 'preview', - 'promote', - 'query', - 'raw', - 'read', - 'receipt', - 'received', - 'recipient', - 'recording', - 'relay', - 'remove', - 'response', - 'resume', - 'retry', - 's.whatsapp.net', - 'seconds', - 'set', - 'size', - 'status', - 'subject', - 'subscribe', - 't', - 'text', - 'to', - 'true', - 'type', - 'unarchive', - 'unavailable', - 'url', - 'user', - 'value', - 'web', - 'width', - 'mute', - 'read_only', - 'admin', - 'creator', - 'short', - 'update', - 'powersave', - 'checksum', - 'epoch', - 'block', - 'previous', - '409', - 'replaced', - 'reason', - 'spam', - 'modify_tag', - 'message_info', - 'delivery', - 'emoji', - 'title', - 'description', - 'canonical-url', - 'matched-text', - 'star', - 'unstar', - 'media_key', - 'filename', - 'identity', - 'unread', - 'page', - 'page_count', - 'search', - 'media_message', - 'security', - 'call_log', - 'profile', - 'ciphertext', - 'invite', - 'gif', - 'vcard', - 'frequent', - 'privacy', - 'blacklist', - 'whitelist', - 'verify', - 'location', - 'document', - 'elapsed', - 'revoke_invite', - 'expiration', - 'unsubscribe', - 'disable', - 'vname', - 'old_jid', - 'new_jid', - 'announcement', - 'locked', - 'prop', - 'label', - 'color', - 'call', - 'offer', - 'call-id', - 'quick_reply', - 'sticker', - 'pay_t', - 'accept', - 'reject', - 'sticker_pack', - 'invalid', - 'canceled', - 'missed', - 'connected', - 'result', - 'audio', - 'video', - 'recent', - ] - export const Message = proto.WebMessageInfo - export type NodeAttributes = { [key: string]: string } | string | null - export type NodeData = Array | any | null - export type Node = [string, NodeAttributes, NodeData] -} diff --git a/src/Binary/Decoder.ts b/src/Binary/Decoder.ts deleted file mode 100644 index 57a83db..0000000 --- a/src/Binary/Decoder.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { WA } from './Constants' - -export default class Decoder { - buffer: Buffer = null - index = 0 - - checkEOS(length: number) { - if (this.index + length > this.buffer.length) { - throw new Error('end of stream') - } - } - next() { - const value = this.buffer[this.index] - this.index += 1 - return value - } - readByte() { - this.checkEOS(1) - return this.next() - } - readStringFromChars(length: number) { - this.checkEOS(length) - const value = this.buffer.slice(this.index, this.index + length) - - this.index += length - return value.toString ('utf-8') - } - readBytes(n: number): Buffer { - this.checkEOS(n) - const value = this.buffer.slice(this.index, this.index + n) - this.index += n - return value - } - readInt(n: number, littleEndian = false) { - this.checkEOS(n) - let val = 0 - for (let i = 0; i < n; i++) { - const shift = littleEndian ? i : n - 1 - i - val |= this.next() << (shift * 8) - } - return val - } - readInt20() { - this.checkEOS(3) - return ((this.next() & 15) << 16) + (this.next() << 8) + this.next() - } - unpackHex(value: number) { - if (value >= 0 && value < 16) { - return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10 - } - throw new Error('invalid hex: ' + value) - } - unpackNibble(value: number) { - if (value >= 0 && value <= 9) { - return '0'.charCodeAt(0) + value - } - switch (value) { - case 10: - return '-'.charCodeAt(0) - case 11: - return '.'.charCodeAt(0) - case 15: - return '\0'.charCodeAt(0) - default: - throw new Error('invalid nibble: ' + value) - } - } - unpackByte(tag: number, value: number) { - if (tag === WA.Tags.NIBBLE_8) { - return this.unpackNibble(value) - } else if (tag === WA.Tags.HEX_8) { - return this.unpackHex(value) - } else { - throw new Error('unknown tag: ' + tag) - } - } - readPacked8(tag: number) { - const startByte = this.readByte() - let value = '' - - for (let i = 0; i < (startByte & 127); i++) { - const curByte = this.readByte() - value += String.fromCharCode(this.unpackByte(tag, (curByte & 0xf0) >> 4)) - value += String.fromCharCode(this.unpackByte(tag, curByte & 0x0f)) - } - if (startByte >> 7 !== 0) { - value = value.slice(0, -1) - } - return value - } - readRangedVarInt(min, max, description = 'unknown') { - // value = - throw new Error('WTF; should not be called') - } - isListTag(tag: number) { - return tag === WA.Tags.LIST_EMPTY || tag === WA.Tags.LIST_8 || tag === WA.Tags.LIST_16 - } - readListSize(tag: number) { - switch (tag) { - case WA.Tags.LIST_EMPTY: - return 0 - case WA.Tags.LIST_8: - return this.readByte() - case WA.Tags.LIST_16: - return this.readInt(2) - default: - throw new Error('invalid tag for list size: ' + tag) - } - } - - readString(tag: number): string { - if (tag >= 3 && tag <= 235) { - const token = this.getToken(tag) - return token// === 's.whatsapp.net' ? 'c.us' : token - } - - switch (tag) { - case WA.Tags.DICTIONARY_0: - case WA.Tags.DICTIONARY_1: - case WA.Tags.DICTIONARY_2: - case WA.Tags.DICTIONARY_3: - return this.getTokenDouble(tag - WA.Tags.DICTIONARY_0, this.readByte()) - case WA.Tags.LIST_EMPTY: - return null - case WA.Tags.BINARY_8: - return this.readStringFromChars(this.readByte()) - case WA.Tags.BINARY_20: - return this.readStringFromChars(this.readInt20()) - case WA.Tags.BINARY_32: - return this.readStringFromChars(this.readInt(4)) - case WA.Tags.JID_PAIR: - const i = this.readString(this.readByte()) - const j = this.readString(this.readByte()) - if (typeof i === 'string' && j) { - return i + '@' + j - } - throw new Error('invalid jid pair: ' + i + ', ' + j) - case WA.Tags.HEX_8: - case WA.Tags.NIBBLE_8: - return this.readPacked8(tag) - default: - throw new Error('invalid string with tag: ' + tag) - } - } - readAttributes(n: number) { - if (n !== 0) { - const attributes: WA.NodeAttributes = {} - for (let i = 0; i < n; i++) { - const key = this.readString(this.readByte()) - const b = this.readByte() - - attributes[key] = this.readString(b) - } - return attributes - } - return null - } - readList(tag: number) { - const arr = [...new Array(this.readListSize(tag))] - return arr.map(() => this.readNode()) - } - getToken(index: number) { - if (index < 3 || index >= WA.SingleByteTokens.length) { - throw new Error('invalid token index: ' + index) - } - return WA.SingleByteTokens[index] - } - getTokenDouble(index1, index2): string { - const n = 256 * index1 + index2 - if (n < 0 || n > WA.DoubleByteTokens.length) { - throw new Error('invalid double token index: ' + n) - } - return WA.DoubleByteTokens[n] - } - readNode(): WA.Node { - const listSize = this.readListSize(this.readByte()) - const descrTag = this.readByte() - if (descrTag === WA.Tags.STREAM_END) { - throw new Error('unexpected stream end') - } - - const descr = this.readString(descrTag) - if (listSize === 0 || !descr) { - throw new Error('invalid node') - } - - const attrs = this.readAttributes((listSize - 1) >> 1) - let content: WA.NodeData = null - - if (listSize % 2 === 0) { - const tag = this.readByte() - if (this.isListTag(tag)) { - content = this.readList(tag) - } else { - let decoded: Buffer | string - switch (tag) { - case WA.Tags.BINARY_8: - decoded = this.readBytes(this.readByte()) - break - case WA.Tags.BINARY_20: - decoded = this.readBytes(this.readInt20()) - break - case WA.Tags.BINARY_32: - decoded = this.readBytes(this.readInt(4)) - break - default: - decoded = this.readString(tag) - break - } - - if (descr === 'message' && Buffer.isBuffer(decoded)) { - content = WA.Message.decode(decoded) - } else { - content = decoded - } - } - } - - return [descr, attrs, content] - } - - read(buffer: Buffer) { - this.buffer = buffer - this.index = 0 - return this.readNode() - } -} diff --git a/src/Binary/Encoder.ts b/src/Binary/Encoder.ts deleted file mode 100644 index 63331e9..0000000 --- a/src/Binary/Encoder.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { Message } from 'protobufjs' -import { WA } from './Constants' - -export default class Encoder { - data: number[] = [] - - pushByte(value: number) { - this.data.push(value & 0xff) - } - pushInt(value: number, n: number, littleEndian=false) { - for (let i = 0; i < n; i++) { - const curShift = littleEndian ? i : n - 1 - i - this.data.push((value >> (curShift * 8)) & 0xff) - } - } - pushInt20(value: number) { - this.pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff]) - } - pushBytes(bytes: Uint8Array | Buffer | number[]) { - bytes.forEach (b => this.data.push(b)) - } - writeByteLength(length: number) { - if (length >= 4294967296) throw new Error('string too large to encode: ' + length) - - if (length >= 1 << 20) { - this.pushByte(WA.Tags.BINARY_32) - this.pushInt(length, 4) // 32 bit integer - } else if (length >= 256) { - this.pushByte(WA.Tags.BINARY_20) - this.pushInt20(length) - } else { - this.pushByte(WA.Tags.BINARY_8) - this.pushByte(length) - } - } - writeStringRaw(string: string) { - const bytes = Buffer.from (string, 'utf-8') - this.writeByteLength(bytes.length) - this.pushBytes(bytes) - } - writeJid(left: string, right: string) { - this.pushByte(WA.Tags.JID_PAIR) - left && left.length > 0 ? this.writeString(left) : this.writeToken(WA.Tags.LIST_EMPTY) - this.writeString(right) - } - writeToken(token: number) { - if (token < 245) { - this.pushByte(token) - } else if (token <= 500) { - throw new Error('invalid token') - } - } - writeString(token: string, i: boolean = null) { - if (token === 'c.us') token = 's.whatsapp.net' - - const tokenIndex = WA.SingleByteTokens.indexOf(token) - if (!i && token === 's.whatsapp.net') { - this.writeToken(tokenIndex) - } else if (tokenIndex >= 0) { - if (tokenIndex < WA.Tags.SINGLE_BYTE_MAX) { - this.writeToken(tokenIndex) - } else { - const overflow = tokenIndex - WA.Tags.SINGLE_BYTE_MAX - const dictionaryIndex = overflow >> 8 - if (dictionaryIndex < 0 || dictionaryIndex > 3) { - throw new Error('double byte dict token out of range: ' + token + ', ' + tokenIndex) - } - this.writeToken(WA.Tags.DICTIONARY_0 + dictionaryIndex) - this.writeToken(overflow % 256) - } - } else if (token) { - const jidSepIndex = token.indexOf('@') - if (jidSepIndex <= 0) { - this.writeStringRaw(token) - } else { - this.writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length)) - } - } - } - writeAttributes(attrs: Record | string, keys: string[]) { - if (!attrs) { - return - } - keys.forEach((key) => { - this.writeString(key) - this.writeString(attrs[key]) - }) - } - writeListStart(listSize: number) { - if (listSize === 0) { - this.pushByte(WA.Tags.LIST_EMPTY) - } else if (listSize < 256) { - this.pushBytes([WA.Tags.LIST_8, listSize]) - } else { - this.pushBytes([WA.Tags.LIST_16, listSize]) - } - } - writeChildren(children: string | Array | Buffer | Object) { - if (!children) return - - if (typeof children === 'string') { - this.writeString(children, true) - } else if (Buffer.isBuffer(children)) { - this.writeByteLength (children.length) - this.pushBytes(children) - } else if (Array.isArray(children)) { - this.writeListStart(children.length) - children.forEach(c => c && this.writeNode(c)) - } else if (typeof children === 'object') { - const buffer = WA.Message.encode(children as any).finish() - this.writeByteLength(buffer.length) - this.pushBytes(buffer) - } else { - throw new Error('invalid children: ' + children + ' (' + typeof children + ')') - } - } - getValidKeys(obj: Object) { - return obj ? Object.keys(obj).filter((key) => obj[key] !== null && obj[key] !== undefined) : [] - } - writeNode(node: WA.Node) { - if (!node) { - return - } else if (node.length !== 3) { - throw new Error('invalid node given: ' + node) - } - const validAttributes = this.getValidKeys(node[1]) - - this.writeListStart(2 * validAttributes.length + 1 + (node[2] ? 1 : 0)) - this.writeString(node[0]) - this.writeAttributes(node[1], validAttributes) - this.writeChildren(node[2]) - } - write(data) { - this.data = [] - this.writeNode(data) - - return Buffer.from(this.data) - } -} diff --git a/src/Binary/GenerateStatics.sh b/src/BinaryNode/GenerateStatics.sh similarity index 100% rename from src/Binary/GenerateStatics.sh rename to src/BinaryNode/GenerateStatics.sh diff --git a/src/Binary/WAMessage.proto b/src/BinaryNode/WAMessage.proto similarity index 100% rename from src/Binary/WAMessage.proto rename to src/BinaryNode/WAMessage.proto diff --git a/src/BinaryNode/decode.ts b/src/BinaryNode/decode.ts new file mode 100644 index 0000000..bed96f1 --- /dev/null +++ b/src/BinaryNode/decode.ts @@ -0,0 +1,204 @@ +import { proto } from '../../WAMessage/WAMessage' +import { BinaryNode, DoubleByteTokens, SingleByteTokens, Tags } from './types' + +function decode(buffer: Buffer, makeNode: () => T, indexRef: { index: number }) { + + const checkEOS = (length: number) => { + if (indexRef.index + length > buffer.length) { + throw new Error('end of stream') + } + } + const next = () => { + const value = buffer[indexRef.index] + indexRef.index += 1 + return value + } + const readByte = () => { + checkEOS(1) + return next() + } + const readStringFromChars = (length: number) => { + checkEOS(length) + const value = buffer.slice(indexRef.index, indexRef.index + length) + + indexRef.index += length + return value.toString('utf-8') + } + const readBytes = (n: number) => { + checkEOS(n) + const value = buffer.slice(indexRef.index, indexRef.index + n) + indexRef.index += n + return value + } + const readInt = (n: number, littleEndian = false) => { + checkEOS(n) + let val = 0 + for (let i = 0; i < n; i++) { + const shift = littleEndian ? i : n - 1 - i + val |= next() << (shift * 8) + } + return val + } + const readInt20 = () => { + checkEOS(3) + return ((next() & 15) << 16) + (next() << 8) + next() + } + const unpackHex = (value: number) => { + if (value >= 0 && value < 16) { + return value < 10 ? '0'.charCodeAt(0) + value : 'A'.charCodeAt(0) + value - 10 + } + throw new Error('invalid hex: ' + value) + } + const unpackNibble = (value: number) => { + if (value >= 0 && value <= 9) { + return '0'.charCodeAt(0) + value + } + switch (value) { + case 10: + return '-'.charCodeAt(0) + case 11: + return '.'.charCodeAt(0) + case 15: + return '\0'.charCodeAt(0) + default: + throw new Error('invalid nibble: ' + value) + } + } + const unpackByte = (tag: number, value: number) => { + if (tag === Tags.NIBBLE_8) { + return unpackNibble(value) + } else if (tag === Tags.HEX_8) { + return unpackHex(value) + } else { + throw new Error('unknown tag: ' + tag) + } + } + const readPacked8 = (tag: number) => { + const startByte = readByte() + let value = '' + + for (let i = 0; i < (startByte & 127); i++) { + const curByte = readByte() + value += String.fromCharCode(unpackByte(tag, (curByte & 0xf0) >> 4)) + value += String.fromCharCode(unpackByte(tag, curByte & 0x0f)) + } + if (startByte >> 7 !== 0) { + value = value.slice(0, -1) + } + return value + } + const isListTag = (tag: number) => { + return tag === Tags.LIST_EMPTY || tag === Tags.LIST_8 || tag === Tags.LIST_16 + } + const readListSize = (tag: number) => { + switch (tag) { + case Tags.LIST_EMPTY: + return 0 + case Tags.LIST_8: + return readByte() + case Tags.LIST_16: + return readInt(2) + default: + throw new Error('invalid tag for list size: ' + tag) + } + } + const getToken = (index: number) => { + if (index < 3 || index >= SingleByteTokens.length) { + throw new Error('invalid token index: ' + index) + } + return SingleByteTokens[index] + } + const readString = (tag: number) => { + if (tag >= 3 && tag <= 235) { + const token = getToken(tag) + return token// === 's.whatsapp.net' ? 'c.us' : token + } + + switch (tag) { + case Tags.DICTIONARY_0: + case Tags.DICTIONARY_1: + case Tags.DICTIONARY_2: + case Tags.DICTIONARY_3: + return getTokenDouble(tag - Tags.DICTIONARY_0, readByte()) + case Tags.LIST_EMPTY: + return null + case Tags.BINARY_8: + return readStringFromChars(readByte()) + case Tags.BINARY_20: + return readStringFromChars(readInt20()) + case Tags.BINARY_32: + return readStringFromChars(readInt(4)) + case Tags.JID_PAIR: + const i = readString(readByte()) + const j = readString(readByte()) + if (typeof i === 'string' && j) { + return i + '@' + j + } + throw new Error('invalid jid pair: ' + i + ', ' + j) + case Tags.HEX_8: + case Tags.NIBBLE_8: + return readPacked8(tag) + default: + throw new Error('invalid string with tag: ' + tag) + } + } + const readList = (tag: number) => ( + [...new Array(readListSize(tag))].map(() => decode(buffer, makeNode, indexRef)) + ) + const getTokenDouble = (index1: number, index2: number) => { + const n = 256 * index1 + index2 + if (n < 0 || n > DoubleByteTokens.length) { + throw new Error('invalid double token index: ' + n) + } + return DoubleByteTokens[n] + } + const node = makeNode() + const listSize = readListSize(readByte()) + const descrTag = readByte() + if (descrTag === Tags.STREAM_END) { + throw new Error('unexpected stream end') + } + node.header = readString(descrTag) + if (listSize === 0 || !node.header) { + throw new Error('invalid node') + } + // read the attributes in + const attributesLength = (listSize - 1) >> 1 + for (let i = 0; i < attributesLength; i++) { + const key = readString(readByte()) + const b = readByte() + + node.attributes[key] = readString(b) + } + + if (listSize % 2 === 0) { + const tag = readByte() + if (isListTag(tag)) { + node.data = readList(tag) + } else { + let decoded: Buffer | string + switch (tag) { + case Tags.BINARY_8: + decoded = readBytes(readByte()) + break + case Tags.BINARY_20: + decoded = readBytes(readInt20()) + break + case Tags.BINARY_32: + decoded = readBytes(readInt(4)) + break + default: + decoded = readString(tag) + break + } + + if (node.header === 'message' && Buffer.isBuffer(decoded)) { + node.data = proto.WebMessageInfo.decode(decoded) + } else { + node.data = decoded + } + } + } + return node +} +export default decode \ No newline at end of file diff --git a/src/BinaryNode/encode.ts b/src/BinaryNode/encode.ts new file mode 100644 index 0000000..b1e8f79 --- /dev/null +++ b/src/BinaryNode/encode.ts @@ -0,0 +1,124 @@ +import { proto } from "../../WAMessage/WAMessage"; +import { BinaryNode, SingleByteTokens, Tags } from "./types"; + +const encode = ({ header, attributes, data }: BinaryNode, buffer: number[] = []) => { + + const pushByte = (value: number) => buffer.push(value & 0xff) + + const pushInt = (value: number, n: number, littleEndian=false) => { + for (let i = 0; i < n; i++) { + const curShift = littleEndian ? i : n - 1 - i + buffer.push((value >> (curShift * 8)) & 0xff) + } + } + const pushBytes = (bytes: Uint8Array | Buffer | number[]) => ( + bytes.forEach (b => buffer.push(b)) + ) + const pushInt20 = (value: number) => ( + pushBytes([(value >> 16) & 0x0f, (value >> 8) & 0xff, value & 0xff]) + ) + const pushString = (str: string) => { + const bytes = Buffer.from (str, 'utf-8') + pushBytes(bytes) + } + const writeByteLength = (length: number) => { + if (length >= 4294967296) throw new Error('string too large to encode: ' + length) + + if (length >= 1 << 20) { + pushByte(Tags.BINARY_32) + pushInt(length, 4) // 32 bit integer + } else if (length >= 256) { + pushByte(Tags.BINARY_20) + pushInt20(length) + } else { + pushByte(Tags.BINARY_8) + pushByte(length) + } + } + const writeStringRaw = (string: string) => { + writeByteLength(string.length) + pushString(string) + } + const writeToken = (token: number) => { + if (token < 245) { + pushByte(token) + } else if (token <= 500) { + throw new Error('invalid token') + } + } + const writeString = (token: string, i?: boolean) => { + if (token === 'c.us') token = 's.whatsapp.net' + + const tokenIndex = SingleByteTokens.indexOf(token) + if (!i && token === 's.whatsapp.net') { + writeToken(tokenIndex) + } else if (tokenIndex >= 0) { + if (tokenIndex < Tags.SINGLE_BYTE_MAX) { + writeToken(tokenIndex) + } else { + const overflow = tokenIndex - Tags.SINGLE_BYTE_MAX + const dictionaryIndex = overflow >> 8 + if (dictionaryIndex < 0 || dictionaryIndex > 3) { + throw new Error('double byte dict token out of range: ' + token + ', ' + tokenIndex) + } + writeToken(Tags.DICTIONARY_0 + dictionaryIndex) + writeToken(overflow % 256) + } + } else if (token) { + const jidSepIndex = token.indexOf('@') + if (jidSepIndex <= 0) { + writeStringRaw(token) + } else { + writeJid(token.slice(0, jidSepIndex), token.slice(jidSepIndex + 1, token.length)) + } + } + } + const writeJid = (left: string, right: string) => { + pushByte(Tags.JID_PAIR) + left && left.length > 0 ? writeString(left) : writeToken(Tags.LIST_EMPTY) + writeString(right) + } + const writeListStart = (listSize: number) => { + if (listSize === 0) { + pushByte(Tags.LIST_EMPTY) + } else if (listSize < 256) { + pushBytes([Tags.LIST_8, listSize]) + } else { + pushBytes([Tags.LIST_16, listSize]) + } + } + const validAttributes = Object.keys(attributes).filter(k => ( + typeof attributes[k] !== 'undefined' && attributes[k] !== null + )) + + writeListStart(2*validAttributes.length + 1 + (typeof data !== 'undefined' && data !== null ? 1 : 0)) + writeString(header) + + validAttributes.forEach((key) => { + writeString(key) + writeString(attributes[key]) + }) + + if(data instanceof proto.WebMessageInfo && !Buffer.isBuffer(data)) { + data = Buffer.from(proto.WebMessageInfo.encode(data).finish()) + } + + if (typeof data === 'string') { + writeString(data, true) + } else if (Buffer.isBuffer(data)) { + writeByteLength(data.length) + pushBytes(data) + } else if (Array.isArray(data)) { + writeListStart(data.length) + for(const item of data) { + if(item) encode(item, buffer) + } + } else if(typeof data === 'undefined' || data === null) { + + } else { + throw new Error(`invalid children for header "${header}": ${data} (${typeof data})`) + } + + return Buffer.from(buffer) +} +export default encode \ No newline at end of file diff --git a/src/BinaryNode/index.ts b/src/BinaryNode/index.ts new file mode 100644 index 0000000..3eb830b --- /dev/null +++ b/src/BinaryNode/index.ts @@ -0,0 +1,8 @@ +import decode from './decode' +import encode from './encode' +import { BinaryNode as BinaryNodeType } from './types' + +export default class BinaryNode extends BinaryNodeType { + toBuffer = () => encode(this, []) + static from = (buffer: Buffer) => decode(buffer, () => new BinaryNode(), { index: 0 }) +} \ No newline at end of file diff --git a/src/BinaryNode/types.ts b/src/BinaryNode/types.ts new file mode 100644 index 0000000..9139342 --- /dev/null +++ b/src/BinaryNode/types.ts @@ -0,0 +1,212 @@ +import { proto } from "../../WAMessage/WAMessage" + +export type Attributes = { [key: string]: string } +export type BinaryNodeData = BinaryNode[] | string | Buffer | proto.IWebMessageInfo | undefined +export class BinaryNode { + header: string + attributes: Attributes = {} + data?: BinaryNodeData + + constructor(header?: string, attrs?: Attributes, data?: BinaryNodeData) { + this.header = header + this.attributes = attrs || {} + this.data = data + } +} +export const Tags = { + LIST_EMPTY: 0, + STREAM_END: 2, + DICTIONARY_0: 236, + DICTIONARY_1: 237, + DICTIONARY_2: 238, + DICTIONARY_3: 239, + LIST_8: 248, + LIST_16: 249, + JID_PAIR: 250, + HEX_8: 251, + BINARY_8: 252, + BINARY_20: 253, + BINARY_32: 254, + NIBBLE_8: 255, + SINGLE_BYTE_MAX: 256, + PACKED_MAX: 254, +} +export const DoubleByteTokens = [] +export const SingleByteTokens = [ + null, + null, + null, + '200', + '400', + '404', + '500', + '501', + '502', + 'action', + 'add', + 'after', + 'archive', + 'author', + 'available', + 'battery', + 'before', + 'body', + 'broadcast', + 'chat', + 'clear', + 'code', + 'composing', + 'contacts', + 'count', + 'create', + 'debug', + 'delete', + 'demote', + 'duplicate', + 'encoding', + 'error', + 'false', + 'filehash', + 'from', + 'g.us', + 'group', + 'groups_v2', + 'height', + 'id', + 'image', + 'in', + 'index', + 'invis', + 'item', + 'jid', + 'kind', + 'last', + 'leave', + 'live', + 'log', + 'media', + 'message', + 'mimetype', + 'missing', + 'modify', + 'name', + 'notification', + 'notify', + 'out', + 'owner', + 'participant', + 'paused', + 'picture', + 'played', + 'presence', + 'preview', + 'promote', + 'query', + 'raw', + 'read', + 'receipt', + 'received', + 'recipient', + 'recording', + 'relay', + 'remove', + 'response', + 'resume', + 'retry', + 's.whatsapp.net', + 'seconds', + 'set', + 'size', + 'status', + 'subject', + 'subscribe', + 't', + 'text', + 'to', + 'true', + 'type', + 'unarchive', + 'unavailable', + 'url', + 'user', + 'value', + 'web', + 'width', + 'mute', + 'read_only', + 'admin', + 'creator', + 'short', + 'update', + 'powersave', + 'checksum', + 'epoch', + 'block', + 'previous', + '409', + 'replaced', + 'reason', + 'spam', + 'modify_tag', + 'message_info', + 'delivery', + 'emoji', + 'title', + 'description', + 'canonical-url', + 'matched-text', + 'star', + 'unstar', + 'media_key', + 'filename', + 'identity', + 'unread', + 'page', + 'page_count', + 'search', + 'media_message', + 'security', + 'call_log', + 'profile', + 'ciphertext', + 'invite', + 'gif', + 'vcard', + 'frequent', + 'privacy', + 'blacklist', + 'whitelist', + 'verify', + 'location', + 'document', + 'elapsed', + 'revoke_invite', + 'expiration', + 'unsubscribe', + 'disable', + 'vname', + 'old_jid', + 'new_jid', + 'announcement', + 'locked', + 'prop', + 'label', + 'color', + 'call', + 'offer', + 'call-id', + 'quick_reply', + 'sticker', + 'pay_t', + 'accept', + 'reject', + 'sticker_pack', + 'invalid', + 'canceled', + 'missed', + 'connected', + 'result', + 'audio', + 'video', + 'recent', +] \ No newline at end of file diff --git a/src/Connection/auth.ts b/src/Connection/auth.ts new file mode 100644 index 0000000..398fdcd --- /dev/null +++ b/src/Connection/auth.ts @@ -0,0 +1,260 @@ +import Boom from "boom" +import EventEmitter from "events" +import * as Curve from 'curve25519-js' +import { BaileysEventEmitter, BaileysEventMap, SocketConfig, CurveKeyPair, WAInitResponse, ConnectionState } from "../Types" +import { makeSocket } from "./socket" +import { generateClientID, promiseTimeout } from "../Utils/generics" +import { normalizedAuthInfo, computeChallengeResponse, validateNewConnection } from "../Utils/validateConnection" +import { randomBytes } from "crypto" +import { AuthenticationCredentials } from "../Types" + +const makeAuthSocket = (config: SocketConfig) => { + const { + logger, + version, + browser, + connectTimeoutMs, + pendingRequestTimeoutMs, + maxQRCodes, + printQRInTerminal, + credentials: anyAuthInfo + } = config + const ev = new EventEmitter() as BaileysEventEmitter + + let authInfo = normalizedAuthInfo(anyAuthInfo) || + // generate client id if not there + { clientID: generateClientID() } as AuthenticationCredentials + + const state: ConnectionState = { + phoneConnected: false, + connection: 'connecting', + } + + const socket = makeSocket({ + ...config, + phoneConnectionChanged: phoneConnected => { + if(phoneConnected !== state.phoneConnected) { + updateState({ phoneConnected }) + } + } + }) + const { socketEvents } = socket + let curveKeys: CurveKeyPair + let initTimeout: NodeJS.Timeout + // add close listener + socketEvents.on('ws-close', (error: Boom | Error) => { + logger.info({ error }, 'Closed connection to WhatsApp') + initTimeout && clearTimeout(initTimeout) + // if no reconnects occur + // send close event + updateState({ + connection: 'close', + qr: undefined, + connectionTriesLeft: undefined, + lastDisconnect: { + error, + date: new Date() + } + }) + }) + /** Can you login to WA without scanning the QR */ + const canLogin = () => !!authInfo?.encKey && !!authInfo?.macKey + + const updateState = (update: Partial) => { + Object.assign(state, update) + ev.emit('connection.update', update) + } + + /** + * Logs you out from WA + * If connected, invalidates the credentials with the server + */ + const logout = async() => { + if(state.connection === 'open') { + await socket.sendMessage({ + json: ['admin', 'Conn', 'disconnect'], + tag: 'goodbye' + }) + } + // will call state update to close connection + socket?.end( + Boom.unauthorized('Logged Out') + ) + authInfo = undefined + } + /** Waits for the connection to WA to open up */ + const waitForConnection = async(waitInfinitely: boolean = false) => { + if(state.connection === 'open') return + + let listener: (item: BaileysEventMap['connection.update']) => void + const timeout = waitInfinitely ? undefined : pendingRequestTimeoutMs + if(timeout < 0) { + throw Boom.preconditionRequired('Connection Closed') + } + + await ( + promiseTimeout( + timeout, + (resolve, reject) => { + listener = ({ connection, lastDisconnect }) => { + if(connection === 'open') resolve() + else if(connection == 'close') { + reject(lastDisconnect.error || Boom.preconditionRequired('Connection Closed')) + } + } + ev.on('connection.update', listener) + } + ) + .finally(() => ( + ev.off('state.update', listener) + )) + ) + } + + const generateKeysForAuth = async(ref: string, ttl?: number) => { + curveKeys = Curve.generateKeyPair(randomBytes(32)) + const publicKey = Buffer.from(curveKeys.public).toString('base64') + let qrGens = 0 + + const qrLoop = ttl => { + const qr = [ref, publicKey, authInfo.clientID].join(',') + updateState({ qr }) + + initTimeout = setTimeout(async () => { + if(state.connection !== 'connecting') return + + logger.debug('regenerating QR') + try { + if(qrGens >= maxQRCodes) { + throw new Boom( + 'Too many QR codes', + { statusCode: 429 } + ) + } + // request new QR + const {ref: newRef, ttl: newTTL} = await socket.query({ + json: ['admin', 'Conn', 'reref'], + expect200: true, + longTag: true, + requiresPhoneConnection: false + }) + ttl = newTTL + ref = newRef + } catch (error) { + logger.error({ error }, `error in QR gen`) + if (error.output?.statusCode === 429) { // too many QR requests + socket.end(error) + return + } + } + qrGens += 1 + qrLoop(ttl) + }, ttl || 20_000) // default is 20s, on the off-chance ttl is not present + } + qrLoop(ttl) + } + socketEvents.once('ws-open', async() => { + const canDoLogin = canLogin() + const initQuery = (async () => { + const {ref, ttl} = await socket.query({ + json: ['admin', 'init', version, browser, authInfo.clientID, true], + expect200: true, + longTag: true, + requiresPhoneConnection: false + }) as WAInitResponse + + if (!canDoLogin) { + generateKeysForAuth(ref, ttl) + } + })(); + let loginTag: string + if(canDoLogin) { + // if we have the info to restore a closed session + const json = [ + 'admin', + 'login', + authInfo.clientToken, + authInfo.serverToken, + authInfo.clientID, + 'takeover' + ] + loginTag = socket.generateMessageTag(true) + // send login every 10s + const sendLoginReq = () => { + if(state.connection === 'open') { + logger.warn('Received login timeout req when state=open, ignoring...') + return + } + logger.debug('sending login request') + socket.sendMessage({ + json, + tag: loginTag + }) + initTimeout = setTimeout(sendLoginReq, 10_000) + } + sendLoginReq() + } + await initQuery + + // wait for response with tag "s1" + let response = await Promise.race( + [ + socket.waitForMessage('s1', false, undefined), + ...(loginTag ? [socket.waitForMessage(loginTag, false, connectTimeoutMs)] : []) + + ] + ) + initTimeout && clearTimeout(initTimeout) + initTimeout = undefined + + if(response.status && response.status !== 200) { + throw new Boom(`Unexpected error in login`, { data: response, statusCode: response.status }) + } + // if its a challenge request (we get it when logging in) + if(response[1]?.challenge) { + const json = computeChallengeResponse(response[1].challenge, authInfo) + logger.info('resolving login challenge') + + await socket.query({ json, expect200: true, timeoutMs: connectTimeoutMs }) + + response = await socket.waitForMessage('s2', true) + } + // validate the new connection + const {user, auth} = validateNewConnection(response[1], authInfo, curveKeys)// validate the connection + const isNewLogin = user.jid !== state.user?.jid + + authInfo = auth + // update the keys so we can decrypt traffic + socket.updateKeys({ encKey: auth.encKey, macKey: auth.macKey }) + + updateState({ + connection: 'open', + phoneConnected: true, + user, + isNewLogin, + connectionTriesLeft: undefined, + qr: undefined + }) + }) + + if(printQRInTerminal) { + ev.on('connection.update', async({ qr }) => { + if(qr) { + const QR = await import('qrcode-terminal').catch(err => { + logger.error('QR code terminal not added as dependency') + }) + QR?.generate(qr, { small: true }) + } + }) + } + return { + ...socket, + ev, + getState: () => state, + getAuthInfo: () => authInfo, + waitForConnection, + canLogin, + logout + } +} +export default makeAuthSocket \ No newline at end of file diff --git a/src/Connection/chats.ts b/src/Connection/chats.ts new file mode 100644 index 0000000..68a611c --- /dev/null +++ b/src/Connection/chats.ts @@ -0,0 +1,6 @@ +import { SocketConfig } from "../Types"; + +const makeChatsSocket = (config: SocketConfig) => { + +} +export default makeChatsSocket \ No newline at end of file diff --git a/src/Connection/socket.ts b/src/Connection/socket.ts new file mode 100644 index 0000000..a7b3e6b --- /dev/null +++ b/src/Connection/socket.ts @@ -0,0 +1,362 @@ +import Boom from "boom" +import EventEmitter from "events" +import { STATUS_CODES } from "http" +import { promisify } from "util" +import WebSocket from "ws" +import BinaryNode from "../BinaryNode" +import { DisconnectReason, SocketConfig, SocketQueryOptions, SocketSendMessageOptions } from "../Types" +import { aesEncrypt, hmacSign, promiseTimeout, unixTimestampSeconds } from "../Utils/generics" +import { decodeWAMessage } from "../Utils/decodeWAMessage" +import { WAFlag, WAMetric, WATag } from "../Types" +import { DEFAULT_ORIGIN, DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, PHONE_CONNECTION_CB } from "../Defaults" + +/** + * Connects to WA servers and performs: + * - simple queries (no retry mechanism, wait for connection establishment) + * - listen to messages and emit events + * - query phone connection + */ +export const makeSocket = ({ + waWebSocketUrl, + connectTimeoutMs, + phoneResponseTimeMs, + logger, + agent, + keepAliveIntervalMs, + expectResponseTimeout, + phoneConnectionChanged +}: SocketConfig) => { + const socketEvents = new EventEmitter() + // for generating tags + const referenceDateSeconds = unixTimestampSeconds(new Date()) + const ws = new WebSocket(waWebSocketUrl, undefined, { + origin: DEFAULT_ORIGIN, + timeout: connectTimeoutMs, + agent, + headers: { + 'Accept-Encoding': 'gzip, deflate, br', + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'no-cache', + 'Host': 'web.whatsapp.com', + 'Pragma': 'no-cache', + 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits', + } + }) + let lastDateRecv: Date + let epoch = 0 + let authInfo: { encKey: Buffer, macKey: Buffer } + let keepAliveReq: NodeJS.Timeout + + let phoneCheckInterval: NodeJS.Timeout + let phoneCheckListeners = 0 + + const sendPromise = promisify(ws.send) + /** generate message tag and increment epoch */ + const generateMessageTag = (longTag: boolean = false) => { + const tag = `${longTag ? referenceDateSeconds : (referenceDateSeconds%1000)}.--${epoch}` + epoch += 1 // increment message count, it makes the 'epoch' field when sending binary messages + return tag + } + const sendRawMessage = (data: Buffer | string) => sendPromise.call(ws, data) as Promise + /** + * Send a message to the WA servers + * @returns the tag attached in the message + * */ + const sendMessage = async( + { json, binaryTag, tag, longTag }: SocketSendMessageOptions + ) => { + tag = tag || generateMessageTag(longTag) + let data: Buffer | string + if(logger.level === 'trace') { + logger.trace({ tag, fromMe: true, json, binaryTag }, 'communication') + } + + if(binaryTag) { + if(!(json instanceof BinaryNode)) { + throw new Boom(`Invalid binary message of type "${typeof json}". Must be BinaryNode`, { statusCode: 400 }) + } + if(!authInfo) { + throw new Boom('No encryption/mac keys to encrypt node with', { statusCode: 400 }) + } + const binary = json.toBuffer() // encode the JSON to the WhatsApp binary format + + const buff = aesEncrypt(binary, authInfo.encKey) // encrypt it using AES and our encKey + const sign = hmacSign(buff, authInfo.macKey) // sign the message using HMAC and our macKey + + data = Buffer.concat([ + Buffer.from(tag + ','), // generate & prefix the message tag + Buffer.from(binaryTag), // prefix some bytes that tell whatsapp what the message is about + sign, // the HMAC sign of the message + buff, // the actual encrypted buffer + ]) + } else { + data = `${tag},${JSON.stringify(json)}` + } + await sendRawMessage(data) + return tag + } + const end = (error: Error | undefined) => { + ws.removeAllListeners('close') + ws.removeAllListeners('error') + ws.removeAllListeners('open') + ws.removeAllListeners('message') + + phoneCheckListeners = 0 + clearInterval(keepAliveReq) + clearPhoneCheckInterval() + + if(ws.readyState !== ws.CLOSED && ws.readyState !== ws.CLOSING) { + socketEvents.emit('ws-close', error) + try { ws.close() } catch { } + } + } + const onMessageRecieved = (message: string | Buffer) => { + if(message[0] === '!') { + // when the first character in the message is an '!', the server is sending a pong frame + const timestamp = message.slice(1, message.length).toString ('utf-8') + lastDateRecv = new Date(parseInt(timestamp)) + socketEvents.emit('received-pong') + } else { + let messageTag: string + let json: any + try { + const dec = decodeWAMessage(message, authInfo) + messageTag = dec[0] + json = dec[1] + if (!json) return + } catch (error) { + end(error) + return + } + //if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false }) + + if (logger.level === 'trace') { + logger.trace({ tag: messageTag, fromMe: false, json }, 'communication') + } + + let anyTriggered = false + /* Check if this is a response to a message we sent */ + anyTriggered = socketEvents.emit(`${DEF_TAG_PREFIX}${messageTag}`, json) + /* Check if this is a response to a message we are expecting */ + const l0 = json.header || json[0] || '' + const l1 = json?.attributes || json?.[1] || { } + const l2 = ((json.data || json[2] || [])[0] || [])[0] || '' + + Object.keys(l1).forEach(key => { + anyTriggered = socketEvents.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, json) || anyTriggered + anyTriggered = socketEvents.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, json) || anyTriggered + anyTriggered = socketEvents.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, json) || anyTriggered + }) + anyTriggered = socketEvents.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered + anyTriggered = socketEvents.emit(`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered + + if (!anyTriggered && logger.level === 'debug') { + logger.debug({ unhandled: true, tag: messageTag, fromMe: false, json }, 'communication recv') + } + } + } + + /** Exits a query if the phone connection is active and no response is still found */ + const exitQueryIfResponseNotExpected = (tag: string, cancel: (error: Boom) => void) => { + let timeout: NodeJS.Timeout + const listener = ([, connected]) => { + if(connected) { + timeout = setTimeout(() => { + logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`) + cancel(new Boom('Not expecting a response', { statusCode: 422 })) + }, expectResponseTimeout) + socketEvents.off(PHONE_CONNECTION_CB, listener) + } + } + socketEvents.on(PHONE_CONNECTION_CB, listener) + return () => { + socketEvents.off(PHONE_CONNECTION_CB, listener) + timeout && clearTimeout(timeout) + } + } + /** interval is started when a query takes too long to respond */ + const startPhoneCheckInterval = () => { + phoneCheckListeners += 1 + if (!phoneCheckInterval) { + // if its been a long time and we haven't heard back from WA, send a ping + phoneCheckInterval = setInterval(() => { + if(phoneCheckListeners <= 0) { + logger.warn('phone check called without listeners') + return + } + logger.info('checking phone connection...') + sendAdminTest() + + phoneConnectionChanged(false) + }, phoneResponseTimeMs) + } + } + const clearPhoneCheckInterval = () => { + phoneCheckListeners -= 1 + if (phoneCheckListeners <= 0) { + clearInterval(phoneCheckInterval) + phoneCheckInterval = undefined + phoneCheckListeners = 0 + } + } + /** checks for phone connection */ + const sendAdminTest = () => sendMessage({ json: ['admin', 'test'] }) + /** + * Wait for a message with a certain tag to be received + * @param tag the message tag to await + * @param json query that was sent + * @param timeoutMs timeout after which the promise will reject + */ + const waitForMessage = async(tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) => { + let onRecv: (json) => void + let onErr: (err) => void + let cancelPhoneChecker: () => void + if (requiresPhoneConnection) { + startPhoneCheckInterval() + cancelPhoneChecker = exitQueryIfResponseNotExpected(tag, onErr) + } + try { + const result = await promiseTimeout(timeoutMs, + (resolve, reject) => { + onRecv = resolve + onErr = err => reject(err || new Boom('Connection Closed', { statusCode: 429 })) + + socketEvents.on(`TAG:${tag}`, onRecv) + socketEvents.on('ws-close', onErr) // if the socket closes, you'll never receive the message + }, + ) + return result as any + } finally { + requiresPhoneConnection && clearPhoneCheckInterval() + cancelPhoneChecker && cancelPhoneChecker() + + socketEvents.off(`TAG:${tag}`, onRecv) + socketEvents.off(`ws-close`, onErr) + } + } + /** + * Query something from the WhatsApp servers + * @param json the query itself + * @param binaryTags the tags to attach if the query is supposed to be sent encoded in binary + * @param timeoutMs timeout after which the query will be failed (set to null to disable a timeout) + * @param tag the tag to attach to the message + */ + const query = async( + {json, timeoutMs, expect200, tag, longTag, binaryTag, requiresPhoneConnection}: SocketQueryOptions + ) => { + tag = tag || generateMessageTag(longTag) + const promise = waitForMessage(tag, requiresPhoneConnection, timeoutMs) + + await sendMessage({ json, tag, binaryTag }) + const response = await promise + const responseStatusCode = +(response.status ? response.status : 200) // default status + // read here: http://getstatuscode.com/599 + if(responseStatusCode === 599) { // the connection has gone bad + end(new Boom('WA server overloaded', { statusCode: 599 })) + } + if(expect200 && Math.floor(responseStatusCode/100) !== 2) { + const message = STATUS_CODES[responseStatusCode] || 'unknown' + throw new Boom( + `Unexpected status in '${Object.values(json)[0] || 'query'}': ${message}(${responseStatusCode})`, + { data: { query: json, message }, statusCode: response.status } + ) + } + return response + } + const startKeepAliveRequest = () => ( + keepAliveReq = setInterval(() => { + if (!lastDateRecv) lastDateRecv = new Date() + const diff = Date.now() - lastDateRecv.getTime() + /* + check if it's been a suspicious amount of time since the server responded with our last seen + it could be that the network is down + */ + if (diff > keepAliveIntervalMs+5000) { + end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) + } else if(ws.readyState === ws.OPEN) { + sendRawMessage('?,,') // if its all good, send a keep alive request + } else { + logger.warn('keep alive called when WS not open') + } + }, keepAliveIntervalMs) + ) + + const waitForSocketOpen = async() => { + if(ws.readyState === ws.OPEN) return + if(ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + throw Boom.preconditionRequired('Connection Closed') + } + let onOpen: () => void + let onClose: (err: Error) => void + await new Promise((resolve, reject) => { + onOpen = () => resolve(undefined) + onClose = reject + socketEvents.on('ws-open', onOpen) + socketEvents.on('ws-close', onClose) + }) + .finally(() => { + socketEvents.off('ws-open', onOpen) + socketEvents.off('ws-close', onClose) + }) + } + + ws.on('message', onMessageRecieved) + ws.on('open', () => { + startKeepAliveRequest() + + logger.info('Opened WS connection to WhatsApp Web') + socketEvents.emit('ws-open') + }) + ws.on('error', end) + ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionLost }))) + + socketEvents.on(PHONE_CONNECTION_CB, json => { + if (!json[1]) { + end(new Boom('Connection terminated by phone', { statusCode: DisconnectReason.connectionLost })) + logger.info('Connection terminated by phone, closing...') + } else { + phoneConnectionChanged(true) + } + }) + socketEvents.on('CB:Cmd,type:disconnect', json => { + const {kind} = json[1] + let reason: DisconnectReason + switch(kind) { + case 'replaced': + reason = DisconnectReason.connectionReplaced + break + default: + reason = DisconnectReason.connectionLost + break + } + end(new Boom( + `Connection terminated by server: "${kind || 'unknown'}"`, + { statusCode: reason } + )) + }) + + return { + socketEvents, + ws, + updateKeys: (info: { encKey: Buffer, macKey: Buffer }) => authInfo = info, + waitForSocketOpen, + sendRawMessage, + sendMessage, + generateMessageTag, + waitForMessage, + query, + /** Generic function for action, set queries */ + setQuery: async(nodes: BinaryNode[], binaryTag: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) => ( + query({ + json: ['action', { epoch: epoch.toString(), type: 'set' }, nodes], + binaryTag, + tag, + expect200: true, + requiresPhoneConnection: true + }) as Promise<{ status: number }> + ), + currentEpoch: () => epoch, + end + } +} +export type Socket = ReturnType \ No newline at end of file diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts new file mode 100644 index 0000000..79fc2d8 --- /dev/null +++ b/src/Defaults/index.ts @@ -0,0 +1,29 @@ +import P from "pino" +import type { SocketConfig } from "../Types" +import { Browsers } from "../Utils/generics" + +export const UNAUTHORIZED_CODES = [401, 403, 419] + +export const DEFAULT_ORIGIN = 'https://web.whatsapp.com' +export const DEF_CALLBACK_PREFIX = 'CB:' +export const DEF_TAG_PREFIX = 'TAG:' +export const PHONE_CONNECTION_CB = 'CB:Pong' + +export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { + version: [2, 2123, 8], + browser: Browsers.baileys('Chrome'), + + waWebSocketUrl: 'wss://web.whatsapp.com/ws', + keepAliveIntervalMs: 25_000, + phoneResponseTimeMs: 15_000, + connectTimeoutMs: 30_000, + expectResponseTimeout: 12_000, + logger: P().child({ class: 'baileys' }), + phoneConnectionChanged: () => { }, + maxRetries: 5, + connectCooldownMs: 2500, + pendingRequestTimeoutMs: undefined, + reconnectMode: 'on-connection-error', + maxQRCodes: Infinity, + printQRInTerminal: false, +} diff --git a/src/Tests/Common.ts b/src/Tests/Common.ts index 08e46ef..942f5be 100644 --- a/src/Tests/Common.ts +++ b/src/Tests/Common.ts @@ -39,7 +39,7 @@ export async function sendAndRetrieveMessage(conn: WAConnection, content, type: const chat = conn.chats.get(recipientJid) assert.ok (chat.messages.get(GET_MESSAGE_ID(message.key))) - assert.ok (chat.t >= (unixTimestampSeconds()-5), `expected: ${chat.t} > ${(unixTimestampSeconds()-5)}`) + assert.ok (chat.t >= (unixTimestampSeconds()-5) ) return message } export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => ( diff --git a/src/Tests/Tests.Messages.ts b/src/Tests/Tests.Messages.ts index a9b43fc..f271a0f 100644 --- a/src/Tests/Tests.Messages.ts +++ b/src/Tests/Tests.Messages.ts @@ -72,7 +72,7 @@ WAConnectionTest('Messages', conn => { assert.ok (message.message.audioMessage.seconds > 0) await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud') }) - it('should send a voice note', async () => { + it('should send an audio as a voice note', async () => { const content = await fs.readFile('./Media/sonata.mp3') const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio, ptt: true }) diff --git a/src/Tests/test.binary.ts b/src/Tests/test.binary.ts new file mode 100644 index 0000000..7263626 --- /dev/null +++ b/src/Tests/test.binary.ts @@ -0,0 +1,95 @@ +import BinaryNode from '../BinaryNode' + +describe('Binary Coding Tests', () => { + + const TEST_VECTORS: [string, BinaryNode][] = [ + [ + 'f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305', + new BinaryNode( + 'action', + { last: 'true', add: 'before' }, + [ + new BinaryNode( + 'message', + {}, + { + key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB009675E7ED37AABF2' }, + message: { conversation: '*piano room timings are:*\n 6:00AM-12:00AM' }, + messageTimestamp: '1584004403', + status: 'DELIVERY_ACK', + } as any + ), + new BinaryNode( + 'message', + {}, + { + key: { + remoteJid: '917529871117@s.whatsapp.net', + fromMe: false, + id: '0FCEC5330F64929C6E9A2CFFD2BC8EAD', + }, + messageTimestamp: '1584004413', + messageStubType: 'REVOKE', + } as any + ), + new BinaryNode( + 'message', + {}, + { + key: { remoteJid: '917529871117@s.whatsapp.net', fromMe: true, id: '3EB003C7B539AFD09753' }, + message: { + conversation: + "Sorry fren, I couldn't understand 'Libra'. Type 'help' to know what all I can do", + }, + messageTimestamp: '1584004417', + status: 'DELIVERY_ACK', + } as any + ), + new BinaryNode( + 'message', + {}, + { + key: { + remoteJid: '917529871117@s.whatsapp.net', + fromMe: false, + id: 'A1230B8D6B0A1D793EC2AE2EA0C168D8', + }, + message: { conversation: 'library' }, + messageTimestamp: '1584004418', + } as any + ), + ] + ) + ], + [ + 'f8063f2dfafc0831323334353637385027fc0431323334f801f80228fc0701020304050607', + new BinaryNode( + 'picture', + {jid: '12345678@s.whatsapp.net', id: '1234'}, + [ + new BinaryNode( + 'image', + {}, + Buffer.from([1,2,3,4,5,6,7]) + ) + ] + ) + ] + ] + it('should encode/decode strings', () => { + for(const [input, output] of TEST_VECTORS) { + const buff = Buffer.from(input, 'hex') + const node = BinaryNode.from(buff) + expect( + JSON.parse(JSON.stringify(node)) + ).toStrictEqual( + JSON.parse(JSON.stringify(output)) + ) + expect( + node.toBuffer().toString('hex') + ).toStrictEqual( + input + ) + } + }) +}) diff --git a/src/Tests/test.connect.ts b/src/Tests/test.connect.ts new file mode 100644 index 0000000..155f729 --- /dev/null +++ b/src/Tests/test.connect.ts @@ -0,0 +1,200 @@ +import Boom from 'boom' +import P from 'pino' +import BinaryNode from '../BinaryNode' +import makeConnection, { Connection, DisconnectReason } from '../makeConnection' +import { delay } from '../WAConnection/Utils' + +describe('QR Generation', () => { + it('should generate QR', async () => { + const QR_GENS = 1 + const {ev, open} = makeConnection({ + maxRetries: 0, + maxQRCodes: QR_GENS, + logger: P({ level: 'trace' }) + }) + let calledQR = 0 + ev.removeAllListeners('qr') + ev.on('state.update', ({ qr }) => { + if(qr) calledQR += 1 + }) + + await expect(open()).rejects.toThrowError('Too many QR codes') + expect( + Object.keys(ev.eventNames()).filter(key => key.startsWith('TAG:')) + ).toHaveLength(0) + expect(calledQR).toBeGreaterThanOrEqual(QR_GENS) + }, 60_000) +}) + +describe('Test Connect', () => { + const logger = P({ level: 'trace' }) + it('should connect', async () => { + + logger.info('please be ready to scan with your phone') + + const conn = makeConnection({ + logger, + printQRInTerminal: true + }) + await conn.open() + const { user, isNewLogin } = await conn.getState() + expect(user).toHaveProperty('jid') + expect(user).toHaveProperty('name') + expect(isNewLogin).toBe(true) + + conn.close() + }, 65_000) + + it('should restore session', async () => { + const conn = makeConnection({ + printQRInTerminal: true, + logger, + }) + await conn.open() + conn.close() + + await delay(2500) + + await conn.open() + const { user, isNewLogin, qr } = await conn.getState() + expect(user).toHaveProperty('jid') + expect(user).toHaveProperty('name') + expect(isNewLogin).toBe(false) + expect(qr).toBe(undefined) + + conn.close() + }, 65_000) + + it('should logout', async () => { + let conn = makeConnection({ + printQRInTerminal: true, + logger, + }) + await conn.open() + const { user, qr } = await conn.getState() + expect(user).toHaveProperty('jid') + expect(user).toHaveProperty('name') + expect(qr).toBe(undefined) + + const credentials = conn.getAuthInfo() + await conn.logout() + + conn = makeConnection({ + credentials, + logger + }) + await expect(conn.open()).rejects.toThrowError('Unexpected error in login') + }, 65_000) +}) + +describe ('Reconnects', () => { + const verifyConnectionOpen = async (conn: Connection) => { + expect((await conn.getState()).user).toBeDefined() + let failed = false + // check that the connection stays open + conn.ev.on('state.update', ({ connection, lastDisconnect }) => { + if(connection === 'close' && !!lastDisconnect.error) { + failed = true + } + }) + await delay (60*1000) + conn.close () + + expect(failed).toBe(false) + } + it('should dispose correctly on bad_session', async () => { + const conn = makeConnection({ + reconnectMode: 'on-any-error', + credentials: './auth_info.json', + maxRetries: 2, + connectCooldownMs: 500 + }) + let gotClose0 = false + let gotClose1 = false + + const openPromise = conn.open() + + conn.getSocket().ev.once('ws-close', () => { + gotClose0 = true + }) + conn.ev.on('state.update', ({ lastDisconnect }) => { + //@ts-ignore + if(lastDisconnect?.error?.output?.statusCode === DisconnectReason.badSession) { + gotClose1 = true + } + }) + setTimeout (() => conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')), 1500) + await openPromise + + console.log('opened connection') + + await delay(1000) + conn.getSocket().ws.emit ('message', Buffer.from('some-tag,sdjjij1jo2ejo1je')) + + await delay(2000) + await conn.waitForConnection() + + conn.close() + + expect(gotClose0).toBe(true) + expect(gotClose1).toBe(true) + }, 20_000) + /** + * the idea is to test closing the connection at multiple points in the connection + * and see if the library cleans up resources correctly + */ + it('should cleanup correctly', async () => { + const conn = makeConnection({ + reconnectMode: 'on-any-error', + credentials: './auth_info.json' + }) + let timeoutMs = 100 + while (true) { + let tmout = setTimeout (() => { + conn.close() + }, timeoutMs) + try { + await conn.open() + clearTimeout (tmout) + break + } catch (error) { + + } + // exponentially increase the timeout disconnect + timeoutMs *= 2 + } + await verifyConnectionOpen(conn) + }, 120_000) + /** + * the idea is to test closing the connection at multiple points in the connection + * and see if the library cleans up resources correctly + */ + it('should disrupt connect loop', async () => { + const conn = makeConnection({ + reconnectMode: 'on-any-error', + credentials: './auth_info.json' + }) + + let timeout = 1000 + let tmout + const endConnection = async () => { + while (!conn.getSocket()) { + await delay(100) + } + conn.getSocket().end(Boom.preconditionRequired('conn close')) + + while (conn.getSocket()) { + await delay(100) + } + + timeout *= 2 + tmout = setTimeout (endConnection, timeout) + } + tmout = setTimeout (endConnection, timeout) + + await conn.open() + clearTimeout (tmout) + + await verifyConnectionOpen(conn) + }, 120_000) +}) \ No newline at end of file diff --git a/src/Tests/test.queries.ts b/src/Tests/test.queries.ts new file mode 100644 index 0000000..f9e28ed --- /dev/null +++ b/src/Tests/test.queries.ts @@ -0,0 +1,165 @@ +import BinaryNode from '../BinaryNode' +import makeConnection from '../makeConnection' +import { delay } from '../WAConnection/Utils' + +describe('Queries', () => { + /*it ('should correctly send updates for chats', async () => { + const conn = makeConnection({ + pendingRequestTimeoutMs: undefined, + credentials: './auth_info.json' + }) + const task = new Promise(resolve => conn.once('chats-received', resolve)) + await conn.connect () + await task + + conn.close () + + const oldChat = conn.chats.all()[0] + oldChat.archive = 'true' // mark the first chat as archived + oldChat.modify_tag = '1234' // change modify tag to detect change + + const promise = new Promise(resolve => conn.once('chats-update', resolve)) + + const result = await conn.connect () + assert.ok (!result.newConnection) + + const chats = await promise as Partial[] + const chat = chats.find (c => c.jid === oldChat.jid) + assert.ok (chat) + + assert.ok ('archive' in chat) + assert.strictEqual (Object.keys(chat).length, 3) + assert.strictEqual (Object.keys(chats).length, 1) + + conn.close () + }) + it ('should correctly send updates for contacts', async () => { + const conn = makeConnection () + conn.pendingRequestTimeoutMs = null + conn.loadAuthInfo('./auth_info.json') + + const task: any = new Promise(resolve => conn.once('contacts-received', resolve)) + await conn.connect () + const initialResult = await task + assert.strictEqual( + initialResult.updatedContacts.length, + Object.keys(conn.contacts).length + ) + + + conn.close () + + const [jid] = Object.keys(conn.contacts) + const oldContact = conn.contacts[jid] + oldContact.name = 'Lol' + oldContact.index = 'L' + + const promise = new Promise(resolve => conn.once('contacts-received', resolve)) + + const result = await conn.connect () + assert.ok (!result.newConnection) + + const {updatedContacts} = await promise as { updatedContacts: Partial[] } + const contact = updatedContacts.find (c => c.jid === jid) + assert.ok (contact) + + assert.ok ('name' in contact) + assert.strictEqual (Object.keys(contact).length, 3) + assert.strictEqual (Object.keys(updatedContacts).length, 1) + + conn.close () + })*/ + it('should queue requests when closed', async () => { + const conn = makeConnection({ + credentials: './auth_info.json' + }) + await conn.open() + await delay(2000) + + conn.close() + const { user: { jid } } = await conn.getState() + const task: Promise = conn.query({ + json: ['query', 'Status', jid] + }) + + await delay(2000) + + conn.open() + const json = await task + + expect(json.status).toBeDefined() + + conn.close() + }, 65_000) + + it('[MANUAL] should recieve query response after phone disconnect', async () => { + const conn = makeConnection ({ + printQRInTerminal: true, + credentials: './auth_info.json' + }) + await conn.open() + const { phoneConnected } = await conn.getState() + expect(phoneConnected).toBe(true) + + try { + const waitForEvent = expect => new Promise (resolve => { + conn.ev.on('state.update', ({phoneConnected}) => { + if (phoneConnected === expect) { + conn.ev.removeAllListeners('state.update') + resolve(undefined) + } + }) + }) + + console.log('disconnect your phone from the internet') + await delay(10_000) + console.log('phone should be disconnected now, testing...') + + const query = conn.query({ + json: new BinaryNode( + 'query', + { + epoch: conn.getSocket().currentEpoch().toString(), + type: 'message', + jid: '1234@s.whatsapp.net', + kind: 'before', + count: '10', + } + ), + requiresPhoneConnection: true, + expect200: false + }) + await waitForEvent(false) + + console.log('reconnect your phone to the internet') + await waitForEvent(true) + + console.log('reconnected successfully') + + await expect(query).resolves.toBeDefined() + } finally { + conn.close() + } + }, 65_000) + + it('should re-execute query on connection closed error', async () => { + const conn = makeConnection({ + credentials: './auth_info.json' + }) + await conn.open() + const { user: { jid } } = await conn.getState() + const task: Promise = conn.query({ json: ['query', 'Status', jid], waitForOpen: true }) + + await delay(20) + // fake cancel the connection + conn.getSocket().ev.emit('message', '1234,["Pong",false]') + + await delay(2000) + + const json = await task + + expect(json.status).toBeDefined() + + conn.close() + }, 65_000) +}) \ No newline at end of file diff --git a/src/Types/Auth.ts b/src/Types/Auth.ts new file mode 100644 index 0000000..aeaa907 --- /dev/null +++ b/src/Types/Auth.ts @@ -0,0 +1,22 @@ + +export interface AuthenticationCredentials { + clientID: string + serverToken: string + clientToken: string + encKey: Buffer + macKey: Buffer +} +export interface AuthenticationCredentialsBase64 { + clientID: string + serverToken: string + clientToken: string + encKey: string + macKey: string +} +export interface AuthenticationCredentialsBrowser { + WABrowserId: string + WASecretBundle: {encKey: string, macKey: string} | string + WAToken1: string + WAToken2: string +} +export type AnyAuthenticationCredentials = AuthenticationCredentialsBrowser | AuthenticationCredentialsBase64 | AuthenticationCredentials \ No newline at end of file diff --git a/src/Types/Chat.ts b/src/Types/Chat.ts new file mode 100644 index 0000000..5e3ac08 --- /dev/null +++ b/src/Types/Chat.ts @@ -0,0 +1,44 @@ +import type KeyedDB from "@adiwajshing/keyed-db"; +import type { proto } from '../../WAMessage/WAMessage' +import type { GroupMetadata } from "./GroupMetadata"; + +/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ +export enum Presence { + unavailable = 'unavailable', // "offline" + available = 'available', // "online" + composing = 'composing', // "typing..." + recording = 'recording', // "recording..." + paused = 'paused', // stop typing +} + +export interface PresenceData { + lastKnownPresence?: Presence + lastSeen?: number + name?: string +} + +export interface Chat { + jid: string + + t: number + /** number of unread messages, is < 0 if the chat is manually marked unread */ + count: number + archive?: 'true' | 'false' + clear?: 'true' | 'false' + read_only?: 'true' | 'false' + mute?: string + pin?: string + spam?: 'false' | 'true' + modify_tag?: string + name?: string + /** when ephemeral messages were toggled on */ + eph_setting_ts?: string + /** how long each message lasts for */ + ephemeral?: string + + // Baileys added properties + messages: KeyedDB + imgUrl?: string + presences?: { [k: string]: PresenceData } + metadata?: GroupMetadata +} \ No newline at end of file diff --git a/src/Types/Contact.ts b/src/Types/Contact.ts new file mode 100644 index 0000000..2103534 --- /dev/null +++ b/src/Types/Contact.ts @@ -0,0 +1,15 @@ +export interface Contact { + verify?: string + /** name of the contact, the contact has set on their own on WA */ + notify?: string + jid: string + /** I have no idea */ + vname?: string + /** name of the contact, you have saved on your WA */ + name?: string + index?: string + /** short name for the contact */ + short?: string + // Baileys Added + imgUrl?: string +} \ No newline at end of file diff --git a/src/Types/GroupMetadata.ts b/src/Types/GroupMetadata.ts new file mode 100644 index 0000000..1d5a6c4 --- /dev/null +++ b/src/Types/GroupMetadata.ts @@ -0,0 +1,19 @@ +import { Contact } from "./Contact"; + +export type GroupParticipant = (Contact & { isAdmin: boolean; isSuperAdmin: boolean }) + +export interface GroupMetadata { + id: string + owner: string + subject: string + creation: number + desc?: string + descOwner?: string + descId?: string + /** is set when the group only allows admins to change group settings */ + restrict?: 'true' | 'false' + /** is set when the group only allows admins to write messages */ + announce?: 'true' | 'false' + // Baileys modified array + participants: GroupParticipant[] +} \ No newline at end of file diff --git a/src/Types/Store.ts b/src/Types/Store.ts new file mode 100644 index 0000000..9c5c65e --- /dev/null +++ b/src/Types/Store.ts @@ -0,0 +1,25 @@ +import type KeyedDB from '@adiwajshing/keyed-db' +import type { Chat } from './Chat' +import type { Contact } from './Contact' + +export type WAConnectionState = 'open' | 'connecting' | 'close' + +export type ConnectionState = { + user?: Contact + phoneConnected: boolean + phoneInfo?: any + connection: WAConnectionState + lastDisconnect?: { + error: Error, + date: Date + }, + isNewLogin?: boolean + connectionTriesLeft?: number + qr?: string +} + +export type BaileysState = { + connection: ConnectionState + chats: KeyedDB + contacts: { [jid: string]: Contact } +} \ No newline at end of file diff --git a/src/Types/index.ts b/src/Types/index.ts new file mode 100644 index 0000000..b16999b --- /dev/null +++ b/src/Types/index.ts @@ -0,0 +1,147 @@ +export * from './Auth' +export * from './GroupMetadata' +export * from './Chat' +export * from './Contact' +export * from './Store' + +import type EventEmitter from "events" +import type { Agent } from "https" +import type { Logger } from "pino" +import type { URL } from "url" +import type BinaryNode from "../BinaryNode" +import { AnyAuthenticationCredentials } from './Auth' +import { ConnectionState } from './Store' + +/** used for binary messages */ +export enum WAMetric { + debugLog = 1, + queryResume = 2, + liveLocation = 3, + queryMedia = 4, + queryChat = 5, + queryContact = 6, + queryMessages = 7, + presence = 8, + presenceSubscribe = 9, + group = 10, + read = 11, + chat = 12, + received = 13, + picture = 14, + status = 15, + message = 16, + queryActions = 17, + block = 18, + queryGroup = 19, + queryPreview = 20, + queryEmoji = 21, + queryRead = 22, + queryVCard = 29, + queryStatus = 30, + queryStatusUpdate = 31, + queryLiveLocation = 33, + queryLabel = 36, + queryQuickReply = 39 +} + +/** used for binary messages */ +export enum WAFlag { + available = 160, + other = 136, // don't know this one + ignore = 1 << 7, + acknowledge = 1 << 6, + unavailable = 1 << 4, + expires = 1 << 3, + composing = 1 << 2, + recording = 1 << 2, + paused = 1 << 2 +} + +/** Tag used with binary queries */ +export type WATag = [WAMetric, WAFlag] + +export type SocketSendMessageOptions = { + json: BinaryNode | any[] + binaryTag?: WATag + tag?: string + longTag?: boolean +} + +export type WAVersion = [number, number, number] +export type WABrowserDescription = [string, string, string] +export type ReconnectMode = 'no-reconnects' | 'on-any-error' | 'on-connection-error' + +export type SocketConfig = { + /** the WS url to connect to WA */ + waWebSocketUrl: string | URL + /** Fails the connection if the connection times out in this time interval or no data is received */ + connectTimeoutMs: number + /** max time for the phone to respond to a connectivity test */ + phoneResponseTimeMs: number + /** ping-pong interval for WS connection */ + keepAliveIntervalMs: number + + expectResponseTimeout: number + /** proxy agent */ + agent?: Agent + logger: Logger + + version: WAVersion + browser: WABrowserDescription + /** maximum attempts to connect */ + maxRetries: number + connectCooldownMs: number + /** agent used for fetch requests -- uploading/downloading media */ + fetchAgent?: Agent + /** credentials used to sign back in */ + credentials?: AnyAuthenticationCredentials | string + /** + * Sometimes WA does not send the chats, + * this keeps pinging the phone to send the chats over + * */ + queryChatsTillReceived?: boolean + /** */ + pendingRequestTimeoutMs: number + reconnectMode: ReconnectMode + maxQRCodes: number + /** should the QR be printed in the terminal */ + printQRInTerminal: boolean + + phoneConnectionChanged: (connected: boolean) => void +} + +export type SocketQueryOptions = SocketSendMessageOptions & { + timeoutMs?: number + expect200?: boolean + requiresPhoneConnection?: boolean +} + +export enum DisconnectReason { + connectionClosedIntentionally = 428, + connectionReplaced = 440, + connectionLost = 408, + timedOut = 408, + credentialsInvalidated = 401, + badSession = 500 +} + +export type WAInitResponse = { + ref: string + ttl: number + status: 200 +} + +export type QueryOptions = SocketQueryOptions & { + waitForOpen?: boolean + maxRetries?: number + startDebouncedTimeout?: boolean +} +export type CurveKeyPair = { private: Uint8Array; public: Uint8Array } + +export type BaileysEventMap = { + 'connection.update': Partial +} +export interface BaileysEventEmitter extends EventEmitter { + on(event: T, listener: (arg: BaileysEventMap[T]) => void): this + emit(event: T, arg: BaileysEventMap[T]): boolean +} \ No newline at end of file diff --git a/src/Utils/decodeWAMessage.ts b/src/Utils/decodeWAMessage.ts new file mode 100644 index 0000000..c0d759c --- /dev/null +++ b/src/Utils/decodeWAMessage.ts @@ -0,0 +1,63 @@ +import Boom from "boom" +import BinaryNode from "../BinaryNode" +import { aesDecrypt, hmacSign } from "./generics" +import { DisconnectReason, WATag } from "../Types" + +export const decodeWAMessage = ( + message: string | Buffer, + auth: { macKey: Buffer, encKey: Buffer }, + fromMe: boolean=false +) => { + + let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message + if (commaIndex < 0) throw new Boom('invalid message', { data: message }) // if there was no comma, then this message must be not be valid + + if (message[commaIndex+1] === ',') commaIndex += 1 + let data = message.slice(commaIndex+1, message.length) + + // get the message tag. + // If a query was done, the server will respond with the same message tag we sent the query with + const messageTag: string = message.slice(0, commaIndex).toString() + let json: any + let tags: WATag + if (data.length > 0) { + if (typeof data === 'string') { + json = JSON.parse(data) // parse the JSON + } else { + const { macKey, encKey } = auth || {} + if (!macKey || !encKey) { + throw new Boom('recieved encrypted buffer when auth creds unavailable', { data: message, statusCode: DisconnectReason.badSession }) + } + /* + If the data recieved was not a JSON, then it must be an encrypted message. + Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys + */ + if (fromMe) { + tags = [data[0], data[1]] + data = data.slice(2, data.length) + } + + const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message + data = data.slice(32, data.length) // the actual message + const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey + + if (checksum.equals(computedChecksum)) { + // the checksum the server sent, must match the one we computed for the message to be valid + const decrypted = aesDecrypt(data, encKey) // decrypt using AES + json = BinaryNode.from(decrypted) // decode the binary message into a JSON array + } else { + throw new Boom('Bad checksum', { + data: { + received: checksum.toString('hex'), + computed: computedChecksum.toString('hex'), + data: data.slice(0, 80).toString(), + tag: messageTag, + message: message.slice(0, 80).toString() + }, + statusCode: DisconnectReason.badSession + }) + } + } + } + return [messageTag, json, tags] as const +} \ No newline at end of file diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts new file mode 100644 index 0000000..b524074 --- /dev/null +++ b/src/Utils/generics.ts @@ -0,0 +1,148 @@ +import Boom from 'boom' +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto' +import HKDF from 'futoin-hkdf' +import { platform, release } from 'os' + +const PLATFORM_MAP = { + 'aix': 'AIX', + 'darwin': 'Mac OS', + 'win32': 'Windows', + 'android': 'Android' +} +export const Browsers = { + ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string], + macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string], + baileys: browser => ['Baileys', browser, '4.0.0'] as [string, string, string], + /** The appropriate browser based on your OS & release */ + appropriate: browser => [ PLATFORM_MAP[platform()] || 'Ubuntu', browser, release() ] as [string, string, string] +} +export const toNumber = (t: Long | number) => (t['low'] || t) as number + +export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net') +export const isGroupID = (jid: string) => jid?.endsWith ('@g.us') + +export function shallowChanges (old: T, current: T, {lookForDeletedKeys}: {lookForDeletedKeys: boolean}): Partial { + let changes: Partial = {} + for (let key in current) { + if (old[key] !== current[key]) { + changes[key] = current[key] || null + } + } + if (lookForDeletedKeys) { + for (let key in old) { + if (!changes[key] && old[key] !== current[key]) { + changes[key] = current[key] || null + } + } + } + return changes +} + +/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */ +export function aesDecrypt(buffer: Buffer, key: Buffer) { + return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16)) +} +/** decrypt AES 256 CBC */ +export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { + const aes = createDecipheriv('aes-256-cbc', key, IV) + return Buffer.concat([aes.update(buffer), aes.final()]) +} +// encrypt AES 256 CBC; where a random IV is prefixed to the buffer +export function aesEncrypt(buffer: Buffer, key: Buffer) { + const IV = randomBytes(16) + const aes = createCipheriv('aes-256-cbc', key, IV) + return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer +} +// encrypt AES 256 CBC with a given IV +export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { + const aes = createCipheriv('aes-256-cbc', key, IV) + return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer +} +// sign HMAC using SHA 256 +export function hmacSign(buffer: Buffer, key: Buffer) { + return createHmac('sha256', key).update(buffer).digest() +} +export function sha256(buffer: Buffer) { + return createHash('sha256').update(buffer).digest() +} +// HKDF key expansion +export function hkdf(buffer: Buffer, expandedLength: number, info = null) { + return HKDF(buffer, expandedLength, { salt: Buffer.alloc(32), info: info, hash: 'SHA-256' }) +} +/** unix timestamp of a date in seconds */ +export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000) + +export type DebouncedTimeout = ReturnType +export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => { + let timeout: NodeJS.Timeout + return { + start: (newIntervalMs?: number, newTask?: () => void) => { + task = newTask || task + intervalMs = newIntervalMs || intervalMs + timeout && clearTimeout(timeout) + timeout = setTimeout(task, intervalMs) + }, + cancel: () => { + timeout && clearTimeout(timeout) + timeout = undefined + }, + setTask: (newTask: () => void) => task = newTask, + setInterval: (newInterval: number) => intervalMs = newInterval + } +} + +export const delay = (ms: number) => delayCancellable (ms).delay +export const delayCancellable = (ms: number) => { + const stack = new Error().stack + let timeout: NodeJS.Timeout + let reject: (error) => void + const delay: Promise = new Promise((resolve, _reject) => { + timeout = setTimeout(resolve, ms) + reject = _reject + }) + const cancel = () => { + clearTimeout (timeout) + reject( + new Boom('Cancelled', { + statusCode: 500, + data: { + stack + } + }) + ) + } + return { delay, cancel } +} +export async function promiseTimeout(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) { + if (!ms) return new Promise (promise) + const stack = new Error().stack + // Create a promise that rejects in milliseconds + let {delay, cancel} = delayCancellable (ms) + const p = new Promise ((resolve, reject) => { + delay + .then(() => reject( + new Boom('Timed Out', { + statusCode: 408, + data: { + stack + } + }) + )) + .catch (err => reject(err)) + + promise (resolve, reject) + }) + .finally (cancel) + return p as Promise +} +// whatsapp requires a message tag for every message, we just use the timestamp as one +export function generateMessageTag(epoch?: number) { + let tag = unixTimestampSeconds().toString() + if (epoch) tag += '.--' + epoch // attach epoch if provided + return tag +} +// generate a random 16 byte client ID +export const generateClientID = () => randomBytes(16).toString('base64') +// generate a random ID to attach to a message +// this is the format used for WA Web 4 byte hex prefixed with 3EB0 +export const generateMessageID = () => '3EB0' + randomBytes(4).toString('hex').toUpperCase() \ No newline at end of file diff --git a/src/Utils/validateConnection.ts b/src/Utils/validateConnection.ts new file mode 100644 index 0000000..94f7279 --- /dev/null +++ b/src/Utils/validateConnection.ts @@ -0,0 +1,106 @@ +import Boom from 'boom' +import * as Curve from 'curve25519-js' +import type { Contact } from '../Types/Contact' +import type { AnyAuthenticationCredentials, AuthenticationCredentials, CurveKeyPair } from "../Types" +import { aesDecrypt, hkdf, hmacSign, whatsappID } from './generics' +import { readFileSync } from 'fs' + +export const normalizedAuthInfo = (authInfo: AnyAuthenticationCredentials | string) => { + if (!authInfo) return + + if (typeof authInfo === 'string') { + const file = readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists + authInfo = JSON.parse(file) as AnyAuthenticationCredentials + } + if ('clientID' in authInfo) { + authInfo = { + clientID: authInfo.clientID, + serverToken: authInfo.serverToken, + clientToken: authInfo.clientToken, + encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'), + macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'), + } + } else { + const secretBundle: {encKey: string, macKey: string} = typeof authInfo.WASecretBundle === 'string' ? JSON.parse (authInfo.WASecretBundle): authInfo.WASecretBundle + authInfo = { + clientID: authInfo.WABrowserId.replace(/\"/g, ''), + serverToken: authInfo.WAToken2.replace(/\"/g, ''), + clientToken: authInfo.WAToken1.replace(/\"/g, ''), + encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64 + macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64 + } + } + return authInfo as AuthenticationCredentials +} +/** +* Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in +* @private +* @param json +*/ +export const validateNewConnection = ( + json: { [_: string]: any }, + auth: AuthenticationCredentials, + curveKeys: CurveKeyPair +) => { + // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone + const onValidationSuccess = () => { + const user: Contact = { + jid: whatsappID(json.wid), + name: json.pushname + } + return { user, auth, phone: json.phone } + } + if (!json.secret) { + // if we didn't get a secret, we don't need it, we're validated + if (json.clientToken && json.clientToken !== auth.clientToken) { + auth = { ...auth, clientToken: json.clientToken } + } + if (json.serverToken && json.serverToken !== auth.serverToken) { + auth = { ...auth, serverToken: json.serverToken } + } + return onValidationSuccess() + } + const secret = Buffer.from(json.secret, 'base64') + if (secret.length !== 144) { + throw new Error ('incorrect secret length received: ' + secret.length) + } + + // generate shared key from our private key & the secret shared by the server + const sharedKey = Curve.sharedKey(curveKeys.private, secret.slice(0, 32)) + // expand the key to 80 bytes using HKDF + const expandedKey = hkdf(sharedKey as Buffer, 80) + + // perform HMAC validation. + const hmacValidationKey = expandedKey.slice(32, 64) + const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)]) + + const hmac = hmacSign(hmacValidationMessage, hmacValidationKey) + + if (!hmac.equals(secret.slice(32, 64))) { + // if the checksums didn't match + throw new Boom('HMAC validation failed', { statusCode: 400 }) + } + + // computed HMAC should equal secret[32:64] + // expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp + // they are encrypted using key: expandedKey[0:32] + const encryptedAESKeys = Buffer.concat([ + expandedKey.slice(64, expandedKey.length), + secret.slice(64, secret.length), + ]) + const decryptedKeys = aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32)) + // set the credentials + auth = { + encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages + macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages + clientToken: json.clientToken, + serverToken: json.serverToken, + clientID: auth.clientID, + } + return onValidationSuccess() +} +export const computeChallengeResponse = (challenge: string, auth: AuthenticationCredentials) => { + const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string + const signed = hmacSign(bytes, auth.macKey).toString('base64') // sign the challenge string with our macKey + return[ 'admin', 'challenge', signed, auth.serverToken, auth.clientID] // prepare to send this signed string with the serverToken & clientID +} \ No newline at end of file diff --git a/src/WAConnection/0.Base.ts b/src/WAConnection/0.Base.ts deleted file mode 100644 index 1a3865f..0000000 --- a/src/WAConnection/0.Base.ts +++ /dev/null @@ -1,478 +0,0 @@ -import WS from 'ws' -import * as fs from 'fs' -import * as Utils from './Utils' -import Encoder from '../Binary/Encoder' -import Decoder from '../Binary/Decoder' -import got, { Method } from 'got' -import { - AuthenticationCredentials, - WAUser, - WANode, - WATag, - BaileysError, - WAMetric, - WAFlag, - DisconnectReason, - WAConnectionState, - AnyAuthenticationCredentials, - WAContact, - WAQuery, - ReconnectMode, - WAConnectOptions, - MediaConnInfo, - DEFAULT_ORIGIN, -} from './Constants' -import { EventEmitter } from 'events' -import KeyedDB from '@adiwajshing/keyed-db' -import { STATUS_CODES } from 'http' -import { Agent } from 'https' -import pino from 'pino' - -const logger = pino({ prettyPrint: { levelFirst: true, ignore: 'hostname', translateTime: true }, prettifier: require('pino-pretty') }) - -export class WAConnection extends EventEmitter { - /** The version of WhatsApp Web we're telling the servers we are */ - version: [number, number, number] = [2, 2123, 8] - /** The Browser we're telling the WhatsApp Web servers we are */ - browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome') - /** Metadata like WhatsApp id, name set on WhatsApp etc. */ - user: WAUser - /** Should requests be queued when the connection breaks in between; if 0, then an error will be thrown */ - pendingRequestTimeoutMs: number = null - /** The connection state */ - state: WAConnectionState = 'close' - connectOptions: WAConnectOptions = { - maxIdleTimeMs: 60_000, - maxRetries: 10, - connectCooldownMs: 4000, - phoneResponseTime: 15_000, - maxQueryResponseTime: 10_000, - alwaysUseTakeover: true, - queryChatsTillReceived: true, - logQR: true - } - /** When to auto-reconnect */ - autoReconnect = ReconnectMode.onConnectionLost - /** Whether the phone is connected */ - phoneConnected: boolean = false - /** key to use to order chats */ - chatOrderingKey = Utils.waChatKey(false) - - logger = logger.child ({ class: 'Baileys' }) - - /** log messages */ - shouldLogMessages = false - messageLog: { tag: string, json: string, fromMe: boolean, binaryTags?: any[] }[] = [] - - maxCachedMessages = 50 - - lastChatsReceived: Date - chats = new KeyedDB (Utils.waChatKey(false), value => value.jid) - contacts: { [k: string]: WAContact } = {} - blocklist: string[] = [] - - /** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */ - protected authInfo: AuthenticationCredentials - /** Curve keys to initially authenticate */ - protected curveKeys: { private: Uint8Array; public: Uint8Array } - /** The websocket connection */ - protected conn: WS - protected msgCount = 0 - protected keepAliveReq: NodeJS.Timeout - protected encoder = new Encoder() - protected decoder = new Decoder() - protected phoneCheckInterval - protected phoneCheckListeners = 0 - - protected referenceDate = new Date () // used for generating tags - protected lastSeen: Date = null // last keep alive received - protected initTimeout: NodeJS.Timeout - - protected lastDisconnectTime: Date = null - protected lastDisconnectReason: DisconnectReason - - protected mediaConn: MediaConnInfo - protected connectionDebounceTimeout = Utils.debouncedTimeout( - 1000, - () => this.state === 'connecting' && this.endConnection(DisconnectReason.timedOut) - ) - // timeout to know when we're done recieving messages - protected messagesDebounceTimeout = Utils.debouncedTimeout(2000) - // ping chats till recieved - protected chatsDebounceTimeout = Utils.debouncedTimeout(10_000) - /** - * Connect to WhatsAppWeb - * @param options the connect options - */ - async connect() { - return null - } - async unexpectedDisconnect (error: DisconnectReason) { - if (this.state === 'open') { - const willReconnect = - (this.autoReconnect === ReconnectMode.onAllErrors || - (this.autoReconnect === ReconnectMode.onConnectionLost && error !== DisconnectReason.replaced)) && - error !== DisconnectReason.invalidSession // do not reconnect if credentials have been invalidated - - this.closeInternal(error, willReconnect) - willReconnect && ( - this.connect() - .catch(err => {}) // prevent unhandled exeception - ) - } else { - this.endConnection(error) - } - } - /** - * base 64 encode the authentication credentials and return them - * these can then be used to login again by passing the object to the connect () function. - * @see connect () in WhatsAppWeb.Session - */ - base64EncodedAuthInfo() { - return { - clientID: this.authInfo.clientID, - serverToken: this.authInfo.serverToken, - clientToken: this.authInfo.clientToken, - encKey: this.authInfo.encKey.toString('base64'), - macKey: this.authInfo.macKey.toString('base64'), - } - } - /** Can you login to WA without scanning the QR */ - canLogin () { - return !!this.authInfo?.encKey && !!this.authInfo?.macKey - } - /** Clear authentication info so a new connection can be created */ - clearAuthInfo () { - this.authInfo = null - return this - } - /** - * Load in the authentication credentials - * @param authInfo the authentication credentials or file path to auth credentials - */ - loadAuthInfo(authInfo: AnyAuthenticationCredentials | string) { - if (!authInfo) throw new Error('given authInfo is null') - - if (typeof authInfo === 'string') { - this.logger.info(`loading authentication credentials from ${authInfo}`) - const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists - authInfo = JSON.parse(file) as AnyAuthenticationCredentials - } - if ('clientID' in authInfo) { - this.authInfo = { - clientID: authInfo.clientID, - serverToken: authInfo.serverToken, - clientToken: authInfo.clientToken, - encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'), - macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'), - } - } else { - const secretBundle: {encKey: string, macKey: string} = typeof authInfo.WASecretBundle === 'string' ? JSON.parse (authInfo.WASecretBundle): authInfo.WASecretBundle - this.authInfo = { - clientID: authInfo.WABrowserId.replace(/\"/g, ''), - serverToken: authInfo.WAToken2.replace(/\"/g, ''), - clientToken: authInfo.WAToken1.replace(/\"/g, ''), - encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64 - macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64 - } - } - return this - } - /** - * Wait for a message with a certain tag to be received - * @param tag the message tag to await - * @param json query that was sent - * @param timeoutMs timeout after which the promise will reject - */ - async waitForMessage(tag: string, requiresPhoneConnection: boolean, timeoutMs?: number) { - let onRecv: (json) => void - let onErr: (err) => void - let cancelPhoneChecker: () => void - if (requiresPhoneConnection) { - this.startPhoneCheckInterval() - cancelPhoneChecker = this.exitQueryIfResponseNotExpected(tag, err => onErr(err)) - } - try { - const result = await Utils.promiseTimeout(timeoutMs, - (resolve, reject) => { - onRecv = resolve - onErr = ({ reason, status }) => reject(new BaileysError(reason, { status })) - this.on (`TAG:${tag}`, onRecv) - this.on ('ws-close', onErr) // if the socket closes, you'll never receive the message - }, - ) - return result as any - } finally { - requiresPhoneConnection && this.clearPhoneCheckInterval() - this.off (`TAG:${tag}`, onRecv) - this.off (`ws-close`, onErr) - cancelPhoneChecker && cancelPhoneChecker() - } - } - /** Generic function for action, set queries */ - async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) { - const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes] - const result = await this.query({ json, binaryTags, tag, expect200: true, requiresPhoneConnection: true }) as Promise<{status: number}> - return result - } - /** - * Query something from the WhatsApp servers - * @param json the query itself - * @param binaryTags the tags to attach if the query is supposed to be sent encoded in binary - * @param timeoutMs timeout after which the query will be failed (set to null to disable a timeout) - * @param tag the tag to attach to the message - */ - async query(q: WAQuery): Promise { - let {json, binaryTags, tag, timeoutMs, expect200, waitForOpen, longTag, requiresPhoneConnection, startDebouncedTimeout, maxRetries} = q - requiresPhoneConnection = requiresPhoneConnection !== false - waitForOpen = waitForOpen !== false - let triesLeft = maxRetries || 2 - tag = tag || this.generateMessageTag(longTag) - - while (triesLeft >= 0) { - if (waitForOpen) await this.waitForConnection() - - const promise = this.waitForMessage(tag, requiresPhoneConnection, timeoutMs) - - if (this.logger.level === 'trace') { - this.logger.trace ({ fromMe: true },`${tag},${JSON.stringify(json)}`) - } - - if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag) - else tag = await this.sendJSON(json, tag) - - try { - const response = await promise - if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) { - const message = STATUS_CODES[response.status] || 'unknown' - throw new BaileysError ( - `Unexpected status in '${json[0] || 'query'}': ${STATUS_CODES[response.status]}(${response.status})`, - {query: json, message, status: response.status} - ) - } - if (startDebouncedTimeout) { - this.connectionDebounceTimeout.start() - } - return response - } catch (error) { - if (triesLeft === 0) { - throw error - } - // read here: http://getstatuscode.com/599 - if (error.status === 599) { - this.unexpectedDisconnect (DisconnectReason.badSession) - } else if ( - (error.message === 'close' || error.message === 'lost') && - waitForOpen && - this.state !== 'close' && - (this.pendingRequestTimeoutMs === null || - this.pendingRequestTimeoutMs > 0)) { - // nothing here - } else throw error - - triesLeft -= 1 - this.logger.debug(`query failed due to ${error}, retrying...`) - } - } - } - protected exitQueryIfResponseNotExpected(tag: string, cancel: ({ reason, status }) => void) { - let timeout: NodeJS.Timeout - const listener = ({ connected }) => { - if(connected) { - timeout = setTimeout(() => { - this.logger.info({ tag }, `cancelling wait for message as a response is no longer expected from the phone`) - cancel({ reason: 'Not expecting a response', status: 422 }) - }, this.connectOptions.maxQueryResponseTime) - this.off('connection-phone-change', listener) - } - } - this.on('connection-phone-change', listener) - return () => { - this.off('connection-phone-change', listener) - timeout && clearTimeout(timeout) - } - } - /** interval is started when a query takes too long to respond */ - protected startPhoneCheckInterval () { - this.phoneCheckListeners += 1 - if (!this.phoneCheckInterval) { - // if its been a long time and we haven't heard back from WA, send a ping - this.phoneCheckInterval = setInterval (() => { - if (!this.conn) return // if disconnected, then don't do anything - - this.logger.info('checking phone connection...') - this.sendAdminTest () - if(this.phoneConnected !== false) { - this.phoneConnected = false - this.emit ('connection-phone-change', { connected: false }) - } - }, this.connectOptions.phoneResponseTime) - } - - } - protected clearPhoneCheckInterval () { - this.phoneCheckListeners -= 1 - if (this.phoneCheckListeners <= 0) { - this.phoneCheckInterval && clearInterval (this.phoneCheckInterval) - this.phoneCheckInterval = undefined - this.phoneCheckListeners = 0 - } - - } - /** checks for phone connection */ - protected async sendAdminTest () { - return this.sendJSON (['admin', 'test']) - } - /** - * Send a binary encoded message - * @param json the message to encode & send - * @param tags the binary tags to tell WhatsApp what the message is all about - * @param tag the tag to attach to the message - * @return the message tag - */ - protected async sendBinary(json: WANode, tags: WATag, tag: string = null, longTag: boolean = false) { - const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format - - let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey - const sign = Utils.hmacSign(buff, this.authInfo.macKey) // sign the message using HMAC and our macKey - tag = tag || this.generateMessageTag(longTag) - - if (this.shouldLogMessages) this.messageLog.push ({ tag, json: JSON.stringify(json), fromMe: true, binaryTags: tags }) - - buff = Buffer.concat([ - Buffer.from(tag + ','), // generate & prefix the message tag - Buffer.from(tags), // prefix some bytes that tell whatsapp what the message is about - sign, // the HMAC sign of the message - buff, // the actual encrypted buffer - ]) - await this.send(buff) // send it off - return tag - } - /** - * Send a plain JSON message to the WhatsApp servers - * @param json the message to send - * @param tag the tag to attach to the message - * @returns the message tag - */ - protected async sendJSON(json: any[] | WANode, tag: string = null, longTag: boolean = false) { - tag = tag || this.generateMessageTag(longTag) - if (this.shouldLogMessages) this.messageLog.push ({ tag, json: JSON.stringify(json), fromMe: true }) - await this.send(`${tag},${JSON.stringify(json)}`) - return tag - } - /** Send some message to the WhatsApp servers */ - protected async send(m) { - this.conn.send(m) - } - protected async waitForConnection () { - if (this.state === 'open') return - - let onOpen: () => void - let onClose: ({ reason }) => void - - if (this.pendingRequestTimeoutMs !== null && this.pendingRequestTimeoutMs <= 0) { - throw new BaileysError(DisconnectReason.close, { status: 428 }) - } - await ( - Utils.promiseTimeout ( - this.pendingRequestTimeoutMs, - (resolve, reject) => { - onClose = ({ reason }) => { - if (reason === DisconnectReason.invalidSession || reason === DisconnectReason.intentional) { - reject (new Error(reason)) - } - } - onOpen = resolve - this.on ('close', onClose) - this.on ('open', onOpen) - } - ) - .finally(() => { - this.off ('open', onOpen) - this.off ('close', onClose) - }) - ) - } - /** - * Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request. - * @see close() if you just want to close the connection - */ - async logout () { - this.authInfo = null - if (this.state === 'open') { - //throw new Error("You're not even connected, you can't log out") - await new Promise(resolve => this.conn.send('goodbye,["admin","Conn","disconnect"]', null, resolve)) - } - this.user = undefined - this.chats.clear() - this.contacts = {} - this.close() - } - /** Close the connection to WhatsApp Web */ - close () { - this.closeInternal (DisconnectReason.intentional) - } - protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean=false) { - this.logger.info (`closed connection, reason ${reason}${isReconnecting ? ', reconnecting in a few seconds...' : ''}`) - - this.state = 'close' - this.phoneConnected = false - this.lastDisconnectReason = reason - this.lastDisconnectTime = new Date () - - this.endConnection(reason) - // reconnecting if the timeout is active for the reconnect loop - this.emit ('close', { reason, isReconnecting }) - } - protected endConnection (reason: DisconnectReason) { - this.conn?.removeAllListeners ('close') - this.conn?.removeAllListeners ('error') - this.conn?.removeAllListeners ('open') - this.conn?.removeAllListeners ('message') - - this.initTimeout && clearTimeout (this.initTimeout) - this.connectionDebounceTimeout.cancel() - this.messagesDebounceTimeout.cancel() - this.chatsDebounceTimeout.cancel() - this.keepAliveReq && clearInterval(this.keepAliveReq) - this.phoneCheckListeners = 0 - this.clearPhoneCheckInterval () - - this.emit ('ws-close', { reason }) - - try { - this.conn?.close() - //this.conn?.terminate() - } catch { - - } - this.conn = undefined - this.lastSeen = undefined - this.msgCount = 0 - } - /** - * Does a fetch request with the configuration of the connection - */ - protected fetchRequest = ( - endpoint: string, - method: Method = 'GET', - body?: any, - agent?: Agent, - headers?: {[k: string]: string}, - followRedirect = true - ) => ( - got(endpoint, { - method, - body, - followRedirect, - headers: { Origin: DEFAULT_ORIGIN, ...(headers || {}) }, - agent: { https: agent || this.connectOptions.fetchAgent } - }) - ) - generateMessageTag (longTag: boolean = false) { - const seconds = Utils.unixTimestampSeconds(this.referenceDate) - const tag = `${longTag ? seconds : (seconds%1000)}.--${this.msgCount}` - this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages - return tag - } -} diff --git a/src/WAConnection/1.Validation.ts b/src/WAConnection/1.Validation.ts deleted file mode 100644 index 7bbaaec..0000000 --- a/src/WAConnection/1.Validation.ts +++ /dev/null @@ -1,224 +0,0 @@ -import * as Curve from 'curve25519-js' -import * as Utils from './Utils' -import {WAConnection as Base} from './0.Base' -import { WAMetric, WAFlag, BaileysError, Presence, WAUser, WAInitResponse, WAOpenResult } from './Constants' - -export class WAConnection extends Base { - - /** Authenticate the connection */ - protected async authenticate (reconnect?: string) { - // if no auth info is present, that is, a new session has to be established - // generate a client ID - if (!this.authInfo?.clientID) { - this.authInfo = { clientID: Utils.generateClientID() } as any - } - const canLogin = this.canLogin() - this.referenceDate = new Date () // refresh reference date - - this.connectionDebounceTimeout.start() - - const initQuery = (async () => { - const {ref, ttl} = await this.query({ - json: ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true], - expect200: true, - waitForOpen: false, - longTag: true, - requiresPhoneConnection: false, - startDebouncedTimeout: true - }) as WAInitResponse - - if (!canLogin) { - this.connectionDebounceTimeout.cancel() // stop the debounced timeout for QR gen - this.generateKeysForAuth (ref, ttl) - } - })(); - let loginTag: string - if (canLogin) { - // if we have the info to restore a closed session - const json = [ - 'admin', - 'login', - this.authInfo?.clientToken, - this.authInfo?.serverToken, - this.authInfo?.clientID, - ] - loginTag = this.generateMessageTag(true) - - if (reconnect) json.push(...['reconnect', reconnect.replace('@s.whatsapp.net', '@c.us')]) - else json.push ('takeover') - // send login every 10s - const sendLoginReq = () => { - if (!this.conn || this.conn?.readyState !== this.conn.OPEN) { - this.logger.warn('Received login timeout req when WS not open, ignoring...') - return - } - if (this.state === 'open') { - this.logger.warn('Received login timeout req when state=open, ignoring...') - return - } - this.logger.debug('sending login request') - this.sendJSON(json, loginTag) - this.initTimeout = setTimeout(sendLoginReq, 10_000) - } - sendLoginReq() - } - - await initQuery - - // wait for response with tag "s1" - let response = await Promise.race( - [ - this.waitForMessage('s1', false, undefined), - loginTag && this.waitForMessage(loginTag, false, undefined) - ] - .filter(Boolean) - ) - this.connectionDebounceTimeout.start() - this.initTimeout && clearTimeout (this.initTimeout) - this.initTimeout = null - - if (response.status && response.status !== 200) { - throw new BaileysError(`Unexpected error in login`, { response, status: response.status }) - } - // if its a challenge request (we get it when logging in) - if (response[1]?.challenge) { - await this.respondToChallenge(response[1].challenge) - response = await this.waitForMessage('s2', true) - } - - const result = this.validateNewConnection(response[1])// validate the connection - if (result.user.jid !== this.user?.jid) { - result.isNewUser = true - // clear out old data - this.chats.clear() - this.contacts = {} - } - this.user = result.user - - this.logger.info('validated connection successfully') - - return result - } - /** - * Refresh QR Code - * @returns the new ref - */ - async requestNewQRCodeRef() { - const response = await this.query({ - json: ['admin', 'Conn', 'reref'], - expect200: true, - waitForOpen: false, - longTag: true, - requiresPhoneConnection: false - }) - return response as WAInitResponse - } - /** - * Once the QR code is scanned and we can validate our connection, or we resolved the challenge when logging back in - * @private - * @param {object} json - */ - private validateNewConnection(json) { - // set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone - const onValidationSuccess = () => ({ - user: { - jid: Utils.whatsappID(json.wid), - name: json.pushname, - phone: json.phone, - imgUrl: null - }, - auth: this.authInfo - }) as WAOpenResult - - if (!json.secret) { - // if we didn't get a secret, we don't need it, we're validated - if (json.clientToken && json.clientToken !== this.authInfo.clientToken) { - this.authInfo = { ...this.authInfo, clientToken: json.clientToken } - } - if (json.serverToken && json.serverToken !== this.authInfo.serverToken) { - this.authInfo = { ...this.authInfo, serverToken: json.serverToken } - } - return onValidationSuccess() - } - const secret = Buffer.from(json.secret, 'base64') - if (secret.length !== 144) { - throw new Error ('incorrect secret length received: ' + secret.length) - } - - // generate shared key from our private key & the secret shared by the server - const sharedKey = Curve.sharedKey(this.curveKeys.private, secret.slice(0, 32)) - // expand the key to 80 bytes using HKDF - const expandedKey = Utils.hkdf(sharedKey as Buffer, 80) - - // perform HMAC validation. - const hmacValidationKey = expandedKey.slice(32, 64) - const hmacValidationMessage = Buffer.concat([secret.slice(0, 32), secret.slice(64, secret.length)]) - - const hmac = Utils.hmacSign(hmacValidationMessage, hmacValidationKey) - - if (!hmac.equals(secret.slice(32, 64))) { - // if the checksums didn't match - throw new BaileysError ('HMAC validation failed', json) - } - - // computed HMAC should equal secret[32:64] - // expandedKey[64:] + secret[64:] are the keys, encrypted using AES, that are used to encrypt/decrypt the messages recieved from WhatsApp - // they are encrypted using key: expandedKey[0:32] - const encryptedAESKeys = Buffer.concat([ - expandedKey.slice(64, expandedKey.length), - secret.slice(64, secret.length), - ]) - const decryptedKeys = Utils.aesDecrypt(encryptedAESKeys, expandedKey.slice(0, 32)) - // set the credentials - this.authInfo = { - encKey: decryptedKeys.slice(0, 32), // first 32 bytes form the key to encrypt/decrypt messages - macKey: decryptedKeys.slice(32, 64), // last 32 bytes from the key to sign messages - clientToken: json.clientToken, - serverToken: json.serverToken, - clientID: this.authInfo.clientID, - } - return onValidationSuccess() - } - /** - * When logging back in (restoring a previously closed session), WhatsApp may challenge one to check if one still has the encryption keys - * WhatsApp does that by asking for us to sign a string it sends with our macKey - */ - protected respondToChallenge(challenge: string) { - const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string - const signed = Utils.hmacSign(bytes, this.authInfo.macKey).toString('base64') // sign the challenge string with our macKey - const json = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID - - this.logger.info('resolving login challenge') - return this.query({json, expect200: true, waitForOpen: false, startDebouncedTimeout: true}) - } - /** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */ - protected generateKeysForAuth(ref: string, ttl?: number) { - this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32)) - const publicKey = Buffer.from(this.curveKeys.public).toString('base64') - - const qrLoop = ttl => { - const qr = [ref, publicKey, this.authInfo.clientID].join(',') - this.emit ('qr', qr) - - this.initTimeout = setTimeout (async () => { - if (this.state === 'open') return - - this.logger.debug ('regenerating QR') - try { - const {ref: newRef, ttl: newTTL} = await this.requestNewQRCodeRef() - ttl = newTTL - ref = newRef - } catch (error) { - this.logger.warn ({ error }, `error in QR gen`) - // @ts-ignore - if (error.status === 429 && this.state !== 'open') { // too many QR requests - this.endConnection(error.message) - return - } - } - qrLoop (ttl) - }, ttl || 20_000) // default is 20s, on the off-chance ttl is not present - } - qrLoop (ttl) - } -} diff --git a/src/WAConnection/3.Connect.ts b/src/WAConnection/3.Connect.ts deleted file mode 100644 index 0dc4f56..0000000 --- a/src/WAConnection/3.Connect.ts +++ /dev/null @@ -1,196 +0,0 @@ -import * as Utils from './Utils' -import { KEEP_ALIVE_INTERVAL_MS, BaileysError, WAConnectOptions, DisconnectReason, UNAUTHORIZED_CODES, CancelledError, WAOpenResult, DEFAULT_ORIGIN, WS_URL } from './Constants' -import {WAConnection as Base} from './1.Validation' -import Decoder from '../Binary/Decoder' -import WS from 'ws' - -const DEF_CALLBACK_PREFIX = 'CB:' -const DEF_TAG_PREFIX = 'TAG:' - -export class WAConnection extends Base { - /** Connect to WhatsApp Web */ - async connect () { - // if we're already connected, throw an error - if (this.state !== 'close') { - throw new BaileysError('cannot connect when state=' + this.state, { status: 409 }) - } - - const options = this.connectOptions - const newConnection = !this.authInfo - - this.state = 'connecting' - this.emit ('connecting') - - let tries = 0 - let lastConnect = this.lastDisconnectTime - let result: WAOpenResult - while (this.state === 'connecting') { - tries += 1 - try { - const diff = lastConnect ? new Date().getTime()-lastConnect.getTime() : Infinity - result = await this.connectInternal ( - options, - diff > this.connectOptions.connectCooldownMs ? 0 : this.connectOptions.connectCooldownMs - ) - this.phoneConnected = true - this.state = 'open' - } catch (error) { - lastConnect = new Date() - - const loggedOut = error instanceof BaileysError && UNAUTHORIZED_CODES.includes(error.status) - const willReconnect = !loggedOut && (tries < options?.maxRetries) && (this.state === 'connecting') - const reason = loggedOut ? DisconnectReason.invalidSession : error.message - - this.logger.warn ({ error }, `connect attempt ${tries} failed: ${error}${ willReconnect ? ', retrying...' : ''}`) - - if ((this.state as string) !== 'close' && !willReconnect) { - this.closeInternal (reason) - } - if (!willReconnect) throw error - } - } - if (newConnection) result.newConnection = newConnection - this.emit ('open', result) - - this.logger.info ('opened connection to WhatsApp Web') - - this.conn.on ('close', () => this.unexpectedDisconnect (DisconnectReason.close)) - - return result - } - /** Meat of the connect logic */ - protected async connectInternal (options: WAConnectOptions, delayMs?: number) { - const rejections: ((e?: Error) => void)[] = [] - const rejectAll = (e: Error) => rejections.forEach (r => r(e)) - const rejectAllOnWSClose = ({ reason }) => rejectAll(new Error(reason)) - // actual connect - const connect = () => ( - new Promise((resolve, reject) => { - rejections.push (reject) - // determine whether reconnect should be used or not - const shouldUseReconnect = (this.lastDisconnectReason === DisconnectReason.close || - this.lastDisconnectReason === DisconnectReason.lost) && - !this.connectOptions.alwaysUseTakeover - const reconnectID = shouldUseReconnect && this.user.jid.replace ('@s.whatsapp.net', '@c.us') - - this.conn = new WS(WS_URL, null, { - origin: DEFAULT_ORIGIN, - timeout: this.connectOptions.maxIdleTimeMs, - agent: options.agent, - headers: { - 'Accept-Encoding': 'gzip, deflate, br', - 'Accept-Language': 'en-US,en;q=0.9', - 'Cache-Control': 'no-cache', - 'Host': 'web.whatsapp.com', - 'Pragma': 'no-cache', - 'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits', - } - }) - - this.conn.on('message', data => this.onMessageRecieved(data as any)) - - this.conn.once('open', async () => { - this.startKeepAliveRequest() - this.logger.info(`connected to WhatsApp Web server, authenticating via ${reconnectID ? 'reconnect' : 'takeover'}`) - - try { - this.connectionDebounceTimeout.setInterval(this.connectOptions.maxIdleTimeMs) - const authResult = await this.authenticate(reconnectID) - - this.conn - .removeAllListeners('error') - .removeAllListeners('close') - this.connectionDebounceTimeout.start() - resolve(authResult as WAOpenResult) - } catch (error) { - reject(error) - } - }) - this.conn.on('error', rejectAll) - this.conn.on('close', () => rejectAll(new Error(DisconnectReason.close))) - }) as Promise - ) - - this.on ('ws-close', rejectAllOnWSClose) - try { - if (delayMs) { - const {delay, cancel} = Utils.delayCancellable (delayMs) - rejections.push (cancel) - await delay - } - const result = await connect () - return result - } catch (error) { - if (this.conn) { - this.endConnection(error.message) - } - throw error - } finally { - this.off ('ws-close', rejectAllOnWSClose) - } - } - private onMessageRecieved(message: string | Buffer) { - if (message[0] === '!') { - // when the first character in the message is an '!', the server is sending a pong frame - const timestamp = message.slice(1, message.length).toString ('utf-8') - this.lastSeen = new Date(parseInt(timestamp)) - this.emit ('received-pong') - } else { - let messageTag: string - let json: any - try { - const dec = Utils.decryptWA (message, this.authInfo?.macKey, this.authInfo?.encKey, new Decoder()) - messageTag = dec[0] - json = dec[1] - } catch (error) { - this.logger.error ({ error }, `encountered error in decrypting message, closing: ${error}`) - - this.unexpectedDisconnect(DisconnectReason.badSession) - } - - if (this.shouldLogMessages) this.messageLog.push ({ tag: messageTag, json: JSON.stringify(json), fromMe: false }) - if (!json) return - - if (this.logger.level === 'trace') { - this.logger.trace(messageTag + ',' + JSON.stringify(json)) - } - - let anyTriggered = false - /* Check if this is a response to a message we sent */ - anyTriggered = this.emit (`${DEF_TAG_PREFIX}${messageTag}`, json) - /* Check if this is a response to a message we are expecting */ - const l0 = json[0] || '' - const l1 = typeof json[1] !== 'object' || json[1] === null ? {} : json[1] - const l2 = ((json[2] || [])[0] || [])[0] || '' - - Object.keys(l1).forEach(key => { - anyTriggered = this.emit (`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]},${l2}`, json) || anyTriggered; - anyTriggered = this.emit (`${DEF_CALLBACK_PREFIX}${l0},${key}:${l1[key]}`, json) || anyTriggered; - anyTriggered = this.emit (`${DEF_CALLBACK_PREFIX}${l0},${key}`, json) || anyTriggered; - }) - anyTriggered = this.emit (`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, json) || anyTriggered; - anyTriggered = this.emit (`${DEF_CALLBACK_PREFIX}${l0}`, json) || anyTriggered; - - if (anyTriggered) return - - if (this.logger.level === 'debug') { - this.logger.debug({ unhandled: true }, messageTag + ',' + JSON.stringify(json)) - } - } - } - /** Send a keep alive request every X seconds, server updates & responds with last seen */ - private startKeepAliveRequest() { - this.keepAliveReq && clearInterval (this.keepAliveReq) - - this.keepAliveReq = setInterval(() => { - if (!this.lastSeen) this.lastSeen = new Date () - const diff = new Date().getTime() - this.lastSeen.getTime() - /* - check if it's been a suspicious amount of time since the server responded with our last seen - it could be that the network is down - */ - if (diff > KEEP_ALIVE_INTERVAL_MS+5000) this.unexpectedDisconnect(DisconnectReason.lost) - else if (this.conn) this.send('?,,') // if its all good, send a keep alive request - }, KEEP_ALIVE_INTERVAL_MS) - } -} diff --git a/src/WAConnection/4.Events.ts b/src/WAConnection/4.Events.ts deleted file mode 100644 index 994d600..0000000 --- a/src/WAConnection/4.Events.ts +++ /dev/null @@ -1,717 +0,0 @@ -import * as QR from 'qrcode-terminal' -import { WAConnection as Base } from './3.Connect' -import { WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, PresenceUpdate, BaileysEvent, DisconnectReason, WAOpenResult, Presence, WAParticipantAction, WAGroupMetadata, WANode, WAPresenceData, WAChatUpdate, BlocklistUpdate, WAContactUpdate, WAMetric, WAFlag } from './Constants' -import { whatsappID, unixTimestampSeconds, GET_MESSAGE_ID, WA_MESSAGE_ID, newMessagesDB, shallowChanges, toNumber, isGroupID } from './Utils' -import KeyedDB from '@adiwajshing/keyed-db' -import { Mutex } from './Mutex' - -export class WAConnection extends Base { - - constructor () { - super () - this.setMaxListeners (30) - this.chatsDebounceTimeout.setTask(() => { - this.logger.debug('pinging with chats query') - this.sendChatsQuery(this.msgCount) - - this.chatsDebounceTimeout.start() - }) - this.on('open', () => { - // send queries WA Web expects - this.sendBinary (['query', {type: 'contacts', epoch: '1'}, null], [ WAMetric.queryContact, WAFlag.ignore ]) - this.sendBinary (['query', {type: 'status', epoch: '1'}, null], [ WAMetric.queryStatus, WAFlag.ignore ]) - this.sendBinary (['query', {type: 'quick_reply', epoch: '1'}, null], [ WAMetric.queryQuickReply, WAFlag.ignore ]) - this.sendBinary (['query', {type: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ]) - this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ]) - this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, WAFlag.available ]) - - if(this.connectOptions.queryChatsTillReceived) { - this.chatsDebounceTimeout.start() - } else { - this.sendChatsQuery(1) - } - - this.logger.debug('sent init queries') - }) - // on disconnects - this.on('CB:Cmd,type:disconnect', json => ( - this.state === 'open' && this.unexpectedDisconnect(json[1].kind || 'unknown') - )) - this.on('CB:Pong', json => { - if (!json[1]) { - this.unexpectedDisconnect(DisconnectReason.close) - this.logger.info('Connection terminated by phone, closing...') - } else if (this.phoneConnected !== json[1]) { - this.phoneConnected = json[1] - this.emit ('connection-phone-change', { connected: this.phoneConnected }) - } - }) - // chats received - this.on('CB:response,type:chat', json => { - if (json[1].duplicate || !json[2]) return - - this.chatsDebounceTimeout.cancel() - const chats = new KeyedDB(this.chatOrderingKey, c => c.jid) - - json[2].forEach(([item, chat]: [any, WAChat]) => { - if (!chat) { - this.logger.warn (`unexpectedly got null chat: ${item}`, chat) - return - } - chat.jid = whatsappID (chat.jid) - chat.t = +chat.t - chat.count = +chat.count - chat.messages = newMessagesDB() - // chats data (log json to see what it looks like) - chats.insertIfAbsent(chat) - }) - this.logger.info (`received ${json[2].length} chats`) - - const oldChats = this.chats - const updatedChats = [] - let hasNewChats = false - - chats.all().forEach (chat => { - const respectiveContact = this.contacts[chat.jid] - chat.name = respectiveContact?.name || respectiveContact?.notify || chat.name - - const oldChat = oldChats.get(chat.jid) - if (!oldChat) { - hasNewChats = true - } else { - chat.messages = oldChat.messages - if (oldChat.t !== chat.t || oldChat.modify_tag !== chat.modify_tag) { - const changes = shallowChanges (oldChat, chat, { lookForDeletedKeys: true }) - delete chat.metadata // remove group metadata as that may have changed; TODO, write better mechanism for this - delete changes.messages - - updatedChats.push({ ...changes, jid: chat.jid }) - } - } - }) - this.chats = chats - this.lastChatsReceived = new Date() - - updatedChats.length > 0 && this.emit('chats-update', updatedChats) - - this.emit('chats-received', { hasNewChats }) - }) - // we store these last messages - const lastMessages = {} - // keep track of overlaps, - // if there are no overlaps of messages and we had messages present, we clear the previous messages - // this prevents missing messages in conversations - let overlaps: { [_: string]: { requiresOverlap: boolean, didOverlap?: boolean } } = {} - const onLastBatchOfDataReceived = () => { - // find which chats had missing messages - // list out all the jids, and how many messages we've cached now - const chatsWithMissingMessages = Object.keys(overlaps).map(jid => { - // if there was no overlap, delete previous messages - if (!overlaps[jid].didOverlap && overlaps[jid].requiresOverlap) { - this.logger.debug(`received messages for ${jid}, but did not overlap with previous messages, clearing...`) - const chat = this.chats.get(jid) - if (chat) { - const message = chat.messages.get(lastMessages[jid]) - const remainingMessages = chat.messages.paginatedByValue(message, this.maxCachedMessages, undefined, 'after') - chat.messages = newMessagesDB([message, ...remainingMessages]) - return { jid, count: chat.messages.length } // return number of messages we've left - } - } - }).filter(Boolean) - this.emit('initial-data-received', { chatsWithMissingMessages }) - } - // messages received - const messagesUpdate = (json, style: 'previous' | 'last') => { - //console.log('msg ', json[1]) - this.messagesDebounceTimeout.start(undefined, onLastBatchOfDataReceived) - if (style === 'last') { - overlaps = {} - } - const messages = json[2] as WANode[] - if (messages) { - const updates: { [k: string]: KeyedDB } = {} - messages.reverse().forEach (([,, message]: ['message', null, WAMessage]) => { - const jid = message.key.remoteJid - const chat = this.chats.get(jid) - - const mKeyID = WA_MESSAGE_ID(message) - if (chat) { - if (style === 'previous') { - const fm = chat.messages.get(lastMessages[jid]) - if (!fm) return - const prevEpoch = fm['epoch'] - message['epoch'] = prevEpoch-1 - } else if (style === 'last') { - // no overlap required, if there were no previous messages - overlaps[jid] = { requiresOverlap: chat.messages.length > 0 } - - const lm = chat.messages.all()[chat.messages.length-1] - const prevEpoch = (lm && lm['epoch']) || 0 - // hacky way to allow more previous messages - message['epoch'] = prevEpoch+1000 - } - if (chat.messages.upsert(message).length > 0) { - overlaps[jid] = { ...(overlaps[jid] || { requiresOverlap: true }), didOverlap: true } - } - updates[jid] = updates[jid] || newMessagesDB() - updates[jid].upsert(message) - - lastMessages[jid] = mKeyID - } else if (!chat) this.logger.debug({ jid }, `chat not found`) - }) - if (Object.keys(updates).length > 0) { - this.emit ('chats-update', - Object.keys(updates).map(jid => ({ jid, messages: updates[jid] })) - ) - } - } - } - this.on('CB:action,add:last', json => messagesUpdate(json, 'last')) - this.on('CB:action,add:before', json => messagesUpdate(json, 'previous')) - this.on('CB:action,add:unread', json => messagesUpdate(json, 'previous')) - - // contacts received - this.on('CB:response,type:contacts', json => { - if (json[1].duplicate || !json[2]) return - const contacts = this.contacts - const updatedContacts: WAContact[] = [] - - json[2].forEach(([type, contact]: ['user', WAContact]) => { - if (!contact) return this.logger.info (`unexpectedly got null contact: ${type}`, contact) - - contact.jid = whatsappID (contact.jid) - const presentContact = contacts[contact.jid] - if (presentContact) { - const changes = shallowChanges(presentContact, contact, { lookForDeletedKeys: false }) - if (changes && Object.keys(changes).length > 0) { - updatedContacts.push({ ...changes, jid: contact.jid }) - } - } else updatedContacts.push(contact) - - contacts[contact.jid] = { ...(presentContact || {}), ...contact } - }) - // update chat names - const updatedChats = [] - this.chats.all().forEach(c => { - const contact = contacts[c.jid] - if (contact) { - const name = contact?.name || contact?.notify || c.name - if (name !== c.name) { - updatedChats.push({ jid: c.jid, name }) - } - } - }) - updatedChats.length > 0 && this.emit('chats-update', updatedChats) - - this.logger.info (`received ${json[2].length} contacts`) - this.contacts = contacts - - this.emit('contacts-received', { updatedContacts }) - }) - // new messages - this.on('CB:action,add:relay,message', json => { - const message = json[2][0][2] as WAMessage - this.chatAddMessageAppropriate (message) - }) - this.on('CB:Chat,cmd:action', json => { - const data = json[1].data - if (data) { - const emitGroupParticipantsUpdate = (action: WAParticipantAction) => this.emitParticipantsUpdate - (json[1].id, data[2].participants.map(whatsappID), action) - const emitGroupUpdate = (data: Partial) => this.emitGroupUpdate(json[1].id, data) - - switch (data[0]) { - case "promote": - emitGroupParticipantsUpdate('promote') - break - case "demote": - emitGroupParticipantsUpdate('demote') - break - case "desc_add": - emitGroupUpdate({ ...data[2], descOwner: data[1] }) - break - default: - this.logger.debug({ unhandled: true }, json) - break - } - } - }) - // presence updates - this.on('CB:Presence', json => { - const chatUpdate = this.applyingPresenceUpdate(json[1]) - chatUpdate && this.emit('chat-update', chatUpdate) - }) - // If a message has been updated (usually called when a video message gets its upload url, or live locations) - this.on ('CB:action,add:update,message', json => { - const message: WAMessage = json[2][0][2] - const jid = whatsappID(message.key.remoteJid) - const chat = this.chats.get(jid) - if (!chat) return - // reinsert to update - const oldMessage = chat.messages.get (WA_MESSAGE_ID(message)) - if (oldMessage) { - message['epoch'] = oldMessage['epoch'] - if (chat.messages.upsert(message).length) { - const chatUpdate: Partial = { jid, messages: newMessagesDB([ message ]) } - this.emit ('chat-update', chatUpdate) - } - } else { - this.logger.debug ({ unhandled: true }, 'received message update for non-present message from ' + jid) - } - }) - // message status updates - const onMessageStatusUpdate = json => { - json = json[2][0][1] - const MAP = { - read: WA_MESSAGE_STATUS_TYPE.READ, - message: WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK, - error: WA_MESSAGE_STATUS_TYPE.ERROR - } - this.onMessageStatusUpdate( - whatsappID(json.jid), - { id: json.index, fromMe: json.owner === 'true' }, - MAP[json.type] - ) - } - this.on('CB:action,add:relay,received', onMessageStatusUpdate) - this.on('CB:action,,received', onMessageStatusUpdate) - - this.on('CB:Msg,cmd:ack', json => ( - this.onMessageStatusUpdate( - whatsappID(json[1].to), - { id: json[1].id, fromMe: true }, - +json[1].ack + 1 - ) - )) - - // If a user's contact has changed - this.on ('CB:action,,user', json => { - const node = json[2][0] - if (node) { - const user = node[1] as WAContact - user.jid = whatsappID(user.jid) - - this.contacts[user.jid] = user - this.emit('contact-update', user) - - const chat = this.chats.get (user.jid) - if (chat) { - chat.name = user.name || user.notify || chat.name - this.emit ('chat-update', { jid: chat.jid, name: chat.name }) - } - } - }) - // chat archive, pin etc. - this.on('CB:action,,chat', json => { - json = json[2][0] - - const updateType = json[1].type - const jid = whatsappID(json[1]?.jid) - - const chat = this.chats.get(jid) - if (!chat) return - - const FUNCTIONS = { - 'delete': () => { - chat['delete'] = 'true' - this.chats.deleteById(chat.jid) - return 'delete' - }, - 'clear': () => { - if (!json[2]) chat.messages.clear () - else json[2].forEach(item => chat.messages.filter(m => m.key.id !== item[1].index)) - return 'clear' - }, - 'archive': () => { - this.chats.update(chat.jid, chat => chat.archive = 'true') - return 'archive' - }, - 'unarchive': () => { - delete chat.archive - return 'archive' - }, - 'pin': () => { - chat.pin = json[1].pin - return 'pin' - } - } - const func = FUNCTIONS [updateType] - - if (func) { - const property = func () - this.emit ('chat-update', { jid, [property]: chat[property] || 'false' }) - } - }) - // profile picture updates - this.on('CB:Cmd,type:picture', async json => { - json = json[1] - const jid = whatsappID(json.jid) - const imgUrl = await this.getProfilePicture(jid).catch(() => '') - const contact = this.contacts[jid] - if (contact) { - contact.imgUrl = imgUrl - this.emit('contact-update', { jid, imgUrl }) - } - const chat = this.chats.get(jid) - if (chat) { - chat.imgUrl = imgUrl - this.emit ('chat-update', { jid, imgUrl }) - } - }) - // status updates - this.on('CB:Status,status', async json => { - const jid = whatsappID(json[1].id) - this.emit ('contact-update', { jid, status: json[1].status }) - }) - // User Profile Name Updates - this.on ('CB:Conn,pushname', json => { - if (this.user) { - const name = json[1].pushname - if(this.user.name !== name) { - this.user.name = name // update on client too - this.emit ('contact-update', { jid: this.user.jid, name }) - } - } - }) - // read updates - this.on ('CB:action,,read', async json => { - const update = json[2][0][1] - const jid = whatsappID(update.jid) - const chat = this.chats.get (jid) - if(chat) { - if (update.type === 'false') chat.count = -1 - else chat.count = 0 - - this.emit ('chat-update', { jid: chat.jid, count: chat.count }) - } else { - this.logger.warn('recieved read update for unknown chat ' + jid) - } - }) - this.on('qr', qr => { - if (this.connectOptions.logQR) { - QR.generate(qr, { small: true }) - } - }); - - // blocklist updates - this.on('CB:Blocklist', json => { - json = json[1] - const initial = this.blocklist - this.blocklist = json.blocklist - - const added = this.blocklist.filter(id => !initial.includes(id)) - const removed = initial.filter(id => !this.blocklist.includes(id)) - - const update: BlocklistUpdate = { added, removed } - - this.emit('blocklist-update', update) - }) - } - protected sendChatsQuery(epoch: number) { - return this.sendBinary(['query', {type: 'chat', epoch: epoch.toString()}, null], [ WAMetric.queryChat, WAFlag.ignore ]) - } - /** Get the URL to download the profile picture of a person/group */ - @Mutex (jid => jid) - async getProfilePicture(jid: string | null) { - const response = await this.query({ - json: ['query', 'ProfilePicThumb', jid || this.user.jid], - expect200: true, - requiresPhoneConnection: false - }) - return response.eurl as string - } - protected applyingPresenceUpdate(update: PresenceUpdate) { - const chatId = whatsappID(update.id) - const jid = whatsappID(update.participant || update.id) - - const chat = this.chats.get(chatId) - if (chat && jid.endsWith('@s.whatsapp.net')) { // if its a single chat - chat.presences = chat.presences || {} - - const presence = { ...(chat.presences[jid] || {}) } as WAPresenceData - - if (update.t) presence.lastSeen = +update.t - else if (update.type === Presence.unavailable && (presence.lastKnownPresence === Presence.available || presence.lastKnownPresence === Presence.composing)) { - presence.lastSeen = unixTimestampSeconds() - } - presence.lastKnownPresence = update.type - // no update - if(presence.lastKnownPresence === chat.presences[jid]?.lastKnownPresence && presence.lastSeen === chat.presences[jid]?.lastSeen) { - return - } - - const contact = this.contacts[jid] - if (contact) { - presence.name = contact.name || contact.notify || contact.vname - } - - chat.presences[jid] = presence - return { jid: chatId, presences: { [jid]: presence } } as Partial - } - } - /** inserts an empty chat into the DB */ - protected chatAdd (jid: string, name?: string, properties: Partial = {}) { - const chat: WAChat = { - jid, - name, - t: unixTimestampSeconds(), - messages: newMessagesDB(), - count: 0, - ...(properties || {}) - } - if(this.chats.insertIfAbsent(chat).length) { - this.emit('chat-new', chat) - return chat - } - } - protected onMessageStatusUpdate(jid: string, key: { id: string, fromMe: boolean }, status: WA_MESSAGE_STATUS_TYPE) { - const chat = this.chats.get( whatsappID(jid) ) - const msg = chat?.messages.get(GET_MESSAGE_ID(key)) - if (msg) { - if (typeof status !== 'undefined') { - if (status > msg.status || status === WA_MESSAGE_STATUS_TYPE.ERROR) { - msg.status = status - this.emit('chat-update', { jid: chat.jid, messages: newMessagesDB([ msg ]) }) - } - } else { - this.logger.warn({ update: status }, 'received unknown message status update') - } - } else { - this.logger.debug ({ unhandled: true, update: status, key }, 'received message status update for non-present message') - } - } - protected contactAddOrGet (jid: string) { - jid = whatsappID(jid) - if (!this.contacts[jid]) this.contacts[jid] = { jid } - return this.contacts[jid] - } - /** find a chat or return an error */ - protected assertChatGet = jid => { - const chat = this.chats.get (jid) - if (!chat) throw new Error (`chat '${jid}' not found`) - return chat - } - /** Adds the given message to the appropriate chat, if the chat doesn't exist, it is created */ - protected async chatAddMessageAppropriate (message: WAMessage) { - const jid = whatsappID(message.key.remoteJid) - if(isGroupID(jid) && !jid.includes('-')) { - this.logger.warn({ gid: jid }, 'recieved odd group ID') - return - } - const chat = this.chats.get(jid) || await this.chatAdd (jid) - this.chatAddMessage (message, chat) - } - protected chatAddMessage (message: WAMessage, chat: WAChat) { - // store updates in this - const chatUpdate: WAChatUpdate = { jid: chat.jid } - // add to count if the message isn't from me & there exists a message - if (!message.key.fromMe && message.message) { - chat.count += 1 - chatUpdate.count = chat.count - - const participant = whatsappID(message.participant || chat.jid) - const contact = chat.presences && chat.presences[participant] - if (contact?.lastKnownPresence === Presence.composing) { // update presence - const update = this.applyingPresenceUpdate({ id: chat.jid, participant, type: Presence.available }) - update && Object.assign(chatUpdate, update) - } - } - - const ephemeralProtocolMsg = message.message?.ephemeralMessage?.message?.protocolMessage - if ( - ephemeralProtocolMsg && - ephemeralProtocolMsg.type === WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING - ) { - chatUpdate.eph_setting_ts = message.messageTimestamp.toString() - chatUpdate.ephemeral = ephemeralProtocolMsg.ephemeralExpiration.toString() - - if (ephemeralProtocolMsg.ephemeralExpiration) { - chat.eph_setting_ts = chatUpdate.eph_setting_ts - chat.ephemeral = chatUpdate.ephemeral - } else { - delete chat.eph_setting_ts - delete chat.ephemeral - } - } - - const messages = chat.messages - const protocolMessage = message.message?.protocolMessage - // if it's a message to delete another message - if (protocolMessage) { - switch (protocolMessage.type) { - case WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE: - const found = chat.messages.get (GET_MESSAGE_ID(protocolMessage.key)) - if (found?.message) { - this.logger.info ('deleting message: ' + protocolMessage.key.id + ' in chat: ' + protocolMessage.key.remoteJid) - - found.messageStubType = WA_MESSAGE_STUB_TYPE.REVOKE - delete found.message - chatUpdate.messages = newMessagesDB([ found ]) - } - break - default: - break - } - } else if (!messages.get(WA_MESSAGE_ID(message))) { // if the message is not already there - - const lastEpoch = (messages.last && messages.last['epoch']) || 0 - message['epoch'] = lastEpoch+1 - - messages.insert (message) - while (messages.length > this.maxCachedMessages) { - messages.delete (messages.all()[0]) // delete oldest messages - } - // only update if it's an actual message - if (message.message && !ephemeralProtocolMsg) { - this.chats.update(chat.jid, chat => { - chat.t = +toNumber(message.messageTimestamp) - chatUpdate.t = chat.t - // a new message unarchives the chat - if (chat.archive) { - delete chat.archive - chatUpdate.archive = 'false' - } - }) - } - chatUpdate.hasNewMessage = true - chatUpdate.messages = newMessagesDB([ message ]) - // check if the message is an action - if (message.messageStubType) { - const jid = chat.jid - //let actor = whatsappID (message.participant) - let participants: string[] - const emitParticipantsUpdate = (action: WAParticipantAction) => ( - this.emitParticipantsUpdate(jid, participants, action) - ) - const emitGroupUpdate = (update: Partial) => this.emitGroupUpdate(jid, update) - - switch (message.messageStubType) { - case WA_MESSAGE_STUB_TYPE.CHANGE_EPHEMERAL_SETTING: - chatUpdate.eph_setting_ts = message.messageTimestamp.toString() - chatUpdate.ephemeral = message.messageStubParameters[0] - - if (+chatUpdate.ephemeral) { - chat.eph_setting_ts = chatUpdate.eph_setting_ts - chat.ephemeral = chatUpdate.ephemeral - } else { - delete chat.eph_setting_ts - delete chat.ephemeral - } - break - case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_LEAVE: - case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_REMOVE: - participants = message.messageStubParameters.map (whatsappID) - emitParticipantsUpdate('remove') - // mark the chat read only if you left the group - if (participants.includes(this.user.jid)) { - chat.read_only = 'true' - chatUpdate.read_only = 'true' - } - break - case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_ADD: - case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_INVITE: - case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_ADD_REQUEST_JOIN: - participants = message.messageStubParameters.map (whatsappID) - if (participants.includes(this.user.jid) && chat.read_only === 'true') { - delete chat.read_only - chatUpdate.read_only = 'false' - } - emitParticipantsUpdate('add') - break - case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_ANNOUNCE: - const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false' - emitGroupUpdate({ announce }) - break - case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_RESTRICT: - const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false' - emitGroupUpdate({ restrict }) - break - case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_SUBJECT: - case WA_MESSAGE_STUB_TYPE.GROUP_CREATE: - chat.name = message.messageStubParameters[0] - chatUpdate.name = chat.name - if (chat.metadata) chat.metadata.subject = chat.name - break - } - } - } - - this.emit('chat-update', chatUpdate) - } - protected emitParticipantsUpdate = (jid: string, participants: string[], action: WAParticipantAction) => { - const chat = this.chats.get(jid) - const meta = chat?.metadata - if (meta) { - switch (action) { - case 'add': - participants.forEach(jid => ( - meta.participants.push({ ...this.contactAddOrGet(jid), isAdmin: false, isSuperAdmin: false }) - )) - break - case 'remove': - meta.participants = meta.participants.filter(p => !participants.includes(p.jid)) - break - case 'promote': - case 'demote': - const isAdmin = action==='promote' - meta.participants.forEach(p => { - if (participants.includes( p.jid )) p.isAdmin = isAdmin - }) - break - } - } - this.emit ('group-participants-update', { jid, participants, action }) - } - protected emitGroupUpdate = (jid: string, update: Partial) => { - const chat = this.chats.get(jid) - if (chat.metadata) Object.assign(chat.metadata, update) - this.emit ('group-update', { jid, ...update }) - } - protected chatUpdateTime = (chat, stamp: number) => this.chats.update (chat.jid, c => c.t = stamp) - /** sets the profile picture of a chat */ - protected async setProfilePicture (chat: WAChat) { - chat.imgUrl = await this.getProfilePicture (chat.jid).catch (err => '') - } - - // Add all event types - - /** when the connection has opened successfully */ - on (event: 'open', listener: (result: WAOpenResult) => void): this - /** when the connection is opening */ - on (event: 'connecting', listener: () => void): this - /** when the connection has closed */ - on (event: 'close', listener: (err: {reason?: DisconnectReason | string, isReconnecting: boolean}) => void): this - /** when the socket is closed */ - on (event: 'ws-close', listener: (err: {reason?: DisconnectReason | string}) => void): this - /** when a new QR is generated, ready for scanning */ - on (event: 'qr', listener: (qr: string) => void): this - /** when the connection to the phone changes */ - on (event: 'connection-phone-change', listener: (state: {connected: boolean}) => void): this - /** when a contact is updated */ - on (event: 'contact-update', listener: (update: WAContactUpdate) => void): this - /** when a new chat is added */ - on (event: 'chat-new', listener: (chat: WAChat) => void): this - /** when contacts are sent by WA */ - on (event: 'contacts-received', listener: (u: { updatedContacts: Partial[] }) => void): this - /** when chats are sent by WA, and when all messages are received */ - on (event: 'chats-received', listener: (update: {hasNewChats?: boolean}) => void): this - /** when all initial messages are received from WA */ - on (event: 'initial-data-received', listener: (update: {chatsWithMissingMessages: { jid: string, count: number }[] }) => void): this - /** when multiple chats are updated (new message, updated message, deleted, pinned, etc) */ - on (event: 'chats-update', listener: (chats: WAChatUpdate[]) => void): this - /** when a chat is updated (new message, updated message, read message, deleted, pinned, presence updated etc) */ - on (event: 'chat-update', listener: (chat: WAChatUpdate) => void): this - /** when participants are added to a group */ - on (event: 'group-participants-update', listener: (update: {jid: string, participants: string[], actor?: string, action: WAParticipantAction}) => void): this - /** when the group is updated */ - on (event: 'group-update', listener: (update: Partial & {jid: string, actor?: string}) => void): this - /** when WA sends back a pong */ - on (event: 'received-pong', listener: () => void): this - /** when a user is blocked or unblockd */ - on (event: 'blocklist-update', listener: (update: BlocklistUpdate) => void): this - - on (event: BaileysEvent | string, listener: (json: any) => void): this - - on (event: BaileysEvent | string, listener: (...args: any[]) => void) { return super.on (event, listener) } - emit (event: BaileysEvent | string, ...args: any[]) { return super.emit (event, ...args) } -} diff --git a/src/WAConnection/5.User.ts b/src/WAConnection/5.User.ts deleted file mode 100644 index 9d04c4f..0000000 --- a/src/WAConnection/5.User.ts +++ /dev/null @@ -1,255 +0,0 @@ -import {WAConnection as Base} from './4.Events' -import { Presence, WABroadcastListInfo, WAProfilePictureChange, WALoadChatOptions, WAChatIndex, BlocklistUpdate, WABusinessProfile } from './Constants' -import { - WAMessage, - WANode, - WAMetric, - WAFlag, -} from '../WAConnection/Constants' -import { generateProfilePicture, whatsappID } from './Utils' -import { Mutex } from './Mutex' -import { URL } from 'url' - -// All user related functions -- get profile picture, set status etc. - -export class WAConnection extends Base { - /** - * Query whether a given number is registered on WhatsApp - * @param str phone number/jid you want to check for - * @returns undefined if the number doesn't exists, otherwise the correctly formatted jid - */ - isOnWhatsApp = async (str: string) => { - if (this.state !== 'open') { - return this.isOnWhatsAppNoConn(str) - } - const { status, jid, biz } = await this.query({json: ['query', 'exist', str], requiresPhoneConnection: false}) - if (status === 200) return { exists: true, jid: whatsappID(jid), isBusiness: biz as boolean} - } - /** - * Query whether a given number is registered on WhatsApp, without needing to open a WS connection - * @param str phone number/jid you want to check for - * @returns undefined if the number doesn't exists, otherwise the correctly formatted jid - */ - isOnWhatsAppNoConn = async (str: string) => { - let phone = str.split('@')[0] - const url = `https://wa.me/${phone}` - const response = await this.fetchRequest(url, 'GET', undefined, undefined, undefined, false) - const loc = response.headers.location as string - if (!loc) { - this.logger.warn({ url, status: response.statusCode }, 'did not get location from request') - return - } - const locUrl = new URL('', loc) - if (!locUrl.pathname.endsWith('send/')) { - return - } - phone = locUrl.searchParams.get('phone') - return { exists: true, jid: `${phone}@s.whatsapp.net` } - } - /** - * Tell someone about your presence -- online, typing, offline etc. - * @param jid the ID of the person/group who you are updating - * @param type your presence - */ - updatePresence = (jid: string | null, type: Presence) => this.sendBinary( - [ 'action', - {epoch: this.msgCount.toString(), type: 'set'}, - [ ['presence', { type: type, to: jid }, null] ] - ], - [WAMetric.presence, WAFlag[type] ], // weird stuff WA does - undefined, - true - ) - /** Request an update on the presence of a user */ - requestPresenceUpdate = async (jid: string) => this.query({ json: ['action', 'presence', 'subscribe', jid] }) - /** Query the status of the person (see groupMetadata() for groups) */ - async getStatus (jid?: string) { - const status: { status: string } = await this.query({ json: ['query', 'Status', jid || this.user.jid], requiresPhoneConnection: false }) - return status - } - async setStatus (status: string) { - const response = await this.setQuery ( - [ - [ - 'status', - null, - Buffer.from (status, 'utf-8') - ] - ] - ) - this.emit ('contact-update', { jid: this.user.jid, status }) - return response - } - /** Updates business profile. */ - async updateBusinessProfile(profile: WABusinessProfile) { - if (profile.business_hours?.config) { - profile.business_hours.business_config = profile.business_hours.config - delete profile.business_hours.config - } - const json = ['action', "editBusinessProfile", {...profile, v: 2}] - let response; - try { - response = await this.query({ json, expect200: true, requiresPhoneConnection: true }) - } catch (_) { - return {status: 400} - } - return { status: response.status } - } - async updateProfileName (name: string) { - const response = (await this.setQuery ( - [ - [ - 'profile', - { - name - }, - null - ] - ] - )) as any as {status: number, pushname: string} - if (response.status === 200) { - this.user.name = response.pushname; - this.emit ('contact-update', { jid: this.user.jid, name }) - } - return response - } - /** Get your contacts */ - async getContacts() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null] - const response = await this.query({ json, binaryTags: [WAMetric.queryContact, WAFlag.ignore], expect200: true, requiresPhoneConnection: true }) // this has to be an encrypted query - return response - } - /** Get the stories of your contacts */ - async getStories() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null] - const response = await this.query({json, binaryTags: [WAMetric.queryStatus, WAFlag.ignore], expect200: true, requiresPhoneConnection: true }) as WANode - if (Array.isArray(response[2])) { - return response[2].map (row => ( - { - unread: row[1]?.unread, - count: row[1]?.count, - messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : [] - } as {unread: number, count: number, messages: WAMessage[]} - )) - } - return [] - } - /** Fetch your chats */ - async getChats() { - const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null] - return this.query({ json, binaryTags: [5, WAFlag.ignore], expect200: true }) // this has to be an encrypted query - } - /** Query broadcast list info */ - async getBroadcastListInfo(jid: string) { - return this.query({ - json: ['query', 'contact', jid], - expect200: true, - requiresPhoneConnection: true - }) as Promise - } - /** - * Load chats in a paginated manner + gets the profile picture - * @param before chats before the given cursor - * @param count number of results to return - * @param searchString optionally search for users - * @returns the chats & the cursor to fetch the next page - */ - loadChats (count: number, before: string | null, options: WALoadChatOptions = {}) { - const searchString = options.searchString?.toLowerCase() - const chats = this.chats.paginated (before, count, options && (chat => ( - (typeof options?.custom !== 'function' || options?.custom(chat)) && - (typeof searchString === 'undefined' || chat.name?.toLowerCase().includes (searchString) || chat.jid?.includes(searchString)) - ))) - const cursor = (chats[chats.length-1] && chats.length >= count) && this.chatOrderingKey.key (chats[chats.length-1]) - return { chats, cursor } - } - /** - * Update the profile picture - * @param jid - * @param img - */ - @Mutex (jid => jid) - async updateProfilePicture (jid: string, img: Buffer) { - jid = whatsappID (jid) - const data = await generateProfilePicture (img) - const tag = this.generateMessageTag () - const query: WANode = [ - 'picture', - { jid: jid, id: tag, type: 'set' }, - [ - ['image', null, data.img], - ['preview', null, data.preview] - ] - ] - const response = await (this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise) - if (jid === this.user.jid) this.user.imgUrl = response.eurl - else if (this.chats.get(jid)) { - this.chats.get(jid).imgUrl = response.eurl - this.emit ('chat-update', { jid, imgUrl: response.eurl }) - } - return response - } - /** - * Add or remove user from blocklist - * @param jid the ID of the person who you are blocking/unblocking - * @param type type of operation - */ - @Mutex (jid => jid) - async blockUser (jid: string, type: 'add' | 'remove' = 'add') { - const json: WANode = [ - 'block', - { - type: type, - }, - [ - ['user', { jid }, null] - ], - ] - const result = await this.setQuery ([json], [WAMetric.block, WAFlag.ignore]) - - if (result.status === 200) { - if (type === 'add') { - this.blocklist.push(jid) - } else { - const index = this.blocklist.indexOf(jid); - if (index !== -1) { - this.blocklist.splice(index, 1); - } - } - - // Blocklist update event - const update: BlocklistUpdate = { added: [], removed: [] } - let key = type === 'add' ? 'added' : 'removed' - update[key] = [ jid ] - this.emit('blocklist-update', update) - } - - return result - } - /** - * Query Business Profile (Useful for VCards) - * @param jid Business Jid - * @returns {WABusinessProfile} profile object or undefined if not business account - */ - async getBusinessProfile(jid: string) { - jid = whatsappID(jid) - const { - profiles: [{ - profile, - wid - }] - } = await this.query({ - json: ["query", "businessProfile", [ - { - "wid": jid.replace('@s.whatsapp.net', '@c.us') - } - ], 84], - expect200: true, - requiresPhoneConnection: false, - }) - return { - ...profile, - wid: whatsappID(wid) - } - } -} diff --git a/src/WAConnection/6.MessagesSend.ts b/src/WAConnection/6.MessagesSend.ts deleted file mode 100644 index 3dd2fe4..0000000 --- a/src/WAConnection/6.MessagesSend.ts +++ /dev/null @@ -1,437 +0,0 @@ -import {WAConnection as Base} from './5.User' -import {createReadStream, promises as fs} from 'fs' -import { - MessageOptions, - MessageType, - Mimetype, - MimetypeMap, - MediaPathMap, - WALocationMessage, - WAContactMessage, - WAContactsArrayMessage, - WAGroupInviteMessage, - WATextMessage, - WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, WA_MESSAGE_STATUS_TYPE, WAMessageProto, MediaConnInfo, MessageTypeProto, URL_REGEX, WAUrlInfo, WA_DEFAULT_EPHEMERAL, WAMediaUpload -} from './Constants' -import { isGroupID, generateMessageID, extensionForMediaMessage, whatsappID, unixTimestampSeconds, getAudioDuration, newMessagesDB, encryptedStream, decryptMediaMessageBuffer, generateThumbnail } from './Utils' -import { Mutex } from './Mutex' -import { Readable } from 'stream' - -export class WAConnection extends Base { - /** - * Send a message to the given ID (can be group, single, or broadcast) - * @param id the id to send to - * @param message the message can be a buffer, plain string, location message, extended text message - * @param type type of message - * @param options Extra options - */ - async sendMessage( - id: string, - message: string | WATextMessage | WALocationMessage | WAContactMessage | WAContactsArrayMessage | WAGroupInviteMessage | WAMediaUpload, - type: MessageType, - options: MessageOptions = {}, - ) { - const waMessage = await this.prepareMessage (id, message, type, options) - await this.relayWAMessage (waMessage, { waitForAck: options.waitForAck !== false }) - return waMessage - } - /** Prepares a message for sending via sendWAMessage () */ - async prepareMessage( - id: string, - message: string | WATextMessage | WALocationMessage | WAContactMessage | WAContactsArrayMessage | WAGroupInviteMessage | WAMediaUpload, - type: MessageType, - options: MessageOptions = {}, - ) { - const content = await this.prepareMessageContent( - message, - type, - options - ) - const preparedMessage = this.prepareMessageFromContent(id, content, options) - return preparedMessage - } - /** - * Toggles disappearing messages for the given chat - * - * @param jid the chat to toggle - * @param ephemeralExpiration 0 to disable, enter any positive number to enable disappearing messages for the specified duration; - * For the default see WA_DEFAULT_EPHEMERAL - */ - async toggleDisappearingMessages(jid: string, ephemeralExpiration?: number, opts: { waitForAck: boolean } = { waitForAck: true }) { - if(isGroupID(jid)) { - const tag = this.generateMessageTag(true) - await this.setQuery([ - [ - 'group', - { id: tag, jid, type: 'prop', author: this.user.jid }, - [ [ 'ephemeral', { value: ephemeralExpiration.toString() }, null ] ] - ] - ], [WAMetric.group, WAFlag.other], tag) - } else { - const message = this.prepareMessageFromContent( - jid, - this.prepareDisappearingMessageSettingContent(ephemeralExpiration), - {} - ) - await this.relayWAMessage(message, opts) - } - } - /** Prepares the message content */ - async prepareMessageContent (message: string | WATextMessage | WALocationMessage | WAContactMessage | WAContactsArrayMessage | WAGroupInviteMessage | WAMediaUpload, type: MessageType, options: MessageOptions) { - let m: WAMessageContent = {} - switch (type) { - case MessageType.text: - case MessageType.extendedText: - if (typeof message === 'string') message = {text: message} as WATextMessage - - if ('text' in message) { - if (options.detectLinks !== false && message.text.match(URL_REGEX)) { - try { - message = await this.generateLinkPreview (message.text) - } catch (error) { // ignore if fails - this.logger.trace(`failed to generate link preview for message '${message.text}': ${error}`) - } - } - m.extendedTextMessage = WAMessageProto.ExtendedTextMessage.fromObject(message as any) - } else { - throw new BaileysError ('message needs to be a string or object with property \'text\'', message) - } - break - case MessageType.location: - case MessageType.liveLocation: - m.locationMessage = WAMessageProto.LocationMessage.fromObject(message as any) - break - case MessageType.contact: - m.contactMessage = WAMessageProto.ContactMessage.fromObject(message as any) - break - case MessageType.contactsArray: - m.contactsArrayMessage = WAMessageProto.ContactsArrayMessage.fromObject(message as any) - break - case MessageType.groupInviteMessage: - m.groupInviteMessage = WAMessageProto.GroupInviteMessage.fromObject(message as any) - break - case MessageType.image: - case MessageType.sticker: - case MessageType.document: - case MessageType.video: - case MessageType.audio: - m = await this.prepareMessageMedia(message as Buffer, type, options) - break - } - return WAMessageProto.Message.fromObject (m) - } - prepareDisappearingMessageSettingContent(ephemeralExpiration?: number) { - ephemeralExpiration = ephemeralExpiration || 0 - const content: WAMessageContent = { - ephemeralMessage: { - message: { - protocolMessage: { - type: WAMessageProto.ProtocolMessage.ProtocolMessageType.EPHEMERAL_SETTING, - ephemeralExpiration - } - } - } - } - return WAMessageProto.Message.fromObject(content) - } - /** Prepare a media message for sending */ - async prepareMessageMedia(media: WAMediaUpload, mediaType: MessageType, options: MessageOptions = {}) { - if (mediaType === MessageType.document && !options.mimetype) { - throw new Error('mimetype required to send a document') - } - if (mediaType === MessageType.sticker && options.caption) { - throw new Error('cannot send a caption with a sticker') - } - if (!options.mimetype) { - options.mimetype = MimetypeMap[mediaType] - } - let isGIF = false - if (options.mimetype === Mimetype.gif) { - isGIF = true - options.mimetype = MimetypeMap[MessageType.video] - } - const requiresDurationComputation = mediaType === MessageType.audio && !options.duration - const requiresThumbnailComputation = (mediaType === MessageType.image || mediaType === MessageType.video) && !('thumbnail' in options) - const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation - const { - mediaKey, - encBodyPath, - bodyPath, - fileEncSha256, - fileSha256, - fileLength, - didSaveToTmpPath - } = await encryptedStream(media, mediaType, requiresOriginalForSomeProcessing) - // url safe Base64 encode the SHA256 hash of the body - const fileEncSha256B64 = encodeURIComponent( - fileEncSha256.toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/\=+$/, '') - ) - if(requiresThumbnailComputation) { - await generateThumbnail(bodyPath, mediaType, options) - } - if (requiresDurationComputation) { - try { - options.duration = await getAudioDuration(bodyPath) - } catch (error) { - this.logger.debug ({ error }, 'failed to obtain audio duration: ' + error.message) - } - } - // send a query JSON to obtain the url & auth token to upload our media - let json = await this.refreshMediaConn(options.forceNewMediaOptions) - - let mediaUrl: string - for (let host of json.hosts) { - const auth = encodeURIComponent(json.auth) // the auth token - const url = `https://${host.hostname}${MediaPathMap[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}` - - try { - const {body: responseText} = await this.fetchRequest( - url, - 'POST', - createReadStream(encBodyPath), - options.uploadAgent, - { 'Content-Type': 'application/octet-stream' } - ) - const result = JSON.parse(responseText) - mediaUrl = result?.url - - if (mediaUrl) break - else { - json = await this.refreshMediaConn(true) - throw new Error (`upload failed, reason: ${JSON.stringify(result)}`) - } - } catch (error) { - const isLast = host.hostname === json.hosts[json.hosts.length-1].hostname - this.logger.error (`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`) - } - } - if (!mediaUrl) throw new Error('Media upload failed on all hosts') - // remove tmp files - await Promise.all( - [ - fs.unlink(encBodyPath), - didSaveToTmpPath && bodyPath && fs.unlink(bodyPath) - ] - .filter(Boolean) - ) - - const message = { - [mediaType]: MessageTypeProto[mediaType].fromObject( - { - url: mediaUrl, - mediaKey: mediaKey, - mimetype: options.mimetype, - fileEncSha256: fileEncSha256, - fileSha256: fileSha256, - fileLength: fileLength, - seconds: options.duration, - fileName: options.filename || 'file', - gifPlayback: isGIF || undefined, - caption: options.caption, - ptt: options.ptt - } - ) - } - return WAMessageProto.Message.fromObject(message)// as WAMessageContent - } - /** prepares a WAMessage for sending from the given content & options */ - prepareMessageFromContent(id: string, message: WAMessageContent, options: MessageOptions) { - if (!options.timestamp) options.timestamp = new Date() // set timestamp to now - if (typeof options.sendEphemeral === 'undefined') options.sendEphemeral = 'chat' - // prevent an annoying bug (WA doesn't accept sending messages with '@c.us') - id = whatsappID (id) - - const key = Object.keys(message)[0] - const timestamp = unixTimestampSeconds(options.timestamp) - const quoted = options.quoted - - if (options.contextInfo) message[key].contextInfo = options.contextInfo - - if (quoted) { - const participant = quoted.key.fromMe ? this.user.jid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid) - - message[key].contextInfo = message[key].contextInfo || { } - message[key].contextInfo.participant = participant - message[key].contextInfo.stanzaId = quoted.key.id - message[key].contextInfo.quotedMessage = quoted.message - - // if a participant is quoted, then it must be a group - // hence, remoteJid of group must also be entered - if (quoted.key.participant) { - message[key].contextInfo.remoteJid = quoted.key.remoteJid - } - } - if (options?.thumbnail) { - message[key].jpegThumbnail = Buffer.from(options.thumbnail, 'base64') - } - - const chat = this.chats.get(id) - if ( - // if we want to send a disappearing message - ((options?.sendEphemeral === 'chat' && chat?.ephemeral) || - options?.sendEphemeral === true) && - // and it's not a protocol message -- delete, toggle disappear message - key !== 'protocolMessage' && - // already not converted to disappearing message - key !== 'ephemeralMessage' - ) { - message[key].contextInfo = { - ...(message[key].contextInfo || {}), - expiration: chat?.ephemeral || WA_DEFAULT_EPHEMERAL, - ephemeralSettingTimestamp: chat?.eph_setting_ts - } - message = { - ephemeralMessage: { - message - } - } - } - message = WAMessageProto.Message.fromObject (message) - - const messageJSON = { - key: { - remoteJid: id, - fromMe: true, - id: options?.messageId || generateMessageID(), - }, - message: message, - messageTimestamp: timestamp, - messageStubParameters: [], - participant: id.includes('@g.us') ? this.user.jid : null, - status: WA_MESSAGE_STATUS_TYPE.PENDING - } - return WAMessageProto.WebMessageInfo.fromObject (messageJSON) - } - /** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */ - async relayWAMessage(message: WAMessage, { waitForAck } = { waitForAck: true }) { - const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, message]]] - const flag = message.key.remoteJid === this.user?.jid ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself - const mID = message.key.id - message.status = WA_MESSAGE_STATUS_TYPE.PENDING - const promise = this.query({ - json, - binaryTags: [WAMetric.message, flag], - tag: mID, - expect200: true, - requiresPhoneConnection: true - }) - .then(() => message.status = WA_MESSAGE_STATUS_TYPE.SERVER_ACK) - - if (waitForAck) { - await promise - } else { - const emitUpdate = (status: WA_MESSAGE_STATUS_TYPE) => { - message.status = status - this.emit('chat-update', { jid: message.key.remoteJid, messages: newMessagesDB([ message ]) }) - } - promise - .then(() => emitUpdate(WA_MESSAGE_STATUS_TYPE.SERVER_ACK)) - .catch(() => emitUpdate(WA_MESSAGE_STATUS_TYPE.ERROR)) - } - await this.chatAddMessageAppropriate (message) - } - /** - * Fetches the latest url & media key for the given message. - * You may need to call this when the message is old & the content is deleted off of the WA servers - * @param message - */ - @Mutex (message => message?.key?.id) - async updateMediaMessage (message: WAMessage) { - const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage - if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message) - - const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null] - const response = await this.query ({ - json: query, - binaryTags: [WAMetric.queryMedia, WAFlag.ignore], - expect200: true, - requiresPhoneConnection: true - }) - Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message - } - async downloadMediaMessage (message: WAMessage): Promise - async downloadMediaMessage (message: WAMessage, type: 'buffer'): Promise - async downloadMediaMessage (message: WAMessage, type: 'stream'): Promise - /** - * Securely downloads the media from the message. - * Renews the download url automatically, if necessary. - */ - @Mutex (message => message?.key?.id) - async downloadMediaMessage (message: WAMessage, type: 'buffer' | 'stream' = 'buffer') { - let mContent = message.message?.ephemeralMessage?.message || message.message - if (!mContent) throw new BaileysError('No message present', { status: 400 }) - - const downloadMediaMessage = async () => { - const stream = await decryptMediaMessageBuffer(mContent) - if(type === 'buffer') { - let buffer = Buffer.from([]) - for await(const chunk of stream) { - buffer = Buffer.concat([buffer, chunk]) - } - return buffer - } - return stream - } - - try { - const buff = await downloadMediaMessage() - return buff - } catch (error) { - if (error instanceof BaileysError && error.status === 404) { // media needs to be updated - this.logger.info (`updating media of message: ${message.key.id}`) - await this.updateMediaMessage (message) - mContent = message.message?.ephemeralMessage?.message || message.message - const buff = await downloadMediaMessage() - return buff - } - throw error - } - } - /** - * Securely downloads the media from the message and saves to a file. - * Renews the download url automatically, if necessary. - * @param message the media message you want to decode - * @param filename the name of the file where the media will be saved - * @param attachExtension should the parsed extension be applied automatically to the file - */ - async downloadAndSaveMediaMessage (message: WAMessage, filename: string, attachExtension: boolean=true) { - const extension = extensionForMediaMessage (message.message) - const trueFileName = attachExtension ? (filename + '.' + extension) : filename - const buffer = await this.downloadMediaMessage(message) - - await fs.writeFile(trueFileName, buffer) - return trueFileName - } - /** Query a string to check if it has a url, if it does, return required extended text message */ - async generateLinkPreview (text: string) { - const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null] - const response = await this.query ({json: query, binaryTags: [26, WAFlag.ignore], expect200: true, requiresPhoneConnection: false}) - - if (response[1]) response[1].jpegThumbnail = response[2] - const data = response[1] as WAUrlInfo - - const content = {text} as WATextMessage - content.canonicalUrl = data['canonical-url'] - content.matchedText = data['matched-text'] - content.jpegThumbnail = data.jpegThumbnail - content.description = data.description - content.title = data.title - content.previewType = 0 - return content - } - @Mutex () - protected async refreshMediaConn (forceGet = false) { - if (!this.mediaConn || forceGet || (new Date().getTime()-this.mediaConn.fetchDate.getTime()) > this.mediaConn.ttl*1000) { - this.mediaConn = await this.getNewMediaConn() - this.mediaConn.fetchDate = new Date() - } - return this.mediaConn - } - protected async getNewMediaConn () { - const {media_conn} = await this.query({json: ['query', 'mediaConn'], requiresPhoneConnection: false}) - return media_conn as MediaConnInfo - } -} diff --git a/src/WAConnection/7.MessagesExtra.ts b/src/WAConnection/7.MessagesExtra.ts deleted file mode 100644 index 2e255e7..0000000 --- a/src/WAConnection/7.MessagesExtra.ts +++ /dev/null @@ -1,481 +0,0 @@ -import {WAConnection as Base} from './6.MessagesSend' -import { MessageType, WAMessageKey, MessageInfo, WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, ChatModification, BaileysError, WAChatIndex, WAChat } from './Constants' -import { whatsappID, delay, toNumber, unixTimestampSeconds, GET_MESSAGE_ID, isGroupID, newMessagesDB } from './Utils' -import { Mutex } from './Mutex' - -export class WAConnection extends Base { - - @Mutex () - async loadAllUnreadMessages () { - const tasks = this.chats.all() - .filter(chat => chat.count > 0) - .map (chat => this.loadMessages(chat.jid, chat.count)) - const list = await Promise.all (tasks) - const combined: WAMessage[] = [] - list.forEach (({messages}) => combined.push(...messages)) - return combined - } - /** Get the message info, who has read it, who its been delivered to */ - @Mutex ((jid, messageID) => jid+messageID) - async messageInfo (jid: string, messageID: string) { - const query = ['query', {type: 'message_info', index: messageID, jid: jid, epoch: this.msgCount.toString()}, null] - const [,,response] = await this.query ({ - json: query, - binaryTags: [WAMetric.queryRead, WAFlag.ignore], - expect200: true, - requiresPhoneConnection: true - }) - - const info: MessageInfo = {reads: [], deliveries: []} - if (response) { - const reads = response.filter (node => node[0] === 'read') - if (reads[0]) { - info.reads = reads[0][2].map (item => item[1]) - } - const deliveries = response.filter (node => node[0] === 'delivery') - if (deliveries[0]) { - info.deliveries = deliveries[0][2].map (item => item[1]) - } - } - return info - } - /** - * Marks a chat as read/unread; updates the chat object too - * @param jid the ID of the person/group whose message you want to mark read - * @param unread unreads the chat, if true - */ - @Mutex (jid => jid) - async chatRead (jid: string, type: 'unread' | 'read' = 'read') { - jid = whatsappID (jid) - const chat = this.assertChatGet (jid) - - const count = type === 'unread' ? '-2' : Math.abs(chat.count).toString() - if (type === 'unread' || chat.count !== 0) { - const idx = await this.getChatIndex(jid) - await this.setQuery ([ - ['read', { jid, count, ...idx, participant: undefined }, null] - ], [ WAMetric.read, WAFlag.ignore ]) - } - chat.count = type === 'unread' ? -1 : 0 - this.emit ('chat-update', {jid, count: chat.count}) - } - /** - * Sends a read receipt for a given message; - * does not update the chat do @see chatRead - * @deprecated just use chatRead() - * @param jid the ID of the person/group whose message you want to mark read - * @param messageKey the key of the message - * @param count number of messages to read, set to < 0 to unread a message - */ - async sendReadReceipt(jid: string, messageKey: WAMessageKey, count: number) { - const attributes = { - jid, - count: count.toString(), - index: messageKey?.id, - participant: messageKey?.participant || undefined, - owner: messageKey?.fromMe?.toString() - } - const read = await this.setQuery ([['read', attributes, null]], [ WAMetric.read, WAFlag.ignore ]) - return read - } - async fetchMessagesFromWA (jid: string, count: number, indexMessage?: { id?: string; fromMe?: boolean }, mostRecentFirst: boolean = true) { - const json = [ - 'query', - { - epoch: this.msgCount.toString(), - type: 'message', - jid: jid, - kind: mostRecentFirst ? 'before' : 'after', - count: count.toString(), - index: indexMessage?.id, - owner: indexMessage?.fromMe === false ? 'false' : 'true', - }, - null, - ] - const response = await this.query({json, binaryTags: [WAMetric.queryMessages, WAFlag.ignore], expect200: false, requiresPhoneConnection: true}) - return (response[2] as WANode[])?.map(item => item[2] as WAMessage) || [] - } - /** - * Load the conversation with a group or person - * @param count the number of messages to load - * @param cursor the data for which message to offset the query by - * @param mostRecentFirst retrieve the most recent message first or retrieve from the converation start - */ - @Mutex (jid => jid) - async loadMessages ( - jid: string, - count: number, - cursor?: { id?: string; fromMe?: boolean }, - mostRecentFirst: boolean = true - ) { - jid = whatsappID(jid) - - const retrieve = (count: number, indexMessage: any) => this.fetchMessagesFromWA (jid, count, indexMessage, mostRecentFirst) - - const chat = this.chats.get (jid) - const hasCursor = cursor?.id && typeof cursor?.fromMe !== 'undefined' - const cursorValue = hasCursor && chat?.messages.get (GET_MESSAGE_ID(cursor)) - - let messages: WAMessage[] - if (chat?.messages && mostRecentFirst && (!hasCursor || cursorValue)) { - messages = chat.messages.paginatedByValue (cursorValue, count, null, 'before') - - const diff = count - messages.length - if (diff < 0) { - messages = messages.slice(-count) // get the last X messages - } else if (diff > 0) { - const fMessage = chat.messages.all()[0] - let fepoch = (fMessage && fMessage['epoch']) || 0 - const extra = await retrieve (diff, messages[0]?.key || cursor) - // add to DB - for (let i = extra.length-1;i >= 0; i--) { - const m = extra[i] - fepoch -= 1 - m['epoch'] = fepoch - - if(chat.messages.length < this.maxCachedMessages) { - chat.messages.insertIfAbsent(m) - } - } - messages.unshift (...extra) - } - } else messages = await retrieve (count, cursor) - - if (messages[0]) cursor = { id: messages[0].key.id, fromMe: messages[0].key.fromMe } - else cursor = null - - return {messages, cursor} - } - /** - * Load the entire friggin conversation with a group or person - * @param onMessage callback for every message retrieved - * @param chunkSize the number of messages to load in a single request - * @param mostRecentFirst retrieve the most recent message first or retrieve from the converation start - */ - loadAllMessages(jid: string, onMessage: (m: WAMessage) => Promise|void, chunkSize = 25, mostRecentFirst = true) { - let offsetID = null - const loadMessage = async () => { - const {messages} = await this.loadMessages(jid, chunkSize, offsetID, mostRecentFirst) - // callback with most recent message first (descending order of date) - let lastMessage - if (mostRecentFirst) { - for (let i = messages.length - 1; i >= 0; i--) { - await onMessage(messages[i]) - lastMessage = messages[i] - } - } else { - for (let i = 0; i < messages.length; i++) { - await onMessage(messages[i]) - lastMessage = messages[i] - } - } - // if there are still more messages - if (messages.length >= chunkSize) { - offsetID = lastMessage.key // get the last message - await delay(200) - return loadMessage() - } - } - return loadMessage() as Promise - } - /** - * Find a message in a given conversation - * @param chunkSize the number of messages to load in a single request - * @param onMessage callback for every message retrieved, if return true -- the loop will break - */ - async findMessage (jid: string, chunkSize: number, onMessage: (m: WAMessage) => boolean) { - const chat = this.chats.get (whatsappID(jid)) - let count = chat?.messages?.all().length || chunkSize - let offsetID - while (true) { - const {messages, cursor} = await this.loadMessages(jid, count, offsetID, true) - // callback with most recent message first (descending order of date) - for (let i = messages.length - 1; i >= 0; i--) { - if (onMessage(messages[i])) return - } - if (messages.length === 0) return - // if there are more messages - offsetID = cursor - await delay (200) - } - } - /** - * Loads all messages sent after a specific date - */ - async messagesReceivedAfter (date: Date, onlyUnrespondedMessages = false) { - const stamp = unixTimestampSeconds (date) - // find the index where the chat timestamp becomes greater - const idx = this.chats.all ().findIndex (c => c.t < stamp) - // all chats before that index -- i.e. all chats that were updated after that - const chats = this.chats.all ().slice (0, idx) - - const messages: WAMessage[] = [] - await Promise.all ( - chats.map (async chat => { - await this.findMessage (chat.jid, 5, m => { - if (toNumber(m.messageTimestamp) < stamp || (onlyUnrespondedMessages && m.key.fromMe)) return true - messages.push (m) - }) - }) - ) - return messages - } - /** Load a single message specified by the ID */ - async loadMessage (jid: string, id: string) { - let message: WAMessage - - jid = whatsappID (jid) - const chat = this.chats.get (jid) - if (chat) { - // see if message is present in cache - message = chat.messages.get (GET_MESSAGE_ID({ id, fromMe: true })) || chat.messages.get (GET_MESSAGE_ID({ id, fromMe: false })) - } - if (!message) { - // load the message before the given message - let messages = (await this.loadMessages (jid, 1, {id, fromMe: true})).messages - if (!messages[0]) messages = (await this.loadMessages (jid, 1, {id, fromMe: false})).messages - // the message after the loaded message is the message required - const actual = await this.loadMessages (jid, 1, messages[0] && messages[0].key, false) - message = actual.messages[0] - } - return message - } - /** - * Search WhatsApp messages with a given text string - * @param txt the search string - * @param inJid the ID of the chat to search in, set to null to search all chats - * @param count number of results to return - * @param page page number of results (starts from 1) - */ - async searchMessages(txt: string, inJid: string | null, count: number, page: number) { - const json = [ - 'query', - { - epoch: this.msgCount.toString(), - type: 'search', - search: Buffer.from(txt, 'utf-8'), - count: count.toString(), - page: page.toString(), - jid: inJid - }, - null, - ] - - const response: WANode = await this.query({json, binaryTags: [24, WAFlag.ignore], expect200: true}) // encrypt and send off - const messages = response[2] ? response[2].map (row => row[2]) : [] - return { - last: response[1]['last'] === 'true', - messages: messages as WAMessage[] - } - } - /** - * Delete a message in a chat for yourself - * @param messageKey key of the message you want to delete - */ - @Mutex (m => m.remoteJid) - async clearMessage (messageKey: WAMessageKey) { - const tag = Math.round(Math.random ()*1000000) - const attrs: WANode = [ - 'chat', - { jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' }, - [ - ['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null] - ] - ] - const result = await this.setQuery ([attrs]) - - const chat = this.chats.get (whatsappID(messageKey.remoteJid)) - if (chat) { - const value = chat.messages.get (GET_MESSAGE_ID(messageKey)) - value && chat.messages.delete (value) - } - return result - } - /** - * Star or unstar a message - * @param messageKey key of the message you want to star or unstar - */ - @Mutex (m => m.remoteJid) - async starMessage (messageKey: WAMessageKey, type: 'star' | 'unstar' = 'star') { - const attrs: WANode = [ - 'chat', - { - jid: messageKey.remoteJid, - type - }, - [ - ['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null] - ] - ] - const result = await this.setQuery ([attrs]) - - const chat = this.chats.get (whatsappID(messageKey.remoteJid)) - if (result.status == 200 && chat) { - const message = chat.messages.get (GET_MESSAGE_ID(messageKey)) - if (message) { - message.starred = type === 'star' - - const chatUpdate: Partial = { jid: messageKey.remoteJid, messages: newMessagesDB([ message ]) } - this.emit ('chat-update', chatUpdate) - } - } - return result - } - /** - * Delete a message in a chat for everyone - * @param id the person or group where you're trying to delete the message - * @param messageKey key of the message you want to delete - */ - async deleteMessage (k: string | WAMessageKey, messageKey?: WAMessageKey) { - if (typeof k === 'object') { - messageKey = k - } - const json: WAMessageContent = { - protocolMessage: { - key: messageKey, - type: WAMessageProto.ProtocolMessage.ProtocolMessageType.REVOKE - } - } - const waMessage = this.prepareMessageFromContent (messageKey.remoteJid, json, {}) - await this.relayWAMessage (waMessage) - return waMessage - } - /** - * Generate forwarded message content like WA does - * @param message the message to forward - * @param forceForward will show the message as forwarded even if it is from you - */ - generateForwardMessageContent (message: WAMessage, forceForward: boolean=false) { - let content = message.message - if (!content) throw new BaileysError ('no content in message', { status: 400 }) - content = WAMessageProto.Message.fromObject(content) // hacky copy - - let key = Object.keys(content)[0] - - let score = content[key].contextInfo?.forwardingScore || 0 - score += message.key.fromMe && !forceForward ? 0 : 1 - if (key === MessageType.text) { - content[MessageType.extendedText] = { text: content[key] } - delete content[MessageType.text] - - key = MessageType.extendedText - } - if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true } - else content[key].contextInfo = {} - return content - } - /** - * Forward a message like WA - * @param jid the chat ID to forward to - * @param message the message to forward - * @param forceForward will show the message as forwarded even if it is from you - */ - async forwardMessage(jid: string, message: WAMessage, forceForward: boolean=false) { - const content = this.generateForwardMessageContent(message, forceForward) - const waMessage = this.prepareMessageFromContent (jid, content, {}) - await this.relayWAMessage (waMessage) - return waMessage - } - /** - * Clear the chat messages - * @param jid the ID of the person/group you are modifiying - * @param includeStarred delete starred messages, default false - */ - async modifyChat (jid: string, type: ChatModification.clear, includeStarred?: boolean): Promise<{status: number;}>; - /** - * Modify a given chat (archive, pin etc.) - * @param jid the ID of the person/group you are modifiying - * @param durationMs only for muting, how long to mute the chat for - */ - async modifyChat (jid: string, type: ChatModification.pin | ChatModification.mute, durationMs: number): Promise<{status: number;}>; - /** - * Modify a given chat (archive, pin etc.) - * @param jid the ID of the person/group you are modifiying - */ - async modifyChat (jid: string, type: ChatModification | (keyof typeof ChatModification)): Promise<{status: number;}>; - @Mutex ((jid, type) => jid+type) - async modifyChat (jid: string, type: (keyof typeof ChatModification), arg?: number | boolean): Promise<{status: number;}> { - jid = whatsappID (jid) - const chat = this.assertChatGet (jid) - - let chatAttrs: Record = {jid: jid} - if (type === ChatModification.mute && !arg) { - throw new BaileysError( - 'duration must be set to the timestamp of the time of pinning/unpinning of the chat', - { status: 400 } - ) - } - - const durationMs:number = arg as number || 0 - const includeStarred:boolean = arg as boolean - let index: WAChatIndex; - switch (type) { - case ChatModification.pin: - case ChatModification.mute: - const strStamp = (unixTimestampSeconds() + Math.floor(durationMs/1000)).toString() - chatAttrs.type = type - chatAttrs[type] = strStamp - break - case ChatModification.unpin: - case ChatModification.unmute: - chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin' - chatAttrs.previous = chat[type.replace ('un', '')] - break - case ChatModification.clear: - chatAttrs.type = type - chatAttrs.star = includeStarred ? 'true' : 'false' - index = await this.getChatIndex(jid) - chatAttrs = { ...chatAttrs, ...index } - delete chatAttrs.participant - break - default: - chatAttrs.type = type - index = await this.getChatIndex(jid) - chatAttrs = { ...chatAttrs, ...index } - break - } - - const response = await this.setQuery ([['chat', chatAttrs, null]], [ WAMetric.chat, WAFlag.ignore ]) - - if (chat && response.status === 200) { - switch(type) { - case ChatModification.clear: - if (includeStarred) { - chat.messages.clear() - } else { - chat.messages = chat.messages.filter(m => m.starred) - } - break - case ChatModification.delete: - this.chats.deleteById(jid) - this.emit('chat-update', { jid, delete: 'true' }) - break - default: - this.chats.update(jid, chat => { - if (type.includes('un')) { - type = type.replace ('un', '') as ChatModification - delete chat[type.replace('un','')] - this.emit ('chat-update', { jid, [type]: false }) - } else { - chat[type] = chatAttrs[type] || 'true' - this.emit ('chat-update', { jid, [type]: chat[type] }) - } - }) - break - } - } - return response - } - protected async getChatIndex (jid: string): Promise { - const chatAttrs = {} as WAChatIndex - const { messages: [msg] } = await this.loadMessages(jid, 1) - if (msg) { - chatAttrs.index = msg.key.id - chatAttrs.owner = msg.key.fromMe.toString() as 'true' | 'false' - } - if (isGroupID(jid)) { - chatAttrs.participant = msg.key.fromMe ? this.user?.jid : whatsappID(msg.participant || msg.key.participant) - } - return chatAttrs - } -} diff --git a/src/WAConnection/8.Groups.ts b/src/WAConnection/8.Groups.ts deleted file mode 100644 index 379768d..0000000 --- a/src/WAConnection/8.Groups.ts +++ /dev/null @@ -1,202 +0,0 @@ -import {WAConnection as Base} from './7.MessagesExtra' -import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification, BaileysError } from '../WAConnection/Constants' -import { GroupSettingChange } from './Constants' -import { generateMessageID, whatsappID } from '../WAConnection/Utils' -import { Mutex } from './Mutex' - -export class WAConnection extends Base { - /** Generic function for group queries */ - async groupQuery(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: WANode[]) { - const tag = this.generateMessageTag() - const json: WANode = [ - 'group', - { - author: this.user.jid, - id: tag, - type: type, - jid: jid, - subject: subject, - }, - participants ? participants.map(jid => ['participant', { jid }, null]) : additionalNodes, - ] - const result = await this.setQuery ([json], [WAMetric.group, 136], tag) - return result - } - /** - * Get the metadata of the group - * Baileys automatically caches & maintains this state - */ - @Mutex(jid => jid) - async groupMetadata (jid: string) { - const chat = this.chats.get(jid) - let metadata = chat?.metadata - if (!metadata) { - if (chat?.read_only) { - metadata = await this.groupMetadataMinimal(jid) - } else { - metadata = await this.fetchGroupMetadataFromWA(jid) - } - if (chat) chat.metadata = metadata - } - return metadata - } - /** Get the metadata of the group from WA */ - fetchGroupMetadataFromWA = async (jid: string) => { - const metadata = await this.query({json: ['query', 'GroupMetadata', jid], expect200: true}) - metadata.participants = metadata.participants.map(p => ( - { ...this.contactAddOrGet(p.id), ...p } - )) - return metadata as WAGroupMetadata - } - /** Get the metadata (works after you've left the group also) */ - groupMetadataMinimal = async (jid: string) => { - const query = ['query', {type: 'group', jid: jid, epoch: this.msgCount.toString()}, null] - const response = await this.query({json: query, binaryTags: [WAMetric.group, WAFlag.ignore], expect200: true}) - const json = response[2][0] - const creatorDesc = json[1] - const participants = json[2] ? json[2].filter (item => item[0] === 'participant') : [] - const description = json[2] ? json[2].find (item => item[0] === 'description') : null - return { - id: jid, - owner: creatorDesc?.creator, - creator: creatorDesc?.creator, - creation: parseInt(creatorDesc?.create), - subject: null, - desc: description && description[2].toString('utf-8'), - participants: participants.map (item => ( - { ...this.contactAddOrGet(item[1].jid), isAdmin: item[1].type === 'admin' } - )) - } as WAGroupMetadata - } - /** - * Create a group - * @param title like, the title of the group - * @param participants people to include in the group - */ - groupCreate = async (title: string, participants: string[]) => { - const response = await this.groupQuery('create', null, title, participants) as WAGroupCreateResponse - const gid = response.gid - let metadata: WAGroupMetadata - try { - metadata = await this.groupMetadata (gid) - } catch (error) { - this.logger.warn (`error in group creation: ${error}, switching gid & checking`) - // if metadata is not available - const comps = gid.replace ('@g.us', '').split ('-') - response.gid = `${comps[0]}-${+comps[1] + 1}@g.us` - - metadata = await this.groupMetadata (gid) - this.logger.warn (`group ID switched from ${gid} to ${response.gid}`) - } - await this.chatAdd(response.gid, title, { metadata }) - return response - } - /** - * Leave a group - * @param jid the ID of the group - */ - groupLeave = async (jid: string) => { - const response = await this.groupQuery('leave', jid) - - const chat = this.chats.get (jid) - if (chat) chat.read_only = 'true' - - return response - } - /** - * Update the subject of the group - * @param {string} jid the ID of the group - * @param {string} title the new title of the group - */ - groupUpdateSubject = async (jid: string, title: string) => { - const chat = this.chats.get (jid) - if (chat?.name === title) throw new BaileysError ('redundant change', { status: 400 }) - - const response = await this.groupQuery('subject', jid, title) - if (chat) chat.name = title - - return response - } - - /** - * Update the group description - * @param {string} jid the ID of the group - * @param {string} title the new title of the group - */ - groupUpdateDescription = async (jid: string, description: string) => { - const metadata = await this.groupMetadata (jid) - const node: WANode = [ - 'description', - {id: generateMessageID(), prev: metadata?.descId}, - Buffer.from (description, 'utf-8') - ] - const response = await this.groupQuery ('description', jid, null, null, [node]) - return response - } - /** - * Add somebody to the group - * @param jid the ID of the group - * @param participants the people to add - */ - groupAdd = (jid: string, participants: string[]) => - this.groupQuery('add', jid, null, participants) as Promise - /** - * Remove somebody from the group - * @param jid the ID of the group - * @param participants the people to remove - */ - groupRemove = (jid: string, participants: string[]) => - this.groupQuery('remove', jid, null, participants) as Promise - /** - * Make someone admin on the group - * @param jid the ID of the group - * @param participants the people to make admin - */ - groupMakeAdmin = (jid: string, participants: string[]) => - this.groupQuery('promote', jid, null, participants) as Promise - /** - * Make demote an admin on the group - * @param jid the ID of the group - * @param participants the people to make admin - */ - groupDemoteAdmin = (jid: string, participants: string[]) => - this.groupQuery('demote', jid, null, participants) as Promise - /** - * Make demote an admin on the group - * @param jid the ID of the group - * @param participants the people to make admin - */ - groupSettingChange = (jid: string, setting: GroupSettingChange, onlyAdmins: boolean) => { - const node: WANode = [ setting, {value: onlyAdmins ? 'true' : 'false'}, null ] - return this.groupQuery('prop', jid, null, null, [node]) as Promise<{status: number}> - } - /** - * Get the invite link of the given group - * @param jid the ID of the group - * @returns invite code - */ - async groupInviteCode(jid: string) { - const json = ['query', 'inviteCode', jid] - const response = await this.query({json, expect200: true, requiresPhoneConnection: false}) - return response.code as string - } - /** - * Join group via invite code - * @param code the invite code - * @returns Object containing gid - */ - async acceptInvite(code: string) { - const json = ['action', 'invite', code] - const response = await this.query({json, expect200: true}) - return response - } - /** - * Revokes the current invite link for a group chat - * @param jid the ID of the group - */ - async revokeInvite(jid: string) { - const json = ['action', 'inviteReset', jid] - const response = await this.query({json, expect200: true}) - return response - } -} diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts deleted file mode 100644 index df47251..0000000 --- a/src/WAConnection/Constants.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { WA } from '../Binary/Constants' -import { proto } from '../../WAMessage/WAMessage' -import { Agent } from 'https' -import KeyedDB from '@adiwajshing/keyed-db' -import { URL } from 'url' - -export const WS_URL = 'wss://web.whatsapp.com/ws' -export const DEFAULT_ORIGIN = 'https://web.whatsapp.com' - -export const KEEP_ALIVE_INTERVAL_MS = 20*1000 -export const WA_DEFAULT_EPHEMERAL = 7*24*60*60 - -// export the WAMessage Prototypes -export { proto as WAMessageProto } -export type WANode = WA.Node -export type WAMessage = proto.WebMessageInfo -export type WAMessageContent = proto.IMessage -export type WAContactMessage = proto.ContactMessage -export type WAContactsArrayMessage = proto.ContactsArrayMessage -export type WAGroupInviteMessage = proto.GroupInviteMessage -export type WAMessageKey = proto.IMessageKey -export type WATextMessage = proto.ExtendedTextMessage -export type WAContextInfo = proto.IContextInfo -export type WAGenericMediaMessage = proto.IVideoMessage | proto.IImageMessage | proto.IAudioMessage | proto.IDocumentMessage | proto.IStickerMessage -export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WebMessageInfoStubType -export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WebMessageInfoStatus - -export type WAInitResponse = { - ref: string - ttl: number - status: 200 -} - -export interface WABusinessProfile { - description: string - email: string - business_hours: WABusinessHours - website: string[] - categories: WABusinessCategories[] - wid?: string -} - -export type WABusinessCategories = { - id: string - localized_display_name: string -} - -export type WABusinessHours = { - timezone: string - config?: WABusinessHoursConfig[] - business_config?: WABusinessHoursConfig[] -} - -export type WABusinessHoursConfig = { - day_of_week: string - mode: string - open_time?: number - close_time?: number -} - -export interface WALocationMessage { - degreesLatitude: number - degreesLongitude: number - address?: string -} -/** Reverse stub type dictionary */ -export const WA_MESSAGE_STUB_TYPES = function () { - const types = WA_MESSAGE_STUB_TYPE - const dict: Record = {} - Object.keys(types).forEach(element => dict[ types[element] ] = element) - return dict -}() - -export class BaileysError extends Error { - status?: number - context: any - - constructor (message: string, context: any, stack?: string) { - super (message) - this.name = 'BaileysError' - this.status = context.status - this.context = context - if(stack) { - this.stack = stack - } - } -} -export const TimedOutError = (stack?: string) => new BaileysError ('timed out', { status: 408 }, stack) -export const CancelledError = (stack?: string) => new BaileysError ('cancelled', { status: 500 }, stack) - -export interface WAQuery { - json: any[] | WANode - binaryTags?: WATag - timeoutMs?: number - tag?: string - expect200?: boolean - waitForOpen?: boolean - longTag?: boolean - requiresPhoneConnection?: boolean - startDebouncedTimeout?: boolean - maxRetries?: number -} - -export type WAMediaUpload = Buffer | { url: URL | string } - -export enum ReconnectMode { - /** does not reconnect */ - off = 0, - /** reconnects only when the connection is 'lost' or 'close' */ - onConnectionLost = 1, - /** reconnects on all disconnects, including take overs */ - onAllErrors = 2 -} -export type WALoadChatOptions = { - searchString?: string - custom?: (c: WAChat) => boolean -} -export type WAConnectOptions = { - /** fails the connection if no data is received for X seconds */ - maxIdleTimeMs?: number - /** maximum attempts to connect */ - maxRetries?: number - /** max time for the phone to respond to a connectivity test */ - phoneResponseTime?: number - connectCooldownMs?: number - /** agent used for WS connections */ - agent?: Agent - /** agent used for fetch requests -- uploading/downloading media */ - fetchAgent?: Agent - /** Always uses takeover for connections */ - alwaysUseTakeover?: boolean - /** - * Sometimes WA does not send the chats, - * this keeps pinging the phone to send the chats over - * */ - queryChatsTillReceived?: boolean - /** max time for the phone to respond to a query */ - maxQueryResponseTime?: number - /** Log QR to terminal or not */ - logQR?: boolean -} -/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */ -export const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)?/gi - -export type WAConnectionState = 'open' | 'connecting' | 'close' - -export const UNAUTHORIZED_CODES = [401, 419] -/** Types of Disconnect Reasons */ -export enum DisconnectReason { - /** The connection was closed intentionally */ - intentional = 'intentional', - /** The connection was terminated either by the client or server */ - close = 'close', - /** The connection was lost, called when the server stops responding to requests */ - lost = 'lost', - /** When WA Web is opened elsewhere & this session is disconnected */ - replaced = 'replaced', - /** The credentials for the session have been invalidated, i.e. logged out either from the phone or WA Web */ - invalidSession = 'invalid_session', - /** Received a 500 result in a query -- something has gone very wrong */ - badSession = 'bad_session', - /** No idea, can be a sign of log out too */ - unknown = 'unknown', - /** Well, the connection timed out */ - timedOut = 'timed out' -} -export interface MediaConnInfo { - auth: string - ttl: number - hosts: { - hostname: string - }[] - fetchDate: Date -} -export interface AuthenticationCredentials { - clientID: string - serverToken: string - clientToken: string - encKey: Buffer - macKey: Buffer -} -export interface AuthenticationCredentialsBase64 { - clientID: string - serverToken: string - clientToken: string - encKey: string - macKey: string -} -export interface AuthenticationCredentialsBrowser { - WABrowserId: string - WASecretBundle: {encKey: string, macKey: string} | string - WAToken1: string - WAToken2: string -} -export type AnyAuthenticationCredentials = AuthenticationCredentialsBrowser | AuthenticationCredentialsBase64 | AuthenticationCredentials - -export interface WAGroupCreateResponse { - status: number - gid?: string - participants?: [{ [key: string]: any }] -} -export type WAGroupParticipant = (WAContact & { isAdmin: boolean; isSuperAdmin: boolean }) -export interface WAGroupMetadata { - id: string - owner: string - subject: string - creation: number - desc?: string - descOwner?: string - descId?: string - /** is set when the group only allows admins to change group settings */ - restrict?: 'true' | 'false' - /** is set when the group only allows admins to write messages */ - announce?: 'true' | 'false' - // Baileys modified array - participants: WAGroupParticipant[] -} -export interface WAGroupModification { - status: number - participants?: { [key: string]: any } -} -export interface WAPresenceData { - lastKnownPresence?: Presence - lastSeen?: number - name?: string -} -export interface WAContact { - verify?: string - /** name of the contact, the contact has set on their own on WA */ - notify?: string - jid: string - /** I have no idea */ - vname?: string - /** name of the contact, you have saved on your WA */ - name?: string - index?: string - /** short name for the contact */ - short?: string - // Baileys Added - imgUrl?: string -} -export interface WAUser extends WAContact { - phone: any -} -export type WAContactUpdate = Partial & { jid: string, status?: string } -export interface WAChat { - jid: string - - t: number - /** number of unread messages, is < 0 if the chat is manually marked unread */ - count: number - archive?: 'true' | 'false' - clear?: 'true' | 'false' - read_only?: 'true' | 'false' - mute?: string - pin?: string - spam?: 'false' | 'true' - modify_tag?: string - name?: string - /** when ephemeral messages were toggled on */ - eph_setting_ts?: string - /** how long each message lasts for */ - ephemeral?: string - - // Baileys added properties - messages: KeyedDB - imgUrl?: string - presences?: { [k: string]: WAPresenceData } - metadata?: WAGroupMetadata -} -export type WAChatIndex = { index: string, owner: 'true' | 'false', participant?: string } -export type WAChatUpdate = Partial & { jid: string, hasNewMessage?: boolean } -export enum WAMetric { - debugLog = 1, - queryResume = 2, - liveLocation = 3, - queryMedia = 4, - queryChat = 5, - queryContact = 6, - queryMessages = 7, - presence = 8, - presenceSubscribe = 9, - group = 10, - read = 11, - chat = 12, - received = 13, - picture = 14, - status = 15, - message = 16, - queryActions = 17, - block = 18, - queryGroup = 19, - queryPreview = 20, - queryEmoji = 21, - queryRead = 22, - queryVCard = 29, - queryStatus = 30, - queryStatusUpdate = 31, - queryLiveLocation = 33, - queryLabel = 36, - queryQuickReply = 39 -} - -export const STORIES_JID = 'status@broadcast' - -export enum WAFlag { - available = 160, - other = 136, // don't know this one - ignore = 1 << 7, - acknowledge = 1 << 6, - unavailable = 1 << 4, - expires = 1 << 3, - composing = 1 << 2, - recording = 1 << 2, - paused = 1 << 2 -} -/** Tag used with binary queries */ -export type WATag = [WAMetric, WAFlag] -/** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ -export enum Presence { - unavailable = 'unavailable', // "offline" - available = 'available', // "online" - composing = 'composing', // "typing..." - recording = 'recording', // "recording..." - paused = 'paused', // stop typing -} -/** Set of message types that are supported by the library */ -export enum MessageType { - text = 'conversation', - extendedText = 'extendedTextMessage', - contact = 'contactMessage', - contactsArray = 'contactsArrayMessage', - groupInviteMessage = 'groupInviteMessage', - location = 'locationMessage', - liveLocation = 'liveLocationMessage', - - image = 'imageMessage', - video = 'videoMessage', - sticker = 'stickerMessage', - document = 'documentMessage', - audio = 'audioMessage', - product = 'productMessage' -} - -export const MessageTypeProto = { - [MessageType.image]: proto.ImageMessage, - [MessageType.video]: proto.VideoMessage, - [MessageType.audio]: proto.AudioMessage, - [MessageType.sticker]: proto.StickerMessage, - [MessageType.document]: proto.DocumentMessage, -} -export enum ChatModification { - archive='archive', - unarchive='unarchive', - pin='pin', - unpin='unpin', - mute='mute', - unmute='unmute', - delete='delete', - clear='clear' -} -export const HKDFInfoKeys = { - [MessageType.image]: 'WhatsApp Image Keys', - [MessageType.audio]: 'WhatsApp Audio Keys', - [MessageType.video]: 'WhatsApp Video Keys', - [MessageType.document]: 'WhatsApp Document Keys', - [MessageType.sticker]: 'WhatsApp Image Keys' -} -export enum Mimetype { - jpeg = 'image/jpeg', - png = 'image/png', - mp4 = 'video/mp4', - gif = 'video/gif', - pdf = 'application/pdf', - ogg = 'audio/ogg; codecs=opus', - mp4Audio = 'audio/mp4', - /** for stickers */ - webp = 'image/webp', -} -export interface MessageOptions { - /** the message you want to quote */ - quoted?: WAMessage - /** some random context info (can show a forwarded message with this too) */ - contextInfo?: WAContextInfo - /** optional, if you want to manually set the timestamp of the message */ - timestamp?: Date - /** (for media messages) the caption to send with the media (cannot be sent with stickers though) */ - caption?: string - /** - * For location & media messages -- has to be a base 64 encoded JPEG if you want to send a custom thumb, - * or set to null if you don't want to send a thumbnail. - * Do not enter this field if you want to automatically generate a thumb - * */ - thumbnail?: string - /** (for media messages) specify the type of media (optional for all media types except documents) */ - mimetype?: Mimetype | string - /** (for media messages) file name for the media */ - filename?: string - /** For audio messages, if set to true, will send as a `voice note` */ - ptt?: boolean - /** Optional agent for media uploads */ - uploadAgent?: Agent - /** If set to true (default), automatically detects if you're sending a link & attaches the preview*/ - detectLinks?: boolean - /** Optionally specify the duration of the media (audio/video) in seconds */ - duration?: number - /** Fetches new media options for every media file */ - forceNewMediaOptions?: boolean - /** Wait for the message to be sent to the server (default true) */ - waitForAck?: boolean - /** Should it send as a disappearing messages. - * By default 'chat' -- which follows the setting of the chat */ - sendEphemeral?: 'chat' | boolean - /** Force message id */ - messageId?: string -} -export interface WABroadcastListInfo { - status: number - name: string - recipients?: {id: string}[] -} -export interface WAUrlInfo { - 'canonical-url': string - 'matched-text': string - title: string - description: string - jpegThumbnail?: Buffer -} -export interface WAProfilePictureChange { - status: number - tag: string - eurl: string -} -export interface MessageInfo { - reads: {jid: string, t: string}[] - deliveries: {jid: string, t: string}[] -} -export interface WAMessageStatusUpdate { - from: string - to: string - /** Which participant caused the update (only for groups) */ - participant?: string - timestamp: Date - /** Message IDs read/delivered */ - ids: string[] - /** Status of the Message IDs */ - type: WA_MESSAGE_STATUS_TYPE -} - -export interface WAOpenResult { - /** Was this connection opened via a QR scan */ - newConnection?: true - user: WAUser - isNewUser?: true - auth: AuthenticationCredentials -} - -export enum GroupSettingChange { - messageSend = 'announcement', - settingsChange = 'locked', -} -export interface PresenceUpdate { - id: string - participant?: string - t?: string - type?: Presence - deny?: boolean -} -export interface BlocklistUpdate { - added?: string[] - removed?: string[] -} -// path to upload the media -export const MediaPathMap = { - imageMessage: '/mms/image', - videoMessage: '/mms/video', - documentMessage: '/mms/document', - audioMessage: '/mms/audio', - stickerMessage: '/mms/image', -} -// gives WhatsApp info to process the media -export const MimetypeMap = { - imageMessage: Mimetype.jpeg, - videoMessage: Mimetype.mp4, - documentMessage: Mimetype.pdf, - audioMessage: Mimetype.ogg, - stickerMessage: Mimetype.webp, -} -export type WAParticipantAction = 'add' | 'remove' | 'promote' | 'demote' -export type BaileysEvent = - 'open' | - 'connecting' | - 'close' | - 'ws-close' | - 'qr' | - 'connection-phone-change' | - 'contacts-received' | - 'chats-received' | - 'initial-data-received' | - 'chat-new' | - 'chat-update' | - 'group-participants-update' | - 'group-update' | - 'received-pong' | - 'blocklist-update' | - 'contact-update' diff --git a/src/WAConnection/Mutex.ts b/src/WAConnection/Mutex.ts deleted file mode 100644 index 68c44a8..0000000 --- a/src/WAConnection/Mutex.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * A simple mutex that can be used as a decorator. For examples, see Tests.Mutex.ts - * @param keyGetter if you want to lock functions based on certain arguments, specify the key for the function based on the arguments - */ -export function Mutex (keyGetter?: (...args: any[]) => string) { - let tasks: { [k: string]: Promise } = {} - return function (_, __, descriptor: PropertyDescriptor) { - const originalMethod = descriptor.value - descriptor.value = function (this: Object, ...args) { - const key = (keyGetter && keyGetter.call(this, ...args)) || 'undefined' - - tasks[key] = (async () => { - try { - tasks[key] && await tasks[key] - } catch { - - } - const result = await originalMethod.call(this, ...args) - return result - })() - return tasks[key] - } - } -} \ No newline at end of file diff --git a/src/WAConnection/Utils.ts b/src/WAConnection/Utils.ts deleted file mode 100644 index cd7bac8..0000000 --- a/src/WAConnection/Utils.ts +++ /dev/null @@ -1,469 +0,0 @@ -import * as Crypto from 'crypto' -import { Readable, Transform } from 'stream' -import HKDF from 'futoin-hkdf' -import Jimp from 'jimp' -import {createReadStream, createWriteStream, promises as fs, WriteStream} from 'fs' -import { exec } from 'child_process' -import {platform, release, tmpdir} from 'os' -import HttpsProxyAgent from 'https-proxy-agent' -import { URL } from 'url' -import { Agent } from 'https' -import Decoder from '../Binary/Decoder' -import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageContent, BaileysError, WAMessageProto, TimedOutError, CancelledError, WAGenericMediaMessage, WAMessage, WAMessageKey, DEFAULT_ORIGIN, WAMediaUpload } from './Constants' -import KeyedDB from '@adiwajshing/keyed-db' -import got, { Options, Response } from 'got' -import { join } from 'path' -import { IAudioMetadata } from 'music-metadata' - -const platformMap = { - 'aix': 'AIX', - 'darwin': 'Mac OS', - 'win32': 'Windows', - 'android': 'Android' -} -export const Browsers = { - ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string], - macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string], - baileys: browser => ['Baileys', browser, '3.0'] as [string, string, string], - /** The appropriate browser based on your OS & release */ - appropriate: browser => [ platformMap [platform()] || 'Ubuntu', browser, release() ] as [string, string, string] -} -export const toNumber = (t: Long | number) => (t['low'] || t) as number -export const waChatKey = (pin: boolean) => ({ - key: (c: WAChat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive === 'true' ? '0' : '1') + c.t.toString(16).padStart(8, '0') + c.jid, - compare: (k1: string, k2: string) => k2.localeCompare (k1) -}) -export const waMessageKey = { - key: (m: WAMessage) => (5000 + (m['epoch'] || 0)).toString(16).padStart(6, '0') + toNumber(m.messageTimestamp).toString(16).padStart(8, '0'), - compare: (k1: string, k2: string) => k1.localeCompare (k2) -} -export const WA_MESSAGE_ID = (m: WAMessage) => GET_MESSAGE_ID (m.key) -export const GET_MESSAGE_ID = (key: WAMessageKey) => `${key.id}|${key.fromMe ? 1 : 0}` - -export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net') -export const isGroupID = (jid: string) => jid?.endsWith ('@g.us') - -export const newMessagesDB = (messages: WAMessage[] = []) => { - const db = new KeyedDB(waMessageKey, WA_MESSAGE_ID) - messages.forEach(m => !db.get(WA_MESSAGE_ID(m)) && db.insert(m)) - return db -} - -export function shallowChanges (old: T, current: T, {lookForDeletedKeys}: {lookForDeletedKeys: boolean}): Partial { - let changes: Partial = {} - for (let key in current) { - if (old[key] !== current[key]) { - changes[key] = current[key] || null - } - } - if (lookForDeletedKeys) { - for (let key in old) { - if (!changes[key] && old[key] !== current[key]) { - changes[key] = current[key] || null - } - } - } - return changes -} - -/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */ -export function aesDecrypt(buffer: Buffer, key: Buffer) { - return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16)) -} -/** decrypt AES 256 CBC */ -export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { - const aes = Crypto.createDecipheriv('aes-256-cbc', key, IV) - return Buffer.concat([aes.update(buffer), aes.final()]) -} -// encrypt AES 256 CBC; where a random IV is prefixed to the buffer -export function aesEncrypt(buffer: Buffer, key: Buffer) { - const IV = randomBytes(16) - const aes = Crypto.createCipheriv('aes-256-cbc', key, IV) - return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer -} -// encrypt AES 256 CBC with a given IV -export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) { - const aes = Crypto.createCipheriv('aes-256-cbc', key, IV) - return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer -} -// sign HMAC using SHA 256 -export function hmacSign(buffer: Buffer, key: Buffer) { - return Crypto.createHmac('sha256', key).update(buffer).digest() -} -export function sha256(buffer: Buffer) { - return Crypto.createHash('sha256').update(buffer).digest() -} -// HKDF key expansion -export function hkdf(buffer: Buffer, expandedLength: number, info = null) { - return HKDF(buffer, expandedLength, { salt: Buffer.alloc(32), info: info, hash: 'SHA-256' }) -} -// generate a buffer with random bytes of the specified length -export function randomBytes(length) { - return Crypto.randomBytes(length) -} -/** unix timestamp of a date in seconds */ -export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000) - -export type DebouncedTimeout = ReturnType -export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => { - let timeout: NodeJS.Timeout - return { - start: (newIntervalMs?: number, newTask?: () => void) => { - task = newTask || task - intervalMs = newIntervalMs || intervalMs - timeout && clearTimeout(timeout) - timeout = setTimeout(task, intervalMs) - }, - cancel: () => { - timeout && clearTimeout(timeout) - timeout = undefined - }, - setTask: (newTask: () => void) => task = newTask, - setInterval: (newInterval: number) => intervalMs = newInterval - } -} - -export const delay = (ms: number) => delayCancellable (ms).delay -export const delayCancellable = (ms: number) => { - const stack = new Error().stack - let timeout: NodeJS.Timeout - let reject: (error) => void - const delay: Promise = new Promise((resolve, _reject) => { - timeout = setTimeout(resolve, ms) - reject = _reject - }) - const cancel = () => { - clearTimeout (timeout) - reject (CancelledError(stack)) - } - return { delay, cancel } -} -export async function promiseTimeout(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) { - if (!ms) return new Promise (promise) - const stack = new Error().stack - // Create a promise that rejects in milliseconds - let {delay, cancel} = delayCancellable (ms) - const p = new Promise ((resolve, reject) => { - delay - .then(() => reject(TimedOutError(stack))) - .catch (err => reject(err)) - - promise (resolve, reject) - }) - .finally (cancel) - return p as Promise -} -// whatsapp requires a message tag for every message, we just use the timestamp as one -export function generateMessageTag(epoch?: number) { - let tag = unixTimestampSeconds().toString() - if (epoch) tag += '.--' + epoch // attach epoch if provided - return tag -} -// generate a random 16 byte client ID -export function generateClientID() { - return randomBytes(16).toString('base64') -} -// generate a random 16 byte ID to attach to a message -export function generateMessageID() { - return '3EB0' + randomBytes(4).toString('hex').toUpperCase() -} -export function decryptWA (message: string | Buffer, macKey: Buffer, encKey: Buffer, decoder: Decoder, fromMe: boolean=false): [string, Object, [number, number]?] { - let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message - if (commaIndex < 0) throw new BaileysError ('invalid message', { message }) // if there was no comma, then this message must be not be valid - - if (message[commaIndex+1] === ',') commaIndex += 1 - let data = message.slice(commaIndex+1, message.length) - - // get the message tag. - // If a query was done, the server will respond with the same message tag we sent the query with - const messageTag: string = message.slice(0, commaIndex).toString() - let json - let tags - if (data.length > 0) { - if (typeof data === 'string') { - json = JSON.parse(data) // parse the JSON - } else { - if (!macKey || !encKey) { - throw new BaileysError ('recieved encrypted buffer when auth creds unavailable', { message }) - } - /* - If the data recieved was not a JSON, then it must be an encrypted message. - Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys - */ - if (fromMe) { - tags = [data[0], data[1]] - data = data.slice(2, data.length) - } - - const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message - data = data.slice(32, data.length) // the actual message - const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey - - if (checksum.equals(computedChecksum)) { - // the checksum the server sent, must match the one we computed for the message to be valid - const decrypted = aesDecrypt(data, encKey) // decrypt using AES - json = decoder.read(decrypted) // decode the binary message into a JSON array - } else { - throw new BaileysError ('checksum failed', { - received: checksum.toString('hex'), - computed: computedChecksum.toString('hex'), - data: data.slice(0, 80).toString(), - tag: messageTag, - message: message.slice(0, 80).toString() - }) - } - } - } - return [messageTag, json, tags] -} -/** generates all the keys required to encrypt/decrypt & sign a media message */ -export function getMediaKeys(buffer, mediaType: MessageType) { - if (typeof buffer === 'string') { - buffer = Buffer.from (buffer.replace('data:;base64,', ''), 'base64') - } - // expand using HKDF to 112 bytes, also pass in the relevant app info - const expandedMediaKey = hkdf(buffer, 112, HKDFInfoKeys[mediaType]) - return { - iv: expandedMediaKey.slice(0, 16), - cipherKey: expandedMediaKey.slice(16, 48), - macKey: expandedMediaKey.slice(48, 80), - } -} -/** Extracts video thumb using FFMPEG */ -const extractVideoThumb = async ( - path: string, - destPath: string, - time: string, - size: { width: number; height: number }, -) => - new Promise((resolve, reject) => { - const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}` - exec(cmd, (err) => { - if (err) reject(err) - else resolve() - }) - }) as Promise - -export const compressImage = async (bufferOrFilePath: Buffer | string) => { - const jimp = await Jimp.read(bufferOrFilePath as any) - const result = await jimp.resize(48, 48).getBufferAsync(Jimp.MIME_JPEG) - return result -} -export const generateProfilePicture = async (buffer: Buffer) => { - const jimp = await Jimp.read (buffer) - const min = Math.min(jimp.getWidth (), jimp.getHeight ()) - const cropped = jimp.crop (0, 0, min, min) - return { - img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG), - preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG) - } -} -export const ProxyAgent = (host: string | URL) => HttpsProxyAgent(host) as any as Agent -/** gets the SHA256 of the given media message */ -export const mediaMessageSHA256B64 = (message: WAMessageContent) => { - const media = Object.values(message)[0] as WAGenericMediaMessage - return media?.fileSha256 && Buffer.from(media.fileSha256).toString ('base64') -} -export async function getAudioDuration (buffer: Buffer | string) { - const musicMetadata = await import ('music-metadata') - let metadata: IAudioMetadata - if(Buffer.isBuffer(buffer)) { - metadata = await musicMetadata.parseBuffer(buffer, null, { duration: true }) - } else { - const rStream = createReadStream(buffer) - metadata = await musicMetadata.parseStream(rStream, null, { duration: true }) - rStream.close() - } - return metadata.format.duration; -} -export const toReadable = (buffer: Buffer) => { - const readable = new Readable({ read: () => {} }) - readable.push(buffer) - readable.push(null) - return readable -} -export const getStream = async (item: WAMediaUpload) => { - if(Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' } - if(item.url.toString().startsWith('http://') || item.url.toString().startsWith('https://')) { - return { stream: await getGotStream(item.url), type: 'remote' } - } - return { stream: createReadStream(item.url), type: 'file' } -} -/** generates a thumbnail for a given media, if required */ -export async function generateThumbnail(file: string, mediaType: MessageType, info: MessageOptions) { - if ('thumbnail' in info) { - // don't do anything if the thumbnail is already provided, or is null - if (mediaType === MessageType.audio) { - throw new Error('audio messages cannot have thumbnails') - } - } else if (mediaType === MessageType.image) { - const buff = await compressImage(file) - info.thumbnail = buff.toString('base64') - } else if (mediaType === MessageType.video) { - const imgFilename = join(tmpdir(), generateMessageID() + '.jpg') - try { - await extractVideoThumb(file, imgFilename, '00:00:00', { width: 48, height: 48 }) - const buff = await fs.readFile(imgFilename) - info.thumbnail = buff.toString('base64') - await fs.unlink(imgFilename) - } catch (err) { - console.log('could not generate video thumb: ' + err) - } - } -} -export const getGotStream = async(url: string | URL, options: Options & { isStream?: true } = {}) => { - const fetched = got.stream(url, { ...options, isStream: true }) - await new Promise((resolve, reject) => { - fetched.once('error', reject) - fetched.once('response', ({statusCode: status}: Response) => { - if (status >= 400) { - reject(new BaileysError ( - 'Invalid code (' + status + ') returned', - { status } - )) - } else { - resolve(undefined) - } - }) - }) - return fetched -} -export const encryptedStream = async(media: WAMediaUpload, mediaType: MessageType, saveOriginalFileIfRequired = true) => { - const { stream, type } = await getStream(media) - - const mediaKey = randomBytes(32) - const {cipherKey, iv, macKey} = getMediaKeys(mediaKey, mediaType) - // random name - const encBodyPath = join(tmpdir(), mediaType + generateMessageID() + '.enc') - const encWriteStream = createWriteStream(encBodyPath) - let bodyPath: string - let writeStream: WriteStream - if(type === 'file') { - bodyPath = (media as any).url - } else if(saveOriginalFileIfRequired) { - bodyPath = join(tmpdir(), mediaType + generateMessageID()) - writeStream = createWriteStream(bodyPath) - } - - let fileLength = 0 - const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv) - let hmac = Crypto.createHmac('sha256', macKey).update(iv) - let sha256Plain = Crypto.createHash('sha256') - let sha256Enc = Crypto.createHash('sha256') - - const onChunk = (buff: Buffer) => { - sha256Enc = sha256Enc.update(buff) - hmac = hmac.update(buff) - encWriteStream.write(buff) - } - for await(const data of stream) { - fileLength += data.length - sha256Plain = sha256Plain.update(data) - writeStream && writeStream.write(data) - onChunk(aes.update(data)) - } - onChunk(aes.final()) - - const mac = hmac.digest().slice(0, 10) - sha256Enc = sha256Enc.update(mac) - - const fileSha256 = sha256Plain.digest() - const fileEncSha256 = sha256Enc.digest() - - encWriteStream.write(mac) - encWriteStream.close() - - writeStream && writeStream.close() - - return { - mediaKey, - encBodyPath, - bodyPath, - mac, - fileEncSha256, - fileSha256, - fileLength, - didSaveToTmpPath: type !== 'file' - } -} -/** - * Decode a media message (video, image, document, audio) & return decrypted buffer - * @param message the media message you want to decode - */ -export async function decryptMediaMessageBuffer(message: WAMessageContent): Promise { - /* - One can infer media type from the key in the message - it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc. - */ - const type = Object.keys(message)[0] as MessageType - if (!type) { - throw new BaileysError('unknown message type', message) - } - if (type === MessageType.text || type === MessageType.extendedText) { - throw new BaileysError('cannot decode text message', message) - } - if (type === MessageType.location || type === MessageType.liveLocation) { - const buffer = Buffer.from(message[type].jpegThumbnail) - const readable = new Readable({ read: () => {} }) - readable.push(buffer) - readable.push(null) - return readable - } - let messageContent: WAGenericMediaMessage - if (message.productMessage) { - const product = message.productMessage.product?.productImage - if (!product) throw new BaileysError ('product has no image', message) - messageContent = product - } else { - messageContent = message[type] - } - // download the message - const fetched = await getGotStream(messageContent.url, { - headers: { Origin: DEFAULT_ORIGIN } - }) - let remainingBytes = Buffer.from([]) - const { cipherKey, iv } = getMediaKeys(messageContent.mediaKey, type) - const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv) - - const output = new Transform({ - transform(chunk, _, callback) { - let data = Buffer.concat([remainingBytes, chunk]) - const decryptLength = - Math.floor(data.length / 16) * 16 - remainingBytes = data.slice(decryptLength) - data = data.slice(0, decryptLength) - - try { - this.push(aes.update(data)) - callback() - } catch(error) { - callback(error) - } - }, - final(callback) { - try { - this.push(aes.final()) - callback() - } catch(error) { - callback(error) - } - }, - }) - return fetched.pipe(output, { end: true }) -} -export function extensionForMediaMessage(message: WAMessageContent) { - const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1] - const type = Object.keys(message)[0] as MessageType - let extension: string - if (type === MessageType.location || type === MessageType.liveLocation || type === MessageType.product) { - extension = '.jpeg' - } else { - const messageContent = message[type] as - | WAMessageProto.VideoMessage - | WAMessageProto.ImageMessage - | WAMessageProto.AudioMessage - | WAMessageProto.DocumentMessage - extension = getExtension (messageContent.mimetype) - } - return extension -} \ No newline at end of file diff --git a/src/WAConnection/index.ts b/src/WAConnection/index.ts deleted file mode 100644 index f0cb3ad..0000000 --- a/src/WAConnection/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './8.Groups' -export * from './Utils' -export * from './Constants' -export * from './Mutex' \ No newline at end of file