Build a chat app in Flask and Vue with sentiment analysis - Part 3: Live chat with sentiment analysis

Introduction

In this third tutorial, we will be implementing live chat and sentiment analysis.

If you haven’t followed the previous parts, you can catch it up here:

Chatting over channels

Pusher Channels provides us with realtime functionalities. It has a publish/subscribe model where communication happens across channels. There are different types of channel, which we can subscribe to: public channel, private channel, presence channel and the encrypted channel.

For our app, we will make use of the private channel since the chat messages need to be only accessible by the two users involved. This way we can authenticate a channels’ subscription to make sure users subscribing to it are actually authorized to do so. When naming your private channel, it needs to have a prefix of “private-”.

The flow

Once a user logs in, we'll redirect the user to the chat page. Then we subscribe this user to a private channel - private-notification-<the_user_id>, where <the_user_id> is the actual ID of the logged in user. This channel will be used to send notifications to the user. So that means every logged in user will have a private notification channel where we can notify them anytime we want to.

After subscribing to the notifications channel (“private-notification-<the_user_id>”), we will start to listen for an event we will name new_chat. We'll trigger this event once a user clicks on another user they want to chat with. Also, we'll send along data that looks like below when triggering this event:

1{
2      from_user, // ID of the user initiating the chat
3      to_user, // ID of the other user
4      from_user_notification_channel,// notificaction channel for the user intiating the chat
5      to_user_notification_channel, // notificaction channel of the other user
6      channel_name, // The channel name where both can chat
7    }

In the data above, we have:

  • from_user — The user that triggered the event (the user starting the conversation).
  • to_user — The other user.
  • from_user_notification_channel — Notification channel for the user initiating the chat (for example private-notification-1).
  • to_user_notification_channel — Notification channel for the other user (for example private-notification-2).
  • channel_name — The channel where both can exchange messages.

The notification channels for users is unique since we are making use of their IDs.

How do we generate the channel_name?

We need a way to generate a channel name for the two users since they need to be on the same channel to chat. Also, the name should not be re-used by other users. To do this we’ll use a simple convention to name the channel - "private-chat_<from_user>_<to_user>" (for example "private-chat_1_2"). Once we get the channel we’ll store it in the channels table. So subsequently, before we generate a new channel name, we’ll query the database to check if there is an already generated channel for the users and use that instead.

After getting the channel_name, we’ll notify the other user (to_user_notification_channel) by triggering the new_chat event.

Once a user receives the new_chat event, we’ll subscribe that user to the channel name we got from the event and then start to listen for another event we’ll name new_message on the channel we just subscribed to. The new_message event will be triggered when a user types and submits a message.

This way, we’ll be able to subscribe users to channels dynamically so they can receive messages from any number of users at a time. Let’s go ahead and write the code.

Coding the Server

First, initialize Pusher’s Python library by adding the following code to the api/app.py file right after the app = Flask(__name__) line of code:

1# api/app.py
2    
3    # [...]
4    
5    pusher = pusher.Pusher(
6        app_id=os.getenv('PUSHER_APP_ID'),
7        key=os.getenv('PUSHER_KEY'),
8        secret=os.getenv('PUSHER_SECRET'),
9        cluster=os.getenv('PUSHER_CLUSTER'),
10        ssl=True)
11    
12    # [...]

We already have our login and register endpoint ready from part two. We still need to create several endpoints:

  • /api/request_chat we will use this endpoint to generate a channel name where both users can communicate.
  • /api/pusher/auth the endpoint for authenticating Pusher Channels subscription
  • /api/send_message the endpoint to send message across users.
  • /api/users endpoint for getting all users from the database.
  • /api/get_message/<channel_id> we’ll use this endpoint to get all messages in a particular channel.

Request chat

We’ll make a request to the /api/request_chat endpoint to generate a channel name when users want to chat.

Recall, every user on our chat will have their private channel. To keep things simple, we used "private-notification_user_<user_id>" to name the channel. Where <user_id> is the ID of that user in the users table. This way every users will have a unique channel name we can use to notify them.

