diff --git a/README.md b/README.md index b3ed591..636ae43 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,12 @@ They're all nicely typed up, so you shouldn't have any issues with an Intellisen Also, these events are fired regardless of whether they are initiated by the Baileys client or are relayed from your phone. ``` ts - /** when the connection has opened successfully */ -on (event: 'open', listener: () => void): this +on (event: 'open', listener: (result: WAOpenResult) => void): this /** when the connection is opening */ on (event: 'connecting', listener: () => void): this /** when the connection has closed */ -on (event: 'close', listener: (err: {reason?: string, isReconnecting: boolean}) => void): this +on (event: 'close', listener: (err: {reason?: DisconnectReason | 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 */ @@ -143,12 +142,14 @@ on (event: 'user-presence-update', listener: (update: PresenceUpdate) => void): 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, read, unread, name changed) */ +/** when a chat is updated (archived, deleted, pinned) */ on (event: 'chat-update', listener: (chat: Partial & { 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) */ +/** when a message object itself is updated (receives its media info or is deleted) */ on (event: 'message-update', listener: (message: WAMessage) => void): this +/** when a message's status is updated (deleted, delivered, read, sent etc.) */ +on (event: 'message-status-update', listener: (message: WAMessageStatusUpdate) => 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 */ diff --git a/package.json b/package.json index 19dff42..5cff011 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@adiwajshing/baileys", - "version": "3.0.0", + "version": "3.0.1", "description": "WhatsApp Web API", "homepage": "https://github.com/adiwajshing/Baileys", "main": "lib/WAConnection/WAConnection.js", diff --git a/src/Tests/Tests.Connect.ts b/src/Tests/Tests.Connect.ts index a6c6ad5..95cafa5 100644 --- a/src/Tests/Tests.Connect.ts +++ b/src/Tests/Tests.Connect.ts @@ -47,33 +47,21 @@ describe('Test Connect', () => { const conn = new WAConnection() conn.connectOptions.timeoutMs = 20*1000 - await conn - .loadAuthInfo (auth) - .connect () - .then (conn => { - assert.ok(conn.user) - assert.ok(conn.user.jid) + await conn.loadAuthInfo (auth).connect () + assert.ok(conn.user) + assert.ok(conn.user.jid) - 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]) - } - } - }) - .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()) + assertChatDBIntegrity (conn) + await conn.logout() + conn.loadAuthInfo(auth) + + await conn.connect() + .then (() => assert.fail('should not have reconnected')) + .catch (err => { + assert.ok (err instanceof BaileysError) + assert.ok ((err as BaileysError).status >= 400) + }) + conn.close() }) it ('should disconnect & reconnect phone', async () => { const conn = new WAConnection () @@ -237,6 +225,31 @@ describe ('Reconnects', () => { }) describe ('Pending Requests', () => { + it ('should correctly send updates', async () => { + const conn = new WAConnection () + conn.connectOptions.timeoutMs = 20*1000 + conn.pendingRequestTimeoutMs = null + + conn.loadAuthInfo('./auth_info.json') + await conn.connect () + + conn.close () + + const oldChat = conn.chats.all()[0] + oldChat.archive = 'true' // mark the first chat as archived + oldChat.modify_tag = '1234' // change modify tag to detect change + + const result = await conn.connect () + assert.ok (!result.newConnection) + + const chat = result.updatedChats[oldChat.jid] + assert.ok (chat) + + assert.ok ('archive' in chat) + assert.equal (Object.keys(chat).length, 2) + + conn.close () + }) it('should queue requests when closed', async () => { const conn = new WAConnection () conn.pendingRequestTimeoutMs = null diff --git a/src/WAConnection/0.Base.ts b/src/WAConnection/0.Base.ts index 68eecb9..473b6c5 100644 --- a/src/WAConnection/0.Base.ts +++ b/src/WAConnection/0.Base.ts @@ -33,7 +33,7 @@ export class WAConnection extends EventEmitter { 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 */ + /** Should requests be queued when the connection breaks in between; if 0, then an error will be thrown */ pendingRequestTimeoutMs: number = null /** The connection state */ state: WAConnectionState = 'close' @@ -43,7 +43,7 @@ export class WAConnection extends EventEmitter { timeoutMs: 60*1000, waitForChats: true, maxRetries: 5, - connectCooldownMs: 5000 + connectCooldownMs: 2250 } /** When to auto-reconnect */ autoReconnect = ReconnectMode.onConnectionLost @@ -71,7 +71,7 @@ export class WAConnection extends EventEmitter { protected lastSeen: Date = null // last keep alive received protected qrTimeout: NodeJS.Timeout - protected lastConnectTime: Date = null + protected lastDisconnectTime: Date = null protected lastDisconnectReason: DisconnectReason constructor () { @@ -85,7 +85,7 @@ export class WAConnection extends EventEmitter { * @param options the connect options */ async connect() { - return this + return null } async unexpectedDisconnect (error: DisconnectReason) { const willReconnect = @@ -313,6 +313,7 @@ export class WAConnection extends EventEmitter { this.msgCount = 0 this.phoneConnected = false this.lastDisconnectReason = reason + this.lastDisconnectTime = new Date () this.endConnection () diff --git a/src/WAConnection/3.Connect.ts b/src/WAConnection/3.Connect.ts index 463f730..223ba34 100644 --- a/src/WAConnection/3.Connect.ts +++ b/src/WAConnection/3.Connect.ts @@ -1,31 +1,36 @@ import * as Utils from './Utils' -import { WAMessage, WAChat, MessageLogLevel, WANode, KEEP_ALIVE_INTERVAL_MS, BaileysError, WAConnectOptions, DisconnectReason, UNAUTHORIZED_CODES, WAContact, TimedOutError, CancelledError } from './Constants' +import { WAMessage, WAChat, MessageLogLevel, WANode, KEEP_ALIVE_INTERVAL_MS, BaileysError, WAConnectOptions, DisconnectReason, UNAUTHORIZED_CODES, WAContact, TimedOutError, CancelledError, WAOpenResult } from './Constants' import {WAConnection as Base} from './1.Validation' import Decoder from '../Binary/Decoder' export class WAConnection extends Base { /** Connect to WhatsApp Web */ - async connect() { + async connect () { // if we're already connected, throw an error if (this.state !== 'close') throw new Error('cannot connect when state=' + this.state) const options = this.connectOptions - + const newConnection = !this.authInfo + this.state = 'connecting' this.emit ('connecting') let tries = 0 + let lastConnect = this.lastDisconnectTime + var updates while (this.state === 'connecting') { tries += 1 try { - const diff = this.lastConnectTime ? new Date().getTime()-this.lastConnectTime.getTime() : Infinity - await this.connectInternal ( + const diff = lastConnect ? new Date().getTime()-lastConnect.getTime() : Infinity + updates = await this.connectInternal ( options, diff > this.connectOptions.connectCooldownMs ? 0 : this.connectOptions.connectCooldownMs ) this.phoneConnected = true this.state = 'open' } catch (error) { + lastConnect = new Date() + const loggedOut = error instanceof BaileysError && UNAUTHORIZED_CODES.includes(error.status) const willReconnect = !loggedOut && (tries <= (options?.maxRetries || 5)) && this.state === 'connecting' @@ -36,12 +41,12 @@ export class WAConnection extends Base { } if (!willReconnect) throw error - } finally { - this.lastConnectTime = new Date() } } - this.emit ('open') + const updatedChats = !!this.lastDisconnectTime && updates + const result: WAOpenResult = { newConnection, updatedChats } + this.emit ('open', result) this.releasePendingRequests () this.startKeepAliveRequest() @@ -50,8 +55,7 @@ export class WAConnection extends Base { this.conn.on ('close', () => this.unexpectedDisconnect (DisconnectReason.close)) - return this - + return result } /** Meat of the connect logic */ protected async connectInternal (options: WAConnectOptions, delayMs?: number) { @@ -61,7 +65,7 @@ export class WAConnection extends Base { const { ws, cancel } = Utils.openWebSocketConnection (5000, false) - let task = ws + let task: Promise }> = ws .then (conn => this.conn = conn) .then (() => ( this.conn.on('message', data => this.onMessageRecieved(data as any)) @@ -78,16 +82,17 @@ export class WAConnection extends Base { let cancelTask: () => void if (typeof options?.waitForChats === 'undefined' ? true : options?.waitForChats) { - const {waitForChats, cancelChats} = this.receiveChatsAndContacts(true) + const {waitForChats, cancelChats} = this.receiveChatsAndContacts() - task = Promise.all ([task, waitForChats]).then (() => {}) + task = Promise.all ([task, waitForChats]).then (([_, updates]) => updates) cancelTask = () => { cancelChats(); cancel() } } else cancelTask = cancel // determine whether reconnect should be used or not const shouldUseReconnect = this.lastDisconnectReason !== DisconnectReason.replaced && this.lastDisconnectReason !== DisconnectReason.unknown && - this.lastDisconnectReason !== DisconnectReason.intentional && this.user?.jid + this.lastDisconnectReason !== DisconnectReason.intentional && + this.user?.jid const reconnectID = shouldUseReconnect ? this.user.jid.replace ('@s.whatsapp.net', '@c.us') : null const promise = Utils.promiseTimeout(timeoutMs, (resolve, reject) => ( @@ -96,7 +101,7 @@ export class WAConnection extends Base { .catch (err => { this.endConnection () throw err - }) as Promise + }) as Promise }> return { promise, cancel: cancelTask } } @@ -114,23 +119,27 @@ export class WAConnection extends Base { cancellations.push (cancel) } - return promise - .then (() => { - const {promise, cancel} = connect () - cancellations.push (cancel) - return promise - }) - .finally (() => { - cancel() - this.off('close', cancel) - }) + try { + await promise + + const result = connect () + cancellations.push (result.cancel) + + const final = await result.promise + return final + } finally { + cancel() + this.off('close', cancel) + } } /** * Sets up callbacks to receive chats, contacts & messages. * Must be called immediately after connect * @returns [chats, contacts] */ - protected receiveChatsAndContacts(stopAfterMostRecentMessage: boolean=false) { + protected receiveChatsAndContacts() { + const oldChats: {[k: string]: WAChat} = this.chats['dict'] + this.contacts = {} this.chats.clear () @@ -141,21 +150,19 @@ export class WAConnection extends Base { const deregisterCallbacks = () => { // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages this.deregisterCallback(['action', 'add:last']) - if (!stopAfterMostRecentMessage) { - this.deregisterCallback(['action', 'add:before']) - this.deregisterCallback(['action', 'add:unread']) - } + this.deregisterCallback(['action', 'add:before']) + this.deregisterCallback(['action', 'add:unread']) + this.deregisterCallback(['response', 'type:chat']) this.deregisterCallback(['response', 'type:contacts']) } - const checkForResolution = () => { - if (receivedContacts && receivedMessages) resolveTask () - } + const checkForResolution = () => receivedContacts && receivedMessages && resolveTask () // wait for messages to load const chatUpdate = json => { receivedMessages = true - const isLast = json[1].last || stopAfterMostRecentMessage + + const isLast = json[1].last const messages = json[2] as WANode[] if (messages) { @@ -171,10 +178,9 @@ export class WAConnection extends Base { // wait for actual messages to load, "last" is the most recent message, "before" contains prior messages this.registerCallback(['action', 'add:last'], chatUpdate) - if (!stopAfterMostRecentMessage) { - this.registerCallback(['action', 'add:before'], chatUpdate) - this.registerCallback(['action', 'add:unread'], chatUpdate) - } + this.registerCallback(['action', 'add:before'], chatUpdate) + this.registerCallback(['action', 'add:unread'], chatUpdate) + // get chats this.registerCallback(['response', 'type:chat'], json => { if (json[1].duplicate || !json[2]) return @@ -185,6 +191,7 @@ export class WAConnection extends Base { 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 @@ -220,21 +227,33 @@ export class WAConnection extends Base { // wait for the chats & contacts to load let cancelChats: () => void - const waitForChats = new Promise ((resolve, reject) => { - resolveTask = resolve - cancelChats = () => reject (CancelledError()) - }) - .then (() => ( - this.chats - .all () - .forEach (chat => { + const waitForChats = async () => { + try { + await new Promise ((resolve, reject) => { + resolveTask = resolve + cancelChats = () => reject (CancelledError()) + }) + + const updatedChats: { [k: string]: Partial } = {} + for (let chat of this.chats.all()) { const respectiveContact = this.contacts[chat.jid] chat.name = respectiveContact?.name || respectiveContact?.notify || chat.name - }) - )) - .finally (deregisterCallbacks) + + if (!oldChats[chat.jid]) { + updatedChats[chat.jid] = chat + } else if (oldChats[chat.jid].t < chat.t || oldChats[chat.jid].modify_tag !== chat.modify_tag) { + const changes = Utils.shallowChanges (oldChats[chat.jid], chat) + delete changes.messages - return { waitForChats, cancelChats } + updatedChats[chat.jid] = changes + } + } + return updatedChats + } finally { + deregisterCallbacks () + } + } + return { waitForChats: waitForChats (), cancelChats } } private releasePendingRequests () { this.pendingRequests.forEach (({resolve}) => resolve()) // send off all pending request diff --git a/src/WAConnection/4.Events.ts b/src/WAConnection/4.Events.ts index 68580da..b7acc31 100644 --- a/src/WAConnection/4.Events.ts +++ b/src/WAConnection/4.Events.ts @@ -1,6 +1,6 @@ import * as QR from 'qrcode-terminal' import { WAConnection as Base } from './3.Connect' -import { WAMessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, MessageLogLevel, PresenceUpdate, BaileysEvent, DisconnectReason, WANode } from './Constants' +import { WAMessageStatusUpdate, WAMessage, WAContact, WAChat, WAMessageProto, WA_MESSAGE_STUB_TYPE, WA_MESSAGE_STATUS_TYPE, MessageLogLevel, PresenceUpdate, BaileysEvent, DisconnectReason, WANode, WAOpenResult } from './Constants' import { whatsappID, unixTimestampSeconds, isGroupID, toNumber } from './Utils' export class WAConnection extends Base { @@ -143,18 +143,7 @@ export class WAConnection extends Base { } this.registerCallback('Msg', func) this.registerCallback('MsgInfo', func) - /*// genetic chat action - this.registerCallback (['Chat', 'cmd:action'], json => { - const data = json[1].data as WANode - if (!data) return - - this.log (data, MessageLogLevel.info) - - if (data[0] === 'create') { - - } - })*/ - + this.on ('qr', qr => QR.generate(qr, { small: true })) } /** Get the URL to download the profile picture of a person/group */ @@ -300,7 +289,7 @@ export class WAConnection extends Base { // Add all event types /** when the connection has opened successfully */ - on (event: 'open', listener: () => void): this + on (event: 'open', listener: (result: WAOpenResult) => void): this /** when the connection is opening */ on (event: 'connecting', listener: () => void): this /** when the connection has closed */ diff --git a/src/WAConnection/7.MessagesExtra.ts b/src/WAConnection/7.MessagesExtra.ts index c73160d..0d8799c 100644 --- a/src/WAConnection/7.MessagesExtra.ts +++ b/src/WAConnection/7.MessagesExtra.ts @@ -7,7 +7,7 @@ import { WAUrlInfo, WAMessageContent, WAMetric, WAFlag, WANode, WAMessage, WAMessageProto, BaileysError, MessageLogLevel, WA_MESSAGE_STATUS_TYPE } from './Constants' -import { whatsappID } from './Utils' +import { whatsappID, delay } from './Utils' export class WAConnection extends Base { @@ -173,6 +173,27 @@ export class WAConnection extends Base { } return loadMessage() as Promise } + /** + * Find a message in a given conversation + * @param chunkSize the number of messages to load in a single request + * @param onMessage callback for every message retreived, if return true -- the loop will break + */ + async findMessage (jid: string, chunkSize: number, onMessage: (m: WAMessage) => boolean) { + const chat = this.chats.get (whatsappID(jid)) + let count = chat?.messages?.length || chunkSize + let offsetID + while (true) { + const {messages, cursor} = await this.loadMessages(jid, count, offsetID, true) + // callback with most recent message first (descending order of date) + for (let i = messages.length - 1; i >= 0; i--) { + if (onMessage(messages[i])) return + } + if (messages.length === 0) return + // if there are more messages + offsetID = cursor + await delay (200) + } + } /** Load a single message specified by the ID */ async loadMessage (jid: string, messageID: string) { // load the message before the given message diff --git a/src/WAConnection/Constants.ts b/src/WAConnection/Constants.ts index 144a698..f3bfb6c 100644 --- a/src/WAConnection/Constants.ts +++ b/src/WAConnection/Constants.ts @@ -219,7 +219,6 @@ export enum WAFlag { } /** Tag used with binary queries */ export type WATag = [WAMetric, WAFlag] - /** set of statuses visible to other people; see updatePresence() in WhatsAppWeb.Send */ export enum Presence { available = 'available', // "online" @@ -310,6 +309,15 @@ export interface WAMessageStatusUpdate { /** Status of the Message IDs */ type: WA_MESSAGE_STATUS_TYPE } + +export interface WAOpenResult { + /** Was this connection opened via a QR scan */ + newConnection: boolean + updatedChats?: { + [k: string]: Partial + } +} + export enum GroupSettingChange { messageSend = 'announcement', settingsChange = 'locked', diff --git a/src/WAConnection/Utils.ts b/src/WAConnection/Utils.ts index b6b3d09..30dd484 100644 --- a/src/WAConnection/Utils.ts +++ b/src/WAConnection/Utils.ts @@ -33,6 +33,21 @@ export const waChatUniqueKey = (c: WAChat) => ((c.t*100000) + (hashCode(c.jid)%1 export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net') export const isGroupID = (jid: string) => jid?.includes ('@g.us') +export function shallowChanges (old: T, current: T): Partial { + let changes: Partial = {} + for (let key in current) { + if (old[key] !== current[key]) { + changes[key] = current[key] || null + } + } + for (let key in old) { + if (!changes[key] && old[key] !== current[key]) { + changes[key] = current[key] || null + } + } + return changes +} + /** 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)) diff --git a/yarn.lock b/yarn.lock index d666f7c..2e13e64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -373,14 +373,14 @@ integrity sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w== "@types/node@*", "@types/node@^14.6.2": - version "14.6.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.2.tgz#264b44c5a28dfa80198fc2f7b6d3c8a054b9491f" - integrity sha512-onlIwbaeqvZyniGPfdw/TEhKIh79pz66L1q06WUQqJLnAb6wbjvOtepLYTGHTqzdXgBYIE3ZdmqHDGsRsbBz7A== + version "14.6.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.3.tgz#cc4f979548ca4d8e7b90bc0180052ab99ee64224" + integrity sha512-pC/hkcREG6YfDfui1FBmj8e20jFU5Exjw4NYDm8kEdrW+mOh0T1Zve8DWKnS7ZIZvgncrctcNCXF4Q2I+loyww== "@types/node@^13.7.0": - version "13.13.15" - resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" - integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== + version "13.13.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.16.tgz#66f2177047b61131eaac18c47eb25d6f1317070a" + integrity sha512-dJ9vXxJ8MEwzNn4GkoAGauejhXoKuJyYKegsA6Af25ZpEDXomeVXt5HUWUNVHk5UN7+U0f6ghC6otwt+7PdSDg== "@types/strip-bom@^3.0.0": version "3.0.0" @@ -1878,9 +1878,9 @@ trim-newlines@^1.0.0: integrity sha1-WIeWa7WCpFA6QetST301ARgVphM= ts-node-dev@^1.0.0-pre.61: - version "1.0.0-pre.61" - resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.0.0-pre.61.tgz#e3ecd7388cdf4ebdebb9e754017550ed58cd4447" - integrity sha512-BZTR7mk7K3fJmoD5cdGho4jYZNd0bbQegJs6UXpfOB9qgVqMyis8p76VlFlMBoNhGPR5ojmE0zgk2qPAH7Ac2Q== + version "1.0.0-pre.62" + resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.0.0-pre.62.tgz#835644c43669b659a880379b9d06df86cef665ad" + integrity sha512-hfsEuCqUZOVnZ86l7A3icxD1nFt1HEmLVbx4YOHCkrbSHPBNWcw+IczAPZo3zz7YiOm9vs0xG6OENNrkgm89tQ== dependencies: chokidar "^3.4.0" dateformat "~1.0.4-1.2.3"