diff --git a/receipt-printer/index.html b/receipt-printer/index.html new file mode 100644 index 0000000..f41a78a --- /dev/null +++ b/receipt-printer/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + +
Connecting...
+ + + + + \ No newline at end of file diff --git a/receipt-printer/script.js b/receipt-printer/script.js new file mode 100644 index 0000000..87dffc0 --- /dev/null +++ b/receipt-printer/script.js @@ -0,0 +1,691 @@ +//////////////// +// PARAMETERS // +//////////////// + +const queryString = window.location.search; +const urlParams = new URLSearchParams(queryString); + +const sbServerAddress = urlParams.get("address") || "127.0.0.1"; +const sbServerPort = urlParams.get("port") || "8080"; + + + +/////////////////// +// PAGE ELEMENTS // +/////////////////// + +const headerEl = document.getElementById('header'); +const contentEl = document.getElementById('content'); +const footerEl = document.getElementById('footer'); + +const avatarEl = document.getElementById('avatar'); +const titleEl = document.getElementById('title'); +const subtitleEl = document.getElementById('subtitle'); +const dateEl = document.getElementById('date'); + + + +////////////////////// +// GLOBAL VARIABLES // +////////////////////// + +const avatarMap = new Map(); + + + +///////////////////////// +// STREAMER.BOT CLIENT // +///////////////////////// + +const client = new StreamerbotClient({ + host: sbServerAddress, + port: sbServerPort, + + onConnect: (data) => { + console.log(`Streamer.bot successfully connected to ${sbServerAddress}:${sbServerPort}`) + console.debug(data); + SetConnectionStatus(true); + }, + + onDisconnect: () => { + console.error(`Streamer.bot disconnected from ${sbServerAddress}:${sbServerPort}`) + SetConnectionStatus(false); + } +}); + +client.on('General.Custom', (response) => { + console.debug(response.data); + CustomEvent(response.data); +}) + + + +//////////////////// +// STREAM PRINTER // +//////////////////// + +async function CustomEvent(data) { + if (data.actionName != 'Receipt Printer | Events') + return; + + // Get a reference to the template + const template = document.getElementById('receipt-template'); + + // Create a new instance of the template + const instance = template.content.cloneNode(true); + + // Get divs + const headerEl = instance.querySelector('#header'); + const contentEl = instance.querySelector('#content'); + const footerEl = instance.querySelector('#footer'); + const avatarEl = instance.querySelector('#avatar'); + const titleEl = instance.querySelector('#title'); + const subtitleEl = instance.querySelector('#subtitle'); + const dateEl = instance.querySelector('#date'); + + // Set the main contents + switch (data.__source) { + // Twitch events + case ('TwitchCheer'): + { + avatarEl.src = await GetAvatar(data.userName, 'twitch'); + titleEl.innerText = `${data.bits} BITS`; + subtitleEl.innerText = `${data.user}`; + + const messageEl = document.createElement('div'); + messageEl.innerHTML = data.message; + + // Render emotes + for (i in data.emotes) { + const emoteElement = ``; + const emoteName = EscapeRegExp(data.emotes[i].name); + + let regexPattern = emoteName; + + // Check if the emote name consists only of word characters (alphanumeric and underscore) + if (/^\w+$/.test(emoteName)) { + regexPattern = `\\b${emoteName}\\b`; + } + else { + // For non-word emotes, ensure they are surrounded by non-word characters or boundaries + regexPattern = `(?<=^|[^\\w])${emoteName}(?=$|[^\\w])`; + } + + const regex = new RegExp(regexPattern, 'g'); + messageEl.innerHTML = messageEl.innerHTML.replace(regex, emoteElement); + } + + // Render cheermotes + for (i in data.cheerEmotes) { + const bits = data.cheerEmotes[i].bits; + const imageUrl = data.cheerEmotes[i].imageUrl; + const name = data.cheerEmotes[i].name; + const cheerEmoteElement = ``; + const bitsElements = `${bits}` + messageEl.innerHTML = messageEl.innerHTML.replace(new RegExp(`\\b${name}${bits}\\b`, 'i'), cheerEmoteElement + bitsElements); + } + + contentEl.appendChild(messageEl); + } + break; + case ('TwitchSub'): + { + avatarEl.src = await GetAvatar(data.userName, 'twitch'); + titleEl.innerText = `${data.tier} subscriber`; + subtitleEl.innerText = `${data.user}`; + + const messageEl = document.createElement('div'); + messageEl.innerText = 'First time subscriber!'; + + contentEl.appendChild(messageEl); + } + break; + case ('TwitchReSub'): + { + avatarEl.src = await GetAvatar(data.userName, 'twitch'); + titleEl.innerText = `${data.tier} subscriber`; + subtitleEl.innerText = `${data.user}`; + + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.cumulative} months${data.monthStreak > 1 ? ' (' + data.monthStreak + ' in a row!)' : ''}`; + if (data.messageStripped) + messageEl.innerHTML += `

