From 0d55d408850ef5214fde751d8b55af42fd70aef1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Feb 2022 14:51:08 -0800 Subject: [PATCH] feat: Multi-device support (#889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :ambulance: Added ready selector for multi-device * SendMessage fix * File management system and some fixes * cleanup * cleanup again * eslint * critical fix for reloading the same session * Checking for valid folder name (regex) * ESLint hotfix (regex escapes) * Typings cleanup * cleanup listener * Multi-device Branch merge (#888) * Duplicate * qr fix and allow non-beta users to connect * urgent: selector fix * urgent: qr timeout fix * fix * Updated type so no TS error when sending list/buttons * Update index.d.ts * fix QueryExist for Multidevice (#928) * creates isRegisteredUserBeta * fix QueryExist * fix Error: GROUP_JID: invalid jid type: Not an instance of WID issue (#926) * fix Error: GROUP_JID: invalid jid type: Not an instance of WID issue * clean code * Cleanup * Fix for update chrome error * ESLint fix * :red_light: fix for RMDIR * Update README.md * Update README.md * fix: getProfilePicUrl fix by victormga (#941) * fix: MD presence available/unavailable (#942) * delete session when appropriate & fix for SW * ignore QR timeout errors * Presence and ChatState updates working for MD+Non-MD * shell uses new session storage * lint fix * support session.json-based auth for non-md * md fix * md fix * fix shell clientId * remove exclusive mocha test * make linkPreview default to false * remove ignored errors on getQuotedMessage * fix: dont modify existing this.options.puppeteer object * tests work with new dir auth * remove exclusive test * fixes and tests for group creation and participant functions * remove unused function * wip fix group settings functions * isRegisteredUser && getNumberId hotFix (#955) * isRegisteredUser && getNumberId hotFix A fix for client.isRegisteredUser and client.getNumberId. Use for reference or if you are stuck with MD and NEEDS this function. Problably Whatsapp will break this in a couple weeks * fix for non-md Co-authored-by: Rajeh Taher * Fix WA 2.2146.9 MD + victormga branch (#991) * qrcode now uses observers instead of timeout * automatic auth/qrcode detection * Fix WA 2.2146.9 MD Got from github:victormga/whatsapp-web.js#multidevice maybe it's behind pedro branch Co-authored-by: victormga * fix * fix* * getnumberid to multidevice (#1027) * getNumberId to main isRegisteredUser && getNumberId hotFix #955 To main * Update Client.js Co-authored-by: tuyuribr <45042245+tuyuribr@users.noreply.github.com> * Update Client.js * Message.raw() (#1005) * Message.raw() * i just noticed * Update index.d.ts * Update index.d.ts * Update Message.js * Get rid of sharp now!!!!!!!! (#1045) * commit 1 * finally, gotten rid of sharp * pckg.json * service worker fix & disableMessage option * typings * Update example.js * clear session system * Update Client.js * Update Client.js * Fix accepting group private invite (#1094) Co-authored-by: github-actions[bot] * [MD] Add getCommonGroups with specific user. (#1097) * Add getCommonGroups with specific user. * Fix * Fix * Fix Co-authored-by: github-actions[bot] * Fix getCommonGroups. (#1122) * Fix of Unexpected identifier async destroy() (#1123) * Fix of Unexpected identifier async destroy() * Fix made in #1107 * Temporary fix for "Sticker" module * some really quick changes * Update Injected.js * Update Injected.js * Update index.d.ts * fix: getNumberId Solved (#1142) * getNumberId Solved * isRegisteredUser Solved * formmated * Apply suggestions from code review * Update src/util/Injected.js Co-authored-by: Rajeh Taher * Fix: "Chrome user data dir was not found ..." fixes the error caused by puppeteer. * Update Client.js (#1154) * fix: getNumberId and isRegisteredUser (#1159) * fix: getNumberId and isRegisteredUser * Apply suggestions from code review Co-authored-by: Rajeh Taher * Update client.js * Update Injected.js * Update Client.js * Update index.d.ts * Update Client.js * Update Client.js * fix lint indentation * fix auth_failure event for non-md, tests * fix setting group subject * fix finding Label module * set remember-me after clearing localStorage * fix: send messages to groups correctly on MD, use new ID format * fix setting / getting contact status * fix msg.getInfo, add message tests * fix group settings functions * fix set group description, handle errors in setSubject * fix group invite functions * fix leaving group * bring back phone info for non-md users * remove unused option, update typings * add back jsdoc for qr event * fix setting sticker metadata, clean up sticker functions * rawData is a get only property * fix and simplify getNumberId/isRegisteredUser * fix getInviteInfo * setDisplayName returns bool, not yet implemented for md * fix: stream module (#1241) * linkPreview has no effect on MD, return default to true * fix: del linkPreview option on md * cleanup, types and docs updates * update readmes / test notes * remove DS_Store * DS_Store in gitignore * test stability (timeouts/sleeps) Co-authored-by: Rajeh Taher Co-authored-by: Gustavo B <52040719+Gugabit@users.noreply.github.com> Co-authored-by: Maikel Ortega Hernández Co-authored-by: victormga Co-authored-by: Pedro Lopez Co-authored-by: tuyuribr <45042245+tuyuribr@users.noreply.github.com> Co-authored-by: gon <68490103+nekiak@users.noreply.github.com> Co-authored-by: Alon Schwartzblat <63599777+Schwartzblat@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Šebestíček <44745014+SebestikCZ@users.noreply.github.com> Co-authored-by: Emmanuel Anaya Luna <38712443+KeruMx@users.noreply.github.com> Co-authored-by: L337C0D3R <51872799+L337C0D3R@users.noreply.github.com> Co-authored-by: Reni Delonzek --- .env.example | 5 +- .gitignore | 9 +- example.js | 35 +-- index.d.ts | 77 +++++-- package.json | 6 +- shell.js | 13 +- src/Client.js | 398 +++++++++++++++++++++-------------- src/structures/ClientInfo.js | 13 +- src/structures/Contact.js | 11 +- src/structures/GroupChat.js | 105 +++++---- src/structures/Message.js | 83 +++++--- src/util/Constants.js | 7 +- src/util/Injected.js | 188 ++++++++++------- src/util/Util.js | 54 ++--- tests/README.md | 10 +- tests/client.js | 277 +++++++++++++----------- tests/helper.js | 40 +++- tests/structures/chat.js | 15 +- tests/structures/group.js | 227 ++++++++++++++++++++ tests/structures/message.js | 112 ++++++++++ 20 files changed, 1132 insertions(+), 553 deletions(-) create mode 100644 tests/structures/group.js create mode 100644 tests/structures/message.js diff --git a/.env.example b/.env.example index 6cd9eb9..3add97c 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ -WWEBJS_TEST_SESSION_PATH=test_session.json -WWEBJS_TEST_REMOTE_ID=XXXXXXXXXX@c.us \ No newline at end of file +WWEBJS_TEST_REMOTE_ID=XXXXXXXXXX@c.us +WWEBJS_TEST_CLIENT_ID=authenticated +WWEBJS_TEST_MD=1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index f0b9c4f..73c67dc 100644 --- a/.gitignore +++ b/.gitignore @@ -64,8 +64,13 @@ typings/ # next.js build output .next -# macOS Thumbnails +# macOS ._* +.DS_Store # Test sessions -*session.json \ No newline at end of file +*session.json + +# user data +WWebJS/ +userDataDir/ \ No newline at end of file diff --git a/example.js b/example.js index 4958e87..bc3388e 100644 --- a/example.js +++ b/example.js @@ -1,15 +1,9 @@ -const fs = require('fs'); const { Client, Location, List, Buttons } = require('./index'); -const SESSION_FILE_PATH = './session.json'; -let sessionCfg; -if (fs.existsSync(SESSION_FILE_PATH)) { - sessionCfg = require(SESSION_FILE_PATH); -} - -const client = new Client({ puppeteer: { headless: false }, session: sessionCfg }); -// You can use an existing session and avoid scanning a QR code by adding a "session" object to the client options. -// This object must include WABrowserId, WASecretBundle, WAToken1 and WAToken2. +const client = new Client({ + clientId: 'example', + puppeteer: { headless: false } +}); // You also could connect to an existing instance of a browser // { @@ -25,18 +19,12 @@ client.on('qr', (qr) => { console.log('QR RECEIVED', qr); }); -client.on('authenticated', (session) => { - console.log('AUTHENTICATED', session); - sessionCfg=session; - fs.writeFile(SESSION_FILE_PATH, JSON.stringify(session), function (err) { - if (err) { - console.error(err); - } - }); +client.on('authenticated', () => { + console.log('AUTHENTICATED'); }); client.on('auth_failure', msg => { - // Fired if session restore was unsuccessfull + // Fired if session restore was unsuccessful console.error('AUTHENTICATION FAILURE', msg); }); @@ -124,9 +112,8 @@ client.on('message', async msg => { client.sendMessage(msg.from, ` *Connection info* User name: ${info.pushname} - My number: ${info.me.user} + My number: ${info.wid.user} Platform: ${info.platform} - WhatsApp version: ${info.phone.wa_version} `); } else if (msg.body === '!mediainfo' && msg.hasMedia) { const attachmentData = await msg.downloadMedia(); @@ -267,12 +254,6 @@ client.on('group_update', (notification) => { console.log('update', notification); }); -client.on('change_battery', (batteryInfo) => { - // Battery percentage for attached device has changed - const { battery, plugged } = batteryInfo; - console.log(`Battery: ${battery}% - Charging? ${plugged}`); -}); - client.on('change_state', state => { console.log('CHANGE STATE', state ); }); diff --git a/index.d.ts b/index.d.ts index 7039752..c3d1ab1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -84,6 +84,9 @@ declare namespace WAWebJS { /** Returns the contact ID's profile picture URL, if privacy settings allow it */ getProfilePicUrl(contactId: string): Promise + /** Gets the Contact's common groups with you. Returns empty array if you don't have any common group. */ + getCommonGroups(contactId: string): Promise + /** Gets the current connection state for the client */ getState(): Promise @@ -118,6 +121,9 @@ declare namespace WAWebJS { /** Marks the client as online */ sendPresenceAvailable(): Promise + /** Marks the client as offline */ + sendPresenceUnavailable(): Promise + /** Mark as seen for the Chat */ sendSeen(chatId: string): Promise @@ -134,7 +140,7 @@ declare namespace WAWebJS { * Sets the current user's display name * @param displayName New display name */ - setDisplayName(displayName: string): Promise + setDisplayName(displayName: string): Promise /** Changes and returns the archive state of the Chat */ unarchiveChat(chatId: string): Promise @@ -150,11 +156,17 @@ declare namespace WAWebJS { /** Emitted when authentication is successful */ on(event: 'authenticated', listener: ( - /** Object containing session information. Can be used to restore the session */ - session: ClientSession + /** + * Object containing session information. Can be used to restore the session + * @deprecated + */ + session?: ClientSession ) => void): this - /** Emitted when the battery percentage for the attached device changes */ + /** + * Emitted when the battery percentage for the attached device changes + * @deprecated + */ on(event: 'change_battery', listener: (batteryInfo: BatteryInfo) => void): this /** Emitted when the connection state changes */ @@ -249,14 +261,12 @@ declare namespace WAWebJS { /** Current connection information */ export interface ClientInfo { - /** - * Current user ID - * @deprecated Use .wid instead - */ - me: ContactId /** Current user ID */ wid: ContactId - /** Information about the phone this client is connected to */ + /** + * Information about the phone this client is connected to. Not available in multi-device. + * @deprecated + */ phone: ClientInfoPhone /** Platform the phone is running on */ platform: string @@ -267,7 +277,10 @@ declare namespace WAWebJS { getBatteryStatus: () => Promise } - /** Information about the phone this client is connected to */ + /** + * Information about the phone this client is connected to + * @deprecated + */ export interface ClientInfoPhone { /** WhatsApp Version running on the phone */ wa_version: string @@ -300,8 +313,19 @@ declare namespace WAWebJS { /** Restart client with a new session (i.e. use null 'session' var) if authentication fails * @default false */ restartOnAuthFail?: boolean - /** Whatsapp session to restore. If not set, will start a new session */ + /** + * Enable authentication via a `session` option. + * @deprecated Will be removed in a future release + */ + useDeprecatedSessionAuth?: boolean + /** + * WhatsApp session to restore. If not set, will start a new session + * @deprecated Set `useDeprecatedSessionAuth: true` to enable. This auth method is not supported by MultiDevice and will be removed in a future release. + */ session?: ClientSession + /** Client id to distinguish instances if you are using multiple, otherwise keep empty if you are using only one instance + * @default '' */ + clientId: string /** If another whatsapp web session is detected (another browser), take over the session in the current browser * @default false */ takeoverOnConflict?: boolean, @@ -314,9 +338,15 @@ declare namespace WAWebJS { /** Ffmpeg path to use when formating videos to webp while sending stickers * @default 'ffmpeg' */ ffmpegPath?: string + /** Path to place session objects in + @default './WWebJS' */ + dataPath?: string } - /** Represents a Whatsapp client session */ + /** + * Represents a WhatsApp client session + * @deprecated + */ export interface ClientSession { WABrowserId: string, WASecretBundle: string, @@ -324,6 +354,9 @@ declare namespace WAWebJS { WAToken2: string, } + /** + * @deprecated + */ export interface BatteryInfo { /** The current battery percentage */ battery: number, @@ -608,6 +641,13 @@ declare namespace WAWebJS { selectedButtonId?: string, /** Selected list row ID */ selectedRowId?: string, + /** Returns message in a raw format */ + rawData: object, + /* + * Reloads this Message object's data in-place with the latest values from WhatsApp Web. + * Note that the Message must still be in the web app cache for this to work, otherwise will return null. + */ + reload: () => Promise, /** Accept the Group V4 Invite in message */ acceptGroupV4Invite: () => Promise<{status: number}>, /** Deletes the message from the chat */ @@ -679,7 +719,7 @@ declare namespace WAWebJS { /** Options for sending a message */ export interface MessageSendOptions { - /** Show links preview */ + /** Show links preview. Has no effect on multi-device accounts. */ linkPreview?: boolean /** Send audio as voice message */ sendAudioAsVoice?: boolean @@ -741,7 +781,7 @@ declare namespace WAWebJS { static fromUrl: (url: string, options?: MediaFromURLOptions) => Promise } - export type MessageContent = string | MessageMedia | Location | Contact | Contact[] | List | Buttons + export type MessageContent = string | MessageMedia | Location | Contact | Contact[] | List | Buttons /** * Represents a Contact on WhatsApp @@ -834,6 +874,9 @@ declare namespace WAWebJS { /** Gets the Contact's current "about" info. Returns null if you don't have permission to read their status. */ getAbout: () => Promise, + + /** Gets the Contact's common groups with you. Returns empty array if you don't have any common group. */ + getCommonGroups: () => Promise } @@ -1011,9 +1054,9 @@ declare namespace WAWebJS { /** Demotes participants by IDs to regular users */ demoteParticipants: ChangeParticipantsPermisions; /** Updates the group subject */ - setSubject: (subject: string) => Promise; + setSubject: (subject: string) => Promise; /** Updates the group description */ - setDescription: (description: string) => Promise; + setDescription: (description: string) => Promise; /** Updates the group settings to only allow admins to send messages * @param {boolean} [adminsOnly=true] Enable or disable this option * @returns {Promise} Returns true if the setting was properly updated. This can return false if the user does not have the necessary permissions. diff --git a/package.json b/package.json index 2ed4f90..093758c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./index.js", "typings": "./index.d.ts", "scripts": { - "test": "mocha tests --recursive", + "test": "mocha tests --recursive --timeout 5000", "test-single": "mocha", "shell": "node --experimental-repl-await ./shell.js", "generate-docs": "node_modules/.bin/jsdoc --configure .jsdoc.json --verbose" @@ -35,12 +35,12 @@ "mime": "^3.0.0", "node-fetch": "^2.6.5", "node-webpmux": "^3.1.0", - "puppeteer": "^13.0.0", - "sharp": "^0.28.3" + "puppeteer": "^13.0.0" }, "devDependencies": { "@types/node-fetch": "^2.5.12", "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", "dotenv": "^16.0.0", "eslint": "^8.4.1", "eslint-plugin-mocha": "^10.0.3", diff --git a/shell.js b/shell.js index 10e44d0..4f94c35 100644 --- a/shell.js +++ b/shell.js @@ -7,19 +7,12 @@ */ const repl = require('repl'); -const fs = require('fs'); const { Client } = require('./index'); -const SESSION_FILE_PATH = './session.json'; -let sessionCfg; -if (fs.existsSync(SESSION_FILE_PATH)) { - sessionCfg = require(SESSION_FILE_PATH); -} - const client = new Client({ puppeteer: { headless: false }, - session: sessionCfg + clientId: 'shell' }); console.log('Initializing...'); @@ -30,6 +23,10 @@ client.on('qr', () => { console.log('Please scan the QR code on the browser.'); }); +client.on('authenticated', (session) => { + console.log(JSON.stringify(session)); +}); + client.on('ready', () => { const shell = repl.start('wwebjs> '); shell.context.client = client; diff --git a/src/Client.js b/src/Client.js index 27c2079..5f908e0 100644 --- a/src/Client.js +++ b/src/Client.js @@ -1,9 +1,10 @@ 'use strict'; +const path = require('path'); +const fs = require('fs'); const EventEmitter = require('events'); const puppeteer = require('puppeteer'); const moduleRaid = require('@pedroslopez/moduleraid/moduleraid'); -const jsQR = require('jsqr'); const Util = require('./util/Util'); const InterfaceController = require('./util/InterfaceController'); @@ -11,27 +12,24 @@ const { WhatsWebURL, DefaultOptions, Events, WAState } = require('./util/Constan const { ExposeStore, LoadUtils } = require('./util/Injected'); const ChatFactory = require('./factories/ChatFactory'); const ContactFactory = require('./factories/ContactFactory'); -const { ClientInfo, Message, MessageMedia, Contact, Location, GroupNotification , Label, Call, Buttons, List} = require('./structures'); +const { ClientInfo, Message, MessageMedia, Contact, Location, GroupNotification, Label, Call, Buttons, List } = require('./structures'); /** * Starting point for interacting with the WhatsApp Web API * @extends {EventEmitter} * @param {object} options - Client options * @param {number} options.authTimeoutMs - Timeout for authentication selector in puppeteer * @param {object} options.puppeteer - Puppeteer launch options. View docs here: https://github.com/puppeteer/puppeteer/ - * @param {number} options.qrRefreshIntervalMs - Refresh interval for qr code (how much time to wait before checking if the qr code has changed) - * @param {number} options.qrTimeoutMs - Timeout for qr code selector in puppeteer * @param {number} options.qrMaxRetries - How many times should the qrcode be refreshed before giving up * @param {string} options.restartOnAuthFail - Restart client with a new session (i.e. use null 'session' var) if authentication fails - * @param {object} options.session - Whatsapp session to restore. If not set, will start a new session - * @param {string} options.session.WABrowserId - * @param {string} options.session.WASecretBundle - * @param {string} options.session.WAToken1 - * @param {string} options.session.WAToken2 + * @param {boolean} options.useDeprecatedSessionAuth - Enable JSON-based authentication. This is deprecated due to not being supported by MultiDevice, and will be removed in a future version. + * @param {object} options.session - This is deprecated due to not being supported by MultiDevice, and will be removed in a future version. * @param {number} options.takeoverOnConflict - If another whatsapp web session is detected (another browser), take over the session in the current browser * @param {number} options.takeoverTimeoutMs - How much time to wait before taking over the session + * @param {string} options.dataPath - Change the default path for saving session files, default is: "./WWebJS/" * @param {string} options.userAgent - User agent to use in puppeteer * @param {string} options.ffmpegPath - Ffmpeg path to use when formating videos to webp while sending stickers * @param {boolean} options.bypassCSP - Sets bypassing of page's Content-Security-Policy. + * @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance * * @fires Client#qr * @fires Client#authenticated @@ -48,7 +46,6 @@ const { ClientInfo, Message, MessageMedia, Contact, Location, GroupNotification * @fires Client#group_update * @fires Client#disconnected * @fires Client#change_state - * @fires Client#change_battery */ class Client extends EventEmitter { constructor(options = {}) { @@ -56,6 +53,19 @@ class Client extends EventEmitter { this.options = Util.mergeDefault(DefaultOptions, options); + this.id = this.options.clientId; + + // eslint-disable-next-line no-useless-escape + const foldernameRegex = /^(?!.{256,})(?!(aux|clock\$|con|nul|prn|com[1-9]|lpt[1-9])(?:$|\.))[^ ][ \.\w-$()+=[\];#@~,&']+[^\. ]$/i; + if (this.id && !foldernameRegex.test(this.id)) throw Error('Invalid client ID. Make sure you abide by the folder naming rules of your operating system.'); + + if (!this.options.useDeprecatedSessionAuth) { + this.dataDir = this.options.puppeteer.userDataDir; + const dirPath = path.join(process.cwd(), this.options.dataPath, this.id ? 'session-' + this.id : 'session'); + if (!this.dataDir) this.dataDir = dirPath; + fs.mkdirSync(this.dataDir, { recursive: true }); + } + this.pupBrowser = null; this.pupPage = null; @@ -67,39 +77,39 @@ class Client extends EventEmitter { */ async initialize() { let [browser, page] = [null, null]; - - if(this.options.puppeteer && this.options.puppeteer.browserWSEndpoint) { - browser = await puppeteer.connect(this.options.puppeteer); + + const puppeteerOpts = { + ...this.options.puppeteer, + userDataDir: this.options.useDeprecatedSessionAuth ? undefined : this.dataDir + }; + if (puppeteerOpts && puppeteerOpts.browserWSEndpoint) { + browser = await puppeteer.connect(puppeteerOpts); page = await browser.newPage(); } else { - browser = await puppeteer.launch(this.options.puppeteer); + browser = await puppeteer.launch(puppeteerOpts); page = (await browser.pages())[0]; } - + await page.setUserAgent(this.options.userAgent); this.pupBrowser = browser; this.pupPage = page; - // remember me - await page.evaluateOnNewDocument(() => { - localStorage.setItem('remember-me', 'true'); - }); + if (this.options.useDeprecatedSessionAuth && this.options.session) { + await page.evaluateOnNewDocument(session => { + if (document.referrer === 'https://whatsapp.com/') { + localStorage.clear(); + localStorage.setItem('WABrowserId', session.WABrowserId); + localStorage.setItem('WASecretBundle', session.WASecretBundle); + localStorage.setItem('WAToken1', session.WAToken1); + localStorage.setItem('WAToken2', session.WAToken2); + } - if (this.options.session) { - await page.evaluateOnNewDocument( - session => { - if(document.referrer === 'https://whatsapp.com/') { - localStorage.clear(); - localStorage.setItem('WABrowserId', session.WABrowserId); - localStorage.setItem('WASecretBundle', session.WASecretBundle); - localStorage.setItem('WAToken1', session.WAToken1); - localStorage.setItem('WAToken2', session.WAToken2); - } - }, this.options.session); + localStorage.setItem('remember-me', 'true'); + }, this.options.session); } - if(this.options.bypassCSP) { + if (this.options.bypassCSP) { await page.setBypassCSP(true); } @@ -109,56 +119,55 @@ class Client extends EventEmitter { referer: 'https://whatsapp.com/' }); - 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"]'; + const INTRO_IMG_SELECTOR = '[data-testid="intro-md-beta-logo-dark"], [data-testid="intro-md-beta-logo-light"], [data-asset-intro-image-light="true"], [data-asset-intro-image-dark="true"]'; + const INTRO_QRCODE_SELECTOR = 'div[data-ref] canvas'; - if (this.options.session) { - // Check if session restore was successful - try { - await page.waitForSelector(KEEP_PHONE_CONNECTED_IMG_SELECTOR, { timeout: this.options.authTimeoutMs }); - } catch (err) { - if (err.name === 'TimeoutError') { - /** - * Emitted when there has been an error while trying to restore an existing session - * @event Client#auth_failure - * @param {string} message - */ - this.emit(Events.AUTHENTICATION_FAILURE, 'Unable to log in. Are the session details valid?'); - browser.close(); - if (this.options.restartOnAuthFail) { - // session restore failed so try again but without session to force new authentication - this.options.session = null; - this.initialize(); - } - return; + // Checks which selector appears first + const needAuthentication = await Promise.race([ + new Promise(resolve => { + page.waitForSelector(INTRO_IMG_SELECTOR, { timeout: this.options.authTimeoutMs }) + .then(() => resolve(false)) + .catch((err) => resolve(err)); + }), + new Promise(resolve => { + page.waitForSelector(INTRO_QRCODE_SELECTOR, { timeout: this.options.authTimeoutMs }) + .then(() => resolve(true)) + .catch((err) => resolve(err)); + }) + ]); + + // Checks if an error ocurred on the first found selector. The second will be discarded and ignored by .race; + if (needAuthentication instanceof Error) throw needAuthentication; + + // Scan-qrcode selector was found. Needs authentication + if (needAuthentication) { + if(this.options.session) { + /** + * Emitted when there has been an error while trying to restore an existing session + * @event Client#auth_failure + * @param {string} message + * @deprecated + */ + this.emit(Events.AUTHENTICATION_FAILURE, 'Unable to log in. Are the session details valid?'); + await this.destroy(); + if (this.options.restartOnAuthFail) { + // session restore failed so try again but without session to force new authentication + this.options.session = null; + return this.initialize(); } - - throw err; + return; } - } else { + const QR_CONTAINER = 'div[data-ref]'; + const QR_RETRY_BUTTON = 'div[data-ref] > span > button'; let qrRetries = 0; - - const getQrCode = async () => { - // Check if retry button is present - var QR_RETRY_SELECTOR = 'div[data-ref] > span > button'; - var qrRetry = await page.$(QR_RETRY_SELECTOR); - if (qrRetry) { - await qrRetry.click(); - } - - // Wait for QR Code - const QR_CANVAS_SELECTOR = 'canvas'; - await page.waitForSelector(QR_CANVAS_SELECTOR, { timeout: this.options.qrTimeoutMs }); - const qrImgData = await page.$eval(QR_CANVAS_SELECTOR, canvas => [].slice.call(canvas.getContext('2d').getImageData(0, 0, 264, 264).data)); - const qr = jsQR(qrImgData, 264, 264).data; - + await page.exposeFunction('qrChanged', async (qr) => { /** - * Emitted when the QR code is received + * Emitted when a QR code is received * @event Client#qr * @param {string} qr QR Code */ this.emit(Events.QR_RECEIVED, qr); - if (this.options.qrMaxRetries > 0) { qrRetries++; if (qrRetries > this.options.qrMaxRetries) { @@ -166,15 +175,39 @@ class Client extends EventEmitter { await this.destroy(); } } - }; - getQrCode(); - this._qrRefreshInterval = setInterval(getQrCode, this.options.qrRefreshIntervalMs); + }); + + await page.evaluate(function (selectors) { + const qr_container = document.querySelector(selectors.QR_CONTAINER); + window.qrChanged(qr_container.dataset.ref); + + const obs = new MutationObserver((muts) => { + muts.forEach(mut => { + // Listens to qr token change + if (mut.type === 'attributes' && mut.attributeName === 'data-ref') { + window.qrChanged(mut.target.dataset.ref); + } else + // Listens to retry button, when found, click it + if (mut.type === 'childList') { + const retry_button = document.querySelector(selectors.QR_RETRY_BUTTON); + if (retry_button) retry_button.click(); + } + }); + }); + obs.observe(qr_container.parentElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['data-ref'], + }); + }, { + QR_CONTAINER, + QR_RETRY_BUTTON + }); // Wait for code scan try { - await page.waitForSelector(KEEP_PHONE_CONNECTED_IMG_SELECTOR, { timeout: 0 }); - clearInterval(this._qrRefreshInterval); - this._qrRefreshInterval = undefined; + await page.waitForSelector(INTRO_IMG_SELECTOR, { timeout: 0 }); } catch(error) { if ( error.name === 'ProtocolError' && @@ -187,32 +220,30 @@ class Client extends EventEmitter { throw error; } + } await page.evaluate(ExposeStore, moduleRaid.toString()); - - // Get session tokens - const localStorage = JSON.parse(await page.evaluate(() => { - return JSON.stringify(window.localStorage); - })); + let authEventPayload = undefined; + if (this.options.useDeprecatedSessionAuth) { + // Get session tokens + const localStorage = JSON.parse(await page.evaluate(() => { + return JSON.stringify(window.localStorage); + })); - const session = { - WABrowserId: localStorage.WABrowserId, - WASecretBundle: localStorage.WASecretBundle, - WAToken1: localStorage.WAToken1, - WAToken2: localStorage.WAToken2 - }; + authEventPayload = { + WABrowserId: localStorage.WABrowserId, + WASecretBundle: localStorage.WASecretBundle, + WAToken1: localStorage.WAToken1, + WAToken2: localStorage.WAToken2 + }; + } /** * Emitted when authentication is successful * @event Client#authenticated - * @param {object} session Object containing session information. Can be used to restore the session. - * @param {string} session.WABrowserId - * @param {string} session.WASecretBundle - * @param {string} session.WAToken1 - * @param {string} session.WAToken2 */ - this.emit(Events.AUTHENTICATED, session); + this.emit(Events.AUTHENTICATED, authEventPayload); // Check window.Store Injection await page.waitForFunction('window.Store != undefined'); @@ -221,8 +252,17 @@ class Client extends EventEmitter { return window.Store.Features.features.MD_BACKEND; }); - if(isMD) { - throw new Error('Multi-device is not yet supported by whatsapp-web.js. Please check out https://github.com/pedroslopez/whatsapp-web.js/pull/889 to follow the progress.'); + await page.evaluate(async () => { + // safely unregister service workers + const registrations = await navigator.serviceWorker.getRegistrations(); + for (let registration of registrations) { + registration.unregister(); + } + + }); + + if (this.options.useDeprecatedSessionAuth && isMD) { + throw new Error('Authenticating via JSON session is not supported for MultiDevice-enabled WhatsApp accounts.'); } //Load util functions (serializers, helper functions) @@ -234,7 +274,7 @@ class Client extends EventEmitter { * @type {ClientInfo} */ this.info = new ClientInfo(this, await page.evaluate(() => { - return window.Store.Conn.serialize(); + return { ...window.Store.Conn.serialize(), wid: window.Store.User.getMeUser() }; })); // Add InterfaceController @@ -398,11 +438,12 @@ class Client extends EventEmitter { if (battery === undefined) return; /** - * Emitted when the battery percentage for the attached device changes + * Emitted when the battery percentage for the attached device changes. Will not be sent if using multi-device. * @event Client#change_battery * @param {object} batteryInfo * @param {number} batteryInfo.battery - The current battery percentage * @param {boolean} batteryInfo.plugged - Indicates if the phone is plugged in (true) or not (false) + * @deprecated */ this.emit(Events.BATTERY_CHANGED, { battery, plugged }); }); @@ -421,14 +462,14 @@ class Client extends EventEmitter { * @param {boolean} call.webClientShouldHandle - If Waweb should handle * @param {object} call.participants - Participants */ - const cll = new Call(this,call); + const cll = new Call(this, call); this.emit(Events.INCOMING_CALL, cll); }); - + await page.evaluate(() => { window.Store.Msg.on('change', (msg) => { window.onChangeMessageEvent(window.WWebJS.getMessageModel(msg)); }); window.Store.Msg.on('change:type', (msg) => { window.onChangeMessageTypeEvent(window.WWebJS.getMessageModel(msg)); }); - window.Store.Msg.on('change:ack', (msg,ack) => { window.onMessageAckEvent(window.WWebJS.getMessageModel(msg), ack); }); + window.Store.Msg.on('change:ack', (msg, ack) => { window.onMessageAckEvent(window.WWebJS.getMessageModel(msg), ack); }); window.Store.Msg.on('change:isUnsentMedia', (msg, unsent) => { if (msg.id.fromMe && !unsent) window.onMessageMediaUploadedEvent(window.WWebJS.getMessageModel(msg)); }); window.Store.Msg.on('remove', (msg) => { if (msg.isNewMsg) window.onRemoveMessageEvent(window.WWebJS.getMessageModel(msg)); }); window.Store.AppState.on('change:state', (_AppState, state) => { window.onAppStateChangedEvent(state); }); @@ -466,9 +507,6 @@ class Client extends EventEmitter { * Closes the client */ async destroy() { - if (this._qrRefreshInterval) { - clearInterval(this._qrRefreshInterval); - } await this.pupBrowser.close(); } @@ -476,9 +514,13 @@ class Client extends EventEmitter { * Logs out the client, closing the current session */ async logout() { - return await this.pupPage.evaluate(() => { + await this.pupPage.evaluate(() => { return window.Store.AppState.logout(); }); + + if (this.dataDir) { + return (fs.rmSync ? fs.rmSync : fs.rmdirSync).call(this.dataDir, { recursive: true }); + } } /** @@ -508,7 +550,7 @@ class Client extends EventEmitter { /** * Message options. * @typedef {Object} MessageSendOptions - * @property {boolean} [linkPreview=true] - Show links preview + * @property {boolean} [linkPreview=true] - Show links preview. Has no effect on multi-device accounts. * @property {boolean} [sendAudioAsVoice=false] - Send audio as voice message * @property {boolean} [sendVideoAsGif=false] - Send video as gif * @property {boolean} [sendMediaAsSticker=false] - Send media as a sticker @@ -558,34 +600,36 @@ class Client extends EventEmitter { } else if (content instanceof Location) { internalOptions.location = content; content = ''; - } else if(content instanceof Contact) { + } else if (content instanceof Contact) { internalOptions.contactCard = content.id._serialized; content = ''; - } else if(Array.isArray(content) && content.length > 0 && content[0] instanceof Contact) { + } else if (Array.isArray(content) && content.length > 0 && content[0] instanceof Contact) { internalOptions.contactCardList = content.map(contact => contact.id._serialized); content = ''; - } else if(content instanceof Buttons){ - if(content.type !== 'chat'){internalOptions.attachment = content.body;} + } else if (content instanceof Buttons) { + if (content.type !== 'chat') { internalOptions.attachment = content.body; } internalOptions.buttons = content; content = ''; - } else if(content instanceof List){ + } else if (content instanceof List) { internalOptions.list = content; content = ''; } if (internalOptions.sendMediaAsSticker && internalOptions.attachment) { - internalOptions.attachment = - await Util.formatToWebpSticker(internalOptions.attachment, { + internalOptions.attachment = await Util.formatToWebpSticker( + internalOptions.attachment, { name: options.stickerName, author: options.stickerAuthor, categories: options.stickerCategories - }); + }, this.pupPage + ); } const newMessage = await this.pupPage.evaluate(async (chatId, message, options, sendSeen) => { const chatWid = window.Store.WidFactory.createWid(chatId); const chat = await window.Store.Chat.find(chatWid); + if (sendSeen) { window.WWebJS.sendSeen(chatId); } @@ -672,7 +716,7 @@ class Client extends EventEmitter { */ async getInviteInfo(inviteCode) { return await this.pupPage.evaluate(inviteCode => { - return window.Store.Wap.groupInviteInfo(inviteCode); + return window.Store.InviteInfo.sendQueryGroupInvite(inviteCode); }, inviteCode); } @@ -691,25 +735,25 @@ class Client extends EventEmitter { /** * Accepts a private invitation to join a group - * @param {object} inviteV4 Invite V4 Info + * @param {object} inviteInfo Invite V4 Info * @returns {Promise} */ async acceptGroupV4Invite(inviteInfo) { - if(!inviteInfo.inviteCode) throw 'Invalid invite code, try passing the message.inviteV4 object'; + if (!inviteInfo.inviteCode) throw 'Invalid invite code, try passing the message.inviteV4 object'; if (inviteInfo.inviteCodeExp == 0) throw 'Expired invite code'; - return await this.pupPage.evaluate(async inviteInfo => { - let { groupId, fromId, inviteCode, inviteCodeExp, toId } = inviteInfo; - return await window.Store.Wap.acceptGroupV4Invite(groupId, fromId, inviteCode, String(inviteCodeExp), toId); + return this.pupPage.evaluate(async inviteInfo => { + let { groupId, fromId, inviteCode, inviteCodeExp } = inviteInfo; + return await window.Store.JoinInviteV4.sendJoinGroupViaInviteV4(inviteCode, String(inviteCodeExp), groupId, fromId); }, inviteInfo); } - + /** * Sets the current user's status message * @param {string} status New status message */ async setStatus(status) { await this.pupPage.evaluate(async status => { - return await window.Store.Wap.sendSetStatus(status); + return await window.Store.StatusUtils.setMyStatus(status); }, status); } @@ -717,11 +761,22 @@ class Client extends EventEmitter { * Sets the current user's display name. * This is the name shown to WhatsApp users that have not added you as a contact beside your number in groups and in your profile. * @param {string} displayName New display name + * @returns {Promise} */ async setDisplayName(displayName) { - await this.pupPage.evaluate(async displayName => { - return await window.Store.Wap.setPushname(displayName); + const couldSet = await this.pupPage.evaluate(async displayName => { + if(!window.Store.Conn.canSetMyPushname()) return false; + + if(window.Store.Features.features.MD_BACKEND) { + // TODO + return false; + } else { + const res = await window.Store.Wap.setPushname(displayName); + return !res.status || res.status === 200; + } }, displayName); + + return couldSet; } /** @@ -740,7 +795,16 @@ class Client extends EventEmitter { */ async sendPresenceAvailable() { return await this.pupPage.evaluate(() => { - return window.Store.Wap.sendPresenceAvailable(); + return window.Store.PresenceUtils.sendPresenceAvailable(); + }); + } + + /** + * Marks the client as unavailable + */ + async sendPresenceUnavailable() { + return await this.pupPage.evaluate(() => { + return window.Store.PresenceUtils.sendPresenceUnavailable(); }); } @@ -847,12 +911,37 @@ class Client extends EventEmitter { */ async getProfilePicUrl(contactId) { const profilePic = await this.pupPage.evaluate((contactId) => { - return window.Store.Wap.profilePicFind(contactId); + const chatWid = window.Store.WidFactory.createWid(contactId); + return window.Store.getProfilePicFull(chatWid); }, contactId); return profilePic ? profilePic.eurl : undefined; } + /** + * Gets the Contact's common groups with you. Returns empty array if you don't have any common group. + * @param {string} contactId the whatsapp user's ID (_serialized format) + * @returns {Promise} + */ + async getCommonGroups(contactId) { + const commonGroups = await this.pupPage.evaluate(async (contactId) => { + const contact = window.Store.Contact.get(contactId); + if (contact.commonGroups) { + return contact.commonGroups.serialize(); + } + const status = await window.Store.findCommonGroups(contact); + if (status) { + return contact.commonGroups.serialize(); + } + return []; + }, contactId); + const chats = []; + for (const group of commonGroups) { + chats.push(group.id); + } + return chats; + } + /** * Force reset of connection state for the client */ @@ -868,10 +957,7 @@ class Client extends EventEmitter { * @returns {Promise} */ async isRegisteredUser(id) { - return await this.pupPage.evaluate(async (id) => { - let result = await window.Store.Wap.queryExist(id); - return result.jid !== undefined; - }, id); + return Boolean(await this.getNumberId(id)); } /** @@ -881,14 +967,15 @@ class Client extends EventEmitter { * @returns {Promise} */ async getNumberId(number) { - if (!number.endsWith('@c.us')) number += '@c.us'; - try { - return await this.pupPage.evaluate(async numberId => { - return window.WWebJS.getNumberId(numberId); - }, number); - } catch(_) { - return null; + if (!number.endsWith('@c.us')) { + number += '@c.us'; } + + return await this.pupPage.evaluate(async number => { + const result = await window.Store.QueryExist(number); + if (!result || result.wid === undefined) return null; + return result.wid; + }, number); } /** @@ -897,14 +984,14 @@ class Client extends EventEmitter { * @returns {Promise} */ async getFormattedNumber(number) { - if(!number.endsWith('@s.whatsapp.net')) number = number.replace('c.us', 's.whatsapp.net'); - if(!number.includes('@s.whatsapp.net')) number = `${number}@s.whatsapp.net`; - + if (!number.endsWith('@s.whatsapp.net')) number = number.replace('c.us', 's.whatsapp.net'); + if (!number.includes('@s.whatsapp.net')) number = `${number}@s.whatsapp.net`; + return await this.pupPage.evaluate(async numberId => { return window.Store.NumberInfo.formattedPhoneNumber(numberId); }, number); } - + /** * Get the country code of a WhatsApp ID. * @param {string} number Number or ID @@ -917,7 +1004,7 @@ class Client extends EventEmitter { return window.Store.NumberInfo.findCC(numberId); }, number); } - + /** * Create a new group * @param {string} name group title @@ -936,12 +1023,9 @@ class Client extends EventEmitter { } const createRes = await this.pupPage.evaluate(async (name, participantIds) => { - const res = await window.Store.Wap.createGroup(name, participantIds); - console.log(res); - if (!res.status === 200) { - throw 'An error occurred while creating the group!'; - } - + const participantWIDs = participantIds.map(p => window.Store.WidFactory.createWid(p)); + const id = window.Store.genId(); + const res = await window.Store.GroupUtils.sendCreateGroup(name, participantWIDs, undefined, id); return res; }, name, participants); @@ -962,9 +1046,9 @@ class Client extends EventEmitter { async getLabels() { const labels = await this.pupPage.evaluate(async () => { return window.WWebJS.getLabels(); - }); + }); - return labels.map(data => new Label(this , data)); + return labels.map(data => new Label(this, data)); } /** @@ -975,7 +1059,7 @@ class Client extends EventEmitter { async getLabelById(labelId) { const label = await this.pupPage.evaluate(async (labelId) => { return window.WWebJS.getLabel(labelId); - }, labelId); + }, labelId); return new Label(this, label); } @@ -985,12 +1069,12 @@ class Client extends EventEmitter { * @param {string} chatId * @returns {Promise>} */ - async getChatLabels(chatId){ + async getChatLabels(chatId) { const labels = await this.pupPage.evaluate(async (chatId) => { return window.WWebJS.getChatLabels(chatId); }, chatId); - return labels.map(data => new Label(this, data)); + return labels.map(data => new Label(this, data)); } /** @@ -998,16 +1082,16 @@ class Client extends EventEmitter { * @param {string} labelId * @returns {Promise>} */ - async getChatsByLabelId(labelId){ + async getChatsByLabelId(labelId) { const chatIds = await this.pupPage.evaluate(async (labelId) => { const label = window.Store.Label.get(labelId); const labelItems = label.labelItemCollection.models; return labelItems.reduce((result, item) => { - if(item.parentType === 'Chat'){ + if (item.parentType === 'Chat') { result.push(item.parentId); } return result; - },[]); + }, []); }, labelId); return Promise.all(chatIds.map(id => this.getChatById(id))); @@ -1020,7 +1104,7 @@ class Client extends EventEmitter { async getBlockedContacts() { const blockedContacts = await this.pupPage.evaluate(() => { let chatIds = window.Store.Blocklist.models.map(a => a.id._serialized); - return Promise.all(chatIds.map(id => window.WWebJS.getContact(id))); + return Promise.all(chatIds.map(id => window.WWebJS.getContact(id))); }); return blockedContacts.map(contact => ContactFactory.create(this.client, contact)); diff --git a/src/structures/ClientInfo.js b/src/structures/ClientInfo.js index 4dcd00e..b6cb2d6 100644 --- a/src/structures/ClientInfo.js +++ b/src/structures/ClientInfo.js @@ -20,12 +20,6 @@ class ClientInfo extends Base { */ this.pushname = data.pushname; - /** - * @type {object} - * @deprecated Use .wid instead - */ - this.me = data.wid; - /** * Current user ID * @type {object} @@ -33,18 +27,19 @@ class ClientInfo extends Base { this.wid = data.wid; /** - * Information about the phone this client is connected to + * Information about the phone this client is connected to. Not available in multi-device. * @type {object} * @property {string} wa_version WhatsApp Version running on the phone * @property {string} os_version OS Version running on the phone (iOS or Android version) * @property {string} device_manufacturer Device manufacturer * @property {string} device_model Device model * @property {string} os_build_number OS build number + * @deprecated */ this.phone = data.phone; /** - * Platform the phone is running on + * Platform WhatsApp is running on * @type {string} */ this.platform = data.platform; @@ -57,6 +52,7 @@ class ClientInfo extends Base { * @returns {object} batteryStatus * @returns {number} batteryStatus.battery - The current battery percentage * @returns {boolean} batteryStatus.plugged - Indicates if the phone is plugged in (true) or not (false) + * @deprecated */ async getBatteryStatus() { return await this.client.pupPage.evaluate(() => { @@ -64,7 +60,6 @@ class ClientInfo extends Base { return { battery, plugged }; }); } - } module.exports = ClientInfo; \ No newline at end of file diff --git a/src/structures/Contact.js b/src/structures/Contact.js index 8444840..7c20012 100644 --- a/src/structures/Contact.js +++ b/src/structures/Contact.js @@ -183,7 +183,8 @@ class Contact extends Base { */ async getAbout() { const about = await this.client.pupPage.evaluate(async (contactId) => { - return window.Store.Wap.statusFind(contactId); + const wid = window.Store.WidFactory.createWid(contactId); + return window.Store.StatusUtils.getStatus(wid); }, this.id._serialized); if (typeof about.status !== 'string') @@ -191,6 +192,14 @@ class Contact extends Base { return about.status; } + + /** + * Gets the Contact's common groups with you. Returns empty array if you don't have any common group. + * @returns {Promise} + */ + async getCommonGroups() { + return await this.client.getCommonGroups(this.id._serialized); + } } diff --git a/src/structures/GroupChat.js b/src/structures/GroupChat.js index 76ff151..fc837a2 100644 --- a/src/structures/GroupChat.js +++ b/src/structures/GroupChat.js @@ -60,7 +60,9 @@ class GroupChat extends Chat { */ async addParticipants(participantIds) { return await this.client.pupPage.evaluate((chatId, participantIds) => { - return window.Store.Wap.addParticipants(chatId, participantIds); + const chatWid = window.Store.WidFactory.createWid(chatId); + const participantWids = participantIds.map(p => window.Store.WidFactory.createWid(p)); + return window.Store.GroupParticipants.sendAddParticipants(chatWid, participantWids); }, this.id._serialized, participantIds); } @@ -71,7 +73,9 @@ class GroupChat extends Chat { */ async removeParticipants(participantIds) { return await this.client.pupPage.evaluate((chatId, participantIds) => { - return window.Store.Wap.removeParticipants(chatId, participantIds); + const chatWid = window.Store.WidFactory.createWid(chatId); + const participantWids = participantIds.map(p => window.Store.WidFactory.createWid(p)); + return window.Store.GroupParticipants.sendRemoveParticipants(chatWid, participantWids); }, this.id._serialized, participantIds); } @@ -82,7 +86,9 @@ class GroupChat extends Chat { */ async promoteParticipants(participantIds) { return await this.client.pupPage.evaluate((chatId, participantIds) => { - return window.Store.Wap.promoteParticipants(chatId, participantIds); + const chatWid = window.Store.WidFactory.createWid(chatId); + const participantWids = participantIds.map(p => window.Store.WidFactory.createWid(p)); + return window.Store.GroupParticipants.sendPromoteParticipants(chatWid, participantWids); }, this.id._serialized, participantIds); } @@ -93,39 +99,53 @@ class GroupChat extends Chat { */ async demoteParticipants(participantIds) { return await this.client.pupPage.evaluate((chatId, participantIds) => { - return window.Store.Wap.demoteParticipants(chatId, participantIds); + const chatWid = window.Store.WidFactory.createWid(chatId); + const participantWids = participantIds.map(p => window.Store.WidFactory.createWid(p)); + return window.Store.GroupParticipants.sendDemoteParticipants(chatWid, participantWids); }, this.id._serialized, participantIds); } /** * Updates the group subject * @param {string} subject - * @returns {Promise} + * @returns {Promise} Returns true if the subject was properly updated. This can return false if the user does not have the necessary permissions. */ async setSubject(subject) { - let res = await this.client.pupPage.evaluate((chatId, subject) => { - return window.Store.Wap.changeSubject(chatId, subject); + const success = await this.client.pupPage.evaluate(async (chatId, subject) => { + const chatWid = window.Store.WidFactory.createWid(chatId); + try { + return await window.Store.GroupUtils.sendSetGroupSubject(chatWid, subject); + } catch (err) { + if(err.name === 'ServerStatusCodeError') return false; + throw err; + } }, this.id._serialized, subject); - if(res.status == 200) { - this.name = subject; - } + if(!success) return false; + this.name = subject; + return true; } /** * Updates the group description * @param {string} description - * @returns {Promise} + * @returns {Promise} Returns true if the description was properly updated. This can return false if the user does not have the necessary permissions. */ async setDescription(description) { - let res = await this.client.pupPage.evaluate((chatId, description) => { - let descId = window.Store.GroupMetadata.get(chatId).descId; - return window.Store.Wap.setGroupDescription(chatId, description, window.Store.genId(), descId); + const success = await this.client.pupPage.evaluate(async (chatId, description) => { + const chatWid = window.Store.WidFactory.createWid(chatId); + let descId = window.Store.GroupMetadata.get(chatWid).descId; + try { + return await window.Store.GroupUtils.sendSetGroupDescription(chatWid, description, window.Store.genId(), descId); + } catch (err) { + if(err.name === 'ServerStatusCodeError') return false; + throw err; + } }, this.id._serialized, description); - if (res.status == 200) { - this.groupMetadata.desc = description; - } + if(!success) return false; + this.groupMetadata.desc = description; + return true; } /** @@ -134,12 +154,18 @@ class GroupChat extends Chat { * @returns {Promise} Returns true if the setting was properly updated. This can return false if the user does not have the necessary permissions. */ async setMessagesAdminsOnly(adminsOnly=true) { - let res = await this.client.pupPage.evaluate((chatId, value) => { - return window.Store.Wap.setGroupProperty(chatId, 'announcement', value); + const success = await this.client.pupPage.evaluate(async (chatId, adminsOnly) => { + const chatWid = window.Store.WidFactory.createWid(chatId); + try { + return await window.Store.GroupUtils.sendSetGroupProperty(chatWid, 'announcement', adminsOnly ? 1 : 0); + } catch (err) { + if(err.name === 'ServerStatusCodeError') return false; + throw err; + } }, this.id._serialized, adminsOnly); - if (res.status !== 200) return false; - + if(!success) return false; + this.groupMetadata.announce = adminsOnly; return true; } @@ -150,11 +176,17 @@ class GroupChat extends Chat { * @returns {Promise} Returns true if the setting was properly updated. This can return false if the user does not have the necessary permissions. */ async setInfoAdminsOnly(adminsOnly=true) { - let res = await this.client.pupPage.evaluate((chatId, value) => { - return window.Store.Wap.setGroupProperty(chatId, 'restrict', value); + const success = await this.client.pupPage.evaluate(async (chatId, adminsOnly) => { + const chatWid = window.Store.WidFactory.createWid(chatId); + try { + return await window.Store.GroupUtils.sendSetGroupProperty(chatWid, 'restrict', adminsOnly ? 1 : 0); + } catch (err) { + if(err.name === 'ServerStatusCodeError') return false; + throw err; + } }, this.id._serialized, adminsOnly); - if (res.status !== 200) return false; + if(!success) return false; this.groupMetadata.restrict = adminsOnly; return true; @@ -165,25 +197,25 @@ class GroupChat extends Chat { * @returns {Promise} Group's invite code */ async getInviteCode() { - let res = await this.client.pupPage.evaluate(chatId => { - return window.Store.Wap.groupInviteCode(chatId); + const code = await this.client.pupPage.evaluate(async chatId => { + const chatWid = window.Store.WidFactory.createWid(chatId); + return window.Store.Invite.sendQueryGroupInviteCode(chatWid); }, this.id._serialized); - if (res.status == 200) { - return res.code; - } - - throw new Error('Not authorized'); + return code; } /** * Invalidates the current group invite code and generates a new one - * @returns {Promise} + * @returns {Promise} New invite code */ async revokeInvite() { - return await this.client.pupPage.evaluate(chatId => { - return window.Store.Wap.revokeGroupInvite(chatId); + const code = await this.client.pupPage.evaluate(chatId => { + const chatWid = window.Store.WidFactory.createWid(chatId); + return window.Store.Invite.sendRevokeGroupInviteCode(chatWid); }, this.id._serialized); + + return code; } /** @@ -191,8 +223,9 @@ class GroupChat extends Chat { * @returns {Promise} */ async leave() { - return await this.client.pupPage.evaluate(chatId => { - return window.Store.Wap.leaveGroup(chatId); + await this.client.pupPage.evaluate(chatId => { + const chatWid = window.Store.WidFactory.createWid(chatId); + return window.Store.GroupUtils.sendExitGroup(chatWid); }, this.id._serialized); } diff --git a/src/structures/Message.js b/src/structures/Message.js index 5f6e4f0..6a326b9 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -19,13 +19,14 @@ class Message extends Base { } _patch(data) { + this._data = data; + /** * MediaKey that represents the sticker 'ID' * @type {string} */ this.mediaKey = data.mediaKey; - - + /** * ID that represents the message * @type {object} @@ -50,7 +51,7 @@ class Message extends Base { */ this.body = this.hasMedia ? data.caption || '' : data.body || ''; - /** + /** * Message type * @type {MessageTypes} */ @@ -70,9 +71,9 @@ class Message extends Base { /** * ID for who this message is for. - * + * * If the message is sent by the current user, it will be the Chat to which the message is being sent. - * If the message is sent by another user, it will be the ID for the current user. + * If the message is sent by another user, it will be the ID for the current user. * @type {string} */ this.to = (typeof (data.to) === 'object' && data.to !== null) ? data.to._serialized : data.to; @@ -87,8 +88,8 @@ class Message extends Base { * String that represents from which device type the message was sent * @type {string} */ - this.deviceType = data.id.id.length > 21 ? 'android' : data.id.id.substring(0,2) =='3A' ? 'ios' : 'web'; - + this.deviceType = data.id.id.length > 21 ? 'android' : data.id.id.substring(0, 2) == '3A' ? 'ios' : 'web'; + /** * Indicates if the message was forwarded * @type {boolean} @@ -114,14 +115,14 @@ class Message extends Base { * @type {boolean} */ this.isStarred = data.star; - + /** * Indicates if the message was a broadcast * @type {boolean} */ this.broadcast = data.broadcast; - /** + /** * Indicates if the message was sent by the current user * @type {boolean} */ @@ -157,7 +158,7 @@ class Message extends Base { fromId: data.from._serialized, toId: data.to._serialized } : undefined; - + /** * Indicates the mentions in the message body. * @type {Array} @@ -214,7 +215,7 @@ class Message extends Base { /** * Links included in the message. * @type {Array<{link: string, isSuspicious: boolean}>} - * + * */ this.links = data.links; @@ -222,7 +223,7 @@ class Message extends Base { if (data.dynamicReplyButtons) { this.dynamicReplyButtons = data.dynamicReplyButtons; } - + /** Selected Button Id **/ if (data.selectedButtonId) { this.selectedButtonId = data.selectedButtonId; @@ -232,7 +233,7 @@ class Message extends Base { if (data.listResponse && data.listResponse.singleSelectReply.selectedRowId) { this.selectedRowId = data.listResponse.singleSelectReply.selectedRowId; } - + return super._patch(data); } @@ -240,6 +241,32 @@ class Message extends Base { return this.fromMe ? this.to : this.from; } + /** + * Reloads this Message object's data in-place with the latest values from WhatsApp Web. + * Note that the Message must still be in the web app cache for this to work, otherwise will return null. + * @returns {Promise} + */ + async reload() { + const newData = await this.client.pupPage.evaluate((msgId) => { + const msg = window.Store.Msg.get(msgId); + if(!msg) return null; + return window.WWebJS.getMessageModel(msg); + }, this.id._serialized); + + if(!newData) return null; + + this._patch(newData); + return this; + } + + /** + * Returns message in a raw format + * @type {Object} + */ + get rawData() { + return this._data; + } + /** * Returns the Chat this message was sent in * @returns {Promise} @@ -280,12 +307,12 @@ class Message extends Base { } /** - * Sends a message as a reply to this message. If chatId is specified, it will be sent - * through the specified Chat. If not, it will send the message + * Sends a message as a reply to this message. If chatId is specified, it will be sent + * through the specified Chat. If not, it will send the message * in the same Chat as the original message was sent. - * - * @param {string|MessageMedia|Location} content - * @param {string} [chatId] + * + * @param {string|MessageMedia|Location} content + * @param {string} [chatId] * @param {MessageSendOptions} [options] * @returns {Promise} */ @@ -309,10 +336,10 @@ class Message extends Base { async acceptGroupV4Invite() { return await this.client.acceptGroupV4Invite(this.inviteV4); } - + /** * Forwards this message to another chat - * + * * @param {string|Chat} chat Chat model or chat ID to which the message will be forwarded * @returns {Promise} */ @@ -342,7 +369,7 @@ class Message extends Base { if (msg.mediaData.mediaStage != 'RESOLVED') { // try to resolve media await msg.downloadMedia({ - downloadEvenIfExpensive: true, + downloadEvenIfExpensive: true, rmrReason: 1 }); } @@ -362,9 +389,9 @@ class Message extends Base { type: msg.type, signal: (new AbortController).signal }); - + const data = window.WWebJS.arrayBufferToBase64(decryptedMedia); - + return { data, mimetype: msg.mimetype, @@ -440,14 +467,10 @@ class Message extends Base { async getInfo() { const info = await this.client.pupPage.evaluate(async (msgId) => { const msg = window.Store.Msg.get(msgId); - if(!msg) return null; - - return await window.Store.Wap.queryMsgInfo(msg.id); - }, this.id._serialized); + if (!msg) return null; - if(info.status) { - return null; - } + return await window.Store.MessageInfo.sendQueryMsgInfo(msg.id); + }, this.id._serialized); return info; } diff --git a/src/util/Constants.js b/src/util/Constants.js index b95ccd1..1b27ca5 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -7,10 +7,9 @@ exports.DefaultOptions = { headless: true, defaultViewport: null }, - session: false, - qrTimeoutMs: 45000, - qrRefreshIntervalMs: 20000, - authTimeoutMs: 45000, + dataPath: './WWebJS/', + useDeprecatedSessionAuth: false, + authTimeoutMs: 0, qrMaxRetries: 0, takeoverOnConflict: false, takeoverTimeoutMs: 0, diff --git a/src/util/Injected.js b/src/util/Injected.js index 6031752..0169494 100644 --- a/src/util/Injected.js +++ b/src/util/Injected.js @@ -8,36 +8,59 @@ exports.ExposeStore = (moduleRaidStr) => { window.Store = Object.assign({}, window.mR.findModule(m => m.default && m.default.Chat)[0].default); window.Store.AppState = window.mR.findModule('Socket')[0].Socket; window.Store.Conn = window.mR.findModule('Conn')[0].Conn; - 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]; - window.Store.SendDelete = window.mR.findModule('sendDelete')[0]; - window.Store.genId = window.mR.findModule('randomId')[0].randomId; - window.Store.SendMessage = window.mR.findModule('addAndSendMsgToChat')[0]; - window.Store.MsgKey = window.mR.findModule((module) => module.default && module.default.fromString)[0].default; + window.Store.BlockContact = window.mR.findModule('blockContact')[0]; + window.Store.Call = window.mR.findModule('CallCollection')[0].CallCollection; + window.Store.Cmd = window.mR.findModule('Cmd')[0].Cmd; + window.Store.CryptoLib = window.mR.findModule('decryptE2EMedia')[0]; + window.Store.DownloadManager = window.mR.findModule('downloadManager')[0].downloadManager; + window.Store.Features = window.mR.findModule('FEATURE_CHANGE_EVENT')[0].GK; + window.Store.genId = window.mR.findModule('newTag')[0].newTag; + window.Store.GroupMetadata = window.mR.findModule((module) => module.default && module.default.handlePendingInvite)[0].default; window.Store.Invite = window.mR.findModule('sendJoinGroupViaInvite')[0]; - window.Store.OpaqueData = window.mR.findModule(module => module.default && module.default.createFromData)[0].default; + window.Store.InviteInfo = window.mR.findModule('sendQueryGroupInvite')[0]; + window.Store.Label = window.mR.findModule('LabelCollection')[0].LabelCollection; window.Store.MediaPrep = window.mR.findModule('MediaPrep')[0]; window.Store.MediaObject = window.mR.findModule('getOrCreateMediaObject')[0]; - window.Store.MediaUpload = window.mR.findModule('uploadMedia')[0]; window.Store.NumberInfo = window.mR.findModule('formattedPhoneNumber')[0]; - window.Store.Cmd = window.mR.findModule('Cmd')[0].Cmd; window.Store.MediaTypes = window.mR.findModule('msgToMediaType')[0]; - window.Store.VCard = window.mR.findModule('vcardFromContactModel')[0]; + window.Store.MediaUpload = window.mR.findModule('uploadMedia')[0]; + window.Store.MsgKey = window.mR.findModule((module) => module.default && module.default.fromString)[0].default; + window.Store.MessageInfo = window.mR.findModule('sendQueryMsgInfo')[0]; + window.Store.OpaqueData = window.mR.findModule(module => module.default && module.default.createFromData)[0].default; + window.Store.QueryExist = window.mR.findModule(module => typeof module.default === 'function' && module.default.toString().includes('Should not reach queryExists MD'))[0].default; + window.Store.QueryProduct = window.mR.findModule('queryProduct')[0]; + window.Store.QueryOrder = window.mR.findModule('queryOrder')[0]; + window.Store.SendClear = window.mR.findModule('sendClear')[0]; + window.Store.SendDelete = window.mR.findModule('sendDelete')[0]; + window.Store.SendMessage = window.mR.findModule('addAndSendMsgToChat')[0]; + window.Store.SendSeen = window.mR.findModule('sendSeen')[0]; + window.Store.Sticker = window.mR.findModule('Sticker')[0].Sticker; + window.Store.User = window.mR.findModule('getMaybeMeUser')[0]; + window.Store.UploadUtils = window.mR.findModule((module) => (module.default && module.default.encryptAndUpload) ? module.default : null)[0].default; window.Store.UserConstructor = window.mR.findModule((module) => (module.default && module.default.prototype && module.default.prototype.isServer && module.default.prototype.isUser) ? module.default : null)[0].default; window.Store.Validators = window.mR.findModule('findLinks')[0]; + window.Store.VCard = window.mR.findModule('vcardFromContactModel')[0]; + window.Store.Wap = window.mR.findModule('queryLinkPreview')[0].default; window.Store.WidFactory = window.mR.findModule('createWid')[0]; - 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].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]; - window.Store.DownloadManager = window.mR.findModule('downloadManager')[0].downloadManager; - window.Store.Call = window.mR.findModule('CallCollection')[0].CallCollection; + window.Store.getProfilePicFull = window.mR.findModule('getProfilePicFull')[0].getProfilePicFull; + window.Store.PresenceUtils = window.mR.findModule('sendPresenceAvailable')[0]; + window.Store.ChatState = window.mR.findModule('sendChatStateComposing')[0]; + window.Store.GroupParticipants = window.mR.findModule('sendPromoteParticipants')[0]; + window.Store.JoinInviteV4 = window.mR.findModule('sendJoinGroupViaInviteV4')[0]; + window.Store.findCommonGroups = window.mR.findModule('findCommonGroups')[0].findCommonGroups; + window.Store.StatusUtils = window.mR.findModule('setMyStatus')[0]; + window.Store.StickerTools = { + ...window.mR.findModule('toWebpSticker')[0], + ...window.mR.findModule('addWebpMetadata')[0] + }; + + window.Store.GroupUtils = { + ...window.mR.findModule('sendCreateGroup')[0], + ...window.mR.findModule('sendSetGroupSubject')[0], + ...window.mR.findModule('markExited')[0] + }; - if(!window.Store.Chat._find) { + if (!window.Store.Chat._find) { window.Store.Chat._find = e => { const target = window.Store.Chat.get(e); return target ? Promise.resolve(target) : Promise.resolve({ @@ -50,14 +73,6 @@ exports.ExposeStore = (moduleRaidStr) => { exports.LoadUtils = () => { window.WWebJS = {}; - window.WWebJS.getNumberId = async (id) => { - - let result = await window.Store.Wap.queryExist(id); - if (result.jid === undefined) - throw 'The number provided is not a registered whatsapp user'; - return result.jid; - }; - window.WWebJS.sendSeen = async (chatId) => { let chat = window.Store.Chat.get(chatId); if (chat !== undefined) { @@ -67,14 +82,14 @@ exports.LoadUtils = () => { return false; }; - + window.WWebJS.sendMessage = async (chat, content, options = {}) => { let attOptions = {}; if (options.attachment) { - attOptions = options.sendMediaAsSticker + attOptions = options.sendMediaAsSticker ? await window.WWebJS.processStickerData(options.attachment) : await window.WWebJS.processMediaData(options.attachment, { - forceVoice: options.sendAudioAsVoice, + forceVoice: options.sendAudioAsVoice, forceDocument: options.sendMediaAsDocument, forceGif: options.sendVideoAsGif }); @@ -144,22 +159,26 @@ exports.LoadUtils = () => { if (options.linkPreview) { delete options.linkPreview; - const link = window.Store.Validators.findLink(content); - if (link) { - const preview = await window.Store.Wap.queryLinkPreview(link.url); - preview.preview = true; - preview.subtype = 'url'; - options = { ...options, ...preview }; + + // Not supported yet by WhatsApp Web on MD + if(!window.Store.Features.features.MD_BACKEND) { + const link = window.Store.Validators.findLink(content); + if (link) { + const preview = await window.Store.Wap.queryLinkPreview(link.url); + preview.preview = true; + preview.subtype = 'url'; + options = { ...options, ...preview }; + } } } let buttonOptions = {}; if(options.buttons){ let caption; - if(options.buttons.type === 'chat') { + if (options.buttons.type === 'chat') { content = options.buttons.body; caption = content; - }else{ + } else { caption = options.caption ? options.caption : ' '; //Caption can't be empty } buttonOptions = { @@ -192,11 +211,16 @@ exports.LoadUtils = () => { delete options.list; delete listOptions.list.footer; } - + + const meUser = window.Store.User.getMaybeMeUser(); + const isMD = window.Store.Features.features.MD_BACKEND; + const newMsgId = new window.Store.MsgKey({ - fromMe: true, - remote: chat.id, + from: meUser, + to: chat.id, id: window.Store.genId(), + participant: isMD && chat.id.isGroup() ? meUser : undefined, + selfDir: 'out', }); const extraOptions = options.extraOptions || {}; @@ -213,7 +237,7 @@ exports.LoadUtils = () => { id: newMsgId, ack: 0, body: content, - from: window.Store.Conn.wid, + from: meUser, to: chat.id, local: true, self: 'out', @@ -234,11 +258,25 @@ exports.LoadUtils = () => { return window.Store.Msg.get(newMsgId._serialized); }; + window.WWebJS.toStickerData = async (mediaInfo) => { + if (mediaInfo.mimetype == 'image/webp') return mediaInfo; + + const file = window.WWebJS.mediaInfoToFile(mediaInfo); + const webpSticker = await window.Store.StickerTools.toWebpSticker(file); + const webpBuffer = await webpSticker.arrayBuffer(); + const data = window.WWebJS.arrayBufferToBase64(webpBuffer); + + return { + mimetype: 'image/webp', + data + }; + }; + window.WWebJS.processStickerData = async (mediaInfo) => { if (mediaInfo.mimetype !== 'image/webp') throw new Error('Invalid media type'); const file = window.WWebJS.mediaInfoToFile(mediaInfo); - let filehash = await window.WWebJS.getFileHash(file); + let filehash = await window.WWebJS.getFileHash(file); let mediaKey = await window.WWebJS.generateHash(32); const controller = new AbortController(); @@ -324,11 +362,11 @@ exports.LoadUtils = () => { window.WWebJS.getMessageModel = message => { const msg = message.serialize(); - + msg.isEphemeral = message.isEphemeral; msg.isStatusV3 = message.isStatusV3; - msg.links = (message.getLinks()).map(link => ({ - link: link.href, + msg.links = (message.getLinks()).map(link => ({ + link: link.href, isSuspicious: Boolean(link.suspiciousCharacters && link.suspiciousCharacters.size) })); @@ -338,28 +376,30 @@ exports.LoadUtils = () => { if (msg.dynamicReplyButtons) { msg.dynamicReplyButtons = JSON.parse(JSON.stringify(msg.dynamicReplyButtons)); } - if(msg.replyButtons) { + if (msg.replyButtons) { msg.replyButtons = JSON.parse(JSON.stringify(msg.replyButtons)); } - if(typeof msg.id.remote === 'object') { - msg.id = Object.assign({}, msg.id, {remote: msg.id.remote._serialized}); + if (typeof msg.id.remote === 'object') { + msg.id = Object.assign({}, msg.id, { remote: msg.id.remote._serialized }); } - + delete msg.pendingAckUpdate; - + return msg; }; window.WWebJS.getChatModel = async chat => { + let res = chat.serialize(); res.isGroup = chat.isGroup; res.formattedTitle = chat.formattedTitle; res.isMuted = chat.mute && chat.mute.isMuted; if (chat.groupMetadata) { - await window.Store.GroupMetadata.update(chat.id._serialized); + const chatWid = window.Store.WidFactory.createWid((chat.id._serialized)); + await window.Store.GroupMetadata.update(chatWid); res.groupMetadata = chat.groupMetadata.serialize(); } @@ -414,7 +454,7 @@ exports.LoadUtils = () => { }; window.WWebJS.mediaInfoToFile = ({ data, mimetype, filename }) => { - const binaryData = atob(data); + const binaryData = window.atob(data); const buffer = new ArrayBuffer(binaryData.length); const view = new Uint8Array(buffer); @@ -431,15 +471,15 @@ exports.LoadUtils = () => { window.WWebJS.arrayBufferToBase64 = (arrayBuffer) => { let binary = ''; - const bytes = new Uint8Array( arrayBuffer ); + const bytes = new Uint8Array(arrayBuffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { - binary += String.fromCharCode( bytes[ i ] ); + binary += String.fromCharCode(bytes[i]); } - return window.btoa( binary ); + return window.btoa(binary); }; - window.WWebJS.getFileHash = async (data) => { + window.WWebJS.getFileHash = async (data) => { let buffer = await data.arrayBuffer(); const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); return btoa(String.fromCharCode(...new Uint8Array(hashBuffer))); @@ -449,7 +489,7 @@ exports.LoadUtils = () => { var result = ''; var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var charactersLength = characters.length; - for ( var i = 0; i < length; i++ ) { + for (var i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; @@ -474,15 +514,18 @@ exports.LoadUtils = () => { }; window.WWebJS.sendChatstate = async (state, chatId) => { + if (window.Store.Features.features.MD_BACKEND) { + chatId = window.Store.WidFactory.createWid(chatId); + } switch (state) { case 'typing': - await window.Store.Wap.sendChatstateComposing(chatId); + await window.Store.ChatState.sendChatStateComposing(chatId); break; case 'recording': - await window.Store.Wap.sendChatstateRecording(chatId); + await window.Store.ChatState.sendChatStateRecording(chatId); break; case 'stop': - await window.Store.Wap.sendChatstatePaused(chatId); + await window.Store.ChatState.sendChatStatePaused(chatId); break; default: throw 'Invalid chatstate'; @@ -494,7 +537,7 @@ exports.LoadUtils = () => { window.WWebJS.getLabelModel = label => { let res = label.serialize(); res.hexColor = label.hexColor; - + return res; }; @@ -527,20 +570,3 @@ exports.LoadUtils = () => { return undefined; }; }; - -exports.MarkAllRead = () => { - let Chats = window.Store.Chat.models; - - for (let chatIndex in Chats) { - if (isNaN(chatIndex)) { - continue; - } - - let chat = Chats[chatIndex]; - - if (chat.unreadCount > 0) { - chat.markSeen(); - window.Store.Wap.sendConversationSeen(chat.id, chat.getLastMsgKeyForAction(), chat.unreadCount - chat.pendingSeenCount); - } - } -}; diff --git a/src/util/Util.js b/src/util/Util.js index 4bfc378..a3a6bb9 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -1,6 +1,5 @@ 'use strict'; -const sharp = require('sharp'); const path = require('path'); const Crypto = require('crypto'); const { tmpdir } = require('os'); @@ -14,7 +13,6 @@ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k); * Utility methods */ class Util { - constructor() { throw new Error(`The ${this.constructor.name} class may not be instantiated.`); } @@ -23,7 +21,7 @@ class Util { var result = ''; var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var charactersLength = characters.length; - for ( var i = 0; i < length; i++ ) { + for (var i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; @@ -55,33 +53,19 @@ class Util { * * @returns {Promise} media in webp format */ - static async formatImageToWebpSticker(media) { + static async formatImageToWebpSticker(media, pupPage) { if (!media.mimetype.includes('image')) throw new Error('media is not a image'); - + if (media.mimetype.includes('webp')) { return media; } - - const buff = Buffer.from(media.data, 'base64'); - - let sharpImg = sharp(buff); - sharpImg = sharpImg.webp(); - - sharpImg = sharpImg.resize(512, 512, { - fit: 'contain', - background: { r: 0, g: 0, b: 0, alpha: 0 }, - }); - - let webpBase64 = (await sharpImg.toBuffer()).toString('base64'); - - return { - mimetype: 'image/webp', - data: webpBase64, - filename: media.filename, - }; + + return pupPage.evaluate((media) => { + return window.WWebJS.toStickerData(media); + }, media); } - + /** * Formats a video to webp * @param {MessageMedia} media @@ -91,14 +75,14 @@ class Util { static async formatVideoToWebpSticker(media) { if (!media.mimetype.includes('video')) throw new Error('media is not a video'); - + const videoType = media.mimetype.split('/')[1]; const tempFile = path.join( tmpdir(), `${Crypto.randomBytes(6).readUIntLE(0, 6).toString(36)}.webp` ); - + const stream = new (require('stream').Readable)(); const buffer = Buffer.from( media.data.replace(`data:${media.mimetype};base64,`, ''), @@ -135,17 +119,17 @@ class Util { .toFormat('webp') .save(tempFile); }); - + const data = await fs.readFile(tempFile, 'base64'); await fs.unlink(tempFile); - - return { + + return { mimetype: 'image/webp', data: data, filename: media.filename, }; } - + /** * Sticker metadata. * @typedef {Object} StickerMetadata @@ -161,14 +145,14 @@ class Util { * * @returns {Promise} media in webp format */ - static async formatToWebpSticker(media, metadata) { + static async formatToWebpSticker(media, metadata, pupPage) { let webpMedia; - if (media.mimetype.includes('image')) - webpMedia = await this.formatImageToWebpSticker(media); - else if (media.mimetype.includes('video')) + if (media.mimetype.includes('image')) + webpMedia = await this.formatImageToWebpSticker(media, pupPage); + else if (media.mimetype.includes('video')) webpMedia = await this.formatVideoToWebpSticker(media); - else + else throw new Error('Invalid media format'); if (metadata.name || metadata.author) { diff --git a/tests/README.md b/tests/README.md index e1c9544..4b98aa8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,8 +3,12 @@ These tests require an authenticated WhatsApp Web session, as well as an additional phone that you can send messages to. This can be configured using the following environment variables: -- `WWEBJS_TEST_SESSION`: A JSON-formatted string with the session details. Must include `WABrowserId`, `WASecretBundle`, `WAToken1` and `WAToken2`. -- `WWEBJS_TEST_SESSION_PATH`: Path to a JSON file that contains the session details. Must include `WABrowserId`, `WASecretBundle`, `WAToken1` and `WAToken2`. +- `WWEBJS_TEST_SESSION`: A JSON-formatted string with legacy auth session details. Must include `WABrowserId`, `WASecretBundle`, `WAToken1` and `WAToken2`. +- `WWEBJS_TEST_SESSION_PATH`: Path to a JSON file that contains the legacy auth session details. Must include `WABrowserId`, `WASecretBundle`, `WAToken1` and `WAToken2`. +- `WWEBJS_TEST_CLIENT_ID`: `clientId` to use for local file based authentication. - `WWEBJS_TEST_REMOTE_ID`: A valid WhatsApp ID that you can send messages to, e.g. `123456789@c.us`. It should be different from the ID used by the provided session. -You *must* set `WWEBJS_TEST_REMOTE_ID` **and** either `WWEBJS_TEST_SESSION` or `WWEBJS_TEST_SESSION_PATH` for the tests to run properly. +You *must* set `WWEBJS_TEST_REMOTE_ID` **and** either `WWEBJS_TEST_SESSION`, `WWEBJS_TEST_SESSION_PATH` or `WWEBJS_TEST_CLIENT_ID` for the tests to run properly. + +### Multidevice +Some of the tested functionality depends on whether the account has multidevice enabled or not. If you are using multidevice, you should set `WWEBJS_TEST_MD=1`. \ No newline at end of file diff --git a/tests/client.js b/tests/client.js index 49c69ec..c99d672 100644 --- a/tests/client.js +++ b/tests/client.js @@ -1,4 +1,5 @@ -const {expect} = require('chai'); +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); const sinon = require('sinon'); const helper = require('./helper'); @@ -9,7 +10,11 @@ const MessageMedia = require('../src/structures/MessageMedia'); const Location = require('../src/structures/Location'); const { MessageTypes, WAState } = require('../src/util/Constants'); +const expect = chai.expect; +chai.use(chaiAsPromised); + const remoteId = helper.remoteId; +const isMD = helper.isMD(); describe('Client', function() { describe('Authentication', function() { @@ -47,76 +52,6 @@ describe('Client', function() { expect(disconnectedCallback.calledOnceWith('Max qrcode retries reached')).to.eql(true); }); - it('should fail auth if session is invalid', async function() { - this.timeout(40000); - - const authFailCallback = sinon.spy(); - const qrCallback = sinon.spy(); - const readyCallback = sinon.spy(); - - const client = helper.createClient({ - options: { - session: { - WABrowserId: 'invalid', - WASecretBundle: 'invalid', - WAToken1: 'invalid', - WAToken2: 'invalid' - }, - authTimeoutMs: 10000, - restartOnAuthFail: false - } - }); - - client.on('qr', qrCallback); - client.on('auth_failure', authFailCallback); - client.on('ready', readyCallback); - - client.initialize(); - - await helper.sleep(25000); - - expect(authFailCallback.called).to.equal(true); - expect(authFailCallback.args[0][0]).to.equal('Unable to log in. Are the session details valid?'); - - expect(readyCallback.called).to.equal(false); - expect(qrCallback.called).to.equal(false); - - await client.destroy(); - }); - - it('can restart without a session if session was invalid and restartOnAuthFail=true', async function() { - this.timeout(40000); - - const authFailCallback = sinon.spy(); - const qrCallback = sinon.spy(); - - const client = helper.createClient({ - options:{ - session: { - WABrowserId: 'invalid', - WASecretBundle: 'invalid', - WAToken1: 'invalid', - WAToken2: 'invalid' - }, - authTimeoutMs: 10000, - restartOnAuthFail: true - } - }); - - client.on('auth_failure', authFailCallback); - client.on('qr', qrCallback); - - client.initialize(); - - await helper.sleep(35000); - - expect(authFailCallback.called).to.equal(true); - expect(qrCallback.called).to.equal(true); - expect(qrCallback.args[0][0]).to.have.lengthOf(152); - - await client.destroy(); - }); - it('should authenticate with existing session', async function() { this.timeout(40000); @@ -124,7 +59,10 @@ describe('Client', function() { const qrCallback = sinon.spy(); const readyCallback = sinon.spy(); - const client = helper.createClient({withSession: true}); + const client = helper.createClient({ + authenticated: true, + }); + client.on('qr', qrCallback); client.on('authenticated', authenticatedCallback); client.on('ready', readyCallback); @@ -132,59 +70,137 @@ describe('Client', function() { await client.initialize(); expect(authenticatedCallback.called).to.equal(true); - const newSession = authenticatedCallback.args[0][0]; - expect(newSession).to.have.key([ - 'WABrowserId', - 'WASecretBundle', - 'WAToken1', - 'WAToken2' - ]); - expect(authenticatedCallback.called).to.equal(true); + + if(helper.isUsingDeprecatedSession()) { + const newSession = authenticatedCallback.args[0][0]; + expect(newSession).to.have.key([ + 'WABrowserId', + 'WASecretBundle', + 'WAToken1', + 'WAToken2' + ]); + } + expect(readyCallback.called).to.equal(true); expect(qrCallback.called).to.equal(false); 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('Non-MD only', function () { + if(!isMD) { + 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({ + authenticated: true, + options: { takeoverOnConflict: true, takeoverTimeoutMs: 5000 } + }); + const client2 = helper.createClient({authenticated: 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(); + }); + + it('should fail auth if session is invalid', async function() { + this.timeout(40000); + + const authFailCallback = sinon.spy(); + const qrCallback = sinon.spy(); + const readyCallback = sinon.spy(); + + const client = helper.createClient({ + options: { + session: { + WABrowserId: 'invalid', + WASecretBundle: 'invalid', + WAToken1: 'invalid', + WAToken2: 'invalid' + }, + authTimeoutMs: 10000, + restartOnAuthFail: false, + useDeprecatedSessionAuth: true + } + }); + + client.on('qr', qrCallback); + client.on('auth_failure', authFailCallback); + client.on('ready', readyCallback); + + client.initialize(); + + await helper.sleep(25000); + + expect(authFailCallback.called).to.equal(true); + expect(authFailCallback.args[0][0]).to.equal('Unable to log in. Are the session details valid?'); + + expect(readyCallback.called).to.equal(false); + expect(qrCallback.called).to.equal(false); + + await client.destroy(); + }); + + it('can restart without a session if session was invalid and restartOnAuthFail=true', async function() { + this.timeout(40000); + + const authFailCallback = sinon.spy(); + const qrCallback = sinon.spy(); + + const client = helper.createClient({ + options:{ + session: { + WABrowserId: 'invalid', + WASecretBundle: 'invalid', + WAToken1: 'invalid', + WAToken2: 'invalid' + }, + authTimeoutMs: 10000, + restartOnAuthFail: true, + useDeprecatedSessionAuth: true + } + }); + + client.on('auth_failure', authFailCallback); + client.on('qr', qrCallback); + + client.initialize(); + + await helper.sleep(35000); + + expect(authFailCallback.called).to.equal(true); + expect(qrCallback.called).to.equal(true); + expect(qrCallback.args[0][0]).to.have.lengthOf(152); + + await client.destroy(); + }); + } + }); }); describe('Authenticated', function() { @@ -192,7 +208,7 @@ describe('Client', function() { before(async function() { this.timeout(35000); - client = helper.createClient({withSession: true}); + client = helper.createClient({authenticated: true}); await client.initialize(); }); @@ -221,27 +237,38 @@ describe('Client', function() { 'BlockContact', 'Call', 'Chat', + 'ChatState', 'Cmd', 'Conn', 'Contact', 'DownloadManager', 'Features', 'GroupMetadata', + 'GroupParticipants', + 'GroupUtils', 'Invite', + 'InviteInfo', + 'JoinInviteV4', 'Label', 'MediaObject', 'MediaPrep', 'MediaTypes', 'MediaUpload', + 'MessageInfo', 'Msg', 'MsgKey', 'OpaqueData', 'QueryOrder', 'QueryProduct', + 'PresenceUtils', + 'QueryExist', + 'QueryProduct', + 'QueryOrder', 'SendClear', 'SendDelete', 'SendMessage', 'SendSeen', + 'StatusUtils', 'Sticker', 'UploadUtils', 'UserConstructor', @@ -249,7 +276,9 @@ describe('Client', function() { 'Validators', 'Wap', 'WidFactory', - 'genId' + 'findCommonGroups', + 'genId', + 'getProfilePicFull', ]; const loadedModules = await client.pupPage.evaluate((expectedModules) => { @@ -535,8 +564,6 @@ END:VCARD`; 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'); @@ -581,4 +608,4 @@ END:VCARD`; }); }); }); -}); \ No newline at end of file +}); diff --git a/tests/helper.js b/tests/helper.js index febda54..bcd22a3 100644 --- a/tests/helper.js +++ b/tests/helper.js @@ -1,13 +1,25 @@ const path = require('path'); +const crypto = require('crypto'); const Client = require('../src/Client'); -const Util = require('../src/util/Util'); require('dotenv').config(); const remoteId = process.env.WWEBJS_TEST_REMOTE_ID; if(!remoteId) throw new Error('The WWEBJS_TEST_REMOTE_ID environment variable has not been set.'); +function isUsingDeprecatedSession() { + return Boolean(process.env.WWEBJS_TEST_SESSION || process.env.WWEBJS_TEST_SESSION_PATH); +} + +function isMD() { + return Boolean(process.env.WWEBJS_TEST_MD); +} + +if(isUsingDeprecatedSession() && isMD()) throw 'Cannot use deprecated sessions with WWEBJS_TEST_MD=true'; + function getSessionFromEnv() { + if (!isUsingDeprecatedSession()) return null; + const envSession = process.env.WWEBJS_TEST_SESSION; if(envSession) return JSON.parse(envSession); @@ -16,17 +28,27 @@ function getSessionFromEnv() { const absPath = path.resolve(process.cwd(), envSessionPath); return require(absPath); } - - throw new Error('No session found in environment.'); } -function createClient({withSession, options: additionalOpts}={}) { +function createClient({authenticated, options: additionalOpts}={}) { const options = {}; - if(withSession) { - options.session = getSessionFromEnv(); + + if(authenticated) { + const deprecatedSession = getSessionFromEnv(); + if(deprecatedSession) { + options.session = deprecatedSession; + options.useDeprecatedSessionAuth = true; + } else { + const clientId = process.env.WWEBJS_TEST_CLIENT_ID; + if(!clientId) throw new Error('No session found in environment.'); + options.clientId = clientId; + } + } else { + options.clientId = crypto.randomBytes(5).toString('hex'); } - return new Client(Util.mergeDefault(options, additionalOpts || {})); + const allOpts = {...options, ...(additionalOpts || {})}; + return new Client(allOpts); } function sleep(ms) { @@ -36,5 +58,7 @@ function sleep(ms) { module.exports = { sleep, createClient, - remoteId + isUsingDeprecatedSession, + isMD, + remoteId, }; \ No newline at end of file diff --git a/tests/structures/chat.js b/tests/structures/chat.js index 144aa5d..6148485 100644 --- a/tests/structures/chat.js +++ b/tests/structures/chat.js @@ -13,7 +13,7 @@ describe('Chat', function () { before(async function() { this.timeout(35000); - client = helper.createClient({ withSession: true }); + client = helper.createClient({ authenticated: true }); await client.initialize(); chat = await client.getChatById(remoteId); }); @@ -32,9 +32,9 @@ describe('Chat', function () { }); it('can fetch messages sent in a chat', async function () { - this.timeout(5000); await helper.sleep(1000); const msg = await chat.sendMessage('another message'); + await helper.sleep(500); const messages = await chat.fetchMessages(); expect(messages.length).to.be.greaterThanOrEqual(2); @@ -49,6 +49,7 @@ describe('Chat', function () { 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'); + await helper.sleep(500); const messages = await chat.fetchMessages({limit: 1}); expect(messages).to.have.lengthOf(1); @@ -80,6 +81,8 @@ describe('Chat', function () { const res = await chat.sendSeen(); expect(res).to.equal(true); + await helper.sleep(1000); + // refresh chat chat = await client.getChatById(remoteId); expect(chat.unreadCount).to.equal(0); @@ -137,6 +140,8 @@ describe('Chat', function () { it('can mute a chat forever', async function() { await chat.mute(); + await helper.sleep(1000); + // refresh chat chat = await client.getChatById(remoteId); expect(chat.isMuted).to.equal(true); @@ -147,6 +152,8 @@ describe('Chat', function () { const unmuteDate = new Date(new Date().getTime() + (1000*60*60)); await chat.mute(unmuteDate); + await helper.sleep(1000); + // refresh chat chat = await client.getChatById(remoteId); expect(chat.isMuted).to.equal(true); @@ -168,9 +175,7 @@ describe('Chat', function () { // 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); - + it('can clear all messages from chat', async function () { const res = await chat.clearMessages(); expect(res).to.equal(true); diff --git a/tests/structures/group.js b/tests/structures/group.js new file mode 100644 index 0000000..6c47dd5 --- /dev/null +++ b/tests/structures/group.js @@ -0,0 +1,227 @@ +const { expect } = require('chai'); +const helper = require('../helper'); + +const remoteId = helper.remoteId; + +describe('Group', function() { + let client; + let group; + + before(async function() { + this.timeout(35000); + client = helper.createClient({ + authenticated: true, + }); + await client.initialize(); + + const createRes = await client.createGroup('My Awesome Group', [remoteId]); + expect(createRes.gid).to.exist; + await helper.sleep(500); + group = await client.getChatById(createRes.gid._serialized); + expect(group).to.exist; + }); + + beforeEach(async function () { + await helper.sleep(500); + }); + + describe('Settings', function () { + it('can change the group subject', async function () { + expect(group.name).to.equal('My Awesome Group'); + const res = await group.setSubject('My Amazing Group'); + expect(res).to.equal(true); + + await helper.sleep(1000); + + // reload + group = await client.getChatById(group.id._serialized); + expect(group.name).to.equal('My Amazing Group'); + }); + + it('can change the group description', async function () { + expect(group.description).to.equal(undefined); + const res = await group.setDescription('some description'); + expect(res).to.equal(true); + expect(group.description).to.equal('some description'); + + await helper.sleep(1000); + + // reload + group = await client.getChatById(group.id._serialized); + expect(group.description).to.equal('some description'); + }); + + it('can set only admins able to send messages', async function () { + expect(group.groupMetadata.announce).to.equal(false); + const res = await group.setMessagesAdminsOnly(); + expect(res).to.equal(true); + expect(group.groupMetadata.announce).to.equal(true); + + await helper.sleep(1000); + + // reload + group = await client.getChatById(group.id._serialized); + expect(group.groupMetadata.announce).to.equal(true); + }); + + it('can set all participants able to send messages', async function () { + expect(group.groupMetadata.announce).to.equal(true); + const res = await group.setMessagesAdminsOnly(false); + expect(res).to.equal(true); + expect(group.groupMetadata.announce).to.equal(false); + + await helper.sleep(1000); + + // reload + group = await client.getChatById(group.id._serialized); + expect(group.groupMetadata.announce).to.equal(false); + }); + + it('can set only admins able to set group info', async function () { + expect(group.groupMetadata.restrict).to.equal(false); + const res = await group.setInfoAdminsOnly(); + expect(res).to.equal(true); + expect(group.groupMetadata.restrict).to.equal(true); + + await helper.sleep(1000); + + // reload + group = await client.getChatById(group.id._serialized); + expect(group.groupMetadata.restrict).to.equal(true); + }); + + it('can set all participants able to set group info', async function () { + expect(group.groupMetadata.restrict).to.equal(true); + const res = await group.setInfoAdminsOnly(false); + expect(res).to.equal(true); + expect(group.groupMetadata.restrict).to.equal(false); + + await helper.sleep(1000); + + // reload + group = await client.getChatById(group.id._serialized); + expect(group.groupMetadata.restrict).to.equal(false); + }); + }); + + describe('Invites', function () { + it('can get the invite code', async function () { + const code = await group.getInviteCode(); + expect(typeof code).to.equal('string'); + }); + + it('can get invite info', async function () { + const code = await group.getInviteCode(); + const info = await client.getInviteInfo(code); + expect(info.id._serialized).to.equal(group.id._serialized); + expect(info.participants.length).to.equal(2); + }); + + it('can revoke the invite code', async function () { + const code = await group.getInviteCode(); + const newCode = await group.revokeInvite(); + expect(typeof newCode).to.equal('string'); + expect(newCode).to.not.equal(code); + }); + }); + + describe('Participants', function () { + it('can promote a user to admin', async function () { + let participant = group.participants.find(p => p.id._serialized === remoteId); + expect(participant).to.exist; + expect(participant.isAdmin).to.equal(false); + + const res = await group.promoteParticipants([remoteId]); + expect(res.status).to.be.greaterThanOrEqual(200); + + await helper.sleep(1000); + + // reload and check + group = await client.getChatById(group.id._serialized); + participant = group.participants.find(p => p.id._serialized=== remoteId); + expect(participant).to.exist; + expect(participant.isAdmin).to.equal(true); + }); + + it('can demote a user', async function () { + let participant = group.participants.find(p => p.id._serialized=== remoteId); + expect(participant).to.exist; + expect(participant.isAdmin).to.equal(true); + + const res = await group.demoteParticipants([remoteId]); + expect(res.status).to.be.greaterThanOrEqual(200); + + await helper.sleep(1000); + + // reload and check + group = await client.getChatById(group.id._serialized); + participant = group.participants.find(p => p.id._serialized=== remoteId); + expect(participant).to.exist; + expect(participant.isAdmin).to.equal(false); + }); + + it('can remove a user from the group', async function () { + let participant = group.participants.find(p => p.id._serialized=== remoteId); + expect(participant).to.exist; + + const res = await group.removeParticipants([remoteId]); + expect(res.status).to.be.greaterThanOrEqual(200); + + await helper.sleep(1000); + + // reload and check + group = await client.getChatById(group.id._serialized); + participant = group.participants.find(p => p.id._serialized=== remoteId); + expect(participant).to.not.exist; + }); + + it('can add back a user to the group', async function () { + let participant = group.participants.find(p => p.id._serialized=== remoteId); + expect(participant).to.not.exist; + + const res = await group.addParticipants([remoteId]); + expect(res.status).to.be.greaterThanOrEqual(200); + + await helper.sleep(1000); + + // reload and check + group = await client.getChatById(group.id._serialized); + participant = group.participants.find(p => p.id._serialized=== remoteId); + expect(participant).to.exist; + }); + }); + + describe('Leave / re-join', function () { + let code; + before(async function () { + code = await group.getInviteCode(); + }); + + it('can leave the group', async function () { + expect(group.isReadOnly).to.equal(false); + await group.leave(); + + await helper.sleep(1000); + + // reload and check + group = await client.getChatById(group.id._serialized); + expect(group.isReadOnly).to.equal(true); + }); + + it('can join a group via invite code', async function () { + const chatId = await client.acceptInvite(code); + expect(chatId).to.equal(group.id._serialized); + + await helper.sleep(1000); + + // reload and check + group = await client.getChatById(group.id._serialized); + expect(group.isReadOnly).to.equal(false); + }); + }); + + after(async function () { + await client.destroy(); + }); + +}); diff --git a/tests/structures/message.js b/tests/structures/message.js new file mode 100644 index 0000000..7a9749d --- /dev/null +++ b/tests/structures/message.js @@ -0,0 +1,112 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const helper = require('../helper'); +const { Contact, Chat } = require('../../src/structures'); + +const remoteId = helper.remoteId; + +describe('Message', function () { + let client; + let chat; + let message; + + before(async function() { + this.timeout(35000); + client = helper.createClient({ authenticated: true }); + await client.initialize(); + + chat = await client.getChatById(remoteId); + message = await chat.sendMessage('this is only a test'); + + // wait for message to be sent + await helper.sleep(1000); + }); + + after(async function () { + await client.destroy(); + }); + + it('can get the related chat', async function () { + const chat = await message.getChat(); + expect(chat).to.be.instanceOf(Chat); + expect(chat.id._serialized).to.equal(remoteId); + }); + + it('can get the related contact', async function () { + const contact = await message.getContact(); + expect(contact).to.be.instanceOf(Contact); + expect(contact.id._serialized).to.equal(client.info.wid._serialized); + }); + + it('can get message info', async function () { + const info = await message.getInfo(); + expect(typeof info).to.equal('object'); + expect(Array.isArray(info.played)).to.equal(true); + expect(Array.isArray(info.read)).to.equal(true); + expect(Array.isArray(info.delivery)).to.equal(true); + }); + + describe('Replies', function () { + let replyMsg; + + it('can reply to a message', async function () { + replyMsg = await message.reply('this is my reply'); + expect(replyMsg.hasQuotedMsg).to.equal(true); + }); + + it('can get the quoted message', async function () { + const quotedMsg = await replyMsg.getQuotedMessage(); + expect(quotedMsg.id._serialized).to.equal(message.id._serialized); + }); + }); + + describe('Star', function () { + it('can star a message', async function () { + expect(message.isStarred).to.equal(false); + await message.star(); + + // reload and check + await message.reload(); + expect(message.isStarred).to.equal(true); + }); + + it('can un-star a message', async function () { + expect(message.isStarred).to.equal(true); + await message.unstar(); + + // reload and check + await message.reload(); + expect(message.isStarred).to.equal(false); + }); + }); + + describe('Delete', function () { + it('can delete a message for me', async function () { + await message.delete(); + + await helper.sleep(1000); + expect(await message.reload()).to.equal(null); + }); + + it('can delete a message for everyone', async function () { + message = await chat.sendMessage('sneaky message'); + await helper.sleep(1000); + + const callback = sinon.spy(); + client.once('message_revoke_everyone', callback); + + await message.delete(true); + await helper.sleep(1000); + + expect(await message.reload()).to.equal(null); + expect(callback.called).to.equal(true); + const [ revokeMsg, originalMsg ] = callback.args[0]; + expect(revokeMsg.id._serialized).to.equal(originalMsg.id._serialized); + expect(originalMsg.body).to.equal('sneaky message'); + expect(originalMsg.type).to.equal('chat'); + expect(revokeMsg.body).to.equal(''); + expect(revokeMsg.type).to.equal('revoked'); + }); + }); +}); \ No newline at end of file