Having one way bulk SMS lines is great. In some cases though, you may want to have a chat application that run on Africa's Talking sms API (and handles spam messages ). In this post, I guide you into achieving this using python django. The logic, however, can be used with any programming language / framework. Before starting this tutorial, you'll need an AT account and postman installed on your workstation. You'll also need to have setup ngrok server. I'll go through a simple setup of ngrok server (if you are on linux. I am working on ubuntu 18.04)

Setting Up Africa's Talking

To be able to send two way SMSs, we need an AT shortcode and an API key. In this tutorial, we make use of the Sandbox app. Login to your AT account, click on Sandbox.

On the left pane, click on settings then click on API Key.

On the tab that comes up, you'll be required to put in your password so as to generate a token. Thats the Token you'll need to have the API working.

Once you have the token, we need to get an AT short-code to use. Click on SMS on the left pane. A drop down will appear. On that, click on Shortcodes then Create Short Code. From the tab that appears, you can now create the short code to use for this project.

Setting up Django

In this tutorial, we use django and the powerful django-rest-framework (DRF) library to create the backend REST API. To make it simple, we use SQLite for the DB backend but any Database engine would work. SQLite comes in with django as the default enabled DB backend.

To install DRF, run the command

pip install djangorestframework

Add rest_framework as an app to your django project INSTALLED_APPS

INSTALLED_APPS = [
    ...
    'rest_framework',
]

Once that is done, we need to create an app. I will use an app called sms. To create the app, run the following command from your project's root folder.

python3 manage.py startapp sms

Lets now run our initial migrations that come with django. Run the command

python3 migrate

Since we will need access to the django admin for this project, we need to create a super user. Run the command

python3 manage.py createsuperuser