When users want to chat, they need to be on the same channel. We need a way to generate a unique channel name for both of them to use. This endpoint will generate such channel as "private-chat_<from_user>_<to_user>", where from_user is the user ID of the user initiating the chat and to_user is the user ID of the other user. Once we generate the channel name, we will store it to our channels table. Now if the two users want to chat again, we don't need to generate a channel name again, we'll fetch the first generated channel name we stored in the database.

After the first user generates the channel name, we’ll notify the other users on their private channel, sending them the channel name so they can subscribe to it.

Add the below code to api/app.py to create the endpoint:

1# api/app.py
2    [...]
3    @app.route('/api/request_chat', methods=["POST"])
4    @jwt_required
5    def request_chat():
6        request_data = request.get_json()
7        from_user = request_data.get('from_user', '')
8        to_user = request_data.get('to_user', '')
9        to_user_channel = "private-notification_user_%s" %(to_user)
10        from_user_channel = "private-notification_user_%s" %(from_user)
11        
12        # check if there is a channel that already exists between this two user
13        channel = Channel.query.filter( Channel.from_user.in_([from_user, to_user]) ) \
14                               .filter( Channel.to_user.in_([from_user, to_user]) ) \
15                               .first()
16        if not channel:
17            # Generate a channel...
18            chat_channel = "private-chat_%s_%s" %(from_user, to_user)
19            
20            new_channel = Channel()
21            new_channel.from_user = from_user
22            new_channel.to_user = to_user
23            new_channel.name = chat_channel
24            db_session.add(new_channel)
25            db_session.commit()
26        else:
27            # Use the channel name stored on the database
28            chat_channel = channel.name
29               
30        data = {
31            "from_user": from_user,
32            "to_user": to_user,
33            "from_user_notification_channel": from_user_channel,
34            "to_user_notification_channel": to_user_channel,
35            "channel_name": chat_channel,
36        }
37        
38        # Trigger an event to the other user
39        pusher.trigger(to_user_channel, 'new_chat', data)
40        
41        return jsonify(data)
42    [...]

In the preceding code:

  • First of all, we created a route named /api/request_chat where users can get a channel name where they can chat.
  • We also protected the route to check for JWT token using @jwt_required.
  • Next, we get the ID of the user initiating the chat and the ID of the other participating user.
  • Next, we check if there is already a chat channel created for the two users in the database. If the channel name already exists, we return the channel else we generate a new channel for the users then save it to the database.
  • Then using pusher.trigger(), we trigger an event named new_chat to the other user’s private channel.
  • Finally, we return a JSON object containing the details of the channel name created.

Authenticate Channel subscriptions

Since we are using a private channel, we need to authenticate every user subscribing to the channel. We’ll make a request to the /api/pusher/auth endpoint to authenticate channels.

Add the below code to create the endpoint to authenticate channels in api/app.py.

1# api/app.py
2    [...]
3    @app.route("/api/pusher/auth", methods=['POST'])
4    @jwt_required
5    def pusher_authentication():
6        channel_name = request.form.get('channel_name')
7        socket_id = request.form.get('socket_id')
8    
9        auth = pusher.authenticate(
10            channel=channel_name,
11            socket_id=socket_id
12        )
13        
14        return jsonify(auth)
15    [...]

Pusher will make a request to this endpoint to authenticate channels, passing along the channel name and socket_id of the logged in user. Then, we call pusher.authenticate() to authenticate the channel.

Sending messages

When a user sends a message, we’ll save the message to the database and notify the other user. We’ll make a request to the /api/send_message endpoint for sending messages.

Add the following code to api/app.py.

1# api/app.py
2    [...]
3    @app.route("/api/send_message", methods=["POST"])
4    @jwt_required
5    def send_message():
6        request_data = request.get_json()
7        from_user = request_data.get('from_user', '')
8        to_user = request_data.get('to_user', '')
9        message = request_data.get('message', '')
10        channel = request_data.get('channel')
11        
12        new_message = Message(message=message, channel_id=channel)
13        new_message.from_user = from_user
14        new_message.to_user = to_user
15        db_session.add(new_message)
16        db_session.commit()
17        
18        message = {
19            "from_user": from_user,
20            "to_user": to_user,
21            "message": message,
22            "channel": channel
23        }
24        
25        # Trigger an event to the other user
26        pusher.trigger(channel, 'new_message', message)
27        
28        return jsonify(message)
29    [...]
  • We created a POST request route which expects some data to be sent along:
    • from_user - The user sending the message.
    • to_user - The other user on the chat receiving the message.
    • message - The chat message.
    • channel - The channel name where both of the users are subscribed to.
  • Next, we save the data to the database using the Message() class.
  • Then finally, we trigger an event named new_message to the channel name that will be sent from the request data and then return the information as JSON.

