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_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:
# 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_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 namednew_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
.
# 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_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
:
# 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-if="!authenticated" v-on:authenticated="setAuthenticated" />
<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/login
to authenticate our users. - If the login was successful, we’ll emit an event named
authenticated
so we can act on it in thesrc/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:
// ./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
setAuthenticated
which accepts the information we passed along when emitting theauthenticated
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
:
// ./src/App.vue
[...]
<Users :users="users" v-on:chat="chat" />
[...]
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_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:
// ./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_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
:
<Messages
v-else
:active_chat="active_chat_id"
:messages="messages[current_chat_channel]"
/>
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:send_message="send_message" />
[...]
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-for="(message, id) in messages" v-bind:key="id">
<div class="chat-message col-md-5"
v-bind:class="[(message.from_user == active_chat) ? 'to-message' : 'from-message offset-md-7']">
{{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.
5 September 2018
by Gideon Onwuka