// ==UserScript==
// @name StumbleBot
// @namespace StumbleBot
// @version 1.1.2
// @description Play YouTube videos from the chat box and/or add custom commands to StumbleChat
// @author Goji
// @match https://stumblechat.com/room/*
// ==/UserScript==
/* =============================================================================
🤖 OVERVIEW
-----------------------------------------------------------------------------
- Global 1s queue for bot messages (`this._send(...)` is queued automatically)
- Persistent identity: userHandles (handle→username), userNicknames (nick cache)
- Roles: from join `mod` codes + runtime "role" events; hasMinimumRole helper
- YT: operator-only .yt/.play/.video/.youtube (<id|url|search>), URL normalize
- History: `.history [page]` paginated list of added YouTube tracks
- Utilities: `.self` (show your info), `.whois <user|nick|handle>`
- `.commands` alias `.help`
============================================================================= */
/* =============================================================================
🧠 GLOBAL 1000ms MESSAGE QUEUE (affects bot sends only)
============================================================================= */
const SEND_COOLDOWN_MS = 1000;
const _socketState = new WeakMap(); // WebSocket -> { queue, busy, lastSentAt }
function getState(ws) {
let s = _socketState.get(ws);
if (!s) {
s = { queue: [], busy: false, lastSentAt: 0 };
_socketState.set(ws, s);
}
return s;
}
function enqueueSend(ws, payload) {
const state = getState(ws);
state.queue.push(payload);
if (!state.busy) processQueue(ws);
}
function processQueue(ws) {
const state = getState(ws);
if (state.busy) return;
const next = state.queue.shift();
if (!next) return;
const now = Date.now();
const wait = Math.max(0, state.lastSentAt + SEND_COOLDOWN_MS - now);
state.busy = true;
setTimeout(() => {
try {
const raw = (typeof next === "string") ? next : JSON.stringify(next);
const native = ws.__nativeSend || WebSocket.prototype.send;
native.call(ws, raw);
} catch (e) {
console.error("Queue send failed:", e, next);
} finally {
state.lastSentAt = Date.now();
state.busy = false;
if (state.queue.length) processQueue(ws);
}
}, wait);
}
// Optional helper for timers/elsewhere
window.stumbleBotSend = function (textOrPayload) {
const ws = window._ssbSocket;
if (!ws) return;
ws._send(textOrPayload);
};
/* =============================================================================
🌿 HOURLY 4:20 DEMO (optional)
============================================================================= */
let lastSentHour = -1;
let shouldSendMessage = false;
setInterval(() => {
const now = new Date();
if (now.getMinutes() === 20 && now.getSeconds() === 0 && lastSentHour !== now.getHours() && !shouldSendMessage) {
lastSentHour = now.getHours();
shouldSendMessage = true;
}
}, 1000);
/* =============================================================================
🧾 IDENTITY & ROLE TRACKING (persistent)
- userHandles: handle -> username
- userNicknames: username OR handle -> { handle, username, nickname, role, modStatus, hasJoinedBefore? }
- hasMinimumRole(username, minRole) using simple ladder
- role from join: numeric mod (0..4) → role string
- role updates: stumble="role" {handle, type: "owner|moderator|operator|super|revoke"}
============================================================================= */
let userHandles = JSON.parse(localStorage.getItem("userHandles") || "{}"); // handle -> username
let userNicknames = JSON.parse(localStorage.getItem("userNicknames") || "{}"); // username/handle -> record
function saveIdentity() {
localStorage.setItem("userHandles", JSON.stringify(userHandles));
localStorage.setItem("userNicknames", JSON.stringify(userNicknames));
}
// role mapping and ladder
const ROLE_ORDER = ["none", "guest", "regular", "operator", "moderator", "super", "owner"];
const MOD_NUM_TO_ROLE = (n) =>
n >= 4 ? "owner" :
n === 3 ? "super" :
n === 2 ? "moderator" :
n === 1 ? "operator" : "none";
function roleRank(role) {
const idx = ROLE_ORDER.indexOf((role || "none").toLowerCase());
return idx >= 0 ? idx : 0;
}
function hasMinimumRole(username, minimumRole) {
const rec = userNicknames[username];
const current = rec?.role || "none";
return roleRank(current) >= roleRank(minimumRole);
}
function setRole(username, newRole) {
if (!username) return;
const rec = (userNicknames[username] ||= { username, nickname: username });
if (roleRank(newRole) >= roleRank(rec.role || "none")) {
rec.role = newRole;
}
saveIdentity();
}
function updateOnJoin(wsmsg) {
const username = wsmsg.username;
const handle = wsmsg.handle;
let nickname = wsmsg.nick;
if (!username || !handle) return;
if (/^guest-\d+$/i.test(nickname)) nickname = username;
const rec = {
handle,
username,
nickname: nickname || username,
modStatus: wsmsg.mod ? "Moderator" : "Regular",
role: MOD_NUM_TO_ROLE(Number(wsmsg.mod || 0)),
hasJoinedBefore: (userNicknames[username]?.hasJoinedBefore || false)
};
userNicknames[username] = { ...(userNicknames[username] || {}), ...rec };
userNicknames[handle] = { ...(userNicknames[handle] || {}), ...rec };
userHandles[handle] = username;
saveIdentity();
}
function updateNickname(wsmsg) {
const handle = wsmsg.handle;
let nick = wsmsg.nick;
if (!handle || !nick) return;
const username = userHandles[handle];
if (!username) return;
if (/^guest-\d+$/i.test(nick)) nick = username;
if (userNicknames[username]) userNicknames[username].nickname = nick;
if (userNicknames[handle]) userNicknames[handle].nickname = nick;
saveIdentity();
}
function cleanupOnQuit(wsmsg) {
const handle = wsmsg.handle;
if (!handle) return;
delete userHandles[handle];
saveIdentity(); // keep nick/roles
}
/* =============================================================================
🎥 YOUTUBE HELPERS & STORAGE
============================================================================= */
// Keywords
const youtubeKeywords = ['.youtube', '.video', '.play', '.yt'];
// Persisted YT history
let youtubeHistory = JSON.parse(localStorage.getItem("youtubeHistory") || "[]");
function saveYT() { localStorage.setItem("youtubeHistory", JSON.stringify(youtubeHistory)); }
// Normalize many YouTube URL forms to watch?v=
function convertToRegularYouTubeLink(url) {
const videoIdRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/|.*[?&]v=))([\w-]+)/i;
const match = String(url || "").trim().match(videoIdRegex);
return (match && match[1]) ? ("https://www.youtube.com/watch?v=" + match[1]) : null;
}
function saveToHistory(requester, trackName) {
if (!trackName) return;
youtubeHistory.push({ requester: requester || null, track: trackName });
saveYT();
}
/* =============================================================================
🤖 MAIN BOT: HOOK SOCKET & HANDLE MESSAGES
============================================================================= */
(function () {
// Capture native sender, override once
const nativeSend = WebSocket.prototype.send;
WebSocket.prototype.send = function (data) {
if (!this.__ssbHooked) {
this.__ssbHooked = true;
this.__nativeSend = nativeSend;
// Backward-compatible: any existing this._send(...) now goes through queue
this._send = (payload) => enqueueSend(this, payload);
// Expose current socket to helpers
window._ssbSocket = this;
// Bind handler
this.addEventListener("message", handleMessage.bind(this), false);
}
// Keep site traffic unthrottled
return this.__nativeSend.call(this, data);
};
function handleMessage(evt) {
const wsmsg = safeJSONParse(evt?.data);
if (!wsmsg) return;
/* -- Identity & roles -- */
if (wsmsg.stumble === "join" && wsmsg.username && wsmsg.handle) {
const firstJoin = !userNicknames[wsmsg.username]?.hasJoinedBefore;
updateOnJoin(wsmsg);
const display = userNicknames[wsmsg.username]?.nickname || wsmsg.username;
if (firstJoin) {
userNicknames[wsmsg.username].hasJoinedBefore = true;
saveIdentity();
respondWithMessage.call(this, `Welcome, ${display}! 🌟`);
} else {
respondWithMessage.call(this, `Welcome back, ${display}! 🎉`);
}
}
if (wsmsg.stumble === "nick") {
updateNickname(wsmsg);
}
if (wsmsg.stumble === "quit") {
cleanupOnQuit(wsmsg);
}
// Role updates from server
if (wsmsg.stumble === "role" && wsmsg.handle) {
const username = userHandles[wsmsg.handle];
if (username) {
const newRole = (wsmsg.type === "revoke") ? "none" : (wsmsg.type || "none");
setRole(username, newRole);
if (userNicknames[wsmsg.handle]) {
userNicknames[wsmsg.handle].role = newRole;
saveIdentity();
}
}
}
// Capture bot self (if server sends a `self` payload)
if (wsmsg.self && wsmsg.self.username && wsmsg.self.handle) {
const s = wsmsg.self;
updateOnJoin({ stumble:"join", username:s.username, handle:s.handle, nick:s.nick, mod:s.mod });
setRole(s.username, MOD_NUM_TO_ROLE(Number(s.mod || 0)));
}
/* -- Hourly 4:20 demo -- */
if (shouldSendMessage) {
shouldSendMessage = false;
setTimeout(() => {
this._send({ stumble: "msg", text: "🌲 It's 4:20 somewhere! Smoke em if you got em! 💨" });
}, 1000);
}
/* =====================================================================
🎬 YOUTUBE COMMAND (operator+)
Accepts: .yt/.play/.video/.youtube <id|url|search terms>
===================================================================== */
if (wsmsg && typeof wsmsg.text === "string") {
const rawText = wsmsg.text;
const textLower = rawText.toLowerCase();
// Match keyword prefix
let matchedKeyword = null;
for (const kw of youtubeKeywords) {
if (textLower.startsWith(kw)) { matchedKeyword = kw; break; }
}
if (matchedKeyword) {
const handle = wsmsg.handle;
// Resolve username
let username = null;
if (handle && userHandles[handle]) username = userHandles[handle];
else if (handle && userNicknames[handle]?.username) username = userNicknames[handle].username;
const canUse = username ? hasMinimumRole(username, "operator") : false;
if (!canUse) {
respondWithMessage.call(this, "🚫 You need Operator or higher to play YouTube videos.");
} else {
const firstSpace = rawText.indexOf(" ");
const query = (firstSpace >= 0) ? rawText.slice(firstSpace + 1).trim() : "";
if (query && query.toLowerCase() !== matchedKeyword) {
const finalLink = convertToRegularYouTubeLink(query) || query;
this._send({ stumble: "youtube", type: "add", id: finalLink, time: 0 });
}
}
}
}
// Log YT adds from system message
if (wsmsg.stumble === "sysmsg" && typeof wsmsg.text === "string" && wsmsg.text.includes("has added YouTube track:")) {
const lines = wsmsg.text.split("\n");
const trackName = (lines[lines.length - 1] || "").trim();
if (trackName) {
let requester = null;
const m = lines[0].match(/^(.*?) \((.*?)\) has added YouTube track:/);
if (m) requester = m[2];
saveToHistory(requester, trackName);
}
}
/* =====================================================================
📜 .history [page] — paginated YT history
===================================================================== */
if (wsmsg.text && typeof wsmsg.text === "string" && wsmsg.text.toLowerCase().startsWith(".history")) {
const args = wsmsg.text.trim().split(/\s+/);
const page = parseInt(args[1], 10) || 1;
const itemsPerPage = 5;
const reversedHistory = [...youtubeHistory].reverse(); // newest first
const totalPages = Math.max(1, Math.ceil(reversedHistory.length / itemsPerPage));
if (reversedHistory.length === 0) {
respondWithMessage.call(this, "🤖 No recent YouTube tracks played.");
} else if (page < 1 || page > totalPages) {
respondWithMessage.call(this, `⚠️ Invalid page. Use \`.history [1-${totalPages}]\`.`);
} else {
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
const slice = reversedHistory.slice(start, end);
let resp = `📺 YouTube History — Page ${page}/${totalPages}\n`;
slice.forEach((entry, idx) => {
const nick = entry.requester
? (userNicknames[entry.requester]?.nickname || entry.requester)
: null;
const n = start + idx + 1;
resp += entry.requester
? `${n}. ${nick} played: ${entry.track}\n`
: `${n}. ${entry.track}\n`;
});
respondWithMessage.call(this, resp.trim());
}
}
/* =====================================================================
🔎 .self / .whois (NO ECONOMY)
===================================================================== */
if (wsmsg.text && typeof wsmsg.text === "string") {
const lower = wsmsg.text.toLowerCase();
// .self — show your info
if (lower === ".self") {
const handle = wsmsg.handle;
const username = userHandles[handle];
const user = username ? userNicknames[username] : null;
if (user) {
const role = (user.role || "none").toLowerCase();
const roleLabels = {
owner: "👑 Owner",
super: "⭐ Super",
moderator: "🛡️ Moderator",
operator: "🔧 Operator",
none: "👤 Guest",
guest: "👤 Guest",
regular: "👤 Guest"
};
const roleLabel = roleLabels[role] || "👤 Guest";
const msg = `🤖 Your Info:\nUsername: ${user.username}\nNickname: ${user.nickname}\nRole: ${roleLabel}\nHandle: ${user.handle}`;
respondWithMessage.call(this, msg);
} else {
respondWithMessage.call(this, "🤖 Sorry, I couldn't find your information.");
}
}
// .whois <name|nick|handle>
if (lower.startsWith(".whois ")) {
const args = wsmsg.text.split(/\s+/);
const inputName = args[1];
let matchedUsername = null;
// 1) Exact username key
if (userNicknames[inputName]?.username) {
matchedUsername = inputName;
}
// 2) Try nickname case-insensitive
if (!matchedUsername) {
matchedUsername = Object.keys(userNicknames).find(u =>
userNicknames[u]?.username && userNicknames[u]?.nickname?.toLowerCase() === inputName.toLowerCase()
);
}
// 3) Try handle numeric exact
if (!matchedUsername && /^\d+$/.test(inputName)) {
const possible = Object.keys(userNicknames).find(u =>
userNicknames[u]?.username && userNicknames[u]?.handle === inputName
);
if (possible) matchedUsername = possible;
}
if (!matchedUsername) {
respondWithMessage.call(this, `🤖 User "${inputName}" not found.`);
} else {
const user = userNicknames[matchedUsername];
const role = (user.role || "none").toLowerCase();
const roleLabels = {
owner: "👑 Owner",
super: "⭐ Super",
moderator: "🛡️ Moderator",
operator: "🔧 Operator",
none: "👤 Guest",
guest: "👤 Guest",
regular: "👤 Guest"
};
const roleLabel = roleLabels[role] || "👤 Guest";
const msg = `🤖 Info on ${matchedUsername}:\nUsername: ${user.username}\nNickname: ${user.nickname}\nHandle: ${user.handle}\nRole: ${roleLabel}`;
respondWithMessage.call(this, msg);
}
}
}
/* =====================================================================
🧪 SIMPLE COMMAND EXAMPLES
===================================================================== */
const text = wsmsg.text || "";
// .me [message]
if (text.startsWith(".me ")) {
const handle = wsmsg.handle;
const nickname =
(handle && userNicknames[handle]?.nickname) ||
(handle && userHandles[handle] && userNicknames[userHandles[handle]]?.nickname) ||
"User";
respondWithMessage.call(this, `${nickname} ${text.slice(4).trim()}`);
}
// .commands / .help
if (text === ".commands" || text === ".help") {
const lines = [
"- .yt/.play/.video/.youtube <id|url|search> - Play a YouTube video (Operator+)",
"- .history [page] - Show recent YouTube tracks",
"- .self - Show your user info",
"- .whois <user|nick|handle> - Look up a user",
"- .me [message] - Send a message as yourself",
"- .commands/.help - List all commands",
];
lines.forEach((line, i) => setTimeout(() => respondWithMessage.call(this, line), i * 1000));
}
// ping -> PONG
if (text === "ping") {
setTimeout(() => respondWithMessage.call(this, "PONG"), 1000);
}
}
// legacy-safe message helper: always goes through queued path
function respondWithMessage(text) {
this._send({ stumble: "msg", text });
}
// safe JSON parse
function safeJSONParse(s) {
try { return JSON.parse(s); } catch { return null; }
}
})();