logo
Menu
Build a UGC Live Streaming App with Amazon IVS: Adding Chat to a User's Channel Page (Lesson 8.1)

Build a UGC Live Streaming App with Amazon IVS: Adding Chat to a User's Channel Page (Lesson 8.1)

Welcome to Lesson 8.1 in this series where we're looking at building a web based user-generated content live streaming application with Amazon IVS. This entire series is available in video format on the AWS Developers YouTube channel and all of the code related to the sample application used in this series can be viewed on GitHub. Refer to the links at the end of the post for more information.

Todd Sharp
Amazon Employee
Published Jan 3, 2024

Intro

In lesson 8, we'll focus on the social features of the StreamCat application. We'll start off in this lesson by learning how the application adds live chat to a user's channel page to facilitate interaction between stream participants.

Adding Chat to a User's Channel

If you recall from earlier lessons, when a user registers for a new account, the StreamCat application creates a dedicated Amazon IVS channel, chat room and stage for real-time streams. The chat room information is persisted in a ChatRoom entity. Also note the ChatMessage entity which contains a record of every message posted to a ChatRoom during a Stream.
User, Chat Room, Chat Message Relationship
User, Chat Room, Chat Message Relationship
Because chat is a piece of functionality that is re-used in multiple places in StreamCat, it's bundled into a re-usable component. The HTML markup for this component contains a container that will render the chat messages as they are posted, and an <input> that is used to post a new chat message.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div id="chat-container"
x-data="chatmodel('{{chatArn}}', '{{chatEndpoint}}', '{{broadcastType || ''}}')">

<div id="chat">
<template x-for="msg in messages">
<div x-bind:data-timestamp="msg.SendTime">
<span x-text="msg.Sender.Attributes.username"></span>:&nbsp;
<span x-bind:data-msg-id="msg.Id" x-text="msg.Content"></span>
</div>
</template>
</div>
<div>
<input
x-init="$el.focus()"
x-on:keyup.enter="sendChat($el)"
x-ref="chatInput"
maxlength="500"
type="text" />

