mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Initial Conversion to typescript
This commit is contained in:
18
.eslintrc.js
Normal file
18
.eslintrc.js
Normal file
@@ -0,0 +1,18 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ test_pvt.js
|
||||
media_decode_tests.js
|
||||
output.csv
|
||||
package-lock.json
|
||||
*/.DS_Store
|
||||
.DS_Store
|
||||
|
||||
7
.prettierrc.js
Normal file
7
.prettierrc.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
semi: false,
|
||||
trailingComma: "all",
|
||||
singleQuote: true,
|
||||
printWidth: 120,
|
||||
tabWidth: 4
|
||||
}
|
||||
205
Binary/Constants.ts
Normal file
205
Binary/Constants.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { proto as Coding } from './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 = Coding.WebMessageInfo
|
||||
export type NodeAttributes = Record<string, string> | string | null
|
||||
export type NodeData = Array<Node> | any | null
|
||||
export type Node = [string, NodeAttributes, NodeData]
|
||||
}
|
||||
227
Binary/Decoder.ts
Normal file
227
Binary/Decoder.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { WA } from './Constants'
|
||||
|
||||
export default class Decoder {
|
||||
buffer: Buffer = null
|
||||
index = 0
|
||||
|
||||
checkEOS(length: number) {
|
||||
if (this.index + length > this.buffer.length) {
|
||||
throw '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 new TextDecoder().decode(value)
|
||||
}
|
||||
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 '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 '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 '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 '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 '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 (i && j) {
|
||||
return i + '@' + j
|
||||
}
|
||||
throw 'invalid jid pair: ' + i + ', ' + j
|
||||
case WA.Tags.HEX_8:
|
||||
case WA.Tags.NIBBLE_8:
|
||||
return this.readPacked8(tag)
|
||||
default:
|
||||
throw '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 '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 '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 'unexpected stream end'
|
||||
}
|
||||
|
||||
const descr = this.readString(descrTag)
|
||||
if (listSize === 0 || !descr) {
|
||||
throw '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()
|
||||
}
|
||||
}
|
||||
146
Binary/Encoder.ts
Normal file
146
Binary/Encoder.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { WA } from './Constants'
|
||||
import { proto } from './WAMessage'
|
||||
|
||||
export default class Encoder {
|
||||
data: Array<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 | Array<number>) {
|
||||
this.data.push(...bytes)
|
||||
}
|
||||
pushString(str: string) {
|
||||
const bytes = new TextEncoder().encode(str)
|
||||
this.pushBytes(bytes)
|
||||
}
|
||||
writeByteLength(length: number) {
|
||||
if (length >= 4294967296) {
|
||||
throw '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) {
|
||||
this.writeByteLength(string.length)
|
||||
this.pushString(string)
|
||||
}
|
||||
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 '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 '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> | Object) {
|
||||
if (!children) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof children === 'string') {
|
||||
this.writeString(children, true)
|
||||
} else if (Array.isArray(children)) {
|
||||
this.writeListStart(children.length)
|
||||
children.forEach((c) => {
|
||||
if (c) this.writeNode(c)
|
||||
})
|
||||
} else if (typeof children === 'object') {
|
||||
const buffer = WA.Message.encode(children as proto.WebMessageInfo).finish()
|
||||
this.writeByteLength(buffer.length)
|
||||
this.pushBytes(buffer)
|
||||
} else {
|
||||
throw '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 '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)
|
||||
}
|
||||
}
|
||||
81
Binary/Tests.ts
Normal file
81
Binary/Tests.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { strict as assert } from 'assert'
|
||||
import Encoder from './Encoder'
|
||||
import Decoder from './Decoder'
|
||||
|
||||
describe('Binary Coding Tests', () => {
|
||||
const testVectors: [[string, Object]] = [
|
||||
[
|
||||
'f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305',
|
||||
[
|
||||
'action',
|
||||
{ last: 'true', add: 'before' },
|
||||
[
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
key: {
|
||||
remoteJid: '917529871117@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
id: '0FCEC5330F64929C6E9A2CFFD2BC8EAD',
|
||||
},
|
||||
messageTimestamp: '1584004413',
|
||||
messageStubType: 'REVOKE',
|
||||
},
|
||||
],
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
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',
|
||||
},
|
||||
],
|
||||
[
|
||||
'message',
|
||||
null,
|
||||
{
|
||||
key: {
|
||||
remoteJid: '917529871117@s.whatsapp.net',
|
||||
fromMe: false,
|
||||
id: 'A1230B8D6B0A1D793EC2AE2EA0C168D8',
|
||||
},
|
||||
message: { conversation: 'library' },
|
||||
messageTimestamp: '1584004418',
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
const encoder = new Encoder()
|
||||
const decoder = new Decoder()
|
||||
|
||||
it('should decode strings', () => {
|
||||
testVectors.forEach((pair) => {
|
||||
const buff = Buffer.from(pair[0], 'hex')
|
||||
const decoded = decoder.read(buff)
|
||||
|
||||
assert.deepEqual(JSON.stringify(decoded), JSON.stringify(pair[1]))
|
||||
|
||||
const encoded = encoder.write(decoded)
|
||||
assert.deepEqual(encoded, buff)
|
||||
})
|
||||
console.log('all coding tests passed')
|
||||
})
|
||||
})
|
||||
6893
Binary/WAMessage.d.ts
vendored
Normal file
6893
Binary/WAMessage.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
19977
Binary/WAMessage.js
Normal file
19977
Binary/WAMessage.js
Normal file
File diff suppressed because it is too large
Load Diff
144
Example/example.ts
Normal file
144
Example/example.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
WAClient,
|
||||
AuthenticationCredentialsBase64,
|
||||
getNotificationType,
|
||||
MessageType,
|
||||
decodeMediaMessage,
|
||||
Presence,
|
||||
MessageOptions,
|
||||
Mimetype,
|
||||
} from '../WAClient/WAClient'
|
||||
import fs from 'fs'
|
||||
|
||||
async function example() {
|
||||
const client = new WAClient() // instantiate
|
||||
client.autoReconnect = true // auto reconnect on disconnect
|
||||
client.logUnhandledMessages = false // set to true to see what kind of stuff you can implement
|
||||
|
||||
let authInfo: AuthenticationCredentialsBase64 = null
|
||||
try {
|
||||
const file = fs.readFileSync('auth_info.json') // load a closed session back if it exists
|
||||
authInfo = JSON.parse(file)
|
||||
} catch {}
|
||||
|
||||
// connect or timeout in 20 seconds
|
||||
const [user, chats, contacts, unread] = await client.connect(authInfo, 20 * 1000)
|
||||
|
||||
console.log('oh hello ' + user.name + ' (' + user.id + ')')
|
||||
console.log('you have ' + unread.length + ' unread messages')
|
||||
console.log('you have ' + chats.length + ' chats & ' + contacts.length + ' contacts')
|
||||
|
||||
authInfo = client.base64EncodedAuthInfo() // get all the auth info we need to restore this session
|
||||
fs.writeFileSync('./auth_info.json', JSON.stringify(authInfo, null, '\t')) // save this info to a file
|
||||
/* Note: one can take this auth_info.json file and login again from any computer without having to scan the QR code,
|
||||
and get full access to one's WhatsApp. Despite the convenience, be careful with this file */
|
||||
|
||||
client.setOnPresenceUpdate((json) => console.log(json.id + ' presence is ' + json.type))
|
||||
client.setOnMessageStatusChange((json) => {
|
||||
const participant = json.participant ? ' (' + json.participant + ')' : '' // participant exists when the message is from a group
|
||||
console.log(
|
||||
json.to +
|
||||
participant +
|
||||
' acknowledged message(s) ' +
|
||||
json.ids +
|
||||
' as ' +
|
||||
json.type +
|
||||
' at ' +
|
||||
json.timestamp,
|
||||
)
|
||||
})
|
||||
client.setOnUnreadMessage(async (m) => {
|
||||
const [notificationType, messageType] = getNotificationType(m) // get what type of notification it is -- message, group add notification etc.
|
||||
console.log('got notification of type: ' + notificationType)
|
||||
|
||||
if (notificationType !== 'message') {
|
||||
return
|
||||
}
|
||||
if (m.key.fromMe) {
|
||||
console.log('relayed my own message')
|
||||
return
|
||||
}
|
||||
|
||||
let sender = m.key.remoteJid
|
||||
if (m.key.participant) {
|
||||
// participant exists if the message is in a group
|
||||
sender += ' (' + m.key.participant + ')'
|
||||
}
|
||||
if (messageType === MessageType.text) {
|
||||
const text = m.message.conversation
|
||||
console.log(sender + ' sent: ' + text)
|
||||
} else if (messageType === MessageType.extendedText) {
|
||||
const text = m.message.extendedTextMessage.text
|
||||
console.log(sender + ' sent: ' + text + ' and quoted message: ' + JSON.stringify(m.message))
|
||||
} else if (messageType === MessageType.contact) {
|
||||
const contact = m.message.contactMessage
|
||||
console.log(sender + ' sent contact (' + contact.displayName + '): ' + contact.vcard)
|
||||
} else if (messageType === MessageType.location || messageType === MessageType.liveLocation) {
|
||||
const locMessage = m.message[messageType]
|
||||
console.log(
|
||||
sender +
|
||||
' sent location (lat: ' +
|
||||
locMessage.degreesLatitude +
|
||||
', long: ' +
|
||||
locMessage.degreesLongitude +
|
||||
'), saving thumbnail...',
|
||||
)
|
||||
decodeMediaMessage(m.message, 'loc_thumb_in_' + m.key.id)
|
||||
|
||||
if (messageType === MessageType.liveLocation) {
|
||||
console.log(
|
||||
sender +
|
||||
' sent live location for duration: ' +
|
||||
m.duration / 60 +
|
||||
' minutes, seq number: ' +
|
||||
locMessage.sequenceNumber,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// if it is a media (audio, image, video) message
|
||||
// decode, decrypt & save the media.
|
||||
// The extension to the is applied automatically based on the media type
|
||||
try {
|
||||
const savedFile = await decodeMediaMessage(m.message, 'media_in_' + m.key.id)
|
||||
console.log(sender + ' sent media, saved at: ' + savedFile)
|
||||
} catch (err) {
|
||||
console.log('error in decoding message: ' + err)
|
||||
}
|
||||
}
|
||||
// send a reply after 3 seconds
|
||||
setTimeout(async () => {
|
||||
await client.sendReadReceipt(m.key.remoteJid, m.key.id) // send read receipt
|
||||
await client.updatePresence(m.key.remoteJid, Presence.available) // tell them we're available
|
||||
await client.updatePresence(m.key.remoteJid, Presence.composing) // tell them we're composing
|
||||
|
||||
const options: MessageOptions = { quoted: m }
|
||||
let content
|
||||
let type: MessageType
|
||||
const rand = Math.random()
|
||||
if (rand > 0.66) {
|
||||
// choose at random
|
||||
content = 'hello!' // send a "hello!" & quote the message recieved
|
||||
type = MessageType.text
|
||||
} else if (rand > 0.33) {
|
||||
// choose at random
|
||||
content = { degreesLatitude: 32.123123, degreesLongitude: 12.12123123 }
|
||||
type = MessageType.location
|
||||
} else {
|
||||
content = fs.readFileSync('example/ma_gif.mp4') // load the gif
|
||||
options.mimetype = Mimetype.gif
|
||||
}
|
||||
const response = await client.sendMessage(m.key.remoteJid, content, type, options)
|
||||
console.log("sent message with ID '" + response.messageID + "' successfully: " + (response.status === 200))
|
||||
}, 3 * 1000)
|
||||
}, true) // set to false to not relay your own sent messages
|
||||
|
||||
/* example of custom functionality for tracking battery */
|
||||
client.registerCallback(['action', null, 'battery'], (json) => {
|
||||
const batteryLevelStr = json[2][0][1].value
|
||||
const batterylevel = parseInt(batteryLevelStr)
|
||||
console.log('battery level: ' + batterylevel)
|
||||
})
|
||||
client.setOnUnexpectedDisconnect((err) => console.log('disconnected unexpectedly: ' + err))
|
||||
}
|
||||
|
||||
example().catch((err) => console.log(`encountered error: ${err}`))
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
BIN
Media/received_img.jpeg
Normal file
BIN
Media/received_img.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
229
WAClient/Base.ts
Normal file
229
WAClient/Base.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import WAConnection from '../WAConnection/WAConnection'
|
||||
import { MessageStatus, MessageStatusUpdate, PresenceUpdate } from './Constants'
|
||||
import {
|
||||
WAMessage,
|
||||
WANode,
|
||||
WAMetric,
|
||||
WAFlag,
|
||||
WAGroupCreateResponse,
|
||||
WAGroupMetadata,
|
||||
WAGroupModification,
|
||||
} from '../WAConnection/Constants'
|
||||
import { generateMessageTag } from '../WAConnection/Utils'
|
||||
|
||||
export default class WhatsAppWebBase extends WAConnection {
|
||||
/** Set the callback for unexpected disconnects */
|
||||
setOnUnexpectedDisconnect(callback: (error: Error) => void) {
|
||||
this.unexpectedDisconnect = (err) => {
|
||||
this.close()
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
/** Set the callback for message status updates (when a message is delivered, read etc.) */
|
||||
setOnMessageStatusChange(callback: (update: MessageStatusUpdate) => void) {
|
||||
const func = (json) => {
|
||||
json = json[1]
|
||||
let ids = json.id
|
||||
if (json.cmd === 'ack') {
|
||||
ids = [json.id]
|
||||
}
|
||||
const ackTypes = [MessageStatus.sent, MessageStatus.received, MessageStatus.read]
|
||||
const data: MessageStatusUpdate = {
|
||||
from: json.from,
|
||||
to: json.to,
|
||||
participant: json.participant,
|
||||
timestamp: new Date(json.t * 1000),
|
||||
ids: ids,
|
||||
type: ackTypes[json.ack - 1] || 'unknown (' + json.ack + ')',
|
||||
}
|
||||
callback(data)
|
||||
}
|
||||
this.registerCallback('Msg', func)
|
||||
this.registerCallback('MsgInfo', func)
|
||||
}
|
||||
/**
|
||||
* Set the callback for new/unread messages; if someone sends you a message, this callback will be fired
|
||||
* @param callbackOnMyMessages - should the callback be fired on a message you sent from the phone
|
||||
*/
|
||||
setOnUnreadMessage(callbackOnMyMessages = false, callback: (m: WAMessage) => void) {
|
||||
this.registerCallback(['action', 'add:relay', 'message'], (json) => {
|
||||
const message = json[2][0][2]
|
||||
if (!message.key.fromMe || callbackOnMyMessages) {
|
||||
// if this message was sent to us, notify
|
||||
callback(message as WAMessage)
|
||||
} else if (this.logUnhandledMessages) {
|
||||
this.log(`[Unhandled] message - ${JSON.stringify(message)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
/** Set the callback for presence updates; if someone goes offline/online, this callback will be fired */
|
||||
setOnPresenceUpdate(callback: (p: PresenceUpdate) => void) {
|
||||
this.registerCallback('Presence', (json) => callback(json[1]))
|
||||
}
|
||||
/** Query whether a given number is registered on WhatsApp */
|
||||
isOnWhatsApp = (jid: string) => this.query(['query', 'exist', jid]).then((m) => m.status === 200)
|
||||
/** Request an update on the presence of a user */
|
||||
requestPresenceUpdate = (jid: string) => this.queryExpecting200(['action', 'presence', 'subscribe', jid])
|
||||
/** Query the status of the person (see groupMetadata() for groups) */
|
||||
getStatus = (jid: string | null) =>
|
||||
this.query(['query', 'Status', jid || this.userMetaData.id]) as Promise<{ status: string }>
|
||||
/** Get the URL to download the profile picture of a person/group */
|
||||
async getProfilePicture(jid: string | null) {
|
||||
const response = await this.queryExpecting200(['query', 'ProfilePicThumb', jid || this.userMetaData.id])
|
||||
return response.eurl as string
|
||||
}
|
||||
/** Get your contacts */
|
||||
async getContacts() {
|
||||
const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null]
|
||||
const response = await this.query(json, [WAMetric.group, WAFlag.ignore]) // this has to be an encrypted query
|
||||
console.log(response)
|
||||
return response
|
||||
}
|
||||
/** Fetch your chats */
|
||||
getChats() {
|
||||
const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null]
|
||||
return this.query(json, [WAMetric.group, WAFlag.ignore]) // this has to be an encrypted query
|
||||
}
|
||||
/**
|
||||
* Check if your phone is connected
|
||||
* @param timeoutMs max time for the phone to respond
|
||||
*/
|
||||
async isPhoneConnected(timeoutMs = 5000) {
|
||||
try {
|
||||
const response = await this.query(['admin', 'test'], null, timeoutMs)
|
||||
return response[1] as boolean
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Load the conversation with a group or person
|
||||
* @param count the number of messages to load
|
||||
* @param [indexMessage] the data for which message to offset the query by
|
||||
* @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start
|
||||
*/
|
||||
async loadConversation(
|
||||
jid: string,
|
||||
count: number,
|
||||
indexMessage: { id: string; fromMe: boolean } = null,
|
||||
mostRecentFirst = 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, [WAMetric.group, WAFlag.ignore])
|
||||
|
||||
if (response.status) throw new Error(`error in query, got status: ${response.status}`)
|
||||
|
||||
return response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : []
|
||||
}
|
||||
/**
|
||||
* Load the entire friggin conversation with a group or person
|
||||
* @param onMessage callback for every message retreived
|
||||
* @param [chunkSize] the number of messages to load in a single request
|
||||
* @param [mostRecentFirst] retreive the most recent message first or retreive from the converation start
|
||||
*/
|
||||
loadEntireConversation(jid: string, onMessage: (m: WAMessage) => void, chunkSize = 25, mostRecentFirst = true) {
|
||||
let offsetID = null
|
||||
|
||||
const loadMessage = async () => {
|
||||
const json = await this.loadConversation(jid, chunkSize, offsetID, mostRecentFirst)
|
||||
// callback with most recent message first (descending order of date)
|
||||
let lastMessage
|
||||
if (mostRecentFirst) {
|
||||
for (let i = json.length - 1; i >= 0; i--) {
|
||||
onMessage(json[i])
|
||||
lastMessage = json[i]
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
onMessage(json[i])
|
||||
lastMessage = json[i]
|
||||
}
|
||||
}
|
||||
// if there are still more messages
|
||||
if (json.length >= chunkSize) {
|
||||
offsetID = lastMessage.key // get the last message
|
||||
return new Promise((resolve, reject) => {
|
||||
// send query after 200 ms
|
||||
setTimeout(() => loadMessage().then(resolve).catch(reject), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
return loadMessage() as Promise<void>
|
||||
}
|
||||
/** Generic function for group queries */
|
||||
groupQuery(type: string, jid?: string, subject?: string, participants?: string[]) {
|
||||
const json: WANode = [
|
||||
'group',
|
||||
{
|
||||
author: this.userMetaData.id,
|
||||
id: generateMessageTag(),
|
||||
type: type,
|
||||
jid: jid,
|
||||
subject: subject,
|
||||
},
|
||||
participants ? participants.map((str) => ['participant', { jid: str }, null]) : [],
|
||||
]
|
||||
const q = ['action', { type: 'set', epoch: this.msgCount.toString() }, [json]]
|
||||
return this.queryExpecting200(q, [WAMetric.group, WAFlag.ignore])
|
||||
}
|
||||
/** Get the metadata of the group */
|
||||
groupMetadata = (jid: string) => this.queryExpecting200(['query', 'GroupMetadata', jid]) as Promise<WAGroupMetadata>
|
||||
/**
|
||||
* Create a group
|
||||
* @param title like, the title of the group
|
||||
* @param participants people to include in the group
|
||||
*/
|
||||
groupCreate = (title: string, participants: string[]) =>
|
||||
this.groupQuery('create', null, title, participants) as Promise<WAGroupCreateResponse>
|
||||
/**
|
||||
* Leave a group
|
||||
* @param jid the ID of the group
|
||||
*/
|
||||
groupLeave = (jid: string) => this.groupQuery('leave', jid) as Promise<{ status: number }>
|
||||
/**
|
||||
* Update the subject of the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
*/
|
||||
groupUpdateSubject = (jid: string, title: string) =>
|
||||
this.groupQuery('subject', jid, title) as Promise<{ status: number }>
|
||||
/**
|
||||
* 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>
|
||||
/** Get the invite link of the given group */
|
||||
async groupInviteCode(jid: string) {
|
||||
const json = ['query', 'inviteCode', jid]
|
||||
const response = await this.queryExpecting200(json)
|
||||
return response.code as string
|
||||
}
|
||||
}
|
||||
109
WAClient/Constants.ts
Normal file
109
WAClient/Constants.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { WAMessage } from '../WAConnection/Constants'
|
||||
import { proto } from '../Binary/WAMessage'
|
||||
/**
|
||||
* set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send
|
||||
*/
|
||||
export enum Presence {
|
||||
available = 'available', // "online"
|
||||
unavailable = 'unavailable', // "offline"
|
||||
composing = 'composing', // "typing..."
|
||||
recording = 'recording', // "recording..."
|
||||
paused = 'paused', // I have no clue
|
||||
}
|
||||
/**
|
||||
* Status of a message sent or received
|
||||
*/
|
||||
export enum MessageStatus {
|
||||
sent = 'sent',
|
||||
received = 'received',
|
||||
read = 'read',
|
||||
}
|
||||
/**
|
||||
* set of message types that are supported by the library
|
||||
*/
|
||||
export enum MessageType {
|
||||
text = 'conversation',
|
||||
extendedText = 'extendedTextMessage',
|
||||
contact = 'contactMessage',
|
||||
location = 'locationMessage',
|
||||
liveLocation = 'liveLocationMessage',
|
||||
|
||||
image = 'imageMessage',
|
||||
video = 'videoMessage',
|
||||
sticker = 'stickerMessage',
|
||||
document = 'documentMessage',
|
||||
audio = 'audioMessage',
|
||||
}
|
||||
/**
|
||||
* Tells us what kind of message it is
|
||||
*/
|
||||
export const MessageStubTypes = {
|
||||
20: 'addedToGroup',
|
||||
32: 'leftGroup',
|
||||
39: 'createdGroup',
|
||||
}
|
||||
export const HKDFInfoKeys = (function () {
|
||||
const dict: Record<string, string> = {}
|
||||
dict[MessageType.image] = 'WhatsApp Image Keys'
|
||||
dict[MessageType.video] = 'WhatsApp Audio Keys'
|
||||
dict[MessageType.document] = 'WhatsApp Document Keys'
|
||||
dict[MessageType.sticker] = 'WhatsApp Image Keys'
|
||||
return dict
|
||||
})()
|
||||
export enum Mimetype {
|
||||
jpeg = 'image/jpeg',
|
||||
mp4 = 'video/mp4',
|
||||
gif = 'video/gif',
|
||||
pdf = 'appliction/pdf',
|
||||
ogg = 'audio/ogg; codecs=opus',
|
||||
/** for stickers */
|
||||
webp = 'image/webp',
|
||||
}
|
||||
export interface MessageOptions {
|
||||
quoted?: WAMessage
|
||||
timestamp?: Date
|
||||
caption?: string
|
||||
thumbnail?: string
|
||||
mimetype?: Mimetype
|
||||
}
|
||||
export interface MessageStatusUpdate {
|
||||
from: string
|
||||
to: string
|
||||
participant?: string
|
||||
timestamp: Date
|
||||
/** Message IDs read/delivered */
|
||||
ids: string[]
|
||||
/** Status of the Message IDs */
|
||||
type: string
|
||||
}
|
||||
export interface PresenceUpdate {
|
||||
id: string
|
||||
type?: string
|
||||
deny?: boolean
|
||||
}
|
||||
// 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 interface WASendMessageResponse {
|
||||
status: number
|
||||
messageID: string
|
||||
}
|
||||
export interface WALocationMessage {
|
||||
degreesLatitude: number
|
||||
degreesLongitude: number
|
||||
address?: string
|
||||
}
|
||||
export type WAContactMessage = proto.ContactMessage
|
||||
172
WAClient/Messages.ts
Normal file
172
WAClient/Messages.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import WhatsAppWebBase from './Base'
|
||||
import fetch from 'node-fetch'
|
||||
import {
|
||||
MessageOptions,
|
||||
MessageType,
|
||||
Mimetype,
|
||||
MimetypeMap,
|
||||
MediaPathMap,
|
||||
WALocationMessage,
|
||||
WAContactMessage,
|
||||
WASendMessageResponse,
|
||||
Presence,
|
||||
} from './Constants'
|
||||
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes } from '../WAConnection/Utils'
|
||||
import { WAMessageContent, WAMetric, WAFlag } from '../WAConnection/Constants'
|
||||
import { generateThumbnail, getMediaKeys } from './Utils'
|
||||
|
||||
export default class WhatsAppWebMessages extends WhatsAppWebBase {
|
||||
/**
|
||||
* Send a read receipt to the given ID for a certain message
|
||||
* @param {string} jid the ID of the person/group whose message you want to mark read
|
||||
* @param {string} messageID the message ID
|
||||
*/
|
||||
sendReadReceipt(jid: string, messageID: string) {
|
||||
const json = [
|
||||
'action',
|
||||
{ epoch: this.msgCount.toString(), type: 'set' },
|
||||
[['read', { count: '1', index: messageID, jid: jid, owner: 'false' }, null]],
|
||||
]
|
||||
return this.queryExpecting200(json, [WAMetric.group, WAFlag.ignore]) // encrypt and send off
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
async updatePresence(jid: string, type: Presence) {
|
||||
const json = [
|
||||
'action',
|
||||
{ epoch: this.msgCount.toString(), type: 'set' },
|
||||
[['presence', { type: type, to: jid }, null]],
|
||||
]
|
||||
return this.queryExpecting200(json, [WAMetric.group, WAFlag.acknowledge]) as Promise<{ status: number }>
|
||||
}
|
||||
async sendMessage(
|
||||
id: string,
|
||||
message: string | WALocationMessage | WAContactMessage | Buffer,
|
||||
type: MessageType,
|
||||
options: MessageOptions = {},
|
||||
) {
|
||||
let m: any = {}
|
||||
switch (type) {
|
||||
case MessageType.text:
|
||||
case MessageType.extendedText:
|
||||
if (typeof message !== 'string') {
|
||||
throw 'expected message to be a string'
|
||||
}
|
||||
m.extendedTextMessage = { text: message }
|
||||
break
|
||||
case MessageType.location:
|
||||
case MessageType.liveLocation:
|
||||
m.locationMessage = message as WALocationMessage
|
||||
break
|
||||
case MessageType.contact:
|
||||
m.contactMessage = message as WAContactMessage
|
||||
break
|
||||
default:
|
||||
m = await this.prepareMediaMessage(message as Buffer, type, options)
|
||||
break
|
||||
}
|
||||
return this.sendGenericMessage(id, m as WAMessageContent, options)
|
||||
}
|
||||
/** Prepare a media message for sending */
|
||||
protected async prepareMediaMessage(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) {
|
||||
if (mediaType === MessageType.document && !options.mimetype) {
|
||||
throw 'mimetype required to send a document'
|
||||
}
|
||||
if (mediaType === MessageType.sticker && options.caption) {
|
||||
throw '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]
|
||||
}
|
||||
// generate a media key
|
||||
const mediaKey = randomBytes(32)
|
||||
const mediaKeys = getMediaKeys(mediaKey, mediaType)
|
||||
const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
|
||||
const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
|
||||
const body = Buffer.concat([enc, mac]) // body is enc + mac
|
||||
const fileSha256 = sha256(buffer)
|
||||
// url safe Base64 encode the SHA256 hash of the body
|
||||
const fileEncSha256B64 = sha256(body)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/\=+$/, '')
|
||||
|
||||
await generateThumbnail(buffer, mediaType, options)
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
const json = (await this.query(['query', 'mediaConn'])).media_conn
|
||||
const auth = json.auth // the auth token
|
||||
let hostname = 'https://' + json.hosts[0].hostname // first hostname available
|
||||
hostname += MediaPathMap[mediaType] + '/' + fileEncSha256B64 // append path
|
||||
hostname += '?auth=' + auth // add auth token
|
||||
hostname += '&token=' + fileEncSha256B64 // file hash
|
||||
|
||||
const urlFetch = await fetch(hostname, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
headers: { Origin: 'https://web.whatsapp.com' },
|
||||
})
|
||||
const responseJSON = await urlFetch.json()
|
||||
if (!responseJSON.url) {
|
||||
throw 'UPLOAD FAILED GOT: ' + JSON.stringify(responseJSON)
|
||||
}
|
||||
const message = {}
|
||||
message[mediaType] = {
|
||||
url: responseJSON.url,
|
||||
mediaKey: mediaKey.toString('base64'),
|
||||
mimetype: options.mimetype,
|
||||
fileEncSha256: fileEncSha256B64,
|
||||
fileSha256: fileSha256.toString('base64'),
|
||||
fileLength: buffer.length,
|
||||
gifPlayback: isGIF || null,
|
||||
}
|
||||
return message
|
||||
}
|
||||
/** Generic send message function */
|
||||
async sendGenericMessage(id: string, message: WAMessageContent, options: MessageOptions) {
|
||||
if (!options.timestamp) {
|
||||
// if no timestamp was provided,
|
||||
options.timestamp = new Date() // set timestamp to now
|
||||
}
|
||||
const key = Object.keys(message)[0]
|
||||
const timestamp = options.timestamp.getTime() / 1000
|
||||
const quoted = options.quoted
|
||||
if (quoted) {
|
||||
const participant = quoted.key.participant || quoted.key.remoteJid
|
||||
message[key].contextInfo = {
|
||||
participant: participant,
|
||||
stanzaId: quoted.key.id,
|
||||
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
|
||||
}
|
||||
}
|
||||
message[key].caption = options?.caption
|
||||
message[key].jpegThumbnail = options?.thumbnail
|
||||
|
||||
const messageJSON = {
|
||||
key: {
|
||||
remoteJid: id,
|
||||
fromMe: true,
|
||||
id: generateMessageID(),
|
||||
},
|
||||
message: message,
|
||||
messageTimestamp: timestamp,
|
||||
participant: id.includes('@g.us') ? this.userMetaData.id : null,
|
||||
}
|
||||
const json = ['action', { epoch: this.msgCount.toString(), type: 'relay' }, [['message', null, messageJSON]]]
|
||||
const response = await this.queryExpecting200(json, [WAMetric.message, WAFlag.ignore], null, messageJSON.key.id)
|
||||
return { status: response.status as number, messageID: messageJSON.key.id } as WASendMessageResponse
|
||||
}
|
||||
}
|
||||
152
WAClient/Tests.ts
Normal file
152
WAClient/Tests.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { WAClient } from './WAClient'
|
||||
import { MessageType, MessageOptions, Mimetype, Presence } from './Constants'
|
||||
import fs from 'fs'
|
||||
|
||||
import assert from 'assert'
|
||||
import { decodeMediaMessage } from './Utils'
|
||||
import { promiseTimeout } from '../WAConnection/Utils'
|
||||
|
||||
const testJid = '919646328797@s.whatsapp.net'
|
||||
const createTimeout = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout))
|
||||
|
||||
async function sendAndRetreiveMessage(client: WAClient, content, type: MessageType, options: MessageOptions = {}) {
|
||||
const response = await client.sendMessage(testJid, content, type, options)
|
||||
assert.equal(response.status, 200)
|
||||
const messages = await client.loadConversation(testJid, 1, null, true)
|
||||
assert.equal(messages[0].key.id, response.messageID)
|
||||
return messages[0]
|
||||
}
|
||||
function WAClientTest(name: string, func: (client: WAClient) => void) {
|
||||
describe(name, () => {
|
||||
const client = new WAClient()
|
||||
before(async () => {
|
||||
const file = './auth_info.json'
|
||||
await client.connectSlim(file)
|
||||
fs.writeFileSync(file, JSON.stringify(client.base64EncodedAuthInfo(), null, '\t'))
|
||||
})
|
||||
after(() => client.close())
|
||||
func(client)
|
||||
})
|
||||
}
|
||||
WAClientTest('Messages', (client) => {
|
||||
it('should send a text message', async () => {
|
||||
const message = await sendAndRetreiveMessage(client, 'hello fren', MessageType.text)
|
||||
assert.equal(message.message.conversation, 'hello fren')
|
||||
})
|
||||
it('should quote a message', async () => {
|
||||
const messages = await client.loadConversation(testJid, 2)
|
||||
const message = await sendAndRetreiveMessage(client, 'hello fren 2', MessageType.extendedText, {
|
||||
quoted: messages[0],
|
||||
})
|
||||
assert.equal(message.message.extendedTextMessage.contextInfo.stanzaId, messages[0].key.id)
|
||||
})
|
||||
it('should send a gif', async () => {
|
||||
const content = fs.readFileSync('./Media/ma_gif.mp4')
|
||||
const message = await sendAndRetreiveMessage(client, content, MessageType.video, { mimetype: Mimetype.gif })
|
||||
const file = await decodeMediaMessage(message.message, './Media/received_vid')
|
||||
})
|
||||
it('should send an image', async () => {
|
||||
const content = fs.readFileSync('./Media/meme.jpeg')
|
||||
const message = await sendAndRetreiveMessage(client, content, MessageType.image)
|
||||
const file = await decodeMediaMessage(message.message, './Media/received_img')
|
||||
//const message2 = await sendAndRetreiveMessage (client, 'this is a quote', MessageType.extendedText)
|
||||
})
|
||||
it('should send an image & quote', async () => {
|
||||
const messages = await client.loadConversation(testJid, 1)
|
||||
const content = fs.readFileSync('./Media/meme.jpeg')
|
||||
const message = await sendAndRetreiveMessage(client, content, MessageType.image, { quoted: messages[0] })
|
||||
const file = await decodeMediaMessage(message.message, './Media/received_img')
|
||||
assert.equal(message.message.imageMessage.contextInfo.stanzaId, messages[0].key.id)
|
||||
})
|
||||
})
|
||||
WAClientTest('Presence', (client) => {
|
||||
it('should update presence', async () => {
|
||||
const presences = Object.values(Presence)
|
||||
for (const i in presences) {
|
||||
const response = await client.updatePresence(testJid, presences[i])
|
||||
assert.equal(response.status, 200)
|
||||
|
||||
await createTimeout(1500)
|
||||
}
|
||||
})
|
||||
})
|
||||
WAClientTest('Misc', (client) => {
|
||||
it('should tell if someone has an account on WhatsApp', async () => {
|
||||
const response = await client.isOnWhatsApp(testJid)
|
||||
assert.equal(response, true)
|
||||
|
||||
const responseFail = await client.isOnWhatsApp('abcd@s.whatsapp.net')
|
||||
assert.equal(responseFail, false)
|
||||
})
|
||||
it('should return the status', async () => {
|
||||
const response = await client.getStatus(testJid)
|
||||
assert.ok(response.status)
|
||||
assert.equal(typeof response.status, 'string')
|
||||
})
|
||||
it('should return the profile picture', async () => {
|
||||
const response = await client.getProfilePicture(testJid)
|
||||
assert.ok(response)
|
||||
assert.rejects(client.getProfilePicture('abcd@s.whatsapp.net'))
|
||||
})
|
||||
})
|
||||
WAClientTest('Groups', (client) => {
|
||||
let gid: string
|
||||
it('should create a group', async () => {
|
||||
const response = await client.groupCreate('Cool Test Group', [testJid])
|
||||
assert.equal(response.status, 200)
|
||||
gid = response.gid
|
||||
console.log('created group: ' + gid)
|
||||
})
|
||||
it('should retreive group invite code', async () => {
|
||||
const code = await client.groupInviteCode(gid)
|
||||
assert.ok(code)
|
||||
assert.equal(typeof code, 'string')
|
||||
})
|
||||
it('should retreive group metadata', async () => {
|
||||
const metadata = await client.groupMetadata(gid)
|
||||
assert.equal(metadata.id, gid)
|
||||
assert.equal(metadata.participants.filter((obj) => obj.id.split('@')[0] === testJid.split('@')[0]).length, 1)
|
||||
})
|
||||
it('should send a message on the group', async () => {
|
||||
const r = await client.sendMessage(gid, 'hello', MessageType.text)
|
||||
assert.equal(r.status, 200)
|
||||
})
|
||||
it('should update the subject', async () => {
|
||||
const subject = 'V Cool Title'
|
||||
const r = await client.groupUpdateSubject(gid, subject)
|
||||
assert.equal(r.status, 200)
|
||||
|
||||
const metadata = await client.groupMetadata(gid)
|
||||
assert.equal(metadata.subject, subject)
|
||||
})
|
||||
it('should remove someone from a group', async () => {
|
||||
await client.groupRemove(gid, [testJid])
|
||||
})
|
||||
it('should leave the group', async () => {
|
||||
const response = await client.groupLeave(gid)
|
||||
assert.equal(response.status, 200)
|
||||
})
|
||||
})
|
||||
WAClientTest('Events', (client) => {
|
||||
it('should deliver a message', async () => {
|
||||
const waitForUpdate = () =>
|
||||
new Promise((resolve) => {
|
||||
client.setOnMessageStatusChange((update) => {
|
||||
if (update.ids.includes(response.messageID)) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
const response = await client.sendMessage(testJid, 'My Name Jeff', MessageType.text)
|
||||
await promiseTimeout(10000, waitForUpdate())
|
||||
})
|
||||
/* it ('should update me on presence', async () => {
|
||||
//client.logUnhandledMessages = true
|
||||
client.setOnPresenceUpdate (presence => {
|
||||
console.log (presence)
|
||||
})
|
||||
const response = await client.requestPresenceUpdate (client.userMetaData)
|
||||
assert.equal (response.status, 200)
|
||||
await createTimeout (25000)
|
||||
})*/
|
||||
})
|
||||
132
WAClient/Utils.ts
Normal file
132
WAClient/Utils.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { MessageType, HKDFInfoKeys, MessageOptions, MessageStubTypes } from './Constants'
|
||||
import sharp from 'sharp'
|
||||
import fs from 'fs'
|
||||
import fetch from 'node-fetch'
|
||||
import { WAMessage, WAMessageContent } from '../WAConnection/Constants'
|
||||
import { hmacSign, aesDecryptWithIV, hkdf } from '../WAConnection/Utils'
|
||||
import { proto } from '../Binary/WAMessage'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { exec } from 'child_process'
|
||||
|
||||
/** Type of notification */
|
||||
export function getNotificationType(message: WAMessage) {
|
||||
if (message.message) {
|
||||
return ['message', Object.keys(message.message)[0]]
|
||||
} else if (message.messageStubType) {
|
||||
return [MessageStubTypes[message.messageStubType], null]
|
||||
} else {
|
||||
return ['unknown', null]
|
||||
}
|
||||
}
|
||||
/** generates all the keys required to encrypt/decrypt & sign a media message */
|
||||
|
||||
export function getMediaKeys(buffer, mediaType: MessageType) {
|
||||
// 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>
|
||||
|
||||
/** generates a thumbnail for a given media, if required */
|
||||
|
||||
export async function generateThumbnail(buffer: Buffer, mediaType: MessageType, info: MessageOptions) {
|
||||
if (info.thumbnail === null || info.thumbnail) {
|
||||
// don't do anything if the thumbnail is already provided, or is null
|
||||
if (mediaType === MessageType.audio) {
|
||||
throw 'audio messages cannot have thumbnails'
|
||||
}
|
||||
} else if (mediaType === MessageType.image || mediaType === MessageType.sticker) {
|
||||
const buff = await sharp(buffer).resize(48, 48).jpeg().toBuffer()
|
||||
info.thumbnail = buff.toString('base64')
|
||||
} else if (mediaType === MessageType.video) {
|
||||
const filename = './' + randomBytes(5).toString('hex') + '.mp4'
|
||||
const imgFilename = filename + '.jpg'
|
||||
fs.writeFileSync(filename, buffer)
|
||||
try {
|
||||
await extractVideoThumb(filename, imgFilename, '00:00:00', { width: 48, height: 48 })
|
||||
const buff = fs.readFileSync(imgFilename)
|
||||
info.thumbnail = buff.toString('base64')
|
||||
fs.unlinkSync(imgFilename)
|
||||
} catch (err) {
|
||||
console.log('could not generate video thumb: ' + err)
|
||||
}
|
||||
fs.unlinkSync(filename)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Decode a media message (video, image, document, audio) & save it to the given file
|
||||
* @param message the media message you want to decode
|
||||
* @param filename the name of the file where the media will be saved
|
||||
*/
|
||||
export async function decodeMediaMessage(message: WAMessageContent, filename: string) {
|
||||
const getExtension = (mimetype) => mimetype.split(';')[0].split('/')[1]
|
||||
/*
|
||||
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 'unknown message type'
|
||||
}
|
||||
if (type === MessageType.text || type === MessageType.extendedText) {
|
||||
throw 'cannot decode text message'
|
||||
}
|
||||
if (type === MessageType.location || type === MessageType.liveLocation) {
|
||||
fs.writeFileSync(filename + '.jpeg', message[type].jpegThumbnail)
|
||||
return { filename: filename + '.jpeg' }
|
||||
}
|
||||
|
||||
const messageContent = message[type] as
|
||||
| proto.VideoMessage
|
||||
| proto.ImageMessage
|
||||
| proto.AudioMessage
|
||||
| proto.DocumentMessage
|
||||
// get the keys to decrypt the message
|
||||
const mediaKeys = getMediaKeys(messageContent.mediaKey, type) //getMediaKeys(Buffer.from(messageContent.mediaKey, 'base64'), type)
|
||||
const iv = mediaKeys.iv
|
||||
const cipherKey = mediaKeys.cipherKey
|
||||
const macKey = mediaKeys.macKey
|
||||
|
||||
// download the message
|
||||
const fetched = await fetch(messageContent.url, {})
|
||||
const buffer = await fetched.buffer()
|
||||
// first part is actual file
|
||||
const file = buffer.slice(0, buffer.length - 10)
|
||||
// last 10 bytes is HMAC sign of file
|
||||
const mac = buffer.slice(buffer.length - 10, buffer.length)
|
||||
|
||||
// sign IV+file & check for match with mac
|
||||
const testBuff = Buffer.concat([iv, file])
|
||||
const sign = hmacSign(testBuff, macKey).slice(0, 10)
|
||||
// our sign should equal the mac
|
||||
if (sign.equals(mac)) {
|
||||
const decrypted = aesDecryptWithIV(file, cipherKey, iv) // decrypt media
|
||||
|
||||
const trueFileName = filename + '.' + getExtension(messageContent.mimetype)
|
||||
fs.writeFileSync(trueFileName, decrypted)
|
||||
|
||||
return trueFileName
|
||||
} else {
|
||||
throw 'HMAC sign does not match'
|
||||
}
|
||||
}
|
||||
6
WAClient/WAClient.ts
Normal file
6
WAClient/WAClient.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import WhatsAppWebMessages from './Messages'
|
||||
|
||||
export { WhatsAppWebMessages as WAClient }
|
||||
export * from './Constants'
|
||||
export * from './Utils'
|
||||
export * from '../WAConnection/Constants'
|
||||
264
WAConnection/Base.ts
Normal file
264
WAConnection/Base.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import WS from 'ws'
|
||||
import QR from 'qrcode-terminal'
|
||||
import fs from 'fs'
|
||||
import * as Utils from './Utils'
|
||||
import Encoder from '../Binary/Encoder'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
import { AuthenticationCredentials, UserMetaData, WANode, AuthenticationCredentialsBase64, WATag } from './Constants'
|
||||
|
||||
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
|
||||
const generateQRCode = function ([ref, publicKey, clientID]) {
|
||||
const str = ref + ',' + publicKey + ',' + clientID
|
||||
QR.generate(str, { small: true })
|
||||
}
|
||||
|
||||
export default class WAConnectionBase {
|
||||
/** The version of WhatsApp Web we're telling the servers we are */
|
||||
version: [number, number, number] = [2, 2025, 6]
|
||||
/** The Browser we're telling the WhatsApp Web servers we are */
|
||||
browserDescription: [string, string] = ['Baileys', 'Baileys']
|
||||
/** Metadata like WhatsApp id, name set on WhatsApp etc. */
|
||||
userMetaData: UserMetaData = { id: null, name: null, phone: null }
|
||||
/** Should reconnect automatically after an unexpected disconnect */
|
||||
autoReconnect = true
|
||||
lastSeen: Date = null
|
||||
/** Log messages that are not handled, so you can debug & see what custom stuff you can implement */
|
||||
logUnhandledMessages = false
|
||||
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */
|
||||
protected authInfo: AuthenticationCredentials = {
|
||||
clientID: null,
|
||||
serverToken: null,
|
||||
clientToken: null,
|
||||
encKey: null,
|
||||
macKey: null,
|
||||
}
|
||||
/** Curve keys to initially authenticate */
|
||||
protected curveKeys: { private: Uint8Array; public: Uint8Array }
|
||||
/** The websocket connection */
|
||||
protected conn: WS = null
|
||||
protected msgCount = 0
|
||||
protected keepAliveReq: NodeJS.Timeout
|
||||
protected callbacks = {}
|
||||
protected encoder = new Encoder()
|
||||
protected decoder = new Decoder()
|
||||
/**
|
||||
* What to do when you need the phone to authenticate the connection (generate QR code by default)
|
||||
*/
|
||||
onReadyForPhoneAuthentication = generateQRCode
|
||||
unexpectedDisconnect = (err) => this.close()
|
||||
/**
|
||||
* 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'),
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Load in the authentication credentials
|
||||
* @param authInfo the authentication credentials or path to auth credentials JSON
|
||||
*/
|
||||
loadAuthInfoFromBase64(authInfo: AuthenticationCredentialsBase64 | string) {
|
||||
if (!authInfo) {
|
||||
throw 'given authInfo is null'
|
||||
}
|
||||
if (typeof authInfo === 'string') {
|
||||
this.log(`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 AuthenticationCredentialsBase64
|
||||
}
|
||||
this.authInfo = {
|
||||
clientID: authInfo.clientID,
|
||||
serverToken: authInfo.serverToken,
|
||||
clientToken: authInfo.clientToken,
|
||||
encKey: Buffer.from(authInfo.encKey, 'base64'), // decode from base64
|
||||
macKey: Buffer.from(authInfo.macKey, 'base64'), // decode from base64
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Register for a callback for a certain function, will cancel automatically after one execution
|
||||
* @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters
|
||||
*/
|
||||
async registerCallbackOneTime(parameters) {
|
||||
const json = await new Promise((resolve, _) => this.registerCallback(parameters, resolve))
|
||||
this.deregisterCallback(parameters)
|
||||
return json
|
||||
}
|
||||
/**
|
||||
* Register for a callback for a certain function
|
||||
* @param parameters name of the function along with some optional specific parameters
|
||||
*/
|
||||
registerCallback(parameters: [string, string?, string?] | string, callback) {
|
||||
if (typeof parameters === 'string') {
|
||||
return this.registerCallback([parameters, null, null], callback)
|
||||
}
|
||||
if (!Array.isArray(parameters)) {
|
||||
throw 'parameters (' + parameters + ') must be a string or array'
|
||||
}
|
||||
const func = 'function:' + parameters[0]
|
||||
const key = parameters[1] || ''
|
||||
const key2 = parameters[2] || ''
|
||||
if (!this.callbacks[func]) {
|
||||
this.callbacks[func] = {}
|
||||
}
|
||||
if (!this.callbacks[func][key]) {
|
||||
this.callbacks[func][key] = {}
|
||||
}
|
||||
this.callbacks[func][key][key2] = callback
|
||||
}
|
||||
/**
|
||||
* Cancel all further callback events associated with the given parameters
|
||||
* @param parameters name of the function along with some optional specific parameters
|
||||
*/
|
||||
deregisterCallback(parameters: [string, string?, string?] | string) {
|
||||
if (typeof parameters === 'string') {
|
||||
return this.deregisterCallback([parameters])
|
||||
}
|
||||
if (!Array.isArray(parameters)) {
|
||||
throw 'parameters (' + parameters + ') must be a string or array'
|
||||
}
|
||||
const func = 'function:' + parameters[0]
|
||||
const key = parameters[1] || ''
|
||||
const key2 = parameters[2] || ''
|
||||
if (this.callbacks[func] && this.callbacks[func][key] && this.callbacks[func][key][key2]) {
|
||||
delete this.callbacks[func][key][key2]
|
||||
return
|
||||
}
|
||||
this.log('WARNING: could not find ' + JSON.stringify(parameters) + ' to deregister')
|
||||
}
|
||||
/**
|
||||
* 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, json: Object = null, timeoutMs: number = null) {
|
||||
let promise = new Promise(
|
||||
(resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }),
|
||||
)
|
||||
if (timeoutMs) {
|
||||
promise = Utils.promiseTimeout(timeoutMs, promise).catch((err) => {
|
||||
delete this.callbacks[tag]
|
||||
throw err
|
||||
})
|
||||
}
|
||||
return promise as Promise<any>
|
||||
}
|
||||
/**
|
||||
* Query something from the WhatsApp servers and error on a non-200 status
|
||||
* @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
|
||||
* recieved JSON
|
||||
*/
|
||||
async queryExpecting200(
|
||||
json: any[] | WANode,
|
||||
binaryTags: WATag = null,
|
||||
timeoutMs: number = null,
|
||||
tag: string = null,
|
||||
) {
|
||||
return Utils.errorOnNon200Status(this.query(json, binaryTags, timeoutMs, tag))
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
* recieved JSON
|
||||
*/
|
||||
async query(json: any[] | WANode, binaryTags: WATag = null, timeoutMs: number = null, tag: string = null) {
|
||||
if (binaryTags) {
|
||||
tag = this.sendBinary(json as WANode, binaryTags, tag)
|
||||
} else {
|
||||
tag = this.sendJSON(json, tag)
|
||||
}
|
||||
return this.waitForMessage(tag, json, timeoutMs)
|
||||
}
|
||||
/**
|
||||
* Send a binary encoded message
|
||||
* @param json the message to encode & send
|
||||
* @param {[number, number]} tags the binary tags to tell WhatsApp what the message is all about
|
||||
* @param {string} [tag] the tag to attach to the message
|
||||
* @return {string} the message tag
|
||||
*/
|
||||
private sendBinary(json: WANode, tags: [number, number], tag: string) {
|
||||
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 || Utils.generateMessageTag()
|
||||
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
|
||||
])
|
||||
this.send(buff) // send it off
|
||||
return tag
|
||||
}
|
||||
/**
|
||||
* Send a plain JSON message to the WhatsApp servers
|
||||
* @private
|
||||
* @param json the message to send
|
||||
* @param [tag] the tag to attach to the message
|
||||
* @return the message tag
|
||||
*/
|
||||
private sendJSON(json: any[] | WANode, tag: string = null) {
|
||||
tag = tag || Utils.generateMessageTag()
|
||||
this.send(tag + ',' + JSON.stringify(json))
|
||||
return tag
|
||||
}
|
||||
/** Send some message to the WhatsApp servers */
|
||||
protected send(m) {
|
||||
if (!this.conn) {
|
||||
throw 'cannot send message, disconnected from WhatsApp'
|
||||
}
|
||||
this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages
|
||||
this.conn.send(m)
|
||||
}
|
||||
/**
|
||||
* 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() {
|
||||
if (!this.conn) {
|
||||
throw "You're not even connected, you can't log out"
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
this.conn.send('goodbye,["admin","Conn","disconnect"]', null, () => {
|
||||
this.authInfo = null
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
this.close()
|
||||
}
|
||||
/** Close the connection to WhatsApp Web */
|
||||
close() {
|
||||
this.msgCount = 0
|
||||
if (this.conn) {
|
||||
this.conn.close()
|
||||
this.conn = null
|
||||
}
|
||||
const keys = Object.keys(this.callbacks)
|
||||
keys.forEach((key) => {
|
||||
if (!key.includes('function:')) {
|
||||
this.callbacks[key].errCallback('connection closed')
|
||||
delete this.callbacks[key]
|
||||
}
|
||||
})
|
||||
if (this.keepAliveReq) {
|
||||
clearInterval(this.keepAliveReq)
|
||||
}
|
||||
}
|
||||
protected log(text) {
|
||||
console.log(`[Baileys] ${text}`)
|
||||
}
|
||||
}
|
||||
248
WAConnection/Connect.ts
Normal file
248
WAConnection/Connect.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import WS from 'ws'
|
||||
|
||||
import * as Utils from './Utils'
|
||||
import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact } from './Constants'
|
||||
import WAConnectionValidator from './Validation'
|
||||
|
||||
export default class WAConnectionConnector extends WAConnectionValidator {
|
||||
/**
|
||||
* Connect to WhatsAppWeb
|
||||
* @param [authInfo] credentials or path to credentials to log back in
|
||||
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
|
||||
* @return returns [userMetaData, chats, contacts, unreadMessages]
|
||||
*/
|
||||
async connect(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
|
||||
const userInfo = await this.connectSlim(authInfo, timeoutMs)
|
||||
const chats = await this.receiveChatsAndContacts()
|
||||
return [userInfo, ...chats] as [UserMetaData, WAChat[], WAContact[], WAMessage[]]
|
||||
}
|
||||
/**
|
||||
* Connect to WhatsAppWeb, resolves without waiting for chats & contacts
|
||||
* @param [authInfo] credentials to log back in
|
||||
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
|
||||
* @return [userMetaData, chats, contacts, unreadMessages]
|
||||
*/
|
||||
async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
|
||||
// if we're already connected, throw an error
|
||||
if (this.conn) {
|
||||
throw [1, 'already connected or connecting']
|
||||
}
|
||||
// set authentication credentials if required
|
||||
try {
|
||||
this.loadAuthInfoFromBase64(authInfo)
|
||||
} catch {}
|
||||
|
||||
this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' })
|
||||
|
||||
let promise: Promise<UserMetaData> = new Promise((resolve, reject) => {
|
||||
this.conn.on('open', () => {
|
||||
this.log('connected to WhatsApp Web, authenticating...')
|
||||
// start sending keep alive requests (keeps the WebSocket alive & updates our last seen)
|
||||
this.authenticate()
|
||||
.then((user) => {
|
||||
this.startKeepAliveRequest()
|
||||
resolve(user)
|
||||
})
|
||||
.catch(reject)
|
||||
})
|
||||
this.conn.on('message', (m) => this.onMessageRecieved(m)) // in WhatsAppWeb.Recv.js
|
||||
this.conn.on('error', (error) => {
|
||||
// if there was an error in the WebSocket
|
||||
this.close()
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
promise = timeoutMs ? Utils.promiseTimeout(timeoutMs, promise) : promise
|
||||
return promise.catch((err) => {
|
||||
this.close()
|
||||
throw err
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Sets up callbacks to receive chats, contacts & unread messages.
|
||||
* Must be called immediately after connect
|
||||
* [chats, contacts, unreadMessages]
|
||||
*/
|
||||
receiveChatsAndContacts() {
|
||||
const chats: Array<WAChat> = []
|
||||
let contacts: Array<WAContact> = []
|
||||
const unreadMessages: Array<WAMessage> = []
|
||||
const unreadMap = {}
|
||||
|
||||
let encounteredAddBefore = false
|
||||
let convoResolve
|
||||
|
||||
this.log('waiting for chats & contacts') // wait for the message with chats
|
||||
const waitForConvos = () =>
|
||||
new Promise((resolve, _) => {
|
||||
convoResolve = resolve
|
||||
const chatUpdate = (json) => {
|
||||
const isLast = json[1].last
|
||||
encounteredAddBefore = json[1].add === 'before' ? true : encounteredAddBefore
|
||||
|
||||
json = json[2]
|
||||
if (json) {
|
||||
for (let k = json.length - 1; k >= 0; k--) {
|
||||
const message = json[k][2]
|
||||
const jid = message.key.remoteJid.replace('@s.whatsapp.net', '@c.us')
|
||||
if (!message.key.fromMe && unreadMap[jid] > 0) {
|
||||
// only forward if the message is from the sender
|
||||
unreadMessages.push(message)
|
||||
unreadMap[jid] -= 1 // reduce
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLast) {
|
||||
// de-register the callbacks, so that they don't get called again
|
||||
this.deregisterCallback(['action', 'add:last'])
|
||||
this.deregisterCallback(['action', 'add:before'])
|
||||
this.deregisterCallback(['action', 'add:unread'])
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
// wait for actual messages to load, "last" is the most recent message, "before" contains prior messages
|
||||
this.registerCallback(['action', 'add:last'], chatUpdate)
|
||||
this.registerCallback(['action', 'add:before'], chatUpdate)
|
||||
this.registerCallback(['action', 'add:unread'], chatUpdate)
|
||||
})
|
||||
const waitForChats = this.registerCallbackOneTime(['response', 'type:chat']).then((json) => {
|
||||
json[2].forEach((chat) => {
|
||||
chats.push(chat[1]) // chats data (log json to see what it looks like)
|
||||
// store the number of unread messages for each sender
|
||||
unreadMap[chat[1].jid] = chat[1].count
|
||||
})
|
||||
if (chats && chats.length > 0) return waitForConvos()
|
||||
})
|
||||
const waitForContacts = this.registerCallbackOneTime(['response', 'type:contacts']).then((json) => {
|
||||
contacts = json[2].map((item) => item[1])
|
||||
// if no add:before messages are sent, and you receive contacts
|
||||
// should probably resolve the promise
|
||||
if (!encounteredAddBefore && convoResolve) convoResolve()
|
||||
})
|
||||
// wait for the chats & contacts to load
|
||||
return Promise.all([waitForChats, waitForContacts]).then(() => [chats, contacts, unreadMessages])
|
||||
}
|
||||
private onMessageRecieved(message) {
|
||||
if (message[0] === '!') {
|
||||
// when the first character in the message is an '!', the server is updating the last seen
|
||||
const timestamp = message.slice(1, message.length)
|
||||
this.lastSeen = new Date(parseInt(timestamp))
|
||||
} else {
|
||||
const commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
|
||||
|
||||
if (commaIndex < 0) {
|
||||
// if there was no comma, then this message must be not be valid
|
||||
throw [2, 'invalid message', message]
|
||||
}
|
||||
|
||||
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 = message.slice(0, commaIndex).toString()
|
||||
if (data.length === 0) {
|
||||
// got an empty message, usually get one after sending a query with the 128 tag
|
||||
return
|
||||
}
|
||||
|
||||
let json
|
||||
if (data[0] === '[' || data[0] === '{') {
|
||||
// if the first character is a "[", then the data must just be plain JSON array or object
|
||||
json = JSON.parse(data) // parse the JSON
|
||||
} else if (this.authInfo.macKey && this.authInfo.encKey) {
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
||||
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 = Utils.hmacSign(data, this.authInfo.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 = Utils.aesDecrypt(data, this.authInfo.encKey) // decrypt using AES
|
||||
json = this.decoder.read(decrypted) // decode the binary message into a JSON array
|
||||
} else {
|
||||
throw [7, "checksums don't match"]
|
||||
}
|
||||
} else {
|
||||
// if we recieved a message that was encrypted but we don't have the keys, then there must be an error
|
||||
throw [3, 'recieved encrypted message when auth creds not available', message]
|
||||
}
|
||||
/*
|
||||
Check if this is a response to a message we sent
|
||||
*/
|
||||
if (this.callbacks[messageTag]) {
|
||||
const q = this.callbacks[messageTag]
|
||||
q.callback(json)
|
||||
delete this.callbacks[messageTag]
|
||||
return
|
||||
}
|
||||
/*
|
||||
Check if this is a response to a message we are expecting
|
||||
*/
|
||||
if (this.callbacks['function:' + json[0]]) {
|
||||
const callbacks = this.callbacks['function:' + json[0]]
|
||||
let callbacks2
|
||||
let callback
|
||||
for (const key in json[1] || {}) {
|
||||
callbacks2 = callbacks[key + ':' + json[1][key]]
|
||||
if (callbacks2) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!callbacks2) {
|
||||
for (const key in json[1] || {}) {
|
||||
callbacks2 = callbacks[key]
|
||||
if (callbacks2) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!callbacks2) {
|
||||
callbacks2 = callbacks['']
|
||||
}
|
||||
if (callbacks2) {
|
||||
callback = callbacks2[json[2] && json[2][0][0]]
|
||||
if (!callback) {
|
||||
callback = callbacks2['']
|
||||
}
|
||||
}
|
||||
if (callback) {
|
||||
callback(json)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.logUnhandledMessages) {
|
||||
this.log('[Unhandled] ' + messageTag + ', ' + JSON.stringify(json))
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Send a keep alive request every X seconds, server updates & responds with last seen */
|
||||
private startKeepAliveRequest() {
|
||||
const refreshInterval = 20
|
||||
this.keepAliveReq = setInterval(() => {
|
||||
const diff = (new Date().getTime() - this.lastSeen.getTime()) / 1000
|
||||
/*
|
||||
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, or the phone got unpaired from our connection
|
||||
*/
|
||||
if (diff > refreshInterval + 5) {
|
||||
this.close()
|
||||
|
||||
if (this.autoReconnect) {
|
||||
// attempt reconnecting if the user wants us to
|
||||
this.log('disconnected unexpectedly, reconnecting...')
|
||||
const reconnectLoop = () => this.connect(null, 25 * 1000).catch(reconnectLoop)
|
||||
reconnectLoop() // keep trying to connect
|
||||
} else {
|
||||
this.unexpectedDisconnect('lost connection unexpectedly')
|
||||
}
|
||||
} else {
|
||||
// if its all good, send a keep alive request
|
||||
this.send('?,,')
|
||||
}
|
||||
}, refreshInterval * 1000)
|
||||
}
|
||||
}
|
||||
73
WAConnection/Constants.ts
Normal file
73
WAConnection/Constants.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { WA } from '../Binary/Constants'
|
||||
import { proto } from '../Binary/WAMessage'
|
||||
|
||||
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 UserMetaData {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
}
|
||||
export type WANode = WA.Node
|
||||
export type WAMessage = proto.WebMessageInfo
|
||||
export type WAMessageContent = proto.IMessage
|
||||
|
||||
export interface WAGroupCreateResponse {
|
||||
status: number
|
||||
gid?: string
|
||||
participants?: { [key: string]: any }
|
||||
}
|
||||
export interface WAGroupMetadata {
|
||||
id: string
|
||||
owner: string
|
||||
subject: string
|
||||
creation: number
|
||||
participants: [{ id: string; isAdmin: boolean; isSuperAdmin: boolean }]
|
||||
}
|
||||
export interface WAGroupModification {
|
||||
status: number
|
||||
participants?: { [key: string]: any }
|
||||
}
|
||||
|
||||
export interface WAContact {
|
||||
notify?: string
|
||||
jid: string
|
||||
name?: string
|
||||
index?: string
|
||||
short?: string
|
||||
}
|
||||
export interface WAChat {
|
||||
t: string
|
||||
count: string
|
||||
spam: 'false' | 'true'
|
||||
jid: string
|
||||
modify_tag: string
|
||||
}
|
||||
export enum WAMetric {
|
||||
liveLocation = 3,
|
||||
group = 10,
|
||||
message = 16,
|
||||
queryLiveLocation = 33,
|
||||
}
|
||||
export enum WAFlag {
|
||||
ignore = 1 << 7,
|
||||
acknowledge = 1 << 6,
|
||||
available = 1 << 5,
|
||||
unavailable = 1 << 4,
|
||||
expires = 1 << 3,
|
||||
skipOffline = 1 << 2,
|
||||
}
|
||||
/** Tag used with binary queries */
|
||||
export type WATag = [WAMetric, WAFlag]
|
||||
58
WAConnection/Tests.ts
Normal file
58
WAConnection/Tests.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from 'assert'
|
||||
import WAConnection from './WAConnection'
|
||||
import { AuthenticationCredentialsBase64 } from './Constants'
|
||||
|
||||
describe('QR generation', () => {
|
||||
it('should generate QR', async () => {
|
||||
const conn = new WAConnection()
|
||||
let calledQR = false
|
||||
conn.onReadyForPhoneAuthentication = ([ref, curveKey, clientID]) => {
|
||||
assert.ok(ref, 'ref nil')
|
||||
assert.ok(curveKey, 'curve key nil')
|
||||
assert.ok(clientID, 'client ID nil')
|
||||
calledQR = true
|
||||
}
|
||||
await assert.rejects(async () => conn.connectSlim(null, 5000), 'should have failed connect')
|
||||
assert.equal(calledQR, true, 'QR not called')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Test Connect', () => {
|
||||
let auth: AuthenticationCredentialsBase64
|
||||
it('should connect', async () => {
|
||||
console.log('please be ready to scan with your phone')
|
||||
const conn = new WAConnection()
|
||||
await assert.doesNotReject(async () => conn.connectSlim(null), 'initial connection failed')
|
||||
assert.ok(conn.userMetaData)
|
||||
assert.ok(conn.userMetaData.id)
|
||||
|
||||
conn.close()
|
||||
auth = conn.base64EncodedAuthInfo()
|
||||
})
|
||||
it('should reconnect', async () => {
|
||||
const conn = new WAConnection()
|
||||
|
||||
const [user, chats, contacts, unread] = await conn.connect('./auth_info.json', 20 * 1000)
|
||||
|
||||
assert.ok(user)
|
||||
assert.ok(user.id)
|
||||
|
||||
assert.ok(chats)
|
||||
if (chats.length > 0) {
|
||||
assert.ok(chats[0].jid)
|
||||
assert.ok(chats[0].count)
|
||||
}
|
||||
assert.ok(contacts)
|
||||
if (contacts.length > 0) {
|
||||
assert.ok(contacts[0].jid)
|
||||
}
|
||||
assert.ok(unread)
|
||||
if (unread.length > 0) {
|
||||
assert.ok(unread[0].key)
|
||||
}
|
||||
|
||||
await conn.logout()
|
||||
|
||||
await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed')
|
||||
})
|
||||
})
|
||||
70
WAConnection/Utils.ts
Normal file
70
WAConnection/Utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import Crypto from 'crypto'
|
||||
import HKDF from 'futoin-hkdf'
|
||||
|
||||
/** 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)
|
||||
}
|
||||
export function promiseTimeout<T>(ms: number, promise: Promise<T>) {
|
||||
// Create a promise that rejects in <ms> milliseconds
|
||||
const timeout = new Promise((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
clearTimeout(id)
|
||||
reject('Timed out')
|
||||
}, ms)
|
||||
})
|
||||
return Promise.race([promise, timeout]) as Promise<T>
|
||||
}
|
||||
// whatsapp requires a message tag for every message, we just use the timestamp as one
|
||||
export function generateMessageTag() {
|
||||
return new Date().getTime().toString()
|
||||
}
|
||||
// generate a random 16 byte client ID
|
||||
export function generateClientID() {
|
||||
return randomBytes(16).toString('base64')
|
||||
}
|
||||
// generate a random 10 byte ID to attach to a message
|
||||
export function generateMessageID() {
|
||||
return randomBytes(10).toString('hex').toUpperCase()
|
||||
}
|
||||
|
||||
export function errorOnNon200Status(p: Promise<any>) {
|
||||
return p.then((json) => {
|
||||
if (json.status && typeof json.status === 'number' && Math.floor(json.status / 100) !== 2) {
|
||||
throw new Error(`Unexpected status code: ${json.status}`)
|
||||
}
|
||||
return json
|
||||
})
|
||||
}
|
||||
164
WAConnection/Validation.ts
Normal file
164
WAConnection/Validation.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as Curve from 'curve25519-js'
|
||||
import * as Utils from './Utils'
|
||||
import WAConnectionBase from './Base'
|
||||
|
||||
export default class WAConnectionValidator extends WAConnectionBase {
|
||||
/** Authenticate the connection */
|
||||
protected async authenticate() {
|
||||
if (!this.authInfo.clientID) {
|
||||
// if no auth info is present, that is, a new session has to be established
|
||||
// generate a client ID
|
||||
this.authInfo = {
|
||||
clientID: Utils.generateClientID(),
|
||||
clientToken: null,
|
||||
serverToken: null,
|
||||
encKey: null,
|
||||
macKey: null,
|
||||
}
|
||||
}
|
||||
|
||||
const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true]
|
||||
return this.query(data)
|
||||
.then((json) => {
|
||||
// we're trying to establish a new connection or are trying to log in
|
||||
switch (json.status) {
|
||||
case 200: // all good and we can procede to generate a QR code for new connection, or can now login given present auth info
|
||||
if (this.authInfo.encKey && this.authInfo.macKey) {
|
||||
// if we have the info to restore a closed session
|
||||
const data = [
|
||||
'admin',
|
||||
'login',
|
||||
this.authInfo.clientToken,
|
||||
this.authInfo.serverToken,
|
||||
this.authInfo.clientID,
|
||||
'takeover',
|
||||
]
|
||||
return this.query(data, null, null, 's1') // wait for response with tag "s1"
|
||||
} else {
|
||||
return this.generateKeysForAuth(json.ref)
|
||||
}
|
||||
default:
|
||||
throw [json.status, 'unknown error', json]
|
||||
}
|
||||
})
|
||||
.then((json) => {
|
||||
switch (json.status) {
|
||||
case 401: // if the phone was unpaired
|
||||
throw [json.status, 'unpaired from phone', json]
|
||||
case 429: // request to login was denied, don't know why it happens
|
||||
throw [json.status, 'request denied, try reconnecting', json]
|
||||
case 304: // request to generate a new key for a QR code was denied
|
||||
throw [json.status, 'request for new key denied', json]
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (json[1] && json[1].challenge) {
|
||||
// if its a challenge request (we get it when logging in)
|
||||
return this.respondToChallenge(json[1].challenge).then((json) => {
|
||||
if (json.status !== 200) {
|
||||
// throw an error if the challenge failed
|
||||
throw [json.status, 'unknown error', json]
|
||||
}
|
||||
return this.waitForMessage('s2', []) // otherwise wait for the validation message
|
||||
})
|
||||
} else {
|
||||
// otherwise just chain the promise further
|
||||
return json
|
||||
}
|
||||
})
|
||||
.then((json) => {
|
||||
this.validateNewConnection(json[1]) // validate the connection
|
||||
this.log('validated connection successfully')
|
||||
this.lastSeen = new Date() // set last seen to right now
|
||||
return this.userMetaData
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
const onValidationSuccess = () => {
|
||||
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
|
||||
this.userMetaData = {
|
||||
id: json.wid.replace('@c.us', '@s.whatsapp.net'),
|
||||
name: json.pushname,
|
||||
phone: json.phone,
|
||||
}
|
||||
return this.userMetaData
|
||||
}
|
||||
|
||||
if (json.connected) {
|
||||
// only if we're connected
|
||||
if (!json.secret) {
|
||||
// if we didn't get a secret, we don't need it, we're validated
|
||||
return onValidationSuccess()
|
||||
}
|
||||
const secret = Buffer.from(json.secret, 'base64')
|
||||
if (secret.length !== 144) {
|
||||
throw [4, 'incorrect secret length: ' + 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))) {
|
||||
// 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()
|
||||
} else {
|
||||
// if the checksums didn't match
|
||||
throw [5, 'HMAC validation failed']
|
||||
}
|
||||
} else {
|
||||
// if we didn't get the connected field (usually we get this message when one opens WhatsApp on their phone)
|
||||
throw [6, 'json connection failed', json]
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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 data = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
|
||||
this.log('resolving login challenge')
|
||||
return this.query(data)
|
||||
}
|
||||
/**
|
||||
* When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends
|
||||
* @private
|
||||
*/
|
||||
protected generateKeysForAuth(ref: string) {
|
||||
this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
|
||||
this.onReadyForPhoneAuthentication([
|
||||
ref,
|
||||
Buffer.from(this.curveKeys.public).toString('base64'),
|
||||
this.authInfo.clientID,
|
||||
])
|
||||
return this.waitForMessage('s1', [])
|
||||
}
|
||||
}
|
||||
2
WAConnection/WAConnection.ts
Normal file
2
WAConnection/WAConnection.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import WAConnection from './Connect'
|
||||
export default WAConnection
|
||||
@@ -1,204 +0,0 @@
|
||||
/*
|
||||
Contains the code for sending queries to WhatsApp
|
||||
*/
|
||||
module.exports = {
|
||||
/**
|
||||
* Query whether a given number is registered on WhatsApp
|
||||
* @param {string} jid the number you want to query, format as [number]@s.whatsapp.net
|
||||
* @return {Promise<[boolean, string]>} Promise with an array [exists, jid]
|
||||
*/
|
||||
isOnWhatsApp: function (jid) {
|
||||
return this.query(["query", "exist", jid]).then (([m, _]) => [m.status === 200, jid])
|
||||
},
|
||||
/**
|
||||
* @param {string} jid the whatsapp ID of the person
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
requestPresenceUpdate: function (jid) {
|
||||
return this.query(["action","presence","subscribe",jid])
|
||||
},
|
||||
/**
|
||||
* Query the status of the person (see groupMetadata() for groups)
|
||||
* @param {string} [jid] the whatsapp ID of the person
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
getStatus: function (jid) {
|
||||
jid = jid || this.userMetaData.id
|
||||
return this.query(["query","Status",jid])
|
||||
},
|
||||
/**
|
||||
* Get the URL to download the profile picture of a person/group
|
||||
* @param {string} [jid] the whatsapp ID of the person/group (will get your own picture if not specified)
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
getProfilePicture: function (jid) {
|
||||
jid = jid || this.userMetaData.id
|
||||
return this.query(["query","ProfilePicThumb",jid])
|
||||
},
|
||||
/**
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
getContacts: function () {
|
||||
const json = ["query", {epoch: this.msgCount.toString(), type: "contacts"}, null]
|
||||
return this.query(json, [10, 128]) // this has to be an encrypted query
|
||||
},
|
||||
/**
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
getChats: function () {
|
||||
const json = ["query", {epoch: this.msgCount.toString(), type: "chat"}, null]
|
||||
return this.query(json, [10, 128]) // this has to be an encrypted query
|
||||
},
|
||||
/**
|
||||
* Check if your phone is connected
|
||||
* @param {number} timeoutMs max time for the phone to respond
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
isPhoneConnected: function (timeoutMs=5000) {
|
||||
return this.query (["admin", "test"], null, timeoutMs)
|
||||
.then (([json, q]) => json[1])
|
||||
.catch (err => false)
|
||||
},
|
||||
/**
|
||||
* Load the conversation with a group or person
|
||||
* @param {string} jid the id of the group or person
|
||||
* @param {number} count the number of messages to load
|
||||
* @param {object} [indexMessage] the data for which message to offset the query by
|
||||
* @param {string} [indexMessage.id] the id of the message
|
||||
* @param {object} [indexMessage.fromMe] whether the message was sent by yours truly
|
||||
* @param {boolean} [mostRecentFirst] retreive the most recent message first or retreive from the converation start
|
||||
* @return {Promise} Promise of the messages loaded
|
||||
*/
|
||||
loadConversation: function (jid, count, indexMessage=null, mostRecentFirst=true) {
|
||||
// construct JSON
|
||||
let json = [
|
||||
"query",
|
||||
{
|
||||
epoch: this.msgCount.toString(),
|
||||
type: "message",
|
||||
jid: jid,
|
||||
kind: mostRecentFirst ? "before" : "after",
|
||||
owner: "true",
|
||||
count: count.toString()
|
||||
},
|
||||
null
|
||||
]
|
||||
// if we have some index from which we want to query
|
||||
if (indexMessage) {
|
||||
json[1].index = indexMessage.id
|
||||
json[1].owner = indexMessage.fromMe ? "true" : "false"
|
||||
}
|
||||
return this.query(json, [10, 128])
|
||||
},
|
||||
/**
|
||||
* Load the entire friggin conversation with a group or person
|
||||
* @param {string} jid the id of the group or person
|
||||
* @param {function} onMessage callback for every message retreived
|
||||
* @param {number} [chunkSize] the number of messages to load in a single request
|
||||
* @param {boolean} [mostRecentFirst] retreive the most recent message first or retreive from the converation start
|
||||
*/
|
||||
loadEntireConversation: function (jid, onMessage, chunkSize=25, mostRecentFirst=true) {
|
||||
var offsetID = null
|
||||
|
||||
const loadMessage = () => {
|
||||
return this.loadConversation(jid, chunkSize, offsetID, mostRecentFirst)
|
||||
.then (([json]) => {
|
||||
if (json[2]) {
|
||||
// callback with most recent message first (descending order of date)
|
||||
let lastMessage
|
||||
if (mostRecentFirst) {
|
||||
for (var i = json[2].length-1; i >= 0;i--) {
|
||||
onMessage(json[2][i][2])
|
||||
lastMessage = json[2][i][2]
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < json[2].length;i++) {
|
||||
onMessage(json[2][i][2])
|
||||
lastMessage = json[2][i][2]
|
||||
}
|
||||
}
|
||||
// if there are still more messages
|
||||
if (json[2].length >= chunkSize) {
|
||||
offsetID = lastMessage.key // get the last message
|
||||
return new Promise ((resolve, reject) => {
|
||||
// send query after 200 ms
|
||||
setTimeout( () => loadMessage().then (resolve).catch(reject), 200)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return loadMessage()
|
||||
},
|
||||
/**
|
||||
* Get the metadata of the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
groupMetadata: function (jid) {
|
||||
return this.query (["query", "GroupMetadata", jid])
|
||||
},
|
||||
/**
|
||||
* Create a group
|
||||
* @param {string} title like, the title of the group
|
||||
* @param {string[]} participants people to include in the group
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
groupCreate: function (title, participants) {
|
||||
return this.groupQuery ("create", null, title, participants)
|
||||
},
|
||||
/**
|
||||
* Leave a group
|
||||
* @param {string} jid the ID of the group
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
groupLeave: function (jid) {
|
||||
return this.groupQuery ("leave", jid)
|
||||
},
|
||||
/**
|
||||
* Update the title of the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string} title the new title of the group
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
groupUpdateTitle: function (jid, title) {
|
||||
return this.groupQuery ("subject", jid, title)
|
||||
},
|
||||
/**
|
||||
* Add somebody to the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string[]} participants the people to add
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
groupAdd: function (jid, participants) {
|
||||
return this.groupQuery ("add", jid, null, participants)
|
||||
},
|
||||
/**
|
||||
* Remove somebody from the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string[]} participants the people to remove
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
groupRemove: function (jid, participants) {
|
||||
return this.groupQuery ("remove", jid, null, participants)
|
||||
},
|
||||
/**
|
||||
* Make someone admin on the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @param {string[]} participants the people to make admin
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
groupMakeAdmin: function (jid, participants) {
|
||||
return this.groupQuery ("promote", jid, null, participants)
|
||||
},
|
||||
/**
|
||||
* Get the invite link of the group
|
||||
* @param {string} jid the ID of the group
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
groupInviteCode: function (jid) {
|
||||
const json = ["query", "inviteCode", jid]
|
||||
return this.query (json).then (([json, _]) => json.code)
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
const Utils = require("./WhatsAppWeb.Utils")
|
||||
const fs = require("fs")
|
||||
const fetch = require("node-fetch")
|
||||
/*
|
||||
Contains the code for recieving messages and forwarding what to do with them to the correct functions
|
||||
*/
|
||||
module.exports = {
|
||||
/**
|
||||
* Called when a message is recieved on the socket
|
||||
* @private
|
||||
* @param {string|buffer} message
|
||||
* @param {function(any)} reject
|
||||
*/
|
||||
onMessageRecieved: function (message) {
|
||||
if (message[0] === "!") { // when the first character in the message is an '!', the server is updating the last seen
|
||||
const timestamp = message.slice(1,message.length)
|
||||
this.lastSeen = new Date( parseInt(timestamp) )
|
||||
} else {
|
||||
const commaIndex = message.indexOf(",") // all whatsapp messages have a tag and a comma, followed by the actual message
|
||||
|
||||
if (commaIndex < 0) { // if there was no comma, then this message must be not be valid
|
||||
return this.gotError([2, "invalid message", message])
|
||||
}
|
||||
|
||||
var 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 = message.slice(0, commaIndex).toString ()
|
||||
if (data.length === 0) {
|
||||
// got an empty message, usually get one after sending a query with the 128 tag
|
||||
return
|
||||
}
|
||||
|
||||
let json
|
||||
if (data[0] === "[" || data[0] === "{") { // if the first character is a "[", then the data must just be plain JSON array or object
|
||||
json = JSON.parse( data ) // parse the JSON
|
||||
} else if (this.authInfo.macKey && this.authInfo.encKey) {
|
||||
/*
|
||||
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
|
||||
*/
|
||||
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 = Utils.hmacSign(data, this.authInfo.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 = Utils.aesDecrypt(data, this.authInfo.encKey) // decrypt using AES
|
||||
json = this.decoder.read( decrypted ) // decode the binary message into a JSON array
|
||||
} else {
|
||||
this.unexpectedDisconnect([7, "checksums don't match"])
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// if we recieved a message that was encrypted but we don't have the keys, then there must be an error
|
||||
this.unexpectedDisconnect([3, "recieved encrypted message when auth creds not available", message])
|
||||
return
|
||||
}
|
||||
/*
|
||||
Check if this is a response to a message we sent
|
||||
*/
|
||||
if (this.callbacks[messageTag]) {
|
||||
const q = this.callbacks[messageTag]
|
||||
//console.log (messageTag + ", " + q.queryJSON)
|
||||
q.callback([json, q.queryJSON])
|
||||
delete this.callbacks[messageTag]
|
||||
return
|
||||
}
|
||||
/*
|
||||
Check if this is a response to a message we are expecting
|
||||
*/
|
||||
if (this.callbacks["function:" + json[0]]) {
|
||||
let callbacks = this.callbacks["function:" + json[0]]
|
||||
var callbacks2
|
||||
var callback
|
||||
for (var key in json[1] || {}) {
|
||||
callbacks2 = callbacks[key + ":" + json[1][key]]
|
||||
if (callbacks2) { break }
|
||||
}
|
||||
if (!callbacks2) {
|
||||
for (var key in json[1] || {}) {
|
||||
callbacks2 = callbacks[key]
|
||||
if (callbacks2) { break }
|
||||
}
|
||||
}
|
||||
if (!callbacks2) {
|
||||
callbacks2 = callbacks[""]
|
||||
}
|
||||
if (callbacks2) {
|
||||
callback = callbacks2[ json[2] && json[2][0][0] ]
|
||||
if (!callback) {
|
||||
callback = callbacks2[""]
|
||||
}
|
||||
}
|
||||
if (callback) {
|
||||
callback (json)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.logUnhandledMessages) {
|
||||
this.log("[Unhandled] " + messageTag + ", " + JSON.stringify(json))
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Type of notification
|
||||
* @param {object} message
|
||||
* @param {object} [message.message] should be present for actual encrypted messages
|
||||
* @param {object} [message.messageStubType] should be present for group add, leave etc. notifications
|
||||
* @return {[string, string]} [type of notification, specific type of message]
|
||||
*/
|
||||
getNotificationType: function (message) {
|
||||
const MessageStubTypes = {
|
||||
20: "addedToGroup",
|
||||
32: "leftGroup",
|
||||
39: "createdGroup"
|
||||
}
|
||||
if (message.message) {
|
||||
return ["message", Object.keys(message.message)[0]]
|
||||
} else if (message.messageStubType) {
|
||||
return [MessageStubTypes[message.messageStubType] , null]
|
||||
} else {
|
||||
return ["unknown", null]
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Register for a callback for a certain function, will cancel automatically after one execution
|
||||
* @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters
|
||||
* @return {promise<object>} when the function is received
|
||||
*/
|
||||
registerCallbackOneTime: function (parameters) {
|
||||
return new Promise ((resolve, reject) => this.registerCallback (parameters, resolve))
|
||||
.finally (json => {
|
||||
this.deregisterCallback (parameters)
|
||||
return json
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Register for a callback for a certain function
|
||||
* @param {[string, string, string] | string} parameters name of the function along with some optional specific parameters
|
||||
* @param {function(any)} callback
|
||||
*/
|
||||
registerCallback: function (parameters, callback) {
|
||||
if (typeof parameters === "string") {
|
||||
return this.registerCallback ([parameters], callback)
|
||||
}
|
||||
if (!Array.isArray (parameters)) {
|
||||
throw "parameters (" + parameters + ") must be a string or array"
|
||||
}
|
||||
const func = "function:" + parameters[0]
|
||||
const key = parameters[1] || ""
|
||||
const key2 = parameters[2] || ""
|
||||
if (!this.callbacks[func]) {
|
||||
this.callbacks[func] = {}
|
||||
}
|
||||
if (!this.callbacks[func][key]) {
|
||||
this.callbacks[func][key] = {}
|
||||
}
|
||||
this.callbacks[func][key][key2] = callback
|
||||
},
|
||||
/**
|
||||
* Cancel all further callback events associated with the given parameters
|
||||
* @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters
|
||||
*/
|
||||
deregisterCallback: function (parameters) {
|
||||
if (typeof parameters === "string") {
|
||||
return this.deregisterCallback ([parameters])
|
||||
}
|
||||
if (!Array.isArray (parameters)) {
|
||||
throw "parameters (" + parameters + ") must be a string or array"
|
||||
}
|
||||
const func = "function:" + parameters[0]
|
||||
const key = parameters[1] || ""
|
||||
const key2 = parameters[2] || ""
|
||||
if (this.callbacks[func] && this.callbacks[func][key] && this.callbacks[func][key][key2]) {
|
||||
delete this.callbacks[func][key][key2]
|
||||
return
|
||||
}
|
||||
this.log ("WARNING: could not find " + JSON.stringify (parameters) + " to deregister")
|
||||
},
|
||||
/**
|
||||
* Wait for a message with a certain tag to be received
|
||||
* @param {string} tag the message tag to await
|
||||
* @param {object} [json] query that was sent
|
||||
* @param {number} [timeoutMs] timeout after which the promise will reject
|
||||
*/
|
||||
waitForMessage: function (tag, json, timeoutMs) {
|
||||
const promise = new Promise((resolve, reject) =>
|
||||
this.callbacks[tag] = {queryJSON: json, callback: resolve, errCallback: reject})
|
||||
if (timeoutMs) {
|
||||
return Utils.promiseTimeout (timeoutMs, promise)
|
||||
.catch (err => {
|
||||
delete this.callbacks[tag]
|
||||
throw err
|
||||
})
|
||||
} else {
|
||||
return promise
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Decode a media message (video, image, document, audio) & save it to the given file
|
||||
* @param {object} message the media message you want to decode
|
||||
* @param {string} filename the name of the file where the media will be saved
|
||||
* @return {Promise<Object>} promise once the file is successfully saved, with the metadata
|
||||
*/
|
||||
decodeMediaMessage: async function (message, filename) {
|
||||
const getExtension = (mimetype) => mimetype.split(";")[0].split("/")[1]
|
||||
|
||||
/*
|
||||
One can infer media type from the key in the message
|
||||
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
|
||||
*/
|
||||
let type = Object.keys(message)[0]
|
||||
if (!type) {
|
||||
throw "unknown message type"
|
||||
}
|
||||
if (type === "extendedTextMessage" || type === "conversation") {
|
||||
throw "cannot decode text message"
|
||||
}
|
||||
if (type === "locationMessage" || type === "liveLocationMessage") {
|
||||
fs.writeFileSync (filename + ".jpeg", message[type].jpegThumbnail)
|
||||
return {filename: filename + ".jpeg"}
|
||||
}
|
||||
|
||||
message = message[type]
|
||||
// get the keys to decrypt the message
|
||||
const mediaKeys = Utils.getMediaKeys(Buffer.from(message.mediaKey, 'base64'), type)
|
||||
const iv = mediaKeys.iv
|
||||
const cipherKey = mediaKeys.cipherKey
|
||||
const macKey = mediaKeys.macKey
|
||||
|
||||
// download the message
|
||||
const fetched = await fetch(message.url)
|
||||
const buffer = await fetched.buffer()
|
||||
// first part is actual file
|
||||
let file = buffer.slice(0, buffer.length-10)
|
||||
// last 10 bytes is HMAC sign of file
|
||||
let mac = buffer.slice(buffer.length-10, buffer.length)
|
||||
|
||||
// sign IV+file & check for match with mac
|
||||
let testBuff = Buffer.concat([iv, file])
|
||||
let sign = Utils.hmacSign(testBuff, macKey).slice(0, 10)
|
||||
// our sign should equal the mac
|
||||
if (sign.equals(mac)) {
|
||||
let decrypted = Utils.aesDecryptWithIV(file, cipherKey, iv) // decrypt media
|
||||
|
||||
const trueFileName = filename + "." + getExtension(message.mimetype)
|
||||
fs.writeFileSync(trueFileName, decrypted)
|
||||
|
||||
message.filename = trueFileName
|
||||
return message
|
||||
} else {
|
||||
throw "HMAC sign does not match"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
const Utils = require("./WhatsAppWeb.Utils")
|
||||
const fetch = require('node-fetch')
|
||||
/*
|
||||
Contains the code for sending stuff to WhatsApp
|
||||
*/
|
||||
/**
|
||||
* @typedef {Object} MessageOptions
|
||||
* @property {Object} [quoted] the message you may wanna quote along with this message
|
||||
* @property {Date} [timestamp] optionally set the timestamp of the message in Unix time MS
|
||||
* @property {string} [caption] (for media messages) caption to go along with the media
|
||||
* @property {string} [thumbnail] (for media & location messages) base64 encoded thumbnail
|
||||
* @property {string} [mimetype] (for media messages) specify the Mimetype of the media, required for document messages
|
||||
* @property {boolean} [gif] (for video messages) whether the media is a gif or not
|
||||
*/
|
||||
module.exports = {
|
||||
/**
|
||||
* Send a read receipt to the given ID for a certain message
|
||||
* @param {string} jid the ID of the person/group whose message you read
|
||||
* @param {string} messageID the message ID
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
sendReadReceipt: function (jid, messageID) {
|
||||
const json = [
|
||||
"action",
|
||||
{ epoch: this.msgCount.toString(), type: "set" },
|
||||
[ ["read", {count: "1", index: messageID, jid: jid, owner: "false"}, null] ]
|
||||
]
|
||||
return this.query(json, [10, 128]) // encrypt and send off
|
||||
},
|
||||
/**
|
||||
* Tell someone about your presence -- online, typing, offline etc.
|
||||
* @param {string} jid the ID of the person/group who you are updating
|
||||
* @param {string} type your presence
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
updatePresence: function (jid, type) {
|
||||
const json = [
|
||||
"action",
|
||||
{ epoch: this.msgCount.toString(), type: "set" },
|
||||
[ ["presence", {type: type, to: jid}, null] ]
|
||||
]
|
||||
return this.query(json, [10, 64])
|
||||
},
|
||||
/**
|
||||
* Send a text message
|
||||
* @param {string} id the JID of the person/group you're sending the message to
|
||||
* @param {string} txt the actual text of the message
|
||||
* @param {MessageOptions} [options] some additional options
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
sendTextMessage: function (id, txt, options={}) {
|
||||
if (typeof txt !== "string") {
|
||||
return Promise.reject("expected text to be a string")
|
||||
}
|
||||
let message
|
||||
if (options.quoted) {
|
||||
message = {extendedTextMessage: { text: txt }}
|
||||
} else {
|
||||
message = {conversation: txt}
|
||||
}
|
||||
return this.sendMessage(id, message, options)
|
||||
},
|
||||
/**
|
||||
* Send a contact message
|
||||
* @param {string} id the JID of the person/group you're sending the message to
|
||||
* @param {string} displayName the name of the person on the contact, will be displayed on WhatsApp
|
||||
* @param {string} vcard the VCARD formatted contact
|
||||
* @param {MessageOptions} [options] some additional options
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
sendContactMessage: function (id, displayName, vcard, options={}) {
|
||||
if (typeof displayName !== "string") {
|
||||
return Promise.reject("expected text to be a string")
|
||||
}
|
||||
return this.sendMessage(id, {contactMessage: {displayName: displayName, vcard: vcard}}, options)
|
||||
},
|
||||
/**
|
||||
* Send a location message
|
||||
* @param {string} id the JID of the person/group you're sending the message to
|
||||
* @param {number} lat the latitude of the location
|
||||
* @param {number} long the longitude of the location
|
||||
* @param {MessageOptions} [options] some additional options
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
sendLocationMessage: function (id, lat, long, options={}) {
|
||||
return this.sendMessage(id, {locationMessage: {degreesLatitude: lat, degreesLongitude: long}}, options)
|
||||
},
|
||||
/**
|
||||
* Send a media message
|
||||
* @param {string} id the JID of the person/group you're sending the message to
|
||||
* @param {Buffer} buffer the buffer of the actual media you're sending
|
||||
* @param {string} mediaType the type of media, can be one of [imageMessage, documentMessage, stickerMessage, videoMessage]
|
||||
* @param {MessageOptions} [options] additional information about the message
|
||||
* @return {Promise<[object, object]>}
|
||||
*/
|
||||
sendMediaMessage: function (id, buffer, mediaType, options={}) {
|
||||
// path to upload the media
|
||||
const mediaPathMap = {
|
||||
imageMessage: "/mms/image",
|
||||
videoMessage: "/mms/video",
|
||||
documentMessage: "/mms/document",
|
||||
audioMessage: "/mms/audio",
|
||||
stickerMessage: "/mms/image"
|
||||
}
|
||||
// gives WhatsApp info to process the media
|
||||
const defaultMimetypeMap = {
|
||||
imageMessage: "image/jpeg",
|
||||
videoMessage: "video/mp4",
|
||||
documentMessage: "appliction/pdf",
|
||||
audioMessage: "audio/ogg; codecs=opus",
|
||||
stickerMessage: "image/webp"
|
||||
}
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
if (mediaType === "conversation" || mediaType === "extendedTextMessage") {
|
||||
return Promise.reject("use sendTextMessage() to send text messages")
|
||||
}
|
||||
if (mediaType === "documentMessage" && !options.mimetype) {
|
||||
return Promise.reject("mimetype required to send a document")
|
||||
}
|
||||
if (mediaType === "stickerMessage" && options.caption) {
|
||||
return Promise.reject("cannot send a caption with a sticker")
|
||||
}
|
||||
if (!options.mimetype) {
|
||||
options.mimetype = defaultMimetypeMap[mediaType]
|
||||
}
|
||||
|
||||
// generate a media key
|
||||
const mediaKey = Utils.randomBytes(32)
|
||||
const mediaKeys = Utils.getMediaKeys(mediaKey, mediaType)
|
||||
const enc = Utils.aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
|
||||
const mac = Utils.hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
|
||||
const body = Buffer.concat([enc, mac]) // body is enc + mac
|
||||
const fileSha256 = Utils.sha256(buffer)
|
||||
// url safe Base64 encode the SHA256 hash of the body
|
||||
const fileEncSha256B64 = Utils.sha256(body).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+$/, '')
|
||||
|
||||
return Utils.generateThumbnail(buffer, mediaType, options)
|
||||
.then (() => this.query(["query", "mediaConn"])) // send a query JSON to obtain the url & auth token to upload our media
|
||||
.then (([json,_]) => {
|
||||
json = json.media_conn
|
||||
const auth = json.auth // the auth token
|
||||
let hostname = "https://" + json.hosts[0].hostname // first hostname available
|
||||
hostname += mediaPathMap[mediaType] + "/" + fileEncSha256B64 // append path
|
||||
hostname += "?auth=" + auth // add auth token
|
||||
hostname += "&token=" + fileEncSha256B64 // file hash
|
||||
|
||||
return fetch(hostname, {method: 'POST', body: body, headers: {Origin: "https://web.whatsapp.com"}})
|
||||
})
|
||||
.then (res => res.json())
|
||||
.then (json => {
|
||||
if (json.url) {
|
||||
return json.url
|
||||
} else {
|
||||
throw "UPLOAD FAILED GOT: " + JSON.stringify(json)
|
||||
}
|
||||
})
|
||||
.then (url => {
|
||||
let message = {}
|
||||
message[mediaType] = {
|
||||
url: url,
|
||||
mediaKey: mediaKey.toString('base64'),
|
||||
mimetype: options.mimetype,
|
||||
fileEncSha256: fileEncSha256B64,
|
||||
fileSha256: fileSha256.toString('base64'),
|
||||
fileLength: buffer.length
|
||||
}
|
||||
if (mediaType === "videoMessage" && options.gif) {
|
||||
message[mediaType].gifPlayback = options.gif
|
||||
}
|
||||
return this.sendMessage(id, message, options)
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Generic send message function
|
||||
* @private
|
||||
* @param {string} id who to send the message to
|
||||
* @param {object} message like, the message
|
||||
* @param {MessageOptions} [options] some additional options
|
||||
* @return {Promise<[object, object]>} array of the recieved JSON & the query JSON
|
||||
*/
|
||||
sendMessage: function (id, message, options) {
|
||||
if (!options.timestamp) { // if no timestamp was provided,
|
||||
options.timestamp = new Date() // set timestamp to now
|
||||
}
|
||||
const key = Object.keys(message)[0]
|
||||
const timestamp = options.timestamp.getTime()/1000
|
||||
const quoted = options.quoted
|
||||
if (quoted) {
|
||||
const participant = quoted.key.participant || quoted.key.remoteJid
|
||||
message[key].contextInfo = {
|
||||
participant: participant,
|
||||
stanzaId: quoted.key.id,
|
||||
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.caption) {
|
||||
message[key].caption = options.caption
|
||||
}
|
||||
if (options.thumbnail) {
|
||||
if (typeof options.thumbnail !== "string") {
|
||||
return Promise.reject("expected JPEG to be a base64 encoded string")
|
||||
}
|
||||
message[key].jpegThumbnail = options.thumbnail
|
||||
}
|
||||
|
||||
let messageJSON = {
|
||||
key: {
|
||||
remoteJid: id,
|
||||
fromMe: true,
|
||||
id: Utils.generateMessageID()
|
||||
},
|
||||
message: message,
|
||||
messageTimestamp: timestamp,
|
||||
status: "ERROR"
|
||||
}
|
||||
|
||||
if (id.includes ("@g.us")) {
|
||||
messageJSON.participant = this.userMetaData.id
|
||||
}
|
||||
const json = [
|
||||
"action",
|
||||
{epoch: this.msgCount.toString(), type: "relay"},
|
||||
[ ["message", null, messageJSON] ]
|
||||
]
|
||||
return this.query(json, [16, 128], null, messageJSON.key.id)
|
||||
},
|
||||
/**
|
||||
* Generic function for group queries
|
||||
* @param {string} type the type of query
|
||||
* @param {string} [jid] the id of the group
|
||||
* @param {string} [subject] title to attach to the group
|
||||
* @param {string[]} [participants] the people the query will affect
|
||||
* @return {Promise<[object, object]>} array of the recieved JSON & the query JSON
|
||||
*/
|
||||
groupQuery: function (type, jid, subject, participants) {
|
||||
let json = [
|
||||
"group",
|
||||
{
|
||||
author: this.userMetaData.id,
|
||||
id: Utils.generateMessageTag(),
|
||||
type: type
|
||||
},
|
||||
null
|
||||
]
|
||||
if (participants) {
|
||||
json[2] = participants.map (str => ["participant", {jid: str}, null])
|
||||
}
|
||||
if (jid) {
|
||||
json[1].jid = jid
|
||||
}
|
||||
if (subject) {
|
||||
json[1].subject = subject
|
||||
}
|
||||
json = [
|
||||
"action",
|
||||
{type: "set", epoch: this.msgCount.toString()},
|
||||
[json]
|
||||
]
|
||||
return this.query (json, [10, 128])
|
||||
},
|
||||
/**
|
||||
* Query something from the WhatsApp servers
|
||||
* @param {any[]} json the query itself
|
||||
* @param {[number, number]} [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary
|
||||
* @param {Number} [timeoutMs] timeout after which the query will be failed (set to null to disable a timeout)
|
||||
* @param {string} [tag] the tag to attach to the message
|
||||
* @return {Promise<[object, object]>} array of the recieved JSON & the query JSON
|
||||
*/
|
||||
query: function (json, binaryTags, timeoutMs, tag) {
|
||||
if (binaryTags) {
|
||||
tag = this.sendBinary(json, binaryTags, tag)
|
||||
} else {
|
||||
tag = this.sendJSON(json, tag)
|
||||
}
|
||||
return this.waitForMessage (tag, json, timeoutMs)
|
||||
},
|
||||
/**
|
||||
* Send a binary encoded message
|
||||
* @private
|
||||
* @param {[string, object, [string, object, object][]]} json the message to encode & send
|
||||
* @param {[number, number]} tags the binary tags to tell WhatsApp what the message is all about
|
||||
* @param {string} [tag] the tag to attach to the message
|
||||
* @return {string} the message tag
|
||||
*/
|
||||
sendBinary: function (json, tags, tag) {
|
||||
const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format
|
||||
|
||||
var 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 || Utils.generateMessageTag()
|
||||
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
|
||||
])
|
||||
this.send(buff) // send it off
|
||||
return tag
|
||||
},
|
||||
/**
|
||||
* Send a plain JSON message to the WhatsApp servers
|
||||
* @private
|
||||
* @param {[any]} json the message to send
|
||||
* @param {string} [tag] the tag to attach to the message
|
||||
* @return {string} the message tag
|
||||
*/
|
||||
sendJSON: function (json, tag) {
|
||||
const str = JSON.stringify(json)
|
||||
tag = tag || Utils.generateMessageTag()
|
||||
this.send(tag + "," + str)
|
||||
return tag
|
||||
},
|
||||
/**
|
||||
* Send some message to the WhatsApp servers
|
||||
* @private
|
||||
* @param {any} json the message to send
|
||||
*/
|
||||
send: function (m) {
|
||||
this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages
|
||||
this.conn.send( m )
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
const WebSocket = require('ws')
|
||||
const Curve = require ('curve25519-js')
|
||||
const Utils = require('./WhatsAppWeb.Utils')
|
||||
/*
|
||||
Contains the code for connecting to WhatsApp Web, establishing a new session & logging back in
|
||||
*/
|
||||
module.exports = {
|
||||
/**
|
||||
* Connect to WhatsAppWeb
|
||||
* @param {Object} [authInfo] credentials to log back in
|
||||
* @param {number} [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
|
||||
* @return {[object, object[], object[], object[]]} returns [userMetaData, chats, contacts, unreadMessages]
|
||||
*/
|
||||
connect: async function (authInfo, timeoutMs) {
|
||||
const userInfo = await this.connectSlim (authInfo, timeoutMs)
|
||||
const chats = await this.receiveChatsAndContacts (authInfo, timeoutMs)
|
||||
return [userInfo, ...chats]
|
||||
},
|
||||
/**
|
||||
* Connect to WhatsAppWeb, resolves without waiting for chats & contacts
|
||||
* @param {Object} [authInfo] credentials to log back in
|
||||
* @param {number} [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
|
||||
* @return {Promise<Object>} returns [userMetaData, chats, contacts, unreadMessages]
|
||||
*/
|
||||
connectSlim: function (authInfo, timeoutMs) {
|
||||
// set authentication credentials if required
|
||||
if (authInfo) {
|
||||
this.authInfo = Object.assign ({}, authInfo) // copy credentials
|
||||
this.authInfo.encKey = Buffer.from(authInfo.encKey, 'base64') // decode from base64
|
||||
this.authInfo.macKey = Buffer.from(authInfo.macKey, 'base64')
|
||||
}
|
||||
// if we're already connected, throw an error
|
||||
if (this.conn) {
|
||||
return Promise.reject([1, "already connected or connecting"])
|
||||
}
|
||||
this.conn = new WebSocket("wss://web.whatsapp.com/ws", {origin: "https://web.whatsapp.com"})
|
||||
|
||||
let promise = new Promise ( (resolve, reject) => {
|
||||
this.conn.on('open', () => {
|
||||
this.conn.on('message', m => this.onMessageRecieved(m)) // in WhatsAppWeb.Recv.js
|
||||
this.beginAuthentication ().then (resolve).catch (reject)
|
||||
})
|
||||
this.conn.on('error', error => { // if there was an error in the WebSocket
|
||||
this.close()
|
||||
reject (error)
|
||||
})
|
||||
})
|
||||
promise = timeoutMs ? Utils.promiseTimeout (timeoutMs, promise) : promise
|
||||
return promise.catch (err => {this.close (); throw err;})
|
||||
},
|
||||
/**
|
||||
* Once a connection has been successfully established
|
||||
* @private
|
||||
* @return {promise<object[]>}
|
||||
*/
|
||||
beginAuthentication: function () {
|
||||
this.log("connected to WhatsApp Web")
|
||||
|
||||
if (!this.authInfo.clientID) { // if no auth info is present, that is, a new session has to be established
|
||||
this.authInfo = { clientID: Utils.generateClientID() } // generate a client ID
|
||||
}
|
||||
|
||||
const data = ["admin", "init", this.version, this.browserDescriptions, this.authInfo.clientID, true]
|
||||
return this.query(data)
|
||||
.then (([json, _]) => {
|
||||
// we're trying to establish a new connection or are trying to log in
|
||||
switch (json.status) {
|
||||
case 200: // all good and we can procede to generate a QR code for new connection, or can now login given present auth info
|
||||
if (this.authInfo.encKey && this.authInfo.macKey) { // if we have the info to restore a closed session
|
||||
const data = ["admin", "login", this.authInfo.clientToken, this.authInfo.serverToken, this.authInfo.clientID, "takeover"]
|
||||
return this.query(data, null, null, "s1") // wait for response with tag "s1"
|
||||
} else {
|
||||
return this.generateKeysForAuth(json.ref)
|
||||
}
|
||||
default:
|
||||
throw [json.status, "unknown error", json]
|
||||
}
|
||||
})
|
||||
.then (([json, q]) => {
|
||||
switch (json.status) {
|
||||
case 401: // if the phone was unpaired
|
||||
throw [json.status, "unpaired from phone", json]
|
||||
case 429: // request to login was denied, don't know why it happens
|
||||
throw [json.status, "request denied, try reconnecting", json]
|
||||
case 304: // request to generate a new key for a QR code was denied
|
||||
throw [json.status, "request for new key denied", json]
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (json[1] && json[1].challenge) { // if its a challenge request (we get it when logging in)
|
||||
return this.respondToChallenge(json[1].challenge)
|
||||
.then (([json, _]) => {
|
||||
if (json.status !== 200) { // throw an error if the challenge failed
|
||||
throw [json.status, "unknown error", json]
|
||||
}
|
||||
return this.waitForMessage ("s2", []) // otherwise wait for the validation message
|
||||
})
|
||||
} else { // otherwise just chain the promise further
|
||||
return [json, q]
|
||||
}
|
||||
})
|
||||
.then (([json, _]) => {
|
||||
this.validateNewConnection (json[1])
|
||||
this.log("validated connection successfully")
|
||||
this.lastSeen = new Date() // set last seen to right now
|
||||
this.startKeepAliveRequest() // start sending keep alive requests (keeps the WebSocket alive & updates our last seen)
|
||||
}) // validate the connection
|
||||
.then (() => {
|
||||
this.log("connected successfully")
|
||||
return this.userMetaData
|
||||
})
|
||||
},
|
||||
/**
|
||||
* Sets up callbacks to receive chats, contacts & unread messages.
|
||||
* Must be called immediately after connect
|
||||
* @returns {[ object[], object[], object[] ]} - [chats, contacts, unreadMessages]
|
||||
*/
|
||||
receiveChatsAndContacts: function () {
|
||||
let chats = []
|
||||
let contacts = []
|
||||
let unreadMessages = []
|
||||
let unreadMap = {}
|
||||
|
||||
let encounteredAddBefore = false
|
||||
var convoResolve
|
||||
|
||||
this.log ("waiting for chats & contacts") // wait for the message with chats
|
||||
const waitForConvos = () => new Promise ((resolve, _) => {
|
||||
convoResolve = resolve
|
||||
const chatUpdate = (json) => {
|
||||
const isLast = json[1].last
|
||||
encounteredAddBefore = json[1].add === "before" ? true : encounteredAddBefore
|
||||
|
||||
json = json[2]
|
||||
if (json) {
|
||||
for (var k = json.length-1;k >= 0;k--) {
|
||||
const message = json[k][2]
|
||||
const jid = message.key.remoteJid.replace ("@s.whatsapp.net", "@c.us")
|
||||
if (!message.key.fromMe && unreadMap[jid] > 0) { // only forward if the message is from the sender
|
||||
unreadMessages.push (message)
|
||||
unreadMap[jid] -= 1 // reduce
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLast) {
|
||||
// de-register the callbacks, so that they don't get called again
|
||||
this.deregisterCallback (["action", "add:last"])
|
||||
this.deregisterCallback (["action", "add:before"])
|
||||
this.deregisterCallback (["action", "add:unread"])
|
||||
resolve ()
|
||||
}
|
||||
}
|
||||
// wait for actual messages to load, "last" is the most recent message, "before" contains prior messages
|
||||
this.registerCallback (["action", "add:last"], chatUpdate)
|
||||
this.registerCallback (["action", "add:before"], chatUpdate)
|
||||
this.registerCallback (["action", "add:unread"], chatUpdate)
|
||||
})
|
||||
const waitForChats = this.registerCallbackOneTime (["response", "type:chat"])
|
||||
.then (json => {
|
||||
chats = json[2] // chats data (log json to see what it looks like)
|
||||
chats.forEach (chat => unreadMap [chat[1].jid] = chat[1].count) // store the number of unread messages for each sender
|
||||
if (chats && chats.length > 0) return waitForConvos ()
|
||||
})
|
||||
const waitForContacts = this.registerCallbackOneTime (["response", "type:contacts"])
|
||||
.then (json => {
|
||||
contacts = json[2]
|
||||
// if no add:before messages are sent, and you receive contacts
|
||||
// should probably resolve the promise
|
||||
if (!encounteredAddBefore && convoResolve) convoResolve ()
|
||||
})
|
||||
// wait for the chats & contacts to load
|
||||
return Promise.all ([waitForChats, waitForContacts]).then (() => [chats, contacts, unreadMessages])
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
validateNewConnection: function (json) {
|
||||
const onValidationSuccess = () => {
|
||||
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
|
||||
this.userMetaData = {id: json.wid.replace("@c.us", "@s.whatsapp.net"), name: json.pushname, phone: json.phone}
|
||||
return this.userMetaData
|
||||
}
|
||||
|
||||
if (json.connected) { // only if we're connected
|
||||
if (!json.secret) { // if we didn't get a secret, we don't need it, we're validated
|
||||
return onValidationSuccess()
|
||||
}
|
||||
const secret = Buffer.from(json.secret, 'base64')
|
||||
if (secret.length !== 144) {
|
||||
throw [4, "incorrect secret length: " + 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, 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))) { // 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()
|
||||
} else { // if the checksums didn't match
|
||||
throw [5, "HMAC validation failed"]
|
||||
}
|
||||
} else { // if we didn't get the connected field (usually we get this message when one opens WhatsApp on their phone)
|
||||
throw [6, "json connection failed", json]
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
* @private
|
||||
*/
|
||||
respondToChallenge: function (challenge) {
|
||||
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 data = ["admin", "challenge", signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
|
||||
this.log("resolving login challenge")
|
||||
return this.query(data)
|
||||
},
|
||||
/**
|
||||
* When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends
|
||||
* @private
|
||||
*/
|
||||
generateKeysForAuth: function (ref) {
|
||||
this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
|
||||
const phoneAuthInfo = [ref, Buffer.from(this.curveKeys.public).toString('base64'), this.authInfo.clientID]
|
||||
this.onReadyForPhoneAuthentication (phoneAuthInfo)
|
||||
return this.waitForMessage ("s1", [])
|
||||
},
|
||||
/**
|
||||
* Send a keep alive request every X seconds, server updates & responds with last seen
|
||||
* @private
|
||||
*/
|
||||
startKeepAliveRequest: function () {
|
||||
const refreshInterval = 20
|
||||
this.keepAliveReq = setInterval(() => {
|
||||
const diff = (new Date().getTime()-this.lastSeen.getTime())/1000
|
||||
/*
|
||||
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, or the phone got unpaired from our connection
|
||||
*/
|
||||
if (diff > refreshInterval+5) {
|
||||
this.close()
|
||||
|
||||
if (this.autoReconnect) { // attempt reconnecting if the user wants us to
|
||||
this.log("disconnected unexpectedly, reconnecting...")
|
||||
const reconnectLoop = () => this.connect (null, 25*1000).catch (reconnectLoop)
|
||||
reconnectLoop () // keep trying to connect
|
||||
} else {
|
||||
this.unexpectedDisconnect ("lost connection unexpectedly")
|
||||
}
|
||||
} else { // if its all good, send a keep alive request
|
||||
this.send("?,,")
|
||||
}
|
||||
}, refreshInterval * 1000)
|
||||
},
|
||||
/**
|
||||
* Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request.
|
||||
* Use close() if you just want to close the connection
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
logout: function () {
|
||||
return new Promise ( (resolve, reject) => {
|
||||
if (this.conn) {
|
||||
this.conn.send('goodbye,["admin","Conn","disconnect"]', null, () => {
|
||||
this.authInfo = {}
|
||||
resolve ()
|
||||
})
|
||||
} else {
|
||||
reject("You're not even connected, you can't log out")
|
||||
}
|
||||
})
|
||||
.then (() => this.close ())
|
||||
},
|
||||
/** Close the connection to WhatsApp Web */
|
||||
close: function () {
|
||||
this.msgCount = 0
|
||||
if (this.conn) {
|
||||
this.conn.close()
|
||||
this.conn = null
|
||||
}
|
||||
const keys = Object.keys (this.callbacks)
|
||||
keys.forEach (key => {
|
||||
if (!key.includes ("function:")) {
|
||||
this.callbacks[key].errCallback ("connection closed")
|
||||
delete this.callbacks[key]
|
||||
}
|
||||
} )
|
||||
if (this.keepAliveReq) {
|
||||
clearInterval(this.keepAliveReq)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
const Crypto = require("crypto")
|
||||
const HKDF = require("futoin-hkdf")
|
||||
const sharp = require("sharp")
|
||||
const VideoThumb = require("video-thumb")
|
||||
const fs = require("fs")
|
||||
|
||||
/*
|
||||
Basic cryptographic utilities to interact with WhatsApp servers
|
||||
*/
|
||||
module.exports = {
|
||||
// decrypt AES 256 CBC; where the IV is prefixed to the buffer
|
||||
aesDecrypt: function (buffer, key) {
|
||||
return this.aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0,16))
|
||||
},
|
||||
// decrypt AES 256 CBC
|
||||
aesDecryptWithIV: function (buffer, key, IV) {
|
||||
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
|
||||
aesEncrypt: function (buffer, key) {
|
||||
const IV = this.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
|
||||
aesEncrypWithIV: function (buffer, key, IV) {
|
||||
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
|
||||
hmacSign: function (buffer, key) {
|
||||
return Crypto.createHmac('sha256', key).update(buffer).digest()
|
||||
},
|
||||
sha256: function (buffer) {
|
||||
return Crypto.createHash('sha256').update(buffer).digest()
|
||||
},
|
||||
// HKDF key expansion
|
||||
hkdf: function (buffer, expandedLength, info) {
|
||||
return HKDF(buffer, expandedLength, {salt: Buffer.alloc(32), info: info, hash: 'SHA-256'})
|
||||
},
|
||||
// generates all the keys required to encrypt/decrypt & sign a media message
|
||||
getMediaKeys: function (buffer, mediaType) {
|
||||
// info to put into the HKDF key expansion
|
||||
const appInfo = {
|
||||
'imageMessage': 'WhatsApp Image Keys',
|
||||
'videoMessage': 'WhatsApp Video Keys',
|
||||
'audioMessage': 'WhatsApp Audio Keys',
|
||||
'documentMessage': 'WhatsApp Document Keys',
|
||||
'stickerMessage': 'WhatsApp Image Keys'
|
||||
}
|
||||
// expand using HKDF to 112 bytes, also pass in the relevant app info
|
||||
const expandedMediaKey = this.hkdf(buffer, 112, appInfo[mediaType])
|
||||
return {
|
||||
iv: expandedMediaKey.slice(0, 16),
|
||||
cipherKey: expandedMediaKey.slice(16, 48),
|
||||
macKey: expandedMediaKey.slice(48, 80)
|
||||
}
|
||||
},
|
||||
// generates a thumbnail for a given media, if required
|
||||
generateThumbnail: function (buffer, mediaType, info) {
|
||||
let promise
|
||||
if (info.thumbnail === null || info.thumbnail) { // don't do anything if the thumbnail is already provided, or is null
|
||||
if (mediaType === 'audioMessage') {
|
||||
promise = Promise.reject("audio messages cannot have thumbnails")
|
||||
} else {
|
||||
promise = Promise.resolve()
|
||||
}
|
||||
} else {
|
||||
if (mediaType === 'imageMessage' || mediaType === 'stickerMessage') {
|
||||
promise = sharp(buffer) // generate a 48x48 thumb
|
||||
.resize(48, 48)
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
.then (buffer => info.thumbnail = buffer.toString('base64'))
|
||||
} else if (mediaType === 'videoMessage') {
|
||||
const filename = "./" + this.randomBytes(5).toString("hex") + ".mp4"
|
||||
fs.writeFileSync(filename, buffer)
|
||||
|
||||
promise = new Promise ( (resolve, reject) => {
|
||||
VideoThumb.extract (filename, filename + ".png", "00:00:00", "48x48", (err) => {
|
||||
if (err) {
|
||||
console.log("could not generate video thumb: " + err)
|
||||
resolve()
|
||||
} else {
|
||||
const buff = fs.readFileSync(filename + ".png")
|
||||
return sharp(buff)
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
.then (buffer => info.thumbnail = buffer.toString('base64'))
|
||||
.then (() => {
|
||||
fs.unlinkSync(filename)
|
||||
fs.unlinkSync(filename + ".png")
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
promise = Promise.resolve()
|
||||
}
|
||||
}
|
||||
return promise
|
||||
},
|
||||
// generate a buffer with random bytes of the specified length
|
||||
randomBytes: function (length) { return Crypto.randomBytes(length) },
|
||||
promiseTimeout: function(ms, promise) {
|
||||
// Create a promise that rejects in <ms> milliseconds
|
||||
let timeout = new Promise((_, reject) => {
|
||||
let id = setTimeout(() => {
|
||||
clearTimeout(id)
|
||||
reject('Timed out')
|
||||
}, ms)
|
||||
})
|
||||
return Promise.race([promise, timeout])
|
||||
},
|
||||
// whatsapp requires a message tag for every message, we just use the timestamp as one
|
||||
generateMessageTag: function () { return new Date().getTime().toString() },
|
||||
// generate a random 16 byte client ID
|
||||
generateClientID: function () { return this.randomBytes(16).toString('base64') },
|
||||
// generate a random 10 byte ID to attach to a message
|
||||
generateMessageID: function () { return this.randomBytes(10).toString('hex').toUpperCase() }
|
||||
}
|
||||
275
WhatsAppWeb.js
275
WhatsAppWeb.js
@@ -1,275 +0,0 @@
|
||||
const BinaryCoding = require('./binary_coding/binary_encoder.js')
|
||||
const QR = require('qrcode-terminal')
|
||||
const Utils = require('./WhatsAppWeb.Utils')
|
||||
|
||||
/**
|
||||
* @typedef WhatsAppMessage
|
||||
* @property {Object} key metadata about the sender
|
||||
* @property {string} key.remoteJid sender ID (could be group or individual)
|
||||
* @property {bool} key.fromMe
|
||||
* @property {string} key.id ID of the message
|
||||
* @property {string} [key.participant] if its a group, which individual sent it
|
||||
* @property {Object} [message] the actual message
|
||||
* @property {string} messageTimestamp unix timestamp
|
||||
* @property {number} [duration] the duration of the live location
|
||||
*/
|
||||
|
||||
class WhatsAppWeb {
|
||||
/**
|
||||
* set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send
|
||||
*/
|
||||
static Presence = {
|
||||
available: "available", // "online"
|
||||
unavailable: "unavailable", // "offline"
|
||||
composing: "composing", // "typing..."
|
||||
recording: "recording", // "recording..."
|
||||
paused: "paused" // I have no clue
|
||||
}
|
||||
/**
|
||||
* Status of a message sent or received
|
||||
*/
|
||||
static MessageStatus = {
|
||||
sent: "sent",
|
||||
received: "received",
|
||||
read: "read"
|
||||
}
|
||||
/**
|
||||
* set of message types that are supported by the library
|
||||
*/
|
||||
static MessageType = {
|
||||
text: "conversation",
|
||||
image: "imageMessage",
|
||||
video: "videoMessage",
|
||||
sticker: "stickerMessage",
|
||||
document: "documentMessage",
|
||||
audio: "audioMessage",
|
||||
extendedText: "extendedTextMessage",
|
||||
contact: "contactMessage",
|
||||
location: "locationMessage",
|
||||
liveLocation: "liveLocationMessage"
|
||||
}
|
||||
/**
|
||||
* Tells us what kind of message it is
|
||||
*/
|
||||
static MessageStubTypes = {
|
||||
20: "addedToGroup",
|
||||
32: "leftGroup",
|
||||
39: "createdGroup"
|
||||
}
|
||||
|
||||
constructor() {
|
||||
/** The version of WhatsApp Web we're telling the servers we are */
|
||||
this.version = [0,4,1296]
|
||||
this.browserDescriptions = ["Baileys", "Baileys"]
|
||||
/** The websocket connection
|
||||
* @private
|
||||
*/
|
||||
this.conn = null
|
||||
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */
|
||||
this.authInfo = {
|
||||
clientID: null,
|
||||
serverToken: null,
|
||||
clientToken: null,
|
||||
encKey: null,
|
||||
macKey: null
|
||||
}
|
||||
/** Metadata like WhatsApp id, name set on WhatsApp etc. */
|
||||
this.userMetaData = {id: null, name: null, phone: null}
|
||||
/** @private */
|
||||
this.msgCount = 0 // (epoch) number of messages sent to the server; required field for sending messages etc.
|
||||
/** Shoud reconnect automatically after an unexpected disconnect */
|
||||
this.autoReconnect = true //
|
||||
/** @private */
|
||||
this.lastSeen = null // updated by sending a keep alive request to the server, and the server responds with our updated last seen
|
||||
/** Log messages that are not handled, so you can debug & see what custom stuff you can implement */
|
||||
this.logUnhandledMessages = false
|
||||
/** @private */
|
||||
this.callbacks = {}
|
||||
|
||||
/**
|
||||
* What to do when you need the phone to authenticate the connection (generate QR code by default)
|
||||
*/
|
||||
this.onReadyForPhoneAuthentication = this.generateQRCode
|
||||
this.encoder = new BinaryCoding.Encoder()
|
||||
this.decoder = new BinaryCoding.Decoder()
|
||||
|
||||
this.unexpectedDisconnect = (err) => { this.close () }
|
||||
}
|
||||
/**
|
||||
* Set the callback for unexpected disconnects
|
||||
* @param {function(object)} callback
|
||||
*/
|
||||
setOnUnexpectedDisconnect (callback) {
|
||||
this.unexpectedDisconnect = (err) => {
|
||||
this.close ()
|
||||
callback (err)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Set the callback for message status updates (when a message is delivered, read etc.)
|
||||
* @param {function(object)} callback
|
||||
*/
|
||||
setOnMessageStatusChange (callback) {
|
||||
const func = (json) => {
|
||||
json = json[1]
|
||||
var ids = json.id
|
||||
if (json.cmd === "ack") {
|
||||
ids = [json.id]
|
||||
}
|
||||
const ackTypes = [
|
||||
WhatsAppWeb.MessageStatus.sent,
|
||||
WhatsAppWeb.MessageStatus.received,
|
||||
WhatsAppWeb.MessageStatus.read
|
||||
]
|
||||
const data = {
|
||||
from: json.from,
|
||||
to: json.to,
|
||||
participant: json.participant,
|
||||
timestamp: new Date (json.t*1000),
|
||||
ids: ids,
|
||||
type: ackTypes[json.ack-1] || "unknown (" + json.ack + ")"
|
||||
}
|
||||
callback (data)
|
||||
}
|
||||
this.registerCallback ("Msg", func)
|
||||
this.registerCallback ("MsgInfo", func)
|
||||
}
|
||||
/**
|
||||
* Set the callback for new/unread messages; if someone sends you a message, this callback will be fired
|
||||
* @param {function(WhatsAppMessage)} callback
|
||||
* @param {boolean} callbackOnMyMessages - should the callback be fired on a message you sent
|
||||
*/
|
||||
setOnUnreadMessage (callback, callbackOnMyMessages=false) {
|
||||
this.registerCallback (["action", "add:relay", "message"], (json) => {
|
||||
const message = json[2][0][2]
|
||||
if (!message.key.fromMe || callbackOnMyMessages) { // if this message was sent to us, notify
|
||||
callback (message)
|
||||
} else if (this.logUnhandledMessages) {
|
||||
this.log (`[Unhandled] message - ${JSON.stringify(message)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Set the callback for presence updates; if someone goes offline/online, this callback will be fired
|
||||
* @param {function(object)} callback
|
||||
*/
|
||||
setOnPresenceUpdate (callback) {
|
||||
this.registerCallback ("Presence", (json) => callback(json[1]))
|
||||
}
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
}
|
||||
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
|
||||
generateQRCode ([ref, publicKey, clientID]) {
|
||||
const str = ref + "," + publicKey + "," + clientID
|
||||
QR.generate(str, {small: true})
|
||||
}
|
||||
|
||||
log (text) { console.log (`[Baileys] ${text}`) }
|
||||
}
|
||||
|
||||
/* Import the rest of the code */
|
||||
|
||||
const recv = require("./WhatsAppWeb.Recv")
|
||||
/** Called when a message is recieved on the socket */
|
||||
WhatsAppWeb.prototype.onMessageRecieved = recv.onMessageRecieved
|
||||
/** The type of notification one recieved */
|
||||
WhatsAppWeb.prototype.getNotificationType = recv.getNotificationType
|
||||
/** Register for a callback for a certain function, will cancel automatically after one execution */
|
||||
WhatsAppWeb.prototype.registerCallbackOneTime = recv.registerCallbackOneTime
|
||||
/** Register for a callback for a certain function */
|
||||
WhatsAppWeb.prototype.registerCallback = recv.registerCallback
|
||||
/** Cancel all further callback events associated with the given parameters */
|
||||
WhatsAppWeb.prototype.deregisterCallback = recv.deregisterCallback
|
||||
/** Wait for a message with a certain tag to be received */
|
||||
WhatsAppWeb.prototype.waitForMessage = recv.waitForMessage
|
||||
/** Decode a media message (video, image, document, audio) & save it to the given file */
|
||||
WhatsAppWeb.prototype.decodeMediaMessage = recv.decodeMediaMessage
|
||||
|
||||
const session = require("./WhatsAppWeb.Session")
|
||||
WhatsAppWeb.prototype.connect = session.connect
|
||||
WhatsAppWeb.prototype.connectSlim = session.connectSlim
|
||||
WhatsAppWeb.prototype.receiveChatsAndContacts = session.receiveChatsAndContacts
|
||||
WhatsAppWeb.prototype.beginAuthentication = session.beginAuthentication
|
||||
WhatsAppWeb.prototype.validateNewConnection = session.validateNewConnection
|
||||
WhatsAppWeb.prototype.respondToChallenge = session.respondToChallenge
|
||||
WhatsAppWeb.prototype.generateKeysForAuth = session.generateKeysForAuth
|
||||
WhatsAppWeb.prototype.startKeepAliveRequest = session.startKeepAliveRequest
|
||||
WhatsAppWeb.prototype.logout = session.logout
|
||||
WhatsAppWeb.prototype.close = session.close
|
||||
|
||||
const send = require("./WhatsAppWeb.Send")
|
||||
/** Send a read receipt to the given ID for a certain message */
|
||||
WhatsAppWeb.prototype.sendReadReceipt = send.sendReadReceipt
|
||||
/** Tell someone about your presence -- online, typing, offline etc.
|
||||
* @see WhatsAppWeb.Presence for all presence types
|
||||
*/
|
||||
WhatsAppWeb.prototype.updatePresence = send.updatePresence
|
||||
/** Send a text message */
|
||||
WhatsAppWeb.prototype.sendTextMessage = send.sendTextMessage
|
||||
/** Send a contact message */
|
||||
WhatsAppWeb.prototype.sendContactMessage = send.sendContactMessage
|
||||
/** Send a location message */
|
||||
WhatsAppWeb.prototype.sendLocationMessage = send.sendLocationMessage
|
||||
/** Send a media message */
|
||||
WhatsAppWeb.prototype.sendMediaMessage = send.sendMediaMessage
|
||||
/** @private */
|
||||
WhatsAppWeb.prototype.sendMessage = send.sendMessage
|
||||
/** Generic function for group related queries */
|
||||
WhatsAppWeb.prototype.groupQuery = send.groupQuery
|
||||
/** Query something from the WhatsApp servers */
|
||||
WhatsAppWeb.prototype.query = send.query
|
||||
/** @private */
|
||||
WhatsAppWeb.prototype.sendBinary = send.sendBinary
|
||||
/** @private */
|
||||
WhatsAppWeb.prototype.sendJSON = send.sendJSON
|
||||
/** @private */
|
||||
WhatsAppWeb.prototype.send = send.send
|
||||
|
||||
const query = require("./WhatsAppWeb.Query")
|
||||
/** Query whether a given number is registered on WhatsApp */
|
||||
WhatsAppWeb.prototype.isOnWhatsApp = query.isOnWhatsApp
|
||||
/** Check the presence of a given person (online, offline) */
|
||||
WhatsAppWeb.prototype.requestPresenceUpdate = query.requestPresenceUpdate
|
||||
/** Query the status of the person (see groupMetadata() for groups) */
|
||||
WhatsAppWeb.prototype.getStatus = query.getStatus
|
||||
/** Get the URL to download the profile picture of a person/group */
|
||||
WhatsAppWeb.prototype.getProfilePicture = query.getProfilePicture
|
||||
/** Query all your contacts */
|
||||
WhatsAppWeb.prototype.getContacts = query.getContacts
|
||||
/** Query all the people/groups you have a chat history with */
|
||||
WhatsAppWeb.prototype.getChats = query.getChats
|
||||
/** Query whether your phone is still connected to this WhatsApp Web */
|
||||
WhatsAppWeb.prototype.isPhoneConnected = query.isPhoneConnected
|
||||
/** Load the conversation with a group or person */
|
||||
WhatsAppWeb.prototype.loadConversation = query.loadConversation
|
||||
/** Load the entire friggin conversation with a group or person */
|
||||
WhatsAppWeb.prototype.loadEntireConversation = query.loadEntireConversation
|
||||
/** Get the metadata of the group */
|
||||
WhatsAppWeb.prototype.groupMetadata = query.groupMetadata
|
||||
/** Create a group */
|
||||
WhatsAppWeb.prototype.groupCreate = query.groupCreate
|
||||
/** Leave a group */
|
||||
WhatsAppWeb.prototype.groupLeave = query.groupLeave
|
||||
/** Add somebody to the group */
|
||||
WhatsAppWeb.prototype.groupAdd = query.groupAdd
|
||||
/** Remove somebody from the group */
|
||||
WhatsAppWeb.prototype.groupRemove = query.groupRemove
|
||||
/** Make somebody admin on the group */
|
||||
WhatsAppWeb.prototype.groupMakeAdmin = query.groupMakeAdmin
|
||||
/** Get the invite code of the group */
|
||||
WhatsAppWeb.prototype.groupInviteCode = query.groupInviteCode
|
||||
|
||||
|
||||
module.exports = WhatsAppWeb
|
||||
BIN
binary_coding/.DS_Store
vendored
BIN
binary_coding/.DS_Store
vendored
Binary file not shown.
@@ -1,25 +0,0 @@
|
||||
const assert = require('assert').strict
|
||||
const BinaryCoding = require("./binary_encoder.js")
|
||||
|
||||
const testingPairs = [
|
||||
[
|
||||
"f806092f5a0a10f804f80234fc6c0a350a1b39313735323938373131313740732e77686174736170702e6e657410011a143345423030393637354537454433374141424632122b0a292a7069616e6f20726f6f6d2074696d696e6773206172653a2a0a20363a3030414d2d31323a3030414d18b3faa7f3052003f80234fc4c0a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20304643454335333330463634393239433645394132434646443242433845414418bdfaa7f305c00101f80234fc930a350a1b39313735323938373131313740732e77686174736170702e6e657410011a14334542303033433742353339414644303937353312520a50536f727279206672656e2c204920636f756c646e277420756e6465727374616e6420274c69627261272e2054797065202768656c702720746f206b6e6f77207768617420616c6c20492063616e20646f18c1faa7f3052003f80234fc540a410a1b39313735323938373131313740732e77686174736170702e6e657410001a20413132333042384436423041314437393345433241453245413043313638443812090a076c69627261727918c2faa7f305",
|
||||
["action",{"last":"true","add":"before"},[["message",null,{"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"}],["message",null,{"key":{"remoteJid":"917529871117@s.whatsapp.net","fromMe":false,"id":"0FCEC5330F64929C6E9A2CFFD2BC8EAD"},"messageTimestamp":"1584004413","messageStubType":"REVOKE"}],["message",null,{"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"}],["message",null,{"key":{"remoteJid":"917529871117@s.whatsapp.net","fromMe":false,"id":"A1230B8D6B0A1D793EC2AE2EA0C168D8"},"message":{"conversation":"library"},"messageTimestamp":"1584004418"}]]]
|
||||
]
|
||||
]
|
||||
function testCoding () {
|
||||
const encoder = new BinaryCoding.Encoder()
|
||||
const decoder = new BinaryCoding.Decoder()
|
||||
|
||||
testingPairs.forEach(pair => {
|
||||
const buff = Buffer.from(pair[0], 'hex')
|
||||
const decoded = decoder.read(buff)
|
||||
|
||||
assert.deepEqual( JSON.stringify( decoded ), JSON.stringify( pair[1] ))
|
||||
|
||||
const encoded = encoder.write(decoded)
|
||||
assert.deepEqual(encoded, buff)
|
||||
})
|
||||
console.log("all coding tests passed")
|
||||
}
|
||||
testCoding()
|
||||
@@ -1,476 +0,0 @@
|
||||
const ProtoBuf = require("protobufjs")
|
||||
|
||||
const WATags = {
|
||||
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
|
||||
}
|
||||
const WADoubleByteTokens = []
|
||||
|
||||
const WASingleByteTokens = [
|
||||
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"
|
||||
]
|
||||
const WebMessageInfo = ProtoBuf.Root.fromJSON( require("./whatsapp_message_coding.json") ).lookupType("proto.WebMessageInfo")
|
||||
|
||||
class WhatsAppBinaryEncoder {
|
||||
|
||||
constructor () {
|
||||
this.data = []
|
||||
}
|
||||
pushByte (value) {
|
||||
|
||||
this.data.push((value & 0xFF))
|
||||
}
|
||||
pushInt (value, n, littleEndian=false) {
|
||||
for (var i = 0; i < n;i++) {
|
||||
const curShift = littleEndian ? i : (n-1-i)
|
||||
this.data.push( (value>>(curShift*8)) & 0xFF )
|
||||
}
|
||||
}
|
||||
pushInt20 (value) {
|
||||
this.pushBytes ( [(value >> 16) & 0x0F, (value >> 8) & 0xFF, value & 0xFF] )
|
||||
}
|
||||
pushInt16 (value) {
|
||||
this.pushInt(value, 2)
|
||||
}
|
||||
pushInt32 (value) {
|
||||
this.pushInt(value, 4)
|
||||
}
|
||||
pushInt64 (value) {
|
||||
this.pushInt(value, 8)
|
||||
}
|
||||
pushBytes (bytes) {
|
||||
this.data.push.apply(this.data, bytes)
|
||||
}
|
||||
pushString (str) {
|
||||
const bytes = new TextEncoder('utf-8').encode(str)
|
||||
this.pushBytes(bytes)
|
||||
}
|
||||
writeByteLength (length) {
|
||||
if (length >= 4294967296) {
|
||||
throw "string too large to encode: " + length
|
||||
}
|
||||
|
||||
if (length >= (1<<20)) {
|
||||
this.pushByte(WATags.BINARY_32)
|
||||
this.pushInt32(length)
|
||||
} else if (length >= 256) {
|
||||
this.pushByte(WATags.BINARY_20)
|
||||
this.pushInt20(length)
|
||||
} else {
|
||||
this.pushByte(WATags.BINARY_8)
|
||||
this.pushByte(length)
|
||||
}
|
||||
}
|
||||
writeStringRaw (string) {
|
||||
this.writeByteLength( string.length )
|
||||
this.pushString(string)
|
||||
}
|
||||
writeJid(left,right) {
|
||||
this.pushByte(WATags.JID_PAIR)
|
||||
if (left && left.length > 0) {
|
||||
this.writeString(left)
|
||||
} else {
|
||||
this.writeToken(WATags.LIST_EMPTY)
|
||||
}
|
||||
this.writeString(right)
|
||||
}
|
||||
writeToken (token) {
|
||||
if (token < 245) {
|
||||
this.pushByte(token)
|
||||
} else if (token <= 500) {
|
||||
throw "invalid token"
|
||||
}
|
||||
}
|
||||
writeString(token, i=null) {
|
||||
if (typeof token !== "string") {
|
||||
throw "invalid string: " + token
|
||||
}
|
||||
|
||||
if (token === "c.us") {
|
||||
token = "s.whatsapp.net"
|
||||
}
|
||||
|
||||
|
||||
const tokenIndex = WASingleByteTokens.indexOf(token)
|
||||
if (!i && token === "s.whatsapp.net") {
|
||||
this.writeToken( tokenIndex )
|
||||
} else if ( tokenIndex >= 0 ) {
|
||||
if (tokenIndex < WATags.SINGLE_BYTE_MAX) {
|
||||
this.writeToken(tokenIndex)
|
||||
} else {
|
||||
const overflow = tokenIndex-WATags.SINGLE_BYTE_MAX
|
||||
const dictionaryIndex = overflow >> 8
|
||||
if (dictionaryIndex < 0 || dictionaryIndex > 3) {
|
||||
throw "double byte dict token out of range: " + token + ", " + tokenIndex
|
||||
}
|
||||
this.writeToken(WATags.DICTIONARY_0 + dictionaryIndex)
|
||||
this.writeToken(overflow % 256)
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
if (!attrs) {
|
||||
return
|
||||
}
|
||||
Object.keys(attrs).forEach (key => {
|
||||
this.writeString( key )
|
||||
this.writeString( attrs[key] )
|
||||
})
|
||||
}
|
||||
writeListStart (listSize) {
|
||||
if (listSize === 0) {
|
||||
this.pushByte(WATags.LIST_EMPTY)
|
||||
} else if (listSize < 256) {
|
||||
this.pushBytes([WATags.LIST_8, listSize])
|
||||
} else {
|
||||
this.pushByte([WATags.LIST_16, listSize])
|
||||
}
|
||||
}
|
||||
writeChildren (children) {
|
||||
if (!children) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof children === "string") {
|
||||
this.writeString(children, true)
|
||||
} else if (typeof children === "Buffer" || typeof children === "Uint8Array") {
|
||||
this.writeByteLength(children.length)
|
||||
this.pushBytes(children)
|
||||
} else if (Array.isArray(children)) {
|
||||
this.writeListStart(children.length)
|
||||
children.forEach (c => {
|
||||
this.writeNode(c)
|
||||
})
|
||||
} else if (typeof children === "object") {
|
||||
//console.log(children)
|
||||
const buff = WebMessageInfo.encode(children).finish()
|
||||
this.writeByteLength(buff.length)
|
||||
this.pushBytes(buff)
|
||||
} else {
|
||||
throw "invalid children: " + children + " (" + (typeof children) + ")"
|
||||
}
|
||||
}
|
||||
getNumValidKeys (arr) {
|
||||
return arr ? Object.keys(arr).length : 0
|
||||
}
|
||||
writeNode (node) {
|
||||
if (!node) {
|
||||
return
|
||||
} else if (!Array.isArray(node) || node.length !== 3) {
|
||||
throw "invalid node given: " + node
|
||||
}
|
||||
|
||||
const numAttributes = this.getNumValidKeys(node[1])
|
||||
|
||||
this.writeListStart( 2*numAttributes + 1 + ( node[2] ? 1 : 0 ) )
|
||||
this.writeString(node[0])
|
||||
this.writeAttributes(node[1])
|
||||
this.writeChildren(node[2])
|
||||
}
|
||||
write (data) {
|
||||
this.data = new Array()
|
||||
this.writeNode(data)
|
||||
|
||||
return Buffer.from(this.data)
|
||||
}
|
||||
}
|
||||
class WhatsAppBinaryDecoder {
|
||||
|
||||
constructor () {
|
||||
this.buffer = null
|
||||
this.index = 0
|
||||
|
||||
}
|
||||
checkEOS (length) {
|
||||
if (this.index+length > this.buffer.length) {
|
||||
throw "end of stream"
|
||||
}
|
||||
}
|
||||
next () {
|
||||
const value = this.buffer[this.index]
|
||||
this.index += 1
|
||||
return value
|
||||
}
|
||||
readByte () {
|
||||
this.checkEOS(1)
|
||||
return this.next()
|
||||
}
|
||||
|
||||
readInt (n, littleEndian=false) {
|
||||
this.checkEOS(n)
|
||||
let val = 0
|
||||
for (var i = 0; i < n;i++) {
|
||||
const shift = (littleEndian) ? i : (n-1-i)
|
||||
val |= this.next() << (shift*8)
|
||||
}
|
||||
return val
|
||||
}
|
||||
readInt16 (littleEndian=false) {
|
||||
return this.readInt(2, littleEndian)
|
||||
}
|
||||
readInt20 () {
|
||||
this.checkEOS(3)
|
||||
return ( (this.next() & 15) << 16 ) + (this.next()<<8) + this.next()
|
||||
}
|
||||
readInt32 (littleEndian=false) {
|
||||
return this.readInt(4, littleEndian)
|
||||
}
|
||||
readInt64 (littleEndian=false) {
|
||||
return this.readInt(8, littleEndian)
|
||||
}
|
||||
unpackHex (value) {
|
||||
if (value >= 0 && value < 16) {
|
||||
return value<10 ? ('0'.charCodeAt(0)+value) : ('A'.charCodeAt(0)+value-10)
|
||||
}
|
||||
throw "invalid hex: " + value
|
||||
}
|
||||
unpackNibble(value) {
|
||||
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 "invalid nibble: " + value
|
||||
}
|
||||
}
|
||||
unpackByte (tag, value) {
|
||||
if (tag === WATags.NIBBLE_8) {
|
||||
return this.unpackNibble(value)
|
||||
} else if (tag === WATags.HEX_8) {
|
||||
return this.unpackHex(value)
|
||||
} else {
|
||||
throw "unknown tag: " + tag
|
||||
}
|
||||
}
|
||||
readPacked8(tag) {
|
||||
const startByte = this.readByte()
|
||||
let value = ""
|
||||
|
||||
for (var i = 0; i < (startByte&127);i++) {
|
||||
let 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 "WTF"
|
||||
}
|
||||
isListTag (tag) {
|
||||
return tag === WATags.LIST_EMPTY || tag === WATags.LIST_8 || tag === WATags.LIST_16
|
||||
}
|
||||
readListSize (tag) {
|
||||
switch (tag) {
|
||||
case WATags.LIST_EMPTY:
|
||||
return 0
|
||||
case WATags.LIST_8:
|
||||
return this.readByte()
|
||||
case WATags.LIST_16:
|
||||
return this.readInt16()
|
||||
default:
|
||||
throw "invalid tag for list size: " + tag
|
||||
}
|
||||
}
|
||||
readStringFromChars (length) {
|
||||
this.checkEOS(length)
|
||||
const value = this.buffer.slice(this.index, this.index+length)
|
||||
|
||||
this.index += length
|
||||
return new TextDecoder('utf-8').decode(value)
|
||||
}
|
||||
readString (tag) {
|
||||
if (tag >= 3 && tag <= 235) {
|
||||
const token = this.getToken(tag)
|
||||
return token === "s.whatsapp.net" ? "c.us" : token
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case WATags.DICTIONARY_0:
|
||||
case WATags.DICTIONARY_1:
|
||||
case WATags.DICTIONARY_2:
|
||||
case WATags.DICTIONARY_3:
|
||||
return this.getTokenDouble( tag - WATags.DICTIONARY_0, this.readByte() )
|
||||
case WATags.LIST_EMPTY:
|
||||
return null
|
||||
case WATags.BINARY_8:
|
||||
return this.readStringFromChars( this.readByte() )
|
||||
case WATags.BINARY_20:
|
||||
return this.readStringFromChars( this.readInt20() )
|
||||
case WATags.BINARY_32:
|
||||
return this.readStringFromChars( this.readInt32() )
|
||||
case WATags.JID_PAIR:
|
||||
const i = this.readString( this.readByte() )
|
||||
const j = this.readString( this.readByte() )
|
||||
if (i && j) {
|
||||
return i + "@" + j
|
||||
}
|
||||
throw "invalid jid pair: " + i + ", " + j
|
||||
case WATags.HEX_8:
|
||||
case WATags.NIBBLE_8:
|
||||
return this.readPacked8(tag)
|
||||
default:
|
||||
throw "invalid string with tag: " + tag
|
||||
}
|
||||
}
|
||||
readAttributes (n) {
|
||||
if (n !== 0) {
|
||||
let attributes = {}
|
||||
for (var i = 0;i < n;i++) {
|
||||
const index = this.readString(this.readByte())
|
||||
const b = this.readByte()
|
||||
|
||||
attributes[index] = this.readString(b)
|
||||
}
|
||||
return attributes
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
readList (tag) {
|
||||
let list = Array( this.readListSize(tag) )
|
||||
for (var i = 0;i < list.length;i++) {
|
||||
list[i] = this.readNode()
|
||||
}
|
||||
return list
|
||||
}
|
||||
readBytes (n) {
|
||||
this.checkEOS(n)
|
||||
const value = this.buffer.slice(this.index, this.index+n)
|
||||
this.index += n
|
||||
return value
|
||||
}
|
||||
getToken (index) {
|
||||
if (index < 3 || index >= WASingleByteTokens.length) {
|
||||
throw "invalid token index: " + index
|
||||
}
|
||||
return WASingleByteTokens[index]
|
||||
}
|
||||
getTokenDouble (index1, index2) {
|
||||
const n = 256*index1 + index2
|
||||
if (n < 0 || n > WADoubleByteTokens.length) {
|
||||
throw "invalid double token index: " + n
|
||||
}
|
||||
return WADoubleByteTokens[n]
|
||||
}
|
||||
readNode () {
|
||||
const listSize = this.readListSize( this.readByte() )
|
||||
const descrTag = this.readByte()
|
||||
|
||||
if (descrTag === WATags.STREAM_END) {
|
||||
throw "unexpected stream end"
|
||||
}
|
||||
|
||||
const descr = this.readString(descrTag)
|
||||
if (listSize === 0 || !descr) {
|
||||
throw "invalid node"
|
||||
}
|
||||
//console.log(descr + "," + listSize)
|
||||
|
||||
let attrs = this.readAttributes( (listSize-1) >> 1 )
|
||||
let content = null
|
||||
|
||||
|
||||
if (listSize%2 === 0) {
|
||||
const tag = this.readByte()
|
||||
|
||||
if (this.isListTag(tag)) {
|
||||
content = this.readList(tag)
|
||||
} else {
|
||||
switch (tag) {
|
||||
case WATags.BINARY_8:
|
||||
content = this.readBytes( this.readByte() )
|
||||
break
|
||||
case WATags.BINARY_20:
|
||||
content = this.readBytes( this.readInt20() )
|
||||
break
|
||||
case WATags.BINARY_32:
|
||||
content = this.readBytes( this.readInt32() )
|
||||
break
|
||||
default:
|
||||
content = this.readString(tag)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
//console.log( descr + "," + JSON.stringify(attrs) + ", " + content)
|
||||
return [descr, attrs, content]
|
||||
}
|
||||
|
||||
read (buffer) {
|
||||
this.buffer = buffer
|
||||
this.index = 0
|
||||
|
||||
let node = this.readNode()
|
||||
|
||||
if (node[2]) {
|
||||
for (var i = 0; i < node[2].length;i++) {
|
||||
if (node[2][0][0] === "message") {
|
||||
node[2][i][2] = WebMessageInfo.decode( node[2][i][2] )
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { Encoder: WhatsAppBinaryEncoder, Decoder: WhatsAppBinaryDecoder }
|
||||
File diff suppressed because it is too large
Load Diff
BIN
example/.DS_Store
vendored
BIN
example/.DS_Store
vendored
Binary file not shown.
@@ -1,106 +0,0 @@
|
||||
const WhatsAppWeb = require("../WhatsAppWeb")
|
||||
const fs = require("fs")
|
||||
|
||||
const client = new WhatsAppWeb() // instantiate
|
||||
client.autoReconnect = true // auto reconnect on disconnect
|
||||
client.logUnhandledMessages = false // set to true to see what kind of stuff you can implement
|
||||
var authInfo = null
|
||||
|
||||
try {
|
||||
const file = fs.readFileSync("auth_info.json") // load a closed session back if it exists
|
||||
authInfo = JSON.parse(file)
|
||||
} catch { }
|
||||
|
||||
client.connect (authInfo, 20*1000) // connect or timeout in 20 seconds
|
||||
.then (([user, chats, contacts, unread]) => {
|
||||
console.log ("oh hello " + user.name + " (" + user.id + ")")
|
||||
console.log ("you have " + unread.length + " unread messages")
|
||||
console.log ("you have " + chats.length + " chats & " + contacts.length + " contacts")
|
||||
|
||||
const authInfo = client.base64EncodedAuthInfo() // get all the auth info we need to restore this session
|
||||
fs.writeFileSync("auth_info.json", JSON.stringify(authInfo, null, "\t")) // save this info to a file
|
||||
/* Note: one can take this auth_info.json file and login again from any computer without having to scan the QR code,
|
||||
and get full access to one's WhatsApp. Despite the convenience, be careful with this file */
|
||||
|
||||
client.setOnPresenceUpdate (json => console.log(json.id + " presence is " + json.type))
|
||||
client.setOnMessageStatusChange (json => {
|
||||
const participant = json.participant ? " ("+json.participant+")" : "" // participant exists when the message is from a group
|
||||
console.log(json.to + participant +
|
||||
" acknowledged message(s) " + json.ids +
|
||||
" as " + json.type + " at " + json.timestamp)
|
||||
})
|
||||
client.setOnUnreadMessage (m => {
|
||||
const [notificationType, messageType] = client.getNotificationType(m) // get what type of notification it is -- message, group add notification etc.
|
||||
console.log("got notification of type: " + notificationType)
|
||||
|
||||
if (notificationType !== "message") {
|
||||
return
|
||||
}
|
||||
if (m.key.fromMe) {
|
||||
console.log ("relayed my own message")
|
||||
return
|
||||
}
|
||||
|
||||
let sender = m.key.remoteJid
|
||||
if (m.key.participant) { // participant exists if the message is in a group
|
||||
sender += " ("+m.key.participant+")"
|
||||
}
|
||||
if (messageType === WhatsAppWeb.MessageType.text) {
|
||||
const text = m.message.conversation
|
||||
console.log (sender + " sent: " + text)
|
||||
} else if (messageType === WhatsAppWeb.MessageType.extendedText) {
|
||||
const text = m.message.extendedTextMessage.text
|
||||
console.log (sender + " sent: " + text + " and quoted message: " + JSON.stringify(m.message))
|
||||
} else if (messageType === WhatsAppWeb.MessageType.contact) {
|
||||
const contact = m.message.contactMessage
|
||||
console.log (sender + " sent contact (" + contact.displayName + "): " + contact.vcard)
|
||||
} else if (messageType === WhatsAppWeb.MessageType.location || messageType === WhatsAppWeb.MessageType.liveLocation) {
|
||||
const locMessage = m.message[messageType]
|
||||
console.log (sender + " sent location (lat: " + locMessage.degreesLatitude + ", long: " + locMessage.degreesLongitude + "), saving thumbnail...")
|
||||
client.decodeMediaMessage(m.message, "loc_thumb_in_" + m.key.id)
|
||||
|
||||
if (messageType === WhatsAppWeb.MessageType.liveLocation) {
|
||||
console.log (sender + " sent live location for duration: " + m.duration/60 + " minutes, seq number: " + locMessage.sequenceNumber)
|
||||
}
|
||||
} else { // if it is a media (audio, image, video) message
|
||||
// decode, decrypt & save the media.
|
||||
// The extension to the is applied automatically based on the media type
|
||||
client.decodeMediaMessage(m.message, "media_in_" + m.key.id)
|
||||
.then (meta => console.log(sender + " sent media, saved at: " + meta.filename))
|
||||
.catch (err => console.log("error in decoding message: " + err))
|
||||
}
|
||||
// send a reply after 3 seconds
|
||||
setTimeout (() => {
|
||||
client.sendReadReceipt (m.key.remoteJid, m.key.id) // send read receipt
|
||||
.then (() => client.updatePresence(m.key.remoteJid, WhatsAppWeb.Presence.available)) // tell them we're available
|
||||
.then (() => client.updatePresence(m.key.remoteJid, WhatsAppWeb.Presence.composing)) // tell them we're composing
|
||||
.then (() => { // send the message
|
||||
let options = {quoted: m}
|
||||
const rand = Math.random()
|
||||
if (rand > 0.66) { // choose at random
|
||||
return client.sendTextMessage(m.key.remoteJid, "hello!", options) // send a "hello!" & quote the message recieved
|
||||
} else if (rand > 0.33) { // choose at random
|
||||
return client.sendLocationMessage(m.key.remoteJid, 32.123123, 12.12123123) // send a random location lol
|
||||
} else {
|
||||
const buffer = fs.readFileSync("example/ma_gif.mp4") // load the gif
|
||||
options.gif = true // the video is a gif
|
||||
options.caption = "hello!" // the caption
|
||||
return client.sendMediaMessage (m.key.remoteJid, buffer, WhatsAppWeb.MessageType.video, options) // send this gif!
|
||||
}
|
||||
})
|
||||
.then (([m, q]) => { // check if it went successfully
|
||||
const success = m.status === 200
|
||||
const messageID = q[2][0][2].key.id
|
||||
console.log("sent message with ID '" + messageID + "' successfully: " + success)
|
||||
})
|
||||
}, 3*1000)
|
||||
}, true) // set to false to not relay your own sent messages
|
||||
/* custom functionality for tracking battery */
|
||||
client.registerCallback (["action", null, "battery"], json => {
|
||||
const batteryLevelStr = json[2][0][1].value
|
||||
const batterylevel = parseInt (batteryLevelStr)
|
||||
console.log ("battery level: " + batterylevel)
|
||||
})
|
||||
client.setOnUnexpectedDisconnect (err => console.log ("disconnected unexpectedly: " + err) )
|
||||
})
|
||||
.catch (err => console.log ("encountered error: " + err))
|
||||
2462
package-lock.json
generated
2462
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@adiwajshing/baileys",
|
||||
"version": "1.1.1",
|
||||
"version": "2.0.0",
|
||||
"description": "WhatsApp Web API",
|
||||
"main": "WhatsAppWeb.js",
|
||||
"homepage": "https://github.com/adiwajshing/Baileys",
|
||||
@@ -16,19 +16,39 @@
|
||||
"automation"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node test.js"
|
||||
"test": "mocha --timeout 30000 -r ts-node/register */Tests.ts",
|
||||
"lint": "eslint '*/*.ts' --quiet --fix",
|
||||
"tsc": "tsc",
|
||||
"dev": "ts-node-dev --respawn --transpileOnly ./app/app.ts",
|
||||
"prod": "tsc && node ./build/app.js"
|
||||
},
|
||||
"author": "Adhiraj Singh",
|
||||
"license": "MIT",
|
||||
"publishConfig": { "registry": "https://npm.pkg.github.com/" },
|
||||
"repository": {
|
||||
"url": "git@github.com:adiwajshing/baileys.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"curve25519-js": "0.0.4",
|
||||
"futoin-hkdf": "^1.3.2",
|
||||
"node-fetch": "^2.6.0",
|
||||
"protobufjs": "^6.9.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.25.3",
|
||||
"video-thumb": "0.0.3",
|
||||
"sharp": "^0.25.4",
|
||||
"ws": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^7.0.2",
|
||||
"@types/node": "^14.0.14",
|
||||
"@types/sharp": "^0.25.0",
|
||||
"@typescript-eslint/eslint-plugin": "^3.5.0",
|
||||
"@typescript-eslint/parser": "^3.5.0",
|
||||
"assert": "^2.0.0",
|
||||
"eslint": "^7.3.1",
|
||||
"eslint-config-prettier": "^6.11.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"mocha": "^8.0.1",
|
||||
"prettier": "^2.0.5",
|
||||
"ts-node-dev": "^1.0.0-pre.49",
|
||||
"typescript": "^3.9.5"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user