diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 82036b5..f0228fc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,6 @@ name: Lint on: push: - pull_request: jobs: eslint: diff --git a/README.md b/README.md index cd612f5..7f76c99 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![npm](https://img.shields.io/npm/v/whatsapp-web.js.svg)](https://www.npmjs.com/package/whatsapp-web.js) [![Depfu](https://badges.depfu.com/badges/4a65a0de96ece65fdf39e294e0c8dcba/overview.svg)](https://depfu.com/github/pedroslopez/whatsapp-web.js?project_id=9765) ![WhatsApp_Web 2.2202.8](https://img.shields.io/badge/WhatsApp_Web-2.2202.8-brightgreen.svg) [![Discord Chat](https://img.shields.io/discord/698610475432411196.svg?logo=discord)](https://discord.gg/H7DqQs4) +[![npm](https://img.shields.io/npm/v/whatsapp-web.js.svg)](https://www.npmjs.com/package/whatsapp-web.js) [![Depfu](https://badges.depfu.com/badges/4a65a0de96ece65fdf39e294e0c8dcba/overview.svg)](https://depfu.com/github/pedroslopez/whatsapp-web.js?project_id=9765) ![WhatsApp_Web 2.2202.12](https://img.shields.io/badge/WhatsApp_Web-2.2202.12-brightgreen.svg) [![Discord Chat](https://img.shields.io/discord/698610475432411196.svg?logo=discord)](https://discord.gg/H7DqQs4) # whatsapp-web.js A WhatsApp API client that connects through the WhatsApp Web browser app diff --git a/index.d.ts b/index.d.ts index c3c4d6c..05e0285 100644 --- a/index.d.ts +++ b/index.d.ts @@ -443,6 +443,7 @@ declare namespace WAWebJS { PAYMENT = 'payment', UNKNOWN = 'unknown', GROUP_INVITE = 'groups_v4_invite', + BUTTONS_RESPONSE = 'buttons_response' } /** Client status */ diff --git a/package.json b/package.json index 117a118..4d9331f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "./index.js", "typings": "./index.d.ts", "scripts": { - "test": "mocha tests", + "test": "mocha tests --recursive", + "test-single": "mocha", "shell": "node --experimental-repl-await ./shell.js", "generate-docs": "node_modules/.bin/jsdoc --configure .jsdoc.json --verbose" }, @@ -40,13 +41,13 @@ "devDependencies": { "@types/node-fetch": "^2.5.12", "chai": "^4.3.4", - "dotenv": "^10.0.0", + "dotenv": "^16.0.0", "eslint": "^8.4.1", - "eslint-plugin-mocha": "^9.0.0", + "eslint-plugin-mocha": "^10.0.3", "jsdoc": "^3.6.4", "jsdoc-baseline": "^0.1.5", "mocha": "^9.0.2", - "sinon": "^12.0.1" + "sinon": "^13.0.1" }, "engines": { "node": ">=12.0.0" diff --git a/src/Client.js b/src/Client.js index 2888dd2..6b761fb 100644 --- a/src/Client.js +++ b/src/Client.js @@ -76,7 +76,7 @@ class Client extends EventEmitter { page = (await browser.pages())[0]; } - page.setUserAgent(this.options.userAgent); + await page.setUserAgent(this.options.userAgent); this.pupBrowser = browser; this.pupPage = page; @@ -109,7 +109,7 @@ class Client extends EventEmitter { const KEEP_PHONE_CONNECTED_IMG_SELECTOR = '[data-icon="intro-md-beta-logo-dark"], [data-icon="intro-md-beta-logo-light"], [data-asset-intro-image-light="true"], [data-asset-intro-image-dark="true"]'; if (this.options.session) { - // Check if session restore was successfull + // Check if session restore was successful try { await page.waitForSelector(KEEP_PHONE_CONNECTED_IMG_SELECTOR, { timeout: this.options.authTimeoutMs }); } catch (err) { @@ -168,10 +168,22 @@ class Client extends EventEmitter { this._qrRefreshInterval = setInterval(getQrCode, this.options.qrRefreshIntervalMs); // Wait for code scan - await page.waitForSelector(KEEP_PHONE_CONNECTED_IMG_SELECTOR, { timeout: 0 }); - clearInterval(this._qrRefreshInterval); - this._qrRefreshInterval = undefined; + try { + await page.waitForSelector(KEEP_PHONE_CONNECTED_IMG_SELECTOR, { timeout: 0 }); + clearInterval(this._qrRefreshInterval); + this._qrRefreshInterval = undefined; + } catch(error) { + if ( + error.name === 'ProtocolError' && + error.message && + error.message.match(/Target closed/) + ) { + // something has called .destroy() while waiting + return; + } + throw error; + } } await page.evaluate(ExposeStore, moduleRaid.toString()); @@ -430,11 +442,13 @@ class Client extends EventEmitter { */ this.emit(Events.READY); - // Disconnect when navigating away - // Because WhatsApp Web now reloads when logging out from the device, this also covers that case + // Disconnect when navigating away when in PAIRING state (detect logout) this.pupPage.on('framenavigated', async () => { - this.emit(Events.DISCONNECTED, 'NAVIGATION'); - await this.destroy(); + const appState = await this.getState(); + if(appState === WAState.PAIRING) { + this.emit(Events.DISCONNECTED, 'NAVIGATION'); + await this.destroy(); + } }); } @@ -727,7 +741,7 @@ class Client extends EventEmitter { return await this.pupPage.evaluate(async chatId => { let chat = await window.Store.Chat.get(chatId); await window.Store.Cmd.archiveChat(chat, true); - return chat.archive; + return true; }, chatId); } @@ -739,7 +753,7 @@ class Client extends EventEmitter { return await this.pupPage.evaluate(async chatId => { let chat = await window.Store.Chat.get(chatId); await window.Store.Cmd.archiveChat(chat, false); - return chat.archive; + return false; }, chatId); } diff --git a/src/structures/Chat.js b/src/structures/Chat.js index 32bb486..b8aacb1 100644 --- a/src/structures/Chat.js +++ b/src/structures/Chat.js @@ -65,7 +65,7 @@ class Chat extends Base { /** * Indicates if the chat is muted or not - * @type {number} + * @type {boolean} */ this.isMuted = data.isMuted; diff --git a/src/structures/Message.js b/src/structures/Message.js index 9bbdae2..0c582cd 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -382,7 +382,7 @@ class Message extends Base { await this.client.pupPage.evaluate((msgId, everyone) => { let msg = window.Store.Msg.get(msgId); - if (everyone && msg.id.fromMe && msg.canRevoke()) { + if (everyone && msg.id.fromMe && msg._canRevoke()) { return window.Store.Cmd.sendRevokeMsgs(msg.chat, [msg], true); } diff --git a/src/util/Injected.js b/src/util/Injected.js index 6989a86..06b7049 100644 --- a/src/util/Injected.js +++ b/src/util/Injected.js @@ -8,7 +8,6 @@ exports.ExposeStore = (moduleRaidStr) => { window.Store = Object.assign({}, window.mR.findModule(m => m.default && m.default.Chat)[0].default); window.Store.AppState = window.mR.findModule('STREAM')[0].Socket; window.Store.Conn = window.mR.findModule('Conn')[0].Conn; - window.Store.CryptoLib = window.mR.findModule('decryptE2EMedia')[0]; window.Store.Wap = window.mR.findModule('queryLinkPreview')[0].default; window.Store.SendSeen = window.mR.findModule('sendSeen')[0]; window.Store.SendClear = window.mR.findModule('sendClear')[0]; @@ -31,7 +30,7 @@ exports.ExposeStore = (moduleRaidStr) => { window.Store.BlockContact = window.mR.findModule('blockContact')[0]; window.Store.GroupMetadata = window.mR.findModule((module) => module.default && module.default.handlePendingInvite)[0].default; window.Store.UploadUtils = window.mR.findModule((module) => (module.default && module.default.encryptAndUpload) ? module.default : null)[0].default; - window.Store.Label = window.mR.findModule('LabelCollection')[0].default; + window.Store.Label = window.mR.findModule('LabelCollection')[0].LabelCollection; window.Store.Features = window.mR.findModule('FEATURE_CHANGE_EVENT')[0].GK; window.Store.QueryOrder = window.mR.findModule('queryOrder')[0]; window.Store.QueryProduct = window.mR.findModule('queryProduct')[0]; diff --git a/tests/client.js b/tests/client.js index 0ad8e72..49c69ec 100644 --- a/tests/client.js +++ b/tests/client.js @@ -7,7 +7,7 @@ const Contact = require('../src/structures/Contact'); const Message = require('../src/structures/Message'); const MessageMedia = require('../src/structures/MessageMedia'); const Location = require('../src/structures/Location'); -const { MessageTypes } = require('../src/util/Constants'); +const { MessageTypes, WAState } = require('../src/util/Constants'); const remoteId = helper.remoteId; @@ -145,6 +145,46 @@ describe('Client', function() { await client.destroy(); }); + + it('can take over if client was logged in somewhere else with takeoverOnConflict=true', async function() { + this.timeout(40000); + + const readyCallback1 = sinon.spy(); + const readyCallback2 = sinon.spy(); + const disconnectedCallback1 = sinon.spy(); + const disconnectedCallback2 = sinon.spy(); + + const client1 = helper.createClient({ + withSession: true, + options: { takeoverOnConflict: true, takeoverTimeoutMs: 5000 } + }); + const client2 = helper.createClient({withSession: true}); + + client1.on('ready', readyCallback1); + client2.on('ready', readyCallback2); + client1.on('disconnected', disconnectedCallback1); + client2.on('disconnected', disconnectedCallback2); + + await client1.initialize(); + expect(readyCallback1.called).to.equal(true); + expect(readyCallback2.called).to.equal(false); + expect(disconnectedCallback1.called).to.equal(false); + expect(disconnectedCallback2.called).to.equal(false); + + await client2.initialize(); + expect(readyCallback2.called).to.equal(true); + expect(disconnectedCallback1.called).to.equal(false); + expect(disconnectedCallback2.called).to.equal(false); + + // wait for takeoverTimeoutMs to kick in + await helper.sleep(5200); + expect(disconnectedCallback1.called).to.equal(false); + expect(disconnectedCallback2.called).to.equal(true); + expect(disconnectedCallback2.calledWith(WAState.CONFLICT)).to.equal(true); + + await client1.destroy(); + + }); }); describe('Authenticated', function() { @@ -160,6 +200,12 @@ describe('Client', function() { await client.destroy(); }); + it('can get current WhatsApp Web version', async function () { + const version = await client.getWWebVersion(); + expect(typeof version).to.equal('string'); + console.log(`WA Version: ${version}`); + }); + describe('Expose Store', function() { it('exposes the store', async function() { const exposed = await client.pupPage.evaluate(() => { @@ -171,46 +217,46 @@ describe('Client', function() { it('exposes all required WhatsApp Web internal models', async function() { const expectedModules = [ - 'Chat', - 'Msg', - 'Contact', - 'Conn', 'AppState', - 'CryptoLib', - 'Wap', - 'SendSeen', - 'SendClear', - 'SendDelete', - 'genId', - 'SendMessage', - 'MsgKey', - 'Invite', - 'OpaqueData', - 'MediaPrep', - 'MediaObject', - 'MediaUpload', - 'Cmd', - 'MediaTypes', - 'VCard', - 'UserConstructor', - 'Validators', - 'WidFactory', 'BlockContact', - 'GroupMetadata', - 'Sticker', - 'UploadUtils', - 'Label', + 'Call', + 'Chat', + 'Cmd', + 'Conn', + 'Contact', + 'DownloadManager', 'Features', + 'GroupMetadata', + 'Invite', + 'Label', + 'MediaObject', + 'MediaPrep', + 'MediaTypes', + 'MediaUpload', + 'Msg', + 'MsgKey', + 'OpaqueData', 'QueryOrder', 'QueryProduct', - 'DownloadManager' - ]; + 'SendClear', + 'SendDelete', + 'SendMessage', + 'SendSeen', + 'Sticker', + 'UploadUtils', + 'UserConstructor', + 'VCard', + 'Validators', + 'Wap', + 'WidFactory', + 'genId' + ]; - const loadedModules = await client.pupPage.evaluate(() => { - return Object.keys(window.Store); - }); + const loadedModules = await client.pupPage.evaluate((expectedModules) => { + return expectedModules.filter(m => Boolean(window.Store[m])); + }, expectedModules); - expect(loadedModules).to.include.members(expectedModules); + expect(loadedModules).to.have.members(expectedModules); }); }); @@ -486,5 +532,53 @@ END:VCARD`; expect(formatted).to.eql('+1 (809) 220-1111'); }); }); + + describe('Search messages', function () { + it('can search for messages', async function () { + this.timeout(5000); + + const m1 = await client.sendMessage(remoteId, 'I\'m searching for Super Mario Brothers'); + const m2 = await client.sendMessage(remoteId, 'This also contains Mario'); + const m3 = await client.sendMessage(remoteId, 'Nothing of interest here, just Luigi'); + + // wait for search index to catch up + await helper.sleep(1000); + + const msgs = await client.searchMessages('Mario', {chatId: remoteId}); + expect(msgs.length).to.be.greaterThanOrEqual(2); + const msgIds = msgs.map(m => m.id._serialized); + expect(msgIds).to.include.members([ + m1.id._serialized, m2.id._serialized + ]); + expect(msgIds).to.not.include.members([m3.id._serialized]); + }); + }); + + describe('Status/About', function () { + let me, previousStatus; + + before(async function () { + me = await client.getContactById(client.info.wid._serialized); + previousStatus = await me.getAbout(); + }); + + after(async function () { + await client.setStatus(previousStatus); + }); + + it('can set the status text', async function () { + await client.setStatus('My shiny new status'); + + const status = await me.getAbout(); + expect(status).to.eql('My shiny new status'); + }); + + it('can set the status text to something else', async function () { + await client.setStatus('Busy'); + + const status = await me.getAbout(); + expect(status).to.eql('Busy'); + }); + }); }); }); \ No newline at end of file diff --git a/tests/structures/chat.js b/tests/structures/chat.js new file mode 100644 index 0000000..144aa5d --- /dev/null +++ b/tests/structures/chat.js @@ -0,0 +1,188 @@ +const { expect } = require('chai'); + +const helper = require('../helper'); +const Message = require('../../src/structures/Message'); +const { MessageTypes } = require('../../src/util/Constants'); +const { Contact } = require('../../src/structures'); + +const remoteId = helper.remoteId; + +describe('Chat', function () { + let client; + let chat; + + before(async function() { + this.timeout(35000); + client = helper.createClient({ withSession: true }); + await client.initialize(); + chat = await client.getChatById(remoteId); + }); + + after(async function () { + await client.destroy(); + }); + + it('can send a message to a chat', async function () { + const msg = await chat.sendMessage('hello world'); + expect(msg).to.be.instanceOf(Message); + expect(msg.type).to.equal(MessageTypes.TEXT); + expect(msg.fromMe).to.equal(true); + expect(msg.body).to.equal('hello world'); + expect(msg.to).to.equal(remoteId); + }); + + it('can fetch messages sent in a chat', async function () { + this.timeout(5000); + await helper.sleep(1000); + const msg = await chat.sendMessage('another message'); + + const messages = await chat.fetchMessages(); + expect(messages.length).to.be.greaterThanOrEqual(2); + + const fetchedMsg = messages[messages.length-1]; + expect(fetchedMsg).to.be.instanceOf(Message); + expect(fetchedMsg.type).to.equal(MessageTypes.TEXT); + expect(fetchedMsg.id._serialized).to.equal(msg.id._serialized); + expect(fetchedMsg.body).to.equal(msg.body); + }); + + it('can use a limit when fetching messages sent in a chat', async function () { + await helper.sleep(1000); + const msg = await chat.sendMessage('yet another message'); + + const messages = await chat.fetchMessages({limit: 1}); + expect(messages).to.have.lengthOf(1); + + const fetchedMsg = messages[0]; + expect(fetchedMsg).to.be.instanceOf(Message); + expect(fetchedMsg.type).to.equal(MessageTypes.TEXT); + expect(fetchedMsg.id._serialized).to.equal(msg.id._serialized); + expect(fetchedMsg.body).to.equal(msg.body); + }); + + it('can get the related contact', async function () { + const contact = await chat.getContact(); + expect(contact).to.be.instanceOf(Contact); + expect(contact.id._serialized).to.equal(chat.id._serialized); + }); + + describe('Seen', function () { + it('can mark a chat as unread', async function () { + await chat.markUnread(); + await helper.sleep(500); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.unreadCount).to.equal(-1); + }); + + it('can mark a chat as seen', async function () { + const res = await chat.sendSeen(); + expect(res).to.equal(true); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.unreadCount).to.equal(0); + }); + }); + + describe('Archiving', function (){ + it('can archive a chat', async function () { + const res = await chat.archive(); + expect(res).to.equal(true); + + await helper.sleep(1000); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.archived).to.equal(true); + }); + + it('can unarchive a chat', async function () { + const res = await chat.unarchive(); + expect(res).to.equal(false); + + await helper.sleep(1000); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.archived).to.equal(false); + }); + }); + + describe('Pinning', function () { + it('can pin a chat', async function () { + const res = await chat.pin(); + expect(res).to.equal(true); + + await helper.sleep(1000); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.pinned).to.equal(true); + }); + + it('can unpin a chat', async function () { + const res = await chat.unpin(); + expect(res).to.equal(false); + await helper.sleep(1000); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.pinned).to.equal(false); + }); + }); + + describe('Muting', function () { + it('can mute a chat forever', async function() { + await chat.mute(); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.isMuted).to.equal(true); + expect(chat.muteExpiration).to.equal(-1); + }); + + it('can mute a chat until a specific date', async function() { + const unmuteDate = new Date(new Date().getTime() + (1000*60*60)); + await chat.mute(unmuteDate); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.isMuted).to.equal(true); + expect(chat.muteExpiration).to.equal( + Math.round(unmuteDate.getTime() / 1000) + ); + }); + + it('can unmute a chat', async function () { + await chat.unmute(); + await helper.sleep(500); + + // refresh chat + chat = await client.getChatById(remoteId); + expect(chat.isMuted).to.equal(false); + expect(chat.muteExpiration).to.equal(0); + }); + }); + + // eslint-disable-next-line mocha/no-skipped-tests + describe.skip('Destructive operations', function () { + it('can clear all messages from chat', async function () { + this.timeout(5000); + + const res = await chat.clearMessages(); + expect(res).to.equal(true); + + await helper.sleep(3000); + + const msgs = await chat.fetchMessages(); + expect(msgs).to.have.lengthOf(0); + }); + + it('can delete a chat', async function () { + const res = await chat.delete(); + expect(res).to.equal(true); + }); + }); +}); \ No newline at end of file diff --git a/tools/version-checker/.version b/tools/version-checker/.version index 928db87..bfdd224 100644 --- a/tools/version-checker/.version +++ b/tools/version-checker/.version @@ -1 +1 @@ -2.2202.8 \ No newline at end of file +2.2202.12 \ No newline at end of file