Files
whatsapp-web.js/src/Client.js
Pedro Lopez 4fe7fa93b9 fix: delegate all event handlers
Attaching these events directly to puppeteer functions apparently blocks and causes CPU usage to go up, slowing everything down.

related #88, 8bdecad
2020-03-13 17:44:20 -04:00

495 lines
17 KiB
JavaScript

'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', (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', (msg) => { window.onChangeMessageEvent(msg); });
window.Store.Msg.on('change:type', (msg) => { window.onChangeMessageTypeEvent(msg); });
window.Store.Msg.on('change:ack', (msg, ack) => { window.onMessageAckEvent(msg, ack); });
window.Store.Msg.on('remove', (msg) => { if(msg.isNewMsg) window.onRemoveMessageEvent(msg); });
window.Store.AppState.on('change:state', (_AppState, state) => { window.onAppStateChangedEvent(state); });
});
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<boolean>} 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>} 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<Array<Chat>>}
*/
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<Chat>}
*/
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<Array<Contact>>}
*/
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<Contact>}
*/
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;