/* ------------------------ */
/* 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 = `
`;
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 = `
`;
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 = `
`;
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;
}