WhatsApp Automatic Notification Script in NodeJS

Browser automation for WhatsApp web

A brief write-up for the whatsapp_notifier_nodejs script

The Goal

Automate messaging operations to known WhatsApp contacts with Puppeteer.js and web WhatsApp.

NodeJS will run the script and a rest API will control the notification queue flow: getting the notification information and saving the message status.

For this script, we'll be using a JSON notification with the following structure:

{
    pk: int,
    fields: {
        number: int,
        message: string
    }
}

Dealing with async functions in Javascript

The script operates with external events. Therefore we will need to encapsulate in try-catches our asynchronous operations. In this way we can control the flow of our application if an error happens and use those exception to decide what to do next.

Puppeteer.js is a library for browser automation, every function we run is asynchronous and might fail for several reasons. It is always a good idea to log what the script is doing in the Node terminal.


Navigating to WhatsApp web using Puppeteer.js

As you can follow in the code below, we will launch puppeteer instance. Let's now read the QR code with our WhatsApp application to authenticate the session. Our script will allow 30 seconds to complete this operation.

To check whether the page loaded correctly we will look for a fundamental element of the WhatsApp interface: the chat search box. Identified by the code _1awRl.

const puppeteer = require('puppeteer');
const fetch = require('node-fetch');

// CONFIG VARIABLES - TO CHANGE IN CASE WHATSAPP DECIDES TO CHANGE CLASSES NAMES
const search_box_class='._1awRl';
const search_box_number_input_class='._3Eocp';

/*
* How does this work?
* The user's number is saved in the contacts therefore we will be able to start a conversation with him/her
* The service should do a polling to the server that builds a queue of notifications
* The notification queue is consumed by type and recipient
* If the queue is empty the service will continue polling at a lower rate. 
*/

(async function main(){
    try {
        const browser = await puppeteer.launch({headless: false});
        const page = await browser.newPage();
        await page.setUserAgent(
            "User Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36"
        );
        console.log('navigating...');
        await page.goto('https://web.whatsapp.com/');
        await page.waitForSelector(search_box_class, {timeout: 30000});
        console.log('WhatsApp loaded');
        const recursiveFetch = async function () {
            ...
        } 
        recursiveFetch();
    } catch(error) {
        console.log(error)
    }
})();

REST API Polling

The recursion of our script will be based on the list-notifications endpoint response:

If no notifications are returned by our API, the queue is empty so we will retry in 30 seconds
If the server is down and the connection was refused, we will retry in a minute.

This is a good boilerplate to start catching different exceptions and make our script smarter and more interactive:

const recursiveFetch = async function () {
    fetch("http://mydomain.com/list-notifications").then(response => {
        return response.json()
    }).then(async function (json) {
        notifications = JSON.parse(json);
        console.log('fetching the value...')
        if (notifications.length > 0) {
            ...
        } else {
            console.log('No notifications to send. Going to sleep...')
        }
        setTimeout(recursiveFetch, 30000);
    }).catch(error => {
        if (error.code == "ECONNREFUSED") {
            console.error('server not available retrying in a minute');
            
        } else {
            console.error(error);
        }
        setTimeout(recursiveFetch, 60000);
    });
} 

The Base Case

Searching by the phone number

The first operation is to look for a contact in our WhatsApp web interface. Since this is a recursive operation, it’s a good idea to erase everything in the searchbox beforehand.

Now we can paste the contact number in the searchbox using the keyboard.type() method

The web WhatsApp interface will start the search. Now the code flow will split in two: contact found, contact not found.

for (notification of notifications) {
    console.log('Searching for the contact');
    await page.$(search_box_number_input_class).then((erase) => erase.click()).catch((error) => console.log('nothing to erase...'));
    const search = await page.$(search_box_class);
    await search.click();
    console.log('pasting contact...');
    let number = notification.fields.number.toString();
    // in some countries people is used to save their number starting with 0, this is not saved in WhatsApp so we will remove it
    if (number.startsWith('0')) {
        number = number.slice(1,number.length);
    }
    await page.keyboard.type(number);
    console.log('Looking for chats...');
    await page.waitForFunction('document.querySelector("body").innerText.includes("No chats")', {timeout: 5000})
    .then(() => {
        ...
    .catch(async (error) => {
        ...
    });
}
            

Contact not found

The WhatsApp interface will show the message "No chats found", so we will enter in the "then" branch.

In this case the script will update the notification status in the system using the proper endpoint and the recursion step will end. This process is described further here: Consuming the queue.

console.log("no contacts found...");
const code = 404;
fetch(`http://mydomain.com/consume-notification?pk=${notification.pk}&code=${code}`).then(response => {
    return response.json()
}).then(res => {
    console.log('message popped from the queue with status: ' + code);
}).catch(error=> {
    console.error(error);
});
 

Contact found

Good news! WhatsApp knows this contact and we can send him/her a message!

But first, let's do a quick recap on how web WhatsApp interface works:
Once the search operation is concluded, we can navigate to the first contact in the found list by using TAB, and selecting it by using ENTER. Once selected, the interface will open the corresponding chat targeting our "input focus" on the message bar. This means we are ready to type our message. Finally we can send it by pressing ENTER one more time.

The above key combination ensures to select the first element in the found list, if any, and to send him/her a message. Let’s always consider this is a web platform so we should expect a slight delay for the input-output, hence the timeouts in the code.

console.log('we have contacts matching search criteria: timeout');
console.log('taking first contact found...');
setTimeout(()=> {console.log('..waited some milliseconds'), 1000});
await page.keyboard.press('Tab');
setTimeout(()=> {console.log('..waited some milliseconds'), 1000});
await page.keyboard.press('Enter');
setTimeout(()=> {console.log('..waited some milliseconds'), 1000});


Pasting the message

In order to simulate the writing procedure we can pass the optional parameter ‘delay’ to the keyboard.type() method. This will help to simulate better a human interaction, decreasing the issues with our script due to network delay. For this example I used a delay of 10, you can test with different values to adjust the tradeoff between script stability and notification throughput.

console.log('pasting message...');
await page.keyboard.type(c.fields.message, {delay: 10});
await page.keyboard.press('Enter');

Consuming the queue

Either our message was saved successfully or not, we should communicate this to the server in order to carry on with our notification process. Our rest API has an endpoint which allow us to save the status of a notification message. In this way we can have a specific log for the consumed messages. We can use this to retry the notification messages, or notify our users with different means (email for example).

const code = 200;
fetch(`http://mydomain.com/consume-notification?pk=${notification.pk}&code=${code}`).then(response => {
    return response.json();
}).then(res => {
    console.log('message popped from the queue with status: ' + code);
}).catch(error=> {
    console.error(error);
});

The Code

Please find here the Github repository with the full code. Feel free to use it and contribute.