Moved to src

This commit is contained in:
Adhiraj Singh
2020-07-01 18:14:47 +05:30
parent 7a2657a4ad
commit 1f7e9c8142
22 changed files with 8 additions and 8 deletions

205
src/Binary/Constants.ts Normal file
View File

@@ -0,0 +1,205 @@
import { proto as Coding } from '../../WAMessage/WAMessage'
export namespace WA {
export const Tags = {
LIST_EMPTY: 0,
STREAM_END: 2,
DICTIONARY_0: 236,
DICTIONARY_1: 237,
DICTIONARY_2: 238,
DICTIONARY_3: 239,
LIST_8: 248,
LIST_16: 249,
JID_PAIR: 250,
HEX_8: 251,
BINARY_8: 252,
BINARY_20: 253,
BINARY_32: 254,
NIBBLE_8: 255,
SINGLE_BYTE_MAX: 256,
PACKED_MAX: 254,
}
export const DoubleByteTokens = []
export const SingleByteTokens = [
null,
null,
null,
'200',
'400',
'404',
'500',
'501',
'502',
'action',
'add',
'after',
'archive',
'author',
'available',
'battery',
'before',
'body',
'broadcast',
'chat',
'clear',
'code',
'composing',
'contacts',
'count',
'create',
'debug',
'delete',
'demote',
'duplicate',
'encoding',
'error',
'false',
'filehash',
'from',
'g.us',
'group',
'groups_v2',
'height',
'id',
'image',
'in',
'index',
'invis',
'item',
'jid',
'kind',
'last',
'leave',
'live',
'log',
'media',
'message',
'mimetype',
'missing',
'modify',
'name',
'notification',
'notify',
'out',
'owner',
'participant',
'paused',
'picture',
'played',
'presence',
'preview',
'promote',
'query',
'raw',
'read',
'receipt',
'received',
'recipient',
'recording',
'relay',
'remove',
'response',
'resume',
'retry',
's.whatsapp.net',
'seconds',
'set',
'size',
'status',
'subject',
'subscribe',
't',
'text',
'to',
'true',
'type',
'unarchive',
'unavailable',
'url',
'user',
'value',
'web',
'width',
'mute',
'read_only',
'admin',
'creator',
'short',
'update',
'powersave',
'checksum',
'epoch',
'block',
'previous',
'409',
'replaced',
'reason',
'spam',
'modify_tag',
'message_info',
'delivery',
'emoji',
'title',
'description',
'canonical-url',
'matched-text',
'star',
'unstar',
'media_key',
'filename',
'identity',
'unread',
'page',
'page_count',
'search',
'media_message',
'security',
'call_log',
'profile',
'ciphertext',
'invite',
'gif',
'vcard',
'frequent',
'privacy',
'blacklist',
'whitelist',
'verify',
'location',
'document',
'elapsed',
'revoke_invite',
'expiration',
'unsubscribe',
'disable',
'vname',
'old_jid',
'new_jid',
'announcement',
'locked',
'prop',
'label',
'color',
'call',
'offer',
'call-id',
'quick_reply',
'sticker',
'pay_t',
'accept',
'reject',
'sticker_pack',
'invalid',
'canceled',
'missed',
'connected',
'result',
'audio',
'video',
'recent',
]
export const Message = Coding.WebMessageInfo
export type NodeAttributes = Record<string, string> | string | null
export type NodeData = Array<Node> | any | null
export type Node = [string, NodeAttributes, NodeData]
}

227
src/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
src/Binary/Encoder.ts Normal file
View File

@@ -0,0 +1,146 @@
import { WA } from './Constants'
import { proto } from '../../WAMessage/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.apply(this.data, 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
src/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')
})
})

671
src/Binary/def.proto Normal file
View File