${data.messageStripped}`; + + contentEl.appendChild(messageEl); + } + break; + case ('TwitchGiftSub'): + { + // Don't put profile pictures for subs that come from Gift Bombs to avoid rate limits + if (data.fromGiftBomb) + avatarEl.style.display = 'none'; + else + avatarEl.src = await GetAvatar(data.recipientUserName, 'twitch'); + titleEl.innerText = `Gifted Sub`; + + const messageEl = document.createElement('div'); + messageEl.innerHTML += `${data.recipientUser}
received a ${data.tier} sub from
`; + if (data.anonymous) + messageEl.innerHTML += `a mysterious admirer...`; + else + messageEl.innerHTML += `${data.user}!`; + + contentEl.appendChild(messageEl); + } + break; + case ('TwitchGiftBomb'): + { + avatarEl.src = await GetAvatar(data.userName, 'twitch'); + titleEl.innerHTML = `${data.gifts} × Gifted Subs`; + subtitleEl.innerHTML += `✧*・゚✧ ${data.tier.toUpperCase()} ✧・゚*✧`; + if (data.anonymous) + subtitleEl.innerHTML += `
From a mystery person...`; + else + subtitleEl.innerHTML += `
${data.user}`; + + if (data.totalGifts > 1) { + const messageEl = document.createElement('div'); + messageEl.innerHTML = `They've gifted ${data.totalGifts} subs in total!`; + + contentEl.appendChild(messageEl); + } + else { + contentEl.style.display = 'none'; + } + } + break; + case ('TwitchRaid'): + { + avatarEl.src = await GetAvatar(data.userName, 'twitch'); + + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.user}
is raiding with a party of
${data.viewers} viewers!`; + + contentEl.appendChild(messageEl); + } + break; + + // YouTube Events + case ('YouTubeNewSponsor'): + { + if (data.profileImageUrl) + avatarEl.src = data.profileImageUrl; + else + avatarEl.style.display = 'none'; + + titleEl.innerText = `${data.levelName}`; + subtitleEl.innerText = `${data.user}`; + + contentEl.style.display = 'none'; + } + break; + case ('YouTubeGiftMembershipReceived'): + { + if (data.profileImageUrl) + avatarEl.src = data.profileImageUrl; + else + avatarEl.style.display = 'none'; + titleEl.innerText = `Gifted Membership`; + + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.user}
received a membership from
${data.gifterUser}!`; + + contentEl.appendChild(messageEl); + } + break; + case ('YouTubeSuperChat'): + { + if (data.profileImageUrl) + avatarEl.src = data.profileImageUrl; + else + avatarEl.style.display = 'none'; + titleEl.style.fontSize = '2em'; + titleEl.innerText = `${data.amount}`; + + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.user}
sent a Super Chat!`; + if (data.message) + messageEl.innerHTML += `

