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:
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-”.
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.
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.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:
/api/request_chat
where users can get a channel name where they can chat.@jwt_required
.pusher.trigger()
, we trigger an event named new_chat
to the other user’s private channel.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.
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 [...]
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.new_message
to the channel name that will be sent from the request data and then return the information as JSON.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 [...]
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 [...]
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:
/api/login
to authenticate our users.authenticated
so we can act on it in the src/App.vue
file. We also passed some data in the event:
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:
setAuthenticated
which accepts the information we passed along when emitting the authenticated
event in the Login.vue file./api/users
to get all registered users.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.
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 [...]
var notifications = pusher.subscribe(…
once they log in.new_chat
so we can get a notification when a user is requesting for a new chat.new_message
.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.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 [...]
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 [...]
/api/request_chat
to get the channel name for the chat session.current_chat_channel
with the channel returned using:
this.current_chat_channel = response.data.channel_name;
new_message
. Once we receive a new message, we add the message to the messages state.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 />
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.
To get the sentiment from messages, we’ll use the TextBlob Python library which provides a simple API for common natural language processing (NLP).
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.
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.
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.