Initial Conversion to typescript

This commit is contained in:
Adhiraj Singh
2020-06-30 20:57:49 +05:30
parent 0cf6ca66d0
commit e50c1cbaf1
42 changed files with 31850 additions and 4016 deletions

18
.eslintrc.js Normal file
View 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
View File

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

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

205
Binary/Constants.ts Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

19977
Binary/WAMessage.js Normal file

File diff suppressed because it is too large Load Diff

144
Example/example.ts Normal file
View 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}`))

View File

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

BIN
Media/received_img.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

229
WAClient/Base.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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', [])
}
}

View File

@@ -0,0 +1,2 @@
import WAConnection from './Connect'
export default WAConnection

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

Binary file not shown.

View File

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

File diff suppressed because it is too large Load Diff

View File

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