/* ----------------------- */ /* TWITCH MODULE VARIABLES */ /* ----------------------- */ const showTwitch = getURLParam("showTwitch", true); const showTwitchMessages = getURLParam("showTwitchMessages", true); const showTwitchFollows = getURLParam("showTwitchFollows", true); const showTwitchBits = getURLParam("showTwitchBits", true); const showTwitchAnnouncements = getURLParam("showTwitchAnnouncements", true); const showTwitchSubs = getURLParam("showTwitchSubs", true); const showTwitchGiftedSubs = getURLParam("showTwitchGiftedSubs", true); const showTwitchGiftedSubsUserTrain = getURLParam("showTwitchGiftedSubsUserTrain", true); const showTwitchMassGiftedSubs = getURLParam("showTwitchMassGiftedSubs", true); const showTwitchRewardRedemptions = getURLParam("showTwitchRewardRedemptions", true); const showTwitchRaids = getURLParam("showTwitchRaids", true); const showTwitchSharedChat = getURLParam("showTwitchSharedChat", true); const showTwitchPronouns = getURLParam("showTwitchPronouns", false); const showTwitchViewers = getURLParam("showTwitchViewers", true); const twitchAvatars = new Map(); const twitchPronouns = new Map(); // TWITCH EVENTS HANDLERS const twitchMessageHandlers = { 'Twitch.ChatMessage': (response) => { twitchChatMessage(response.data); }, 'Twitch.Follow': (response) => { twitchFollowMessage(response.data); }, 'Twitch.Announcement': (response) => { twitchAnnouncementMessage(response.data); }, 'Twitch.Cheer': (response) => { twitchBitsMessage(response.data); }, 'Twitch.AutomaticRewardRedemption': (response) => { twitchChatMessageGiantEmote(response.data); }, 'Twitch.RewardRedemption': (response) => { twitchRewardRedemption(response.data); }, 'Twitch.Sub': (response) => { twitchSubMessage(response.data); }, 'Twitch.ReSub': (response) => { twitchReSubMessage(response.data); }, 'Twitch.GiftSub': (response) => { twitchGiftMessage(response.data); }, 'Twitch.GiftBomb': (response) => { twitchGiftBombMessage(response.data); }, 'Twitch.Raid': (response) => { twitchRaidMessage(response.data); }, 'Twitch.ChatMessageDeleted': (response) => { twitchChatMessageDeleted(response.data); }, 'Twitch.UserBanned': (response) => { twitchUserBanned(response.data); }, 'Twitch.UserTimedOut': (response) => { twitchUserBanned(response.data); }, 'Twitch.ViewerCountUpdate': (response) => { twitchUpdateStatistics(response.data); }, 'Twitch.ChatCleared': (response) => { twitchChatClearMessages(); } }; if (showTwitch) { const twitchStatistics = ` `; document.querySelector('#statistics').insertAdjacentHTML('beforeend', twitchStatistics); if (showTwitchViewers == true) { document.querySelector('#twitch').style.display = ''; } registerPlatformHandlersToStreamerBot(twitchMessageHandlers, '[Twitch]'); } // --------------------------- // TWITCH EVENT FUNCTIONS async function twitchChatMessage(data) { if (showTwitchMessages == false) return; if (ignoreUserList.includes(data.message.username.toLowerCase())) return; if (data.message.message.startsWith("!") && excludeCommands == true) return; const template = chatTemplate; const clone = template.content.cloneNode(true); const messageId = data.messageId; const userId = data.message.username; 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 = ['twitch', 'chat']; const [avatarImage, fullmessage, badgeList] = await Promise.all([ getTwitchAvatar(data.message.username), getTwitchEmotes(data), getTwitchBadges(data) ]); header.remove(); user.style.color = data.message.color; user.innerHTML = `${data.message.displayName}`; message.innerHTML = fullmessage; if (data.message.isMe) { message.style.color = data.message.color; } if (showAvatar) avatar.innerHTML = ``; else avatar.remove(); if (showBadges) badges.innerHTML = badgeList; else badges.remove(); if (data.user.role == 4) { classes.push('streamer'); } if (data.message.firstMessage) { classes.push('first-chatter'); firstMessage.innerHTML('beforeend', ` First-time Chatter`); } else { firstMessage.remove(); } if (data.message.isReply) { classes.push('reply'); reply.insertAdjacentHTML('beforeend', ` ${data.message.reply.userName}: ${data.message.reply.msgBody}`); } else { reply.remove(); } if (data.message.isSharedChat) { if (showTwitchSharedChat == true) { classes.push('shared-chat'); if (!data.sharedChat.primarySource) { sharedChat.querySelector('span.origin').insertAdjacentHTML('beforeend', ` ${data.sharedChat.sourceRoom.name}`); } } else if (!data.sharedChat.primarySource && showTwitchSharedChat == false) { return; } } else { sharedChat.remove(); } if (showTwitchPronouns === true) { const userPronouns = await getTwitchUserPronouns(data.message.username); if (userPronouns) { pronoun.innerHTML = userPronouns; } } else { pronoun.remove(); } addMessageItem('twitch', clone, classes, userId, messageId); } async function twitchChatMessageGiantEmote(data) { if (showTwitchMessages == false) return; const userMessages = chatContainer.querySelectorAll(`.chat.twitch[data-user="${data.user_login}"]`); if (userMessages.length === 0) return; const firstMessage = userMessages[0]; const emoteImages = firstMessage.querySelectorAll(`img[data-emote-id="${data.gigantified_emote.id}"]`); if (emoteImages.length === 0) return; emoteImages.forEach(img => { img.classList.add("gigantified"); if (img.src.endsWith("2.0")) { img.src = img.src.replace("2.0", "3.0"); } }); } async function twitchFollowMessage(data) { if (showTwitchFollows == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = createRandomString(40); const userId = data.user_name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'follow']; header.remove(); message.remove(); value.remove(); user.innerHTML = `${data.user_name}`; action.innerHTML = ` followed you`; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchAnnouncementMessage(data) { if (showTwitchAnnouncements == false) return; const template = chatTemplate; const clone = template.content.cloneNode(true); const messageId = data.messageId; const userId = data.user.name.toLowerCase(); 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 = ['twitch', 'announcement']; classes.push(data.announcementColor.toLowerCase()); firstMessage.remove(); sharedChat.remove(); timestamp.remove(); //platform.remove(); avatar.remove(); pronoun.remove(); reply.remove(); const [fullmessage, badgeList] = await Promise.all([ getTwitchEmotesOnParts(data), getTwitchAnnouncementBadges(data) ]); header.innerHTML = ` Announcement`; user.style.color = data.user.color; user.innerHTML = `${data.user.name}`; message.innerHTML = fullmessage; if (showBadges) badges.innerHTML = badgeList; else badges.remove(); addMessageItem('twitch', clone, classes, userId, messageId); } async function twitchRewardRedemption(data) { if (showTwitchRewardRedemptions == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = createRandomString(40); const userId = data.user_name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'reward']; header.remove(); user.innerHTML = `${data.user_name}`; action.innerHTML = ` redeemed `; value.innerHTML = `${data.reward.title} (${data.reward.cost})`; var userInput = data.user_input ? `- ${data.user_input}` : ''; message.innerHTML = `${userInput}`; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchBitsMessage(data) { if (showTwitchBits == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = data.messageId; const userId = data.user.name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'bits']; header.remove(); user.innerHTML = `${data.user.name}`; action.innerHTML = ` cheered with `; var bits = data.message.bits > 1 ? 'bits' : 'bit'; value.innerHTML = `${data.message.bits} ${bits}`; var fullmessage = data.message.message.replace(/\bCheer\d+\b/g, '').replace(/\s+/g, ' ').trim(); message.innerHTML = fullmessage; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchSubMessage(data) { if (showTwitchSubs == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = createRandomString(40); const userId = data.user.name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'sub']; header.remove(); message.remove(); user.innerHTML = `${data.user.name}`; action.innerHTML = ` subscribed for `; var months = data.duration_months > 1 ? 'months' : 'month'; var tier = data.is_prime ? 'Prime' : 'Tier '+Math.floor(data.sub_tier/1000); value.innerHTML = `${data.duration_months} ${months} (${tier})`; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchReSubMessage(data) { if (showTwitchSubs == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = createRandomString(40); const userId = data.user.name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'resub']; const [fullmessage] = await Promise.all([ getTwitchEmotesOnParts(data) ]); header.remove(); user.innerHTML = `${data.user.name}`; action.innerHTML = ` subscribed for `; var months = data.cumulativeMonths > 1 ? 'months' : 'month'; var tier = data.isPrime ? 'Prime' : 'Tier '+Math.floor(data.subTier/1000); value.innerHTML = `${data.cumulativeMonths} ${months} (${tier})`; message.innerHTML = fullmessage; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchGiftMessage(data) { const isSub = showTwitchSubs === false; const isGift = showTwitchGiftedSubs === false; const isGiftTrain = showTwitchGiftedSubsUserTrain === false; if ( (!data.fromCommunitySubGift && (isSub || isGift)) || (data.fromCommunitySubGift && (isSub || isGiftTrain)) ) { return; } const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = createRandomString(40); const userId = data.user.name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'giftsub']; header.remove(); message.remove(); user.innerHTML = `${data.user.name}`; var months = data.durationMonths > 1 ? 'months' : 'month'; action.innerHTML = ` gifted ${data.durationMonths} ${months} subscription (Tier ${Math.floor(data.subTier/1000)}) to `; value.innerHTML = `${data.recipient.name}`; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchGiftBombMessage(data) { if (showTwitchSubs == false || showTwitchMassGiftedSubs == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = createRandomString(40); const userId = data.user.name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'giftbomb']; header.remove(); value.remove(); user.innerHTML = `${data.user.name}`; var subs = data.total > 1 ? 'subs' : 'sub'; action.innerHTML = ` gifted ${data.total} Tier ${Math.floor(data.sub_tier/1000)} ${subs} to the Community`; message.innerHTML = `They've gifted a total of ${data.cumulative_total} subs`; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchRaidMessage(data) { if (showTwitchRaids == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = createRandomString(40); const userId = data.from_broadcaster_user_name.toLowerCase(); const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['twitch', 'raid']; header.remove(); message.remove(); user.innerHTML = `${data.from_broadcaster_user_name}`; var viewers = data.viewers > 1 ? 'viewers' : 'viewer'; action.innerHTML = ` raided the channel with `; value.innerHTML = `${data.viewers} ${viewers}`; addEventItem('twitch', clone, classes, userId, messageId); } async function twitchChatMessageDeleted(data) { document.getElementById(data.messageId)?.remove(); } async function twitchUserBanned(data) { chatContainer.querySelectorAll(`[data-user="${data.user_login}"]`).forEach(element => { element.remove(); }); } async function twitchChatClearMessages() { chatContainer.querySelectorAll(`.item.twitch`).forEach(element => { element.remove(); }); } async function twitchUpdateStatistics(data) { if (showPlatformStatistics == false || showTwitchViewers == false) return; const viewers = DOMPurify.sanitize(data.viewerCount); document.querySelector('#statistics #twitch .viewers span').textContent = formatNumber(viewers); } // --------------------------- // TWITCH UTILITY FUNCTIONS async function getTwitchAvatar(user) { if (twitchAvatars.has(user)) { console.debug(`Twitch avatar found for ${user}!`); return twitchAvatars.get(user); } console.debug(`Twitch avatar not found for ${user}! Getting it from DECAPI!`); try { const response = await fetch(`https://decapi.me/twitch/avatar/${user}`); let avatar = await response.text(); if (!avatar) { avatar = 'https://static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-300x300.png'; } twitchAvatars.set(user, avatar); return avatar; } catch (err) { console.error(`Failed to fetch avatar for ${user}:`, err); return 'https://static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-300x300.png'; } } async function getTwitchEmotes(data) { const message = data.message.message; const emotes = data.emotes.sort((a, b) => b.startIndex - a.startIndex); const words = message.split(" ").map(word => { const emote = emotes.find(e => e.name === word); return emote ? `${emote.name}` : word; }); return words.join(" "); } async function getTwitchBadges(data) { const badges = data.message.badges; return badges .map(badge => ``) .join(''); } async function getTwitchEmotesOnParts(data) { let messageText = data.text; for (const part of data.parts) { if (part.type === 'emote') { const emoteName = part.text; const emoteUrl = part.imageUrl; const emoteHTML = `${emoteName}`; const escaped = emoteName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); let pattern; if (/^\w+$/.test(emoteName)) { pattern = `\\b${escaped}\\b`; } else { pattern = `(?<=^|[^\\w])${escaped}(?=$|[^\\w])`; } const regex = new RegExp(pattern, 'g'); messageText = messageText.replace(regex, emoteHTML); } } return messageText; } async function getTwitchAnnouncementBadges(data) { const badges = data.user.badges; return badges .map(badge => ``) .join(''); } async function getTwitchUserPronouns(username) { if (twitchPronouns.has(username)) { console.debug(`Pronouns found for ${username}. Getting it from Map...`); return twitchPronouns.get(username); } console.debug(`Pronouns not found for ${username} in the Map! Retrieving...`); try { const response = await streamerBotClient.getUserPronouns('twitch', username); const pronoun = response?.pronoun?.userFound ? `${response.pronoun.pronounSubject}/${response.pronoun.pronounObject}` : ''; twitchPronouns.set(username, pronoun); return pronoun; } catch (err) { console.error(`Couldn't retrieve pronouns for ${username}:`, err); return ''; } }