@@ -0,0 +1,671 @@
syntax = "proto2";
package proto;
message HydratedQuickReplyButton {
optional string displayText = 1;
optional string id = 2;
}
message HydratedURLButton {
optional string displayText = 1;
optional string url = 2;
}
message HydratedCallButton {
optional string displayText = 1;
optional string phoneNumber = 2;
}
message HydratedTemplateButton {
optional uint32 index = 4;
oneof hydratedButton {
HydratedQuickReplyButton quickReplyButton = 1;
HydratedURLButton urlButton = 2;
HydratedCallButton callButton = 3;
}
}
message QuickReplyButton {
optional HighlyStructuredMessage displayText = 1;
optional string id = 2;
}
message URLButton {
optional HighlyStructuredMessage displayText = 1;
optional HighlyStructuredMessage url = 2;
}
message CallButton {
optional HighlyStructuredMessage displayText = 1;
optional HighlyStructuredMessage phoneNumber = 2;
}
message TemplateButton {
optional uint32 index = 4;
oneof button {
QuickReplyButton quickReplyButton = 1;
URLButton urlButton = 2;
CallButton callButton = 3;
}
}
message Location {
optional double degreesLatitude = 1;
optional double degreesLongitude = 2;
optional string name = 3;
}
message Point {
optional double x = 3;
optional double y = 4;
}
message InteractiveAnnotation {
repeated Point polygonVertices = 1;
oneof action {
Location location = 2;
}
}
message AdReplyInfo {
optional string advertiserName = 1;
enum AD_REPLY_INFO_MEDIATYPE {
NONE = 0;
IMAGE = 1;
VIDEO = 2;
}
optional AD_REPLY_INFO_MEDIATYPE mediaType = 2;
optional bytes jpegThumbnail = 16;
optional string caption = 17;
}
message ContextInfo {
optional string stanzaId = 1;
optional string participant = 2;
optional Message quotedMessage = 3;
optional string remoteJid = 4;
repeated string mentionedJid = 15;
optional string conversionSource = 18;
optional bytes conversionData = 19;
optional uint32 conversionDelaySeconds = 20;
optional uint32 forwardingScore = 21;
optional bool isForwarded = 22;
optional AdReplyInfo quotedAd = 23;
optional MessageKey placeholderKey = 24;
optional uint32 expiration = 25;
}
message SenderKeyDistributionMessage {
optional string groupId = 1;
optional bytes axolotlSenderKeyDistributionMessage = 2;
}
message ImageMessage {
optional string url = 1;
optional string mimetype = 2;
optional string caption = 3;
optional bytes fileSha256 = 4;
optional uint64 fileLength = 5;
optional uint32 height = 6;
optional uint32 width = 7;
optional bytes mediaKey = 8;
optional bytes fileEncSha256 = 9;
repeated InteractiveAnnotation interactiveAnnotations = 10;
optional string directPath = 11;
optional int64 mediaKeyTimestamp = 12;
optional bytes jpegThumbnail = 16;
optional ContextInfo contextInfo = 17;
optional bytes firstScanSidecar = 18;
optional uint32 firstScanLength = 19;
optional uint32 experimentGroupId = 20;
optional bytes scansSidecar = 21;
repeated uint32 scanLengths = 22;
optional bytes midQualityFileSha256 = 23;
optional bytes midQualityFileEncSha256 = 24;
}
message ContactMessage {
optional string displayName = 1;
optional string vcard = 16;
optional ContextInfo contextInfo = 17;
}
message LocationMessage {
optional double degreesLatitude = 1;
optional double degreesLongitude = 2;
optional string name = 3;
optional string address = 4;
optional string url = 5;
optional bytes jpegThumbnail = 16;
optional ContextInfo contextInfo = 17;
}
message ExtendedTextMessage {
optional string text = 1;
optional string matchedText = 2;
optional string canonicalUrl = 4;
optional string description = 5;
optional string title = 6;
optional fixed32 textArgb = 7;
optional fixed32 backgroundArgb = 8;
enum EXTENDED_TEXT_MESSAGE_FONTTYPE {
SANS_SERIF = 0;
SERIF = 1;
NORICAN_REGULAR = 2;
BRYNDAN_WRITE = 3;
BEBASNEUE_REGULAR = 4;
OSWALD_HEAVY = 5;
}
optional EXTENDED_TEXT_MESSAGE_FONTTYPE font = 9;
enum EXTENDED_TEXT_MESSAGE_PREVIEWTYPE {
NONE = 0;
VIDEO = 1;
}
optional EXTENDED_TEXT_MESSAGE_PREVIEWTYPE previewType = 10;
optional bytes jpegThumbnail = 16;
optional ContextInfo contextInfo = 17;
optional bool doNotPlayInline = 18;
}
message DocumentMessage {
optional string url = 1;
optional string mimetype = 2;
optional string title = 3;
optional bytes fileSha256 = 4;
optional uint64 fileLength = 5;
optional uint32 pageCount = 6;
optional bytes mediaKey = 7;
optional string fileName = 8;
optional bytes fileEncSha256 = 9;
optional string directPath = 10;
optional int64 mediaKeyTimestamp = 11;
optional bytes jpegThumbnail = 16;
optional ContextInfo contextInfo = 17;
}
message AudioMessage {
optional string url = 1;
optional string mimetype = 2;
optional bytes fileSha256 = 3;
optional uint64 fileLength = 4;
optional uint32 seconds = 5;
optional bool ptt = 6;
optional bytes mediaKey = 7;
optional bytes fileEncSha256 = 8;
optional string directPath = 9;
optional int64 mediaKeyTimestamp = 10;
optional ContextInfo contextInfo = 17;
optional bytes streamingSidecar = 18;
}
message VideoMessage {
optional string url = 1;
optional string mimetype = 2;
optional bytes fileSha256 = 3;
optional uint64 fileLength = 4;
optional uint32 seconds = 5;
optional bytes mediaKey = 6;
optional string caption = 7;
optional bool gifPlayback = 8;
optional uint32 height = 9;
optional uint32 width = 10;
optional bytes fileEncSha256 = 11;
repeated InteractiveAnnotation interactiveAnnotations = 12;
optional string directPath = 13;
optional int64 mediaKeyTimestamp = 14;
optional bytes jpegThumbnail = 16;
optional ContextInfo contextInfo = 17;
optional bytes streamingSidecar = 18;
enum VIDEO_MESSAGE_ATTRIBUTION {
NONE = 0;
GIPHY = 1;
TENOR = 2;
}
optional VIDEO_MESSAGE_ATTRIBUTION gifAttribution = 19;
}
message Call {
optional bytes callKey = 1;
}
message Chat {
optional string displayName = 1;
optional string id = 2;
}
message ProtocolMessage {
optional MessageKey key = 1;
enum PROTOCOL_MESSAGE_TYPE {
REVOKE = 0;
EPHEMERAL_SETTING = 3;
}
optional PROTOCOL_MESSAGE_TYPE type = 2;
optional uint32 ephemeralExpiration = 4;
}
message ContactsArrayMessage {
optional string displayName = 1;
repeated ContactMessage contacts = 2;
optional ContextInfo contextInfo = 17;
}
message HSMCurrency {
optional string currencyCode = 1;
optional int64 amount1000 = 2;
}
message HSMDateTimeComponent {
enum HSM_DATE_TIME_COMPONENT_DAYOFWEEKTYPE {
MONDAY = 1;
TUESDAY = 2;
WEDNESDAY = 3;
THURSDAY = 4;
FRIDAY = 5;
SATURDAY = 6;
SUNDAY = 7;
}
optional HSM_DATE_TIME_COMPONENT_DAYOFWEEKTYPE dayOfWeek = 1;
optional uint32 year = 2;
optional uint32 month = 3;
optional uint32 dayOfMonth = 4;
optional uint32 hour = 5;
optional uint32 minute = 6;
enum HSM_DATE_TIME_COMPONENT_CALENDARTYPE {
GREGORIAN = 1;
SOLAR_HIJRI = 2;
}
optional HSM_DATE_TIME_COMPONENT_CALENDARTYPE calendar = 7;
}
message HSMDateTimeUnixEpoch {
optional int64 timestamp = 1;
}
message HSMDateTime {
oneof datetimeOneof {
HSMDateTimeComponent component = 1;
HSMDateTimeUnixEpoch unixEpoch = 2;
}
}
message HSMLocalizableParameter {
optional string default = 1;
oneof paramOneof {
HSMCurrency currency = 2;
HSMDateTime dateTime = 3;
}
}
message HighlyStructuredMessage {
optional string namespace = 1;
optional string elementName = 2;
repeated string params = 3;
optional string fallbackLg = 4;
optional string fallbackLc = 5;
repeated HSMLocalizableParameter localizableParams = 6;
optional string deterministicLg = 7;
optional string deterministicLc = 8;
optional TemplateMessage hydratedHsm = 9;
}
message SendPaymentMessage {
optional Message noteMessage = 2;
optional MessageKey requestMessageKey = 3;
}
message RequestPaymentMessage {
optional Message noteMessage = 4;
optional string currencyCodeIso4217 = 1;
optional uint64 amount1000 = 2;
optional string requestFrom = 3;
optional int64 expiryTimestamp = 5;
}
message DeclinePaymentRequestMessage {
optional MessageKey key = 1;
}
message CancelPaymentRequestMessage {
optional MessageKey key = 1;
}
message LiveLocationMessage {
optional double degreesLatitude = 1;
optional double degreesLongitude = 2;
optional uint32 accuracyInMeters = 3;
optional float speedInMps = 4;
optional uint32 degreesClockwiseFromMagneticNorth = 5;
optional string caption = 6;
optional int64 sequenceNumber = 7;
optional uint32 timeOffset = 8;
optional bytes jpegThumbnail = 16;
optional ContextInfo contextInfo = 17;
}
message StickerMessage {
optional string url = 1;
optional bytes fileSha256 = 2;
optional bytes fileEncSha256 = 3;
optional bytes mediaKey = 4;
optional string mimetype = 5;
optional uint32 height = 6;
optional uint32 width = 7;
optional string directPath = 8;
optional uint64 fileLength = 9;
optional int64 mediaKeyTimestamp = 10;
optional uint32 firstFrameLength = 11;
optional bytes firstFrameSidecar = 12;
optional ContextInfo contextInfo = 17;
}
message FourRowTemplate {
optional HighlyStructuredMessage content = 6;
optional HighlyStructuredMessage footer = 7;
repeated TemplateButton buttons = 8;
oneof title {
DocumentMessage documentMessage = 1;
HighlyStructuredMessage highlyStructuredMessage = 2;
ImageMessage imageMessage = 3;
VideoMessage videoMessage = 4;
LocationMessage locationMessage = 5;
}
}
message HydratedFourRowTemplate {
optional string hydratedContentText = 6;
optional string hydratedFooterText = 7;
repeated HydratedTemplateButton hydratedButtons = 8;
optional string templateId = 9;
oneof title {
DocumentMessage documentMessage = 1;
string hydratedTitleText = 2;
ImageMessage imageMessage = 3;
VideoMessage videoMessage = 4;
LocationMessage locationMessage = 5;
}
}
message TemplateMessage {
optional ContextInfo contextInfo = 3;
optional HydratedFourRowTemplate hydratedTemplate = 4;
oneof format {
FourRowTemplate fourRowTemplate = 1;
HydratedFourRowTemplate hydratedFourRowTemplate = 2;
}
}
message TemplateButtonReplyMessage {
optional string selectedId = 1;
optional string selectedDisplayText = 2;
optional ContextInfo contextInfo = 3;
optional uint32 selectedIndex = 4;
}
message ProductSnapshot {
optional ImageMessage productImage = 1;
optional string productId = 2;
optional string title = 3;
optional string description = 4;
optional string currencyCode = 5;
optional int64 priceAmount1000 = 6;
optional string retailerId = 7;
optional string url = 8;
optional uint32 productImageCount = 9;
optional string firstImageId = 11;
}
message ProductMessage {
optional ProductSnapshot product = 1;
optional string businessOwnerJid = 2;
optional ContextInfo contextInfo = 17;
}
message GroupInviteMessage {
optional string groupJid = 1;
optional string inviteCode = 2;
optional int64 inviteExpiration = 3;
optional string groupName = 4;
optional bytes jpegThumbnail = 5;
optional string caption = 6;
optional ContextInfo contextInfo = 7;
}
message DeviceSentMessage {
optional string destinationJid = 1;
optional Message message = 2;
}
message DeviceSyncMessage {
optional bytes serializedXmlBytes = 1;
}
message Message {
optional string conversation = 1;
optional SenderKeyDistributionMessage senderKeyDistributionMessage = 2;
optional ImageMessage imageMessage = 3;
optional ContactMessage contactMessage = 4;
optional LocationMessage locationMessage = 5;
optional ExtendedTextMessage extendedTextMessage = 6;
optional DocumentMessage documentMessage = 7;
optional AudioMessage audioMessage = 8;
optional VideoMessage videoMessage = 9;
optional Call call = 10;
optional Chat chat = 11;
optional ProtocolMessage protocolMessage = 12;
optional ContactsArrayMessage contactsArrayMessage = 13;
optional HighlyStructuredMessage highlyStructuredMessage = 14;
optional SenderKeyDistributionMessage fastRatchetKeySenderKeyDistributionMessage = 15;
optional SendPaymentMessage sendPaymentMessage = 16;
optional LiveLocationMessage liveLocationMessage = 18;
optional RequestPaymentMessage requestPaymentMessage = 22;
optional DeclinePaymentRequestMessage declinePaymentRequestMessage = 23;
optional CancelPaymentRequestMessage cancelPaymentRequestMessage = 24;
optional TemplateMessage templateMessage = 25;
optional StickerMessage stickerMessage = 26;
optional GroupInviteMessage groupInviteMessage = 28;
optional TemplateButtonReplyMessage templateButtonReplyMessage = 29;
optional ProductMessage productMessage = 30;
optional DeviceSentMessage deviceSentMessage = 31;
optional DeviceSyncMessage deviceSyncMessage = 32;
}
message MessageKey {
optional string remoteJid = 1;
optional bool fromMe = 2;
optional string id = 3;
optional string participant = 4;
}
message WebFeatures {
enum WEB_FEATURES_FLAG {
NOT_STARTED = 0;
FORCE_UPGRADE = 1;
DEVELOPMENT = 2;
PRODUCTION = 3;
}
optional WEB_FEATURES_FLAG labelsDisplay = 1;
optional WEB_FEATURES_FLAG voipIndividualOutgoing = 2;
optional WEB_FEATURES_FLAG groupsV3 = 3;
optional WEB_FEATURES_FLAG groupsV3Create = 4;
optional WEB_FEATURES_FLAG changeNumberV2 = 5;
optional WEB_FEATURES_FLAG queryStatusV3Thumbnail = 6;
optional WEB_FEATURES_FLAG liveLocations = 7;
optional WEB_FEATURES_FLAG queryVname = 8;
optional WEB_FEATURES_FLAG voipIndividualIncoming = 9;
optional WEB_FEATURES_FLAG quickRepliesQuery = 10;
optional WEB_FEATURES_FLAG payments = 11;
optional WEB_FEATURES_FLAG stickerPackQuery = 12;
optional WEB_FEATURES_FLAG liveLocationsFinal = 13;
optional WEB_FEATURES_FLAG labelsEdit = 14;
optional WEB_FEATURES_FLAG mediaUpload = 15;
optional WEB_FEATURES_FLAG mediaUploadRichQuickReplies = 18;
optional WEB_FEATURES_FLAG vnameV2 = 19;
optional WEB_FEATURES_FLAG videoPlaybackUrl = 20;
optional WEB_FEATURES_FLAG statusRanking = 21;
optional WEB_FEATURES_FLAG voipIndividualVideo = 22;
optional WEB_FEATURES_FLAG thirdPartyStickers = 23;
optional WEB_FEATURES_FLAG frequentlyForwardedSetting = 24;
optional WEB_FEATURES_FLAG groupsV4JoinPermission = 25;
optional WEB_FEATURES_FLAG recentStickers = 26;
optional WEB_FEATURES_FLAG catalog = 27;
optional WEB_FEATURES_FLAG starredStickers = 28;
optional WEB_FEATURES_FLAG voipGroupCall = 29;
optional WEB_FEATURES_FLAG templateMessage = 30;
optional WEB_FEATURES_FLAG templateMessageInteractivity = 31;
optional WEB_FEATURES_FLAG ephemeralMessages = 32;
}
message TabletNotificationsInfo {
optional uint64 timestamp = 2;
optional uint32 unreadChats = 3;
optional uint32 notifyMessageCount = 4;
repeated NotificationMessageInfo notifyMessage = 5;
}
message NotificationMessageInfo {
optional MessageKey key = 1;
optional Message message = 2;
optional uint64 messageTimestamp = 3;
optional string participant = 4;
}
message WebNotificationsInfo {
optional uint64 timestamp = 2;
optional uint32 unreadChats = 3;
optional uint32 notifyMessageCount = 4;
repeated WebMessageInfo notifyMessages = 5;
}
message PaymentInfo {
optional uint64 amount1000 = 2;
optional string receiverJid = 3;
enum PAYMENT_INFO_STATUS {
UNKNOWN_STATUS = 0;
PROCESSING = 1;
SENT = 2;
NEED_TO_ACCEPT = 3;
COMPLETE = 4;
COULD_NOT_COMPLETE = 5;
REFUNDED = 6;
EXPIRED = 7;
REJECTED = 8;
CANCELLED = 9;
WAITING_FOR_PAYER = 10;
WAITING = 11;
}
optional PAYMENT_INFO_STATUS status = 4;
optional uint64 transactionTimestamp = 5;
optional MessageKey requestMessageKey = 6;
optional uint64 expiryTimestamp = 7;
optional bool futureproofed = 8;
optional string currency = 9;
}
message WebMessageInfo {
required MessageKey key = 1;
optional Message message = 2;
optional uint64 messageTimestamp = 3;
enum WEB_MESSAGE_INFO_STATUS {
ERROR = 0;
PENDING = 1;
SERVER_ACK = 2;
DELIVERY_ACK = 3;
READ = 4;
PLAYED = 5;
}
optional WEB_MESSAGE_INFO_STATUS status = 4;
optional string participant = 5;
optional bool ignore = 16;
optional bool starred = 17;
optional bool broadcast = 18;
optional string pushName = 19;
optional bytes mediaCiphertextSha256 = 20;
optional bool multicast = 21;
optional bool urlText = 22;
optional bool urlNumber = 23;
enum WEB_MESSAGE_INFO_STUBTYPE {
UNKNOWN = 0;
REVOKE = 1;
CIPHERTEXT = 2;
FUTUREPROOF = 3;
NON_VERIFIED_TRANSITION = 4;
UNVERIFIED_TRANSITION = 5;
VERIFIED_TRANSITION = 6;
VERIFIED_LOW_UNKNOWN = 7;
VERIFIED_HIGH = 8;
VERIFIED_INITIAL_UNKNOWN = 9;
VERIFIED_INITIAL_LOW = 10;
VERIFIED_INITIAL_HIGH = 11;
VERIFIED_TRANSITION_ANY_TO_NONE = 12;
VERIFIED_TRANSITION_ANY_TO_HIGH = 13;
VERIFIED_TRANSITION_HIGH_TO_LOW = 14;
VERIFIED_TRANSITION_HIGH_TO_UNKNOWN = 15;
VERIFIED_TRANSITION_UNKNOWN_TO_LOW = 16;
VERIFIED_TRANSITION_LOW_TO_UNKNOWN = 17;
VERIFIED_TRANSITION_NONE_TO_LOW = 18;
VERIFIED_TRANSITION_NONE_TO_UNKNOWN = 19;
GROUP_CREATE = 20;
GROUP_CHANGE_SUBJECT = 21;
GROUP_CHANGE_ICON = 22;
GROUP_CHANGE_INVITE_LINK = 23;
GROUP_CHANGE_DESCRIPTION = 24;
GROUP_CHANGE_RESTRICT = 25;
GROUP_CHANGE_ANNOUNCE = 26;
GROUP_PARTICIPANT_ADD = 27;
GROUP_PARTICIPANT_REMOVE = 28;
GROUP_PARTICIPANT_PROMOTE = 29;
GROUP_PARTICIPANT_DEMOTE = 30;
GROUP_PARTICIPANT_INVITE = 31;
GROUP_PARTICIPANT_LEAVE = 32;
GROUP_PARTICIPANT_CHANGE_NUMBER = 33;
BROADCAST_CREATE = 34;
BROADCAST_ADD = 35;
BROADCAST_REMOVE = 36;
GENERIC_NOTIFICATION = 37;
E2E_IDENTITY_CHANGED = 38;
E2E_ENCRYPTED = 39;
CALL_MISSED_VOICE = 40;
CALL_MISSED_VIDEO = 41;
INDIVIDUAL_CHANGE_NUMBER = 42;
GROUP_DELETE = 43;
GROUP_ANNOUNCE_MODE_MESSAGE_BOUNCE = 44;
CALL_MISSED_GROUP_VOICE = 45;
CALL_MISSED_GROUP_VIDEO = 46;
PAYMENT_CIPHERTEXT = 47;
PAYMENT_FUTUREPROOF = 48;
PAYMENT_TRANSACTION_STATUS_UPDATE_FAILED = 49;
PAYMENT_TRANSACTION_STATUS_UPDATE_REFUNDED = 50;
PAYMENT_TRANSACTION_STATUS_UPDATE_REFUND_FAILED = 51;
PAYMENT_TRANSACTION_STATUS_RECEIVER_PENDING_SETUP = 52;
PAYMENT_TRANSACTION_STATUS_RECEIVER_SUCCESS_AFTER_HICCUP = 53;
PAYMENT_ACTION_ACCOUNT_SETUP_REMINDER = 54;
PAYMENT_ACTION_SEND_PAYMENT_REMINDER = 55;
PAYMENT_ACTION_SEND_PAYMENT_INVITATION = 56;
PAYMENT_ACTION_REQUEST_DECLINED = 57;
PAYMENT_ACTION_REQUEST_EXPIRED = 58;
PAYMENT_ACTION_REQUEST_CANCELLED = 59;
BIZ_VERIFIED_TRANSITION_TOP_TO_BOTTOM = 60;
BIZ_VERIFIED_TRANSITION_BOTTOM_TO_TOP = 61;
BIZ_INTRO_TOP = 62;
BIZ_INTRO_BOTTOM = 63;
BIZ_NAME_CHANGE = 64;
BIZ_MOVE_TO_CONSUMER_APP = 65;
BIZ_TWO_TIER_MIGRATION_TOP = 66;
BIZ_TWO_TIER_MIGRATION_BOTTOM = 67;
OVERSIZED = 68;
GROUP_CHANGE_NO_FREQUENTLY_FORWARDED = 69;
GROUP_V4_ADD_INVITE_SENT = 70;
GROUP_PARTICIPANT_ADD_REQUEST_JOIN = 71;
CHANGE_EPHEMERAL_SETTING = 72;
}
optional WEB_MESSAGE_INFO_STUBTYPE messageStubType = 24;
optional bool clearMedia = 25;
repeated string messageStubParameters = 26;
optional uint32 duration = 27;
repeated string labels = 28;
optional PaymentInfo paymentInfo = 29;
optional LiveLocationMessage finalLiveLocation = 30;
optional PaymentInfo quotedPaymentInfo = 31;
optional uint64 ephemeralStartTimestamp = 32;
optional uint32 ephemeralDuration = 33;
}