Get all users

We’ll make a request to the /api/users endpoint to get all users. Add the below code to api/app.py:

1# api/app.py
2    [...]
3    @app.route('/api/users')
4    @jwt_required
5    def users():
6        users = User.query.all()
7        return jsonify(
8            [{"id": user.id, "userName": user.username} for user in users]
9        ), 200
10    [...]

Get messages from a channel

We’ll make a request to the /api/get_message/<channel_id> endpoint to get all messages sent in a channel. Add the below code to api/app.py:

1# api/app.py
2    [...]
3    @app.route('/api/get_message/<channel_id>')
4    @jwt_required
5    def user_messages(channel_id):
6        messages = Message.query.filter( Message.channel_id == channel_id ).all()
7    
8        return jsonify([
9            {
10                "id": message.id,
11                "message": message.message, 
12                "to_user": message.to_user,
13                "channel_id": message.channel_id,  
14                "from_user": message.from_user, 
15            } 
16            for message in messages
17        ])
18    [...]

Coding the Client

Authenticate users

On our current view, we have the login form and the chat interface visible at the same time. Let’s make the login form only visible when the user is not logged in.

To fix it, add a condition to check if the user is authenticated in src/App.vue:

1// ./src/App.vue
2    
3    [...]
4    <Login v-if="!authenticated" v-on:authenticated="setAuthenticated" />
5    <b-container v-else>
6    [...]

We are using a v-if directive to check if authenticated is false so we can render the login component only. Since authenticated is not defined yet, it will resolve to undefined which is false, which is ok for now.

Load up the app on your browser to confirm that only the login form is visible.

Next, update the src/components/Login.vue component with the below code to log users in:

1// ./src/components/Login.vue
2    
3    [...]
4    <script>
5    export default {
6      name: "Login",
7      data() {
8        return {
9          username: "",
10          password: "",
11          proccessing: false,
12          message: ""
13        };
14      },
15      methods: {
16        login: function() {
17          this.loading = true;
18          this.axios
19            .post("/api/login", {
20              username: this.username,
21              password: this.password
22            })
23            .then(response => {
24              if (response.data.status == "success") {
25                this.proccessing = false;
26                this.$emit("authenticated", true, response.data.data);
27              } else {
28                this.message = "Login Faild, try again";
29              }
30            })
31            .catch(error => {
32              this.message = "Login Faild, try again";
33              this.proccessing = false;
34            });
35        }
36      }
37    };
38    </script>
39    [...]

In the preceding code:

  • We are making a POST request to /api/login to authenticate our users.
  • If the login was successful, we’ll emit an event named authenticated so we can act on it in the src/App.vue file. We also passed some data in the event:
    • true - to indicate the login was successful
    • response.data.data - contains details of the logged in user

Next, add some state of the src/App.vue file in the <script> section:

1// ./src/App.vue
2    
3    [...] 
4      data: function() {
5        return {
6          messages: {},
7          users: [],
8          active_chat_id: null,
9          active_chat_index: null,
10          logged_user_id: null,
11          logged_user_username: null,
12          current_chat_channel: null,
13          authenticated: false
14        };
15      },
16    [...]

So that the entire <script> section looks like below:

1// ./App.vue
2    
3    import MessageInput from "./components/MessageInput.vue";
4    import Messages from "./components/Messages.vue";
5    import NavBar from "./components/NavBar.vue";
6    import Login from "./components/Login.vue";
7    import Users from "./components/Users.vue";
8    import Pusher from "pusher-js";
9    
10    let pusher;
11    
12    export default {
13      name: "app",
14      components: {
15        MessageInput,
16        NavBar,
17        Messages,
18        Users,
19        Login
20      },
21      data: function() {
22        return {
23          authenticated: false,
24          messages: {},
25          users: [],
26          active_chat_id: null,
27          active_chat_index: null,
28          logged_user_id: null,
29          logged_user_username: null,
30          current_chat_channel: null
31        };
32      },
33      methods: {},
34    };