<button type="button" x-on:click="sendChat($refs.chatInput)" id="submit-chat">
<i class="bi bi-send"></i>
</button>
</div>
</div>
In this component, we're passing the chatArn, chatEndpoint, and the broadcastType (real-time, or low-latency) to Alpine.JS in the chatmodel (line 2).
The init() function of the chat component gets a chat participant token and sets data from that token into scope. It then initializes a WebSocket connection to the chatEndpoint and calls initChat().
1
2
3
4
5
6
7
8
async init() {
const token = await this.getChatToken();
this.userId = token.userId;
this.username = token.username;
this.isAdmin = token.isAdmin;
this.chatConnection = new WebSocket(this.chatEndpoint, token.token);
this.initChat();
}
The getChatToken() function makes a request to the /api/chat/token endpoint.
1
2
3
4
5
6
7
8
9
10
11
12
async getChatToken() {
const request = await fetch('/api/chat/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chatArn: this.chatArn,
}),
});
return await request.json();
}
The endpoint generates a chat token via the ChatRoomService with the current user's id and username (or a new UUID and random name for anonymous users).
1
2
3
4
5
6
7
8
9
10
11
Route.post('/chat/token', async ({ auth, request, response }) => {
const body = request.body();
const arn = body.chatArn;
const chatRoom = await ChatRoom.findBy('arn', arn);
const userId = auth.user?.id || uuid4();
const username = auth.user?.username || `Guest${new Date().getTime().toString().substring(10, 13)}`;
const isAdmin = auth.user?.id === chatRoom?.userId;
const isGuest = !auth.user;
const token = await ChatRoomService.createChatToken(arn, userId, username, isAdmin, isGuest);
return response.send({ token: token.token, isAdmin, userId, username });
});
The createChatToken() method of the ChatRoomService uses the AWS SDK for JavaScript (v3) to issue a CreateChatTokenCommand which returns the chat token. By default, all users are granted the SEND_MESSAGE capability. Admin users are granted additional capabilities to handle disconnecting users and deleting messages.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public async createChatToken(roomArn: string, userId: string, username: string, isAdmin: boolean, isGuest: boolean): Promise<CreateChatTokenCommandOutput> {
let capabilities = ['SEND_MESSAGE'];
if (isAdmin) capabilities = [...capabilities, 'DISCONNECT_USER', 'DELETE_MESSAGE'];
const request: CreateChatTokenCommand = new CreateChatTokenCommand({
roomIdentifier: roomArn,
userId: userId.toString(),
attributes: {
username,
isGuest: isGuest.toString(),
},
capabilities,
});
return await this.ivsChatClient.send(request);
}
After the token is obtained and the connection is established, the initChat() function adds onopen and onmessage handlers to the connection.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
initChat() {
this.chatConnection.onopen = () => {
const payload = {
Action: 'SEND_MESSAGE',
Content: '[joined chat]',
};
this.chatConnection.send(JSON.stringify(payload));
};
this.chatConnection.onmessage = (event) => {
const data = JSON.parse(event.data);
const chatEl = document.getElementById('chat');
if (data.Type === 'MESSAGE') {
this.messages.push(data);
this.$nextTick(() => {
chatEl.scrollTop = chatEl.scrollHeight;
});
}
};
}
When new messages are received, they are pushed to the messages array which updates the chat view to render the new message and scrolls the container to display the new message.
The sendChat() function calls send() on the chatConnection, passing it an object with an Action of SEND_MESSAGE and a Content key with the sanitized chat message (to prevent HTML injection).
1
2
3
4
5
6
7
8
9
10
sendChat(msgInput) {
this.chatConnection.send(
JSON.stringify({
Action: 'SEND_MESSAGE',
Content: Utils.stripHtml(msgInput.value),
})
);
msgInput.value = '';
msgInput.focus();
}
The Utils.stripHtml() static method uses the DOMParser to return only the textContent from the message.
1
2
3
4
5
6
export class Utils {
static stripHtml(html) {
let doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
}

Persisting Chat Messages

In order to provide chat logs and replay chat messages during VOD playback, StreamCat archives all of the chat messages from a live stream in the database. If you remember from the architectural overview in lesson 1.4, we created an IVS Chat Logging configuration which logs all chat messages (or events) to CloudWatch. To make it easier to retrieve these messages, StreamCat copies the chat messages from the CloudWatch logs into its own database after a live stream has concluded. This work is done in the AWS Lambda function that is triggered by the Amazon IVS events via Amazon EventBridge.
When a 'Stream End' event is received, we retrieve the Stream from the database.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const stream = await client.query(`
select
id,
date_part('epoch', started_at) * 1000 as started_at,
date_part('epoch', ended_at) * 1000 as ended_at
from streams
where stream_id = $1`
,
[
event.detail.stream_id,
]
);
const streamId = stream.rows[0].id;
const streamStartedAt = stream.rows[0].started_at;
const streamEndedAt = stream.rows[0].ended_at;
And the ChatRoom:
1
2
3
4
5
6
7
8
9
10
11
const chatRoom = await client.query(`
select id, arn
from chat_rooms
where name = $1`
,
[
event.detail.channel_name,
]
);
const chatRoomId = chatRoom.rows[0].id;
const chatRoomArn = chatRoom.rows[0].arn;
const chatRoomArnId = chatRoomArn.split('/')[1];
The chat logs in CloudWatch are stored in a log that uses a standard naming convention that includes the last part - the id - of the arn from the ChatRoom.
1
const chatLogName = `aws/IVSChatLogs/1.0/room_${chatRoomArnId}`;
We can use this chatLogName variable, and the streamStartedAt and streamEndedAt timestamps to retrieve the chat messages that were posted to the user's chat room during a given live stream.
1
2
3
4
5
const chatMessages = await getChatMessages(
chatLogName,
streamStartedAt,
streamEndedAt
);
The getChatMessages() function uses the CloudWatch SDK to retrieve the chat messages (events).
💡 Note: The CloudWatch SDK's FilterLogEventsCommand returns up to 1 MB (up to 10,000 log events), so if your chat log is larger, you'll have to implement pagination.
Since CloudWatch stores all chat events, the FilterLogEventsCommandOutput returned via the SDK will not be quite ready to persist to the database. There could be moderation events, for example. To get a proper log of events, we parse the event array and remove any deleted messages.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const parseEvents = (events) => {
let parsedEvents = [];
events.forEach(e => {
switch (e.type) {
case 'MESSAGE':
parsedEvents.push(e);
break;
case 'EVENT':
if (e.payload.EventName === 'aws:DELETE_MESSAGE') {
const existingEventIdx = parsedEvents.findIndex(parsedEvent => {
return parsedEvent.payload.Id === e.payload.Attributes.MessageID;
});
if (existingEventIdx > -1) {
parsedEvents.splice(existingEventIdx, 1);
}
}
break;
}
});
return parsedEvents;
};
Finally, the parsed events are inserted into the database.
1
2
3
4
5
6
7
8
9
10
11
12
13
let start = 0;
await client.query(`
insert into chat_messages (stream_id, chat_room_id, content, sent_at, sent_by_id)
values
${Array
.from({ length: chatMessages.length })
.map((e, idx) => {
start = Number(start) + 3;
return '($1::integer, $2::integer, ' + '$' + start + '::text, $' + (Number(start) + 1) + '::timestamp, $' + (Number(start) + 2) + '::integer)';
})
.join()}

`
, [streamId, chatRoomId, chatMessages.map((e) => [e.payload.Content, e.payload.SendTime, e.payload?.Sender?.Attributes?.isGuest !== 'true' ? Number(e.payload?.Sender.UserId) : null]).flat()].flat()
);

Summary

In this lesson, we learned how the StreamCat application implements live chat for a user's live stream channel. We also saw how logged chat messages are retrieved from CloudWatch and stored in the application's database for retrieval and display during VOD playback. In a future lesson, we'll look at how VOD playback handles showing chat messages at the proper time in the video. In the next lesson, we'll look at how StreamCat allows streamers to invite a chat user to join their live stream.

Links

Any opinions in this post are those of the individual author and may not reflect the opinions of AWS.