Initial V3.0

This commit is contained in:
Adhiraj
2020-08-19 14:19:30 +05:30
parent 3825824d71
commit 95d2567e76
20 changed files with 1593 additions and 1027 deletions

View File

@@ -1,4 +1,4 @@
import { WAConnection, MessageLogLevel, MessageOptions, MessageType } from '../WAConnection/WAConnection'
import { WAConnection, MessageLogLevel, MessageOptions, MessageType, unixTimestampSeconds } from '../WAConnection/WAConnection'
import * as assert from 'assert'
import {promises as fs} from 'fs'
@@ -7,22 +7,29 @@ export const testJid = process.env.TEST_JID || '1234@s.whatsapp.net' // set TEST
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 {messages} = await conn.loadMessages(testJid, 10)
const message = messages.find (m => m.key.id === response.key.id)
assert.ok(message)
const chat = conn.chats.get(testJid)
assert.ok (chat.messages.find(m => m.key.id === response.key.id))
assert.ok (chat.t >= (unixTimestampSeconds()-5) )
return message
}
export function WAConnectionTest(name: string, func: (conn: WAConnection) => void) {
export const WAConnectionTest = (name: string, func: (conn: WAConnection) => void) => (
describe(name, () => {
const conn = new WAConnection()
conn.logLevel = MessageLogLevel.info
before(async () => {
//conn.logLevel = MessageLogLevel.unhandled
const file = './auth_info.json'
await conn.connectSlim(file)
await conn.loadAuthInfo(file).connect()
await fs.writeFile(file, JSON.stringify(conn.base64EncodedAuthInfo(), null, '\t'))
})
after(() => conn.close())
func(conn)
})
}
)

View File

