diff --git a/.common/core/widget-dock-core/icons/connected.svg b/.common/core/widget-dock-core/icons/connected.svg
new file mode 100644
index 0000000..4e0799a
--- /dev/null
+++ b/.common/core/widget-dock-core/icons/connected.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/.common/core/widget-dock-core/icons/disconnected.svg b/.common/core/widget-dock-core/icons/disconnected.svg
new file mode 100644
index 0000000..962f8ef
--- /dev/null
+++ b/.common/core/widget-dock-core/icons/disconnected.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/.common/core/widget-dock-core/index.html b/.common/core/widget-dock-core/index.html
new file mode 100644
index 0000000..2cecd25
--- /dev/null
+++ b/.common/core/widget-dock-core/index.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.common/core/widget-dock-core/script.js b/.common/core/widget-dock-core/script.js
new file mode 100644
index 0000000..f224816
--- /dev/null
+++ b/.common/core/widget-dock-core/script.js
@@ -0,0 +1,271 @@
+////////////////
+// PARAMETERS //
+////////////////
+
+const queryString = window.location.search;
+const urlParams = new URLSearchParams(queryString);
+
+const configJson = urlParams.get("config") || "";
+
+///////////////////
+// PAGE ELEMENTS //
+///////////////////
+
+const sbConnectDialog = document.getElementById('sb-connect-dialog');
+const sbAddressInput = document.getElementById('sb-address');
+const sbPortInput = document.getElementById('sb-port');
+const sbPasswordInput = document.getElementById('sb-password');
+const sbErrorLabel = document.getElementById('sb-error-label');
+
+const sbRequiredActionsDialog = document.getElementById('sb-required-actions-dialog');
+const sbRequiredActionsSuccessLabel = document.getElementById('sb-required-actions-success');
+const sbRequiredActionsFailureLabel = document.getElementById('sb-required-actions-failure');
+const sbRequiredActionsFailureSubtext = document.getElementById('sb-required-actions-failure-subtext');
+
+
+const sbRequiredActionsList = document.getElementById('sb-required-actions-list');
+const sbImportCodeLabel = document.getElementById('sb-import-code');
+const sbImportCopyButton = document.getElementById('sb-import-copy-button');
+
+
+const sbActionsButton = document.getElementById('sb-actions-button');
+const sbStatusButton = document.getElementById('sb-status-button');
+const sbStatusIcon = document.getElementById('sb-status-icon');
+
+const blurLayer = document.getElementById('blur-layer');
+
+
+
+/////////////////////////
+// STREAMER.BOT CLIENT //
+/////////////////////////
+
+// Check local storage
+if (localStorage.getItem('sbServerAddress') === null)
+ localStorage.setItem('sbServerAddress', '127.0.0.1');
+if (localStorage.getItem('sbServerPort') === null)
+ localStorage.setItem('sbServerPort', '8080');
+
+sbAddressInput.value = localStorage.getItem('sbServerAddress');
+sbPortInput.value = localStorage.getItem('sbServerPort');
+sbPasswordInput.value = localStorage.getItem('sbServerPassword');
+
+const sbServerAddress = sbAddressInput.value;
+const sbServerPort = sbPortInput.value;
+const sbServerPassword = sbPasswordInput.value;
+
+window.sbClient = new StreamerbotClient({
+ host: sbServerAddress,
+ port: sbServerPort,
+ password: sbServerPassword,
+ immediate: true,
+
+ onConnect: (data) => {
+ console.log(`Streamer.bot successfully connected to ${sbServerAddress}:${sbServerPort}`)
+ console.debug(data);
+
+ SetConnectionState(true);
+ },
+
+ onDisconnect: () => {
+ console.error(`Streamer.bot disconnected from ${sbServerAddress}:${sbServerPort}`)
+ SetConnectionState(false);
+ },
+
+ onError: (err) => {
+ console.error(`Streamer.bot disconnected from ${sbServerAddress}:${sbServerPort}`)
+ SetErrorMessage(err);
+ }
+});
+
+function SetConnectionState(isConnected) {
+ if (isConnected) {
+ localStorage.setItem('sbServerAddress', sbAddressInput.value);
+ localStorage.setItem('sbServerPort', sbPortInput.value);
+ localStorage.setItem('sbServerPassword', sbPasswordInput.value);
+
+ sbConnectDialog.style.display = "none";
+ blurLayer.style.display = "none";
+ sbErrorLabel.style.display = 'none';
+ sbStatusIcon.src = 'icons/connected.svg';
+
+ sbStatusButton.title = `Connected to ${window.sbClient.info.name} (${window.sbClient.info.version})`;
+
+ // Check required actions
+ CheckRequiredActions();
+ }
+ else {
+ sbConnectDialog.style.display = "flex";
+ blurLayer.style.display = "block";
+ sbStatusIcon.src = 'icons/disconnected.svg';
+
+ SetErrorMessage('Disconnected from Streamer.bot');
+ }
+}
+
+function SetErrorMessage(error) {
+ sbErrorLabel.textContent = error;
+ sbErrorLabel.style.display = 'block';
+}
+
+
+
+////////////
+// CONFIG //
+////////////
+
+if (configJson) {
+ // Set the header/title of the page
+ fetch(configJson)
+ .then(res => res.json())
+ .then(config => {
+ const root = document.documentElement;
+ const domain = getComputedStyle(root).getPropertyValue('--domain').trim();
+
+ // Set the page title
+ parent.document.title = `${domain} • ${config.title}`;
+
+ // Set the title label
+ title.textContent = config.title;
+ })
+ .catch(err => console.error('Failed to load config:', err));
+}
+
+async function CheckRequiredActions() {
+ if (configJson) {
+ console.debug('Checking required actions...')
+
+ // Set the header/title of the page
+ fetch(configJson)
+ .then(res => res.json())
+ .then(async config => {
+ // Clear the required actions list
+ const sbRequiredActionsList = document.getElementById('sb-required-actions-list');
+ sbRequiredActionsList.innerHTML = '';
+
+ // Set the import code
+ sbImportCodeLabel.textContent = config.sbImportCode;
+
+ // Assume all actions are found
+ SetRequiredActionState(true);
+
+ // Check each required action
+ if (config.requiredSbActions) {
+ // Get a full list of actions currently installed in Streamer.bot
+ const response = await sbClient.getActions();
+
+ // Iterate over required SB actions and check if they're present
+ config.requiredSbActions.forEach(req => {
+ const exists = response.actions.some(act => act.id === req.id);
+
+ console.debug(`${req.name}: ${exists ? 'Found' : 'Missing'}`)
+
+ // As soon as one is found that doesn't exist, throw up warning
+ if (!exists)
+ SetRequiredActionState(false);
+
+ // container div
+ const item = document.createElement('div');
+ item.className = 'sb-required-action';
+
+ // name label
+ const nameLabel = document.createElement('label');
+ nameLabel.textContent = req.name;
+
+ // status label
+ const statusLabel = document.createElement('label');
+ statusLabel.className = 'sb-required-action-found';
+ //statusLabel.textContent = exists ? '✅' : '❌';
+ statusLabel.textContent = exists ? 'Found' : 'Missing';
+ statusLabel.style.color = exists ? '#00d26a' : '#f92f60';
+ statusLabel.style.fontWeight = 500;
+
+ // append labels to div
+ item.appendChild(nameLabel);
+ item.appendChild(statusLabel);
+
+ // append div to list
+ sbRequiredActionsList.appendChild(item);
+ });
+ }
+ else
+ {
+ // There are no required actions, so hide the button
+ sbActionsButton.style.display = 'none';
+ }
+ })
+ .catch(err => console.error('Failed to load config:', err));
+ }
+}
+
+function SetRequiredActionState(isSuccess) {
+ if (isSuccess)
+ {
+ sbRequiredActionsSuccessLabel.style.display = 'block';
+ sbRequiredActionsFailureLabel.style.display = 'none';
+ sbRequiredActionsFailureSubtext.style.display = 'none';
+
+ sbActionsButton.title = `All actions found`;
+
+ sbActionsButton.textContent = '✅';
+ }
+ else
+ {
+ sbRequiredActionsSuccessLabel.style.display = 'none';
+ sbRequiredActionsFailureLabel.style.display = 'block';
+ sbRequiredActionsFailureSubtext.style.display = 'block';
+
+ sbActionsButton.title = `You are missing Streamer.bot actions`;
+
+ sbActionsButton.textContent = '⚠️';
+
+ OpenRequiredActionsDialog();
+ }
+}
+
+
+///////////////////////
+// PAGE INTERACTIONS //
+///////////////////////
+
+function Connect() {
+ sbClient.options.host = sbAddressInput.value;
+ sbClient.options.port = sbPortInput.value;
+ sbClient.options.password = sbPasswordInput.value;
+ sbClient.connect();
+}
+
+function CopyImportCode() {
+ const textToCopy = sbImportCodeLabel.textContent;
+ navigator.clipboard.writeText(textToCopy)
+ .then(() => {
+ console.debug('Copied to clipboard!');
+
+ // Click feedback
+ const root = document.documentElement;
+ const successColor = getComputedStyle(root).getPropertyValue('--success-color').trim();
+ const buttonColor = getComputedStyle(root).getPropertyValue('--button-color').trim();
+ sbImportCopyButton.textContent = 'Copied!';
+ sbImportCopyButton.style.background = successColor;
+ setTimeout(() => {
+ sbImportCopyButton.textContent = 'Copy';
+ sbImportCopyButton.style.background = buttonColor;
+ }, 1500);
+ })
+ .catch(err => console.error('Failed to copy text: ', err));
+}
+
+function OpenConnectDialog() {
+ sbConnectDialog.style.display = "flex";
+ blurLayer.style.display = "block";
+}
+
+function OpenRequiredActionsDialog() {
+ sbRequiredActionsDialog.style.display = "flex";
+ blurLayer.style.display = "block";
+}
+
+function CloseRequiredActionsDialog() {
+ sbRequiredActionsDialog.style.display = "none";
+ blurLayer.style.display = "none";
+}
\ No newline at end of file
diff --git a/.common/core/widget-dock-core/style.css b/.common/core/widget-dock-core/style.css
new file mode 100644
index 0000000..8eb352c
--- /dev/null
+++ b/.common/core/widget-dock-core/style.css
@@ -0,0 +1,118 @@
+html,
+body {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+
+
+/**************/
+/*** HEADER ***/
+/**************/
+
+#header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 1em;
+ gap: 1em;
+ box-shadow: 0px 0px 10px rgba(0, 0, 0, 1);
+}
+
+#header-end {
+ margin-left: auto;
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: 1em;
+}
+
+#header-end button {
+ padding: 0.2em;
+}
+
+
+
+/*********************/
+/*** PAGE CONTENTS ***/
+/*********************/
+
+#content {
+ flex: 1;
+ overflow: auto;
+ margin: 1em;
+}
+
+
+
+/******************/
+/*** BLUR LAYER ***/
+/******************/
+
+#blur-layer {
+ position: fixed;
+ inset: 0;
+ /* top:0; left:0; bottom:0; right:0 */
+ backdrop-filter: blur(5px);
+ /* blur the content behind */
+ -webkit-backdrop-filter: blur(5px);
+ /* Safari support */
+ z-index: 1;
+ /* behind the modal */
+}
+
+
+
+/********************************/
+/*** STREAMER.BOT CONNECT BOX ***/
+/********************************/
+
+#sb-required-actions-dialog {
+ max-width: 30em;
+ display: none;
+}
+
+#sb-connect-button:enabled:hover {
+ background-color: #3be477;
+}
+
+#sb-error-label {
+ background: #a82d2e33;
+ border-radius: 0.5em;
+ padding: 1em;
+ color: #c65e5e;
+ display: none;
+}
+
+
+
+/******************************************/
+/*** STREAMER.BOT REQUIRED ACTIONS LIST ***/
+/******************************************/
+
+.sb-required-action {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.sb-required-action-found {
+ margin-left: auto;
+}
+
+#sb-import-code {
+ white-space: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ max-width: 100%;
+ padding: 0.5em;
+ font-family: monospace;
+ font-size: 1em;
+}
+
+#sb-import-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+}
\ No newline at end of file
diff --git a/.common/resources/logo.png b/.common/resources/logo.png
new file mode 100644
index 0000000..0d8a815
Binary files /dev/null and b/.common/resources/logo.png differ
diff --git a/.common/styles/global.css b/.common/styles/global.css
new file mode 100644
index 0000000..4db99bf
--- /dev/null
+++ b/.common/styles/global.css
@@ -0,0 +1,278 @@
+/*****************/
+/*** VARIABLES ***/
+/*****************/
+
+:root {
+ --domain: nutty;
+ --background-color: #181818;
+ --accent-color: #2196f3;
+ --success-color: #3be477;
+ --button-color: #2e2e2e;
+ --dialog-background: #1d1d1d;
+ --callout-background: #181818;
+}
+
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ font-family: Inter, system-ui, sans-serif;
+ color: white;
+ background-color: var(--background-color);
+}
+
+
+
+/************/
+/*** TEXT ***/
+/************/
+
+.title {
+ font-weight: 900;
+ font-size: 1.5em;
+ text-transform: uppercase;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
+}
+
+.setting-description {
+ font-size: 0.9em;
+ font-weight: 100;
+}
+
+
+
+/***************/
+/*** DIVIDER ***/
+/***************/
+
+.divider {
+ border: none;
+ /* border-top: 0.1px solid #444444; */
+ margin: 0.3em 0;
+}
+
+
+
+/***************/
+/*** BUTTONS ***/
+/***************/
+
+button {
+ font-size: 1em;
+ font-weight: 600;
+ background-color: var(--button-color);
+ color: white;
+ opacity: 0.8;
+ border-width: 0;
+ border-radius: 0.5em;
+ border: 1px solid #404040;
+ padding: 0.5em 1em;
+ width: 100%;
+ transition: all 0.2s ease-in-out;
+}
+
+button:hover {
+ opacity: 1;
+ cursor: pointer;
+}
+
+button:disabled {
+ opacity: 0.3;
+ cursor: inherit;
+}
+
+
+
+/************************/
+/*** INPUT TEXT BOXES ***/
+/************************/
+
+input {
+ border-radius: 0.5em;
+ width: auto;
+ padding: 0.5em;
+ background-color: #171717;
+ border: 1px solid #404040;
+ color: white;
+ font-size: 0.9em;
+}
+
+input:disabled {
+ opacity: 0.5;
+}
+
+
+
+/***********************/
+/*** SLIDER SWITCHES ***/
+/***********************/
+
+/* Switch styling */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 3em;
+ height: 1.5em;
+ font-size: 1em;
+ overflow: hidden;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ transition: 0.4s;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 1.1em;
+ width: 1.1em;
+ left: 0.2em;
+ top: 50%;
+ transform: translateY(-50%);
+ background-color: white;
+ transition: 0.4s;
+
+}
+
+input:checked+.slider {
+ background-color: var(--accent-color);
+}
+
+input:focus+.slider {
+ box-shadow: 0 0 1px var(--accent-color);
+}
+
+input:checked+.slider:before {
+ transform: translate(1.5em, -50%);
+ /* move knob across, stay centered */
+}
+
+/* Rounded sliders */
+.slider.round {
+ border-radius: 1.5em;
+}
+
+.slider.round:before {
+ border-radius: 50%;
+}
+
+
+
+/******************/
+/*** SCROLLBARS ***/
+/******************/
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #2c2c2c;
+ /* Color of the tracking area */
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: #9f9f9f;
+ /* Color of the scroll thumb */
+ border-radius: 4px;
+ /* Roundness of the scroll thumb */
+ border: none;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: #d1d1d1;
+ /* Color of the scroll thumb on hover */
+}
+
+
+
+/***************/
+/*** IFRAMES ***/
+/***************/
+
+iframe {
+ border-width: 0px;
+}
+
+
+
+/***************/
+/*** CALLOUT ***/
+/***************/
+
+.callout {
+ font-size: 0.9em;
+ font-weight: 100;
+ background-color: var(--callout-background);
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+ padding: 1em;
+
+ border-radius: 0.5em;
+ border: 1px solid #40404080;
+}
+
+
+
+/**************/
+/*** DIALOG ***/
+/**************/
+
+.dialog {
+ font-size: 1em;
+ background-color: var(--dialog-background);
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ gap: 1em;
+ padding: 1em;
+
+ border-radius: 0.5em;
+ border: 1px solid #40404080;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ z-index: 1000; /* above overlay */
+}
+
+.dialog-nav-button {
+ background: transparent;
+ border: none;
+ font-size: 1.5em;
+ font-weight: 100;
+ padding: 0em;
+ width: 1em;
+ height: 1em;
+ position: absolute;
+ position: fixed; /* fixed to viewport */
+ top: 0.5em; /* small offset from top */
+ right: 0.5em; /* small offset from right */
+}
+
+.dialog-nav-button:hover {
+ background: var(--button-color);
+}
\ No newline at end of file
diff --git a/printer-bot/config.json b/printer-bot/config.json
new file mode 100644
index 0000000..cc9219c
--- /dev/null
+++ b/printer-bot/config.json
@@ -0,0 +1,14 @@
+{
+ "title": "Printer Bot",
+ "requiredSbActions": [
+ {
+ "id": "2e40b2b2-a751-4504-b1ba-a628361a44cf",
+ "name": "Printer Bot | Events"
+ },
+ {
+ "id": "5c756513-a1d0-4285-9dbc-21ad34491310",
+ "name": "Printer Bot | Print Routine"
+ }
+ ],
+ "sbImportCode": "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"
+}
\ No newline at end of file
diff --git a/printer-bot/icons/platforms/kick.png b/printer-bot/contents/icons/platforms/kick.png
similarity index 100%
rename from printer-bot/icons/platforms/kick.png
rename to printer-bot/contents/icons/platforms/kick.png
diff --git a/printer-bot/icons/platforms/kofi.png b/printer-bot/contents/icons/platforms/kofi.png
similarity index 100%
rename from printer-bot/icons/platforms/kofi.png
rename to printer-bot/contents/icons/platforms/kofi.png
diff --git a/printer-bot/icons/platforms/patreon.png b/printer-bot/contents/icons/platforms/patreon.png
similarity index 100%
rename from printer-bot/icons/platforms/patreon.png
rename to printer-bot/contents/icons/platforms/patreon.png
diff --git a/printer-bot/icons/platforms/tiktok.png b/printer-bot/contents/icons/platforms/tiktok.png
similarity index 100%
rename from printer-bot/icons/platforms/tiktok.png
rename to printer-bot/contents/icons/platforms/tiktok.png
diff --git a/printer-bot/icons/platforms/tipeeeStream.png b/printer-bot/contents/icons/platforms/tipeeeStream.png
similarity index 100%
rename from printer-bot/icons/platforms/tipeeeStream.png
rename to printer-bot/contents/icons/platforms/tipeeeStream.png
diff --git a/printer-bot/icons/platforms/twitch.png b/printer-bot/contents/icons/platforms/twitch.png
similarity index 100%
rename from printer-bot/icons/platforms/twitch.png
rename to printer-bot/contents/icons/platforms/twitch.png
diff --git a/printer-bot/icons/platforms/youtube.png b/printer-bot/contents/icons/platforms/youtube.png
similarity index 100%
rename from printer-bot/icons/platforms/youtube.png
rename to printer-bot/contents/icons/platforms/youtube.png
diff --git a/printer-bot/contents/index.html b/printer-bot/contents/index.html
new file mode 100644
index 0000000..bb744e3
--- /dev/null
+++ b/printer-bot/contents/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Printer Name
+
As labelled in Windows > Settings > Printers & Scanners
+
+
+
+
+
+
+
Printer Bot was designed for 80mm thermal paper
+
+
+
+
+
+
+
In enabled, 'Test' and 'Simulate' events will be ignored
+
+
+
+
+
+
+
Turn this off for debugging
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/printer-bot/contents/script.js b/printer-bot/contents/script.js
new file mode 100644
index 0000000..520a901
--- /dev/null
+++ b/printer-bot/contents/script.js
@@ -0,0 +1,790 @@
+//////////////////////
+// GLOBAL VARIABLES //
+//////////////////////
+
+const sbActionPrintRoutine = '5c756513-a1d0-4285-9dbc-21ad34491310';
+const avatarMap = new Map();
+
+
+
+/////////////////////////
+// STREAMER.BOT EVENTS //
+/////////////////////////
+
+window.parent.sbClient.on('General.Custom', (response) => {
+ console.debug(response.data);
+ CustomEvent(response.data);
+})
+
+
+
+/////////////////
+// PRINTER BOT //
+/////////////////
+
+async function CustomEvent(data) {
+ if (data.actionName != 'Printer Bot | 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('#receipt-header');
+ const contentEl = instance.querySelector('#receipt-content');
+ const footerEl = instance.querySelector('#receipt-footer');
+ const avatarEl = instance.querySelector('#receipt-avatar');
+ const titleEl = instance.querySelector('#receipt-title');
+ const subtitleEl = instance.querySelector('#receipt-subtitle');
+ const iconEl = instance.querySelector('#receipt-icon');
+ const dateEl = instance.querySelector('#receipt-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);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'twitch');
+ }
+ 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.innerHTML = 'First time subscriber!';
+
+ contentEl.appendChild(messageEl);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'twitch');
+ }
+ 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);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'twitch');
+ }
+ 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';
+ return;
+ 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);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'twitch');
+ }
+ 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}`;
+
+ const messageEl = document.createElement('div');
+ if (data.totalGifts > 1) {
+ messageEl.innerHTML = `They've gifted ${data.totalGifts} subs in total!`;
+ }
+
+ // Get a list of all recipient users
+ Object.keys(data)
+ .filter(key => /^gift\.recipientUser\d+$/.test(key))
+ .forEach((key, index) => {
+ const username = data[key];
+ messageEl.innerHTML += `${username}`;
+ });
+
+ contentEl.appendChild(messageEl);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'twitch');
+ }
+ 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);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'twitch');
+ }
+ break;
+
+ // YouTube Events
+ case ('YouTubeNewSponsor'):
+ {
+ if (data.userProfileUrl)
+ avatarEl.src = data.userProfileUrl;
+ else
+ avatarEl.style.display = 'none';
+
+ titleEl.innerText = `${data.levelName}`;
+ subtitleEl.innerText = `${data.user}`;
+
+ contentEl.style.display = 'none';
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'youtube');
+ }
+ break;
+ case ('YouTubeGiftMembershipReceived'):
+ {
+ if (data.gifterProfileUrl)
+ avatarEl.src = data.gifterProfileUrl;
+ 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);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'youtube');
+ }
+ break;
+ case ('YouTubeSuperChat'):
+ {
+ if (data.userProfileUrl)
+ avatarEl.src = data.userProfileUrl;
+ 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);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'youtube');
+ }
+ break;
+ case ('YouTubeSuperSticker'):
+ {
+ if (data.stickerImageUrl)
+ avatarEl.src = data.stickerImageUrl;
+ 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);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'youtube');
+ }
+ 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.duration > 1)
+ messageEl.innerHTML = `${data.duration} months`;
+ else
+ messageEl.innerHTML = 'First time subscriber!';
+
+ contentEl.appendChild(messageEl);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'kick');
+ }
+ break;
+ case ('KickGiftSubscription'):
+ {
+ avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data["recipient.userLogin"], 'kick'));
+ titleEl.innerText = `Gifted Sub`;
+
+ const messageEl = document.createElement('div');
+ messageEl.innerHTML = `${data["recipient.userName"]}
received a sub from
${data.user}!`;
+
+ contentEl.appendChild(messageEl);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'kick');
+ }
+ break;
+ case ('KickMassGiftSubscription'):
+ {
+ // There is only one sub, so use the same template for a single gifted sub
+ if ('recipient.userName' in data) {
+ avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data["recipient.userLogin"], 'kick'));
+ titleEl.innerText = `Gifted Sub`;
+
+ const messageEl = document.createElement('div');
+ messageEl.innerHTML = `${data["recipient.userName"]}
received a sub from
${data.user}!`;
+
+ contentEl.appendChild(messageEl);
+ }
+ else {
+ 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);
+ }
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'kick');
+ }
+ 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["fw.username"], 'twitch');
+ if (IsValidUrl(avatarURL))
+ avatarEl.src = avatarURL;
+ else
+ avatarEl.style.display = 'none'
+ titleEl.style.fontSize = '2em';
+ titleEl.innerText = FormatCurrency(data["fw.amount"], data["fw.currency"]);
+ if (data["fw.username"])
+ subtitleEl.innerText = `${data["fw.username"]}`;
+ else if (data["fw.email"])
+ subtitleEl.innerText = `${data["fw.email"]}`;
+
+ if (data["fw.message"]) {
+ const messageEl = document.createElement('div');
+ messageEl.innerHTML = `${data["fw.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';
+
+ // Check if they left a custom message
+ let customMessageEl = document.createElement('div');
+ const customMessage = data["fw.statmessageus"];
+ if (customMessage) {
+ const txt = document.createElement("textarea");
+ txt.innerHTML = customMessage;
+ customMessageEl.innerHTML += `
${txt.value}`;
+ }
+
+ // 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(customMessageEl);
+ contentEl.appendChild(thankYouEl);
+ }
+ break;
+ case ('FourthwallSubscriptionPurchased'):
+ {
+ const avatarURL = await GetAvatar(data["fw.nickname"], 'twitch');
+ if (IsValidUrl(avatarURL))
+ avatarEl.src = avatarURL;
+ else
+ avatarEl.style.display = 'none'
+ titleEl.innerText = `New Member`;
+ subtitleEl.innerHTML = `${data["fw.nickname"]}`;
+
+ const messageEl = document.createElement('div');
+ messageEl.innerHTML = `Thanks for joining at the ${FormatCurrency(data["fw.amount"], data["fw.currency"])} tier!`;
+
+ contentEl.appendChild(messageEl);
+ }
+ break;
+
+ // Custom Code Events
+ case ('CustomCodeEvent'):
+ {
+ switch (data.triggerCustomCodeEventName) {
+ case ('kickIncomingRaid'):
+ {
+ avatarEl.src = ConvertWEBPToPNG(await GetAvatar(data.user, 'kick'));
+
+ const messageEl = document.createElement('div');
+ messageEl.innerHTML = `${data.user}
is hosting with a party of
${data.viewers} viewers!`;
+
+ contentEl.appendChild(messageEl);
+
+ // Set the platform icon
+ SetPlatformIcon(iconEl, 'kick');
+ }
+ break;
+ }
+ }
+ 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);
+
+ window.parent.sbClient.doAction({ id: sbActionPrintRoutine }, {
+ receiptHTML: receiptHTML,
+ isTest: data.isTest,
+ printerName: document.getElementById('printer-name').value,
+ paperWidth: document.getElementById('paper-width').value,
+ ignoreTestTriggers: document.getElementById('ignore-test-triggers').checked,
+ deleteTempFiles: document.getElementById('delete-temp-files').checked
+ });
+}
+
+
+//////////////////////
+// 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}
+
+