mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Moved to src
This commit is contained in:
230
src/WAClient/Base.ts
Normal file
230
src/WAClient/Base.ts
Normal 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
109
src/WAClient/Constants.ts
Normal 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
172
src/WAClient/Messages.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import WhatsAppWebBase from './Base'
|
||||
import fetch from 'node-fetch'
|
||||
import {
|
||||
MessageOptions,
|
||||
MessageType,
|
||||
Mimetype,
|
||||
MimetypeMap,
|
||||
MediaPathMap,
|
||||
WALocationMessage,
|
||||
WAContactMessage,
|
||||
WASendMessageResponse,
|
||||
Presence,
|
||||
} from './Constants'
|
||||
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes } from '../WAConnection/Utils'
|
||||
import { WAMessageContent, WAMetric, WAFlag } from '../WAConnection/Constants'
|
||||
import { generateThumbnail, getMediaKeys } from './Utils'
|
||||
|
||||
export default class WhatsAppWebMessages extends WhatsAppWebBase {
|
||||
/**
|
||||
* Send a read receipt to the given ID for a certain message
|
||||
* @param {string} jid the ID of the person/group whose message you want to mark read
|
||||
* @param {string} messageID the message ID
|
||||
*/
|
||||
sendReadReceipt(jid: string, messageID: string) {
|
||||
const json = [
|
||||
'action',
|
||||
{ epoch: this.msgCount.toString(), type: 'set' },
|
||||
[['read', { count: '1', index: messageID, jid: jid, owner: 'false' }, null]],
|
||||
]
|
||||
return this.queryExpecting200(json, [WAMetric.group, WAFlag.ignore]) // encrypt and send off
|
||||
}
|
||||
/**
|
||||
* Tell someone about your presence -- online, typing, offline etc.
|
||||
* @param jid the ID of the person/group who you are updating
|
||||
* @param type your presence
|
||||
*/
|
||||
async updatePresence(jid: string, type: Presence) {
|
||||
const json = [
|
||||
'action',
|
||||
{ epoch: this.msgCount.toString(), type: 'set' },
|
||||
[['presence', { type: type, to: jid }, null]],
|
||||
]
|
||||
return this.queryExpecting200(json, [WAMetric.group, WAFlag.acknowledge]) as Promise<{ status: number }>
|
||||
}
|
||||
async sendMessage(
|
||||
id: string,
|
||||
message: string | WALocationMessage | WAContactMessage | Buffer,
|
||||
type: MessageType,
|
||||
options: MessageOptions = {},
|
||||
) {
|
||||
let m: any = {}
|
||||
switch (type) {
|
||||
case MessageType.text:
|
||||
case MessageType.extendedText:
|
||||
if (typeof message !== 'string') {
|
||||
throw 'expected message to be a string'
|
||||
}
|
||||
m.extendedTextMessage = { text: message }
|
||||
break
|
||||
case MessageType.location:
|
||||
case MessageType.liveLocation:
|
||||
m.locationMessage = message as WALocationMessage
|
||||
break
|
||||
case MessageType.contact:
|
||||
m.contactMessage = message as WAContactMessage
|
||||
break
|
||||
default:
|
||||
m = await this.prepareMediaMessage(message as Buffer, type, options)
|
||||
break
|
||||
}
|
||||
return this.sendGenericMessage(id, m as WAMessageContent, options)
|
||||
}
|
||||
/** Prepare a media message for sending */
|
||||
protected async prepareMediaMessage(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) {
|
||||
if (mediaType === MessageType.document && !options.mimetype) {
|
||||
throw 'mimetype required to send a document'
|
||||
}
|
||||
if (mediaType === MessageType.sticker && options.caption) {
|
||||
throw 'cannot send a caption with a sticker'
|
||||
}
|
||||
if (!options.mimetype) {
|
||||
options.mimetype = MimetypeMap[mediaType]
|
||||
}
|
||||
let isGIF = false
|
||||
if (options.mimetype === Mimetype.gif) {
|
||||
isGIF = true
|
||||
options.mimetype = MimetypeMap[MessageType.video]
|
||||
}
|
||||
// generate a media key
|
||||
const mediaKey = randomBytes(32)
|
||||
const mediaKeys = getMediaKeys(mediaKey, mediaType)
|
||||
const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
|
||||
const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
|
||||
const body = Buffer.concat([enc, mac]) // body is enc + mac
|
||||
const fileSha256 = sha256(buffer)
|
||||
// url safe Base64 encode the SHA256 hash of the body
|
||||
const fileEncSha256B64 = sha256(body)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/\=+$/, '')
|
||||
|
||||
await generateThumbnail(buffer, mediaType, options)
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
const json = (await this.query(['query', 'mediaConn'])).media_conn
|
||||
const auth = json.auth // the auth token
|
||||
let hostname = 'https://' + json.hosts[0].hostname // first hostname available
|
||||
hostname += MediaPathMap[mediaType] + '/' + fileEncSha256B64 // append path
|
||||
hostname += '?auth=' + auth // add auth token
|
||||
hostname += '&token=' + fileEncSha256B64 // file hash
|
||||
|
||||
const urlFetch = await fetch(hostname, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
headers: { Origin: 'https://web.whatsapp.com' },
|
||||
})
|
||||
const responseJSON = await urlFetch.json()
|
||||
if (!responseJSON.url) {
|
||||
throw 'UPLOAD FAILED GOT: ' + JSON.stringify(responseJSON)
|
||||
}
|
||||
const message = {}
|
||||
message[mediaType] = {
|
||||
url: responseJSON.url,
|
||||
mediaKey: mediaKey.toString('base64'),
|
||||
mimetype: options.mimetype,
|
||||
fileEncSha256: fileEncSha256B64,
|
||||
fileSha256: fileSha256.toString('base64'),
|
||||
fileLength: buffer.length,
|
||||
gifPlayback: isGIF || null,
|
||||
}
|
||||
return message
|
||||
}
|
||||
/** Generic send message function */
|
||||
async sendGenericMessage(id: string, message: WAMessageContent, options: MessageOptions) {
|
||||
if (!options.timestamp) {
|
||||
// if no timestamp was provided,
|
||||
options.timestamp = new Date() // set timestamp to now
|
||||
}
|
||||
const key = Object.keys(message)[0]
|
||||
const timestamp = options.timestamp.getTime() / 1000
|
||||
const quoted = options.quoted
|
||||
if (quoted) {
|
||||
const participant = quoted.key.participant || quoted.key.remoteJid
|
||||
message[key].contextInfo = {
|
||||
participant: participant,
|
||||
stanzaId: quoted.key.id,
|
||||
quotedMessage: quoted.message,
|
||||
}
|
||||
// if a participant is quoted, then it must be a group
|
||||
// hence, remoteJid of group must also be entered
|
||||
if (quoted.key.participant) {
|
||||
message[key].contextInfo.remoteJid = quoted.key.remoteJid
|
||||
}
|
||||
}
|
||||
message[key].caption = options?.caption
|
||||
message[key].jpegThumbnail = options?.thumbnail
|
||||
|
||||
const messageJSON = {
|
||||
key: {
|
||||
remoteJid: id,
|
||||
fromMe: true,
|
||||
id: generateMessageID(),
|
||||
},
|
||||
message: message,
|
||||
messageTimestamp: timestamp,
|
||||
participant: id.includes('@g.us') ? this.userMetaData.id : null,
|
||||
}
|
||||
const json = ['action', { epoch: this.msgCount.toString(), type: 'relay' }, [['message', null, messageJSON]]]
|
||||
const response = await this.queryExpecting200(json, [WAMetric.message, WAFlag.ignore], null, messageJSON.key.id)
|
||||
return { status: response.status as number, messageID: messageJSON.key.id } as WASendMessageResponse
|
||||
}
|
||||
}
|
||||
159
src/WAClient/Tests.ts
Normal file
159
src/WAClient/Tests.ts
Normal 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
132
src/WAClient/Utils.ts
Normal 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
6
src/WAClient/WAClient.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import WhatsAppWebMessages from './Messages'
|
||||
|
||||
export { WhatsAppWebMessages as WAClient }
|
||||
export * from './Constants'
|
||||
export * from './Utils'
|
||||
export * from '../WAConnection/Constants'
|
||||
Reference in New Issue
Block a user