mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
remove all files
This commit is contained in:
18
.eslintrc.js
18
.eslintrc.js
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
semi: false,
|
|
||||||
trailingComma: "all",
|
|
||||||
singleQuote: true,
|
|
||||||
printWidth: 120,
|
|
||||||
tabWidth: 4
|
|
||||||
}
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
204
src/BinaryNode/decode.ts
Normal 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
124
src/BinaryNode/encode.ts
Normal 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
8
src/BinaryNode/index.ts
Normal 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
212
src/BinaryNode/types.ts
Normal 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
260
src/Connection/auth.ts
Normal 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
6
src/Connection/chats.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { SocketConfig } from "../Types";
|
||||||
|
|
||||||
|
const makeChatsSocket = (config: SocketConfig) => {
|
||||||
|
|
||||||
|
}
|
||||||
|
export default makeChatsSocket
|
||||||
362
src/Connection/socket.ts
Normal file
362
src/Connection/socket.ts
Normal 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
29
src/Defaults/index.ts
Normal 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,
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ export async function sendAndRetrieveMessage(conn: WAConnection, content, type:
|
|||||||
const chat = conn.chats.get(recipientJid)
|
const chat = conn.chats.get(recipientJid)
|
||||||
|
|
||||||
assert.ok (chat.messages.get(GET_MESSAGE_ID(message.key)))
|
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
|
return message
|
||||||
}
|
}
|
||||||
export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => (
|
export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => (
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ WAConnectionTest('Messages', conn => {
|
|||||||
assert.ok (message.message.audioMessage.seconds > 0)
|
assert.ok (message.message.audioMessage.seconds > 0)
|
||||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
|
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 content = await fs.readFile('./Media/sonata.mp3')
|
||||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio, ptt: true })
|
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio, ptt: true })
|
||||||
|
|
||||||
|
|||||||
95
src/Tests/test.binary.ts
Normal file
95
src/Tests/test.binary.ts
Normal 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
200
src/Tests/test.connect.ts
Normal 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
165
src/Tests/test.queries.ts
Normal 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
22
src/Types/Auth.ts
Normal 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
44
src/Types/Chat.ts
Normal 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
15
src/Types/Contact.ts
Normal 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
|
||||||
|
}
|
||||||
19
src/Types/GroupMetadata.ts
Normal file
19
src/Types/GroupMetadata.ts
Normal 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
25
src/Types/Store.ts
Normal 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
147
src/Types/index.ts
Normal 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
|
||||||
|
}
|
||||||
63
src/Utils/decodeWAMessage.ts
Normal file
63
src/Utils/decodeWAMessage.ts
Normal 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
148
src/Utils/generics.ts
Normal 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()
|
||||||
106
src/Utils/validateConnection.ts
Normal file
106
src/Utils/validateConnection.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './8.Groups'
|
|
||||||
export * from './Utils'
|
|
||||||
export * from './Constants'
|
|
||||||
export * from './Mutex'
|
|
||||||
Reference in New Issue
Block a user