${data.message}`; + + contentEl.appendChild(messageEl); + } + break; + case ('YouTubeSuperSticker'): + { + // TODO: Need to add image URL of Super Sticker + if (data.profileImageUrl) + avatarEl.src = data.profileImageUrl; + else + avatarEl.style.display = 'none'; + titleEl.style.fontSize = '2em'; + titleEl.innerText = `${data.amount}`; + + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.user}
sent a Super Sticker!`; + + contentEl.appendChild(messageEl); + } + break; + break; + + // Kick Events + case ('KickSubscription'): + case ('KickResubscription'): + { + avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick')); + titleEl.innerText = `Subscriber`; + subtitleEl.innerText = `${data.user}`; + + const messageEl = document.createElement('div'); + if (data.monthsSubscribed > 1) + messageEl.innerText = `${data.monthsSubscribed} months`; + else + messageEl.innerText = 'First time subscriber!'; + + contentEl.appendChild(messageEl); + } + break; + case ('KickGiftSubscription'): + { + avatarEl.src = ConvertWEBPToPNG(data["recipient.profilePicture"]); + titleEl.innerText = `Gifted Sub`; + + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data["recipient.userName"]}
received a sub from
${data.user}!`; + + contentEl.appendChild(messageEl); + } + break; + case ('KickMassGiftSubscription'): + { + avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick')); + + // Calculate how many subs were gived + let maxIndex = -1; + for (const key in data) { + const match = key.match(/^recipient\.(\d+)\./); + if (match) { + const index = parseInt(match[1], 10); + if (index > maxIndex) { + maxIndex = index; + } + } + } + const totalGifts = maxIndex + 1; + + titleEl.innerHTML = `${totalGifts} × Gifted Subs`; + subtitleEl.innerText = `${data.user}`; + + const messageEl = document.createElement('div'); + + // Loop through each recipient and include it in the receipt + const recipients = {}; + + // Reconstruct recipient objects + for (const key in data) { + const match = key.match(/^recipient\.(\d+)\.(.+)$/); + if (match) { + const index = match[1]; + const field = match[2]; + + if (!recipients[index]) { + recipients[index] = {}; + } + + recipients[index][field] = data[key]; + } + } + + // Loop through and print userName + for (const index in recipients) { + messageEl.innerHTML += `${recipients[index].userName}
`; + } + + contentEl.appendChild(messageEl); + } + break; + + // StreamElements Events + case ('StreamElementsTip'): + { + const avatarURL = await GetAvatar(data.tipUsername, 'twitch'); + if (IsValidUrl(avatarURL)) + avatarEl.src = avatarURL; + else + avatarEl.style.display = 'none' + titleEl.style.fontSize = '2em'; + titleEl.innerText = FormatCurrency(data.tipAmount, data.tipCurrency); + subtitleEl.innerText = `${data.tipUsername}`; + + if (data.tipMessage) { + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.tipMessage}`; + + contentEl.appendChild(messageEl); + } + else { + contentEl.style.display = 'none'; + } + } + break; + + // Streamlabs Events + case ('StreamlabsDonation'): + { + const avatarURL = await GetAvatar(data.donationFrom, 'twitch'); + if (IsValidUrl(avatarURL)) + avatarEl.src = avatarURL; + else + avatarEl.style.display = 'none' + titleEl.style.fontSize = '2em'; + titleEl.innerText = data.donationFormattedAmount; + subtitleEl.innerText = `${data.donationFrom}`; + + if (data.donationMessage) { + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.donationMessage}`; + + contentEl.appendChild(messageEl); + } + else { + contentEl.style.display = 'none'; + } + } + break; + + // Fourthwall Events + case ('FourthwallDonation'): + { + const avatarURL = await GetAvatar(data.username, 'twitch'); + if (IsValidUrl(avatarURL)) + avatarEl.src = avatarURL; + else + avatarEl.style.display = 'none' + titleEl.style.fontSize = '2em'; + titleEl.innerText = FormatCurrency(data.amount, data.currency); + subtitleEl.innerText = `${data.username}`; + + if (data.message) { + const messageEl = document.createElement('div'); + messageEl.innerHTML = `${data.message}`; + + contentEl.appendChild(messageEl); + } + else { + contentEl.style.display = 'none'; + } + } + break; + // case ('FourthwallGiftPurchase'): + // break; + case ('FourthwallOrderPlaced'): + { + // Only print non-free orders + if (data["fw.total"] <= 0) + return; + + const avatarURL = await GetAvatar(data["fw.username"], 'twitch'); + if (IsValidUrl(avatarURL)) + avatarEl.src = avatarURL; + else + avatarEl.style.display = 'none' + titleEl.style.fontSize = '2em'; + titleEl.innerText = FormatCurrency(data["fw.total"], data["fw.currency"]); + if (data["fw.username"]) + subtitleEl.innerText = `${data["fw.username"]}`; + else if (data["fw.email"]) + subtitleEl.innerText = `${data["fw.email"]}`; + + // Compile a list of all items bought + const variants = []; + + // Iterate through all keys in the data object + for (const key in data) { + const match = key.match(/^fw\.variants\[(\d+)\]\.(\w+)$/); + if (match) { + const index = Number(match[1]); + const field = match[2]; + + // Make sure the array slot exists + if (!variants[index]) { + variants[index] = {}; + } + + // Assign the field to the appropriate variant object + variants[index][field] = data[key]; + } + } + + // Print each item on the receipt + const messageEl = document.createElement('div'); + variants.forEach((variant, i) => { + messageEl.innerHTML += `${variant.quantity} × ${variant.name}
`; + }); + messageEl.style.textAlign = 'left'; + + // Add a cute thank you message because you're uwu like that + const thankYouEl = document.createElement('div'); + thankYouEl.innerHTML += `
Thank you for your purchase!`; + + contentEl.appendChild(messageEl); + contentEl.appendChild(thankYouEl); + } + break; + case ('FourthwallSubscriptionPurchased'): + { + const avatarURL = await GetAvatar(data.nickname, 'twitch'); + if (IsValidUrl(avatarURL)) + avatarEl.src = avatarURL; + else + avatarEl.style.display = 'none' + titleEl.innerText = `New Member`; + subtitleEl.innerText = `${data.nickname}`; + + const messageEl = document.createElement('div'); + messageEl.innerText = `Thanks for joining at the ${FormatCurrency(data.amount, data.currency)} tier!`; + + contentEl.appendChild(messageEl); + } + break; + + // Don't print any event not excplicitly listed above + default: + return; + } + + // Set the timestamp + const { DateTime } = luxon; + const now = DateTime.local(); + const formatted = now.toFormat("cccc, LLLL d',' yyyy HH:mm:ss"); + + // Add ordinal suffix manually + function addOrdinal(n) { + if (n >= 11 && n <= 13) return 'th'; + switch (n % 10) { + case 1: return 'st'; + case 2: return 'nd'; + case 3: return 'rd'; + default: return 'th'; + } + } + + const day = now.day; + const ordinal = addOrdinal(day); + const fullFormatted = now.toFormat(`cccc, LLLL '${day}${ordinal}' yyyy HH:mm:ss`); + + dateEl.textContent = fullFormatted; + + // Send it to the print routine! + const receiptHTML = await GetRenderedHTML(instance); + client.doAction({ name: 'Receipt Printer | Print Routine' }, { receiptHTML: receiptHTML }); +} + + +////////////////////// +// HELPER FUNCTIONS // +////////////////////// + +async function GetAvatar(username, platform) { + + // First, check if the username is hashed already + if (avatarMap.has(`${username}-${platform}`)) { + console.debug(`Avatar found for ${username} (${platform}). Retrieving from hash map.`) + return avatarMap.get(`${username}-${platform}`); + } + + // If code reaches this point, the username hasn't been hashed, so retrieve avatar + switch (platform) { + case 'twitch': + { + console.debug(`No avatar found for ${username} (${platform}). Retrieving from Decapi.`) + let response = await fetch('https://decapi.me/twitch/avatar/' + username); + let data = await response.text(); + avatarMap.set(`${username}-${platform}`, data); + return data; + } + case 'kick': + { + console.debug(`No avatar found for ${username} (${platform}). Retrieving from Kick.`) + try { + let response = await fetch('https://kick.com/api/v2/channels/' + username); + console.log('https://kick.com/api/v2/channels/' + username) + let data = await response.json(); + let avatarURL = data.user.profile_pic; + if (!avatarURL) + avatarURL = 'https://kick.com/img/default-profile-pictures/default2.jpeg'; + avatarMap.set(`${username}-${platform}`, avatarURL); + return avatarURL; + } + catch (error) { + console.debug(error); + return 'https://kick.com/img/default-profile-pictures/default2.jpeg'; + } + } + } +} + +async function GetRenderedHTML(fragment) { + if (!(fragment instanceof DocumentFragment)) { + throw new Error('Argument must be a DocumentFragment'); + } + + // Filter out comment nodes from fragment content + const nodes = Array.from(fragment.childNodes).filter( + node => node.nodeType !== Node.COMMENT_NODE + ); + + const bodyContent = nodes + .map(node => node.outerHTML || node.textContent) + .join(''); + + // Get inline + + + ${bodyContent} + +`.trim(); + + return fullHTML; +} + +function ConvertWEBPToPNG(URL) { + return `https://images.weserv.nl/?url=${URL}&output=png`; +} + +function FormatCurrency(amount, currency) { + const isISOCode = /^[A-Z]{3}$/.test(currency); + + if (isISOCode) { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currency, + currencyDisplay: 'symbol', + }).format(amount); + } catch { + return `${amount.toFixed(2)} ${currency}`; + } + } + + // Handle some common symbols that go before the number + const symbolsBefore = ['$', '€', '£', '¥', '₹']; + + if (symbolsBefore.includes(currency)) { + return `${currency}${amount.toFixed(2)}`; + } + + // Otherwise default to appending after + return `${amount.toFixed(2)} ${currency}`; +} + +function IsValidUrl(string) { + try { + new URL(string); + return true; + } catch { + return false; + } +} + +function EscapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + + + +/////////////////////////////////// +// STREAMER.BOT WEBSOCKET STATUS // +/////////////////////////////////// + +// This function sets the visibility of the Streamer.bot status label on the overlay +function SetConnectionStatus(connected) { + let statusContainer = document.getElementById("statusContainer"); + if (connected) { + statusContainer.style.background = "#2FB774"; + statusContainer.innerText = "Connected!"; + statusContainer.style.opacity = 1; + setTimeout(() => { + statusContainer.style.transition = "all 2s ease"; + statusContainer.style.opacity = 0; + }, 10); + } + else { + statusContainer.style.background = "#D12025"; + statusContainer.innerText = "Connecting..."; + statusContainer.style.transition = ""; + statusContainer.style.opacity = 1; + } +} \ No newline at end of file diff --git a/receipt-printer/style.css b/receipt-printer/style.css new file mode 100644 index 0000000..47d60c3 --- /dev/null +++ b/receipt-printer/style.css @@ -0,0 +1,54 @@ +body { + font-size: 16px; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + text-align: center; +} + +#header { + +} + +#avatar { + height: 6em; + border-radius: 50%; + object-fit: cover; + overflow: hidden; +} + +#title { + font-weight: 900; + font-size: 1.5em; + text-transform: uppercase; +} + +#subtitle { + font-weight: 700; + font-size: 1.2em; +} + +#attribute { + padding: 0.5em 0em; +} + +#content { + border-top: 1px solid black; + border-bottom: 1px solid black; + border-left: none; + border-right: none; + margin: 1em 0em; + padding: 0.5em 0em; +} + +#date { + margin-top: 1em; + font-size: 0.7em; + text-transform: uppercase; +} + +#footer { + +} + +.emote { + height: 1em; +} \ No newline at end of file