Simple Chat using Python Flask API

I have been working on a project and using Flask API with Socket.io. This API supports rooms as a built in feature – so making a chat becomes very easy. I decided to create a chat with private chat feature and multiple rooms and see how difficult it would be to implement. Here is the final product below.

SocketIO vs. WebSocket

One thing that is always confusing is the difference between WebSocket and SocketIO. They both have same kind of application. WebSocket is a low level API that opens a two way TCP channel between the client (normally web browser) and server. Socket.io is built on top of WebSocket. However in case WebSocket is not available, it can also fallback to other mechanisms for the two way communication e.g. Ajax long polling. For ease of development socket.io is the way to go. It will auto connect when connection is dropped. It allows for message broadcast as well as ‘rooms’ are available by default. Socket.io also supports ‘namespaces’ for logical message separation over the same TCP channel.

We can leverage the ‘rooms’ available by default in our chat application. This will allow us to open up multiple group chats within the same server.

Installation

Apart for socket.io, we will also use Flask for Python for this project. So we will be using the Flask wrapper for socket.io.

We will need to install the following additional Python libraries (use pip to install).

  • Flask
  • Flask-socketio
  • Eventlet

Design Goals

Let’s start with the design goals. We will use the following.

  • Chat will support any number of users
  • Users can log into any rooms they want to create
  • Chat will also be within the same room
  • At any point of time there can be more that one room available
  • We should be able to do a one-to-one private chat within a room
  • Users can send messages and images (no other files will be supported)

Let’s start by creating a new Python project. As always, I will check in the POC code to my Github public account.

Python Server

We will start by importing all required libraries and initializing globals. This will include creating an instance of new Flask application and SocketIO.

import json
import logging
import eventlet
from flask import Flask, request
from flask_socketio import SocketIO, send, join_room, leave_room


PORT = 5005
eventlet.monkey_patch()
app = Flask(__name__)
app.config['SECRET_KEY'] = 'abc123def456'
sio = SocketIO(app, cors_allowed_origins='*', async_mode='eventlet')

users = {}

We have to maintain a list of users who have joined the chat and also what rooms they belong to. For this we have a dictionary defined as users. Next we write a few utility methods that is used to get user information.

"""
Adds a new user to global dictionary
    username: Handle for this user
    roomnane: Room of the user
    sid: Session ID for the user
"""
def add_user(username, roomname, sid):
    user = {'name': username.upper(), 'room': roomname.upper(), 'sid': sid}
    join_room(roomname)
    users[sid] = user


"""
Gets a user based on the session ID
    sid: SID for the user
"""
def get_user_by_sid(sid):
    if sid in users:
        return users[sid]
    return None


"""
gets a user based on user handle
    name: Handle for this user
"""
def get_user_by_name(name):
    for key, value in users.items():
        if value['name'] == name.upper():
            return value
    return None


"""
Deletes a user from global dictionary
    roomnane: Room of the user
    sid: SID for the user
"""
def del_user(sid, roomname):
    elm = None
    if sid in users:
        elm = users[sid]
        leave_room(roomname)
        del users[sid]
    return elm


"""
Gets all users in a Room
    roomnane: Room of the user
"""
def get_all_users(roomname):
    all_users = []
    for key, value in users.items():
        if value['room'] == roomname.upper():
            all_users.append(value['name'])
    return all_users 
Chat message formats

Before proceeding further, let’s define the message formats used for chat communication. We will need to handle several types of messages. There will be messages about user joining or leaving, messages dealing with what users are in a room, messages for chat and transferring images.

The first type of message to handle is users joining and leaving rooms. In this case we will be sending the user and room. We also define an event tag that will be used to differentiate between various messages.

{
    "event": "join/ leave",
    "user": "<username>",
    "room": "<roomname>"
}

The second message we define is relatively simpler. We use this message to show all users in a room.

{
    "event": "users",
    "room": "<roomname>"
}

Now we define a message to send chat message. This will have both room name and username. API will send user tag for a private one-to-message. In this case, user tag will contain the requested user handle. If neither user or room is sent, this message will be broadcast.

{
    "event": "chat",
    "user": "<@ username>",
    "room": "<roomname>",
    "message": "<message>"
}

