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}
+
+