/* ------------------------ */ /* YOUTUBE MODULE VARIABLES */ /* ------------------------ */ const showYoutube = getURLParam("showYoutube", true); const showYouTubeMessages = getURLParam("showYouTubeMessages", true); const showYouTubeSuperChats = getURLParam("showYouTubeSuperChats", true); const showYouTubeSuperStickers = getURLParam("showYouTubeSuperStickers", true); const showYouTubeSuperStickerGif = getURLParam("showYouTubeSuperStickerGif", true); const showYouTubeMemberships = getURLParam("showYouTubeMemberships", true); const showYouTubeGiftMemberships = getURLParam("showYouTubeGiftMemberships", true); const showYouTubeMembershipsTrain = getURLParam("showYouTubeMembershipsTrain", true); const showYouTubeStatistics = getURLParam("showYouTubeStatistics", true); let youTubeCustomEmotes = []; let youTubeBTTVEmotes = []; userColors.set('youtube', new Map()); // YOUTUBE EVENTS HANDLERS const youtubeMessageHandlers = { 'YouTube.Message': (response) => { youTubeChatMessage(response.data); }, 'YouTube.UserBanned': (response) => { youTubeUserBanned(response.data); }, 'YouTube.SuperChat': (response) => { youTubeSuperChatMessage(response.data); }, 'YouTube.SuperSticker': (response) => { youTubeSuperStickerMessage(response.data); }, 'YouTube.NewSponsor': (response) => { youTubeNewSponsorMessage(response.data); }, 'YouTube.MemberMileStone': (response) => { youTubeNewSponsorMessage(response.data); }, 'YouTube.MembershipGift': (response) => { youTubeGiftBombMessage(response.data); }, 'YouTube.GiftMembershipReceived': (response) => { youTubeGiftBombReceivedMessage(response.data); }, 'YouTube.StatisticsUpdated': (response) => { youTubeUpdateStatistics(response.data); } }; if (showYoutube) { const youtubeStatistics = ` `; document.querySelector('#statistics').insertAdjacentHTML('beforeend', youtubeStatistics); if (showYouTubeStatistics == true) { document.querySelector('#youtube').style.display = ''; } registerPlatformHandlersToStreamerBot(youtubeMessageHandlers, '[YouTube]'); } // --------------------------- // YOUTUBE EVENT FUNCTIONS async function youTubeChatMessage(data) { if (showYouTubeMessages == false) return; if (ignoreUserList.includes(data.user.name.toLowerCase())) return; if (data.message.startsWith("!") && excludeCommands == true) return; const template = chatTemplate; const clone = template.content.cloneNode(true); const messageId = data.eventId; const userId = data.user.id; 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 = ['youtube', 'chat']; const [fullmessage, badgeList] = await Promise.all([ getYouTubeEmotes(data), getYouTubeBadges(data) ]); header.remove(); firstMessage.remove(); sharedChat.remove(); reply.remove(); pronoun.remove(); var color = await createRandomColor('youtube', data.user.name); user.style.color = color; user.innerHTML = `${data.user.name}`; message.innerHTML = fullmessage; if (showAvatar) avatar.innerHTML = ``; else avatar.remove(); if (showBadges) badges.innerHTML = badgeList; else badges.remove(); if (data.user.isOwner) { classes.push('streamer'); } addMessageItem('youtube', clone, classes, userId, messageId); } async function youTubeSuperChatMessage(data) { if (showYouTubeSuperChats == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = data.eventId; const userId = data.user.id; const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['youtube', 'superchat']; header.remove(); user.innerHTML = `${data.user.name}`; action.innerHTML = ` superchatted `; value.innerHTML = `${data.amount}`; var fullmessage = await getYouTubeEmotes(data); message.innerHTML = fullmessage; addEventItem('youtube', clone, classes, userId, messageId); } async function youTubeSuperStickerMessage(data) { if (showYouTubeMemberships == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = data.eventId; const userId = data.user.id; const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['youtube', 'sticker']; if (showYouTubeSuperStickerGif == true) { youtubeStickerUrl = await getYouTubeStickerImage(data); header.innerHTML = ``; } else { header.remove(); } user.innerHTML = `${data.user.name}`; action.innerHTML = ` sent a supersticker `; value.innerHTML = `(${data.amount})`; message.remove(); addEventItem('youtube', clone, classes, userId, messageId); } async function youTubeNewSponsorMessage(data) { if (showYouTubeMemberships == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = data.eventId; const userId = data.user.id; const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['youtube', 'sponsor']; header.remove(); user.innerHTML = `${data.user.name}`; action.innerHTML = ` became a member `; var months = data.months > 1 ? 'months' : 'month'; value.innerHTML = `${data.months || 1} ${months}`; var fullmessage = await getYouTubeEmotes(data); message.innerHTML = fullmessage; addEventItem('youtube', clone, classes, userId, messageId); } async function youTubeGiftBombMessage(data) { if (showYouTubeMemberships == false || showYouTubeGiftMemberships == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = data.eventId; const userId = data.user.id; const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['youtube', 'giftbomb']; header.remove(); user.innerHTML = `${data.user.name}`; action.innerHTML = ` gifted `; var count = data.count > 1 ? 'memberships' : 'membership'; value.innerHTML = `${data.count} ${count} (Tier ${data.tier}) to the channel`; message.remove(); addEventItem('youtube', clone, classes, userId, messageId); } async function youTubeGiftBombReceivedMessage(data) { if (showYouTubeMemberships == false || showYouTubeGiftMemberships == false || showYouTubeMembershipsTrain == false) return; const template = eventTemplate; const clone = template.content.cloneNode(true); const messageId = data.eventId; const userId = data.user.id; const { header, platform, user, action, value, 'actual-message': message } = Object.fromEntries( [...clone.querySelectorAll('[class]')] .map(el => [el.className, el]) ); const classes = ['youtube', 'giftbomb']; header.remove(); user.innerHTML = `${data.user.name}`; action.innerHTML = ` gifted a membership (Tier ${data.tier}) to `; value.innerHTML = `${data.gifter.name}`; message.remove(); addEventItem('youtube', clone, classes, userId, messageId); } async function youTubeUserBanned(data) { chatContainer.querySelectorAll(`[data-user="${data.bannedUser.id}"]:not(.event)`).forEach(element => { element.remove(); }); } async function youTubeUpdateStatistics(data) { if (showPlatformStatistics == false || showYouTubeStatistics == false) return; const viewers = DOMPurify.sanitize(data.concurrentViewers); const likes = DOMPurify.sanitize(data.likeCount); document.querySelector('#statistics #youtube .viewers span').textContent = formatNumber(viewers); document.querySelector('#statistics #youtube .likes span').textContent = formatNumber(likes); } // --------------------------- // YOUTUBE UTILITY FUNCTIONS async function getYouTubeEmotes(data) { let message = data.message; const channelId = data.broadcast?.channelId; if (!channelId) return message; // Load BTTV Emotes if not already loaded if (youTubeBTTVEmotes.length === 0) { try { const res = await fetch(`https://api.betterttv.net/3/cached/users/youtube/${channelId}`); const emoteData = await res.json(); console.debug('Getting YouTube BTTV Channel Emotes', `https://api.betterttv.net/3/cached/users/youtube/${channelId}`, emoteData); youTubeBTTVEmotes = [ ...(emoteData.sharedEmotes || []), ...(emoteData.channelEmotes || []) ]; } catch (err) { console.warn("[YouTube] Failed to load BTTV emotes:", err); } } // Create an Emote Map const emoteMap = new Map(); // BTTV emotes for (const emote of youTubeBTTVEmotes) { const imageUrl = `https://cdn.betterttv.net/emote/${emote.id}/1x`; const emoteElement = `${emote.code}`; emoteMap.set(emote.code, { html: emoteElement, raw: emote.code }); } // YouTube emotes (ex: :hand-pink-waving:) if (data.emotes) { for (const emote of data.emotes) { const emoteElement = `${emote.name}`; emoteMap.set(emote.name, { html: emoteElement, raw: emote.name }); } } // Custom Member Emotes if (data.user.isSponsor === true || data.user.isOwner === true) { for (const [name, url] of Object.entries(youTubeCustomEmotes)) { const emoteElement = `${name}`; emoteMap.set(`:${name}:`, { html: emoteElement, raw: `:${name}:` }); } } // DOMParser just to replace the text nodes const parser = new DOMParser(); const doc = parser.parseFromString(`
${message}
`, 'text/html'); const container = doc.body.firstChild; function escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function replaceEmotesInText(text) { // Sort them DESC to avoid conflicts with similar names const sorted = Array.from(emoteMap.values()).sort((a, b) => b.raw.length - a.raw.length); for (const { raw, html } of sorted) { const escaped = escapeRegex(raw); // Emotes with colons: :emote: → allow colons const isDelimited = raw.startsWith(':') && raw.endsWith(':'); const regex = isDelimited ? new RegExp(escaped, 'g') : new RegExp(`(?', isSponsor && '', isModerator && '', isOwner && '', ].filter(Boolean).join(''); return badgesHTML; }