Last but not the least, we will define the message format for sending images. Tag bimage is used to send a base64 image source.

{
    "event": "image",
    "room": "<roomname>",
    "bimage": "<base64 image>"
}

Now that all of these is out of the way, we can concentrate on the main program loop.

SocketIO Event Handling
@sio.on('chat')
def handle_chat(message):
    logger.debug('Chat for session % received: %s' % (request.sid, message))
    try:
        evt = message['event'] if 'event' in message else 'users'
        if evt == 'join':
            user = message['user'] if 'user' in message else 'JDOE'
            room = message['room'] if 'room' in message else 'PARKED'
            add_user(user, room, request.sid)
            msg2send = {
                'type': 'newuser',
                'message': 'New User %s joined room %s' % (user, room)
            }
            sio.emit('chat', msg2send, room=room)
        elif evt == 'leave':
            user = message['user'] if 'user' in message else 'JDOE'
            room = message['room'] if 'room' in message else 'PARKED'
            del_user(request.sid, room)
            msg2send = {
                'type': 'deluser',
                'message': 'User %s left room %s' % (user, room)
            }
            sio.emit('chat', msg2send, room=room)
        elif evt == 'users':
            room = message['room'] if 'room' in message else 'PARKED'
            all_users = get_all_users(room)
            msg2send = {
                'type': 'users',
                'message': 'Users in room: ' + str(all_users)
            }
            sio.emit('chat', msg2send, room=request.sid)
        elif evt == 'chat':
            me = get_user_by_sid(request.sid)
            room = message['room'] if 'room' in message else None
            user = message['user'] if 'user' in message else None
            text = message['text'] if 'text' in message else 'Knock! Knock!'
            msg2send = {
                'type': 'chat',
                'from': me['name'],
                'message': text
            }
            if room is None:
                # Broadcast
                logger.debug('Broadcasting message...')
                sio.emit('chat', msg2send, skip_sid=request.sid, broadcast=True)
            else:
                if user is None:
                    # Send to everyone in room
                    sio.emit('chat', msg2send, skip_sid=request.sid, room=room)
                else:
                    elm = get_user_by_name(user)
                    if elm is not None:
                        logger.debug('Sending chat message to user %s at socket: %s' % (user, elm['sid']))
                        sio.emit('chat', msg2send, room=elm['sid'], skip_sid=request.sid)
                    else:
                        logger.error('User requested %s, not found...' % user)
        elif evt == 'image':
            me = get_user_by_sid(request.sid)
            msg2send = {
                'type': 'image',
                'from': me['name'],
                'bimage': message['bimage']
            }
            sio.emit('chat', msg2send, skip_sid=request.sid, room=message['room'])
    except json.decoder.JSONDecodeError as jse:
        send(json.dumps({ 'code': 99, 'message': 'JSON Parse failed' }))

This is the chat server code. I have enabled line numbers so that it is easier to reference. Join event starts at line 6. Here we add the user to the room and also to our dictionary. Conversely, for leave event, we will delete the user from dictionary and force them out of the room.

Chat event starts at line 32. This is where all text chat happens. If no room is present in the message, we will broadcast this text. If a room is found and also an @ user, we will only send this text to the requested user. See line 54. For all other cases, we will send the message to all recipients in the room (except sender).

Image starts at line 57. This is fairly basic. It takes the message and forwards it across to all recipients in the room (except sender).

Client

I decided to put the client in a separate JavaScript/ HTML page instead of using a template location from Flask. The primary reason for this is that in production you will normally server static pages from nginx.

 <body>
    <div class="container">
        <div class="userlogin">
            <label for="username">Name</label>
            <input type="text" id="username" placeholder="User Name" onkeyup="this.value = this.value.toUpperCase();"></text>
            <input type="text" id="roomname" placeholder="Room Name" onkeyup="this.value = this.value.toUpperCase();"></text>
            <br />
            <button id="btnJoin">Join</button>
            <button id="btnLeave">Leave</button>
            <button id="btnUsers">Users</button>
        </div>
        <hr />
        <div class="userlogin">
            <label for="sendmsg">Message</label>
            <input type="text" id="sendmsg" placeholder="Send Message"></text>
            <button id="btnSendMsg">Send</button>
            <input type="file" id="imgfile" class="file" accept="image/*">
            <button id="btnSendAtt">Image</button>
        </div>
        <div id="chatbox" class="chatbox"></div>
    </div>
