Got rid of WAClient, deprecated code. Prep for V3

Layered classes based on hierarchy as well.
This commit is contained in:
Adhiraj
2020-08-16 17:51:29 +05:30
parent 1360bef9bb
commit 002d304041
23 changed files with 803 additions and 869 deletions

View File

@@ -1,6 +1,6 @@
import fs from 'fs'
import { decryptWA } from './Utils'
import Decoder from '../Binary/Decoder'
import { decryptWA } from './WAConnection/WAConnection'
import Decoder from './Binary/Decoder'
interface BrowserMessagesInfo {
encKey: string,

28
src/Tests/Common.ts Normal file
View File

@@ -0,0 +1,28 @@
import { WAConnection, MessageLogLevel, MessageOptions, MessageType } from '../WAConnection/WAConnection'
import * as assert from 'assert'
import fs from 'fs/promises'
require ('dotenv').config () // dotenv to load test jid
export 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
export async function sendAndRetreiveMessage(conn: WAConnection, content, type: MessageType, options: MessageOptions = {}) {
const response = await conn.sendMessage(testJid, content, type, options)
const messages = await conn.loadConversation(testJid, 10, null, true)
const message = messages.find (m => m.key.id === response.key.id)
assert.ok(message)
return message
}
export function WAConnectionTest(name: string, func: (conn: WAConnection) => void) {
describe(name, () => {
const conn = new WAConnection()
conn.logLevel = MessageLogLevel.info
before(async () => {
const file = './auth_info.json'
await conn.connectSlim(file)
await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t'))
})
after(() => conn.close())
func(conn)
})
}

View File

@@ -1,6 +1,6 @@
import { strict as assert } from 'assert'
import Encoder from './Encoder'
import Decoder from './Decoder'
import Encoder from '../Binary/Encoder'
import Decoder from '../Binary/Decoder'
describe('Binary Coding Tests', () => {
const testVectors: [string, Object][] = [

View File

@@ -1,10 +1,10 @@
import * as assert from 'assert'
import * as QR from 'qrcode-terminal'
import WAConnection from './WAConnection'
import { AuthenticationCredentialsBase64 } from './Constants'
import { createTimeout } from './Utils'
import {WAConnection} from '../WAConnection/WAConnection'
import { AuthenticationCredentialsBase64 } from '../WAConnection/Constants'
import { createTimeout } from '../WAConnection/Utils'
describe('QR generation', () => {
describe('QR Generation', () => {
it('should generate QR', async () => {
const conn = new WAConnection()
let calledQR = false

59
src/Tests/Tests.Groups.ts Normal file
View File

@@ -0,0 +1,59 @@
import { MessageType, GroupSettingChange, createTimeout, ChatModification } from '../WAConnection/WAConnection'
import * as assert from 'assert'
import { WAConnectionTest, testJid } from './Common'
WAConnectionTest('Groups', (conn) => {
let gid: string
it('should create a group', async () => {
const response = await conn.groupCreate('Cool Test Group', [testJid])
gid = response.gid
console.log('created group: ' + JSON.stringify(response))
})
it('should retreive group invite code', async () => {
const code = await conn.groupInviteCode(gid)
assert.ok(code)
assert.strictEqual(typeof code, 'string')
})
it('should retreive group metadata', async () => {
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.id, gid)
assert.strictEqual(metadata.participants.filter((obj) => obj.id.split('@')[0] === testJid.split('@')[0]).length, 1)
})
it('should update the group description', async () => {
const newDesc = 'Wow this was set from Baileys'
await conn.groupUpdateDescription (gid, newDesc)
await createTimeout (1000)
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.desc, newDesc)
})
it('should send a message on the group', async () => {
await conn.sendMessage(gid, 'hello', MessageType.text)
})
it('should update the subject', async () => {
const subject = 'V Cool Title'
await conn.groupUpdateSubject(gid, subject)
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.subject, subject)
})
it('should update the group settings', async () => {
await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true)
await createTimeout (5000)
await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true)
})
it('should remove someone from a group', async () => {
await conn.groupRemove(gid, [testJid])
})
it('should leave the group', async () => {
await conn.groupLeave(gid)
await conn.groupMetadataMinimal (gid)
})
it('should archive the group', async () => {
await conn.modifyChat(gid, ChatModification.archive)
})
it('should delete the group', async () => {
await conn.deleteChat(gid)
})
})

View File

@@ -0,0 +1,68 @@
import { MessageType, Mimetype, createTimeout } from '../WAConnection/WAConnection'
import fs from 'fs/promises'
import * as assert from 'assert'
import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common'
WAConnectionTest('Messages', (conn) => {
it('should send a text message', async () => {
const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text)
assert.strictEqual(message.message.conversation, 'hello fren')
})
it('should forward a message', async () => {
let messages = await conn.loadConversation (testJid, 1)
await conn.forwardMessage (testJid, messages[0], true)
messages = await conn.loadConversation (testJid, 1)
const message = messages[0]
const content = message.message[ Object.keys(message.message)[0] ]
assert.equal (content?.contextInfo?.isForwarded, true)
})
it('should send a link preview', async () => {
const content = await conn.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys')
const message = await sendAndRetreiveMessage(conn, content, MessageType.text)
const received = message.message.extendedTextMessage
assert.strictEqual(received.text, content.text)
assert.ok (received.canonicalUrl)
assert.ok (received.title)
assert.ok (received.jpegThumbnail)
})
it('should quote a message', async () => {
const messages = await conn.loadConversation(testJid, 2)
const message = await sendAndRetreiveMessage(conn, '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 = await fs.readFile('./Media/ma_gif.mp4')
const message = await sendAndRetreiveMessage(conn, content, MessageType.video, { mimetype: Mimetype.gif })
await conn.downloadAndSaveMediaMessage(message,'./Media/received_vid')
})
it('should send an image', async () => {
const content = await fs.readFile('./Media/meme.jpeg')
const message = await sendAndRetreiveMessage(conn, content, MessageType.image)
await conn.downloadMediaMessage(message)
//const message2 = await sendAndRetreiveMessage (conn, 'this is a quote', MessageType.extendedText)
})
it('should send an image & quote', async () => {
const messages = await conn.loadConversation(testJid, 1)
const content = await fs.readFile('./Media/meme.jpeg')
const message = await sendAndRetreiveMessage(conn, content, MessageType.image, { quoted: messages[0] })
await conn.downloadMediaMessage(message) // check for successful decoding
assert.strictEqual(message.message.imageMessage.contextInfo.stanzaId, messages[0].key.id)
})
it('should send a text message & delete it', async () => {
const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text)
await createTimeout (2000)
await conn.deleteMessage (testJid, message.key)
})
it('should clear the most recent message', async () => {
const messages = await conn.loadConversation (testJid, 1)
await createTimeout (2000)
await conn.clearMessage (messages[0].key)
})
})

113
src/Tests/Tests.Misc.ts Normal file
View File

@@ -0,0 +1,113 @@
import { MessageType, Presence, ChatModification, promiseTimeout, createTimeout } from '../WAConnection/WAConnection'
import fs from 'fs/promises'
import * as assert from 'assert'
import fetch from 'node-fetch'
import { WAConnectionTest, testJid } from './Common'
WAConnectionTest('Presence', (conn) => {
it('should update presence', async () => {
const presences = Object.values(Presence)
for (const i in presences) {
const response = await conn.updatePresence(testJid, presences[i])
assert.strictEqual(response.status, 200)
await createTimeout(1500)
}
})
})
WAConnectionTest('Misc', (conn) => {
it('should tell if someone has an account on WhatsApp', async () => {
const response = await conn.isOnWhatsApp(testJid)
assert.strictEqual(response, true)
const responseFail = await conn.isOnWhatsApp('abcd@s.whatsapp.net')
assert.strictEqual(responseFail, false)
})
it('should return the status', async () => {
const response = await conn.getStatus(testJid)
assert.strictEqual(typeof response.status, 'string')
})
it('should update status', async () => {
const newStatus = 'v cool status'
const response = await conn.getStatus()
assert.strictEqual(typeof response.status, 'string')
await createTimeout (1000)
await conn.setStatus (newStatus)
const response2 = await conn.getStatus()
assert.equal (response2.status, newStatus)
await createTimeout (1000)
await conn.setStatus (response.status) // update back
})
it('should return the stories', async () => {
await conn.getStories()
})
it('should change the profile picture', async () => {
await createTimeout (5000)
const ppUrl = await conn.getProfilePicture(conn.userMetaData.id)
const fetched = await fetch(ppUrl, { headers: { Origin: 'https://web.whatsapp.com' } })
const buff = await fetched.buffer ()
const newPP = await fs.readFile ('./Media/cat.jpeg')
const response = await conn.updateProfilePicture (conn.userMetaData.id, newPP)
await createTimeout (10000)
await conn.updateProfilePicture (conn.userMetaData.id, buff) // revert back
})
it('should return the profile picture', async () => {
const response = await conn.getProfilePicture(testJid)
assert.ok(response)
assert.rejects(conn.getProfilePicture('abcd@s.whatsapp.net'))
})
it('should send typing indicator', async () => {
const response = await conn.updatePresence(testJid, Presence.composing)
assert.ok(response)
})
it('should mark a chat unread', async () => {
await conn.sendReadReceipt(testJid, null, 'unread')
})
it('should archive & unarchive', async () => {
await conn.modifyChat (testJid, ChatModification.archive)
await createTimeout (2000)
await conn.modifyChat (testJid, ChatModification.unarchive)
})
it('should pin & unpin a chat', async () => {
const response = await conn.modifyChat (testJid, ChatModification.pin)
await createTimeout (2000)
await conn.modifyChat (testJid, ChatModification.unpin, {stamp: response.stamp})
})
it('should mute & unmute a chat', async () => {
const mutedate = new Date (new Date().getTime() + 8*60*60*1000) // 8 hours in the future
await conn.modifyChat (testJid, ChatModification.mute, {stamp: mutedate})
await createTimeout (2000)
await conn.modifyChat (testJid, ChatModification.unmute, {stamp: mutedate})
})
it('should return search results', async () => {
const jids = [null, testJid]
for (let i in jids) {
const response = await conn.searchMessages('Hello', jids[i], 25, 1)
assert.ok (response.messages)
assert.ok (response.messages.length >= 0)
}
})
})
WAConnectionTest('Events', (conn) => {
it('should deliver a message', async () => {
const waitForUpdate = () =>
new Promise((resolve) => {
conn.setOnMessageStatusChange((update) => {
if (update.ids.includes(response.key.id)) {
resolve()
}
})
})
const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text)
await promiseTimeout(15000, waitForUpdate())
})
})

View File

@@ -1,155 +0,0 @@
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',
product = 'productMessage'
}
export enum ChatModification {
archive='archive',
unarchive='unarchive',
pin='pin',
unpin='unpin',
mute='mute',
unmute='unmute'
}
export const HKDFInfoKeys = {
[MessageType.image]: 'WhatsApp Image Keys',
[MessageType.audio]: 'WhatsApp Audio Keys',
[MessageType.video]: 'WhatsApp Video Keys',
[MessageType.document]: 'WhatsApp Document Keys',
[MessageType.sticker]: 'WhatsApp Image Keys'
}
export enum Mimetype {
jpeg = 'image/jpeg',
png = 'image/png',
mp4 = 'video/mp4',
gif = 'video/gif',
pdf = 'application/pdf',
ogg = 'audio/ogg; codecs=opus',
/** for stickers */
webp = 'image/webp',
}
export interface MessageOptions {
quoted?: WAMessage
contextInfo?: WAContextInfo
timestamp?: Date
caption?: string
thumbnail?: string
mimetype?: Mimetype | string
validateID?: boolean,
filename?: string
}
export interface WABroadcastListInfo {
status: number
name: string
recipients?: {id: string}[]
}
export interface WAUrlInfo {
'canonical-url': string
'matched-text': string
title: string
description: string
jpegThumbnail?: Buffer
}
export interface WAProfilePictureChange {
status: number
tag: string
eurl: string
}
export interface MessageInfo {
reads: {jid: string, t: string}[]
deliveries: {jid: string, t: string}[]
}
export interface MessageStatusUpdate {
from: string
to: string
/** Which participant caused the update (only for groups) */
participant?: string
timestamp: Date
/** Message IDs read/delivered */
ids: string[]
/** Status of the Message IDs */
type: WA_MESSAGE_STATUS_TYPE
}
export enum GroupSettingChange {
messageSend = 'announcement',
settingsChange = 'locked',
}
export interface PresenceUpdate {
id: string
participant?: string
t?: string
type?: Presence
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
message: WAMessage
}
export interface WALocationMessage {
degreesLatitude: number
degreesLongitude: number
address?: string
}
export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE
export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS
/** Reverse stub type dictionary */
export const WAMessageType = function () {
const types = WA_MESSAGE_STUB_TYPE
const dict: Record<number, string> = {}
Object.keys(types).forEach(element => dict[ types[element] ] = element)
return dict
}()
export type WAContactMessage = proto.ContactMessage
export type WAMessageKey = proto.IMessageKey
export type WATextMessage = proto.ExtendedTextMessage
export type WAContextInfo = proto.IContextInfo

View File

@@ -1,275 +0,0 @@
import { WAClient } from './WAClient'
import { MessageType, MessageOptions, Mimetype, Presence, ChatModification, GroupSettingChange } from './Constants'
import * as fs from 'fs'
import * as assert from 'assert'
import fetch from 'node-fetch'
import { decodeMediaMessage, validateJIDForSending } from './Utils'
import { promiseTimeout, createTimeout, Browsers, generateMessageTag } from '../WAConnection/Utils'
import { MessageLogLevel } from '../WAConnection/Constants'
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
async function sendAndRetreiveMessage(client: WAClient, content, type: MessageType, options: MessageOptions = {}) {
const response = await client.sendMessage(testJid, content, type, options)
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()
client.logLevel = MessageLogLevel.info
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 forward a message', async () => {
let messages = await client.loadConversation (testJid, 1)
await client.forwardMessage (testJid, messages[0])
messages = await client.loadConversation (testJid, 1)
const message = messages[0]
const content = message.message[ Object.keys(message.message)[0] ]
assert.equal (content?.contextInfo?.isForwarded, true)
})
it('should send a link preview', async () => {
const content = await client.generateLinkPreview ('hello this is from https://www.github.com/adiwajshing/Baileys')
const message = await sendAndRetreiveMessage(client, content, MessageType.text)
const received = message.message.extendedTextMessage
assert.strictEqual(received.text, content.text)
fs.writeFileSync ('Media/received-thumb.jpeg', content.jpegThumbnail)
})
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 })
await client.downloadAndSaveMediaMessage(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)
})
it('should send a text message & delete it', async () => {
const message = await sendAndRetreiveMessage(client, 'hello fren', MessageType.text)
await createTimeout (2000)
await client.deleteMessage (testJid, message.key)
})
it('should clear the most recent message', async () => {
const messages = await client.loadConversation (testJid, 1)
await createTimeout (2000)
await client.clearMessage (messages[0].key)
})
})
describe('Validate WhatsApp IDs', () => {
it ('should correctly validate', () => {
assert.doesNotThrow (() => validateJIDForSending ('12345@s.whatsapp.net'))
assert.doesNotThrow (() => validateJIDForSending ('919999999999@s.whatsapp.net'))
assert.doesNotThrow (() => validateJIDForSending ('10203040506@s.whatsapp.net'))
assert.doesNotThrow (() => validateJIDForSending ('12345-3478@g.us'))
assert.doesNotThrow (() => validateJIDForSending ('1234567890-34712121238@g.us'))
assert.throws (() => validateJIDForSending ('123454677@c.us'))
assert.throws (() => validateJIDForSending ('+123454677@s.whatsapp.net'))
assert.throws (() => validateJIDForSending ('+12345-3478@g.us'))
})
})
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.strictEqual(typeof response.status, 'string')
})
it('should update status', async () => {
const newStatus = 'v cool status'
const response = await client.getStatus()
assert.strictEqual(typeof response.status, 'string')
await createTimeout (1000)
await client.setStatus (newStatus)
const response2 = await client.getStatus()
assert.equal (response2.status, newStatus)
await createTimeout (1000)
await client.setStatus (response.status) // update back
})
it('should return the stories', async () => {
await client.getStories()
})
it('should change the profile picture', async () => {
await createTimeout (5000)
const ppUrl = await client.getProfilePicture(client.userMetaData.id)
const fetched = await fetch(ppUrl, { headers: { Origin: 'https://web.whatsapp.com' } })
const buff = await fetched.buffer ()
const newPP = fs.readFileSync ('./Media/cat.jpeg')
const response = await client.updateProfilePicture (client.userMetaData.id, newPP)
await createTimeout (10000)
await client.updateProfilePicture (client.userMetaData.id, buff) // revert back
})
it('should return the profile picture', async () => {
const response = await client.getProfilePicture(testJid)
assert.ok(response)
assert.rejects(client.getProfilePicture('abcd@s.whatsapp.net'))
})
it('should send typing indicator', async () => {
const response = await client.updatePresence(testJid, Presence.composing)
assert.ok(response)
})
it('should mark a chat unread', async () => {
await client.sendReadReceipt(testJid, null, 'unread')
})
it('should archive & unarchive', async () => {
await client.modifyChat (testJid, ChatModification.archive)
await createTimeout (2000)
await client.modifyChat (testJid, ChatModification.unarchive)
})
it('should pin & unpin a chat', async () => {
const response = await client.modifyChat (testJid, ChatModification.pin)
await createTimeout (2000)
await client.modifyChat (testJid, ChatModification.unpin, {stamp: response.stamp})
})
it('should mute & unmute a chat', async () => {
const mutedate = new Date (new Date().getTime() + 8*60*60*1000) // 8 hours in the future
await client.modifyChat (testJid, ChatModification.mute, {stamp: mutedate})
await createTimeout (2000)
await client.modifyChat (testJid, ChatModification.unmute, {stamp: mutedate})
})
it('should return search results', async () => {
const jids = [null, testJid]
for (let i in jids) {
const response = await client.searchMessages('Hello', jids[i], 25, 1)
assert.ok (response.messages)
assert.ok (response.messages.length >= 0)
}
})
})
WAClientTest('Groups', (client) => {
let gid: string
it('should create a group', async () => {
const response = await client.groupCreate('Cool Test Group', [testJid])
gid = response.gid
console.log('created group: ' + JSON.stringify(response))
})
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 update the group description', async () => {
const newDesc = 'Wow this was set from Baileys'
await client.groupUpdateDescription (gid, newDesc)
await createTimeout (1000)
const metadata = await client.groupMetadata(gid)
assert.strictEqual(metadata.desc, newDesc)
})
it('should send a message on the group', async () => {
await client.sendMessage(gid, 'hello', MessageType.text)
})
it('should update the subject', async () => {
const subject = 'V Cool Title'
await client.groupUpdateSubject(gid, subject)
const metadata = await client.groupMetadata(gid)
assert.strictEqual(metadata.subject, subject)
})
it('should update the group settings', async () => {
await client.groupSettingChange (gid, GroupSettingChange.messageSend, true)
await createTimeout (5000)
await client.groupSettingChange (gid, GroupSettingChange.settingsChange, true)
})
it('should remove someone from a group', async () => {
await client.groupRemove(gid, [testJid])
})
it('should leave the group', async () => {
await client.groupLeave(gid)
await client.groupMetadataMinimal (gid)
})
it('should archive the group', async () => {
await client.archiveChat(gid)
})
it('should delete the group', async () => {
await client.deleteChat(gid)
})
})
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 retreive all conversations', async () => {
const [chats] = await client.receiveChatsAndContacts (10000)
for (let chat of chats.all()) {
console.log ('receiving ' + chat.jid)
const convo = await client.loadConversation (chat.jid.replace('@s.whatsapp.net', '@c.us'), 25)
await createTimeout (200)
}
})*/
})