We defined some default states of data which we will use. For example, we’ll use the authenticated: false state to check if a user is authenticated or not.

Recall that in the Login component, we emitted an event when a user logs in successfully. Now we need to listen to that event on the src/App.vue component so as to update the users states.

Add a function to set authenticated users information to src/App.vue in the methods block:

1// ./src/App.vue
2    
3    [...]
4      data: function() {
5        return {
6          authenticated: false,
7          messages: {},
8          users: [],
9          active_chat_id: null,
10          active_chat_index: null,
11          logged_user_id: null,
12          logged_user_username: null,
13          current_chat_channel: null
14        };
15      },
16      methods: {
17        async setAuthenticated(login_status, user_data) {
18          
19          // Update the states
20          this.logged_user_id = user_data.id;
21          this.logged_user_username = user_data.username;
22          this.authenticated = login_status;
23          this.token = user_data.token;
24          
25          // Initialize Pusher JavaScript library
26          pusher = new Pusher(process.env.VUE_APP_PUSHER_KEY, {
27              cluster: process.env.VUE_APP_PUSHER_CLUSTER,
28              authEndpoint: "/api/pusher/auth",
29              auth: {
30                headers: {
31                  Authorization: "Bearer " + this.token
32                }
33              }
34          });
35          
36          // Get all the users from the server
37          const users = await this.axios.get("/api/users", {
38            headers: { Authorization: "Bearer " + this.token }
39          });
40          
41          // Get all users excluding the current logged user
42          this.users = users.data.filter(
43            user => user.userName != user_data.username
44          );
45    
46        },
47      },
48    };
49    [...]

In the code above:

  • We created a new function named setAuthenticated which accepts the information we passed along when emitting the authenticated event in the Login.vue file.
  • After updating the component state with the logged in user information, we made a request to /api/users to get all registered users.
  • Then we initialize Pusher JavaScript library
  • Finally, we remove the current log users from the users list we got and then update the users state.

Finally, pass down the users we fetched to the Users.vue component. Update the Users component in src/App.vue:

1// ./src/App.vue
2    [...]
3    <Users :users="users" v-on:chat="chat" />
4    [...]

Here we passed the users list down to the Users.vue component so we can render them. Also, using the v-on directive we listen for an event chat which will be triggered from Users.vue whenever a user is clicked to start up a chat.

Subscribe the user to a channel

Add the below code to the setAuthenticated function in src/App.vue to subscribe the user to a channel when they are logged in:

