In part 1 on this article, we tried automating the messaging sending via WhatsApp web using selenium. Drawback of the approach was that, a new instance of browser was created everytime we need to send the message. And we need to scan the QR code everytime new browser instance is created.
In this article we will fix the above issue. We will also develop a Django application to expose few API endpoints. Then we will use those endpoints to send the WhatsApp message.
(1) Creating Django application
If this is the first time you are working with Django, please follow this article to setup and run simplest Django application.
https://pythoncircle.com/post/26/hello-word-in-django-how-to-start-with-django/
Please note that for this article, I used Python 3.10 and Django 3.2.
(2) Virtual Environment
Create a virtual environment using python 3.10 and install below dependencies in it. Save the below content in a file requirements.txt
and run the command pip install -r requirements.txt
after activating the virtual environment.
asgiref==3.6.0 async-generator==1.10 attrs==22.2.0 certifi==2022.12.7 charset-normalizer==2.1.1 Django==3.2.16 exceptiongroup==1.1.0 h11==0.14.0 idna==3.4 outcome==1.2.0 packaging==22.0 PySocks==1.7.1 python-dotenv==0.21.0 pytz==2022.7 requests==2.28.1 selenium==4.7.2 sniffio==1.3.0 sortedcontainers==2.4.0 sqlparse==0.4.3 tqdm==4.64.1 trio==0.22.0 trio-websocket==0.9.2 urllib3==1.26.13 webdriver-manager==3.8.5 wsproto==1.2.0
(2) URLs
Create a urls.py
file in your app and add the below content to it.
Replace app1
with your app name.
from django.urls import path from app1 import views from django.urls import path app_name = "app1" urlpatterns = [ path(r'send/', views.send, name='send'), path(r'instance/', views.create_instance, name='create_instance'), path(r'instance/<str:instance_id>/', views.delete_instance, name='delete_instance'), ]
send/
is to send the WhatsApp messages using GET and POST methods both. Both methods are supported. Curl to send messages is below.instance/
is used to create a new browser instance. You have to scan the QR code only once and you can re-use that instance everytime you need to send the WhatsApp message.urls.py
filefrom django.urls import path, include urlpatterns = [ path(r'', include('app1.urls', namespace="app1")), ]
(4) Views
In views.py
file, paste the below content. I will explain the code shortly.
import json import logging import os import random import string import sys import time from django.conf import settings from django.contrib import messages from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.models import User from django.http import HttpResponse, JsonResponse from django.shortcuts import render, redirect, render from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from selenium import webdriver from selenium.common.exceptions import ( UnexpectedAlertPresentException, NoSuchElementException, ) from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.ui import WebDriverWait from urllib.parse import quote from webdriver_manager.chrome import ChromeDriverManager BASE_URL = "https://web.whatsapp.com/" CHAT_URL = "https://web.whatsapp.com/send?phone={phone}&text&type=phone_number&app_absent=1" headers = {'content_type': 'application/json'} # keep session_id and object reference of all open browsers # implement LRU later active_sessions = { } def create_instance(request): chrome_options = Options() chrome_options.add_argument("start-maximized") user_data_dir = ''.join(random.choices(string.ascii_letters, k=8)) chrome_options.add_argument("--user-data-dir=/tmp/chrome-data/" + user_data_dir) chrome_options.add_argument("--incognito") browser = webdriver.Chrome( ChromeDriverManager().install(), options=chrome_options, ) handles = browser.window_handles for _, handle in enumerate(handles): if handle != browser.current_window_handle: browser.switch_to.window(handle) browser.close() browser.get(BASE_URL) browser.maximize_window() # print(browser.session_id) # print(browser.__dict__) """ Output: {'port': 0, 'vendor_prefix': 'goog', 'service': <selenium.webdriver.chrome.service.Service object at 0x7f909886db40>, 'command_executor': <selenium.webdriver.chromium.remote_connection.ChromiumRemoteConnection object at 0x7f90989d7c70>, '_is_remote': False, 'session_id': '3519b8069ac6cd1c15fa126c47659201', 'caps': {'acceptInsecureCerts': False, 'browserName': 'chrome', 'browserVersion': '107.0.5304.121', 'chrome': {'chromedriverVersion': '107.0.5304.62 (1eec40d3a5764881c92085aaee66d25075c159aa-refs/branch-heads/5304@{#942})', 'userDataDir': './User_Data'}, 'goog:chromeOptions': {'debuggerAddress': 'localhost:33499'}, 'networkConnectionEnabled': False, 'pageLoadStrategy': 'normal', 'platformName': 'linux', 'proxy': {}, 'setWindowRect': True, 'strictFileInteractability': False, 'timeouts': {'implicit': 0, 'pageLoad': 300000, 'script': 30000}, 'unhandledPromptBehavior': 'dismiss and notify', 'webauthn:extension:credBlob': True, 'webauthn:extension:largeBlob': True, 'webauthn:virtualAuthenticators': True}, 'pinned_scripts': {}, 'error_handler': <selenium.webdriver.remote.errorhandler.ErrorHandler object at 0x7f90989d7640>, '_switch_to': <selenium.webdriver.remote.switch_to.SwitchTo object at 0x7f90989d7b80>, '_mobile': <selenium.webdriver.remote.mobile.Mobile object at 0x7f90989d7940>, '_file_detector': <selenium.webdriver.remote.file_detector.LocalFileDetector object at 0x7f90989d7550>, '_authenticator_id': None} """ # this make sure we have a list of all active session. # also keeping a global reference of this session/browser stops it from closing. # otherwise browser will close automatically after this function exists session_id = browser.__dict__.get('session_id') print(session_id) active_sessions[session_id] = browser # browser is now ready with QR code. # let's wait someone to scan this QR in WA application and start a new session return JsonResponse({ 'instance': session_id }) @csrf_exempt def delete_instance(request, instance_id): if request.method == 'DELETE': instance = active_sessions.pop(instance_id, None) logging.getLogger("info").info("instance {instance} deleted".format(instance=instance)) response = { 'status': 'success', 'message': 'instance {instance} deleted successfully'.format(instance=instance_id) } return HttpResponse(json.dumps(response), headers=headers, status=200) else: return HttpResponse(status=405) def send_message(data): instance = data.get('instance', '') instance = instance.strip() if not instance: print('no instance found') return HttpResponse( json.dumps({ 'message': 'no instance found' }), headers=headers, status=400 ) phone = data.get('phone', '') phone = phone.strip('+') if not phone: print('no phone found') return HttpResponse( json.dumps({ 'message': 'no phone found' }), headers=headers, status=400 ) message = data.get('message', '') message = message.strip() if not message: print('no message found') return HttpResponse( json.dumps({ 'message': 'no message found' }), headers=headers, status=400 ) browser = active_sessions.get(instance, None) if not browser: print('no active instance found') return HttpResponse( json.dumps({ 'message': f'no active instance with instance id {instance} found'.format(instance=instance) }), headers=headers, status=400 ) # open chat URL to this number and wait for window/chats to load browser.get(CHAT_URL.format(phone=phone)) time.sleep(3) # find text input box inp_xpath = ( '//*[@id="main"]/footer/div[1]/div/span[2]/div/div[2]/div[1]/div/div[1]' ) input_box = WebDriverWait(browser, 60).until( expected_conditions.presence_of_element_located((By.XPATH, inp_xpath)) ) for line in message.split("\n"): input_box.send_keys(line) ActionChains(browser).key_down(Keys.SHIFT).key_down( Keys.ENTER ).key_up(Keys.ENTER).key_up(Keys.SHIFT).perform() input_box.send_keys(Keys.ENTER) logging.getLogger('info').info(f"Message sent successfully to {phone}") return HttpResponse( json.dumps({ 'status': 'success', 'message': 'Message sent successfully to {phone}'.format(phone=phone) }), headers=headers, status=200 ) @csrf_exempt def send(request): if request.method == 'GET': print(request.GET) data = request.GET return send_message(data) if request.method == 'POST': data = request.body # byte data = json.loads(data.decode('utf-8')) # json/dict return send_message(data) else: return HttpResponse(status=405)
(5) Logging
We are logging the info and errors in log files. For this to work, we need to define the logging configuration.
Create a file logger_settings.py
parallel to settings.py
file.
import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'formatters': { 'large': { 'format': '%(asctime)s %(levelname)s %(process)d %(pathname)s ' + '%(funcName)s %(lineno)d %(message)s ' }, 'small': { 'format': '%(asctime)s %(levelname)s %(pathname)s %(lineno)d %(message)s ' }, 'tiny': { 'format': '%(asctime)s %(message)s ' } }, 'handlers': { 'errors_file': { 'level': 'ERROR', 'class': 'logging.handlers.TimedRotatingFileHandler', 'when': 'midnight', 'interval': 1, 'filename': os.path.join(BASE_DIR, 'logs/errors.log'), 'formatter': 'large', }, 'warning_file': { 'level': 'WARNING', 'class': 'logging.handlers.TimedRotatingFileHandler', 'when': 'midnight', 'interval': 1, 'filename': os.path.join(BASE_DIR, 'logs/warnings.log'), 'formatter': 'large', }, 'info_file': { 'level': 'INFO', 'class': 'logging.handlers.TimedRotatingFileHandler', 'when': 'midnight', 'interval': 1, 'filename': os.path.join(BASE_DIR, 'logs/info.log'), 'formatter': 'large', }, }, 'loggers': { 'error': { 'handlers': ['errors_file'], 'level': 'WARNING', 'propagate': False, }, 'warning': { 'handlers': ['warning_file'], 'level': 'WARNING', 'propagate': False, }, 'info': { 'handlers': ['info_file'], 'level': 'INFO', 'propagate': False, }, }, }
logger_settings.py
file in settings.py
file.try: from .logger_settings import * except Exception as e: print("Unable to load logger settings") pass
(6) Running application
Django code is ready. Run the application using command python manage.py runserver
. If everything is correct, you will see below messages on terminal.
Watching for file changes with StatReloader Performing system checks... System check identified no issues (0 silenced). You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions. Run 'python manage.py migrate' to apply them. January 03, 2023 - 12:49:05 Django version 3.2.16, using settings 'wa.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C.
curl localhost:8000/instance/
This will start a new browser session in incognito mode and open the base URL of whatsapp web. A QR code will be shown. Scan this QR code in your WhatsApp app and link the devices.
Once device is linked, you can send the messages.
This API will return the JSON response containing the instance Id. We will use this instance Id in next CURL.
(8) Sending message
Use below curl to send the Whatsapp message.
curl --location --request POST 'localhost:8000/send/' \ --header 'Content-Type: application/json' \ --data-raw '{ "phone": "Phone number with country code (without +)", "message": "Hello Https://PythonCirle.com", "instance": "Instance Id here" }'
Replace the values in above curl with your phone number and instance Id.
You will see that the message is sent.
Note :- Message is sent using your account. Abusing this process may result in blocking your account. Please use wisely.
(9) Understanding the code.
I have tried to write a self explanatory code with proper comments and messages.
Create Instance method is trying to create a new browser instance. To keep the track of all active sessions so that we can re-use the once created session everytime a user wants to send the message with scanning QR code again and again, we will keep the reference of active session in a dictionary. Since the browser instance reference is still alive due to reference in dictionary, browser won't close automatically as it was happening in previous article.
Send method fetches the existing session from dictionary and uses it send the message.
Once you are done and no longer wants to send more messages, you can destroy the instance by calling destroy API as below.
curl -X DELETE localhost:8000/instance/<instance-id>/
These are REST APIs. APIs are returning JSON response.
You can enhance this project as per your requirements.
In the next article we will host this application on a remote server like digitalocean or pythonanywhere so that application keep running 24 hours.
Host your Django Application for free on PythonAnyWhere.
If you want complete control of your application and server, you should consider DigitalOcean. Create an account with this link and get $200 credits.