'use strict'; const EventEmitter = require('events'); const puppeteer = require('puppeteer'); const moduleRaid = require('moduleraid/moduleraid'); const jsQR = require('jsqr'); const Util = require('./util/Util'); const { WhatsWebURL, UserAgent, DefaultOptions, Events, WAState } = require('./util/Constants'); const { ExposeStore, LoadUtils } = require('./util/Injected'); const ChatFactory = require('./factories/ChatFactory'); const ContactFactory = require('./factories/ContactFactory'); const { ClientInfo, Message, MessageMedia, Location, GroupNotification } = require('./structures'); /** * Starting point for interacting with the WhatsApp Web API * @extends {EventEmitter} * @fires Client#qr * @fires Client#authenticated * @fires Client#auth_failure * @fires Client#ready * @fires Client#message * @fires Client#message_ack * @fires Client#message_create * @fires Client#message_revoke_me * @fires Client#message_revoke_everyone * @fires Client#group_join * @fires Client#group_leave * @fires Client#group_update * @fires Client#disconnected * @fires Client#change_state */ class Client extends EventEmitter { constructor(options = {}) { super(); this.options = Util.mergeDefault(DefaultOptions, options); this.pupBrowser = null; this.pupPage = null; } /** * Sets up events and requirements, kicks off authentication request */ async initialize() { const browser = await puppeteer.launch(this.options.puppeteer); const page = (await browser.pages())[0]; page.setUserAgent(UserAgent); if (this.options.session) { await page.evaluateOnNewDocument( session => { 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); } await page.goto(WhatsWebURL); const KEEP_PHONE_CONNECTED_IMG_SELECTOR = '[data-asset-intro-image="true"]'; if (this.options.session) { // Check if session restore was successfull try { await page.waitForSelector(KEEP_PHONE_CONNECTED_IMG_SELECTOR, { timeout: 15000 }); } 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(); return; } throw err; } } else { const getQrCode = async () => { // Check if retry button is present var QR_RETRY_SELECTOR = 'div[data-ref] > span > div'; 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); 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; /** * Emitted when the QR code is received * @event Client#qr * @param {string} qr QR Code */ this.emit(Events.QR_RECEIVED, qr); }; getQrCode(); let retryInterval = setInterval(getQrCode, 20000); // check for qr code every 20 seconds // Wait for code scan await page.waitForSelector(KEEP_PHONE_CONNECTED_IMG_SELECTOR, { timeout: 0 }); clearInterval(retryInterval); } await page.evaluate(ExposeStore, moduleRaid.toString()); // 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 }; /** * Emitted when authentication is successful * @event Client#authenticated * @param {object} session Object containing session information. Can be used to restore the session. */ this.emit(Events.AUTHENTICATED, session); // Check window.Store Injection await page.waitForFunction('window.Store != undefined'); //Load util functions (serializers, helper functions) await page.evaluate(LoadUtils); // Expose client info this.info = new ClientInfo(this, await page.evaluate(() => { return window.Store.Conn.serialize(); })); // Register events await page.exposeFunction('onAddMessageEvent', msg => { if (!msg.isNewMsg) return; if (msg.type === 'gp2') { const notification = new GroupNotification(this, msg); if (msg.subtype === 'add' || msg.subtype === 'invite') { /** * Emitted when a user joins the chat via invite link or is added by an admin. * @event Client#group_join * @param {GroupNotification} notification GroupNotification with more information about the action */ this.emit(Events.GROUP_JOIN, notification); } else if (msg.subtype === 'remove' || msg.subtype === 'leave') { /** * Emitted when a user leaves the chat or is removed by an admin. * @event Client#group_leave * @param {GroupNotification} notification GroupNotification with more information about the action */ this.emit(Events.GROUP_LEAVE, notification); } else { /** * Emitted when group settings are updated, such as subject, description or picture. * @event Client#group_update * @param {GroupNotification} notification GroupNotification with more information about the action */ this.emit(Events.GROUP_UPDATE, notification); } return; } const message = new Message(this, msg); /** * Emitted when a new message is created, which may include the current user's own messages. * @event Client#message_create * @param {Message} message The message that was created */ this.emit(Events.MESSAGE_CREATE, message); if (msg.id.fromMe) return; /** * Emitted when a new message is received. * @event Client#message * @param {Message} message The message that was received */ this.emit(Events.MESSAGE_RECEIVED, message); }); let last_message; await page.exposeFunction('onChangeMessageTypeEvent', (msg) => { if (msg.type === 'revoked') { const message = new Message(this, msg); let revoked_msg; if (last_message && msg.id.id === last_message.id.id) { revoked_msg = new Message(this, last_message); } /** * Emitted when a message is deleted for everyone in the chat. * @event Client#message_revoke_everyone * @param {Message} message The message that was revoked, in its current state. It will not contain the original message's data. * @param {?Message} revoked_msg The message that was revoked, before it was revoked. It will contain the message's original data. * Note that due to the way this data is captured, it may be possible that this param will be undefined. */ this.emit(Events.MESSAGE_REVOKED_EVERYONE, message, revoked_msg); } }); await page.exposeFunction('onChangeMessageEvent', (msg) => { if (msg.type !== 'revoked') { last_message = msg; } }); await page.exposeFunction('onRemoveMessageEvent', (msg) => { if (!msg.isNewMsg) return; const message = new Message(this, msg); /** * Emitted when a message is deleted by the current user. * @event Client#message_revoke_me * @param {Message} message The message that was revoked */ this.emit(Events.MESSAGE_REVOKED_ME, message); }); await page.exposeFunction('onMessageAckEvent', (msg, ack) => { const message = new Message(this, msg); /** * Emitted when an ack event occurrs on message type. * @event Client#message_ack * @param {Message} message The message that was affected * @param {MessageAck} ack The new ACK value */ this.emit(Events.MESSAGE_ACK, message, ack); }); await page.exposeFunction('onAppStateChangedEvent', (_AppState, state) => { /** * Emitted when the connection state changes * @event Client#change_state * @param {WAState} state the new connection state */ this.emit(Events.STATE_CHANGED, state); const ACCEPTED_STATES = [WAState.CONNECTED, WAState.OPENING, WAState.PAIRING, WAState.TIMEOUT]; if (!ACCEPTED_STATES.includes(state)) { /** * Emitted when the client has been disconnected * @event Client#disconnected * @param {WAState} reason state that caused the disconnect */ this.emit(Events.DISCONNECTED, state); this.destroy(); } }); await page.evaluate(() => { window.Store.Msg.on('add', (msg) => { if(msg.isNewMsg) window.onAddMessageEvent(msg);}); window.Store.Msg.on('change', window.onChangeMessageEvent); window.Store.Msg.on('change:type', window.onChangeMessageTypeEvent); window.Store.Msg.on('change:ack', window.onMessageAckEvent); window.Store.Msg.on('remove', window.onRemoveMessageEvent); window.Store.AppState.on('change:state', window.onAppStateChangedEvent); }); this.pupBrowser = browser; this.pupPage = page; /** * Emitted when the client has initialized and is ready to receive messages. * @event Client#ready */ this.emit(Events.READY); } /** * Closes the client */ async destroy() { await this.pupBrowser.close(); } /** * Mark as seen for the Chat * @param {string} chatId * @returns {Promise} result * */ async sendSeen(chatId) { const result = await this.pupPage.evaluate(async (chatId) => { return window.WWebJS.sendSeen(chatId); }, chatId); return result; } /** * Send a message to a specific chatId * @param {string} chatId * @param {string|MessageMedia|Location} content * @param {object} options * @returns {Promise} Message that was just sent */ async sendMessage(chatId, content, options = {}) { let internalOptions = { caption: options.caption, quotedMessageId: options.quotedMessageId, mentionedJidList: Array.isArray(options.mentions) ? options.mentions.map(contact => contact.id._serialized) : [] }; const sendSeen = typeof options.sendSeen === 'undefined' ? true : options.sendSeen; if (content instanceof MessageMedia) { internalOptions.attachment = content; content = ''; } else if (options.media instanceof MessageMedia) { internalOptions.attachment = options.media; internalOptions.caption = content; } else if (content instanceof Location) { internalOptions.location = content; content = ''; } const newMessage = await this.pupPage.evaluate(async (chatId, message, options, sendSeen) => { let chat = window.Store.Chat.get(chatId); let msg; if (!chat) { // The chat is not available in the previously chatted list let newChatId = await window.WWebJS.getNumberId(chatId); if (newChatId) { //get the topmost chat object and assign the new chatId to it . //This is just a workaround.May cause problem if there are no chats at all. Need to dig in and emulate how whatsapp web does let chat = window.Store.Chat.models[0]; if (!chat) throw 'Chat List empty! Need at least one open conversation with any of your contact'; let originalChatObjId = chat.id; chat.id = newChatId; msg = await window.WWebJS.sendMessage(chat, message, options); chat.id = originalChatObjId; //replace the chat with its original id } } else { if(sendSeen) { window.WWebJS.sendSeen(chatId); } msg = await window.WWebJS.sendMessage(chat, message, options, sendSeen); } return msg.serialize(); }, chatId, content, internalOptions, sendSeen); return new Message(this, newMessage); } /** * Get all current chat instances * @returns {Promise>} */ async getChats() { let chats = await this.pupPage.evaluate(() => { return window.WWebJS.getChats(); }); return chats.map(chat => ChatFactory.create(this, chat)); } /** * Get chat instance by ID * @param {string} chatId * @returns {Promise} */ async getChatById(chatId) { let chat = await this.pupPage.evaluate(chatId => { return window.WWebJS.getChat(chatId); }, chatId); return ChatFactory.create(this, chat); } /** * Get all current contact instances * @returns {Promise>} */ async getContacts() { let contacts = await this.pupPage.evaluate(() => { return window.WWebJS.getContacts(); }); return contacts.map(contact => ContactFactory.create(this, contact)); } /** * Get contact instance by ID * @param {string} contactId * @returns {Promise} */ async getContactById(contactId) { let contact = await this.pupPage.evaluate(contactId => { return window.WWebJS.getContact(contactId); }, contactId); return ContactFactory.create(this, contact); } /** * Accepts an invitation to join a group * @param {string} inviteCode Invitation code */ async acceptInvite(inviteCode) { const chatId = await this.pupPage.evaluate(async inviteCode => { return await window.Store.Invite.sendJoinGroupViaInvite(inviteCode); }, inviteCode); return chatId._serialized; } /** * 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); }, status); } /** * Gets the current connection state for the client * @returns {WAState} */ async getState() { return await this.pupPage.evaluate(() => { return window.Store.AppState.state; }); } /** * Enables and returns the archive state of the Chat * @returns {boolean} */ async archiveChat(chatId) { return await this.pupPage.evaluate(async chatId => { let chat = await window.Store.Chat.get(chatId); await window.Store.Cmd.archiveChat(chat, true); return chat.archive; }, chatId); } /** * Changes and returns the archive state of the Chat * @returns {boolean} */ async unarchiveChat(chatId) { return await this.pupPage.evaluate(async chatId => { let chat = await window.Store.Chat.get(chatId); await window.Store.Cmd.archiveChat(chat, false); return chat.archive; }, chatId); } /** * Force reset of connection state for the client */ async resetState(){ await this.pupPage.evaluate(() => { window.Store.AppState.phoneWatchdog.shiftTimer.forceRunNow(); }); } } module.exports = Client;