@@ -1,21 +1,25 @@
import * as assert from 'assert'
import * as QR from 'qrcode-terminal'
import {WAConnection} from '../WAConnection/WAConnection'
import { AuthenticationCredentialsBase64 } from '../WAConnection/Constants'
import { createTimeout } from '../WAConnection/Utils'
import { AuthenticationCredentialsBase64, BaileysError, MessageLogLevel } from '../WAConnection/Constants'
import { delay, promiseTimeout } from '../WAConnection/Utils'
describe('QR Generation', () => {
it('should generate QR', async () => {
const conn = new WAConnection()
let calledQR = false
conn.onReadyForPhoneAuthentication = ([ref, curveKey, clientID]) => {
assert.ok(ref, 'ref nil')
assert.ok(curveKey, 'curve key nil')
assert.ok(clientID, 'client ID nil')
calledQR = true
}
await assert.rejects(async () => conn.connectSlim(null, 5000), 'should have failed connect')
assert.equal(calledQR, true, 'QR not called')
conn.regenerateQRIntervalMs = 5000
let calledQR = 0
conn.removeAllListeners ('qr')
conn.on ('qr', qr => calledQR += 1)
await conn.connect(15000)
.then (() => assert.fail('should not have succeeded'))
.catch (error => {
assert.equal (error.message, 'timed out')
})
assert.equal (conn['pendingRequests'].length, 0)
assert.equal (Object.keys(conn['callbacks']).filter(key => !key.startsWith('function:')).length, 0)
assert.ok(calledQR >= 2, 'QR not called')
})
})
@@ -23,54 +27,49 @@ describe('Test Connect', () => {
let auth: AuthenticationCredentialsBase64
it('should connect', async () => {
console.log('please be ready to scan with your phone')
const conn = new WAConnection()
const user = await conn.connectSlim(null)
assert.ok(user)
assert.ok(user.id)
await conn.connect (null)
assert.ok(conn.user?.id)
assert.ok(conn.user?.phone)
assert.ok (conn.user?.imgUrl || conn.user.imgUrl === '')
conn.close()
auth = conn.base64EncodedAuthInfo()
})
it('should re-generate QR & connect', async () => {
const conn = new WAConnection()
conn.onReadyForPhoneAuthentication = async ([ref, publicKey, clientID]) => {
for (let i = 0; i < 2; i++) {
console.log ('called QR ' + i + ' times')
await createTimeout (3000)
ref = await conn.generateNewQRCode ()
}
const str = ref + ',' + publicKey + ',' + clientID
QR.generate(str, { small: true })
}
const user = await conn.connectSlim(null)
assert.ok(user)
assert.ok(user.id)
conn.close()
})
it('should reconnect', async () => {
const conn = new WAConnection()
const [user, chats, contacts] = await conn.connect(auth, 20*1000)
await conn
.loadAuthInfo (auth)
.connect (20*1000)
.then (conn => {
assert.ok(conn.user)
assert.ok(conn.user.id)
assert.ok(user)
assert.ok(user.id)
assert.ok(chats)
const chatArray = chats.all()
if (chatArray.length > 0) {
assert.ok(chatArray[0].jid)
assert.ok(chatArray[0].count !== null)
if (chatArray[0].messages.length > 0) {
assert.ok(chatArray[0].messages[0])
}
}
assert.ok(contacts)
if (contacts.length > 0) {
assert.ok(contacts[0].jid)
}
await conn.logout()
await assert.rejects(async () => conn.connectSlim(auth), 'reconnect should have failed')
const chatArray = conn.chats.all()
if (chatArray.length > 0) {
assert.ok(chatArray[0].jid)
assert.ok(chatArray[0].count !== null)
if (chatArray[0].messages.length > 0) {
assert.ok(chatArray[0].messages[0])
}
}
const contactValues = Object.values(conn.contacts)
if (contactValues[0]) {
assert.ok(contactValues[0].jid)
}
})
.then (() => conn.logout())
.then (() => conn.loadAuthInfo(auth))
.then (() => (
conn.connect()
.then (() => assert.fail('should not have reconnected'))
.catch (err => {
assert.ok (err instanceof BaileysError)
assert.ok ((err as BaileysError).status >= 400)
})
))
.finally (() => conn.close())
})
})
describe ('Pending Requests', async () => {
@@ -78,21 +77,17 @@ describe ('Pending Requests', async () => {
const conn = new WAConnection ()
conn.pendingRequestTimeoutMs = null
await conn.connectSlim ()
await conn.loadAuthInfo('./auth_info.json').connect ()
await createTimeout (2000)
await delay (2000)
conn.close ()
const task: Promise<any> = new Promise ((resolve, reject) => {
conn.query(['query', 'Status', conn.userMetaData.id])
.then (json => resolve(json))
.catch (error => reject ('should not have failed, got error: ' + error))
})
const task: Promise<any> = conn.query({json: ['query', 'Status', conn.user.id]})
await createTimeout (2000)
await delay (2000)
await conn.connectSlim ()
conn.connect ()
const json = await task
assert.ok (json.status)

View File

@@ -1,12 +1,18 @@
import { MessageType, GroupSettingChange, createTimeout, ChatModification, whatsappID } from '../WAConnection/WAConnection'
import { MessageType, GroupSettingChange, delay, ChatModification } from '../WAConnection/WAConnection'
import * as assert from 'assert'
import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common'
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])
assert.ok (conn.chats.get(response.gid))
const {chats} = await conn.loadChats(10, null)
assert.equal (chats[0].jid, response.gid) // first chat should be new group
gid = response.gid
console.log('created group: ' + JSON.stringify(response))
})
it('should retreive group invite code', async () => {
@@ -22,8 +28,18 @@ WAConnectionTest('Groups', (conn) => {
it('should update the group description', async () => {
const newDesc = 'Wow this was set from Baileys'
const waitForEvent = new Promise (resolve => {
conn.on ('group-description-update', ({jid, actor}) => {
if (jid === gid) {
assert.ok (actor, conn.user.id)
resolve ()
}
})
})
await conn.groupUpdateDescription (gid, newDesc)
await createTimeout (1000)
await waitForEvent
conn.removeAllListeners ('group-description-update')
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.desc, newDesc)
@@ -32,39 +48,102 @@ WAConnectionTest('Groups', (conn) => {
await conn.sendMessage(gid, 'hello', MessageType.text)
})
it('should quote a message on the group', async () => {
const messages = await conn.loadConversation (gid, 20)
const {messages} = await conn.loadMessages (gid, 100)
const quotableMessage = messages.find (m => m.message)
assert.ok (quotableMessage, 'need at least one message')
const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: messages[0]})
const messagesNew = await conn.loadConversation(gid, 10, null, true)
const message = messagesNew.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage
const response = await conn.sendMessage(gid, 'hello', MessageType.extendedText, {quoted: quotableMessage})
const loaded = await conn.loadMessages(gid, 10)
const message = loaded.messages.find (m => m.key.id === response.key.id)?.message?.extendedTextMessage
assert.ok(message)
assert.equal (message.contextInfo.stanzaId, quotableMessage.key.id)
})
it('should update the subject', async () => {
const subject = 'V Cool Title'
const subject = 'Baileyz ' + Math.floor(Math.random()*5)
const waitForEvent = new Promise (resolve => {
conn.on ('chat-update', ({jid, title}) => {
if (jid === gid) {
assert.equal (title, subject)
resolve ()
}
})
})
await conn.groupUpdateSubject(gid, subject)
await waitForEvent
conn.removeAllListeners ('chat-update')
const metadata = await conn.groupMetadata(gid)
assert.strictEqual(metadata.subject, subject)
})
it('should update the group settings', async () => {
const waitForEvent = new Promise (resolve => {
conn.on ('group-settings-update', ({jid, announce}) => {
if (jid === gid) {
assert.equal (announce, 'true')
resolve ()
}
})
})
await conn.groupSettingChange (gid, GroupSettingChange.messageSend, true)
await createTimeout (5000)
await waitForEvent
conn.removeAllListeners ('group-settings-update')
await delay (2000)
await conn.groupSettingChange (gid, GroupSettingChange.settingsChange, true)
})
it('should remove someone from a group', async () => {
const waitForEvent = new Promise (resolve => {
conn.on ('group-participants-remove', ({jid, participants}) => {
if (jid === gid) {
assert.equal (participants[0], testJid)
resolve ()
}
})
})
await conn.groupRemove(gid, [testJid])
await waitForEvent
conn.removeAllListeners ('group-participants-remove')
})
it('should leave the group', async () => {
const waitForEvent = new Promise (resolve => {
conn.on ('chat-update', ({jid, read_only}) => {
if (jid === gid) {
assert.equal (read_only, 'true')
resolve ()
}
})
})
await conn.groupLeave(gid)
await waitForEvent
conn.removeAllListeners ('chat-update')
await conn.groupMetadataMinimal (gid)
})
it('should archive the group', async () => {
const waitForEvent = new Promise (resolve => {
conn.on ('chat-update', ({jid, archive}) => {
if (jid === gid) {
assert.equal (archive, 'true')
resolve ()
}
})
})
await conn.modifyChat(gid, ChatModification.archive)
await waitForEvent
conn.removeAllListeners ('chat-update')
})
it('should delete the group', async () => {
const waitForEvent = new Promise (resolve => {
conn.on ('chat-update', (chat) => {
if (chat.jid === gid) {
assert.equal (chat['delete'], 'true')
resolve ()
}
})
})
await conn.deleteChat(gid)
await waitForEvent
conn.removeAllListeners ('chat-update')
})
})

View File

@@ -1,18 +1,18 @@
import { MessageType, Mimetype, createTimeout } from '../WAConnection/WAConnection'
import { MessageType, Mimetype, delay, promiseTimeout, WAMessage, WA_MESSAGE_STATUS_TYPE } from '../WAConnection/WAConnection'
import {promises as fs} from 'fs'
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')
//const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text)
//assert.strictEqual(message.message.conversation || message.message.extendedTextMessage?.text, 'hello fren')
})
it('should forward a message', async () => {
let messages = await conn.loadConversation (testJid, 1)
let messages = await conn.loadMessages (testJid, 1)
await conn.forwardMessage (testJid, messages[0], true)
messages = await conn.loadConversation (testJid, 1)
messages = await conn.loadMessages (testJid, 1)
const message = messages[0]
const content = message.message[ Object.keys(message.message)[0] ]
assert.equal (content?.contextInfo?.isForwarded, true)
@@ -28,7 +28,7 @@ WAConnectionTest('Messages', (conn) => {
assert.ok (received.jpegThumbnail)
})
it('should quote a message', async () => {
const messages = await conn.loadConversation(testJid, 2)
const messages = await conn.loadMessages(testJid, 2)
const message = await sendAndRetreiveMessage(conn, 'hello fren 2', MessageType.extendedText, {
quoted: messages[0],
})
@@ -48,21 +48,36 @@ WAConnectionTest('Messages', (conn) => {
//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 messages = await conn.loadMessages(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 () => {
it('should send a message & delete it', async () => {
const message = await sendAndRetreiveMessage(conn, 'hello fren', MessageType.text)
await createTimeout (2000)
await delay (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)
const messages = await conn.loadMessages (testJid, 1)
await delay (2000)
await conn.clearMessage (messages[0].key)
})
})
})
WAConnectionTest('Message Events', (conn) => {
it('should deliver a message', async () => {
const waitForUpdate =
promiseTimeout(15000, resolve => {
conn.on('message-update', message => {
if (message.key.id === response.key.id) {
resolve(message)
}
})
}) as Promise<WAMessage>
const response = await conn.sendMessage(testJid, 'My Name Jeff', MessageType.text)
const m = await waitForUpdate
assert.ok (m.status >= WA_MESSAGE_STATUS_TYPE.DELIVERY_ACK)
})
})

View File

@@ -1,20 +1,9 @@
import { MessageType, Presence, ChatModification, promiseTimeout, createTimeout } from '../WAConnection/WAConnection'
import { Presence, ChatModification, delay } from '../WAConnection/WAConnection'
import {promises as fs} from 'fs'
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)
@@ -30,16 +19,28 @@ WAConnectionTest('Misc', (conn) => {
it('should update status', async () => {
const newStatus = 'v cool status'
const waitForEvent = new Promise (resolve => {
conn.on ('user-status-update', ({jid, status}) => {
if (jid === conn.user.id) {
assert.equal (status, newStatus)
conn.removeAllListeners ('user-status-update')
resolve ()
}
})
})
const response = await conn.getStatus()
assert.strictEqual(typeof response.status, 'string')
await createTimeout (1000)
await delay (1000)
await conn.setStatus (newStatus)
const response2 = await conn.getStatus()
assert.equal (response2.status, newStatus)
await createTimeout (1000)
await waitForEvent
await delay (1000)
await conn.setStatus (response.status) // update back
})
@@ -47,18 +48,18 @@ WAConnectionTest('Misc', (conn) => {
await conn.getStories()
})
it('should change the profile picture', async () => {
await createTimeout (5000)
await delay (5000)
const ppUrl = await conn.getProfilePicture(conn.userMetaData.id)
const ppUrl = await conn.getProfilePicture(conn.user.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)
const response = await conn.updateProfilePicture (conn.user.id, newPP)
await createTimeout (10000)
await delay (10000)
await conn.updateProfilePicture (conn.userMetaData.id, buff) // revert back
await conn.updateProfilePicture (conn.user.id, buff) // revert back
})
it('should return the profile picture', async () => {
const response = await conn.getProfilePicture(testJid)
@@ -70,22 +71,32 @@ WAConnectionTest('Misc', (conn) => {
assert.ok(response)
})
it('should mark a chat unread', async () => {
await conn.sendReadReceipt(testJid, null, 'unread')
const waitForEvent = new Promise (resolve => {
conn.on ('chat-update', ({jid, count}) => {
if (jid === testJid) {
assert.ok (count < 0)
conn.removeAllListeners ('chat-update')
resolve ()
}
})
})
await conn.sendReadReceipt(testJid, null, -2)
await waitForEvent
})
it('should archive & unarchive', async () => {
await conn.modifyChat (testJid, ChatModification.archive)
await createTimeout (2000)
await delay (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 delay (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 delay (2000)
await conn.modifyChat (testJid, ChatModification.unmute, {stamp: mutedate})
})
it('should return search results', async () => {
@@ -96,18 +107,14 @@ WAConnectionTest('Misc', (conn) => {
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())
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 delay(1500)
}
})
})
})

View File

@@ -1,4 +1,3 @@
import * as QR from 'qrcode-terminal'
import * as fs from 'fs'
import WS from 'ws'
import * as Utils from './Utils'
@@ -6,120 +5,80 @@ import Encoder from '../Binary/Encoder'
import Decoder from '../Binary/Decoder'
import {
AuthenticationCredentials,
UserMetaData,
WAUser,
WANode,
AuthenticationCredentialsBase64,
WATag,
MessageLogLevel,
AuthenticationCredentialsBrowser,
BaileysError,
WAConnectionMode,
WAMessage,
PresenceUpdate,
MessageStatusUpdate,
WAMetric,
WAFlag,
DisconnectReason,
WAConnectionState,
AnyAuthenticationCredentials,
WAContact,
WAChat,
WAQuery,
ReconnectMode,
} from './Constants'
import { EventEmitter } from 'events'
import KeyedDB from '@adiwajshing/keyed-db'
/** Generate a QR code from the ref & the curve public key. This is scanned by the phone */
const generateQRCode = function ([ref, publicKey, clientID]) {
const str = ref + ',' + publicKey + ',' + clientID
QR.generate(str, { small: true })
}
export class WAConnection {
export class WAConnection extends EventEmitter {
/** The version of WhatsApp Web we're telling the servers we are */
version: [number, number, number] = [2, 2027, 10]
version: [number, number, number] = [2, 2033, 7]
/** The Browser we're telling the WhatsApp Web servers we are */
browserDescription: [string, string, string] = Utils.Browsers.baileys ('Chrome')
/** Metadata like WhatsApp id, name set on WhatsApp etc. */
userMetaData: UserMetaData = { id: null, name: null, phone: null }
/** Should reconnect automatically after an unexpected disconnect */
autoReconnect = true
lastSeen: Date = null
user: WAUser
/** What level of messages to log to the console */
logLevel: MessageLogLevel = MessageLogLevel.info
/** Should requests be queued when the connection breaks in between; if false, then an error will be thrown */
pendingRequestTimeoutMs: number = null
connectionMode: WAConnectionMode = WAConnectionMode.onlyRequireValidation
/** What to do when you need the phone to authenticate the connection (generate QR code by default) */
onReadyForPhoneAuthentication = generateQRCode
protected unexpectedDisconnectCallback: (err: string) => any
/** The connection state */
state: WAConnectionState = 'closed'
/** New QR generation interval, set to null if you don't want to regenerate */
regenerateQRIntervalMs = 30*1000
autoReconnect = ReconnectMode.onConnectionLost
/** Whether the phone is connected */
phoneConnected: boolean = false
maxCachedMessages = 25
contacts: {[k: string]: WAContact} = {}
chats: KeyedDB<WAChat> = new KeyedDB (Utils.waChatUniqueKey, value => value.jid)
/** Data structure of tokens & IDs used to establish one's identiy to WhatsApp Web */
protected authInfo: AuthenticationCredentials = {
clientID: null,
serverToken: null,
clientToken: null,
encKey: null,
macKey: null,
}
protected authInfo: AuthenticationCredentials = null
/** Curve keys to initially authenticate */
protected curveKeys: { private: Uint8Array; public: Uint8Array }
/** The websocket connection */
protected conn: WS = null
protected msgCount = 0
protected keepAliveReq: NodeJS.Timeout
protected callbacks = {}
protected callbacks: {[k: string]: any} = {}
protected encoder = new Encoder()
protected decoder = new Decoder()
protected pendingRequests: (() => void)[] = []
protected reconnectLoop: () => Promise<void>
protected pendingRequests: {resolve: () => void, reject: (error) => void}[] = []
protected referenceDate = new Date () // used for generating tags
protected lastSeen: Date = null // last keep alive received
protected qrTimeout: NodeJS.Timeout
protected phoneCheck: NodeJS.Timeout
protected cancelledReconnect = false
protected cancelReconnect: () => void
constructor () {
super ()
this.registerCallback (['Cmd', 'type:disconnect'], json => this.unexpectedDisconnect(json[1].kind))
}
async unexpectedDisconnect (error: string) {
this.close()
if ((error === 'lost' || error === 'closed') && this.autoReconnect) {
await this.reconnectLoop ()
} else if (this.unexpectedDisconnectCallback) {
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
async unexpectedDisconnect (error?: DisconnectReason) {
const willReconnect = this.autoReconnect === ReconnectMode.onAllErrors || (this.autoReconnect === ReconnectMode.onConnectionLost && (error === 'lost' || error === 'closed'))
this.log (`got disconnected, reason ${error || 'unknown'}${willReconnect ? ', reconnecting in a few seconds...' : ''}`, MessageLogLevel.info)
this.closeInternal(error, willReconnect)
willReconnect && this.reconnectLoop ()
}
/**
* base 64 encode the authentication credentials and return them
@@ -135,68 +94,42 @@ export class WAConnection {
macKey: this.authInfo.macKey.toString('base64'),
}
}
/**
* Clear authentication info so a new connection can be created
*/
/** Clear authentication info so a new connection can be created */
clearAuthInfo () {
this.authInfo = {
clientID: null,
serverToken: null,
clientToken: null,
encKey: null,
macKey: null,
}
this.authInfo = null
return this
}
/**
* Load in the authentication credentials
* @param authInfo the authentication credentials or path to auth credentials JSON
* @param authInfo the authentication credentials or file path to auth credentials
*/
loadAuthInfoFromBase64(authInfo: AuthenticationCredentialsBase64 | string) {
if (!authInfo) {
throw new Error('given authInfo is null')
}
if (typeof authInfo === 'string') {
this.log(`loading authentication credentials from ${authInfo}`, MessageLogLevel.info)
const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
authInfo = JSON.parse(file) as AuthenticationCredentialsBase64
}
this.authInfo = {
clientID: authInfo.clientID,
serverToken: authInfo.serverToken,
clientToken: authInfo.clientToken,
encKey: Buffer.from(authInfo.encKey, 'base64'), // decode from base64
macKey: Buffer.from(authInfo.macKey, 'base64'), // decode from base64
}
}
/**
* Load in the authentication credentials
* @param authInfo the authentication credentials or path to browser credentials JSON
*/
loadAuthInfoFromBrowser(authInfo: AuthenticationCredentialsBrowser | string) {
loadAuthInfo(authInfo: AnyAuthenticationCredentials | string) {
if (!authInfo) throw new Error('given authInfo is null')
if (typeof authInfo === 'string') {
this.log(`loading authentication credentials from ${authInfo}`, MessageLogLevel.info)
const file = fs.readFileSync(authInfo, { encoding: 'utf-8' }) // load a closed session back if it exists
authInfo = JSON.parse(file) as AuthenticationCredentialsBrowser
authInfo = JSON.parse(file) as AnyAuthenticationCredentials
}
const secretBundle: {encKey: string, macKey: string} = typeof authInfo === 'string' ? JSON.parse (authInfo): authInfo
this.authInfo = {
clientID: authInfo.WABrowserId.replace(/\"/g, ''),
serverToken: authInfo.WAToken2.replace(/\"/g, ''),
clientToken: authInfo.WAToken1.replace(/\"/g, ''),
encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64
macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64
}
}
/**
* Register for a callback for a certain function, will cancel automatically after one execution
* @param {[string, object, string] | string} parameters name of the function along with some optional specific parameters
*/
async registerCallbackOneTime(parameters) {
const json = await new Promise((resolve, _) => this.registerCallback(parameters, resolve))
this.deregisterCallback(parameters)
return json
if ('clientID' in authInfo) {
this.authInfo = {
clientID: authInfo.clientID,
serverToken: authInfo.serverToken,
clientToken: authInfo.clientToken,
encKey: Buffer.isBuffer(authInfo.encKey) ? authInfo.encKey : Buffer.from(authInfo.encKey, 'base64'),
macKey: Buffer.isBuffer(authInfo.macKey) ? authInfo.macKey : Buffer.from(authInfo.macKey, 'base64'),
}
} else {
const secretBundle: {encKey: string, macKey: string} = typeof authInfo === 'string' ? JSON.parse (authInfo): authInfo
this.authInfo = {
clientID: authInfo.WABrowserId.replace(/\"/g, ''),
serverToken: authInfo.WAToken2.replace(/\"/g, ''),
clientToken: authInfo.WAToken1.replace(/\"/g, ''),
encKey: Buffer.from(secretBundle.encKey, 'base64'), // decode from base64
macKey: Buffer.from(secretBundle.macKey, 'base64'), // decode from base64
}
}
return this
}
/**
* Register for a callback for a certain function
@@ -247,30 +180,20 @@ export class WAConnection {
* @param timeoutMs timeout after which the promise will reject
*/
async waitForMessage(tag: string, json: Object = null, timeoutMs: number = null) {
let promise = new Promise(
let promise = Utils.promiseTimeout(timeoutMs,
(resolve, reject) => (this.callbacks[tag] = { queryJSON: json, callback: resolve, errCallback: reject }),
)
if (timeoutMs) {
promise = Utils.promiseTimeout(timeoutMs, promise).catch((err) => {
delete this.callbacks[tag]
throw err
})
}
.catch((err) => {
delete this.callbacks[tag]
throw err
})
return promise as Promise<any>
}
/**
* Query something from the WhatsApp servers and error on a non-200 status
* @param json the query itself
* @param [binaryTags] the tags to attach if the query is supposed to be sent encoded in binary
* @param [timeoutMs] timeout after which the query will be failed (set to null to disable a timeout)
* @param [tag] the tag to attach to the message
*/
async queryExpecting200(json: any[] | WANode, binaryTags?: WATag, timeoutMs?: number, tag?: string) {
const response = await this.query(json, binaryTags, timeoutMs, tag)
if (response.status && Math.floor(+response.status / 100) !== 2) {
throw new BaileysError(`Unexpected status code in '${json[0] || 'generic query'}': ${response.status}`, {query: json})
}
return response
/** 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.query({ json, binaryTags, tag, expect200: true }) as Promise<{status: number}>
return result
}
/**
* Query something from the WhatsApp servers
@@ -280,17 +203,18 @@ export class WAConnection {
* @param tag the tag to attach to the message
* recieved JSON
*/
async query(json: any[] | WANode, binaryTags?: WATag, timeoutMs?: number, tag?: string) {
async query({json, binaryTags, tag, timeoutMs, expect200, waitForOpen}: WAQuery) {
waitForOpen = typeof waitForOpen === 'undefined' ? true : waitForOpen
await this.waitForConnection (waitForOpen)
if (binaryTags) tag = await this.sendBinary(json as WANode, binaryTags, tag)
else tag = await this.sendJSON(json, tag)
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
const response = await this.waitForMessage(tag, json, timeoutMs)
if (expect200 && response.status && Math.floor(+response.status / 100) !== 2) {
throw new BaileysError(`Unexpected status code in '${json[0] || 'generic query'}': ${response.status}`, {query: json})
}
return response
}
/**
* Send a binary encoded message
@@ -299,9 +223,7 @@ export class WAConnection {
* @param tag the tag to attach to the message
* @return the message tag
*/
protected async sendBinary(json: WANode, tags: WATag, tag?: string) {
if (!this.conn || this.conn.readyState !== this.conn.OPEN) await this.waitForConnection ()
protected sendBinary(json: WANode, tags: WATag, tag: string = null) {
const binary = this.encoder.write(json) // encode the JSON to the WhatsApp binary format
let buff = Utils.aesEncrypt(binary, this.authInfo.encKey) // encrypt it using AES and our encKey
@@ -313,7 +235,7 @@ export class WAConnection {
sign, // the HMAC sign of the message
buff, // the actual encrypted buffer
])
await this.send(buff) // send it off
this.send(buff) // send it off
return tag
}
/**
@@ -322,23 +244,22 @@ export class WAConnection {
* @param tag the tag to attach to the message
* @return the message tag
*/
protected async sendJSON(json: any[] | WANode, tag: string = null) {
protected sendJSON(json: any[] | WANode, tag: string = null) {
tag = tag || this.generateMessageTag()
await this.send(tag + ',' + JSON.stringify(json))
this.send(`${tag},${JSON.stringify(json)}`)
return tag
}
/** Send some message to the WhatsApp servers */
protected async send(m) {
if (!this.conn || this.conn.readyState !== this.conn.OPEN) await this.waitForConnection ()
protected send(m) {
this.msgCount += 1 // increment message count, it makes the 'epoch' field when sending binary messages
return this.conn.send(m)
}
protected async waitForConnection () {
protected async waitForConnection (waitForOpen: boolean) {
if (!waitForOpen || this.state === 'open') return
const timeout = this.pendingRequestTimeoutMs
try {
const task = new Promise (resolve => this.pendingRequests.push(resolve))
await Utils.promiseTimeout (timeout, task)
await Utils.promiseTimeout (timeout, (resolve, reject) => this.pendingRequests.push({resolve, reject}))
} catch {
throw new Error('cannot send message, disconnected from WhatsApp')
}
@@ -347,38 +268,51 @@ export class WAConnection {
* Disconnect from the phone. Your auth credentials become invalid after sending a disconnect request.
* @see close() if you just want to close the connection
*/
async logout() {
if (!this.conn) throw new Error("You're not even connected, you can't log out")
async logout () {
if (this.state !== 'open') throw new Error("You're not even connected, you can't log out")
await new Promise(resolve => this.conn.send('goodbye,["admin","Conn","disconnect"]', null, resolve))
this.authInfo = null
this.close()
}
/** Close the connection to WhatsApp Web */
close() {
close () {
this.closeInternal ('intentional')
this.cancelReconnect && this.cancelReconnect ()
this.cancelledReconnect = true
this.pendingRequests.forEach (({reject}) => reject(new Error('closed')))
this.pendingRequests = []
}
protected closeInternal (reason?: DisconnectReason, isReconnecting: boolean = false) {
this.qrTimeout && clearTimeout (this.qrTimeout)
this.phoneCheck && clearTimeout (this.phoneCheck)
this.state = 'closed'
this.msgCount = 0
if (this.conn) {
this.conn.removeAllListeners ('close')
this.conn.close()
this.conn = null
}
const keys = Object.keys(this.callbacks)
keys.forEach(key => {
this.conn?.removeAllListeners ('close')
this.conn?.close()
this.conn = null
this.phoneConnected = false
Object.keys(this.callbacks).forEach(key => {
if (!key.includes('function:')) {
this.callbacks[key].errCallback('connection closed')
this.callbacks[key].errCallback(new Error('closed'))
delete this.callbacks[key]
}
})
if (this.keepAliveReq) {
clearInterval(this.keepAliveReq)
}
if (this.keepAliveReq) clearInterval(this.keepAliveReq)
this.emit ('closed', { reason, isReconnecting })
}
protected async reconnectLoop () {
}
generateMessageTag () {
return `${Math.round(this.referenceDate.getTime())/1000}.--${this.msgCount}`
return `${Utils.unixTimestampSeconds(this.referenceDate)}.--${this.msgCount}`
}
protected log(text, level: MessageLogLevel) {
if (this.logLevel >= level)
console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`)
(this.logLevel >= level) && console.log(`[Baileys][${new Date().toLocaleString()}] ${text}`)
}
}

View File

@@ -7,35 +7,29 @@ export class WAConnection extends Base {
/** Authenticate the connection */
protected async authenticate() {
if (!this.authInfo.clientID) {
// if no auth info is present, that is, a new session has to be established
// generate a client ID
this.authInfo = {
clientID: Utils.generateClientID(),
clientToken: null,
serverToken: null,
encKey: null,
macKey: null,
}
// if no auth info is present, that is, a new session has to be established
// generate a client ID
if (!this.authInfo?.clientID) {
this.authInfo = { clientID: Utils.generateClientID() } as any
}
this.referenceDate = new Date () // refresh reference date
const data = ['admin', 'init', this.version, this.browserDescription, this.authInfo.clientID, true]
const json = ['admin', 'init', this.version, this.browserDescription, this.authInfo?.clientID, true]
return this.queryExpecting200(data)
return this.query({json, expect200: true, waitForOpen: false})
.then(json => {
// we're trying to establish a new connection or are trying to log in
if (this.authInfo.encKey && this.authInfo.macKey) {
if (this.authInfo?.encKey && this.authInfo?.macKey) {
// if we have the info to restore a closed session
const data = [
const json = [
'admin',
'login',
this.authInfo.clientToken,
this.authInfo.serverToken,
this.authInfo.clientID,
this.authInfo?.clientToken,
this.authInfo?.serverToken,
this.authInfo?.clientID,
'takeover',
]
return this.query(data, null, null, 's1') // wait for response with tag "s1"
return this.query({ json, tag: 's1', waitForOpen: false }) // wait for response with tag "s1"
}
return this.generateKeysForAuth(json.ref) // generate keys which will in turn be the QR
})
@@ -62,31 +56,29 @@ export class WAConnection extends Base {
this.validateNewConnection(json[1]) // validate the connection
this.log('validated connection successfully', MessageLogLevel.info)
await this.sendPostConnectQueries ()
this.sendPostConnectQueries ()
this.lastSeen = new Date() // set last seen to right now
return this.userMetaData
})
}
/**
* Send the same queries WA Web sends after connect
*/
async sendPostConnectQueries () {
await this.sendBinary (['query', {type: 'contacts', epoch: '1'}, null], [ WAMetric.queryContact, WAFlag.ignore ])
await this.sendBinary (['query', {type: 'chat', epoch: '1'}, null], [ WAMetric.queryChat, WAFlag.ignore ])
await this.sendBinary (['query', {type: 'status', epoch: '1'}, null], [ WAMetric.queryStatus, WAFlag.ignore ])
await this.sendBinary (['query', {type: 'quick_reply', epoch: '1'}, null], [ WAMetric.queryQuickReply, WAFlag.ignore ])
await this.sendBinary (['query', {type: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ])
await this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ])
await this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, 160 ])
sendPostConnectQueries () {
this.sendBinary (['query', {type: 'contacts', epoch: '1'}, null], [ WAMetric.queryContact, WAFlag.ignore ])
this.sendBinary (['query', {type: 'chat', epoch: '1'}, null], [ WAMetric.queryChat, WAFlag.ignore ])
this.sendBinary (['query', {type: 'status', epoch: '1'}, null], [ WAMetric.queryStatus, WAFlag.ignore ])
this.sendBinary (['query', {type: 'quick_reply', epoch: '1'}, null], [ WAMetric.queryQuickReply, WAFlag.ignore ])
this.sendBinary (['query', {type: 'label', epoch: '1'}, null], [ WAMetric.queryLabel, WAFlag.ignore ])
this.sendBinary (['query', {type: 'emoji', epoch: '1'}, null], [ WAMetric.queryEmoji, WAFlag.ignore ])
this.sendBinary (['action', {type: 'set', epoch: '1'}, [['presence', {type: Presence.available}, null]] ], [ WAMetric.presence, 160 ])
}
/**
* Refresh QR Code
* @returns the new ref
*/
async generateNewQRCode() {
const data = ['admin', 'Conn', 'reref']
const response = await this.query(data)
async generateNewQRCodeRef() {
const response = await this.query({json: ['admin', 'Conn', 'reref'], expect200: true, waitForOpen: false})
return response.ref as string
}
/**
@@ -97,12 +89,13 @@ export class WAConnection extends Base {
private validateNewConnection(json) {
const onValidationSuccess = () => {
// set metadata: one's WhatsApp ID [cc][number]@s.whatsapp.net, name on WhatsApp, info about the phone
this.userMetaData = {
id: json.wid.replace('@c.us', '@s.whatsapp.net'),
this.user = {
id: Utils.whatsappID(json.wid),
name: json.pushname,
phone: json.phone,
imgUrl: null
}
return this.userMetaData
return this.user
}
if (!json.secret) {
@@ -154,18 +147,40 @@ export class WAConnection extends Base {
protected respondToChallenge(challenge: string) {
const bytes = Buffer.from(challenge, 'base64') // decode the base64 encoded challenge string
const signed = Utils.hmacSign(bytes, this.authInfo.macKey).toString('base64') // sign the challenge string with our macKey
const data = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
const json = ['admin', 'challenge', signed, this.authInfo.serverToken, this.authInfo.clientID] // prepare to send this signed string with the serverToken & clientID
this.log('resolving login challenge', MessageLogLevel.info)
return this.queryExpecting200(data)
return this.query({json, expect200: true, waitForOpen: false})
}
/** When starting a new session, generate a QR code by generating a private/public key pair & the keys the server sends */
protected async generateKeysForAuth(ref: string) {
this.curveKeys = Curve.generateKeyPair(Utils.randomBytes(32))
this.onReadyForPhoneAuthentication([
ref,
Buffer.from(this.curveKeys.public).toString('base64'),
this.authInfo.clientID,
])
return this.waitForMessage('s1', [])
const publicKey = Buffer.from(this.curveKeys.public).toString('base64')
const emitQR = () => {
const qr = [ref, publicKey, this.authInfo.clientID].join(',')
this.emit ('qr', qr)
}
const regenQR = () => {
this.qrTimeout = setTimeout (() => {
if (this.state === 'open') return
this.log ('regenerated QR', MessageLogLevel.info)
this.generateNewQRCodeRef ()
.then (newRef => ref = newRef)
.then (emitQR)
.then (regenQR)
.catch (err => this.log (`error in QR gen: ${err}`, MessageLogLevel.info))
}, this.regenerateQRIntervalMs)
}
if (this.regenerateQRIntervalMs) {
regenQR ()
}
const json = await this.waitForMessage('s1', [])
this.qrTimeout && clearTimeout (this.qrTimeout)
this.qrTimeout = null
return json
}
}

View File

@@ -1,74 +1,77 @@
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 { WAMessage, WAChat, WAContact, MessageLogLevel, WANode, KEEP_ALIVE_INTERVAL_MS } from './Constants'
import {WAConnection as Base} from './1.Validation'
import Decoder from '../Binary/Decoder'
export class WAConnection extends Base {
/**
* Connect to WhatsAppWeb
* @param [authInfo] credentials or path to credentials to log back in
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
* @return returns [userMetaData, chats, contacts]
* @param timeoutMs timeout after which the connect will fail, set to null for an infinite timeout
* @param waitForChats should the chats be waited for
*/
async connect(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
try {
const userInfo = await this.connectSlim(authInfo, timeoutMs)
const chats = await this.receiveChatsAndContacts(timeoutMs)
return [userInfo, ...chats] as [UserMetaData, KeyedDB<WAChat>, WAContact[]]
} catch (error) {
this.close ()
throw error
}
}
/**
* Connect to WhatsAppWeb, resolves without waiting for chats & contacts
* @param [authInfo] credentials to log back in
* @param [timeoutMs] timeout after which the connect will fail, set to null for an infinite timeout
* @return [userMetaData, chats, contacts, unreadMessages]
*/
async connectSlim(authInfo: AuthenticationCredentialsBase64 | string = null, timeoutMs: number = null) {
async connect(timeoutMs: number = null, waitForChats: boolean = true) {
// if we're already connected, throw an error
if (this.conn) throw new Error('already connected or connecting')
// set authentication credentials if required
try {
this.loadAuthInfoFromBase64(authInfo)
} catch {}
if (this.state !== 'closed') throw new Error('cannot connect when state=' + this.state)
this.state = 'connecting'
this.emit ('connecting')
this.conn = new WS('wss://web.whatsapp.com/ws', null, { origin: 'https://web.whatsapp.com' })
const promise: Promise<UserMetaData> = new Promise((resolve, reject) => {
const promise: Promise<void> = Utils.promiseTimeout(timeoutMs, (resolve, reject) => {
this.conn.on('open', () => {
this.log('connected to WhatsApp Web, authenticating...', MessageLogLevel.info)
this.log('connected to WhatsApp Web server, authenticating...', MessageLogLevel.info)
// start sending keep alive requests (keeps the WebSocket alive & updates our last seen)
this.authenticate()
.then(user => {
.then(() => {
this.startKeepAliveRequest()
this.conn.removeAllListeners ('error')
this.conn.on ('close', () => this.unexpectedDisconnect ('closed'))
resolve(user)
this.state = 'open'
resolve()
})
.catch(reject)
})
this.conn.on('message', m => this.onMessageRecieved(m))
// if there was an error in the WebSocket
this.conn.on('error', error => { this.close(); reject(error) })
this.conn.on('error', error => { this.closeInternal(error.message as any); reject(error) })
})
const user = await Utils.promiseTimeout(timeoutMs, promise).catch(err => {this.close(); throw err})
if (this.connectionMode === WAConnectionMode.onlyRequireValidation) this.releasePendingRequests ()
return user
try {
await promise
waitForChats && await this.receiveChatsAndContacts(timeoutMs, true)
this.phoneConnected = true
this.state = 'open'
this.user.imgUrl = await this.getProfilePicture (this.user.id).catch (err => '')
this.emit ('open')
this.releasePendingRequests ()
this.log ('opened connection to WhatsApp Web', MessageLogLevel.info)
return this
} catch (error) {
this.closeInternal (error.message)
throw error
}
}
/** Get the URL to download the profile picture of a person/group */
async getProfilePicture(jid: string | null) {
const response = await this.query({ json: ['query', 'ProfilePicThumb', jid || this.user.id] })
return response.eurl as string
}
/**
* Sets up callbacks to receive chats, contacts & unread messages.
* Sets up callbacks to receive chats, contacts & messages.
* Must be called immediately after connect
* @returns [chats, contacts]
*/
async receiveChatsAndContacts(timeoutMs: number = null) {
let contacts: WAContact[] = []
const chats: KeyedDB<WAChat> = new KeyedDB (Utils.waChatUniqueKey, value => value.jid)
protected async receiveChatsAndContacts(timeoutMs: number = null, stopAfterMostRecentMessage: boolean=false) {
this.contacts = {}
this.chats.clear ()
let receivedContacts = false
let receivedMessages = false
@@ -76,75 +79,97 @@ export class WAConnection extends Base {
this.log('waiting for chats & contacts', MessageLogLevel.info) // wait for the message with chats
const waitForConvos = () =>
new Promise(resolve => {
Utils.promiseTimeout(timeoutMs, resolve => {
convoResolve = () => {
// de-register the callbacks, so that they don't get called again
this.deregisterCallback(['action', 'add:last'])
this.deregisterCallback(['action', 'add:before'])
this.deregisterCallback(['action', 'add:unread'])
if (!stopAfterMostRecentMessage) {
this.deregisterCallback(['action', 'add:before'])
this.deregisterCallback(['action', 'add:unread'])
}
resolve()
}
const chatUpdate = json => {
receivedMessages = true
const isLast = json[1].last
const isLast = json[1].last || (json[1].add === 'last' && stopAfterMostRecentMessage)
const messages = json[2] as WANode[]
if (messages) {
messages.reverse().forEach (([, __, message]: ['message', null, WAMessage]) => {
messages.reverse().forEach (([,, message]: ['message', null, WAMessage]) => {
const jid = message.key.remoteJid
const chat = chats.get(jid)
const chat = this.chats.get(jid)
chat?.messages.unshift (message)
})
}
// if received contacts before messages
if (isLast && receivedContacts) convoResolve ()
}
// wait for actual messages to load, "last" is the most recent message, "before" contains prior messages
this.registerCallback(['action', 'add:last'], chatUpdate)
this.registerCallback(['action', 'add:before'], chatUpdate)
this.registerCallback(['action', 'add:unread'], chatUpdate)
})
const waitForChats = async () => {
let json = await this.registerCallbackOneTime(['response', 'type:chat'])
if (json[1].duplicate) json = await this.registerCallbackOneTime (['response', 'type:chat'])
if (!json[2]) return
json[2]
.map(([item, chat]: [any, WAChat]) => {
if (!chat) {
this.log (`unexpectedly got null chat: ${item}, ${chat}`, MessageLogLevel.info)
return
if (!stopAfterMostRecentMessage) {
this.registerCallback(['action', 'add:before'], chatUpdate)
this.registerCallback(['action', 'add:unread'], chatUpdate)
}
chat.jid = Utils.whatsappID (chat.jid)
chat.count = +chat.count
chat.messages = []
chats.insert (chat) // chats data (log json to see what it looks like)
})
.filter (Boolean)
const waitForChats = async () => (
Utils.promiseTimeout (timeoutMs, resolve => {
this.registerCallback(['response', 'type:chat'], json => {
if (json[1].duplicate || !json[2]) return
json[2]
.forEach(([item, chat]: [any, WAChat]) => {
if (!chat) {
this.log (`unexpectedly got null chat: ${item}, ${chat}`, MessageLogLevel.info)
return
}
chat.jid = Utils.whatsappID (chat.jid)
chat.t = +chat.t
chat.count = +chat.count
chat.messages = []
this.chats.insert (chat) // chats data (log json to see what it looks like)
})
this.deregisterCallback(['response', 'type:chat'])
if (this.chats.all().length > 0) waitForConvos().then (resolve)
else resolve ()
})
})
)
const waitForContacts = async () => (
new Promise (resolve => {
this.registerCallback(['response', 'type:contacts'], json => {
if (json[1].duplicate) return
if (chats.all().length > 0) return waitForConvos()
}
const waitForContacts = async () => {
let json = await this.registerCallbackOneTime(['response', 'type:contacts'])
if (json[1].duplicate) json = await this.registerCallbackOneTime (['response', 'type:contacts'])
receivedContacts = true
json[2].forEach(([type, contact]: ['user', WAContact]) => {
if (!contact) return this.log (`unexpectedly got null contact: ${type}, ${contact}`, MessageLogLevel.info)
contact.jid = Utils.whatsappID (contact.jid)
this.contacts[contact.jid] = contact
})
// if you receive contacts after messages
// should probably resolve the promise
if (receivedMessages) convoResolve()
resolve ()
contacts = json[2].map(item => item[1])
receivedContacts = true
// if you receive contacts after messages
// should probably resolve the promise
if (receivedMessages) convoResolve()
}
this.deregisterCallback(['response', 'type:contacts'])
})
})
)
// wait for the chats & contacts to load
const promise = Promise.all([waitForChats(), waitForContacts()])
await Utils.promiseTimeout (timeoutMs, promise)
await Promise.all( [waitForChats(), waitForContacts()] )
if (this.connectionMode === WAConnectionMode.requireChatsAndContacts) this.releasePendingRequests ()
return [chats, contacts] as [KeyedDB<WAChat>, WAContact[]]
this.chats.all ().forEach (chat => {
const respectiveContact = this.contacts[chat.jid]
chat.title = respectiveContact?.name || respectiveContact?.notify
})
}
private releasePendingRequests () {
this.pendingRequests.forEach (send => send()) // send off all pending request
this.pendingRequests.forEach (({resolve}) => resolve()) // send off all pending request
this.pendingRequests = []
}
private onMessageRecieved(message) {
@@ -213,21 +238,45 @@ export class WAConnection extends Base {
}
/** Send a keep alive request every X seconds, server updates & responds with last seen */
private startKeepAliveRequest() {
const refreshInterval = 20
this.keepAliveReq = setInterval(() => {
const diff = (new Date().getTime() - this.lastSeen.getTime()) / 1000
const diff = (new Date().getTime() - this.lastSeen.getTime())
/*
check if it's been a suspicious amount of time since the server responded with our last seen
it could be that the network is down
*/
if (diff > refreshInterval + 5) this.unexpectedDisconnect ('lost')
if (diff > KEEP_ALIVE_INTERVAL_MS+5000) this.unexpectedDisconnect ('lost')
else this.send ('?,,') // if its all good, send a keep alive request
}, refreshInterval * 1000)
}, KEEP_ALIVE_INTERVAL_MS)
}
protected async reconnectLoop () {
this.cancelledReconnect = false
try {
while (true) {
const {delay, cancel} = Utils.delayCancellable (5000)
this.cancelReconnect = cancel
await delay
try {
await this.connect ()
this.cancelReconnect = null
} catch (error) {
this.log (`error in reconnecting: ${error}, reconnecting...`, MessageLogLevel.info)
}
}
} catch {
reconnectLoop = async () => {
// attempt reconnecting if the user wants us to
this.log('network is down, reconnecting...', MessageLogLevel.info)
return this.connectSlim(null, 25*1000).catch(this.reconnectLoop)
}
}
/**
* Check if your phone is connected
* @param timeoutMs max time for the phone to respond
*/
async checkPhoneConnection(timeoutMs = 5000) {
try {
const response = await this.query({json: ['admin', 'test'], timeoutMs})
return response[1] as boolean
} catch (error) {
return false
}
}
}

View File

@@ -0,0 +1,314 @@
import * as QR from 'qrcode-terminal'
import { WAConnection as Base } from './3.Connect'
import { MessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, MessageLogLevel, PresenceUpdate, BaileysEvent } from './Constants'
import { whatsappID, unixTimestampSeconds, isGroupID } from './Utils'
export class WAConnection extends Base {
constructor () {
super ()
this.registerOnMessageStatusChange ()
this.registerOnUnreadMessage ()
this.registerOnPresenceUpdate ()
this.registerPhoneConnectionPoll ()
// If a message has been updated (usually called when a video message gets its upload url)
this.registerCallback (['action', 'add:update', 'message'], json => {
const message: WAMessage = json[2][0][2]
const jid = whatsappID(message.key.remoteJid)
const chat = this.chats.get(jid)
if (!chat) return
const messageIndex = chat.messages.findIndex(m => m.key.id === message.key.id)
if (messageIndex >= 0) chat.messages[messageIndex] = message
this.emit ('message-update', message)
})
// If a user's contact has changed
this.registerCallback (['action', null, 'user'], json => {
const node = json[2][0]
if (node) {
const user = node[1] as WAContact
user.jid = whatsappID(user.jid)
this.contacts[user.jid] = user
const chat = this.chats.get (user.jid)
if (chat) {
chat.title = user.name || user.notify
this.emit ('chat-update', { jid: chat.jid, title: chat.title })
}
}
})
// chat archive, pin etc.
this.registerCallback(['action', null, 'chat'], json => {
json = json[2][0]
const updateType = json[1].type
const jid = whatsappID(json[1]?.jid)
const chat = this.chats.get(jid)
if (!chat) return
const FUNCTIONS = {
'delete': () => {
chat['delete'] = 'true'
this.chats.delete(chat)
return 'delete'
},
'clear': () => {
json[2].forEach(item => chat.messages.filter(m => m.key.id !== item[1].index))
return 'clear'
},
'archive': () => {
chat.archive = 'true'
return 'archive'
},
'unarchive': () => {
delete chat.archive
return 'archive'
},
'pin': () => {
chat.pin = json[1].pin
return 'pin'
}
}
const func = FUNCTIONS [updateType]
if (func) {
const property = func ()
this.emit ('chat-update', { jid, [property]: chat[property] || null })
}
})
// profile picture updates
this.registerCallback(['Cmd', 'type:picture'], async json => {
const jid = whatsappID(json[1].jid)
const chat = this.chats.get(jid)
if (!chat) return
await this.setProfilePicture (chat)
this.emit ('chat-update', { jid, imgUrl: chat.imgUrl })
})
// status updates
this.registerCallback(['Status'], async json => {
const jid = whatsappID(json[1].id)
this.emit ('user-status-update', { jid, status: json[1].status })
})
// read updates
this.registerCallback (['action', null, 'read'], async json => {
const update = json[2][0][1]
const chat = this.chats.get ( whatsappID(update.jid) )
if (update.type === 'false') chat.count = -1
else chat.count = 0
this.emit ('chat-update', { jid: chat.jid, count: chat.count })
})
this.on ('qr', qr => QR.generate(qr, { small: true }))
}
/** Set the callback for message status updates (when a message is delivered, read etc.) */
protected registerOnMessageStatusChange() {
const func = json => {
json = json[1]
let ids = json.id
if (json.cmd === 'ack') ids = [json.id]
const update: MessageStatusUpdate = {
from: json.from,
to: json.to,
participant: json.participant,
timestamp: new Date(json.t * 1000),
ids: ids,
type: (+json.ack)+1,
}
const chat = this.chats.get( whatsappID(update.to) )
if (!chat) return
this.chatUpdatedMessage (update.ids, update.type, chat)
}
this.registerCallback('Msg', func)
this.registerCallback('MsgInfo', func)
}
protected registerOnUnreadMessage() {
this.registerCallback(['action', 'add:relay', 'message'], json => {
const message = json[2][0][2] as WAMessage
this.chatAddMessageAppropriate (message)
})
}
/** Set the callback for presence updates; if someone goes offline/online, this callback will be fired */
protected registerOnPresenceUpdate() {
this.registerCallback('Presence', json => this.emit('user-presence-update', json[1]))
}
/** inserts an empty chat into the DB */
protected async chatAdd (jid: string, title?: string) {
const chat: WAChat = {
jid: jid,
t: unixTimestampSeconds(),
messages: [],
count: 0,
modify_tag: '',
spam: 'false',
title
}
await this.setProfilePicture (chat)
this.chats.insert (chat)
this.emit ('chat-new', chat)
return chat
}
/** find a chat or return an error */
protected assertChatGet = jid => {
const chat = this.chats.get (jid)
if (!chat) throw new Error (`chat '${jid}' not found`)
return chat
}
/** Adds the given message to the appropriate chat, if the chat doesn't exist, it is created */
protected async chatAddMessageAppropriate (message: WAMessage) {
const jid = whatsappID (message.key.remoteJid)
const chat = this.chats.get(jid) || await this.chatAdd (jid)
this.chatAddMessage (message, chat)
}
protected chatAddMessage (message: WAMessage, chat: WAChat) {
// add to count if the message isn't from me & there exists a message
if (!message.key.fromMe && message.message) chat.count += 1
const protocolMessage = message.message?.protocolMessage
// if it's a message to delete another message
if (protocolMessage) {
switch (protocolMessage.type) {
case WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE:
const found = chat.messages.find(m => m.key.id === protocolMessage.key.id)
if (found && found.message) {
//this.log ('deleting message: ' + protocolMessage.key.id + ' in chat: ' + protocolMessage.key.remoteJid)
found.messageStubType = WA_MESSAGE_STUB_TYPE.REVOKE
found.message = null
this.emit ('message-update', found)
}
break
default:
break
}
} else if (!chat.messages.find(m => m.key.id === message.key.id)) {
// this.log ('adding new message from ' + chat.jid)
chat.messages.push(message)
chat.messages = chat.messages.slice (-5) // only keep the last 5 messages
// only update if it's an actual message
if (message.message) this.chatUpdateTime (chat)
this.emit ('message-new', message)
// check if the message is an action
if (message.messageStubType) {
const jid = chat.jid
let actor = whatsappID (message.participant)
let participants: string[]
switch (message.messageStubType) {
case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_LEAVE:
case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_REMOVE:
participants = message.messageStubParameters.map (whatsappID)
this.emit ('group-participants-remove', { jid, actor, participants})
// mark the chat read only if you left the group
if (participants.includes(this.user.id)) {
chat.read_only = 'true'
this.emit ('chat-update', { jid, read_only: chat.read_only })
}
break
case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_ADD:
case WA_MESSAGE_STUB_TYPE.GROUP_PARTICIPANT_INVITE:
participants = message.messageStubParameters.map (whatsappID)
this.emit ('group-participants-add', { jid, participants, actor })
break
case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_ANNOUNCE:
const announce = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
this.emit ('group-settings-update', { jid, announce, actor })
break
case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_ANNOUNCE:
const restrict = message.messageStubParameters[0] === 'on' ? 'true' : 'false'
this.emit ('group-settings-update', { jid, restrict, actor })
break
case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_DESCRIPTION:
this.emit ('group-description-update', { jid, actor })
break
case WA_MESSAGE_STUB_TYPE.GROUP_CHANGE_SUBJECT:
chat.title = message.messageStubParameters[0]
this.emit ('chat-update', { jid, title: chat.title })
break
}
}
}
}
protected chatUpdatedMessage (messageIDs: string[], status: number, chat: WAChat) {
for (let msg of chat.messages) {
if (messageIDs.includes(msg.key.id)) {
if (isGroupID(chat.jid)) msg.status = WA_MESSAGE_STATUS_TYPE.SERVER_ACK
else msg.status = status
this.emit ('message-update', msg)
}
}
}
protected chatUpdateTime = chat => this.chats.updateKey (chat, c => c.t = unixTimestampSeconds())
/** sets the profile picture of a chat */
protected async setProfilePicture (chat: WAChat) {
chat.imgUrl = await this.getProfilePicture (chat.jid).catch (err => '')
}
protected registerPhoneConnectionPoll () {
this.phoneCheck = setInterval (() => {
this.checkPhoneConnection (7500) // 7500 ms for timeout
.then (connected => {
if (this.phoneConnected != connected) {
this.emit ('connection-phone-change', {connected})
}
this.phoneConnected = connected
})
.catch (error => this.log(`error in getting phone connection: ${error}`, MessageLogLevel.info))
}, 20000)
}
// Add all event types
/** when the connection has opened successfully */
on (event: 'open', listener: () => void): this
/** when the connection is opening */
on (event: 'connecting', listener: () => void): this
/** when the connection has closed */
on (event: 'closed', listener: (err: {reason?: string, isReconnecting: boolean}) => void): this
/** when a new QR is generated, ready for scanning */
on (event: 'qr', listener: (qr: string) => void): this
/** when the connection to the phone changes */
on (event: 'connection-phone-change', listener: (state: {connected: boolean}) => void): this
/** when a user's presence is updated */
on (event: 'user-presence-update', listener: (update: PresenceUpdate) => void): this
/** when a user's status is updated */
on (event: 'user-status-update', listener: (update: {jid: string, status?: string}) => void): this
/** when a new chat is added */
on (event: 'chat-new', listener: (chat: WAChat) => void): this
/** when a chat is updated (archived, deleted, pinned) */
on (event: 'chat-update', listener: (chat: Partial<WAChat> & { jid: string }) => void): this
/** when a new message is relayed */
on (event: 'message-new', listener: (message: WAMessage) => void): this
/** when a message is updated (deleted, delivered, read) */
on (event: 'message-update', listener: (message: WAMessage) => void): this
/** when participants are added to a group */
on (event: 'group-participants-add', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this
/** when participants are removed or leave from a group */
on (event: 'group-participants-remove', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this
/** when participants are promoted in a group */
on (event: 'group-participants-promote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this
/** when participants are demoted in a group */
on (event: 'group-participants-demote', listener: (update: {jid: string, participants: string[], actor?: string}) => void): this
/** when the group settings is updated */
on (event: 'group-settings-update', listener: (update: {jid: string, restrict?: string, announce?: string, actor?: string}) => void): this
/** when the group description is updated */
on (event: 'group-description-update', listener: (update: {jid: string, description?: string, actor?: string}) => void): this
on (event: BaileysEvent, listener: (...args: any[]) => void) { return super.on (event, listener) }
emit (event: BaileysEvent, ...args: any[]) { return super.emit (event, ...args) }
}

View File

@@ -1,170 +0,0 @@
import {WAConnection as Base} from './3.Connect'
import { Presence, WABroadcastListInfo, WAProfilePictureChange } from './Constants'
import {
WAMessage,
WANode,
WAMetric,
WAFlag,
} from '../WAConnection/Constants'
import { generateProfilePicture } from './Utils'
// All user related functions -- get profile picture, set status etc.
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)
/**
* 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 | null, 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 }>
}
/** Request an update on the presence of a user */
requestPresenceUpdate = async (jid: string) => this.queryExpecting200(['action', 'presence', 'subscribe', jid])
/** Query the status of the person (see groupMetadata() for groups) */
async getStatus (jid?: string) {
return this.query(['query', 'Status', jid || this.userMetaData.id]) as Promise<{ status: string }>
}
async setStatus (status: string) {
return this.setQuery (
[
[
'status',
null,
Buffer.from (status, 'utf-8')
]
]
)
}
/** 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, [6, WAFlag.ignore]) // this has to be an encrypted query
return response
}
/** Get the stories of your contacts */
async getStories() {
const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null]
const response = await this.queryExpecting200(json, [30, WAFlag.ignore]) as WANode
if (Array.isArray(response[2])) {
return response[2].map (row => (
{
unread: row[1]?.unread,
count: row[1]?.count,
messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : []
} as {unread: number, count: number, messages: WAMessage[]}
))
}
return []
}
/** Fetch your chats */
async getChats() {
const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null]
return this.query(json, [5, WAFlag.ignore]) // this has to be an encrypted query
}
/** Query broadcast list info */
async getBroadcastListInfo(jid: string) { return this.queryExpecting200(['query', 'contact', jid]) as Promise<WABroadcastListInfo> }
/** Delete the chat of a given ID */
async deleteChat (jid: string) {
return this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as Promise<{status: number}>
}
/**
* 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.queryExpecting200(json, [WAMetric.queryMessages, WAFlag.ignore])
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>
}
async updateProfilePicture (jid: string, img: Buffer) {
const data = await generateProfilePicture (img)
const tag = this.generateMessageTag ()
const query: WANode = [
'picture',
{ jid: jid, id: tag, type: 'set' },
[
['image', null, data.img],
['preview', null, data.preview]
]
]
return this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise<WAProfilePictureChange>
}
}

172
src/WAConnection/5.User.ts Normal file
View File

@@ -0,0 +1,172 @@
import {WAConnection as Base} from './4.Events'
import { Presence, WABroadcastListInfo, WAProfilePictureChange, WAChat, ChatModification } from './Constants'
import {
WAMessage,
WANode,
WAMetric,
WAFlag,
} from '../WAConnection/Constants'
import { generateProfilePicture, waChatUniqueKey, whatsappID, unixTimestampSeconds } from './Utils'
// All user related functions -- get profile picture, set status etc.
export class WAConnection extends Base {
/** Query whether a given number is registered on WhatsApp */
isOnWhatsApp = (jid: string) => this.query({json: ['query', 'exist', jid]}).then((m) => m.status === 200)
/**
* 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 | null, type: Presence) {
const json = [
'action',
{ epoch: this.msgCount.toString(), type: 'set' },
[['presence', { type: type, to: jid }, null]],
]
return this.query({json, binaryTags: [WAMetric.group, WAFlag.acknowledge]}) as Promise<{ status: number }>
}
/** Request an update on the presence of a user */
requestPresenceUpdate = async (jid: string) => this.query({json: ['action', 'presence', 'subscribe', jid]})
/** Query the status of the person (see groupMetadata() for groups) */
async getStatus (jid?: string) {
const status: { status: string } = await this.query({json: ['query', 'Status', jid || this.user.id]})
return status
}
async setStatus (status: string) {
const response = await this.setQuery (
[
[
'status',
null,
Buffer.from (status, 'utf-8')
]
]
)
this.emit ('user-status-update', { jid: this.user.id, status })
return response
}
/** Get your contacts */
async getContacts() {
const json = ['query', { epoch: this.msgCount.toString(), type: 'contacts' }, null]
const response = await this.query({ json, binaryTags: [6, WAFlag.ignore] }) // this has to be an encrypted query
return response
}
/** Get the stories of your contacts */
async getStories() {
const json = ['query', { epoch: this.msgCount.toString(), type: 'status' }, null]
const response = await this.query({json, binaryTags: [30, WAFlag.ignore], expect200: true}) as WANode
if (Array.isArray(response[2])) {
return response[2].map (row => (
{
unread: row[1]?.unread,
count: row[1]?.count,
messages: Array.isArray(row[2]) ? row[2].map (m => m[2]) : []
} as {unread: number, count: number, messages: WAMessage[]}
))
}
return []
}
/** Fetch your chats */
async getChats() {
const json = ['query', { epoch: this.msgCount.toString(), type: 'chat' }, null]
return this.query({ json, binaryTags: [5, WAFlag.ignore]}) // this has to be an encrypted query
}
/** Query broadcast list info */
async getBroadcastListInfo(jid: string) { return this.query({json: ['query', 'contact', jid], expect200: true}) as Promise<WABroadcastListInfo> }
/** Delete the chat of a given ID */
async deleteChat (jid: string) {
const response = await this.setQuery ([ ['chat', {type: 'delete', jid: jid}, null] ], [12, WAFlag.ignore]) as {status: number}
const chat = this.chats.get (jid)
if (chat) {
this.chats.delete (chat)
this.emit ('chat-update', { jid, delete: 'true' })
}
return response
}
/**
* Load chats in a paginated manner + gets the profile picture
* @param before chats before the given cursor
* @param count number of results to return
* @param searchString optionally search for users
* @returns the chats & the cursor to fetch the next page
*/
async loadChats (count: number, before: number | null, searchString?: string) {
let db = this.chats
if (searchString) {
db = db.filter (value => value.title?.includes (searchString) || value.jid?.startsWith(searchString))
}
const chats = db.paginated (before, count)
await Promise.all (
chats.map (async chat => (
chat.imgUrl === undefined && await this.setProfilePicture (chat)
))
)
const cursor = (chats[chats.length-1] && chats.length >= count) ? waChatUniqueKey (chats[chats.length-1]) : null
return { chats, cursor }
}
async updateProfilePicture (jid: string, img: Buffer) {
jid = whatsappID (jid)
const data = await generateProfilePicture (img)
const tag = this.generateMessageTag ()
const query: WANode = [
'picture',
{ jid: jid, id: tag, type: 'set' },
[
['image', null, data.img],
['preview', null, data.preview]
]
]
const response = await (this.setQuery ([query], [WAMetric.picture, 136], tag) as Promise<WAProfilePictureChange>)
if (jid === this.user.id) this.user.imgUrl = response.eurl
else if (this.chats.get(jid)) {
this.chats.get(jid).imgUrl = response.eurl
this.emit ('chat-update', { jid, imgUrl: response.eurl })
}
return response
}
/**
* Modify a given chat (archive, pin etc.)
* @param jid the ID of the person/group you are modifiying
* @param options.stamp the timestamp of pinning/muting the chat. Is required when unpinning/unmuting
*/
async modifyChat (jid: string, type: ChatModification, options: {stamp: Date | string} = {stamp: new Date()}) {
jid = whatsappID (jid)
let chatAttrs: Record<string, string> = {jid: jid}
if ((type === ChatModification.unpin || type === ChatModification.unmute) && !options?.stamp) {
throw new Error('options.stamp must be set to the timestamp of the time of pinning/unpinning of the chat')
}
const strStamp = options.stamp &&
(typeof options.stamp === 'string' ? options.stamp : unixTimestampSeconds(options.stamp).toString ())
switch (type) {
case ChatModification.pin:
case ChatModification.mute:
chatAttrs.type = type
chatAttrs[type] = strStamp
break
case ChatModification.unpin:
case ChatModification.unmute:
chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin'
chatAttrs.previous = strStamp
break
default:
chatAttrs.type = type
break
}
let response = await this.setQuery ([['chat', chatAttrs, null]]) as {status: number, stamp: string}
response.stamp = strStamp
const chat = this.chats.get (jid)
if (chat) {
if (type.includes('un')) {
type = type.replace ('un', '') as ChatModification
delete chat[type.replace('un','')]
this.emit ('chat-update', { jid, [type]: false })
} else {
chat[type] = chatAttrs[type] || 'true'
this.emit ('chat-update', { jid, [type]: chat[type] })
}
}
return response
}
}

View File

@@ -1,4 +1,4 @@
import {WAConnection as Base} from './4.User'
import {WAConnection as Base} from './5.User'
import fetch from 'node-fetch'
import {promises as fs} from 'fs'
import {
@@ -9,215 +9,18 @@ import {
MediaPathMap,
WALocationMessage,
WAContactMessage,
WASendMessageResponse,
WAMessageKey,
ChatModification,
MessageInfo,
WATextMessage,
WAUrlInfo,
WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE
WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE
} from './Constants'
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID } from './Utils'
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID, unixTimestampSeconds } from './Utils'
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]
const response = (await this.queryExpecting200 (query, [22, WAFlag.ignore]))[2]
const info: MessageInfo = {reads: [], deliveries: []}
if (response) {
//console.log (response)
const reads = response.filter (node => node[0] === 'read')
if (reads[0]) {
info.reads = reads[0][2].map (item => item[1])
}
const deliveries = response.filter (node => node[0] === 'delivery')
if (deliveries[0]) {
info.deliveries = deliveries[0][2].map (item => item[1])
}
}
return info
}
/**
* Send a read receipt to the given ID for a certain message
* @param jid the ID of the person/group whose message you want to mark read
* @param messageID optionally, the message ID
* @param type whether to read or unread the message
*/
async sendReadReceipt(jid: string, messageID?: string, type: 'read' | 'unread' = 'read') {
const attributes = {
jid: jid,
count: type === 'read' ? '1' : '-2',
index: messageID,
owner: messageID ? 'false' : null
}
return this.setQuery ([['read', attributes, null]])
}
/**
* Modify a given chat (archive, pin etc.)
* @param jid the ID of the person/group you are modifiying
* @param options.stamp the timestamp of pinning/muting the chat. Is required when unpinning/unmuting
*/
async modifyChat (jid: string, type: ChatModification, options: {stamp: Date | string} = {stamp: new Date()}) {
let chatAttrs: Record<string, string> = {jid: jid}
if ((type === ChatModification.unpin || type === ChatModification.unmute) && !options?.stamp) {
throw new Error('options.stamp must be set to the timestamp of the time of pinning/unpinning of the chat')
}
const strStamp = options.stamp &&
(typeof options.stamp === 'string' ? options.stamp : Math.round(options.stamp.getTime ()/1000).toString ())
switch (type) {
case ChatModification.pin:
case ChatModification.mute:
chatAttrs.type = type
chatAttrs[type] = strStamp
break
case ChatModification.unpin:
case ChatModification.unmute:
chatAttrs.type = type.replace ('un', '') // replace 'unpin' with 'pin'
chatAttrs.previous = strStamp
break
default:
chatAttrs.type = type
break
}
let response = await this.setQuery ([['chat', chatAttrs, null]]) as any
response.stamp = strStamp
return response as {status: number, stamp: string}
}
async loadMessage (jid: string, messageID: string) {
let messages
try {
messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: true}, false)
} catch {
messages = await this.loadConversation (jid, 1, {id: messageID, fromMe: false}, false)
}
var index = null
if (messages.length > 0) index = messages[0].key
const actual = await this.loadConversation (jid, 1, index)
return actual[0]
}
/** Query a string to check if it has a url, if it does, return required extended text message */
async generateLinkPreview (text: string) {
const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null]
const response = await this.queryExpecting200 (query, [26, WAFlag.ignore])
if (response[1]) response[1].jpegThumbnail = response[2]
const data = response[1] as WAUrlInfo
const content = {text} as WATextMessage
content.canonicalUrl = data['canonical-url']
content.matchedText = data['matched-text']
content.jpegThumbnail = data.jpegThumbnail
content.description = data.description
content.title = data.title
content.previewType = 0
return content
}
/**
* Search WhatsApp messages with a given text string
* @param txt the search string
* @param inJid the ID of the chat to search in, set to null to search all chats
* @param count number of results to return
* @param page page number of results (starts from 1)
*/
async searchMessages(txt: string, inJid: string | null, count: number, page: number) {
const json = [
'query',
{
epoch: this.msgCount.toString(),
type: 'search',
search: txt,
count: count.toString(),
page: page.toString(),
jid: inJid
},
null,
]
const response: WANode = await this.queryExpecting200(json, [WAMetric.group, WAFlag.ignore]) // encrypt and send off
const messages = response[2] ? response[2].map (row => row[2]) : []
return { last: response[1]['last'] === 'true', messages: messages as WAMessage[] }
}
/**
* Delete a message in a chat for yourself
* @param messageKey key of the message you want to delete
*/
async clearMessage (messageKey: WAMessageKey) {
const tag = Math.round(Math.random ()*1000000)
const attrs: WANode = [
'chat',
{ jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' },
[
['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null]
]
]
return this.setQuery ([attrs])
}
/**
* Fetches the latest url & media key for the given message.
* You may need to call this when the message is old & the content is deleted off of the WA servers
* @param message
*/
async updateMediaMessage (message: WAMessage) {
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message)
const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null]
const response = await this.query (query, [WAMetric.queryMedia, WAFlag.ignore])
if (parseInt(response[1].code) !== 200) throw new BaileysError ('unexpected status ' + response[1].code, response)
Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message
}
/**
* Delete a message in a chat for everyone
* @param id the person or group where you're trying to delete the message
* @param messageKey key of the message you want to delete
*/
async deleteMessage (id: string, messageKey: WAMessageKey) {
const json: WAMessageContent = {
protocolMessage: {
key: messageKey,
type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE
}
}
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, 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 && !forceForward ? 0 : 1
if (key === MessageType.text) {
content[MessageType.extendedText] = { text: content[key] }
delete content[MessageType.text]
key = MessageType.extendedText
}
if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true }
else content[key].contextInfo = {}
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
* @param id the id to send to
* @param message the message can be a buffer, plain string, location message, extended text message
* @param type type of message
* @param options Extra options
*/
async sendMessage(
id: string,
@@ -293,7 +96,7 @@ export class WAConnection extends Base {
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 json = (await this.query({json: ['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
@@ -331,7 +134,7 @@ export class WAConnection extends Base {
id = whatsappID (id)
const key = Object.keys(message)[0]
const timestamp = options.timestamp.getTime()/1000
const timestamp = unixTimestampSeconds(options.timestamp)
const quoted = options.quoted
if (options.contextInfo) message[key].contextInfo = options.contextInfo
@@ -361,7 +164,7 @@ export class WAConnection extends Base {
message: message,
messageTimestamp: timestamp,
messageStubParameters: [],
participant: id.includes('@g.us') ? this.userMetaData.id : null,
participant: id.includes('@g.us') ? this.user.id : null,
status: WA_MESSAGE_STATUS_TYPE.PENDING
}
return messageJSON as WAMessage
@@ -369,8 +172,22 @@ export class WAConnection extends Base {
/** 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
await this.queryExpecting200(json, [WAMetric.message, flag], null, message.key.id)
const flag = message.key.remoteJid === this.user.id ? WAFlag.acknowledge : WAFlag.ignore // acknowledge when sending message to oneself
await this.query({json, binaryTags: [WAMetric.message, flag], tag: message.key.id})
await this.chatAddMessageAppropriate (message)
}
/**
* Fetches the latest url & media key for the given message.
* You may need to call this when the message is old & the content is deleted off of the WA servers
* @param message
*/
async updateMediaMessage (message: WAMessage) {
const content = message.message?.audioMessage || message.message?.videoMessage || message.message?.imageMessage || message.message?.stickerMessage || message.message?.documentMessage
if (!content) throw new BaileysError (`given message ${message.key.id} is not a media message`, message)
const query = ['query',{type: 'media', index: message.key.id, owner: message.key.fromMe ? 'true' : 'false', jid: message.key.remoteJid, epoch: this.msgCount.toString()},null]
const response = await this.query ({json: query, binaryTags: [WAMetric.queryMedia, WAFlag.ignore], expect200: true})
Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message
}
/**
* Securely downloads the media from the message.

View File

@@ -0,0 +1,261 @@
import {WAConnection as Base} from './6.MessagesSend'
import {
MessageType,
WAMessageKey,
MessageInfo,
WATextMessage,
WAUrlInfo,
WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE
} from './Constants'
import { whatsappID } from './Utils'
export class WAConnection extends Base {
async loadAllUnreadMessages () {
const tasks = this.chats.all()
.filter(chat => chat.count > 0)
.map (chat => this.loadMessages(chat.jid, chat.count))
const list = await Promise.all (tasks)
const combined: WAMessage[] = []
list.forEach (({messages}) => combined.push(...messages))
return combined
}
/** 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]
const response = (await this.query ({json: query, binaryTags: [22, WAFlag.ignore], expect200: true}))[2]
const info: MessageInfo = {reads: [], deliveries: []}
if (response) {
//console.log (response)
const reads = response.filter (node => node[0] === 'read')
if (reads[0]) {
info.reads = reads[0][2].map (item => item[1])
}
const deliveries = response.filter (node => node[0] === 'delivery')
if (deliveries[0]) {
info.deliveries = deliveries[0][2].map (item => item[1])
}
}
return info
}
/**
* Read/unread messages of a chat; will mark the entire chat read by default
* @param jid the ID of the person/group whose message you want to mark read
* @param messageID optionally, the message ID
* @param count number of messages to read, set to < 0 to unread a message
*/
async sendReadReceipt(jid: string, messageID?: string, count?: number) {
jid = whatsappID (jid)
const chat = this.chats.get(jid)
count = count || Math.abs(chat?.count || 1)
const attributes = {
jid: jid,
count: count.toString(),
index: messageID,
owner: messageID ? 'false' : null
}
const read = await this.setQuery ([['read', attributes, null]])
if (chat) {
chat.count = count < 0 ? -1 : chat.count-count
this.emit ('chat-update', {jid, count: chat.count})
}
return read
}
/**
* Load the conversation with a group or person
* @param count the number of messages to load
* @param before 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 loadMessages (
jid: string,
count: number,
before: { id?: string; fromMe?: boolean } = null,
mostRecentFirst = true
) {
jid = whatsappID(jid)
const retreive = async (count: number, indexMessage: any) => {
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, binaryTags: [WAMetric.queryMessages, WAFlag.ignore], expect200: true})
const messages = response[2] ? (response[2] as WANode[]).map((item) => item[2] as WAMessage) : []
return messages
}
const chat = this.chats.get (jid)
let messages: WAMessage[]
if (!before && chat && mostRecentFirst) {
messages = chat.messages
if (messages.length < count) {
const extra = await retreive (count-messages.length, messages[0]?.key)
messages.unshift (...extra)
}
} else messages = await retreive (count, before)
const cursor = messages[0] && messages[0].key
return {messages, cursor}
}
/**
* 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
*/
loadAllMessages(jid: string, onMessage: (m: WAMessage) => void, chunkSize = 25, mostRecentFirst = true) {
let offsetID = null
const loadMessage = async () => {
const {messages} = await this.loadMessages(jid, chunkSize, offsetID, mostRecentFirst)
// callback with most recent message first (descending order of date)
let lastMessage
if (mostRecentFirst) {
for (let i = messages.length - 1; i >= 0; i--) {
onMessage(messages[i])
lastMessage = messages[i]
}
} else {
for (let i = 0; i < messages.length; i++) {
onMessage(messages[i])
lastMessage = messages[i]
}
}
// if there are still more messages
if (messages.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>
}
/** Load a single message specified by the ID */
async loadMessage (jid: string, messageID: string) {
let messages: WAMessage[]
try {
messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: true}, false)).messages
} catch {
messages = (await this.loadMessages (jid, 1, {id: messageID, fromMe: false}, false)).messages
}
var index = null
if (messages.length > 0) index = messages[0].key
const actual = await this.loadMessages (jid, 1, index)
return actual.messages[0]
}
/** Query a string to check if it has a url, if it does, return required extended text message */
async generateLinkPreview (text: string) {
const query = ['query', {type: 'url', url: text, epoch: this.msgCount.toString()}, null]
const response = await this.query ({json: query, binaryTags: [26, WAFlag.ignore], expect200: true})
if (response[1]) response[1].jpegThumbnail = response[2]
const data = response[1] as WAUrlInfo
const content = {text} as WATextMessage
content.canonicalUrl = data['canonical-url']
content.matchedText = data['matched-text']
content.jpegThumbnail = data.jpegThumbnail
content.description = data.description
content.title = data.title
content.previewType = 0
return content
}
/**
* Search WhatsApp messages with a given text string
* @param txt the search string
* @param inJid the ID of the chat to search in, set to null to search all chats
* @param count number of results to return
* @param page page number of results (starts from 1)
*/
async searchMessages(txt: string, inJid: string | null, count: number, page: number) {
const json = [
'query',
{
epoch: this.msgCount.toString(),
type: 'search',
search: txt,
count: count.toString(),
page: page.toString(),
jid: inJid
},
null,
]
const response: WANode = await this.query({json, binaryTags: [WAMetric.group, WAFlag.ignore], expect200: true}) // encrypt and send off
const messages = response[2] ? response[2].map (row => row[2]) : []
return { last: response[1]['last'] === 'true', messages: messages as WAMessage[] }
}
/**
* Delete a message in a chat for yourself
* @param messageKey key of the message you want to delete
*/
async clearMessage (messageKey: WAMessageKey) {
const tag = Math.round(Math.random ()*1000000)
const attrs: WANode = [
'chat',
{ jid: messageKey.remoteJid, modify_tag: tag.toString(), type: 'clear' },
[
['item', {owner: `${messageKey.fromMe}`, index: messageKey.id}, null]
]
]
return this.setQuery ([attrs])
}
/**
* Delete a message in a chat for everyone
* @param id the person or group where you're trying to delete the message
* @param messageKey key of the message you want to delete
*/
async deleteMessage (id: string, messageKey: WAMessageKey) {
const json: WAMessageContent = {
protocolMessage: {
key: messageKey,
type: WAMessageProto.ProtocolMessage.PROTOCOL_MESSAGE_TYPE.REVOKE
}
}
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, 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 && !forceForward ? 0 : 1
if (key === MessageType.text) {
content[MessageType.extendedText] = { text: content[key] }
delete content[MessageType.text]
key = MessageType.extendedText
}
if (score > 0) content[key].contextInfo = { forwardingScore: score, isForwarded: true }
else content[key].contextInfo = {}
const waMessage = this.generateWAMessage (id, content, {})
await this.relayWAMessage (waMessage)
return waMessage
}
}

View File

@@ -1,4 +1,4 @@
import {WAConnection as Base} from './5.Messages'
import {WAConnection as Base} from './7.MessagesExtra'
import { WAMetric, WAFlag, WANode, WAGroupMetadata, WAGroupCreateResponse, WAGroupModification } from '../WAConnection/Constants'
import { GroupSettingChange } from './Constants'
import { generateMessageID } from '../WAConnection/Utils'
@@ -10,23 +10,23 @@ export class WAConnection extends Base {
const json: WANode = [
'group',
{
author: this.userMetaData.id,
author: this.user.id,
id: tag,
type: type,
jid: jid,
subject: subject,
},
participants ? participants.map(str => ['participant', { jid: str }, null]) : additionalNodes,
participants ? participants.map(jid => ['participant', { jid }, null]) : additionalNodes,
]
const result = await this.setQuery ([json], [WAMetric.group, WAFlag.ignore], tag)
const result = await this.setQuery ([json], [WAMetric.group, 136], tag)
return result
}
/** Get the metadata of the group */
groupMetadata = (jid: string) => this.queryExpecting200(['query', 'GroupMetadata', jid]) as Promise<WAGroupMetadata>
groupMetadata = (jid: string) => this.query({json: ['query', 'GroupMetadata', jid], expect200: true}) as Promise<WAGroupMetadata>
/** Get the metadata (works after you've left the group also) */
groupMetadataMinimal = async (jid: string) => {
const query = ['query', {type: 'group', jid: jid, epoch: this.msgCount.toString()}, null]
const response = await this.queryExpecting200(query, [WAMetric.group, WAFlag.ignore])
const response = await this.query({json: query, binaryTags: [WAMetric.group, WAFlag.ignore], expect200: true})
const json = response[2][0]
const creatorDesc = json[1]
const participants = json[2] ? json[2].filter (item => item[0] === 'participant') : []
@@ -46,20 +46,39 @@ export class WAConnection extends Base {
* @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>
groupCreate = async (title: string, participants: string[]) => {
const response = await this.groupQuery('create', null, title, participants) as WAGroupCreateResponse
await this.chatAdd (response.gid, title)
return response
}
/**
* Leave a group
* @param jid the ID of the group
*/
groupLeave = (jid: string) => this.groupQuery('leave', jid) as Promise<{ status: number }>
groupLeave = async (jid: string) => {
const response = await this.groupQuery('leave', jid)
const chat = this.chats.get (jid)
if (chat) chat.read_only = 'true'
return response
}
/**
* 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 }>
groupUpdateSubject = async (jid: string, title: string) => {
const chat = this.chats.get (jid)
if (chat?.title === title) throw new Error ('redundant change')
const response = await this.groupQuery('subject', jid, title)
if (chat) {
chat.title = title
//this.emit ('chat-update', {jid, title})
}
return response
}
/**
* Update the group description
* @param {string} jid the ID of the group
@@ -72,7 +91,8 @@ export class WAConnection extends Base {
{id: generateMessageID(), prev: metadata?.descId},
Buffer.from (description, 'utf-8')
]
return this.groupQuery ('description', jid, null, null, [node])
const response = await this.groupQuery ('description', jid, null, null, [node])
return response
}
/**
* Add somebody to the group
@@ -114,7 +134,7 @@ export class WAConnection extends Base {
/** Get the invite link of the given group */
async groupInviteCode(jid: string) {
const json = ['query', 'inviteCode', jid]
const response = await this.queryExpecting200(json)
const response = await this.query({json})
return response.code as string
}
}

View File

@@ -1,6 +1,32 @@
import { WA } from '../Binary/Constants'
import { proto } from '../../WAMessage/WAMessage'
export const KEEP_ALIVE_INTERVAL_MS = 20*1000
// export the WAMessage Prototypes
export { proto as WAMessageProto }
export type WANode = WA.Node
export type WAMessage = proto.WebMessageInfo
export type WAMessageContent = proto.IMessage
export type WAContactMessage = proto.ContactMessage
export type WAMessageKey = proto.IMessageKey
export type WATextMessage = proto.ExtendedTextMessage
export type WAContextInfo = proto.IContextInfo
export import WA_MESSAGE_STUB_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STUBTYPE
export import WA_MESSAGE_STATUS_TYPE = proto.WebMessageInfo.WEB_MESSAGE_INFO_STATUS
export interface WALocationMessage {
degreesLatitude: number
degreesLongitude: number
address?: string
}
/** 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 class BaileysError extends Error {
status?: number
@@ -13,7 +39,25 @@ export class BaileysError extends Error {
this.context = context
}
}
export interface WAQuery {
json: any[] | WANode
binaryTags?: WATag
timeoutMs?: number
tag?: string
expect200?: boolean
waitForOpen?: boolean
}
export enum ReconnectMode {
/** does not reconnect */
off = 0,
/** reconnects only when the connection is 'lost' or 'closed' */
onConnectionLost = 1,
/** reconnects on all disconnects, including take overs */
onAllErrors = 2
}
export type WAConnectionState = 'open' | 'connecting' | 'closed'
export type DisconnectReason = 'closed' | 'lost' | 'replaced' | 'intentional'
export enum MessageLogLevel {
none=0,
info=1,
@@ -40,21 +84,14 @@ export interface AuthenticationCredentialsBrowser {
WAToken1: string
WAToken2: string
}
export interface UserMetaData {
export type AnyAuthenticationCredentials = AuthenticationCredentialsBrowser | AuthenticationCredentialsBase64 | AuthenticationCredentials
export interface WAUser {
id: string
name: string
phone: string
imgUrl: string
}
export type WANode = WA.Node
export type WAMessage = proto.WebMessageInfo
export type WAMessageContent = proto.IMessage
export enum WAConnectionMode {
/** Baileys will let requests through after a simple connect */
onlyRequireValidation = 0,
/** Baileys will let requests through only after chats & contacts are received */
requireChatsAndContacts = 1
}
export interface WAGroupCreateResponse {
status: number
gid?: string
@@ -68,6 +105,10 @@ export interface WAGroupMetadata {
desc?: string
descOwner?: string
descId?: string
/** is set when the group only allows admins to change group settings */
restrict?: 'true'
/** is set when the group only allows admins to write messages */
announce?: 'true'
participants: [{ id: string; isAdmin: boolean; isSuperAdmin: boolean }]
}
export interface WAGroupModification {
@@ -83,16 +124,22 @@ export interface WAContact {
short?: string
}
export interface WAChat {
t: string
jid: string
t: number
/** number of unread messages, is < 0 if the chat is manually marked unread */
count: number
archive?: 'true' | 'false'
read_only?: 'true' | 'false'
mute?: string
pin?: string
spam: 'false' | 'true'
jid: string
modify_tag: string
// Baileys added properties
messages: WAMessage[]
title?: string
imgUrl?: string
}
export enum WAMetric {
debugLog = 1,
@@ -133,8 +180,6 @@ export enum WAFlag {
}
/** Tag used with binary queries */
export type WATag = [WAMetric, WAFlag]
// 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 {
@@ -263,22 +308,21 @@ export interface WASendMessageResponse {
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
export type BaileysEvent =
'open' |
'connecting' |
'closed' |
'qr' |
'connection-phone-change' |
'user-presence-update' |
'user-status-update' |
'chat-new' |
'chat-update' |
'message-new' |
'message-update' |
'group-participants-add' |
'group-participants-remove' |
'group-participants-promote' |
'group-participants-demote' |
'group-settings-update' |
'group-description-update'

View File

@@ -27,11 +27,10 @@ function hashCode(s: string) {
h = Math.imul(31, h) + s.charCodeAt(i) | 0;
return h;
}
export const waChatUniqueKey = (c: WAChat) => ((+c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending
export const waChatUniqueKey = (c: WAChat) => ((c.t*100000) + (hashCode(c.jid)%100000))*-1 // -1 to sort descending
export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net')
export const isGroupID = (jid: string) => jid?.includes ('@g.us')
export function whatsappID (jid: string) {
return jid.replace ('@c.us', '@s.whatsapp.net')
}
/** 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))
@@ -67,25 +66,49 @@ export function hkdf(buffer: Buffer, expandedLength: number, info = null) {
export function randomBytes(length) {
return Crypto.randomBytes(length)
}
export const createTimeout = (timeout) => new Promise(resolve => setTimeout(resolve, timeout))
/** unix timestamp of a date in seconds */
export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000)
export const delay = (ms: number) => delayCancellable (ms).delay
export const delayCancellable = (ms: number) => {
let timeout: NodeJS.Timeout
let reject: (error) => void
const delay: Promise<void> = new Promise((resolve, _reject) => {
timeout = setTimeout(resolve, ms)
reject = _reject
})
const cancel = () => {
clearTimeout (timeout)
reject (new Error('cancelled'))
}
return { delay, cancel }
}
export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) {
if (!ms) return new Promise (promise)
export async function promiseTimeout<T>(ms: number, promise: Promise<T>) {
if (!ms) return promise
// Create a promise that rejects in <ms> milliseconds
let timeoutI
const timeout = new Promise(
(_, reject) => timeoutI = setTimeout(() => reject(new BaileysError ('Timed out', promise)), ms)
)
const {delay, cancel} = delayCancellable (ms)
let pReject: (error) => void
const p = new Promise ((resolve, reject) => {
promise (resolve, reject)
pReject = reject
})
try {
const content = await Promise.race([promise, timeout])
const content = await Promise.race([
p,
delay.then(() => pReject(new BaileysError('timed out', p)))
])
cancel ()
return content as T
} finally {
clearTimeout (timeoutI)
cancel ()
}
}
// whatsapp requires a message tag for every message, we just use the timestamp as one
export function generateMessageTag(epoch?: number) {
let tag = Math.round(new Date().getTime()/1000).toString()
let tag = unixTimestampSeconds().toString()
if (epoch) tag += '.--' + epoch // attach epoch if provided
return tag
}

View File

@@ -1,3 +1,3 @@
export * from './6.Groups'
export * from './8.Groups'
export * from './Utils'
export * from './Constants'