StumbleBot

Play YouTube videos from the chat box and/or add custom commands to StumbleChat

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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; }
  }
})();