Created
June 16, 2025 08:48
-
-
Save AlcaDesign/cb0b3ef4059a5d928b8a9a61a54b0fb7 to your computer and use it in GitHub Desktop.
Parse Twitch emotes and cheermotes for (old) tmi.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const prefixes = [ | |
'Cheer', 'DoodleCheer', 'cheerwhal', 'Corgo', 'Scoops', 'uni', 'ShowLove', 'Party', 'SeemsGood', 'Pride', | |
'Kappa', 'FrankerZ', 'HeyGuys', 'DansGame', 'TriHard', 'Kreygasm', '4Head', 'SwiftRage', 'NotLikeThis', | |
'FailFish', 'VoHiYo', 'PJSalt', 'MrDestructoid', 'bday', 'RIPCheer', 'Shamrock', 'BitBoss', 'Streamlabs', | |
'Muxy', 'HolidayCheer', 'Goal', 'Anon', 'Charity' | |
]; | |
const cheermoteRegex = new RegExp(`\\b(${prefixes.join('|')})(\\d+)\\b`, 'ig'); | |
function onMessage(channel, tags, messageText, self) { | |
// Spread syntax or Array.from to preserve unicode surrogate pairs (emojis) to match Twitch's behavior | |
const textArr = [ ...messageText ]; | |
const parts = [ messageText ]; | |
if(tags.emotes) { | |
parts.splice(0); | |
/** @type {{ start: number; end: number; emoteId: string; ele: HTMLImageElement; }[]} */ | |
const emotes = []; | |
for(const [ emoteId, indices ] of Object.entries(tags.emotes || {})) { | |
for(const n of indices) { | |
const spl = n.split('-'); | |
const [ start, end ] = [ +spl[0], +spl[1] + 1 ]; | |
const text = textArr.slice(start, end).join(''); | |
const ele = renderEmote(emoteId, text); | |
emotes.push({ start, end, emoteId, ele }); | |
} | |
} | |
emotes.sort((a, b) => a.start - b.start); | |
parts.push(textArr.slice(0, emotes[0].start).join('')); | |
for(let i = 0; i < emotes.length; i++) { | |
const { end, ele } = emotes[i]; | |
const nextEmote = emotes[i + 1]; | |
parts.push(ele, textArr.slice(end, nextEmote ? nextEmote.start : undefined).join('')); | |
} | |
} | |
// Check for cheermotes in the message | |
for(let i = parts.length - 1; i >= 0; i--) { | |
const n = parts[i]; | |
if(typeof n !== 'string') { | |
continue; | |
} | |
/** @type {{ start: number; end: number; ele: HTMLSpanElement; }[]} */ | |
const cheermotes = []; | |
for(const m of n.matchAll(cheermoteRegex)) { | |
const [ match, prefix, amountString ] = m; | |
if(amountString.startsWith('0')) { | |
// Skip cheermotes that start with 0, which are invalid | |
continue; | |
} | |
const amount = parseInt(amountString, 10); | |
const ele = renderCheermote(prefix, amount); | |
const [ start, end ] = [ m.index, m.index + match.length ]; | |
cheermotes.push({ start, end, ele }); | |
} | |
parts.splice( | |
i, 1, | |
n.slice(0, cheermotes[0]?.start ?? n.length), | |
...cheermotes.flatMap(({ end, ele: span }, i) => { | |
const nextCheer = cheermotes[i + 1]; | |
return [ span, n.slice(end, nextCheer ? nextCheer.start : n.length) ]; | |
}) | |
); | |
} | |
return parts; | |
} | |
function renderCheermote(prefix, amt) { | |
const image = document.createElement('img'); | |
image.className = 'chat-cheer'; | |
const tier = amt >= 10000 ? 10000 : amt >= 5000 ? 5000 : amt >= 1000 ? 1000 : amt >= 100 ? 100 : 1; | |
image.src = `https://d3aqoihi2n8ty8.cloudfront.net/actions/${prefix.toLowerCase()}/dark/animated/${tier}/4.gif`; | |
image.alt = image.title = `${prefix}${amt}`; | |
const span = document.createElement('span'); | |
span.className = 'chat-cheer-container'; | |
span.append(image, ` ${amt}`); | |
return span; | |
} | |
function renderEmote(emoteId, text) { | |
const image = new Image(); | |
image.className = 'chat-emote'; | |
image.src = `https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/default/dark/3.0`; | |
image.alt = text; | |
return image; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment