NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Blutopia BON Giveaway
// @namespace https://openuserjs.org/users/Nums
// @description Enables the functionality to become poor
// @version 4.2.1
// @updateURL https://openuserjs.org/meta/Nums/Blutopia_BON_Giveaway.meta.js
// @downloadURL https://openuserjs.org/install/Nums/Blutopia_BON_Giveaway.user.js
// @icon https://ptpimg.me/0aq853.gif
// @connect openuserjs.org
// @grant GM_xmlhttpRequest
// @license GPL-3.0-or-later
// @match https://oldtoons.world/
// @match https://upload.cx/
// @match https://aither.cc/
// @match https://reelflix.xyz/
// @match https://onlyencodes.cc/
// @run-at document-idle
// ==/UserScript==
// ==OpenUserJS==
// @author Nums
// ==/OpenUserJS==
//*****If the website is not listed as a match already. Please verify with tracker admins before using this script on their site.*****
//*****It is unlikely the bon gifting portion of the script will work on any site not in the default match list.*****
// Additional credits
// @jacksaw - creating the original script base
// @Coasty - collaborating with jacksaw
// @TheEther - Integration with Aither + some additional features
// @ahoimate - got BON gifting API polling working + added new commands
// @ruckus612 - fixed BON gift bug
// @ZukoXZuko - added some formatting to the giveaway menu
(function() {
'use strict';
// ───────────────────────────────────────────────────────────
// SECTION 1: Global Constants and Configuration
// ───────────────────────────────────────────────────────────
const COMMAND_WINDOW_MS = 10000; // look back 10 seconds
const MAX_COMMANDS_PER_WINDOW = 3; // allow 3 commands in that window
const BASE_PENALTY_SECONDS = 30; // base lockout for exceeding (in seconds)
const MAX_WINNERS = 15; // central location to update max allowable number of winners
const MAX_REMINDERS = 6; //maximum number of reminders allowed
const GENERAL_SETTINGS = {
disable_random: false,
disable_lucky: false,
disable_free: false,
suppress_entry_replies: false
};
const DEBUG_SETTINGS = {
log_chat_messages: false,
disable_chat_output: false,
verify_extractor: false,
verify_sendmessage: false,
verify_cacheChatContext: false,
suppressApiMessages: false // new flag to suppress API message sending
};
const SCRIPT_ID = 'bon-giveaway-update';
const CHECK_EVERY_HOURS = 24;
const CHATROOM_IDS = {
'upload.cx': '11',
'oldtoons.world': '4',
'aither.cc': '4',
'reelflix.xyz': '1',
'onlyencodes.cc': '1'
};
const extractors = {
alpine: extractorAlpine,
onlyencodes: extractorOnlyEncodes,
fallback: extractorFallback
};
const LS_SUPPRESS = "giveaway-suppressEntryReplies";
const currentHost = window.location.hostname;
const chatroomId = CHATROOM_IDS[currentHost] || '2';
const chatboxID = "chatbox__messages-create";
// only run the cooldown/spam‑detection logic on available commands
const baseCommands = ["time", "entries", "help", "commands", "bon", "range", "gift", "random", "number", "free", "lucky", "rig", "unrig"];
const hostCommands = ["addtime", "removetime", "reminder", "addbon", "end", "winners", "naughty"];
const uploadCxExtras = ["ruckus", "ick", "corigins", "ahoimate", "lejosh", "suckur"];
const validCommands = new Set([
...baseCommands,
...hostCommands,
...(window.location.hostname === "upload.cx" ? uploadCxExtras : [])
]);
// ───────────────────────────────────────────────────────────
// SECTION 2: Runtime State Variables
// ───────────────────────────────────────────────────────────
let giveawayStartTime;
let sponsorsInterval;
let observer;
let giveawayData;
let chatbox = null;
let reminderRetryTimeout = null;
let frameHeader;
let CHAT_VARIANT; //detect if using old Vue chatbox or new Alpine
let syncing = false;
let OT_USER_ID = null;
let OT_CHATROOM_ID = null;
let OT_CSRF_TOKEN = null;
const userCooldown = new Map(); // author → timestamp(ms) when lockout ends
const userCommandLog = new Map(); // author → [timestamps of recent commands]
const numberEntries = new Map();
const fancyNames = new Map();
const naughtyWarned = new Set(); // Users that have already been warned this giveaway
const regNum = /^-?\d+$/;
const whitespace = document.createTextNode(" ");
/* --- Naughty (exclusion) list ------------------------------------- */
const NAUGHTY_KEY = "giveaway-naughty-list";
const naughtySet = new Set(
JSON.parse(localStorage.getItem(NAUGHTY_KEY) || "[]")
.map(n => n.toLowerCase()) // store lowercase for case-insensitive match
);
function saveNaughty() {
localStorage.setItem(NAUGHTY_KEY, JSON.stringify([...naughtySet]));
}
const coinsIcon = document.createElement("i");
coinsIcon.setAttribute("class", "fas fa-coins");
const goldCoins = document.createElement("i");
goldCoins.setAttribute("class", "fas fa-coins");
goldCoins.style.color = "#ffc00a";
goldCoins.style.padding = "5px";
const giveawayBTN = document.createElement("a");
giveawayBTN.setAttribute("class", "form__button form__button--text");
giveawayBTN.textContent = "Giveaway";
giveawayBTN.prepend(coinsIcon.cloneNode(false));
giveawayBTN.onclick = toggleMenu;
// ───────────────────────────────────────────────────────────
// SECTION 3: Script Metadata Parsing
// ───────────────────────────────────────────────────────────
const META = (() => {
/* 1. Tampermonkey / Violentmonkey / classic Greasemonkey */
if (typeof GM_info !== "undefined" && GM_info.script) {
return GM_info.script;
}
/* 2. Greasemonkey 4 (GM.info) */
if (typeof GM !== "undefined" && GM.info && GM.info.script) {
return GM.info.script;
}
/* 3. Fallback: read our own source and regex the @version etc. */
try {
/* GM-3 keeps the original userscript text in the <script> tag it
injects. document.currentScript points to that tag. */
const src = document.currentScript?.textContent || "";
const fetch = key => {
const m = src.match(new RegExp(`@${key}\\s+([^\\n]+)`));
return m ? m[1].trim() : "";
};
return {
name: fetch("name") || "BON Giveaway",
updateURL: fetch("updateURL") || "https://openuserjs.org/meta/Nums/Blutopia_BON_Giveaway.meta.js",
version: fetch("version") || "0.0.0"
};
} catch (e) {
/* Last-ditch – never crash the script */
return { name:"BON Giveaway", version:"0.0.0" };
}
})();
const {
name: SCRIPT_NAME,
updateURL: SCRIPT_UPDATE_URL,
version: SCRIPT_VERSION
} = META;
/* — persistent “out-of-date” flag — */
const UPDATE_KEY = `${SCRIPT_ID}-latestRemote`;
const latestRemote = localStorage.getItem(UPDATE_KEY) || "";
/* If we already know a newer version exists, draw the badge immediately */
if (latestRemote && isNewer(latestRemote, SCRIPT_VERSION)) {
/* frame isn’t on the page yet → retry until it is */
waitForBadge(latestRemote);
}
// ───────────────────────────────────────────────────────────
// SECTION 4: UI Template Definitions
// ───────────────────────────────────────────────────────────
const frameHTML = `
<section
id="giveawayFrame"
class="panelV2"
style="width:450px;height:90%;position:fixed;z-index:9999;inset:50px 150px auto auto;overflow:auto;border:1px solid black;"
hidden
>
<!-- HEADER -->
<header class="panel__heading">
<div class="button-holder no-space">
<div class="button-left">
<h4 class="panel__heading">
<img src="https://ptpimg.me/0aq853.gif" width="20px"/>
${SCRIPT_NAME}
<small style="color:#aaa;margin-left:8px;font-size:0.8em;">v${SCRIPT_VERSION}</small>
</h4>
</div>
<div class="button-right">
<button id="resetButton" class="form__button form__button--text giveaway-btn" style="background-color:#b32525;">
<i class="fa-solid fa-rotate-right"></i> Reset
</button>
<button id="giveawaySettingsBtn" class="form__button form__button--text giveaway-btn" style="background-color:#ff6400;">
<i class="fa-solid fa-gear"></i> Settings
</button>
<button id="commandsButton" class="form__button form__button--text giveaway-btn" style="background-color:#ff9600;">
<i class="fa-solid fa-list"></i> Commands
</button>
<button id="closeButton" class="form__button form__button--text giveaway-btn" style="background-color:#4e595f;">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
</header>
<!-- MAIN BODY -->
<div class="panel__body" id="giveaway_body" style="display:flex; flex-direction:column; gap:10px;">
<h1 id="coinHeader" class="panel__heading--centered"></h1>
<form class="form" id="giveawayForm" style="display:flex;flex-flow:column;align-items:center;">
<p class="form__group" style="max-width:35%;">
<input
class="form__text"
required
id="giveawayAmount"
pattern="[0-9,]*"
inputmode="numeric"
type="text"
>
<label class="form__label form__label--floating" for="giveawayAmount">
Giveaway Amount
</label>
</p>
<div class="panel__body flex-row" style="justify-content:center; gap:20px;">
${
[
['startNum', '1'],
['endNum', '50']
]
.map(
([id, val]) => `
<p class="form__group" style="width:20%;">
<input
class="form__text"
required
id="${id}"
pattern="-?\\d+"
value="${val}"
inputmode="numeric"
type="text"
maxlength="9"
>
<label class="form__label form__label--floating" for="${id}">
${id === 'startNum' ? 'Start #' : 'End #'}
</label>
</p>`
)
.join('')
}
</div>
<!-- Giveaway length / reminders / winners row -->
<div class="panel__body flex-row" style="justify-content:center; flex-wrap:wrap; gap:20px;">
<!-- giveaway length -->
<p class="form__group" style="width:28%;">
<input class="form__text" required id="timerNum" value="5" inputmode="numeric">
<label class="form__label form__label--floating" for="timerNum">Time (min)</label>
</p>
<!-- reminders -->
<p class="form__group" style="width:28%;">
<input class="form__text" id="reminderNum" type="number" min="0" step="1" value="0" autocomplete="off">
<label class="form__label form__label--floating"># Reminders</label>
</p>
<!-- cadence label -->
<p class="form__group" style="width:28%;">
<input class="form__text" id="reminderEvery" readonly tabindex="-1" style="cursor:default;">
<label class="form__label form__label--floating">Every (min)</label>
</p>
</div>
<!-- winners in its own row with top margin -->
<div class="panel__body" style="display:flex;justify-content:center; margin-top: 12px; width:100%;">
<p class="form__group" style="width:28%;">
<input class="form__text" required id="winnersNum" min="1" max="10" step="1" value="1">
<label class="form__label form__label--floating" for="winnersNum"># Winners</label>
</p>
</div>
<div class="panel__body" style="display:flex;justify-content:center;gap:20px;">
<p class="form__group" style="width:100%;">
<input
class="form__text"
id="customMessage"
type="text"
maxlength="100"
placeholder="Max 100 chars"
>
<label class="form__label form__label--floating" for="customMessage">
Custom Message
</label>
</p>
</div>
<p class="form__group" style="text-align:center;">
<button
type="button"
id="startButton"
class="form__button form__button--filled"
style="background-color:#02B008;"
>
Start
</button>
</p>
</form>
<!-- Countdown timer below the form, full width -->
<h2 id="countdownHeader" class="panel__heading--centered" hidden
style="display:block; width:100%; margin-top:10px; margin-bottom:10px; text-align:center;">
</h2>
<!-- Entries table below the countdown -->
<div id="entriesWrapper" class="data-table-wrapper" hidden
style="width:100%; overflow-x:auto; margin-top:10px;">
<table id="entriesTable" class="data-table" style="width:100%; border-collapse:collapse; table-layout:fixed;">
<thead><tr><th>User</th><th>Entry #</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- SETTINGS MENU -->
<div id="giveaway_settings_menu" class="giveaway_settings_menu" style="display:none">
<div>
<button type="button" id="toggleAllButton" class="form__button form__button--filled">
Toggle all
</button><br>
${['Random','Lucky','Free','Entry Replies'].map(label => `
<p style="display:inline-block;width:150px;">${label}:</p>
<input
type="checkbox"
id="${label.toLowerCase().replace(/ /g,'')}Toggle"
style="width:15px;height:15px;cursor:pointer;"
checked
><br>`).join('')}
</div>
</div>
<!-- COMMANDS MENU -->
<div id="giveaway_commands_menu" class="commands-menu" style="display:none">
<ul class="commands-list">
<li class="section-label">General Commands</li>
<li><code>!time </code> <span class="desc">Show remaining time</span></li>
<li><code>!entries </code> <span class="desc">List all entries</span></li>
<li><code>!free </code> <span class="desc">Show free numbers</span></li>
<li><code>!number </code> <span class="desc">Show your entry</span></li>
<li><code>!random </code> <span class="desc">Enter with a random #</span></li>
<li><code>!lucky </code> <span class="desc">Show lucky number</span></li>
<li><code>!bon </code> <span class="desc">Show pot amount</span></li>
<li><code>!range </code> <span class="desc">Show valid range</span></li>
<li><code>!rig/!unrig </code> <span class="desc">Toggle rigging (fun)</span></li>
<li><code>!help </code> <span class="desc">Show this list in chat</span></li>
<li class="section-label">Host-Only Commands</li>
<li class="full-span">
<code>!time add N / remove N </code>
<span class="desc">Adjust remaining minutes</span>
</li>
<li><code>!reminder </code> <span class="desc">Send reminder msg</span></li>
<li><code>!addbon </code> <span class="desc">Add BON to pot</span></li>
<li><code>!winners N</code> <span class="desc">Set number of winners</span></li>
<li><code>!end </code> <span class="desc">End the giveaway</span></li>
<li><code>!naughty </code> <span class="desc">list/add/remove a user</span></li>
<li class="naughty-alert">
⚠⚠ !naughty excludes users from the giveaway entirely ⚠⚠ ************************USE RESPONSIBLY************************
</li>
</ul>
</div>
</section>
`;
const baseMenuStyle = `
background-color: #2C2C2C;
color: #CCC;
border-radius: 5px;
position: absolute;
top: 100px;
right: 10px;
z-index: 998;
padding: 15px;
overflow: auto;
flex-direction: column;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
`;
// Settings menu CSS styles
const settingsMenuStyle = `
.giveaway_settings_menu {
${baseMenuStyle}
width: 240px;
max-height: 260px;
}
.giveaway_settings_menu > div {
margin: 5px 0;
}`;
// Commands menu CSS styles – shrink-wrap width, 2-column grid, section labels
const commandsMenuStyle = `
.commands-menu{
${baseMenuStyle}
width:max-content;
max-width:425px;
max-height:70vh;
}
/* ── compact two-column grid ───────────────────────────── */
.commands-menu .commands-list{
list-style:none;
padding:0;
margin:0;
display:grid;
grid-template-columns:max-content 1fr; /* code | description */
column-gap:5px;
row-gap:4px;
}
.commands-menu .full-span{
grid-column: 1 / -1; /* occupy the whole row */
}
/* left column (command keyword) */
.commands-menu code{
font-family:inherit;
font-weight:600;
color:#ffb84d;
font-size:14px;
white-space:nowrap;
}
/* right column (description) */
.commands-menu .desc{
color:#d0d0d0; /* dimmer grey */
font-size:13px;
}
/* orange section headers that span both columns */
.commands-menu .section-label{
grid-column:1 / -1;
margin:6px 0 2px;
font-size:14px;
font-weight:700;
color:#ffa200;
border-bottom:1px solid #555;
}
/* full-width red banner for Naughty */
.commands-menu .naughty-alert{
grid-column:1 / -1; /* span both columns */
background:#dc3d1d;
color:#fff;
font-size:13px;
font-weight:600;
padding:2px 6px;
border-radius:4px;
margin-top:2px;
}`;
// ───────────────────────────────────────────────────────────
// SECTION 5: Initialization and Bootstrapping
// ───────────────────────────────────────────────────────────
// Cache references for UI elements (populated in injectMenu)
let giveawayFrame, coinHeader, countdownHeader,
coinInput, startInput, endInput, timerInput, reminderInput, winnersInput, customMessageInput, entriesWrapper, giveawayForm,
resetButton, closeButton, startButton, toggleAllButton, settingsBtn, commandsBtn, settingsMenu, commandsMenu, byCount, byInterval,
remNumInput, reminderEvery;
// Inject the giveaway menu into the chat UI
injectMenu();
function injectMenu() {
const chatbox_header = document.querySelector(`#chatbox_header div`);
if (!chatbox_header) {
setTimeout(injectMenu, 100);
return;
}
addStyle(`
/* Keep vertical spacing tight */
#giveawayFrame .panel__body {
gap: 2px !important;
row-gap: 2px !important;
margin-top: 2px !important;
margin-bottom: 2px !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
/* Specifically restore horizontal flex layout for the input rows */
#giveawayFrame .panel__body.flex-row {
display: flex !important;
flex-wrap: wrap !important;
flex-direction: row !important;
justify-content: center !important;
gap: 20px !important; /* restore horizontal spacing */
}
/* Form groups still keep tight vertical margin */
#giveawayFrame .form__group {
margin-top: 2px !important;
margin-bottom: 2px !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
#giveawayFrame .form__text {
padding-top: 3px !important;
padding-bottom: 3px !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
#giveawayFrame label.form__label {
margin-top: 0 !important;
margin-bottom: 2px !important;
line-height: 1.1 !important;
}
/* Countdown timer fix: force block and full width below form */
#giveawayFrame #countdownHeader {
display: block !important;
width: 100% !important;
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
/* Entries wrapper full width with horizontal scroll if needed */
#giveawayFrame #entriesWrapper {
width: 100% !important;
overflow-x: auto;
margin-top: 10px;
}
/* Entries table full width with fixed layout */
#giveawayFrame #entriesTable {
width: 100% !important;
border-collapse: collapse;
table-layout: fixed;
}
/* Parent container vertical stacking with spacing */
#giveawayFrame #giveaway_body {
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
}
/* --- Improved vertical centering and layout for coinHeader --- */
#giveawayFrame #coinHeader.panel__heading--centered {
margin-top: 14px !important;
margin-bottom: 0 !important;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5em;
gap: 6px;
}
${settingsMenuStyle}
${commandsMenuStyle}
/* Silence <h1> inside <section> console warning */
#giveawayFrame h1.panel__heading--centered {
font-size: 1.5em;
margin: 0;
}
`, 'giveaway-styles');
document.body.insertAdjacentHTML("beforeend", frameHTML);
settingsMenu = document.getElementById('giveaway_settings_menu');
commandsMenu = document.getElementById('giveaway_commands_menu');
timerInput = document.getElementById("timerNum");
remNumInput = document.getElementById("reminderNum");
reminderEvery = document.getElementById("reminderEvery");
giveawayFrame = document.getElementById('giveawayFrame');
settingsBtn = giveawayFrame.querySelector('#giveawaySettingsBtn');
commandsBtn = giveawayFrame.querySelector('#commandsButton');
// Update both when either changes
timerInput.addEventListener("input", syncReminderNumUI);
remNumInput.addEventListener("input", syncReminderNumUI);
// Call on init
syncReminderNumUI();
/* kick-start synchronisation so defaults line up on first render */
remNumInput.dispatchEvent(new Event("input"));
settingsBtn.addEventListener('click', e => {
e.stopPropagation(); // don’t bubble to outside-click
if (commandsMenu.classList.contains('open')) hardCloseCommands(); // close the other pane first
const open = settingsMenu.classList.toggle('open');
if (open) { // ---------- OPEN ----------
settingsMenu.style.display = 'flex';
settingsMenu.style.height = 'auto';
settingsMenu.style.overflow = 'visible';
document.addEventListener('click', handleOutsideClick);
} else { // ---------- CLOSE ----------
hardCloseSettings();
}
});
chatbox_header.prepend(giveawayBTN);
giveawayBTN.parentNode.insertBefore(whitespace, giveawayBTN.nextSibling);
resetButton = document.getElementById("resetButton");
resetButton.onclick = function () {
if (giveawayData && giveawayData.timeLeft > 0) {
if (window.confirm("Are you sure you want to reset the giveaway? This will clear all entries and cannot be undone.")) {
resetGiveaway();
}
} else {
resetGiveaway();
}
};
closeButton = document.getElementById("closeButton");
closeButton.onclick = function () {
// Check if a giveaway is active
if (giveawayData && giveawayData.timeLeft > 0) {
if (window.confirm("A giveaway is currently running. Are you sure you want to close the menu? This will NOT end the giveaway, but you may lose track of its progress.")) {
toggleMenu();
}
} else {
toggleMenu();
}
};
// Toggles
const toggles = [
["randomToggle", "giveaway-disableRandom", "disable_random"],
["luckyToggle", "giveaway-disableLucky", "disable_lucky"],
["freeToggle", "giveaway-disableFree", "disable_free"],
["entryrepliesToggle", LS_SUPPRESS, "suppress_entry_replies", true]
];
for (const [id, key, setting, invert = false] of toggles) {
const el = document.getElementById(id);
const stored = localStorage.getItem(key) === "true";
el.checked = invert ? !stored : !stored;
GENERAL_SETTINGS[setting] = invert ? stored : stored;
el.addEventListener("change", () => {
const newVal = invert ? !el.checked : !el.checked;
GENERAL_SETTINGS[setting] = newVal;
localStorage.setItem(key, String(newVal));
});
}
coinHeader = document.getElementById("coinHeader");
coinHeader.textContent = fmtBON(document.getElementsByClassName("ratio-bar__points")[0].firstElementChild.textContent.trim());
coinHeader.prepend(goldCoins.cloneNode(false));
coinInput = document.getElementById("giveawayAmount");
// remove formatting while editing
coinInput.addEventListener('focus', () => {
coinInput.value = coinInput.value.replace(/,/g, '');
});
// add commas on blur if it’s a valid integer
coinInput.addEventListener('blur', () => {
const raw = coinInput.value.replace(/,/g, '');
if (/^\d+$/.test(raw)) {
coinInput.value = parseInt(raw, 10).toLocaleString();
}
});
startInput = document.getElementById("startNum");
endInput = document.getElementById("endNum");
winnersInput = document.getElementById("winnersNum");
customMessageInput = document.getElementById("customMessage");
startButton = document.getElementById("startButton");
startButton.onclick = startGiveaway;
startButton.title = "Start the giveaway";
toggleAllButton = document.getElementById("toggleAllButton");
toggleAllButton.onclick = toggleAll;
countdownHeader = document.getElementById("countdownHeader");
entriesWrapper = document.getElementById("entriesWrapper");
giveawayForm = document.getElementById("giveawayForm");
document.body.appendChild(giveawayFrame);
// Draggable panel
frameHeader = giveawayFrame.querySelector('header.panel__heading');
frameHeader.style.cursor = 'move';
frameHeader.style.userSelect = 'none';
let isDragging = false, dragOffsetX = 0, dragOffsetY = 0;
frameHeader.addEventListener('mousedown', e => {
isDragging = true;
const rect = giveawayFrame.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
giveawayFrame.style.left = rect.left + 'px';
giveawayFrame.style.top = rect.top + 'px';
giveawayFrame.style.right = 'auto';
giveawayFrame.style.bottom = 'auto';
});
document.addEventListener('mousemove', e => {
if (!isDragging) return;
const maxX = window.innerWidth - giveawayFrame.offsetWidth;
const maxY = window.innerHeight - giveawayFrame.offsetHeight;
giveawayFrame.style.left = Math.max(0, Math.min(maxX, e.clientX - dragOffsetX)) + 'px';
giveawayFrame.style.top = Math.max(0, Math.min(maxY, e.clientY - dragOffsetY)) + 'px';
});
document.addEventListener('mouseup', () => {
isDragging = false;
});
const cmdMenu = document.getElementById('giveaway_commands_menu');
commandsBtn.addEventListener('click', (e)=>{
e.stopPropagation(); // don’t trigger outside-click
/* -------- close the Settings panel if it’s showing -------- */
if (settingsMenu.classList.contains('open')) hardCloseSettings();
const open = cmdMenu.classList.toggle('open'); // flip the flag
if (open) { // ── OPEN
cmdMenu.style.display = 'flex';
cmdMenu.style.height = 'auto';
cmdMenu.style.overflow = 'visible';
document.addEventListener('click', handleOutsideClick);
} else { // ── HARD CLOSE
hardCloseCommands();
document.removeEventListener('click', handleOutsideClick);
}
});
timerInput.addEventListener("input", reminderAutoScaling);
startInput.addEventListener("input", entryRangeValidation);
endInput.addEventListener("input", entryRangeValidation);
winnersInput.addEventListener("input", winnersValidation);
reminderAutoScaling();
}
function toggleMenu() {
giveawayFrame.hidden = !giveawayFrame.hidden;
}
// --- update check -------------------------------------------------
(async () => {
const last = +localStorage.getItem(`${SCRIPT_ID}-lastCheck`) || 0;
const now = Date.now();
if (now - last < CHECK_EVERY_HOURS * 3_600_000) return;
GM_xmlhttpRequest({
method: 'GET',
url: SCRIPT_UPDATE_URL,
onload: res => {
if (res.status !== 200) return console.warn('update-check HTTP', res.status);
const m = res.responseText.match(/@version\s+([0-9.]+)/);
if (!m) return console.warn('update-check: version tag not found');
const remote = m[1].trim();
if (isNewer(remote, SCRIPT_VERSION)) {
localStorage.setItem(UPDATE_KEY, remote); // 💾 persist
waitForBadge(remote); // 🎟 show badge
} else {
localStorage.removeItem(UPDATE_KEY); // ✅ up-to-date
}
},
onerror: err => console.error('update-check failed', err),
ontimeout:() => console.error('update-check timed out')
});
localStorage.setItem(`${SCRIPT_ID}-lastCheck`, String(now));
})();
// Utility function to compare semantic versions
function isNewer(remote, local) {
const r = remote.split('.').map(Number);
const l = local .split('.').map(Number);
const len = Math.max(r.length, l.length);
for (let i = 0; i < len; i++) {
const a = r[i] || 0;
const b = l[i] || 0;
if (a !== b) return a > b;
}
return false;
}
// Attempts to insert the "Update available" badge into the header
function showBadge(remoteVer) {
// the <small> that holds “v3.0.0”
const versionTag = document.querySelector('#giveawayFrame header.panel__heading small');
if (!versionTag) return false; // frame not rendered yet
// prevent duplicates
if (versionTag.parentElement.querySelector('.bon-gUpdateBadge')) return true;
const badge = document.createElement('a');
badge.className = 'bon-gUpdateBadge';
badge.href = SCRIPT_UPDATE_URL.replace('.meta.js', '.user.js');
badge.target = '_blank';
badge.style.cssText = `
background:#DC3D1D;color:#fff;border-radius:4px;padding:2px 6px;
font-size:12px;margin-left:6px;text-decoration:none;cursor:pointer;
`;
badge.textContent = 'Update available';
badge.title = `New version ${remoteVer} is available – click to install`;
versionTag.appendChild(badge);
return true;
}
// Tries to add the badge once per second until successful
function waitForBadge(remote) {
const id = setInterval(() => {
if (showBadge(remote)) clearInterval(id);
}, 1000);
}
// ───────────────────────────────────────────────────────────
// SECTION 6: Giveaway Lifecycle
// ───────────────────────────────────────────────────────────
function startGiveaway() {
window.selectedFancyNameMethod = detectBestExtractor();
if (!giveawayForm.checkValidity()) {
giveawayForm.reportValidity();
return;
}
if (sponsorsInterval) { clearInterval(sponsorsInterval); sponsorsInterval = null; }
if (observer) { observer.disconnect(); observer = null; }
if (chatbox == null) {
chatbox = document.querySelector(`#${chatboxID}`);
}
cacheChatContext();
startButton.disabled = true;
coinInput.disabled = true;
startInput.disabled = true;
endInput.disabled = true;
timerInput.disabled = true;
customMessageInput.disabled = true;
winnersInput.disabled = true;
remNumInput.disabled = true;
reminderEvery.disabled = true;
//startButton.parentElement.hidden = true;
entriesWrapper.hidden = false;
let totalTimeMin = Number(timerInput.value);
let totalTimeMs = totalTimeMin * 60000;
let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalTimeMin)[0]);
if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0;
const schedule = getReminderSchedule(totalTimeMin, reminderNum);
const cadenceSec = (reminderNum > 0) ? totalTimeMin * 60 / (reminderNum + 1) : 0;
let winnersNum = parseInt(winnersInput.value, 10);
const cleanValue = coinInput.value.replace(/,/g, '');
giveawayData = {
host: document.getElementsByClassName("top-nav__username")[0].children[0].textContent.trim(),
amount: parseInt(cleanValue, 10),
startNum: parseInt(startInput.value, 10),
endNum: parseInt(endInput.value, 10),
totalEntries: parseInt(endInput.value, 10) - parseInt(startInput.value, 10) + 1,
winningNumber: null,
totalSeconds: totalTimeMs / 1000,
timeLeft: totalTimeMs / 1000,
endTs: Date.now() + (totalTimeMs),
winnersNum,
customMessage: customMessageInput.value,
hostAdded: parseInt(cleanValue, 10),
reminderSchedule : schedule,
reminderNum : schedule.length,
reminderFreqSec : cadenceSec, // <‑ kept for legacy helpers
nextReminderSec : cadenceSec, // <‑ ditto (first reminder ETA)
sponsorContribs: {},
sponsors: []
};
giveawayStartTime = new Date();
if (window.__activeTracker) window.__activeTracker = null; // kill previous
let tracker = new SponsorTracker({ chatroomId, giveawayStartTime, giveawayData });
window.__activeTracker = tracker;
tracker.poll().catch(console.error);
sponsorsInterval = setInterval(()=>tracker.poll(),10_000);
const currentBon = parseInt(document.querySelector('.ratio-bar__points').textContent.replace(/[\s,]/g,'') ,10);
if (currentBon < giveawayData.amount) {
window.alert(`GIVEAWAY ERROR: The amount entered (${giveawayData.amount}), is above your current BON (${currentBon}). You may need to refresh the page to update your BON amount.`);
resetGiveaway();
}
else {
giveawayData.winningNumber = getRandomInt(giveawayData.startNum, giveawayData.endNum);
window.onbeforeunload = function (e) {
e.preventDefault();
e.returnValue = "";
return "";
};
const introMessage = `I am hosting a giveaway for [b][color=#ffc00a]${giveawayData.amount.toLocaleString()} BON[/color][/b]. ` +
`Up to [b][color=#5DE2E7]${giveawayData.winnersNum} ${giveawayData.winnersNum === 1 ? 'winner' : 'winners'}[/color][/b] will be selected. ` +
`Entries will be open for [b][color=#1DDC5D]${parseTime(totalTimeMs)}[/color][/b]. ` +
`To enter, submit a whole number [b]between [color=#DC3D1D]${giveawayData.startNum} and ${giveawayData.endNum}[/color] inclusive.[/b] ` +
`[b][color=#5DE2E7]${giveawayData.customMessage} [/color][/b]\n` +
`✨[b][color=#FB4F4F]Gifting BON to the host will add to the pot![/color][/b]✨`
sendMessage(introMessage);
if (observer) {
startObserver();
}
else {
addObserver(giveawayData);
}
giveawayData.countdownTimerID = countdownTimer(countdownHeader, giveawayData);
giveawayData.potUpdater = setInterval(() => {
coinHeader.innerHTML = `${fmtBON(cleanPotString(giveawayData.amount))} BON`;
coinHeader.prepend(goldCoins.cloneNode(false));
}, 5000);
// ** TOGGLE BUTTON TO STOP **
startButton.textContent = "Stop";
startButton.style.backgroundColor = "#b32525"; // red to indicate Stop
startButton.title = "This will end the giveaway and send gifts to the winners";
startButton.disabled = false;
startButton.onclick = () => {
endGiveaway();
};
}
}
function resetGiveaway() {
entriesWrapper.hidden = true
countdownHeader.textContent = "";
countdownHeader.hidden = true
startButton.parentElement.hidden = false
startButton.disabled = false;
coinInput.disabled = false;
startInput.disabled = false;
endInput.disabled = false;
timerInput.disabled = false;
customMessageInput.disabled = false;
winnersInput.disabled = false;
remNumInput.disabled = false;
reminderEvery.disabled = false;
giveawayForm.reset()
updateEntries()
// ——— restore host’s balance display ———
// re‐read balance from the page
const rawText = document
.getElementsByClassName("ratio-bar__points")[0]
.firstElementChild.textContent
.trim()
.replace(/[\s,]/g,'');
const hostBalance = parseInt(rawText, 10) || 0;
// update the header
coinHeader.textContent = hostBalance.toLocaleString();
coinHeader.prepend(goldCoins.cloneNode(false));
stopGiveaway();
// ** RESET BUTTON TO START **
startButton.textContent = "Start";
startButton.style.backgroundColor = "#02B008"; // green for Start
startButton.title = "Start the giveaway";
startButton.onclick = startGiveaway;
}
function stopGiveaway() {
startButton.disabled = true; //prevents stop button from being clicked once giveaway has ended
// ── timers ──
if (giveawayData?.countdownTimerID) clearInterval(giveawayData.countdownTimerID);
if (giveawayData?.potUpdater) clearInterval(giveawayData.potUpdater);
if (sponsorsInterval) {
clearInterval(sponsorsInterval);
sponsorsInterval = null;
}
if (window.__activeTracker) window.__activeTracker = null;
if (observer) { observer.disconnect(); observer = null; }
if (reminderRetryTimeout) { clearTimeout(reminderRetryTimeout); reminderRetryTimeout = null; }
// ── growing maps / sets ──
numberEntries.clear();
fancyNames.clear();
userCooldown.clear();
userCommandLog.clear();
naughtyWarned.clear();
// ── global event listeners ──
document.removeEventListener("click", handleOutsideClick);
giveawayData = null;
window.onbeforeunload = null;
}
// ───────────────────────────────────────────────────────────
// SECTION 7: Chat Observation + Parsing
// ───────────────────────────────────────────────────────────
function addObserver(giveawayData) {
observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
parseMessage(node);
}
}
});
startObserver();
}
function startObserver() {
const messageList = document.querySelector(".chatroom__messages");
if (messageList) {
observer.observe(messageList, { childList: true });
}
}
function parseMessage(messageNode) {
const isBot = !messageNode.querySelector(".chatbox-message__content");
if (isBot) {
const messageContent = messageNode.querySelector(".chatbox-message__header div")?.textContent.trim() || "";
// No action required – handled by SponsorTracker
return;
}
const author = getAuthor(messageNode);
const messageContent = messageNode.querySelector(".chatbox-message__content")?.textContent.trim() || "";
const fancyName = messageNode.querySelector(".user-tag")?.outerHTML || "";
if (regNum.test(messageContent)) {
handleEntryMessage(parseInt(messageContent, 10), author, fancyName, giveawayData);
} else if (messageContent.startsWith("!")) {
handleGiveawayCommands(author, messageContent, fancyName, giveawayData);
}
}
function getAuthor(msgNode) {
// Try Alpine markup: find first visible span in .user-tag__link
const alpineSpan = msgNode.querySelector('.user-tag__link span[x-show]');
if (alpineSpan && alpineSpan.textContent.trim() && alpineSpan.offsetParent !== null) {
return alpineSpan.textContent.trim();
}
// Fallback: any visible .user-tag__link span (covers edge cases)
const visibleSpan = Array.from(msgNode.querySelectorAll('.user-tag__link span'))
.find(span => span.offsetParent !== null && span.textContent.trim() && span.textContent.trim() !== 'Unknown');
if (visibleSpan) return visibleSpan.textContent.trim();
return ''; // couldn't find username, should not happen in Alpine
}
function extractorAlpine(msgNode) {
try {
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: start", msgNode);
const userTag = msgNode.querySelector('address.user-tag');
if (!userTag) {
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: no userTag found");
return null;
}
const userLink = userTag.querySelector('a.user-tag__link');
if (!userLink) {
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: no userLink found");
return null;
}
const userSpan = Array.from(userLink.querySelectorAll('span'))
.find(span => span.offsetParent !== null && span.textContent.trim().length > 0);
const usernameText = userSpan ? userSpan.textContent.trim() : '';
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: usernameText =", usernameText);
const userTagStyles = getComputedStyle(userTag);
const bgImage = userTagStyles.backgroundImage;
const bgRepeat = userTagStyles.backgroundRepeat;
const bgPosition = userTagStyles.backgroundPosition;
const bgSize = userTagStyles.backgroundSize;
let backgroundStyle = '';
if (bgImage && bgImage !== 'none') {
const url = bgImage.slice(5, -2);
backgroundStyle =
`background-image: url('${url}'); ` +
`background-repeat: ${bgRepeat}; ` +
`background-position: ${bgPosition}; ` +
`background-size: ${bgSize}; `;
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: backgroundStyle =", backgroundStyle);
}
const color = getComputedStyle(userLink).color;
const wrapperStyle = `${backgroundStyle} padding-left: 20px; display: inline-block;`;
const linkStyle = `color: ${color};`;
const classes = Array.from(userLink.classList).filter(c => c !== 'user-tag__link');
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: classes =", classes);
const html = `<address class="user-tag" style="${wrapperStyle}">
<a href="${userLink.href}" class="user-tag__link ${classes.join(' ')}" style="${linkStyle}">${usernameText}</a>
</address>`;
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: html generated");
return html;
} catch (err) {
if (DEBUG_SETTINGS.verify_extractor) console.error("extractorAlpine: error", err);
return null;
}
}
function extractorOnlyEncodes(msgNode) {
try {
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: start", msgNode);
const userTag = msgNode.querySelector('address.user-tag');
if (!userTag) {
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: no userTag found");
return null;
}
const userLink = userTag.querySelector('a.user-tag__link');
if (!userLink) {
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: no userLink found");
return null;
}
const userSpan = userLink.querySelector('span');
const usernameText = userSpan ? userSpan.textContent.trim() : '';
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: usernameText =", usernameText);
const userTagStyles = getComputedStyle(userTag);
const bgImage = userTagStyles.backgroundImage;
const bgRepeat = userTagStyles.backgroundRepeat;
const bgPosition = userTagStyles.backgroundPosition;
const bgSize = userTagStyles.backgroundSize;
let backgroundStyle = '';
if (bgImage && bgImage !== 'none') {
const url = bgImage.slice(5, -2);
backgroundStyle =
`background-image: url('${url}'); ` +
`background-repeat: ${bgRepeat}; ` +
`background-position: ${bgPosition}; ` +
`background-size: ${bgSize}; `;
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: backgroundStyle =", backgroundStyle);
}
const color = getComputedStyle(userLink).color;
const wrapperStyle = `${backgroundStyle} padding-left: 20px; display: inline-block;`;
const linkStyle = `color: ${color};`;
const classes = Array.from(userLink.classList).filter(c => c !== 'user-tag__link');
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: classes =", classes);
const html = `<address class="user-tag" style="${wrapperStyle}">
<a href="${userLink.href}" class="user-tag__link ${classes.join(' ')}" style="${linkStyle}">${usernameText}</a>
</address>`;
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: html generated");
return html;
} catch (err) {
if (DEBUG_SETTINGS.verify_extractor) console.error("extractorOnlyEncodes: error", err);
return null;
}
}
function extractorFallback(msgNode) {
try {
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorFallback: start", msgNode);
const usernameSpan = msgNode.querySelector('.user-tag__link span[x-show]');
const userName = usernameSpan?.textContent.trim() || "UnknownUser";
if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorFallback: userName =", userName);
return `<span style="color:#d85e27; font-weight:bold;">${userName}</span>`;
} catch (err) {
if (DEBUG_SETTINGS.verify_extractor) console.error("extractorFallback: error", err);
return "UnknownUser";
}
}
// ───────────────────────────────────────────────────────────
// SECTION 8: Entry Management
// ───────────────────────────────────────────────────────────
function handleEntryMessage(number, author, fancyName, giveawayData) {
// --- Naughty list hard-block (except host & staff) -----------
const isHost = author === giveawayData.host;
const isModerator = isAdmin(fancyName);
if (naughtySet.has(author.toLowerCase()) && !isHost && !isModerator) {
if (!naughtyWarned.has(author)) {
sendMessage(`[color=#d85e27]${sanitizeNick(author)}[/color], `
+ `you are on the [b]naughty list[/b] and may not `
+ `enter the giveaway or use its commands.`);
naughtyWarned.add(author);
}
return;
}
// sanitize the raw author names to avoid IRC pings
const safeAuthor = sanitizeNick(author);
for (let [msgAuthor, msgValue] of numberEntries.entries()) {
const safeOther = sanitizeNick(msgAuthor);
if (msgAuthor === author) {
const repeatMessage =
`Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#32cd53]you[/color] already entered with number [color=#DC3D1D][b]${msgValue}[/b][/color]!`;
sendMessage(repeatMessage);
return;
}
else if (msgValue === number) {
const repeatMessage =
`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#32cd53]${safeOther}[/color] already entered with number [color=#DC3D1D][b]${number}[/b][/color]! Please try another number!`;
sendMessage(repeatMessage);
return;
}
}
if (number < giveawayData.startNum || number > giveawayData.endNum) {
const outOfBoundsMessage =
`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but the number [color=#DC3D1D][b]${number}[/b][/color] is outside of the given range! Enter a number between [color=#DC3D1D][b]${giveawayData.startNum}[/b] and [b]${giveawayData.endNum}[/b][/color]!`;
sendMessage(outOfBoundsMessage);
return;
}
if (!numberEntries.has(author)) {
// when you actually add them, you still store the real author internally
addNewEntry(author, fancyName, number);
}
if (!GENERAL_SETTINGS.suppress_entry_replies) {
const timeLeftStr = parseTime(giveawayData.timeLeft * 1000);
const msg =
`[color=#d85e27]${safeAuthor}[/color] has entered with ` +
`the number [color=#DC3D1D][b]${number}[/b][/color]! ` +
`Time remaining: [b][color=#1DDC5D]${timeLeftStr}[/color][/b].`;
sendMessage(msg);
}
}
function addNewEntry(author, fancyName, number) {
numberEntries.set(author, number);
fancyNames.set(author, fancyName);
updateEntries();
}
function updateEntries() {
if (!window.selectedFancyNameMethod) {
window.selectedFancyNameMethod = detectBestExtractor();
}
const extractor = extractors[window.selectedFancyNameMethod];
const messageNodesByUser = getMessageNodesByUser(window.selectedFancyNameMethod);
let tableHTML = "<thead><tr><th>User</th><th>Entry #</th></tr></thead><tbody>";
numberEntries.forEach((entry, author) => {
const msgNode = messageNodesByUser[author];
let fancyNameHTML = "";
try {
if (msgNode) {
fancyNameHTML = extractor(msgNode);
} else {
fancyNameHTML = sanitizeNick(author);
}
} catch {
fancyNameHTML = sanitizeNick(author);
}
if (!fancyNameHTML) fancyNameHTML = sanitizeNick(author);
tableHTML += `<tr><td>${fancyNameHTML}</td><td>${entry}</td></tr>`;
});
document.getElementById("entriesTable").innerHTML = tableHTML + "</tbody>";
}
function getMessageNodesByUser(selectedFancyNameMethod) {
const map = {};
document.querySelectorAll('.chatbox-message').forEach(node => {
let user = null;
if (selectedFancyNameMethod === "onlyencodes") {
user = node.querySelector('address.user-tag > a.user-tag__link > span')?.textContent.trim();
} else {
user = node.querySelector('.user-tag__link span[x-show]')?.textContent.trim();
}
if (user) map[user] = node;
});
return map;
}
// ───────────────────────────────────────────────────────────
// SECTION 9: Sponsorhip Polling and Parsing
// ───────────────────────────────────────────────────────────
class SponsorTracker {
/** @param {{chatroomId:string, giveawayStartTime:Date, giveawayData:Object}} opts */
constructor({ chatroomId, giveawayStartTime, giveawayData }) {
this.chatroomId = chatroomId;
this.giveawayStartTs = giveawayStartTime.getTime();
this.data = giveawayData;
this.lastMsgId = 0; // API cursor
this.processedIds = new Set(); // de-dupe
this.buffer = []; // gifts waiting to be announced
}
/* ---- poll for any chat messages since last cursor ---- */
async fetchNew() {
const url = new URL(`/api/chat/messages/${this.chatroomId}`, location.origin);
if (this.lastMsgId) url.searchParams.set("after_id", this.lastMsgId);
const res = await fetch(url, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()).data;
}
/* ---- called by the 10-second timer ---- */
async poll() {
let messages;
try {
messages = await this.fetchNew();
} catch (e) {
if (DEBUG_SETTINGS.log_chat_messages) console.error("Sponsor API error:", e);
return;
}
/* — filter new, unprocessed, systembot gift messages — */
const gifts = messages.filter(m =>
(m.bot?.is_systembot || m.bot?.name?.toLowerCase().includes("oe+")) &&
m.message.includes("has gifted") &&
Date.parse(m.created_at) > this.giveawayStartTs &&
!this.processedIds.has(m.id)
);
for (const m of messages) {
if (m.id > this.lastMsgId) this.lastMsgId = m.id;
}
/* parse & buffer gifts */
for (const msg of gifts) {
this.processedIds.add(msg.id);
const { gifter, recipient, amount } = this.parseGiftMsg(msg.message);
if (!gifter || recipient !== this.data.host) continue; // only count gifts to the host
this.buffer.push({ gifter, amount });
this.applyGift(gifter, amount); // update totals immediately
}
/* send ONE summary line if anything new arrived */
if (this.buffer.length) this.flushBuffer();
}
/* ---- pull gifter / recipient / amount from the HTML blob ---- */
parseGiftMsg(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const links = Array.from(doc.querySelectorAll("a"));
const text = doc.body.textContent || "";
const m = text.match(/has gifted\s*([\d.]+)\s*BON/i);
return m && links.length >= 2
? {
gifter: links[0].textContent.trim(),
recipient: links[1].textContent.trim(),
amount: parseFloat(m[1])
}
: {};
}
/* ---- update pot + per-sponsor running totals ---- */
applyGift(gifter, amount) {
this.data.amount += amount;
this.data.sponsorContribs[gifter] =
(this.data.sponsorContribs[gifter] || 0) + amount;
if (!this.data.sponsors.includes(gifter)) this.data.sponsors.push(gifter);
}
/* ---- build a single chat line & clear buffer ---- */
flushBuffer() {
const grouped = this.buffer.reduce((acc, { gifter, amount }) => {
acc[gifter] = (acc[gifter] || 0) + amount;
return acc;
}, {});
const sponsorNames = Object.keys(grouped);
const deltaTotal = this.buffer.reduce((s, g) => s + g.amount, 0).toLocaleString();
const potTotal = Number(cleanPotString(this.data.amount)).toLocaleString();
let msg;
if (sponsorNames.length === 1) {
/* single sponsor */
const g = sponsorNames[0];
const amt = grouped[g].toLocaleString();
msg =
`✨ [color=#1DDC5D][b]${g}[/b][/color] is sponsoring ` +
`[color=#DC3D1D][b]${amt}[/b][/color] additional BON! ` +
`Total pot is now [b][color=#ffc00a]${potTotal} BON[/color][/b]`;
} else {
/* multiple sponsors in this batch */
const parts = sponsorNames.map(
g =>
`[color=#1DDC5D][b]${g}[/b][/color] ` + // green name
`([color=#DC3D1D][b]${grouped[g].toLocaleString()}[/b][/color])` // red amount
);
msg =
`✨ ${parts.join(", ")} have just added ` +
`[color=#DC3D1D][b]${deltaTotal} BON[/b][/color]! ` +
`Total pot is now [b][color=#ffc00a]${potTotal} BON[/color][/b]`;
}
sendMessage(msg);
this.buffer.length = 0; // clear the batch
}
}
// ───────────────────────────────────────────────────────────
// SECTION 10: Command Handling
// ───────────────────────────────────────────────────────────
function handleGiveawayCommands(author, messageContent, fancyName, giveawayData) {
// Fast‑exit when it’s not a command
if (!messageContent.startsWith("!")) return;
// --- Naughty list hard-block (but allow host & admins)
const isHost = giveawayData && author === giveawayData.host;
const isModerator = isAdmin(fancyName);
if (naughtySet.has(author.toLowerCase()) && !isHost && !isModerator) {
if (!naughtyWarned.has(author)) {
sendMessage(`[color=#d85e27]${sanitizeNick(author)}[/color], `
+ `you are on the [b]naughty list[/b] and may not `
+ `enter the giveaway or use its commands.`);
naughtyWarned.add(author);
}
return; // everyone else is still blocked
}
const args = messageContent.slice(1).trim().split(/\s+/);
const command = (args.shift() || "").toLowerCase();
if (!validCommands.has(command)) return; // Unsupported
if (applyCooldown(author)) return; // Spammer – ignored
const handler = COMMAND_HANDLERS[command];
if (!handler) return; // No handler defined (or gated‑out)
handler({
author,
fancyName,
args,
giveawayData,
safeAuthor: sanitizeNick(author),
safeHost: sanitizeNick(giveawayData.host)
});
}
/** Rate‑limit users – returns `true` when the caller must be ignored. */
function applyCooldown(author) {
const now = Date.now();
const lockoutExpires = userCooldown.get(author) || 0;
if (now < lockoutExpires) return true;
const log = (userCommandLog.get(author) || []).filter(ts => now - ts < COMMAND_WINDOW_MS);
log.push(now);
userCommandLog.set(author, log);
if (log.length > MAX_COMMANDS_PER_WINDOW) {
const excess = log.length - MAX_COMMANDS_PER_WINDOW;
const penaltySec = BASE_PENALTY_SECONDS * excess;
userCooldown.set(author, now + penaltySec * 1000);
userCommandLog.delete(author);
sendMessage(`[color=red][b]Spamming detected! ${sanitizeNick(author)} locked out for ${penaltySec} seconds.[/b][/color]`);
return true;
}
return false;
}
function isAdmin(fancyName) {
if (!fancyName) return false;
try {
const div = document.createElement('div');
div.innerHTML = fancyName;
const a = div.querySelector('a.user-tag__link');
if (!a) return false;
const title = a.getAttribute('title')?.toLowerCase() || '';
return title.includes('leader') || title.includes('onlyguardians') || title.includes('administrator') || title.includes('admin') || title.includes('moderator') || title.includes('mod');
} catch {
return false;
}
}
const COMMAND_HANDLERS = {
/* Public commands */
time(ctx) {
const { args, author, fancyName, giveawayData } = ctx;
const addMinutes = hostAdjustTime(+1);
const removeMinutes = hostAdjustTime(-1);
// no args → show countdown (legacy behaviour)
if (args.length === 0) {
sendMessage(
`Time left: [b][color=#1DDC5D]${parseTime(
giveawayData.timeLeft * 1000
)}[/color][/b] ⏳`
);
return;
}
// host / admin modifiers
const action = args[0].toLowerCase();
const minutes = parseFloat(args[1]);
const isPriv = author === giveawayData.host || isAdmin(fancyName);
if (!isPriv) return; // silently ignore
if (isNaN(minutes) || minutes <= 0) {
sendMessage("[color=red]Usage:[/color] !time add|remove <minutes>");
return;
}
// Pass ONLY the minutes as the first arg for add/remove
let ctxWithArg = { ...ctx, args: [args[1]] };
if (action === "add") addMinutes(ctxWithArg);
if (action === "remove") removeMinutes(ctxWithArg);
},
entries({ giveawayData }) {
const taken = numberEntries.size;
const total = giveawayData.totalEntries;
const free = total - taken;
if (taken === 0) {
sendMessage(`[b]No entries yet! ${total} numbers available.[/b]`);
return;
}
const list = Array.from(numberEntries.entries()).map(([user, num]) => `[color=#d85e27][b]${sanitizeNick(user)}[/b][/color]: [b]${num}[/b]`);
sendMessage(`📋 Entries – ${taken}/${total} [b]([color=#1DDC5D]${free} free[/color][/b]): ${list.join(", ")}`);
},
help: showHelp,
commands: showHelp,
gift({ safeHost }) {
sendMessage(`To send a gift type: /gift ${safeHost} amount message`);
},
bon({ giveawayData }) {
sendMessage(`Giveaway Amount: [b][color=#FFB700]${giveawayData.amount.toLocaleString()}[/color][/b]`);
},
range({ giveawayData }) {
sendMessage(`Numbers between [color=#DC3D1D]${giveawayData.startNum} and ${giveawayData.endNum}[/color] inclusive are valid.`);
},
rig() {
sendMessage(`[color=#DC3D1D][b]Giveaway is now rigged[/b][/color]`);
},
unrig() {
sendMessage(`[color=#DC3D1D][b]No, the giveaway will be rigged[/b][/color] 😈`);
},
random(ctx) {
const { author, safeAuthor, fancyName, giveawayData } = ctx;
if (GENERAL_SETTINGS.disable_random) {
sendMessage(`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#999999]!random[/color] has been disabled for this giveaway.`);
return;
}
const userNumber = numberEntries.get(author);
if (userNumber !== undefined) {
sendMessage(`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#32cd53]you[/color] already entered with number [color=#DC3D1D][b]${userNumber}[/b][/color]!`);
return;
}
const takenNumbers = new Set(numberEntries.values());
const availableNumbers = [];
for (let n = giveawayData.startNum; n <= giveawayData.endNum; ++n) {
if (!takenNumbers.has(n)) availableNumbers.push(n);
}
if (availableNumbers.length === 0) {
sendMessage("All numbers are taken — no free numbers left!");
return;
}
const randomNum = availableNumbers[Math.floor(Math.random() * availableNumbers.length)];
addNewEntry(author, fancyName, randomNum);
const timeLeftStr = parseTime(giveawayData.timeLeft * 1000);
sendMessage(`[color=#d85e27]${safeAuthor}[/color] has entered with the number [color=#DC3D1D][b]${randomNum}[/b][/color]! Time remaining: [b][color=#1DDC5D]${timeLeftStr}[/color][/b].`);
},
number({ author, safeAuthor }) {
const userNumber = numberEntries.get(author);
if (userNumber !== undefined) {
sendMessage(`[color=#d85e27]${safeAuthor}[/color] your number is [color=#DC3D1D][b]${userNumber}[/b][/color]`);
} else {
sendMessage(`[color=#d85e27]${safeAuthor}[/color] you are not currently in the giveaway.`);
}
},
free({ safeAuthor, giveawayData }) {
if (GENERAL_SETTINGS.disable_free) {
sendMessage(`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], !free disabled`);
return;
}
const taken = new Set(numberEntries.values());
const startNum = giveawayData.startNum;
const endNum = giveawayData.endNum;
const totalSlots = endNum - startNum + 1;
const sampleSize = 5;
// Use random sampling if range is large and few are taken
if (totalSlots > 100000 && taken.size / totalSlots < 0.01) {
const sample = new Set();
let attempts = 0, maxAttempts = 1000;
while (sample.size < sampleSize && attempts < maxAttempts) {
attempts++;
const candidate = Math.floor(Math.random() * totalSlots) + startNum;
if (!taken.has(candidate)) sample.add(candidate);
}
const result = [...sample];
sendMessage(
result.length > 0
? `Free numbers: ${result.join(", ")}.`
: "There are no free numbers left!"
);
return;
}
// Fallback to array method for normal cases
const freeNumbers = [];
for (let k = startNum; k <= endNum; k++) {
if (!taken.has(k)) freeNumbers.push(k);
}
const actualSampleSize = Math.min(sampleSize, freeNumbers.length);
for (let i = 0; i < actualSampleSize; ++i) {
const j = i + Math.floor(Math.random() * (freeNumbers.length - i));
[freeNumbers[i], freeNumbers[j]] = [freeNumbers[j], freeNumbers[i]];
}
const sample = freeNumbers.slice(0, actualSampleSize);
sendMessage(
sample.length > 0
? `Free numbers: ${sample.join(", ")}.`
: "There are no free numbers left!"
);
},
lucky({ giveawayData }) {
if (GENERAL_SETTINGS.disable_lucky) {
sendMessage(`🚫 Sorry [color=#d85e27]${sanitizeNick(giveawayData.host)}[/color], but [color=#999999]!lucky[/color] has been disabled for this giveaway.`);
return;
}
sendMessage(`The current giveaway lucky number is: [b][color=#1DDC5D]${getLuckyNumber(giveawayData)}[/color][/b].`);
},
/* Fun commands for upload.cx */
suckur: funUpload("Placeholder™"),
ruckus: funUpload("Sucker!"),
ick: funUpload(`WillWa loves the [b][color=BLUE]B[/color][color=#FFFFFF]R[/color][color=#C8102E]I[/color][color=#FFFFFF]T[/color][color=#C8102E]I[/color][color=BLUE]S[/color][color=#FFFFFF]H[/color]`),
corigins: funUpload("🦅 🇺🇸 🦅 🇺🇸 🦅 🇺🇸"),
ahoimate: funUpload("🦜 🏴☠️ 🦜 🏴☠️ 🦜 🏴☠️"),
lejosh: funUpload("🥖 🇫🇷 🥖 🇫🇷 🥖 🇫🇷"),
/* Host + Admin commands */
addbon: hostAddBon,
reminder(ctx) {
if (ctx.author === ctx.giveawayData.host) sendReminder();
},
winners(ctx) {
const {author, fancyName, args, giveawayData} = ctx;
if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;
const newCount = parseInt(ctx.args[0], 10);
if (isNaN(newCount) || newCount < 1 || newCount > MAX_WINNERS) {
sendMessage(`[color=red]Usage:[/color] !winners 1‑${MAX_WINNERS}`);
return;
}
ctx.giveawayData.winnersNum = newCount;
winnersInput.value = newCount;
sendMessage(`Number of winners set to [color=#1DDC5D][b]${newCount}[/b][/color].`);
},
addtime: hostAdjustTime(+1),
removetime: hostAdjustTime(-1),
naughty(ctx) {
const {author, fancyName, args, giveawayData} = ctx;
if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;
const sub = (args.shift() || "").toLowerCase();
const target = (args.shift() || "");
const key = target.toLowerCase(); // key we store/match on
switch (sub) {
case "add": {
if (!key) { sendMessage("[color=red]Usage:[/color] !naughty add username"); return; }
if (key === giveawayData.host.toLowerCase()) {
sendMessage(
`[color=red][b]The host can't be added to the naughty list![/b][/color]`
);
return;
}
naughtySet.add(key); // save in LS
saveNaughty();
// remove any existing entry (fast exact, then fallback loop)
let removed = numberEntries.delete(key); // exact-case fast path
if (!removed) {
for (const user of numberEntries.keys()) {
if (user.toLowerCase() === key) {
numberEntries.delete(user);
fancyNames.delete(user);
removed = true;
break;
}
}
}
if (removed) updateEntries(); // refresh table only when needed
sendMessage(`👮 [color=#FFDE59]${fmtUserList([target])} added to the naughty list and removed from the giveaway.[/color]`);
break;
}
case "remove":
if (!key) { sendMessage("[color=red]Usage:[/color] !naughty remove username"); return; }
naughtySet.delete(key); saveNaughty();
sendMessage(`🥳 [color=#7DDA58]${fmtUserList([target])} removed from the naughty list![/color]`);
break;
case "list":
sendMessage(naughtySet.size
? `[color=#FFDE59]Naughty list: [b]${fmtUserList([...naughtySet])}[/b][/color]`
: "Naughty list is empty.");
break;
default:
sendMessage("[color=red]Usage:[/color] !naughty (add|remove|list) username");
}
},
end(ctx) {
const {author, fancyName, args, giveawayData} = ctx;
// If host, always allow
if (author === giveawayData.host) {
endGiveaway();
return;
}
// If admin (not host), must specify whose to end
if (isAdmin(fancyName)) {
if (!args.length || args[0] !== giveawayData.host) {
sendMessage(`[color=red]Admins must specify whose giveaway to end. Example: !end ${sanitizeNick(giveawayData.host)}[/color]`);
return;
}
endGiveaway();
}
}
};
function isHostOrAdmin(author, fancyName, host) {
return author === host || isAdmin(fancyName);
}
function showHelp() {
const COMMANDS = [
{ name: "random", setting: "disable_random" },
{ name: "time", setting: "disable_time" },
{ name: "free", setting: "disable_free" },
{ name: "number", setting: "disable_number" },
{ name: "lucky", setting: "disable_lucky" },
{ name: "bon", setting: "disable_bon" },
{ name: "range", setting: "disable_range" },
{ name: "rig", setting: "disable_rig" },
{ name: "unrig", setting: "disable_unrig" },
{ name: "entries", setting: "disable_entries" },
{ name: "help", setting: null },
{ name: "commands", setting: null },
];
function fmt(cmd, isDisabled) {
if (isDisabled) {
// Use strikethrough and gray
return `![color=#888888][s][b]${cmd}[/b][/s][/color]`;
}
// Enabled formatting
return `![color=#E50E68][b]${cmd}[/b][/color]`;
}
const helpText = "Commands are " + COMMANDS.map(({ name, setting }) =>
fmt(name, setting && GENERAL_SETTINGS[setting])
).join(" - ") + ".";
sendMessage(helpText);
}
function funUpload(text) {
return () => {
if (window.location.hostname === "upload.cx") sendMessage(text);
};
}
function hostAddBon({ author, args, giveawayData }) {
if (author !== giveawayData.host) return;
const amount = parseFloat(args[0]);
if (isNaN(amount) || amount <= 0) {
sendMessage("[b][color=red]Invalid usage.[/color] Example: !addbon 100[/b]");
return;
}
giveawayData.amount += amount;
sendMessage(`The host is adding [color=#DC3D1D][b]${amount.toLocaleString()}[/b][/color] BON to the pot! The total is now: [b][color=#ffc00a]${Number(cleanPotString(giveawayData.amount)).toLocaleString()} BON[/color][/b]`);
}
function rebuildSchedule() {
const totalMin = (giveawayData.endTs - Date.now()) / 60000;
// Use the UI value for number of reminders (clamp if needed)
let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalMin)[0]);
if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0;
giveawayData.reminderSchedule = getReminderSchedule(totalMin, reminderNum);
giveawayData.reminderNum = reminderNum;
// Frequency fields (legacy helpers)
giveawayData.reminderFreqSec = (reminderNum > 0) ? totalMin * 60 / (reminderNum + 1) : 0;
giveawayData.nextReminderSec = giveawayData.reminderFreqSec;
remNumInput.value = reminderNum;
}
function hostAdjustTime (sign) {
// sign = +1 for !addtime, ‑1 for !removetime
return ({ author, fancyName, args, giveawayData }) => {
if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;
const mins = parseFloat(args[0]);
if (isNaN(mins)) {
sendMessage("[color=red]Usage:[/color] !time add|remove <minutes> or !addtime|!removetime <minutes>");
return;
}
const deltaMs = sign * mins * 60_000;
giveawayData.endTs += deltaMs; // move the deadline
rebuildSchedule(); // top‑off / trim to new cadence
giveawayData.timeLeft = Math.max(Math.ceil((giveawayData.endTs - Date.now()) / 1000), 0);
countdownHeader.textContent = parseTime(giveawayData.endTs - Date.now());
const verb = sign > 0 ? "Added" : "Removed";
const prep = sign > 0 ? "to" : "from";
sendMessage(`${verb} [color=#DC3D1D][b]${mins}[/b][/color] minute${mins === 1 ? "" : "s"} ${prep} the giveaway. ` +
`New time left: [b][color=#1DDC5D]${parseTime(giveawayData.endTs - Date.now())}[/color][/b].`);
};
}
function announce(sign, mins, now, endTs) {
const verb = sign > 0 ? "Added" : "Removed";
const prep = sign > 0 ? "to" : "from";
countdownHeader.textContent = parseTime(endTs - now);
sendMessage(`${verb} [color=#DC3D1D][b]${mins}[/b][/color] minute${mins===1?"":"s"} ${prep} the giveaway. ` +
`New time left: [b][color=#1DDC5D]${parseTime(endTs - now)}[/color][/b].`);
}
// ───────────────────────────────────────────────────────────
// SECTION 11: Winner Selection and Payouts
// ───────────────────────────────────────────────────────────
function endGiveaway() {
// no entries → no winners
if (numberEntries.size == 0) {
const emptyMessage = `Unfortunately, no one has entered the giveaway, so no one wins!`
sendMessage(emptyMessage)
} else {
// 1) sponsors shout-out
if (giveawayData.sponsors.length > 0) {
const safe = giveawayData.sponsors.map(sponsor => {
const amount = giveawayData.sponsorContribs[sponsor] || 0;
return `[color=#1DDC5D][b]${sanitizeNick(sponsor)}[/b][/color] ([color=#ffc00a][b]${amount.toLocaleString()} BON[/b][/color])`;
});
const sponsorsMessage =
`Thank you to all the sponsors! 🥳 ` + safe.join(", ");
sendMessage(sponsorsMessage);
}
// 2) build and sort entries by closeness to winningNumber
const entries = Array.from(numberEntries.entries())
.map(([author, guess], idx) => ({
author,
guess,
gap: Math.abs(guess - giveawayData.winningNumber),
order: idx
}))
.sort((a, b) => a.gap - b.gap || a.order - b.order);
// Detect and announce ties
const ties = entries.filter(e => e.gap === entries[0].gap);
if (ties.length > 1) {
const tieMessage = ties.map(e => `[b][color=#DC3D1D]${e.author}[/color][/b]`).join(", ");
sendMessage(`⚠️ We have a tie between ${tieMessage}! [b][color=#DC3D1D]${entries[0].author}[/color][/b] wins the tie-breaker as their entry was submitted first!`);
}
// 3) pick top N winners
const N = Math.min(giveawayData.winnersNum, entries.length);
const winners = entries.slice(0, N);
// 4) compute weight-based payouts
// weight for rank i (0-based) is (N - i)
const weights = winners.map((_, i) => N - i);
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
// raw amounts, floored to integers
let allocated = winners.map((_, i) =>
Math.floor(giveawayData.amount * weights[i] / totalWeight)
);
// fix any rounding‐leftover by giving it to 1st place
const sumAllocated = allocated.reduce((s, x) => s + x, 0);
const leftover = giveawayData.amount - sumAllocated;
if (leftover > 0) {
allocated[0] += leftover;
}
// 5) announce winners summary
const winNum = giveawayData.winningNumber;
//hard-coded emoji “podium”
const podium = ["🥇", "🥈", "🥉", "🏅", "🎖️"];
//build the tail: 6th, 7th, … up to the larger of N or MAX_WINNERS
const need = Math.max(giveawayData.winnersNum, MAX_WINNERS) - podium.length;
const tail = Array.from({ length: need }, (_, i) => {
const n = i + podium.length + 1;
const s = (n % 10 === 1 && n % 100 !== 11) ? "st" :
(n % 10 === 2 && n % 100 !== 12) ? "nd" :
(n % 10 === 3 && n % 100 !== 13) ? "rd" : "th";
return `${n}${s}`; // "6th" … "15th"
});
//final list
const medals = podium.concat(tail);
const header =
`🏆 The winning number was [b][color=#1DDC5D]${winNum}[/color][/b]. ` +
`Congratulations to ` +
(winners.length === 1
? ` `
: `these [b][color=#5DE2E7]${winners.length} winners[/color][/b]!`
);
if (winners.length === 1) {
// single‐winner public message
const w = winners[0];
const diff = Math.abs(w.guess - winNum);
const prize = allocated[0].toLocaleString();
sendMessage(
`${header}[b][color=#DC3D1D]${w.author}[/color][/b]! ` +
`You guessed [color=#1DDC5D][b]${w.guess}[/b][/color] [color=#FB4F4F](off by ${diff})[/color] ` +
`and will receive [b][color=#FFC00A]${prize} BON[/color][/b]!`
);
} else {
// multi‐winner public message
const lines = winners.map((w, i) => {
const diff = Math.abs(w.guess - winNum);
const prize = allocated[i].toLocaleString();
const medal = medals[i] || `${i+1}.`;
return `${medal} [b][color=#DC3D1D]${w.author}[/color][/b]: ` +
`[color=#1DDC5D][b]${w.guess}[/b][/color] ([color=#FB4F4F]${diff}[/color]) ` +
`[color=#FFC00A][b]${prize} BON[/b][/color]`;
});
sendMessage(
header + ' ' + lines.join(', '));
}
// 6) send the gifts
if (winners.length === 1) {
// single‐winner gift message
const w = winners[0];
const amt = allocated[0];
sendMessage(
`/gift ${w.author} ${amt} ` +
`🎉 You won! Enjoy your ${amt} BON!`
);
} else {
// -- multi-winner gift messages -----------------------------
winners.forEach((w, i) => {
const placeText = ordinal(i + 1); // 1st, 2nd, 3rd…
sendMessage(
`/gift ${w.author} ${allocated[i]} ` +
`🎉 Congratulations on placing ${placeText}!`
);
});
}
}
// 7) clean up timers & state
stopGiveaway();
}
// ───────────────────────────────────────────────────────────
// SECTION 12: Utility Functions
// ───────────────────────────────────────────────────────────
function detectBestExtractor() {
const hostname = location.hostname.toLowerCase();
if (hostname.includes("onlyencodes.cc")) {
return "onlyencodes";
}
const messages = document.querySelectorAll('.chatbox-message');
for (const [key, extractor] of Object.entries(extractors)) {
if (key === "onlyencodes" || key === "fallback") continue;
for (const msgNode of messages) {
if (extractor(msgNode)) {
return key;
}
}
}
return "fallback";
}
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function sendReminder() {
if (!shouldSendReminder(giveawayData)) {
// Try again in 15 seconds if still eligible
if (!reminderRetryTimeout) {
reminderRetryTimeout = setTimeout(() => {
reminderRetryTimeout = null;
sendReminder();
}, 15000);
}
return;
}
// Clear retry timer if any
if (reminderRetryTimeout) {
clearTimeout(reminderRetryTimeout);
reminderRetryTimeout = null;
}
const msg =
`There is an ongoing giveaway for ` +
`[b][color=#ffc00a]${Number(cleanPotString(giveawayData.amount)).toLocaleString()} BON[/color][/b]. ` +
`Up to [b][color=#5DE2E7]${giveawayData.winnersNum} ${giveawayData.winnersNum === 1 ? 'winner' : 'winners'}[/color][/b] will be selected. ` +
`Time left: [b][color=#1DDC5D]${parseTime(giveawayData.timeLeft*1000)}[/color][/b]. ` +
`To enter, submit a whole number [b]between [color=#DC3D1D]${giveawayData.startNum} and ${giveawayData.endNum}[/color] inclusive.[/b] ` +
`[b][color=#5DE2E7]${giveawayData.customMessage} [/color][/b]\n` +
`✨[b][color=#FB4F4F]Gifting BON to the host will add to the pot![/color][/b]✨`;
sendMessage(msg);
}
async function sendMessage(messageStr) {
if (DEBUG_SETTINGS.verify_sendmessage) {
console.debug("sendMessage: caching chat context if needed");
}
// If cache is missing, try to refresh
if (!OT_USER_ID || !OT_CHATROOM_ID || !OT_CSRF_TOKEN) cacheChatContext();
// --- Attempt API POST ---
if (!DEBUG_SETTINGS.disable_chat_output && !DEBUG_SETTINGS.suppressApiMessages) {
try {
// Sanity check
if (OT_USER_ID && OT_CHATROOM_ID && OT_CSRF_TOKEN) {
if (DEBUG_SETTINGS.verify_sendmessage) {
console.debug("sendMessage: sending API message:", messageStr);
}
const apiUrl = `/api/chat/messages`;
const payload = {
bot_id: null,
chatroom_id: Number(OT_CHATROOM_ID),
message: messageStr,
receiver_id: null,
save: true,
targeted: 0,
user_id: Number(OT_USER_ID)
};
const resp = await fetch(apiUrl, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-TOKEN": OT_CSRF_TOKEN,
"X-Requested-With": "XMLHttpRequest"
},
body: JSON.stringify(payload)
});
const respText = await resp.text();
if (resp.ok) {
if (DEBUG_SETTINGS.log_chat_messages) {
console.log(`API send: ${messageStr}`);
}
if (DEBUG_SETTINGS.verify_sendmessage) {
console.debug("sendMessage: API message sent successfully");
}
return;
} else {
try {
const error = JSON.parse(respText);
console.error("API error", error);
} catch (e) {
console.error("API error (raw):", respText);
}
throw new Error("API send failed");
}
}
} catch (e) {
if (DEBUG_SETTINGS.log_chat_messages) {
console.warn("API send failed, falling back to chatbox method:", e);
}
if (DEBUG_SETTINGS.verify_sendmessage) {
console.debug("sendMessage: API send failed, falling back to chatbox method");
}
}
}
// ---- Fallback to legacy chatbox method ----
if (!DEBUG_SETTINGS.disable_chat_output && chatbox) {
if (DEBUG_SETTINGS.log_chat_messages) {
console.log(`Fallback send (chatbox): ${messageStr}`);
}
if (DEBUG_SETTINGS.verify_sendmessage) {
console.debug("sendMessage: sending message via chatbox fallback");
}
const originalValue = chatbox.value;
chatbox.value = messageStr;
chatbox.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
setTimeout(() => {
chatbox.value = originalValue;
if (DEBUG_SETTINGS.verify_sendmessage) {
console.debug("sendMessage: restored chatbox original value");
}
}, 50);
}
}
function countdownTimer (display, giveawayData) {
const startMs = Date.now(); // absolute start‑stamp
display.hidden = false;
const timerID = setInterval(() => {
const now = Date.now();
const msLeft = giveawayData.endTs - now;
giveawayData.timeLeft = Math.max(Math.ceil(msLeft / 1000), 0);
// update MM:SS
const m = Math.floor(giveawayData.timeLeft / 60);
const s = giveawayData.timeLeft % 60;
display.textContent = String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
// finish conditions
if (giveawayData.timeLeft === 0) return endGiveaway();
if (numberEntries.size === giveawayData.totalEntries) {
sendMessage(`All [b][color=#ffc00a]${giveawayData.totalEntries}[/color][/b] slot(s) filled! Ending early with ` +
`[b][color=#1DDC5D]${parseTime(msLeft)}[/color][/b] remaining!`);
return endGiveaway();
}
// automatic reminders
const msElapsed = (giveawayData.endTs - startMs) - msLeft;
const msToNext = nextReminderMs(giveawayData.reminderSchedule, msElapsed);
if (msToNext !== null && msToNext <= 1000) sendReminder();
}, 1000);
return timerID;
}
// Inserts a zero-width space after the first character
function sanitizeNick(nick) {
if (typeof nick !== "string" || nick.length < 2) return nick;
return nick[0] + "\u200B" + nick.slice(1);
}
function fmtUserList(arr) {
return arr.map(n => `[b]${sanitizeNick(n)}[/b]`).join(", ");
}
/* ── pretty-print a BON amount ── */
function fmtBON(value) {
// Accept string or number, strip spaces, coerce to Number, then local-format
return Number(String(value).replace(/\s+/g, '')).toLocaleString();
}
function ordinal(n){
const rem100 = n % 100;
if (rem100 >= 11 && rem100 <= 13) return `${n}th`;
switch (n % 10){
case 1: return `${n}st`;
case 2: return `${n}nd`;
case 3: return `${n}rd`;
default: return `${n}th`;
}
}
function getLuckyNumber(giveawayData) {
// Convert taken numbers to sorted array
const taken = Array.from(numberEntries.values());
taken.sort((a, b) => a - b);
// Append one after the max so we always catch the final gap
taken.push(giveawayData.endNum + 1);
let bestGap = 0;
let lucky = giveawayData.startNum;
let prev = giveawayData.startNum - 1;
for (const current of taken) {
const gap = current - prev;
if (gap > bestGap) {
// Find the middle number in the gap
lucky = Math.floor((prev + current) / 2);
bestGap = gap;
}
prev = current;
}
// Clamp lucky to the valid range
if (lucky < giveawayData.startNum) lucky = giveawayData.startNum;
if (lucky > giveawayData.endNum) lucky = giveawayData.endNum;
return lucky;
}
function cleanPotString(giveawayPotAmount) {
if (giveawayPotAmount % 1 == 0) {
return giveawayPotAmount
} else {
return giveawayPotAmount.toFixed(2)
}
}
function parseTime(ms) {
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
const parts = [];
if (hours) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
if (minutes) parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
if (seconds) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`);
return parts.join(", ");
}
function totalMinutes () {
const t = parseFloat(timerInput.value);
return isNaN(t) || t <= 0 ? 0 : t;
}
function nextReminderMs(schedule, now) {
while(schedule.length && now>=schedule[schedule.length-1]) schedule.pop();
return schedule.length? schedule[schedule.length-1]-now : null;
}
// Returns [maxReminders, minInterval (in min)]
function getReminderLimits(totalMinutes) {
const MIN_INTERVAL = 5; // 5 min between reminders
if (totalMinutes < MIN_INTERVAL) return [0, null];
let max = Math.floor(totalMinutes / MIN_INTERVAL);
return [max, MIN_INTERVAL];
}
// Returns [N reminders] timestamps (ms before end) evenly spaced
function getReminderSchedule(totalMinutes, numReminders) {
if (numReminders < 1) return [];
const interval = totalMinutes / (numReminders + 1);
return Array.from({length: numReminders}, (_,i) =>
Math.round((totalMinutes - (i + 1) * interval) * 60_000)
);
}
function shouldSendReminder(giveawayData) {
// Get the chat messages container (adjust selector as needed for your site)
const messages = Array.from(document.querySelectorAll('.chatbox-message'));
// Only look at the last 7 messages
for (let i = messages.length - 1; i >= Math.max(messages.length - 7, 0); i--) {
const msgNode = messages[i];
const author = getAuthor(msgNode);
// Extract the text content
const text = msgNode.querySelector(".chatbox-message__content")?.textContent || "";
// Does it contain your unique reminder marker?
if (
author === giveawayData.host &&
text.includes("Gifting BON to the host will add to the pot")
) {
// Recent visible reminder by host exists
return false;
}
}
// No visible recent reminder from host found
return true;
}
// Live sync reminder number field with allowed max/min and show interval
function syncReminderNumUI() {
if (!giveawayForm) return;
const totMin = totalMinutes();
const [maxRem, minInterval] = getReminderLimits(totMin);
remNumInput.max = maxRem;
remNumInput.min = 0;
// Clamp to allowed range
if (Number(remNumInput.value) > maxRem) remNumInput.value = maxRem;
if (Number(remNumInput.value) < 0) remNumInput.value = 0;
// Show interval in "Every" field
if (Number(remNumInput.value) > 0) {
const interval = totMin / (Number(remNumInput.value) + 1);
reminderEvery.value = interval.toFixed(2).replace(/\.00$/,"") + " min";
} else {
reminderEvery.value = "–";
}
const label = giveawayForm.querySelector('label[for="reminderNum"]');
if (label) {
label.textContent = "# Reminders" + (maxRem ? ` (max ${maxRem})` : '');
}
}
function cacheChatContext() {
OT_USER_ID = null;
OT_CHATROOM_ID = null;
OT_CSRF_TOKEN = null;
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: starting cache refresh");
}
// Try oldtoons (#chatbody[x-data]) first
const section = document.querySelector('section#chatbody[x-data]');
if (section) {
try {
const raw = section.getAttribute('x-data');
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: found x-data attribute:", raw);
}
// Extract the substring 'JSON.parse(...)' from raw
const jsonParseMatch = raw.match(/JSON\.parse\((['"])([\s\S]*?)\1\)/);
if (jsonParseMatch) {
const jsonParseString = jsonParseMatch[0]; // entire JSON.parse('...') call
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: extracted JSON.parse substring:", jsonParseString);
}
try {
// Evaluate JSON.parse(...) directly
const jsonData = eval(jsonParseString);
if (jsonData) {
OT_USER_ID = Number(jsonData.id);
OT_CHATROOM_ID = Number(jsonData.chatroom_id);
}
} catch (e) {
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: error evaluating JSON.parse string", e);
}
}
} else {
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: JSON.parse(...) pattern not found in x-data");
}
}
} catch (e) {
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: error reading x-data attribute", e);
}
}
}
// If not found, try onlyencodes method
if (!OT_USER_ID || !OT_CHATROOM_ID) {
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: falling back to onlyencodes method");
}
const oeSection = document.querySelector('section.panelV2.blocks__top-torrents[wire\\:snapshot]');
if (oeSection) {
try {
const snap = oeSection.getAttribute('wire:snapshot');
if (snap) {
const obj = JSON.parse(snap);
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: found wire:snapshot attribute:", snap);
}
// The user info is in obj.data.user, second item in array
const userArray = obj.data?.user;
if (Array.isArray(userArray) && userArray.length > 1 && userArray[1].key) {
OT_USER_ID = Number(userArray[1].key);
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: extracted OT_USER_ID from wire:snapshot:", OT_USER_ID);
}
}
}
} catch (e) {
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: error parsing wire:snapshot JSON", e);
}
}
}
// For onlyencodes, chatroom_id is always 1
if (window.location.hostname === "onlyencodes.cc") {
OT_CHATROOM_ID = 1;
}
}
// CSRF token
const xsrfToken = document.querySelector('meta[name=csrf-token]')?.content ||
window?.CSRF_TOKEN ||
(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] || "");
OT_CSRF_TOKEN = xsrfToken ? decodeURIComponent(xsrfToken) : "";
if (DEBUG_SETTINGS.verify_cacheChatContext) {
console.debug("cacheChatContext: final OT_CSRF_TOKEN =", OT_CSRF_TOKEN ? "[token present]" : "[token missing]");
}
}
// ───────────────────────────────────────────────────────────
// SECTION 13: Menu Field Scaling and Validation
// ───────────────────────────────────────────────────────────
function reminderAutoScaling() {
const totMin = totalMinutes();
const [maxRem] = getReminderLimits(totMin);
// Only auto-set if the reminders field isn't focused or is empty/zero
// (so we don't overwrite intentional user edits)
if (
document.activeElement !== remNumInput ||
remNumInput.value === "" ||
remNumInput.value == "0"
) {
remNumInput.value = maxRem;
}
syncReminderNumUI();
}
function entryRangeValidation() {
const startVal = startInput.value.trim();
const endVal = endInput.value.trim();
// Allow optional negative sign followed by digits (no letters)
const integerRegex = /^-?\d+$/;
// Clear previous custom validity messages
startInput.setCustomValidity("");
endInput.setCustomValidity("");
// Check for any letters in the inputs
const lettersRegex = /[A-Za-z]/;
if (lettersRegex.test(startVal) || lettersRegex.test(endVal)) {
startInput.setCustomValidity("Letters are not allowed—please enter valid integers.");
endInput.setCustomValidity("Letters are not allowed—please enter valid integers.");
return false;
}
// Ensure the inputs match the integer pattern
if (
!integerRegex.test(startVal) ||
!integerRegex.test(endVal)
) {
startInput.setCustomValidity("Please enter valid integers (e.g., -5, 0, 10).");
endInput.setCustomValidity("Please enter valid integers (e.g., -5, 0, 10).");
return false;
}
const startNum = parseInt(startVal, 10);
const endNum = parseInt(endVal, 10);
// Check for NaN just in case
if (isNaN(startNum) || isNaN(endNum)) {
startInput.setCustomValidity("Please enter numbers only.");
endInput.setCustomValidity("Please enter numbers only.");
return false;
}
// Ensure start is not greater than end
if (startNum > endNum) {
endInput.setCustomValidity("End # should be greater than or equal to Start #.");
return false;
}
return true;
}
function winnersValidation() {
winnersInput.setCustomValidity("");
const val = parseInt(winnersInput.value, 10);
if (isNaN(val) || val < 1 || val > MAX_WINNERS) {
winnersInput.setCustomValidity(`Please choose between 1 and ${MAX_WINNERS} winners.`);
winnersInput.reportValidity();
return false;
}
return true;
}
function toggleAll() {
const newDisabled = !GENERAL_SETTINGS.disable_random;
GENERAL_SETTINGS.disable_random = newDisabled;
GENERAL_SETTINGS.disable_lucky = newDisabled;
GENERAL_SETTINGS.disable_free = newDisabled;
GENERAL_SETTINGS.suppress_entry_replies = newDisabled;
document.getElementById("randomToggle").checked = !newDisabled;
document.getElementById("luckyToggle").checked = !newDisabled;
document.getElementById("freeToggle").checked = !newDisabled;
document.getElementById("entryrepliesToggle").checked = !newDisabled;
localStorage.setItem("giveaway-disableRandom", String(newDisabled));
localStorage.setItem("giveaway-disableLucky", String(newDisabled));
localStorage.setItem("giveaway-disableFree", String(newDisabled));
localStorage.setItem("giveaway-suppressEntryReplies", String(newDisabled))
}
// Outside-click: if you click anywhere that's not inside a menu or on its button, close both
function handleOutsideClick(event) {
const insideSettings = settingsMenu.contains(event.target) || settingsBtn.contains(event.target);
const insideCommands = commandsMenu.contains(event.target) || commandsBtn.contains(event.target);
if (!insideSettings && !insideCommands) {
settingsMenu.classList.remove('open');
settingsMenu.style.display = 'none';
hardCloseCommands();
document.removeEventListener('click', handleOutsideClick);
}
}
function hardCloseCommands() {
commandsMenu.classList.remove('open');
commandsMenu.style.display = 'none'; // keep it hidden
}
function hardCloseSettings () {
settingsMenu.classList.remove('open');
settingsMenu.style.display = 'none';
document.removeEventListener('click', handleOutsideClick);
}
function addStyle(css, id) {
const style = document.createElement("style");
style.id = id;
style.textContent = css;
document.head.appendChild(style);
}
})();