View File

@@ -1,193 +0,0 @@
import { MessageType, HKDFInfoKeys, MessageOptions, WAMessageType } from './Constants'
import Jimp from 'jimp'
import * as fs from 'fs'
import fetch from 'node-fetch'
import { WAMessage, WAMessageContent, BaileysError } from '../WAConnection/Constants'
import { hmacSign, aesDecryptWithIV, hkdf } from '../WAConnection/Utils'
import { proto } from '../../WAMessage/WAMessage'
import { randomBytes } from 'crypto'
import { exec } from 'child_process'
export function validateJIDForSending (jid: string) {
const regexp = /^[0-9]{1,20}(-[0-9]{1,20}@g.us|@s.whatsapp.net)$/
if (!regexp.test (jid)) {
throw new Error (
`Invalid WhatsApp id: ${jid}
1. Please ensure you suffix '@s.whatsapp.net' for individual numbers & '@g.us' for groups
2. Please do not put any alphabets or special characters like a '+' in the number. A '-' symbol in groups is fine`
)
}
}
/**
* Type of notification
* @deprecated use WA_MESSAGE_STUB_TYPE instead
* */
export function getNotificationType(message: WAMessage): [string, MessageType?] {
if (message.message) {
return ['message', Object.keys(message.message)[0] as MessageType]
} else if (message.messageStubType) {
return [WAMessageType[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) {
if (typeof buffer === 'string') {
buffer = Buffer.from (buffer.replace('data:;base64,', ''), 'base64')
}
// expand using HKDF to 112 bytes, also pass in the relevant app info
const expandedMediaKey = hkdf(buffer, 112, HKDFInfoKeys[mediaType])
return {
iv: expandedMediaKey.slice(0, 16),
cipherKey: expandedMediaKey.slice(16, 48),
macKey: expandedMediaKey.slice(48, 80),
}
}
/** Extracts video thumb using FFMPEG */
const extractVideoThumb = async (
path: string,
destPath: string,
time: string,
size: { width: number; height: number },
) =>
new Promise((resolve, reject) => {
const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}`
exec(cmd, (err) => {
if (err) reject(err)
else resolve()
})
}) as Promise<void>
export const compressImage = async (buffer: Buffer) => {
const jimp = await Jimp.read (buffer)
return jimp.resize(48, 48).getBufferAsync (Jimp.MIME_JPEG)
}
export const generateProfilePicture = async (buffer: Buffer) => {
const jimp = await Jimp.read (buffer)
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
const cropped = jimp.crop (0, 0, min, min)
return {
img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG),
preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG)
}
}
/** 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 new Error('audio messages cannot have thumbnails')
}
} else if (mediaType === MessageType.image || mediaType === MessageType.sticker) {
const buff = await compressImage (buffer)
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) & return decrypted buffer
* @param message the media message you want to decode
*/
export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchHeaders: {[k: string]: string} = {}) {
/*
One can infer media type from the key in the message
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
*/
const type = Object.keys(message)[0] as MessageType
if (!type) {
throw new BaileysError('unknown message type', message)
}
if (type === MessageType.text || type === MessageType.extendedText) {
throw new BaileysError('cannot decode text message', message)
}
if (type === MessageType.location || type === MessageType.liveLocation) {
return new Buffer(message[type].jpegThumbnail)
}
let messageContent: proto.IVideoMessage | proto.IImageMessage | proto.IAudioMessage | proto.IDocumentMessage
if (message.productMessage) {
const product = message.productMessage.product?.productImage
if (!product) throw new BaileysError ('product has no image', message)
messageContent = product
} else {
messageContent = message[type]
}
// download the message
const headers = { Origin: 'https://web.whatsapp.com' }
const fetched = await fetch(messageContent.url, { headers })
const buffer = await fetched.buffer()
if (buffer.length <= 10) {
throw new BaileysError ('Empty buffer returned. File has possibly been deleted from WA servers. Run `client.updateMediaMessage()` to refresh the url', {status: 404})
}
const decryptedMedia = (type: MessageType) => {
// get the keys to decrypt the message
const mediaKeys = getMediaKeys(messageContent.mediaKey, type) //getMediaKeys(Buffer.from(messageContent.mediaKey, 'base64'), type)
// 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([mediaKeys.iv, file])
const sign = hmacSign(testBuff, mediaKeys.macKey).slice(0, 10)
// our sign should equal the mac
if (!sign.equals(mac)) throw new Error()
return aesDecryptWithIV(file, mediaKeys.cipherKey, mediaKeys.iv) // decrypt media
}
const allTypes = [type, ...Object.keys(HKDFInfoKeys)]
for (let i = 0; i < allTypes.length;i++) {
try {
const decrypted = decryptedMedia (allTypes[i] as MessageType)
if (i > 0) { console.log (`decryption of ${type} media with HKDF key of ${allTypes[i]}`) }
return decrypted
} catch {
if (i === 0) { console.log (`decryption of ${type} media with original HKDF key failed`) }
}
}
throw new BaileysError('Decryption failed, HMAC sign does not match', {status: 400})
}
export function extensionForMediaMessage(message: WAMessageContent) {
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
const type = Object.keys(message)[0] as MessageType
let extension: string
if (type === MessageType.location || type === MessageType.liveLocation || type === MessageType.product) {
extension = '.jpeg'
} else {
const messageContent = message[type] as
| proto.VideoMessage
| proto.ImageMessage
| proto.AudioMessage
| proto.DocumentMessage
extension = getExtension (messageContent.mimetype)
}
return extension
}
/**
* Decode a media message (video, image, document, audio) & save it to the given file
* @deprecated use `client.downloadAndSaveMediaMessage`
*/
export async function decodeMediaMessage(message: WAMessageContent, filename: string, attachExtension: boolean=true) {
const buffer = await decodeMediaMessageBuffer (message, {})
const extension = extensionForMediaMessage (message)
const trueFileName = attachExtension ? (filename + '.' + extension) : filename
fs.writeFileSync(trueFileName, buffer)
return trueFileName
}

View File

@@ -1,7 +0,0 @@
import WhatsAppWebMessages from './Messages'
export { WhatsAppWebMessages as WAClient }
export * from './Constants'
export * from './Utils'
export * from '../WAConnection/Constants'
export { Browsers } from '../WAConnection/Utils'

View File

@@ -14,6 +14,11 @@ import {
AuthenticationCredentialsBrowser,
BaileysError,
WAConnectionMode,
WAMessage,
PresenceUpdate,
MessageStatusUpdate,
WAMetric,
WAFlag,
} from './Constants'
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
@@ -22,7 +27,7 @@ const generateQRCode = function ([ref, publicKey, clientID]) {
QR.generate(str, { small: true })
}
export default class WAConnectionBase {
export class WAConnection {
/** The version of WhatsApp Web we're telling the servers we are */
version: [number, number, number] = [2, 2027, 10]
/** The Browser we're telling the WhatsApp Web servers we are */
@@ -61,9 +66,7 @@ export default class WAConnectionBase {
protected pendingRequests: (() => void)[] = []
protected reconnectLoop: () => Promise<void>
protected referenceDate = new Date () // used for generating tags
protected userAgentString: string
constructor () {
this.userAgentString = Utils.userAgentString (this.browserDescription[1])
this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind))
}
async unexpectedDisconnect (error: string) {
@@ -74,6 +77,46 @@ export default class WAConnectionBase {
this.unexpectedDisconnectCallback (error)
}
}
/** 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 data: MessageStatusUpdate = {
from: json.from,
to: json.to,
participant: json.participant,
timestamp: new Date(json.t * 1000),
ids: ids,
type: (+json.ack)+1,
}
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 {
this.log(`[Unhandled] message - ${JSON.stringify(message)}`, MessageLogLevel.unhandled)
}
})
}
/** 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]))
}
/** Set the callback for unexpected disconnects including take over events, log out events etc. */
setOnUnexpectedDisconnect(callback: (error: string) => void) {
this.unexpectedDisconnectCallback = callback
@@ -243,6 +286,12 @@ export default class WAConnectionBase {
return this.waitForMessage(tag, json, timeoutMs)
}
/** Generic function for action, set queries */
async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) {
const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes]
const result = await this.queryExpecting200(json, binaryTags, null, tag) as Promise<{status: number}>
return result
}
/**
* Send a binary encoded message
* @param json the message to encode & send

View File

@@ -1,10 +1,10 @@
import * as Curve from 'curve25519-js'
import * as Utils from './Utils'
import WAConnectionBase from './Base'
import { MessageLogLevel, WAMetric, WAFlag, BaileysError } from './Constants'
import { Presence } from '../WAClient/WAClient'
import {WAConnection as Base} from './0.Base'
import { MessageLogLevel, WAMetric, WAFlag, BaileysError, Presence } from './Constants'
export default class WAConnectionValidator extends WAConnectionBase {
export class WAConnection extends Base {
/** Authenticate the connection */
protected async authenticate() {
if (!this.authInfo.clientID) {
@@ -21,6 +21,7 @@ export default class WAConnectionValidator extends WAConnectionBase {
this.referenceDate = new Date () // refresh reference date
const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true]
return this.queryExpecting200(data)
.then(json => {
// we're trying to establish a new connection or are trying to log in

View File

@@ -2,10 +2,10 @@ import WS from 'ws'
import KeyedDB from '@adiwajshing/keyed-db'
import * as Utils from './Utils'
import { AuthenticationCredentialsBase64, UserMetaData, WAMessage, WAChat, WAContact, MessageLogLevel, WANode, WAConnectionMode } from './Constants'
import WAConnectionValidator from './Validation'
import {WAConnection as Base} from './1.Validation'
import Decoder from '../Binary/Decoder'
export default class WAConnectionConnector extends WAConnectionValidator {
export class WAConnection extends Base {
/**
* Connect to WhatsAppWeb
* @param [authInfo] credentials or path to credentials to log back in

View File

@@ -1,58 +1,16 @@
import WAConnection from '../WAConnection/WAConnection'
import { MessageStatusUpdate, PresenceUpdate, Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants'
import {WAConnection as Base} from './3.Connect'
import { Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants'
import {
WAMessage,
WANode,
WAMetric,
WAFlag,
MessageLogLevel,
WATag,
} from '../WAConnection/Constants'
import { generateProfilePicture } from '../WAClient/Utils'
import { generateProfilePicture } from './Utils'
// All user related functions -- get profile picture, set status etc.
export default class WhatsAppWebBase extends WAConnection {
/** 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 data: MessageStatusUpdate = {
from: json.from,
to: json.to,
participant: json.participant,
timestamp: new Date(json.t * 1000),
ids: ids,
type: (+json.ack)+1,
}
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 {
this.log(`[Unhandled] message - ${JSON.stringify(message)}`, MessageLogLevel.unhandled)
}
})
}
/** 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]))
}
export class WAConnection extends Base {
/** Query whether a given number is registered on WhatsApp */
isOnWhatsApp = (jid: string) => this.query(['query', 'exist', jid]).then((m) => m.status === 200)
/**
@@ -75,13 +33,15 @@ export default class WhatsAppWebBase extends WAConnection {
return this.query(['query', 'Status', jid || this.userMetaData.id]) as Promise<{ status: string }>
}
async setStatus (status: string) {
return this.setQuery ([
return this.setQuery (
[
'status',
null,
Buffer.from (status, 'utf-8')
[
'status',
null,
Buffer.from (status, 'utf-8')
]
]
])
)
}
/** Get the URL to download the profile picture of a person/group */
async getProfilePicture(jid: string | null) {
@@ -157,10 +117,7 @@ export default class WhatsAppWebBase extends WAConnection {
},
null,
]
const response = await this.query(json, [WAMetric.queryMessages, WAFlag.ignore])
if (response.status) throw new Error(`error in query, got status: ${response.status}`)
const response = await this.queryExpecting200(json, [WAMetric.queryMessages, WAFlag.ignore])
return response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : []
}
/**
@@ -210,10 +167,4 @@ export default class WhatsAppWebBase extends WAConnection {
]
return this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise<WAProfilePictureChange>
}
/** Generic function for action, set queries */
async setQuery (nodes: WANode[], binaryTags: WATag = [WAMetric.group, WAFlag.ignore], tag?: string) {
const json = ['action', {epoch: this.msgCount.toString(), type: 'set'}, nodes]
const result = await this.queryExpecting200(json, binaryTags, null, tag) as Promise<{status: number}>
return result
}
}

View File

@@ -1,6 +1,6 @@
import WhatsAppWebGroups from './Groups'
import {WAConnection as Base} from './4.User'
import fetch from 'node-fetch'
import { promises as fs } from 'fs'
import fs from 'fs/promises'
import {
MessageOptions,
MessageType,
@@ -15,13 +15,11 @@ import {
MessageInfo,
WATextMessage,
WAUrlInfo,
WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE
} from './Constants'
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes } from '../WAConnection/Utils'
import { WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel } from '../WAConnection/Constants'
import { validateJIDForSending, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage } from './Utils'
import { proto } from '../../WAMessage/WAMessage'
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage } from './Utils'
export default class WhatsAppWebMessages extends WhatsAppWebGroups {
export class WAConnection extends Base {
/** Get the message info, who has read it, who its been delivered to */
async messageInfo (jid: string, messageID: string) {
const query = ['query', {type: 'message_info', index: messageID, jid: jid, epoch: this.msgCount.toString()}, null]
@@ -56,16 +54,6 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups {
}
return this.setQuery ([['read', attributes, null]])
}
/**
* Mark a given chat as unread
* @deprecated since 2.0.0, use `sendReadReceipt (jid, null, 'unread')` instead
*/
async markChatUnread (jid: string) { return this.sendReadReceipt (jid, null, 'unread') }
/**
* Archive a chat
* @deprecated since 2.0.0, use `modifyChat (jid, ChatModification.archive)` instead
*/
async archiveChat (jid: string) { return this.modifyChat (jid, ChatModification.archive) }
/**
* Modify a given chat (archive, pin etc.)
* @param jid the ID of the person/group you are modifiying
@@ -190,24 +178,27 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups {
const json: WAMessageContent = {
protocolMessage: {
key: messageKey,
type: proto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE
type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE
}
}
return this.sendMessageContent (id, json, {})
const waMessage = this.generateWAMessage (id, json, {})
await this.relayWAMessage (waMessage)
return waMessage
}
/**
* Forward a message like WA does
* @param id the id to forward the message to
* @param message the message to forward
* @param forceForward will show the message as forwarded even if it is from you
*/
async forwardMessage(id: string, message: WAMessage) {
async forwardMessage(id: string, message: WAMessage, forceForward: boolean=false) {
const content = message.message
if (!content) throw new Error ('no content in message')
let key = Object.keys(content)[0]
let score = content[key].contextInfo?.forwardingScore || 0
score += message.key.fromMe ? 0 : 1
score += message.key.fromMe && !forceForward ? 0 : 1
if (key === MessageType.text) {
content[MessageType.extendedText] = { text: content[key] }
delete content[MessageType.text]
@@ -216,18 +207,35 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups {
}
if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true }
else content[key].contextInfo = {}
return this.sendMessageContent (id, content, {})
const waMessage = this.generateWAMessage (id, content, {})
await this.relayWAMessage (waMessage)
return waMessage
}
/**
* Send a message to the given ID (can be group, single, or broadcast)
* @param id
* @param message
* @param type
* @param options
*/
async sendMessage(
id: string,
message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer,
type: MessageType,
options: MessageOptions = {},
) {
if (options.validateID === true || !('validateID' in options)) {
validateJIDForSending (id)
}
const waMessage = await this.prepareMessage (id, message, type, options)
await this.relayWAMessage (waMessage)
return waMessage
}
/** Prepares a message for sending via sendWAMessage () */
async prepareMessage(
id: string,
message: string | WATextMessage | WALocationMessage | WAContactMessage | Buffer,
type: MessageType,
options: MessageOptions = {},
) {
let m: WAMessageContent = {}
switch (type) {
case MessageType.text:
@@ -251,7 +259,7 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups {
m = await this.prepareMediaMessage(message as Buffer, type, options)
break
}
return this.sendMessageContent(id, m, options)
return this.generateWAMessage(id, m, options)
}
/** Prepare a media message for sending */
async prepareMediaMessage(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) {
@@ -315,15 +323,13 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups {
}
return message as WAMessageContent
}
/** Send message content */
async sendMessageContent(id: string, message: WAMessageContent, options: MessageOptions) {
const messageJSON = this.generateWAMessage (id, message, options)
return this.sendWAMessage (messageJSON)
}
/** generates a WAMessage from the given content & options */
generateWAMessage(id: string, message: WAMessageContent, options: MessageOptions) {
if (!options.timestamp) options.timestamp = new Date() // set timestamp to now
// prevent an annoying bug (WA doesn't accept sending messages with '@c.us')
id = id.replace ('@c.us', '@s.whatsapp')
const key = Object.keys(message)[0]
const timestamp = options.timestamp.getTime()/1000
const quoted = options.quoted
@@ -356,29 +362,22 @@ export default class WhatsAppWebMessages extends WhatsAppWebGroups {
messageTimestamp: timestamp,
messageStubParameters: [],
participant: id.includes('@g.us') ? this.userMetaData.id : null,
status: WAMessageProto.proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS.PENDING
status: WA_MESSAGE_STATUS_TYPE.PENDING
}
return messageJSON as WAMessage
}
/**
* Send a WAMessage; more advanced functionality, you may want to stick with sendMessage()
* */
async sendWAMessage(message: WAMessage) {
/** Relay (send) a WAMessage; more advanced functionality to send a built WA Message, you may want to stick with sendMessage() */
async relayWAMessage(message: WAMessage) {
const json = ['action', {epoch: this.msgCount.toString(), type: 'relay'}, [['message', null, message]]]
const flag = message.key.remoteJid === this.userMetaData.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
const response = await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id)
return {
status: response.status as number,
messageID: message.key.id,
message: message as WAMessage
} as WASendMessageResponse
await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id)
}
/**
* Securely downloads the media from the message.
* Renews the download url automatically, if necessary.
*/
async downloadMediaMessage (message: WAMessage) {
const fetchHeaders = { 'User-Agent': this.userAgentString }
const fetchHeaders = { }
try {
const buff = await decodeMediaMessageBuffer (message.message, fetchHeaders)
return buff

View File

@@ -1,9 +1,9 @@
import WhatsAppWebBase from './Base'
import { WAMessage, WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants'
import {WAConnection as Base} from './5.Messages'
import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants'
import { GroupSettingChange } from './Constants'
import { generateMessageID } from '../WAConnection/Utils'
export default class WhatsAppWebGroups extends WhatsAppWebBase {
export class WAConnection extends Base {
/** Generic function for group queries */
async groupQuery(type: string, jid?: string, subject?: string, participants?: string[], additionalNodes?: WANode[]) {
const tag = this.generateMessageTag()

View File

@@ -133,6 +133,152 @@ export enum WAFlag {
}
/** Tag used with binary queries */
export type WATag = [WAMetric, WAFlag]
export * as WAMessageProto from '../../WAMessage/WAMessage'
// export the WAMessage Prototype as well
export { proto as WAMessageProto } 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',
product = 'productMessage'
}
export enum ChatModification {
archive='archive',
unarchive='unarchive',
pin='pin',
unpin='unpin',
mute='mute',
unmute='unmute'
}
export const HKDFInfoKeys = {
[MessageType.image]: 'WhatsApp Image Keys',
[MessageType.audio]: 'WhatsApp Audio Keys',
[MessageType.video]: 'WhatsApp Video Keys',
[MessageType.document]: 'WhatsApp Document Keys',
[MessageType.sticker]: 'WhatsApp Image Keys'
}
export enum Mimetype {
jpeg = 'image/jpeg',
png = 'image/png',
mp4 = 'video/mp4',
gif = 'video/gif',
pdf = 'application/pdf',
ogg = 'audio/ogg; codecs=opus',
/** for stickers */
webp = 'image/webp',
}
export interface MessageOptions {
quoted?: WAMessage
contextInfo?: WAContextInfo
timestamp?: Date
caption?: string
thumbnail?: string
mimetype?: Mimetype | string
filename?: string
}
export interface WABroadcastListInfo {
status: number
name: string
recipients?: {id: string}[]
}
export interface WAUrlInfo {
'canonical-url': string
'matched-text': string
title: string
description: string
jpegThumbnail?: Buffer
}
export interface WAProfilePictureChange {
status: number
tag: string
eurl: string
}
export interface MessageInfo {
reads: {jid: string, t: string}[]
deliveries: {jid: string, t: string}[]
}
export interface MessageStatusUpdate {
from: string
to: string
/** Which participant caused the update (only for groups) */
participant?: string
timestamp: Date
/** Message IDs read/delivered */
ids: string[]
/** Status of the Message IDs */
type: WA_MESSAGE_STATUS_TYPE
}
export enum GroupSettingChange {
messageSend = 'announcement',
settingsChange = 'locked',
}
export interface PresenceUpdate {
id: string
participant?: string
t?: string
type?: Presence
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
message: WAMessage
}
export interface WALocationMessage {
degreesLatitude: number
degreesLongitude: number
address?: string
}
export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE
export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS
/** Reverse stub type dictionary */
export const WAMessageType = function () {
const types = WA_MESSAGE_STUB_TYPE
const dict: Record<number, string> = {}
Object.keys(types).forEach(element => dict[ types[element] ] = element)
return dict
}()
export type WAContactMessage = proto.ContactMessage
export type WAMessageKey = proto.IMessageKey
export type WATextMessage = proto.ExtendedTextMessage
export type WAContextInfo = proto.IContextInfo

View File

@@ -1,9 +1,13 @@
import * as Crypto from 'crypto'
import HKDF from 'futoin-hkdf'
import Decoder from '../Binary/Decoder'
import Jimp from 'jimp'
import fs from 'fs/promises'
import fetch from 'node-fetch'
import { exec } from 'child_process'
import {platform, release} from 'os'
import { BaileysError, WAChat } from './Constants'
import UserAgent from 'user-agents'
import Decoder from '../Binary/Decoder'
import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageType, WAMessage, WAMessageContent, BaileysError, WAMessageProto } from './Constants'
const platformMap = {
'aix': 'AIX',
@@ -25,10 +29,10 @@ function hashCode(s: string) {
}
export const waChatUniqueKey = (c: WAChat) => ((+c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending
export function userAgentString (browser) {
/*export function userAgentString (browser) {
const agent = new UserAgent (new RegExp(browser))
return agent.toString ()
}
}*/
/** 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))
@@ -65,6 +69,7 @@ export function randomBytes(length) {
return Crypto.randomBytes(length)
}
export const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout))
export async function promiseTimeout<T>(ms: number, promise: Promise<T>) {
if (!ms) return promise
// Create a promise that rejects in <ms> milliseconds
@@ -139,4 +144,151 @@ export function decryptWA (message: string | Buffer, macKey: Buffer, encKey: Buf
json = decoder.read(decrypted) // decode the binary message into a JSON array
}
return [messageTag, json, tags]
}
/** generates all the keys required to encrypt/decrypt & sign a media message */
export function getMediaKeys(buffer, mediaType: MessageType) {
if (typeof buffer === 'string') {
buffer = Buffer.from (buffer.replace('data:;base64,', ''), 'base64')
}
// expand using HKDF to 112 bytes, also pass in the relevant app info
const expandedMediaKey = hkdf(buffer, 112, HKDFInfoKeys[mediaType])
return {
iv: expandedMediaKey.slice(0, 16),
cipherKey: expandedMediaKey.slice(16, 48),
macKey: expandedMediaKey.slice(48, 80),
}
}
/** Extracts video thumb using FFMPEG */
const extractVideoThumb = async (
path: string,
destPath: string,
time: string,
size: { width: number; height: number },
) =>
new Promise((resolve, reject) => {
const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}`
exec(cmd, (err) => {
if (err) reject(err)
else resolve()
})
}) as Promise<void>
export const compressImage = async (buffer: Buffer) => {
const jimp = await Jimp.read (buffer)
return jimp.resize(48, 48).getBufferAsync (Jimp.MIME_JPEG)
}
export const generateProfilePicture = async (buffer: Buffer) => {
const jimp = await Jimp.read (buffer)
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
const cropped = jimp.crop (0, 0, min, min)
return {
img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG),
preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG)
}
}
/** 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 new Error('audio messages cannot have thumbnails')
}
} else if (mediaType === MessageType.image || mediaType === MessageType.sticker) {
const buff = await compressImage (buffer)
info.thumbnail = buff.toString('base64')
} else if (mediaType === MessageType.video) {
const filename = './' + randomBytes(5).toString('hex') + '.mp4'
const imgFilename = filename + '.jpg'
await fs.writeFile(filename, buffer)
try {
await extractVideoThumb(filename, imgFilename, '00:00:00', { width: 48, height: 48 })
const buff = await fs.readFile(imgFilename)
info.thumbnail = buff.toString('base64')
await fs.unlink(imgFilename)
} catch (err) {
console.log('could not generate video thumb: ' + err)
}
await fs.unlink(filename)
}
}
/**
* Decode a media message (video, image, document, audio) & return decrypted buffer
* @param message the media message you want to decode
*/
export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchHeaders: {[k: string]: string} = {}) {
/*
One can infer media type from the key in the message
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
*/
const type = Object.keys(message)[0] as MessageType
if (!type) {
throw new BaileysError('unknown message type', message)
}
if (type === MessageType.text || type === MessageType.extendedText) {
throw new BaileysError('cannot decode text message', message)
}
if (type === MessageType.location || type === MessageType.liveLocation) {
return new Buffer(message[type].jpegThumbnail)
}
let messageContent: WAMessageProto.IVideoMessage | WAMessageProto.IImageMessage | WAMessageProto.IAudioMessage | WAMessageProto.IDocumentMessage
if (message.productMessage) {
const product = message.productMessage.product?.productImage
if (!product) throw new BaileysError ('product has no image', message)
messageContent = product
} else {
messageContent = message[type]
}
// download the message
const headers = { Origin: 'https://web.whatsapp.com' }
const fetched = await fetch(messageContent.url, { headers })
const buffer = await fetched.buffer()
if (buffer.length <= 10) {
throw new BaileysError ('Empty buffer returned. File has possibly been deleted from WA servers. Run `client.updateMediaMessage()` to refresh the url', {status: 404})
}
const decryptedMedia = (type: MessageType) => {
// get the keys to decrypt the message
const mediaKeys = getMediaKeys(messageContent.mediaKey, type) //getMediaKeys(Buffer.from(messageContent.mediaKey, 'base64'), type)
// 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([mediaKeys.iv, file])
const sign = hmacSign(testBuff, mediaKeys.macKey).slice(0, 10)
// our sign should equal the mac
if (!sign.equals(mac)) throw new Error()
return aesDecryptWithIV(file, mediaKeys.cipherKey, mediaKeys.iv) // decrypt media
}
const allTypes = [type, ...Object.keys(HKDFInfoKeys)]
for (let i = 0; i < allTypes.length;i++) {
try {
const decrypted = decryptedMedia (allTypes[i] as MessageType)
if (i > 0) { console.log (`decryption of ${type} media with HKDF key of ${allTypes[i]}`) }
return decrypted
} catch {
if (i === 0) { console.log (`decryption of ${type} media with original HKDF key failed`) }
}
}
throw new BaileysError('Decryption failed, HMAC sign does not match', {status: 400})
}
export function extensionForMediaMessage(message: WAMessageContent) {
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
const type = Object.keys(message)[0] as MessageType
let extension: string
if (type === MessageType.location || type === MessageType.liveLocation || type === MessageType.product) {
extension = '.jpeg'
} else {
const messageContent = message[type] as
| WAMessageProto.VideoMessage
| WAMessageProto.ImageMessage
| WAMessageProto.AudioMessage
| WAMessageProto.DocumentMessage
extension = getExtension (messageContent.mimetype)
}
return extension
}

View File

@@ -1,2 +1,3 @@
import WAConnection from './Connect'
export default WAConnection
export * from './6.Groups'
export * from './Utils'
export * from './Constants'