230
src/WAClient/Base.ts Normal file
View File

@@ -0,0 +1,230 @@
import WAConnection from '../WAConnection/WAConnection'
import { MessageStatus, MessageStatusUpdate, PresenceUpdate } from './Constants'
import {
WAMessage,
WANode,
WAMetric,
WAFlag,
WAGroupCreateResponse,
WAGroupMetadata,
WAGroupModification,
MessageLogLevel,
} 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.logLevel >= MessageLogLevel.unhandled) {
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
src/WAClient/Constants.ts Normal file
View File

@@ -0,0 +1,109 @@
import { WAMessage } from '../WAConnection/Constants'
import { proto } from '../../WAMessage/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
src/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
}
}

159
src/WAClient/Tests.ts Normal file
View File

@@ -0,0 +1,159 @@
import { WAClient } from './WAClient'
import { MessageType, MessageOptions, Mimetype, Presence } from './Constants'
import * as fs from 'fs'
import * as assert from 'assert'
import { decodeMediaMessage } from './Utils'
import { promiseTimeout } from '../WAConnection/Utils'
require ('dotenv').config () // dotenv to load test jid
const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST_JID=xyz@s.whatsapp.net in a .env file in the root directory
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.strictEqual(response.status, 200)
const messages = await client.loadConversation(testJid, 1, null, true)
assert.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(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.strictEqual(response, true)
const responseFail = await client.isOnWhatsApp('abcd@s.whatsapp.net')
assert.strictEqual(responseFail, false)
})
it('should return the status', async () => {
const response = await client.getStatus(testJid)
assert.ok(response.status)
assert.strictEqual(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.strictEqual(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.strictEqual(typeof code, 'string')
})
it('should retreive group metadata', async () => {
const metadata = await client.groupMetadata(gid)
assert.strictEqual(metadata.id, gid)
assert.strictEqual(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.strictEqual(r.status, 200)
})
it('should update the subject', async () => {
const subject = 'V Cool Title'
const r = await client.groupUpdateSubject(gid, subject)
assert.strictEqual(r.status, 200)
const metadata = await client.groupMetadata(gid)
assert.strictEqual(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.strictEqual(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.strictEqual (response.status, 200)
await createTimeout (25000)
})*/
})
/*WAClientTest ('Testz', client => {
it ('should work', async () => {
})
})*/

132
src/WAClient/Utils.ts Normal file
View File

@@ -0,0 +1,132 @@
import { MessageType, HKDFInfoKeys, MessageOptions, MessageStubTypes } from './Constants'
import sharp from 'sharp'
import * as fs from 'fs'
import fetch from 'node-fetch'
import { WAMessage, WAMessageContent } from '../WAConnection/Constants'
import { hmacSign, aesDecryptWithIV, hkdf } from '../WAConnection/Utils'
import { proto } from '../../WAMessage/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
src/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'

265
src/WAConnection/Base.ts Normal file
View File

@@ -0,0 +1,265 @@
import * as QR from 'qrcode-terminal'
import * as fs from 'fs'
import WS from 'ws'
import * as Utils from './Utils'
import Encoder from '../Binary/Encoder'
import Decoder from '../Binary/Decoder'
import { AuthenticationCredentials, UserMetaData, WANode, AuthenticationCredentialsBase64, WATag, MessageLogLevel } 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 */
logLevel: MessageLogLevel = MessageLogLevel.none
/** 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][${new Date().toLocaleString()}] ${text}`)
}
}

257
src/WAConnection/Connect.ts Normal file
View File

@@ -0,0 +1,257 @@
import WS from 'ws'
import * as Utils from './Utils'
import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel } 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(timeoutMs)
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 = Utils.promiseTimeout(timeoutMs, promise)
return promise.catch(err => {
this.close()
throw err
})
}
/**
* Sets up callbacks to receive chats, contacts & unread messages.
* Must be called immediately after connect
* @returns [chats, contacts, unreadMessages]
*/
async receiveChatsAndContacts(timeoutMs: number = null) {
let chats: Array<WAChat> = []
let contacts: Array<WAContact> = []
let unreadMessages: Array<WAMessage> = []
let unreadMap: Record<string, number> = {}
let receivedContacts = false
let receivedMessages = false
let convoResolve
this.log('waiting for chats & contacts') // wait for the message with chats
const waitForConvos = () =>
new Promise(resolve => {
convoResolve = () => {
// 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()
}
const chatUpdate = json => {
receivedMessages = true
const isLast = json[1].last
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 && receivedContacts) { // if received contacts before messages
convoResolve ()
}
}
// 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 = async () => {
const json = await this.registerCallbackOneTime(['response', 'type:chat'])
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.length > 0) return waitForConvos()
}
const waitForContacts = async () => {
const json = await this.registerCallbackOneTime(['response', 'type:contacts'])
contacts = json[2].map(item => item[1])
receivedContacts = true
// if you receive contacts after messages
// should probably resolve the promise
if (receivedMessages) convoResolve()
}
// wait for the chats & contacts to load
const promise = Promise.all([waitForChats(), waitForContacts()])
await Utils.promiseTimeout (timeoutMs, promise)
return [chats, contacts, unreadMessages] as [WAChat[], WAContact[], WAMessage[]]
}
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]
}
if (this.logLevel === MessageLogLevel.all) {
this.log(messageTag + ', ' + JSON.stringify(json))
}
/*
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.logLevel === MessageLogLevel.unhandled) {
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)
}
}

View File

@@ -0,0 +1,78 @@
import { WA } from '../Binary/Constants'
import { proto } from '../../WAMessage/WAMessage'
export enum MessageLogLevel {
none=0,
unhandled=1,
all=2
}
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]

57
src/WAConnection/Tests.ts Normal file
View File

@@ -0,0 +1,57 @@
import * as 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()
const user = await conn.connectSlim(null)
assert.ok(user)
assert.ok(user.id)
conn.close()
auth = conn.base64EncodedAuthInfo()
})
it('should reconnect', async () => {
const conn = new WAConnection()
const [user, chats, contacts, unread] = await conn.connect(auth, 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')
})
})

71
src/WAConnection/Utils.ts Normal file
View File

@@ -0,0 +1,71 @@
import * as 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>) {
if (!ms) { return promise }
// 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
})
}

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