Run the django project using python3 manage.py runserver . Check if your server is up and running. If it then you are good to go. To login to the admin (I'm assuming your project is running on the default port 8000, otherwise use your port) naviage to  and login localhost:8000/admin using the superuser you created.

SMS app folder structure

sms
|
|__migrations
|__ __init__.py
|__ admin.py
|__ apps.py
|__ at_service.py
|__ models.py
|__ serializers.py
|__ tests.py
|__ views.py

models.py

We need to setup our models. We will have two models: Message and PhoneBook. The Message model stores our messages while the PhoneBook model stores our contacts. In your sms app models, define the models as follows:

from django.db import models

# Create your models here.
class Message(models.Model):
    message = models.TextField()
    sender = models.CharField(max_length=100)
    receiver = models.CharField(max_length=100)
    at_id = models.CharField(max_length=100)
    status = models.CharField(null=True, blank=True, max_length=100)
    delivery = models.CharField(null=True, blank=True, max_length=100)
    logged_at = models.DateTimeField(auto_now_add=True)
    delivered_at = models.DateTimeField(null=True, blank=True)
    failure_reason = models.CharField(null=True, blank=True, max_length=100)

class PhoneBook(models.Model):
    name = models.CharField(max_length=100)
    phone_number = models.CharField(max_length=100)

Message Model fields:

  • message stores our message text
  • sender is the phone number of the sender
  • receiver is the phone number of the receiver
  • at_id is the message ID as given by AT
  • status is the message status as given by AT
  • delivery is the delivery status as provided by AT
  • logged_at is the time we sent or received the message
  • delivered_at is the time the message was delivered
  • failure_reason is the reason for message delivery failure in case delivery fails

Click here to read more about message statuses. The PhoneBook model simply contains name and phone number. To run the migrations, run the command

python3 manage.py makemigrations --name=create_initial_models then migrate using the command python3 manage.py migrate.

at_service.py

In this file, we will create our code that uses the AT sms API. To begin, we need to install the Africa's Talking python library.

pip3 install africastalking 

or

pip  install africastalking

Our at_service file will simply entail a function that sends SMS.

from django.conf import settings
import africastalking

def send_sms(payload: str):

    username = settings.AFRICASTALKING["username"]
    api_key = settings.AFRICASTALKING["key"]
    
    africastalking.initialize(username, api_key)

    # Initialize a service e.g. SMS
    sms = africastalking.SMS

    recipients = [payload.get('receiver')]
    sender = payload.get('sender')
    message = payload.get('message')

    return sms.send(message, recipients, sender)

The username and api_key are loaded from environment variables for safety reasons. We then initialize the SMS service. After the initialization, we use the send method to send the message and return the outcome.

Serializers

Next, we need to create our serializer class to handle our API data serializations. The file looks like:

from rest_framework import serializers

class MessageSerializer(serializers.Serializer):
    message = serializers.CharField()
    sender = serializers.CharField()
    receiver = serializers.CharField()

We  use the basic serializers.Serializer instead of serializers.ModelSerializer. The basic serializer class has less overhead. To read more on this, refer here. The serializer class here simply serialized the data into an object containing message, sender and receiver fields. Notice that even the receiver and sender are of charfield type. This is because AT expects phone numbers in international format hence using integer fields wont work.

Views

In the views.py file we define our routes. In this example, I use the viewsets approach. If you are comfortable with Class Based Views or Functional Based Views you can still use them. Viewsets make code more concise.

We begin by importing the necessary files. The usage for each will be clearer in the remainder part of the code

from django.utils import timezone
from django.db.models import Q
from rest_framework import status, viewsets
from rest_framework.response import Response
from rest_framework.decorators import action

from sms.models import Message, PhoneBook
from sms.at_service import send_sms
from sms.serializers import MessageSerializer

Next, we create the viewset and begin with a create method. This is a POST endpoint. We intend to use this endpoint as the endpoint that a client (like a web app or mobile app) uses when sending a message.

class MessageViewset(viewsets.ViewSet):

    def create(self, request):
        
        # serialize the data using the message serializer
        serializer = MessageSerializer(data=request.data)

        # check whether the serialization has passed
        if not serializer.is_valid():
            return Response(
                {"details": serializer.errors, "code": 400},
                status=status.HTTP_400_BAD_REQUEST
            )

        # use the at_service that we created before to send the sms
        at_response = send_sms(serializer.validated_data)

        # Response from AT contains a recipients key if it was succesful
        if at_response["SMSMessageData"]["Recipients"]:
            receiver = serializer.validated_data["receiver"]
            sender = serializer.validated_data["sender"]

            # after succesful sending of the message, 
            # store the message
            # in the Message table of our db
            Message.objects.create(
                message=serializer.validated_data.get("message"),
                sender=serializer.validated_data.get("sender"),
                receiver=serializer.validated_data.get("receiver"),
                at_id=at_response["SMSMessageData"]["Recipients"][0]["messageId"],
                status=at_response["SMSMessageData"]["Recipients"][0]["status"]
            )

            # return rhe response
            return Response(
                {
                    "details": "Success",
                    "status": at_response["SMSMessageData"]["Recipients"][0]["status"],
                },
                status=status.HTTP_200_OK,
            )
        else:
            # if the sms wasnt sent succesfully 
            return Response(
                {"details": at_response["SMSMessageData"].get("Message")},
                status=status.HTTP_400_BAD_REQUEST,
            ) 

If you look at how we are retrieving data from AT, we use a format like at_response["SMSMessageData"]["Recipients"][0]["status"] when extracting the status from the response. The reason we have the index ([0]) is because the response comes in a list. Were we sending multiple messages at once, the response would also have more than one item in the list but since its a single message, only one item is contained in at_response["SMSMessageData"]["Recipients"] The response from AT looks like below on successful send. In case of an error, the error message will be in the Message key and Recipients wont be present.

{
    "SMSMessageData": {
        "Message": "Sent to 1/1 Total Cost: KES 0.8000",
        "Recipients": [{
            "statusCode": 101,
            "number": "+254711XXXYYY",
            "status": "Success",
            "cost": "KES 0.8000",
            "messageId": "ATPid_SampleTxnId123"
        }]
    }
}

Next, since this is a two way SMS, we need to have an endpoint that handles incoming messages. This endpoint will be used by AT as a callback to send us incoming messages. It is a POST endpoint.

    @action(methods=["POST"], detail=False)
    def incomingCallback(self, request):

        # convert payload into normal dict
        payload = request.data.dict()
        
        # if the number sending us the message is not in our phonebook
        # we block it
        if not PhoneBook.objects.filter(phone_number=payload["from"]).exists():

            sms_payload = {
                'message': 'Unfortunately, you texted a wrong number',
                'receiver': payload['from'],
                'sender': payload['to']
            }
            send_sms(sms_payload)

            return Response(status=status.HTTP_202_ACCEPTED)
        
        else:

            Message.objects.create(
                receiver=payload["to"],
                sender=payload["from"],
                message=payload["text"],
                at_id=payload["id"],
                status="INBOX",
            )

            return Response(status=status.HTTP_202_ACCEPTED)

One of the big issues with incoming messages is that anyone can send you a message. This means that if you have an application where you just want people on your phonebook to be the only ones able to send you a message, then you have to block the numbers not in your phonebook as these are SPAM messages. However, if you want it open to anyone, then you can skip that. In our scenario, if its a SPAM, then we send back a message saying that the sender texted a wrong number.

We also need an endpoint to cover delivery reports from AT. Since we store our messages, we need to know the status of our messages so as to know whether or not they were delivered. This endpoint is a POST endpoint to which AT sends delivery notifications hence we will also use it as a callback

    @action(methods=["POST"], detail=False)
    def deliveryCallback(self, request):

        payload = request.data.dict()

        message = Message.objects.filter(at_id=payload["id"]).values()

        if message:
            message.update(
                delivery=payload.get("status"),
                delivered_at=timezone.now(),
                failure_reason=payload.get("failureReason"),
            )

        return Response(status=status.HTTP_202_ACCEPTED)


The reason why we convert the incoming payload into a dict is because the incoming payload is a querydict and not a native python dictionary hence we need to convert it to a native python dictionary. As you can see, we filter the message using the at_id so as to update that specific message. In-case there is a failure , the reason for the same will also be logged. We use payload.get() since we dont always have a failure reason. This sets it to None if there's no failure reason thus preventing a dict key exception being raised.

Lastly, we need a GET endpoint to get messages. This endpoint gets all messages in our database and can also get by phone number by passing a query parameter phone_number.

    def list(self, request):

        self.phoneNumber = self.request.GET.get("phoneNumber", None)

        queryset = Message.objects.values().order_by("~logged_at")

        if self.phoneNumber:
            from django.db.models import Q

            queryset = queryset.filter(
                Q(receiver=self.phoneNumber) | Q(sender=self.phoneNumber)
            )

        return Response({"details": queryset}, status=status.HTTP_200_OK)

We use django Q objects to filter out using the OR condition. We also order by the logged_at field as this is the time the message was sent / received. This means that the frontend app gets messages ordered based on time.

Thats is with views. We now need to register the routes. Typically, rather than explicitly registering the views in a viewset in the urlconf, you'll register the viewset with a router class, that automatically determines the urlconf for you. In your project folder, create a file called router.py and add in the following code. To understand more on this click here.

from rest_framework import routers

from sms import views as sms

router = routers.DefaultRouter()

router.register("sms", sms.MessageViewset, basename="sms")

Then on the project level urls.py file, let your code look like

from django.contrib import admin
from django.urls import include, path
from atShortCode.router import router

urlpatterns = [
    path('admin/', admin.site.urls),
    path("api/", include((router.urls, "sms"))),
]

Setup AT Callbacks

First, ensure your local django server is running. Then install ngrok. If you are on ubuntu like me, run sudo snap install ngrok. To start ngrok, run ngrok http 8000. 8000 is my port number since my app is running locally on port 8000.

You will get a url that looks something like this, http://055b7c4661d7.ngrok.io Load this link in the browser to see if its working.

At this point, we now need to add our callback urls to AT. On the left pane of your AT dashboard (while on the sandbox app) click on SMS -> SMS Callback Urls -> Delivery Reports and add the delivery callback url. In my case, I will put

http://055b7c4661d7.ngrok.io/api/sms/deliveryCallback/  

For the incoming messages callback, click on SMS -> SMS Callback Urls -> Incoming Messages and add the delivery callback url. In my case, I will put

http://055b7c4661d7.ngrok.io/api/sms/incomingCallback/

Notice that I am using the ngrok base url. In a live prod environment I'd use my server url. Also take note of the trailing backslash at the end of the url. Django doesnt like POST requests that go to endpoints without the trailing backslash

Trying it Out

Launch your simulator from the dashboard by clicking on the Launch Simulator You'll be asked to add a number. Add your test number and dont lose it.

After adding your number, login to your django admin and add the contact to your phone book model.

To simulate sending a message to the phone number, we will use postman. This however will typically be a request from a client app like a mobile or web app. Make a request on post man to the ngrok endpoint http://055b7c4661d7.ngrok.io/api/sms/ It is a POST request.

You should be able to see this message on the simulator. You can also test the incoming message by sending a message to your shortcode from the simulator. If all goes well, you should be able to see the message in your database.

You can find the code base for this on Github repo here.

Cheers and Happy Coding :-)

You've successfully subscribed to Decoded For Devs
Welcome back! You've successfully signed in.
Great! You've successfully signed up.
Your link has expired
Success! Your account is fully activated, you now have access to all content.