726 lines
20 KiB
JavaScript
726 lines
20 KiB
JavaScript
/* ---------------------- */
|
|
/* KICK MODULE VARIABLES */
|
|
/* ---------------------- */
|
|
|
|
const showKick = getURLParam("showKick", true);
|
|
|
|
const kickUserName = getURLParam("kickUserName", "vortisrd");
|
|
|
|
const showKickMessages = getURLParam("showKickMessages", true);
|
|
const showKickFollows = getURLParam("showKickFollows", true);
|
|
const showKickSubs = getURLParam("showKickSubs", true);
|
|
const showKickGiftedSubs = getURLParam("showKickGiftedSubs", true);
|
|
const showKickMassGiftedSubs = getURLParam("showKickMassGiftedSubs", true);
|
|
const showKickGiftedSubsUserTrain = getURLParam("showKickGiftedSubsUserTrain", true);
|
|
const showKickRewardRedemptions = getURLParam("showKickRewardRedemptions", true);
|
|
const showKickRaids = getURLParam("showKickRaids", true);
|
|
const showKickViewers = getURLParam("showKickViewers", true);
|
|
|
|
const kickAvatars = new Map();
|
|
const kick7TVEmojis = new Map();
|
|
const kickSubBadges = [];
|
|
|
|
const kickWebSocketURL = 'wss://ws-us2.pusher.com/app/32cbd69e4b950bf97679?protocol=7&client=js&version=8.4.0&flash=false';
|
|
|
|
|
|
|
|
|
|
|
|
// KICK EVENTS HANDLERS
|
|
|
|
const kickMessageHandlers = {
|
|
|
|
/*'Kick.ChatMessage': (response) => {\
|
|
kickChatMessage(response.data);
|
|
},*/
|
|
|
|
'Kick.Follow': (response) => {
|
|
kickFollowMessage(response.data);
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
if (showKick) {
|
|
|
|
const kickStatistics = `
|
|
<div class="platform" id="kick" style="display: none;">
|
|
<img src="js/modules/kick/images/logo-kick.svg" alt="">
|
|
<span class="viewers"><i class="fa-solid fa-user"></i> <span>0</span></span>
|
|
</div>
|
|
`;
|
|
|
|
document.querySelector('#statistics').insertAdjacentHTML('beforeend', kickStatistics);
|
|
|
|
if (showKickViewers == true) { document.querySelector('#statistics #kick').style.display = ''; }
|
|
|
|
console.debug('[Kick][Debug] DOMContentLoaded fired');
|
|
|
|
registerPlatformHandlersToStreamerBot(kickMessageHandlers, '[Kick][SB1]');
|
|
|
|
kickConnection();
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------
|
|
// KICK CONNECT HANDLER
|
|
|
|
async function kickConnection() {
|
|
if (!kickUserName) return;
|
|
|
|
const kickMaxTries = 20;
|
|
const kickReconnectDelay = 10000;
|
|
let retryCount = 0;
|
|
|
|
|
|
|
|
async function connect() {
|
|
try {
|
|
const kickUserInfo = await kickGetUserInfo(kickUserName);
|
|
const kickUserId = kickUserInfo.user_id;
|
|
|
|
if (!kickUserInfo || !kickUserInfo.chatroom || !kickUserInfo.chatroom.id) {
|
|
throw new Error('Chatroom ID not found');
|
|
}
|
|
|
|
console.debug(`[Kick] User info for ${kickUserName}!`, kickUserInfo);
|
|
|
|
const kickChatRoomId = kickUserInfo.chatroom.id;
|
|
|
|
if (!kickChatRoomId) {
|
|
console.error(`[Kick] Could not find chatroom id for ${kickUserName}!`);
|
|
return;
|
|
}
|
|
|
|
console.debug(`[Kick] Chatroom for ${kickUserName} Found! (ID: ${kickChatRoomId})`);
|
|
|
|
kickSubBadges.push(...kickUserInfo.subscriber_badges);
|
|
|
|
const kickWebSocket = new WebSocket(kickWebSocketURL);
|
|
|
|
kickWebSocket.onopen = () => {
|
|
kickConnectionState = true;
|
|
retryCount = 0;
|
|
|
|
console.debug(`[Kick] Connected to Kick!`);
|
|
notifySuccess({
|
|
title: 'Connected to Kick',
|
|
text: `User set to <strong>${kickUserName}</strong>.`
|
|
});
|
|
|
|
|
|
|
|
// Getting 7TV User Emotes and Global Emotes
|
|
(async () => {
|
|
const kick7TVEmotes = await getKick7TVEmotes(kickUserId);
|
|
if (kick7TVEmotes != null) {
|
|
kick7TVEmotes.forEach(emote => {
|
|
kick7TVEmojis.set(emote.name, emote.url);
|
|
});
|
|
}
|
|
})();
|
|
|
|
|
|
|
|
|
|
if (showKickViewers === true) {
|
|
setInterval(() => {
|
|
kickGetUserInfo(kickUserName).then(data => {
|
|
kickUpdateStatistics(data);
|
|
});
|
|
}, 15000);
|
|
}
|
|
};
|
|
|
|
kickWebSocket.onmessage = (response) => {
|
|
const data = JSON.parse(response.data);
|
|
const kickData = JSON.parse(data.data);
|
|
const kickEvent = data.event.split('\\').pop();
|
|
|
|
console.debug(`[Kick] ${kickEvent}`, kickData);
|
|
|
|
if (data.event === 'pusher:connection_established') {
|
|
|
|
console.debug(`[Kick][Pusher] Connection established! (ID:${kickData.socket_id})`);
|
|
|
|
const channels = [
|
|
`chatroom_${kickChatRoomId}`,
|
|
`chatrooms.${kickChatRoomId}`,
|
|
`chatrooms.${kickChatRoomId}.v2`,
|
|
`predictions-channel-${kickChatRoomId}`
|
|
];
|
|
|
|
channels.forEach(channel => {
|
|
kickWebSocket.send(JSON.stringify({
|
|
event: 'pusher:subscribe',
|
|
data: { channel }
|
|
}));
|
|
});
|
|
}
|
|
|
|
if (data.event === "pusher:ping") {
|
|
kickWebSocket.send(JSON.stringify({
|
|
event: "pusher:pong",
|
|
data: {}
|
|
}));
|
|
}
|
|
|
|
switch (kickEvent) {
|
|
case 'ChatMessageEvent': kickChatMessage(kickData); break;
|
|
case 'SubscriptionEvent': kickSubMessage(kickData); break;
|
|
case 'GiftedSubscriptionsEvent': kickGiftMessage(kickData); break;
|
|
case 'RewardRedeemedEvent': kickRewardRedemption(kickData); break;
|
|
case 'StreamHostEvent': kickRaidMessage(kickData); break;
|
|
case 'MessageDeletedEvent': kickChatMessageDeleted(kickData); break;
|
|
case 'UserBannedEvent': kickUserBanned(kickData); break;
|
|
case 'ChatroomClearEvent': kickChatClearMessages(); break;
|
|
}
|
|
};
|
|
|
|
kickWebSocket.onclose = (event) => {
|
|
setTimeout(connect, kickReconnectDelay);
|
|
|
|
/*console.warn(`[Kick] WebSocket closed (code: ${event.code})`);
|
|
|
|
if (retryCount < kickMaxTries) {
|
|
retryCount++;
|
|
notifyError({
|
|
title: 'Kick Disconnected',
|
|
text: `Retrying in ${kickReconnectDelay / 1000}s (${retryCount}/${kickMaxTries})`
|
|
});
|
|
setTimeout(connect, kickReconnectDelay);
|
|
} else {
|
|
notifyError({
|
|
title: 'Kick Reconnect Failed',
|
|
text: `Maximum retries (${kickMaxTries}) reached.`
|
|
});
|
|
}*/
|
|
};
|
|
|
|
kickWebSocket.onerror = (error) => {
|
|
console.error('[Kick] WebSocket error:', error);
|
|
kickWebSocket.close();
|
|
};
|
|
|
|
}
|
|
catch (error) {
|
|
setTimeout(connect, kickReconnectDelay);
|
|
|
|
/*console.error(`[Kick] Failed to connect: ${error.message}`);
|
|
|
|
if (retryCount < kickMaxTries) {
|
|
retryCount++;
|
|
notifyError({
|
|
title: 'Kick Connection Error',
|
|
text: `Retrying in ${kickReconnectDelay / 1000}s (${retryCount}/${kickMaxTries})`
|
|
});
|
|
setTimeout(connect, kickReconnectDelay);
|
|
} else {
|
|
notifyError({
|
|
title: 'Kick Reconnect Failed',
|
|
text: `Maximum retries (${kickMaxTries}) reached.`
|
|
});
|
|
}*/
|
|
}
|
|
}
|
|
|
|
return await connect();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------
|
|
// KICK UTILITY FUNCTIONS
|
|
|
|
async function kickChatMessage(data) {
|
|
|
|
if (showKickMessages == false) return;
|
|
if (ignoreUserList.includes(data.sender.username.toLowerCase())) return;
|
|
if (data.content.startsWith("!") && excludeCommands == true) return;
|
|
|
|
const template = chatTemplate;
|
|
const clone = template.content.cloneNode(true);
|
|
const messageId = data.id;
|
|
const userId = data.sender.id;
|
|
const userSlug = data.sender.slug;
|
|
|
|
const {
|
|
'first-message': firstMessage,
|
|
'shared-chat': sharedChat,
|
|
|
|
header,
|
|
timestamp,
|
|
platform,
|
|
badges,
|
|
avatar,
|
|
pronouns: pronoun,
|
|
user,
|
|
|
|
reply,
|
|
'actual-message': message
|
|
} = Object.fromEntries(
|
|
[...clone.querySelectorAll('[class]')]
|
|
.map(el => [el.className, el])
|
|
);
|
|
|
|
const classes = ['kick', 'chat'];
|
|
|
|
if (userSlug == kickUserName) classes.push('streamer');
|
|
|
|
const [avatarImage, messageHTML, badgesHTML] = await Promise.all([
|
|
getKickAvatar(data.sender.slug),
|
|
getKickEmotes(data.content),
|
|
getKickBadges(data.sender.identity.badges),
|
|
]);
|
|
|
|
header.remove();
|
|
firstMessage.remove();
|
|
|
|
user.style.color = data.sender.identity.color;
|
|
user.innerHTML = `<strong>${data.sender.username}</strong>`;
|
|
message.innerHTML = messageHTML;
|
|
|
|
if (showAvatar) avatar.innerHTML = `<img src="${avatarImage}">`; else avatar.remove();
|
|
if (showBadges) {
|
|
if (!badgesHTML) { badges.remove(); }
|
|
else { badges.innerHTML = badgesHTML; }
|
|
}
|
|
else { badges.remove(); }
|
|
|
|
if (data.type == "reply") {
|
|
classes.push('reply');
|
|
var replyHTML = await getKickEmotes(data.metadata.original_message.content);
|
|
reply.insertAdjacentHTML('beforeend', ` <strong>${data.metadata.original_sender.username}:</strong> ${replyHTML}`);
|
|
}
|
|
else { reply.remove(); }
|
|
|
|
sharedChat.remove();
|
|
pronoun.remove();
|
|
|
|
addMessageItem('kick', clone, classes, userSlug, messageId);
|
|
}
|
|
|
|
|
|
|
|
async function kickFollowMessage(data) {
|
|
|
|
if (showKickFollows == false) return;
|
|
|
|
const template = eventTemplate;
|
|
const clone = template.content.cloneNode(true);
|
|
const messageId = createRandomString(40);
|
|
const userId = data.user.login.toLowerCase();
|
|
//const userId = data.userName.toLowerCase();
|
|
|
|
const {
|
|
header,
|
|
platform,
|
|
user,
|
|
action,
|
|
value,
|
|
'actual-message': message
|
|
} = Object.fromEntries(
|
|
[...clone.querySelectorAll('[class]')]
|
|
.map(el => [el.className, el])
|
|
);
|
|
|
|
const classes = ['kick', 'follow'];
|
|
|
|
header.remove();
|
|
|
|
|
|
user.innerHTML = `<strong>${data.user.name}</strong>`;
|
|
//user.innerHTML = `<strong>${data.userName}</strong>`;
|
|
|
|
action.innerHTML = ` followed you`;
|
|
|
|
value.remove()
|
|
|
|
message.remove();
|
|
|
|
addEventItem('kick', clone, classes, userId, messageId);
|
|
}
|
|
|
|
|
|
|
|
async function kickSubMessage(data) {
|
|
|
|
if (showKickSubs == false) return;
|
|
|
|
const template = eventTemplate;
|
|
const clone = template.content.cloneNode(true);
|
|
const messageId = createRandomString(40);
|
|
const userId = data.username.toLowerCase();
|
|
|
|
const {
|
|
header,
|
|
platform,
|
|
user,
|
|
action,
|
|
value,
|
|
'actual-message': message
|
|
} = Object.fromEntries(
|
|
[...clone.querySelectorAll('[class]')]
|
|
.map(el => [el.className, el])
|
|
);
|
|
|
|
const classes = ['kick', 'sub'];
|
|
|
|
header.remove();
|
|
|
|
|
|
user.innerHTML = `<strong>${data.username}</strong>`;
|
|
|
|
action.innerHTML = ` subscribed for `;
|
|
|
|
var months = data.months > 1 ? 'months' : 'month';
|
|
|
|
value.innerHTML = `<strong>${data.months} ${months}</strong>`;
|
|
|
|
message.remove();
|
|
|
|
addEventItem('kick', clone, classes, userId, messageId);
|
|
}
|
|
|
|
|
|
|
|
async function kickGiftMessage(data) {
|
|
|
|
if (showKickGiftedSubs == false) return;
|
|
|
|
const template = eventTemplate;
|
|
const clone = template.content.cloneNode(true);
|
|
const messageId = createRandomString(40);
|
|
const userId = data.gifter_username.toLowerCase();
|
|
|
|
const {
|
|
header,
|
|
platform,
|
|
user,
|
|
action,
|
|
value,
|
|
'actual-message': message
|
|
} = Object.fromEntries(
|
|
[...clone.querySelectorAll('[class]')]
|
|
.map(el => [el.className, el])
|
|
);
|
|
|
|
const classes = ['kick', 'gift'];
|
|
|
|
header.remove();
|
|
|
|
|
|
user.innerHTML = `<strong>${data.gifter_username}</strong>`;
|
|
|
|
var giftedLength = data.gifted_usernames.length;
|
|
|
|
if (giftedLength > 1 && showKickMassGiftedSubs == true) {
|
|
action.innerHTML = ` gifted <strong>${giftedLength} subs</strong> to the Community`;
|
|
message.innerHTML = `They've gifted a total of <strong>${data.gifter_total} subs</strong>`;
|
|
value.remove();
|
|
|
|
if (showKickGiftedSubsUserTrain == true) {
|
|
for (recipients of data.gifted_usernames) {
|
|
kickGiftSingleSub(data.gifter_username, recipients);
|
|
}
|
|
}
|
|
|
|
addEventItem('kick', clone, classes, userId, messageId);
|
|
}
|
|
else {
|
|
kickGiftSingleSub(data.gifter_username, data.gifted_usernames[0]);
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function kickGiftSingleSub(gifter, recipient) {
|
|
const template = eventTemplate;
|
|
const clone = template.content.cloneNode(true);
|
|
const messageId = createRandomString(40);
|
|
const userId = gifter.toLowerCase();
|
|
|
|
const {
|
|
header,
|
|
platform,
|
|
user,
|
|
action,
|
|
value,
|
|
'actual-message': message
|
|
} = Object.fromEntries(
|
|
[...clone.querySelectorAll('[class]')]
|
|
.map(el => [el.className, el])
|
|
);
|
|
|
|
const classes = ['kick', 'gift'];
|
|
|
|
header.remove();
|
|
message.remove();
|
|
|
|
|
|
user.innerHTML = `<strong>${gifter}</strong>`;
|
|
|
|
action.innerHTML = ` gifted a subscription to `;
|
|
|
|
value.innerHTML = `<strong>${recipient}</strong>`;
|
|
|
|
addEventItem('kick', clone, classes, userId, messageId);
|
|
}
|
|
|
|
|
|
|
|
async function kickRewardRedemption(data) {
|
|
|
|
if (showKickRewardRedemptions == false) return;
|
|
|
|
const template = eventTemplate;
|
|
const clone = template.content.cloneNode(true);
|
|
const messageId = createRandomString(40);
|
|
const userId = data.username.toLowerCase();
|
|
|
|
const {
|
|
header,
|
|
platform,
|
|
user,
|
|
action,
|
|
value,
|
|
'actual-message': message
|
|
} = Object.fromEntries(
|
|
[...clone.querySelectorAll('[class]')]
|
|
.map(el => [el.className, el])
|
|
);
|
|
|
|
const classes = ['kick', 'reward'];
|
|
|
|
header.remove();
|
|
|
|
|
|
user.innerHTML = `<strong>${data.username}</strong>`;
|
|
action.innerHTML = ` redeemed `;
|
|
value.innerHTML = `<strong>${data.reward_title}</strong>`;
|
|
|
|
var userInput = data.user_input ? `- ${data.user_input}` : '';
|
|
message.innerHTML = `${userInput}`;
|
|
|
|
addEventItem('kick', clone, classes, userId, messageId);
|
|
}
|
|
|
|
|
|
|
|
|
|
async function kickRaidMessage(data) {
|
|
|
|
if (showKickRaids == false) return;
|
|
|
|
const template = eventTemplate;
|
|
const clone = template.content.cloneNode(true);
|
|
const messageId = createRandomString(40);
|
|
const userId = data.host_username.toLowerCase();
|
|
|
|
const {
|
|
header,
|
|
platform,
|
|
user,
|
|
action,
|
|
value,
|
|
'actual-message': message
|
|
} = Object.fromEntries(
|
|
[...clone.querySelectorAll('[class]')]
|
|
.map(el => [el.className, el])
|
|
);
|
|
|
|
const classes = ['kick', 'raid'];
|
|
|
|
header.remove();
|
|
message.remove();
|
|
|
|
|
|
user.innerHTML = `<strong>${data.host_username}</strong>`;
|
|
|
|
var viewers = data.number_viewers > 1 ? 'viewers' : 'viewer';
|
|
action.innerHTML = ` hosted the channel with `;
|
|
value.innerHTML = `<strong>${data.number_viewers} ${viewers}</strong>`;
|
|
|
|
addEventItem('kick', clone, classes, userId, messageId);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function kickChatMessageDeleted(data) {
|
|
document.getElementById(data.message.id)?.remove();
|
|
}
|
|
|
|
|
|
|
|
async function kickUserBanned(data) {
|
|
chatContainer.querySelectorAll(`[data-user="${data.user.slug}"]`).forEach(element => {
|
|
element.remove();
|
|
});
|
|
}
|
|
|
|
|
|
|
|
async function kickChatClearMessages() {
|
|
chatContainer.querySelectorAll(`.item.kick`).forEach(element => {
|
|
element.remove();
|
|
});
|
|
}
|
|
|
|
|
|
|
|
async function kickUpdateStatistics(data) {
|
|
if (showPlatformStatistics == false || showKickViewers == false) return;
|
|
if (data.livestream == null) { }
|
|
else {
|
|
const viewers = DOMPurify.sanitize(data.livestream.viewer_count);
|
|
document.querySelector('#statistics #kick .viewers span').textContent = formatNumber(viewers);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function kickGetUserInfo(user) {
|
|
const response = await fetch( `https://kick.com/api/v2/channels/${user}` );
|
|
|
|
if (response.status === 404) {
|
|
console.error("[Kick] User was not found!");
|
|
return 404;
|
|
}
|
|
else {
|
|
const data = await response.json();
|
|
return data;
|
|
}
|
|
}
|
|
|
|
async function getKickAvatar(user) {
|
|
if (!showAvatar) return;
|
|
|
|
const DEFAULT_AVATAR = 'https://kick.com/img/default-profile-pictures/default2.jpeg';
|
|
|
|
if (kickAvatars.has(user)) {
|
|
console.debug(`[Kick] Kick avatar found for ${user}!`);
|
|
return kickAvatars.get(user);
|
|
}
|
|
|
|
console.debug(`[Kick] Kick avatar not found for ${user}! Trying to get it...`);
|
|
|
|
try {
|
|
const response = await kickGetUserInfo(user);
|
|
const rawPic = response?.user?.profile_pic;
|
|
|
|
const avatarUrl = (typeof rawPic === "string" && rawPic)
|
|
? rawPic.replace(/fullsize\.webp$/, "medium.webp")
|
|
: DEFAULT_AVATAR;
|
|
|
|
kickAvatars.set(user, avatarUrl);
|
|
return avatarUrl;
|
|
}
|
|
|
|
catch (error) {
|
|
console.warn(`[Kick] Error getting Kick avatar for ${user}:`, error);
|
|
return DEFAULT_AVATAR;
|
|
}
|
|
}
|
|
|
|
|
|
async function getKickEmotes(text) {
|
|
var message = await parseKickEmojis(text);
|
|
message = await parseKick7TVEmotes(message);
|
|
return message;
|
|
}
|
|
|
|
async function parseKickEmojis(content) {
|
|
const message = content;
|
|
const messagewithemotes = message.replace(/\[emote:(\d+):([^\]]+)\]/g, (_, id, name) => {
|
|
return `<img src="https://files.kick.com/emotes/${id}/fullsize" alt="${name}" class="emote" >`;
|
|
});
|
|
|
|
return messagewithemotes;
|
|
}
|
|
|
|
async function parseKick7TVEmotes(text) {
|
|
const words = text.split(/\s+/);
|
|
|
|
const parsedWords = words.map(word => {
|
|
if (kick7TVEmojis.has(word)) {
|
|
const url = kick7TVEmojis.get(word);
|
|
return `<img src="${url}" alt="${word}" class="emote" />`;
|
|
}
|
|
return word;
|
|
});
|
|
|
|
return parsedWords.join(' ');
|
|
}
|
|
|
|
|
|
async function getKick7TVEmotes(userId) {
|
|
const userSet = await fetch(`https://7tv.io/v3/users/kick/${userId}`);
|
|
|
|
if (userSet.status === 404) {
|
|
console.debug("[Kick] 7TV Profile based on this Kick user was not found");
|
|
return null;
|
|
}
|
|
|
|
const userEmojis = await userSet.json();
|
|
|
|
const gettingAllKick7TVEmotes = userEmojis?.emote_set?.emotes?.map(emote => ({
|
|
name: emote.name,
|
|
id: emote.id,
|
|
url: `https://cdn.7tv.app/emote/${emote.id}/1x.webp`
|
|
})) || [];
|
|
|
|
const globalSet = await fetch(`https://7tv.io/v3/emote-sets/global`);
|
|
const globalEmojis = await globalSet.json();
|
|
|
|
const gettingAllGlobal7TVEmotes = globalEmojis?.emotes?.map(emote => ({
|
|
name: emote.name,
|
|
id: emote.id,
|
|
url: `https://cdn.7tv.app/emote/${emote.id}/1x.webp`
|
|
})) || [];
|
|
|
|
const SevenTVEmotesFusion = [...gettingAllKick7TVEmotes, ...gettingAllGlobal7TVEmotes];
|
|
|
|
if (SevenTVEmotesFusion != null) {
|
|
console.debug("[Kick] Getting all Kick's user 7TV Emojis + Globals", SevenTVEmotesFusion);
|
|
|
|
SevenTVEmotesFusion.forEach(emote => {
|
|
kick7TVEmojis.set(emote.name, emote.url);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function getKickBadges(badges) {
|
|
const badgesArray = [];
|
|
|
|
badges.forEach(badge => {
|
|
if (badge.type === 'subscriber') {
|
|
|
|
const targetMonths = badge.count;
|
|
|
|
// Sort badges by months
|
|
const eligibleBadges = kickSubBadges
|
|
.filter(badge => badge.months <= targetMonths)
|
|
.sort((a, b) => b.months - a.months); // sorts from highest to lowest
|
|
|
|
badgesArray.push(`<img src="${eligibleBadges[0]?.badge_image?.src || 'icons/badges/kick-subscriber.svg'}" class="badge">`);
|
|
}
|
|
else {
|
|
badgesArray.push(`<img src="js/modules/kick/images/badge-${badge.type}.svg" class="badge">`);
|
|
}
|
|
});
|
|
|
|
return badgesArray.join(' ');
|
|
} |