remove all files

This commit is contained in:
Adhiraj Singh
2021-07-07 23:20:11 +05:30
parent d4189c3d00
commit 5be4a9cc2c
41 changed files with 2256 additions and 4591 deletions

View File

@@ -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"
}
}

View File

@@ -1,7 +0,0 @@
module.exports = {
semi: false,
trailingComma: "all",
singleQuote: true,
printWidth: 120,
tabWidth: 4
}

View File

@@ -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<Node> | any | null
export type Node = [string, NodeAttributes, NodeData]
}

View File

@@ -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()
}
}

View File

@@ -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, string> | 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<WA.Node> | 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)
}
}

204
src/BinaryNode/decode.ts Normal file
View File

@@ -0,0 +1,204 @@
import { proto } from '../../WAMessage/WAMessage'
import { BinaryNode, DoubleByteTokens, SingleByteTokens, Tags } from './types'
function decode<T extends BinaryNode>(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

124
src/BinaryNode/encode.ts Normal file
View File

@@ -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

8
src/BinaryNode/index.ts Normal file
View File

@@ -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 })
}

212
src/BinaryNode/types.ts Normal file
View File

@@ -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',
]

260
src/Connection/auth.ts Normal file
View File

@@ -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<ConnectionState>) => {
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

6
src/Connection/chats.ts Normal file
View File

@@ -0,0 +1,6 @@
import { SocketConfig } from "../Types";
const makeChatsSocket = (config: SocketConfig) => {
}
export default makeChatsSocket

362
src/Connection/socket.ts Normal file
View File

@@ -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<void>
/**
* 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<typeof makeSocket>

29
src/Defaults/index.ts Normal file
View File

@@ -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,
}

View File

@@ -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) => (

View File

@@ -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 })

95
src/Tests/test.binary.ts Normal file
View File

@@ -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
)
}
})
})

200
src/Tests/test.connect.ts Normal file
View File

@@ -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)
})

165
src/Tests/test.queries.ts Normal file
View File

@@ -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<WAChat>[]
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<WAContact>[] }
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<any> = 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<any> = 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)
})

22
src/Types/Auth.ts Normal file
View File

@@ -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

44
src/Types/Chat.ts Normal file
View File

@@ -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<proto.IWebMessageInfo, string>
imgUrl?: string
presences?: { [k: string]: PresenceData }
metadata?: GroupMetadata
}

15
src/Types/Contact.ts Normal file
View File

@@ -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
}

View File

@@ -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[]
}

25
src/Types/Store.ts Normal file
View File

@@ -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<Chat, string>
contacts: { [jid: string]: Contact }
}

147
src/Types/index.ts Normal file
View File

@@ -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<ConnectionState>
}
export interface BaileysEventEmitter extends EventEmitter {
on<T extends keyof BaileysEventMap>(event: T, listener: (arg: BaileysEventMap[T]) => void): this
emit<T extends keyof BaileysEventMap>(event: T, arg: BaileysEventMap[T]): boolean
}

View File

@@ -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
}

148
src/Utils/generics.ts Normal file
View File

@@ -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 <T> (old: T, current: T, {lookForDeletedKeys}: {lookForDeletedKeys: boolean}): Partial<T> {
let changes: Partial<T> = {}
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<typeof debouncedTimeout>
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<void> = 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<T>(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 <ms> 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<T>
}
// 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()

View File

@@ -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
}

View File

@@ -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<any> {
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
}
}

View File

@@ -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)
}
}

View File

@@ -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<WAOpenResult>
)
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)
}
}

View File

@@ -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<WAMessage, string> } = {}
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<WAGroupMetadata>) => 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<WAChat> = { 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<WAChat>
}
}
/** inserts an empty chat into the DB */
protected chatAdd (jid: string, name?: string, properties: Partial<WAChat> = {}) {
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<WAGroupMetadata>) => 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<WAGroupMetadata>) => {
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<WAContact>[] }) => 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<WAGroupMetadata> & {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) }
}

View File

@@ -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<WABroadcastListInfo>
}
/**
* 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<WAProfilePictureChange>)
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)
}
}
}

View File

@@ -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<Buffer>
async downloadMediaMessage (message: WAMessage, type: 'buffer'): Promise<Buffer>
async downloadMediaMessage (message: WAMessage, type: 'stream'): Promise<Readable>
/**
* 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
}
}

View File

@@ -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>|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<void>
}
/**
* 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<WAChat> = { 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<string, string> = {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<WAChatIndex> {
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
}
}

View File

@@ -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<WAGroupModification>
/**
* 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<WAGroupModification>
/**
* 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<WAGroupModification>
/**
* 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<WAGroupModification>
/**
* 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
}
}

View File

@@ -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<number, string> = {}
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<WAContact> & { 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<WAMessage, string>
imgUrl?: string
presences?: { [k: string]: WAPresenceData }
metadata?: WAGroupMetadata
}
export type WAChatIndex = { index: string, owner: 'true' | 'false', participant?: string }
export type WAChatUpdate = Partial<WAChat> & { 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'

View File

@@ -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<void> } = {}
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]
}
}
}

View File

@@ -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 <T> (old: T, current: T, {lookForDeletedKeys}: {lookForDeletedKeys: boolean}): Partial<T> {
let changes: Partial<T> = {}
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<typeof debouncedTimeout>
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<void> = new Promise((resolve, _reject) => {
timeout = setTimeout(resolve, ms)
reject = _reject
})
const cancel = () => {
clearTimeout (timeout)
reject (CancelledError(stack))
}
return { delay, cancel }
}
export async function promiseTimeout<T>(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 <ms> 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<T>
}
// 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<void>
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<Readable> {
/*
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
}

View File

@@ -1,4 +0,0 @@
export * from './8.Groups'
export * from './Utils'
export * from './Constants'
export * from './Mutex'