</body>

We distribute the space in two sections. The top section will be used for user login. We will have user handle and room entry. Second section will be the real chat section. This is where user will be communicating with other users. Additionally there is a button to send images. The space below is used to show all chat messages.

We have included the following two javascript libraries.

<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/3.1.1/socket.io.min.js"></script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>

We open socketio as soon as the page loads. Rest of the events are as defined below.

var connectWs = function() {
    const sockt = io(SOCKS_URL, {
        reconnectionDelayMax: 10000
    });
    sockt.on('connect', () => {
        connected = true;
    });
    sockt.on('disconnect', () => {
        connected = false;
    });
    sockt.on('chat', (data) => {
        if (data.type === 'user' || data.type === 'users' ||
                data.type === 'newuser' || data.type === 'deluser') {
            addInfo(data.message);
        } else if (data.type === 'chat') {
            addMessage(data.from, data.message, true);
        } else if (data.type === 'image') {
            addImage(data.from, data.bimage, true);
        }
    });
    sockt.on('message', (data) => {
        console.log(data);
    });

    return sockt;
}

I am adding all the implementations below.

$("#btnJoin").click(function() {
    user = $("#username").val().trim();
    room = $("#roomname").val().trim();
    if (user === '' || room === '') {
        alert('Both room and user names are mandatory');
    } else {
        // Validate
        jsn = {
            'event': 'join',
            'user': user,
            'room': room
        };
        socks.emit('chat', jsn);
        $("#btnJoin").prop("disabled", true);
        $("#username").prop("readonly", true);
        $("#roomname").prop("readonly", true);
    }
});

$("#btnLeave").click(function() {
    user = $("#username").val().trim();
    room = $("#roomname").val().trim();
    if (user === '' || room === '') {
        alert('Both room and user names are mandatory');
    } else {
        // Validate
        jsn = {
            'event': 'leave',
            'user': user,
            'room': room
        };
        socks.emit('chat', jsn);
        $("#btnJoin").prop("disabled", false);
        $("#username").prop("readonly", false);
        $("#roomname").prop("readonly", false);
    }
});

Join and Leave works pretty much the same way. In case of join we send the user handle and room name to the server for adding to the chat. For leave request, we also send similar request. However, in this case the event sent is leave.

$("#btnSendMsg").click(function() {
    if (!connected) {
        alert('No socket connection available!');
    } else {
        msg = $("#sendmsg").val().trim();
        addMessage("YOU", msg);
        $("#sendmsg").val('');
        if (msg.startsWith('@')) {
            user = msg.substring(1, msg.indexOf(' '));
            jsn = {
                'event': 'chat',
                'user': user.toUpperCase(),
                'room': $("#roomname").val(),
                'text': msg.substring(msg.indexOf(' ') + 1, msg.length + 1)
            };
            socks.emit('chat', jsn);
        } else {
            jsn = {
                'event': 'chat',
                'room': $("#roomname").val(),
                'text': msg
            };
            socks.emit('chat', jsn);
        }
    }
});

For sending messages, we check to see if this is a private chat. If we find a @ sign, we will extract the user handle following it. We send this user to the API as the to user.

$('#btnSendAtt').click(function(e) {
    $('#imgfile').trigger('click');
    console.log($('#imgfile'));
});

$('#imgfile').change(function(e) {
    var fileobj = e.target.files[0]
    filename = fileobj.name;
    filemime = fileobj.type;
    if (!filemime.startsWith("image/")) {
        alert('Only Images supported at this time');
    } else {
        var reader = new FileReader();
        reader.onload = function(e) {
            addImage("YOU", e.target.result);

            jsn = {
                'event': 'image',
                'room': $("#roomname").val(),
                'bimage': e.target.result
            };
            socks.emit('chat', jsn);
        };
        reader.readAsDataURL(fileobj);
    }
});

This final code is for sending images. We read the image and send it to the API base64 encoded.

Conclusion

That is all required for a chat implementation. Even though it looks like a lot of code, it does not take too much time to implement. Ciao for now!