1// ./src/App.vue
2    
3    [...]
4      methods: {
5        async setAuthenticated(login_status, user_data) {
6          [...]
7          var notifications = pusher.subscribe(
8            `private-notification_user_${this.logged_user_id}`
9          );
10          
11          notifications.bind("new_chat", data => {
12            const isSubscribed = pusher.channel(data.channel_name);
13            if (!isSubscribed) {
14              const one_on_one_chat = pusher.subscribe(data.channel_name);
15              
16              this.$set(this.messages, data.channel_name, []);
17              
18              one_on_one_chat.bind("new_message", data => {
19                // Check if the current chat channel is where the message is coming from
20                if (
21                  data.channel !== this.current_chat_channel &&
22                  data.from_user !== this.logged_user_id
23                ) {
24                  // Get the index of the user that sent the message
25                  const index = this.users.findIndex(
26                    user => user.id == data.from_user
27                  );
28                  // Set the has_new_message status of the user to true
29                  this.$set(this.users, index, {
30                    ...this.users[index],
31                    has_new_message: true
32                  });
33                }
34                  
35                this.messages[data.channel].push({
36                  message: data.message,
37                  from_user: data.from_user,
38                  to_user: data.to_user,
39                  channel: data.channel
40                });
41              });
42            }
43          });
44          
45        },
46      },
47    };
48    [...]
  • First, we subscribe the user to their private channel using var notifications = pusher.subscribe(… once they log in.
  • Next, we bind that channel to an event we named new_chat so we can get a notification when a user is requesting for a new chat.
  • Then if there is any new chat request, we’ll subscribe that user to the channel sent along and also bind that channel to a new event named new_message.
  • Finally, if there is a message coming to the event - new_message, we append the message to the “messages” property in the data component. Also, if the user is not currently chatting on the channel where they received the message, we’ll notify them of the message.

Get all messages in a channel

Add a function to fetch all messages in a chat channel to src/App.vue in the methods block:

1// ./src/App.vue
2    [...]
3        getMessage: function(channel_name) {
4          this.axios
5            .get(`/api/get_message/${channel_name}`, {
6              headers: { Authorization: "Bearer " + this.token }
7            })
8            .then(response => {
9              this.$set(this.messages, channel_name, response.data);
10            });
11        },
12    [...]

The chat function

We'll call the function when a user clicks on another user they want to chat with to prepare the chat channel.

Add the below code to the methods block of src/App.vue

1// ./src/App.vue
2    
3    [...]
4        chat: function(id) {
5          this.active_chat_id = id;
6          
7          // Get index of the current chatting user...
8          this.active_chat_index = this.users.findIndex(
9            user => user.id == this.active_chat_id
10          );
11          
12          // Set the has_new_message status of the user to true
13          this.$set(this.users, this.active_chat_index, {
14            ...this.users[this.active_chat_index],
15            has_new_message: false
16          });
17          
18          this.axios
19            .post(
20              "/api/request_chat",
21              {
22                from_user: this.logged_user_id,
23                to_user: this.active_chat_id
24              },
25              { headers: { Authorization: "Bearer " + this.token } }
26            )
27            .then(response => {
28              this.users[this.active_chat_index]["channel_name"] =
29                response.data.channel_name;
30                
31              this.current_chat_channel = response.data.channel_name;
32              
33              // Get messages on this channel
34              this.getMessage(response.data.channel_name);
35              
36              var isSubscribed = pusher.channel(response.data.channel_name);
37              
38              if (!isSubscribed) {
39                var channel = pusher.subscribe(response.data.channel_name);
40                
41                this.$set(this.messages, response.data.channel_name, []);
42                
43                channel.bind("new_message", data => {
44                 //Check if the current chat channel is where the message is comming from
45                  if (
46                    data.channel !== this.current_chat_channel &&
47                    data.from_user !== this.logged_user_id
48                  ) {
49                    // Set the has_new_message status of the user to true
50                    this.$set(this.users, this.active_chat_index, {
51                      ...this.users[this.active_chat_index],
52                      has_new_message: true
53                    });
54                  }
55                  
56                  this.messages[response.data.channel_name].push({
57                    message: data.message,
58                    from_user: data.from_user,
59                    to_user: data.to_user,
60                    channel: data.channel
61                  });
62                });
63              }
64            })
65            .catch(function(error) {
66              console.log(error);
67            });
68        },
69    [...]
  • We make a request to /api/request_chat to get the channel name for the chat session.
  • Next, we update the state of the current_chat_channel with the channel returned using: this.current_chat_channel = response.data.channel_name;
  • Then we subscribe the user to the channel name returned and then bind the channel to an event we named new_message. Once we receive a new message, we add the message to the messages state.
  • Also, in the bound new_message event, we check if the message received is between the current chat channel, else we display an alert notifying the user that they have a new message from another user.

We are already passing the messages to the Messages.vue component so any new message will be rendered on the page dynamically. Take a look at the Messages component in src/App.vue:

1<Messages 
2      v-else 
3      :active_chat="active_chat_id" 
4      :messages="messages[current_chat_channel]"
5    />

Sending messages

Now add the function for sending messages to src/App.vue:

1// ./src/App.vue
2    [...]
3        send_message: function(message) {
4          this.axios.post(
5            "/api/send_message",
6            {
7              from_user: this.logged_user_id,
8              to_user: this.active_chat_id,
9              message: message,
10              channel: this.current_chat_channel
11            },
12            { headers: { Authorization: "Bearer " + this.token } }
13          );
14        },
15    [...]

We’ll call this function whenever a user submits a message.

Take a look at the MessageInput.vue component which is the component for sending messages. You will notice that after the user submits a message, we trigger an event named send_message passing along the message text.

Now we will listen to the event and send the message to the server once we get the event. Update the MessageInput component in the <template> section of src/App.vue:

1[...]
2    <MessageInput v-on:send_message="send_message" />
3    [...]

Here, we listen for the event using the v-on directive and then call the function we just added (send_message) once we get the event.

Test out the chat by opening the app in two different tabs on your browser.

Get sentiments from messages

To get the sentiment from messages, we’ll use the TextBlob Python library which provides a simple API for common natural language processing (NLP).

Install TextBlob

From your terminal, make sure you are in the api folder. Also, make sure your virtualenv is activated. Then execute the below function.

1# Install the library
2    $ pip install -U textblob
3    
4    # Download NLTK corpora
5    $ python -m textblob.download_corpora lite

This will install TextBlob and download the necessary NLTK corpora (trained models).

Import TextBlob to api/app.py:

    from textblob import TextBlob

Add a function to get the sentiment of a message to api/app.py

1# ./api/app.py
2    
3    def getSentiment(message):
4            text = TextBlob(message)
5            return {'polarity' : text.polarity }

The sentiment property returns a tuple of the form (polarity, subjectivity) where polarity ranges from -1.0 to 1.0 and subjectivity ranges from 0.0 to 1.0. We will only use the polarity property.

Next, include the sentiment on the return statement in the user_messages function in api/app.py:

1[...]
2        return jsonify([
3            {
4                "id": message.id,
5                "message": message.message,
6                "to_user": message.to_user,
7                "channel_id": message.channel_id,
8                "from_user": message.from_user,
9                "sentiment": getSentiment(message.message)
10            }
11            for message in messages
12        ])
13    [...]

And also update the data we trigger to Pusher in the send_message function in api/app.py:

1[...]
2        message = {
3            "from_user": from_user,
4            "to_user": to_user,
5            "message": message,
6            "channel": channel,
7            "sentiment": getSentiment(message)
8        }
9    [...]

Now we have the sentiment of text. Let’s display the related emoji beside messages in the view.

Next update the code in src/components/Messages.vue to display the emoji sentiment:

1[...]
2    <template>
3       <div>
4        <div v-for="(message, id) in messages" v-bind:key="id"> 
5            <div class="chat-message col-md-5" 
6              v-bind:class="[(message.from_user == active_chat) ? 'to-message' : 'from-message offset-md-7']">
7              {{message.message}}
8              {{ getSentiment(message.sentiment.polarity) }}
9            </div> 
10        </div>
11       </div>
12    </template>
13    <script>
14    export default {
15      name: "Messages",
16      data() {
17        return {
18          happy: String.fromCodePoint(0x1f600),
19          neutral: String.fromCodePoint(0x1f610),
20          sad: String.fromCodePoint(0x1f61f)
21        };
22      },
23      methods: {
24        getSentiment(sentiment) {
25          if (sentiment > 0.5) {
26            return this.happy;
27          } else if (sentiment < 0.0) {
28            return this.sad;
29          } else {
30            return this.neutral;
31          }
32        }
33      },
34      props: {
35        messages: Array,
36        active_chat: Number
37      }
38    };
39    </script>
40    [...]

Here, we defined the emotions for each sentiment score.

Then finally update the bound event for new_message to include the sentiment data. Update src/App.vue as below in the setAuthenticated function:

1[...]
2    channel.bind("new_message", data => {
3      [...]
4      this.messages[data.channel].push({
5        message: data.message,
6        sentiment: data.sentiment,
7        from_user: data.from_user,
8        to_user: data.to_user,
9        channel: data.channel
10      });
11    });
12    [...]

And also on the bound event in chat function to include the sentiment data in src/App.vue file:

1[...]
2    one_on_one_chat.bind("new_message", data => {
3      [...]
4      this.messages[response.data.channel_name].push({
5        message: data.message,
6        sentiment: data.sentiment,
7        from_user: data.from_user,
8        to_user: data.to_user,
9        channel: data.channel
10      });
11    });
12    [...]

And that’s it! congrats. If you test the app again, you will see the sentiments of each chat messages.

flask-vue-sentiment-demo-part-3

NOTE:: If you are having issue with displaying the emoji in your browsers, you might want to use the latest version of Chrome or Mozilla to display it.

Conclusion

In this tutorial of the series, we have successfully built a one-to-one private chat with sentiment analysis using Pusher Channels to add realtime functionality.

You can get the complete code on GitHub.