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)
|
||||
|
||||
assert.ok (chat.messages.get(GET_MESSAGE_ID(message.key)))
|
||||
assert.ok (chat.t >= (unixTimestampSeconds()-5), `expected: ${chat.t} > ${(unixTimestampSeconds()-5)}`)
|
||||
assert.ok (chat.t >= (unixTimestampSeconds()-5) )
|
||||
return message
|
||||
}
|
||||
export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => (
|
||||
|
||||
@@ -72,7 +72,7 @@ WAConnectionTest('Messages', conn => {
|
||||
assert.ok (message.message.audioMessage.seconds > 0)
|
||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
|
||||
})
|
||||
it('should send a voice note', async () => {
|
||||
it('should send an audio as a voice note', async () => {
|
||||
const content = await fs.readFile('./Media/sonata.mp3')
|
||||
const message = await sendAndRetrieveMessage(conn, content, MessageType.audio, { mimetype: Mimetype.mp4Audio, ptt: true })
|
||||
|
||||
|
||||
95
src/Tests/test.binary.ts
Normal file
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