Build a chat app in Flask and Vue with sentiment analysis - Part 3: Live chat with sentiment analysis
You will need Node 8.9+ and Python 3.6+ installed on your machine.
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:
- Part one - Setting up the project
- Part two - Create the chat interface, database design, login and registration endpoint
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:
{ from_user, // ID of the user initiating the chat to_user, // ID of the other user from_user_notification_channel,// notificaction channel for the user intiating the chat to_user_notification_channel, // notificaction channel of the other user channel_name, // The channel name where both can chat }
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:
# api/app.py # [...] pusher = pusher.Pusher( app_id=os.getenv('PUSHER_APP_ID'), key=os.getenv('PUSHER_KEY'), secret=os.getenv('PUSHER_SECRET'), cluster=os.getenv('PUSHER_CLUSTER'), ssl=True) # [...]
We already have our login and register endpoint ready from part two. We still need to create several endpoints:
/api/request_chatwe will use this endpoint to generate a channel name where both users can communicate.
/api/pusher/auththe endpoint for authenticating Pusher Channels subscription
/api/send_messagethe endpoint to send message across users.
/api/usersendpoint 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:
# api/app.py [...] @app.route('/api/request_chat', methods=["POST"]) @jwt_required def request_chat(): request_data = request.get_json() from_user = request_data.get('from_user', '') to_user = request_data.get('to_user', '') to_user_channel = "private-notification_user_%s" %(to_user) from_user_channel = "private-notification_user_%s" %(from_user) # check if there is a channel that already exists between this two user channel = Channel.query.filter( Channel.from_user.in_([from_user, to_user]) ) \ .filter( Channel.to_user.in_([from_user, to_user]) ) \ .first() if not channel: # Generate a channel... chat_channel = "private-chat_%s_%s" %(from_user, to_user) new_channel = Channel() new_channel.from_user = from_user new_channel.to_user = to_user new_channel.name = chat_channel db_session.add(new_channel) db_session.commit() else: # Use the channel name stored on the database chat_channel = channel.name data = { "from_user": from_user, "to_user": to_user, "from_user_notification_channel": from_user_channel, "to_user_notification_channel": to_user_channel, "channel_name": chat_channel, } # Trigger an event to the other user pusher.trigger(to_user_channel, 'new_chat', data) return jsonify(data) [...]
In the preceding code:
- First of all, we created a route named
/api/request_chatwhere 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_chatto 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.
# api/app.py [...] @app.route("/api/pusher/auth", methods=['POST']) @jwt_required def pusher_authentication(): channel_name = request.form.get('channel_name') socket_id = request.form.get('socket_id') auth = pusher.authenticate( channel=channel_name, socket_id=socket_id ) return jsonify(auth) [...]
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.
# api/app.py [...] @app.route("/api/send_message", methods=["POST"]) @jwt_required def send_message(): request_data = request.get_json() from_user = request_data.get('from_user', '') to_user = request_data.get('to_user', '') message = request_data.get('message', '') channel = request_data.get('channel') new_message = Message(message=message, channel_id=channel) new_message.from_user = from_user new_message.to_user = to_user db_session.add(new_message) db_session.commit() message = { "from_user": from_user, "to_user": to_user, "message": message, "channel": channel } # Trigger an event to the other user pusher.trigger(channel, 'new_message', message) return jsonify(message) [...]
- 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_messageto 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:
# api/app.py [...] @app.route('/api/users') @jwt_required def users(): users = User.query.all() return jsonify( [{"id": user.id, "userName": user.username} for user in users] ), 200 [...]
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:
# api/app.py [...] @app.route('/api/get_message/<channel_id>') @jwt_required def user_messages(channel_id): messages = Message.query.filter( Message.channel_id == channel_id ).all() return jsonify([ { "id": message.id, "message": message.message, "to_user": message.to_user, "channel_id": message.channel_id, "from_user": message.from_user, } for message in messages ]) [...]
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:
// ./src/App.vue [...] <Login v- <b-container v-else> [...]
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:
// ./src/components/Login.vue [...] <script> export default { name: "Login", data() { return { username: "", password: "", proccessing: false, message: "" }; }, methods: { login: function() { this.loading = true; this.axios .post("/api/login", { username: this.username, password: this.password }) .then(response => { if (response.data.status == "success") { this.proccessing = false; this.$emit("authenticated", true, response.data.data); } else { this.message = "Login Faild, try again"; } }) .catch(error => { this.message = "Login Faild, try again"; this.proccessing = false; }); } } }; </script> [...]
In the preceding code:
- We are making a POST request to
/api/loginto authenticate our users.
- If the login was successful, we’ll emit an event named
authenticatedso we can act on it in the
src/App.vuefile. 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:
// ./src/App.vue [...] data: function() { return { messages: {}, users: [], active_chat_id: null, active_chat_index: null, logged_user_id: null, logged_user_username: null, current_chat_channel: null, authenticated: false }; }, [...]
So that the entire
<script> section looks like below:
// ./App.vue import MessageInput from "./components/MessageInput.vue"; import Messages from "./components/Messages.vue"; import NavBar from "./components/NavBar.vue"; import Login from "./components/Login.vue"; import Users from "./components/Users.vue"; import Pusher from "pusher-js"; let pusher; export default { name: "app", components: { MessageInput, NavBar, Messages, Users, Login }, data: function() { return { authenticated: false, messages: {}, users: [], active_chat_id: null, active_chat_index: null, logged_user_id: null, logged_user_username: null, current_chat_channel: null }; }, methods: {}, };
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:
// ./src/App.vue [...] data: function() { return { authenticated: false, messages: {}, users: [], active_chat_id: null, active_chat_index: null, logged_user_id: null, logged_user_username: null, current_chat_channel: null }; }, methods: { async setAuthenticated(login_status, user_data) { // Update the states this.logged_user_id = user_data.id; this.logged_user_username = user_data.username; this.authenticated = login_status; this.token = user_data.token; // Initialize Pusher JavaScript library pusher = new Pusher(process.env.VUE_APP_PUSHER_KEY, { cluster: process.env.VUE_APP_PUSHER_CLUSTER, authEndpoint: "/api/pusher/auth", auth: { headers: { Authorization: "Bearer " + this.token } } }); // Get all the users from the server const users = await this.axios.get("/api/users", { headers: { Authorization: "Bearer " + this.token } }); // Get all users excluding the current logged user this.users = users.data.filter( user => user.userName != user_data.username ); }, }, }; [...]
In the code above:
- We created a new function named
setAuthenticatedwhich accepts the information we passed along when emitting the
authenticatedevent in the Login.vue file.
- After updating the component state with the logged in user information, we made a request to
/api/usersto 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:
// ./src/App.vue [...] <Users : [...]
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:
// ./src/App.vue [...] methods: { async setAuthenticated(login_status, user_data) { [...] var notifications = pusher.subscribe( `private-notification_user_${this.logged_user_id}` ); notifications.bind("new_chat", data => { const isSubscribed = pusher.channel(data.channel_name); if (!isSubscribed) { const one_on_one_chat = pusher.subscribe(data.channel_name); this.$set(this.messages, data.channel_name, []); one_on_one_chat.bind("new_message", data => { // Check if the current chat channel is where the message is coming from if ( data.channel !== this.current_chat_channel && data.from_user !== this.logged_user_id ) { // Get the index of the user that sent the message const index = this.users.findIndex( user => user.id == data.from_user ); // Set the has_new_message status of the user to true this.$set(this.users, index, { ...this.users[index], has_new_message: true }); } this.messages[data.channel].push({ message: data.message, from_user: data.from_user, to_user: data.to_user, channel: data.channel }); }); } }); }, }, }; [...]
- 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_chatso 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:
// ./src/App.vue [...] getMessage: function(channel_name) { this.axios .get(`/api/get_message/${channel_name}`, { headers: { Authorization: "Bearer " + this.token } }) .then(response => { this.$set(this.messages, channel_name, response.data); }); }, [...]
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
// ./src/App.vue [...] chat: function(id) { this.active_chat_id = id; // Get index of the current chatting user... this.active_chat_index = this.users.findIndex( user => user.id == this.active_chat_id ); // Set the has_new_message status of the user to true this.$set(this.users, this.active_chat_index, { ...this.users[this.active_chat_index], has_new_message: false }); this.axios .post( "/api/request_chat", { from_user: this.logged_user_id, to_user: this.active_chat_id }, { headers: { Authorization: "Bearer " + this.token } } ) .then(response => { this.users[this.active_chat_index]["channel_name"] = response.data.channel_name; this.current_chat_channel = response.data.channel_name; // Get messages on this channel this.getMessage(response.data.channel_name); var isSubscribed = pusher.channel(response.data.channel_name); if (!isSubscribed) { var channel = pusher.subscribe(response.data.channel_name); this.$set(this.messages, response.data.channel_name, []); channel.bind("new_message", data => { //Check if the current chat channel is where the message is comming from if ( data.channel !== this.current_chat_channel && data.from_user !== this.logged_user_id ) { // Set the has_new_message status of the user to true this.$set(this.users, this.active_chat_index, { ...this.users[this.active_chat_index], has_new_message: true }); } this.messages[response.data.channel_name].push({ message: data.message, from_user: data.from_user, to_user: data.to_user, channel: data.channel }); }); } }) .catch(function(error) { console.log(error); }); }, [...]
- We make a request to
/api/request_chatto get the channel name for the chat session.
- Next, we update the state of the
current_chat_channelwith 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_messageevent, 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:
<Messages v-else :
Sending messages
Now add the function for sending messages to
src/App.vue:
// ./src/App.vue [...] send_message: function(message) { this.axios.post( "/api/send_message", { from_user: this.logged_user_id, to_user: this.active_chat_id, message: message, channel: this.current_chat_channel }, { headers: { Authorization: "Bearer " + this.token } } ); }, [...]
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:
[...] <MessageInput v-on: [...]
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.
# Install the library $ pip install -U textblob # Download NLTK corpora $ 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
# ./api/app.py def getSentiment(message): text = TextBlob(message) 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:
[...] return jsonify([ { "id": message.id, "message": message.message, "to_user": message.to_user, "channel_id": message.channel_id, "from_user": message.from_user, "sentiment": getSentiment(message.message) } for message in messages ]) [...]
And also update the data we trigger to Pusher in the
send_message function in
api/app.py:
[...] message = { "from_user": from_user, "to_user": to_user, "message": message, "channel": channel, "sentiment": getSentiment(message) } [...]
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:
[...] <template> <div> <div v- <div class="chat-message col-md-5" v-bind: {{message.message}} {{ getSentiment(message.sentiment.polarity) }} </div> </div> </div> </template> <script> export default { name: "Messages", data() { return { happy: String.fromCodePoint(0x1f600), neutral: String.fromCodePoint(0x1f610), sad: String.fromCodePoint(0x1f61f) }; }, methods: { getSentiment(sentiment) { if (sentiment > 0.5) { return this.happy; } else if (sentiment < 0.0) { return this.sad; } else { return this.neutral; } } }, props: { messages: Array, active_chat: Number } }; </script> [...]
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:
[...] channel.bind("new_message", data => { [...] this.messages[data.channel].push({ message: data.message, sentiment: data.sentiment, from_user: data.from_user, to_user: data.to_user, channel: data.channel }); }); [...]
And also on the bound event in
chat function to include the sentiment data in
src/App.vue file:
[...] one_on_one_chat.bind("new_message", data => { [...] this.messages[response.data.channel_name].push({ message: data.message, sentiment: data.sentiment, from_user: data.from_user, to_user: data.to_user, channel: data.channel }); }); [...]
And that’s it! congrats. If you test the app again, you will see the sentiments of each chat messages.
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.
September 5, 2018
by Gideon Onwuka