KPin Checker

Check the pin of a kahoot game.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         KPin Checker
// @namespace    http://tampermonkey.net/
// @homepage     https://theusaf.org
// @version      2.0.0
// @license      MIT
// @description  Check the pin of a kahoot game.
// @author       theusaf
// @match        *://play.kahoot.it/*
// @exclude      *://play.kahoot.it/v2/assets/*
// @copyright    2020-2023, Daniel Lau (https://github.com/theusaf/kahoot-antibot)
// @grant        none
// @run-at       document-start
// ==/UserScript==

/**
 * PinCheckerMain - The main pin checking function
 */
function main() {
  function listenForTeamMode() {
    document
      .querySelector("[data-functional-selector=team-mode-card]")
      .addEventListener("click", () => {
        console.log("[PIN-CHECKER] - Entered team mode card.");
        setTimeout(() => {
          document
            .querySelector("[data-functional-selector=leave-game-mode-details]")
            .addEventListener("click", () => {
              console.log("[PIN-CHECKER] - Listening again");
              setTimeout(() => listenForTeamMode(), 250);
            });
          document
            .querySelector("[data-functional-selector=start-team-mode-button]")
            .addEventListener("click", () => {
              console.log("[PIN-CHECKER] - Using team mode.");
              window.localStorage.pinCheckerMode = "team";
            });
        }, 250);
      });
  }

  const loader = setInterval(() => {
    if (!document.querySelector("[data-functional-selector=team-mode-card]")) {
      return;
    }
    console.log("[PIN-CHECKER] - Ready!");
    clearInterval(loader);
    listenForTeamMode();

    if (window.localStorage.pinCheckerAutoRelogin === "true") {
      const waiter = setInterval(() => {
        let button = document.querySelector(
          "[data-functional-selector=classic-mode-card]"
        );
        if (window.localStorage.pinCheckerMode === "team") {
          button = document.querySelector(
            "[data-functional-selector=team-mode-card]"
          );
        }
        if (button && !button.disabled) {
          const guestButton = document.querySelector(
            "[data-functional-selector=play-as-guest-button]"
          );
          if (guestButton) {
            guestButton.click();
          }
          button.click();
          if (window.localStorage.pinCheckerMode === "team") {
            setTimeout(() => {
              document
                .querySelector(
                  "[data-functional-selector=start-team-mode-button]"
                )
                .click();
            }, 250);
          }
          window.localStorage.pinCheckerAutoRelogin = false;
          if (
            +window.localStorage.pinCheckerLastQuizIndex <=
            window.kantibotData.kahootInternals.services.game.core.playList
              .length
          ) {
            kantibotData.kahootInternals.services.game.navigation.currentQuizIndex =
              +window.localStorage.pinCheckerLastQuizIndex ?? 0;
          }
          clearInterval(waiter);
          delete window.localStorage.pinCheckerMode;
          delete window.localStorage.pinCheckerLastQuizIndex;
          // check for start button
        }
      }, 500);
    } else {
      delete window.localStorage.pinCheckerMode;
    }
  }, 500);
  let loadChecks = 0;
  const themeLoadChecker = setInterval(() => {
    const errorButton = document.querySelector(
      '[data-functional-selector="dialog-actions"]'
    );
    if (errorButton) {
      clearInterval(themeLoadChecker);
      errorButton.querySelector("button").click();
    } else if (++loadChecks > 10) {
      clearInterval(themeLoadChecker);
    }
  }, 500);

  window.pinCheckerNameList = [];
  window.pinCheckerPin = null;
  window.pinCheckerSendIds = {};
  window.specialData = window.specialData || {};
  window.pinCheckerFalsePositive = false;
  window.pinCheckerFalsePositiveTimeout = null;

  /**
   * ResetGame - Reloads the page
   */
  function resetGame(message) {
    if (window.pinCheckerFalsePositive) {
      return console.log(
        "[PIN-CHECKER] - Detected false-positive broken pin. Not restarting."
      );
    }
    console.error(message || "[PIN-CHECKER] - Pin Broken. Attempting restart.");
    window.localStorage.pinCheckerAutoRelogin = true;
    window.localStorage.pinCheckerLastQuizIndex =
      window.kantibotData.kahootInternals.services.game.navigation.currentQuizIndex;
    window.document.write(
      "<scr" +
        "ipt>" +
        `window.location = "https://play.kahoot.it/v2/${window.location.search}";` +
        "</scr" +
        "ipt>"
    );
  }

  /**
   * concatTokens - From kahoot.js.org. Combines the tokens.
   *
   * @param  {String} headerToken    decoded token
   * @param  {String} challengeToken decoded token 2
   * @returns {String}               The final token
   */
  function concatTokens(headerToken, challengeToken) {
    // Combine the session token and the challenge token together to get the string needed to connect to the websocket endpoint
    let token = "";
    for (let i = 0; i < headerToken.length; i++) {
      const char = headerToken.charCodeAt(i),
        mod = challengeToken.charCodeAt(i % challengeToken.length),
        decodedChar = char ^ mod;
      token += String.fromCharCode(decodedChar);
    }
    return token;
  }

  /**
   * CreateClient - Creates a Kahoot! client to join a game
   * This really only works because kahoot treats kahoot.it, play.kahoot.it, etc as the same thing.
   *
   * @param  {Number} pin The gameid
   */
  function createClient(pin) {
    console.log("[PIN-CHECKER] - Creating client");
    pin += "";
    const sessionRequest = new XMLHttpRequest();
    sessionRequest.open("GET", "/reserve/session/" + pin);
    sessionRequest.send();
    sessionRequest.onload = function () {
      let sessionData;
      try {
        sessionData = JSON.parse(sessionRequest.responseText);
      } catch (e) {
        // probably not found
        return resetGame();
      }
      const headerToken = atob(
        sessionRequest.getResponseHeader("x-kahoot-session-token")
      );
      let { challenge } = sessionData;
      challenge = challenge.replace(/(\u0009|\u2003)/gm, "");
      challenge = challenge.replace(/this /gm, "this");
      challenge = challenge.replace(/ *\. */gm, ".");
      challenge = challenge.replace(/ *\( */gm, "(");
      challenge = challenge.replace(/ *\) */gm, ")");
      challenge = challenge.replace("console.", "");
      challenge = challenge.replace("this.angular.isObject(offset)", "true");
      challenge = challenge.replace("this.angular.isString(offset)", "true");
      challenge = challenge.replace("this.angular.isDate(offset)", "true");
      challenge = challenge.replace("this.angular.isArray(offset)", "true");
      const merger =
          "var _ = {" +
          "    replace: function() {" +
          "        var args = arguments;" +
          "        var str = arguments[0];" +
          "        return str.replace(args[1], args[2]);" +
          "    }" +
          "}; " +
          "var log = function(){};" +
          "return ",
        solver = Function(merger + challenge),
        headerChallenge = solver(),
        finalToken = concatTokens(headerToken, headerChallenge),
        connection = new WebSocket(
          `wss://kahoot.it/cometd/${pin}/${finalToken}`
        ),
        timesync = {};
      let shoken = false,
        clientId = "",
        messageId = 2,
        closed = false,
        name = "";
      connection.addEventListener("error", () => {
        console.error(
          "[PIN-CHECKER] - Socket connection failed. Assuming network connection is lost and realoading page."
        );
        resetGame();
      });
      connection.addEventListener("open", () => {
        connection.send(
          JSON.stringify([
            {
              advice: {
                interval: 0,
                timeout: 60000
              },
              minimumVersion: "1.0",
              version: "1.0",
              supportedConnectionTypes: ["websocket", "long-polling"],
              channel: "/meta/handshake",
              ext: {
                ack: true,
                timesync: {
                  l: 0,
                  o: 0,
                  tc: Date.now()
                }
              },
              id: 1
            }
          ])
        );
      });
      connection.addEventListener("message", (m) => {
        const { data } = m,
          [message] = JSON.parse(data);
        if (message.channel === "/meta/handshake" && !shoken) {
          if (message.ext && message.ext.timesync) {
            shoken = true;
            clientId = message.clientId;
            const { tc, ts, p } = message.ext.timesync,
              l = Math.round((Date.now() - tc - p) / 2),
              o = ts - tc - l;
            Object.assign(timesync, {
              l,
              o,
              get tc() {
                return Date.now();
              }
            });
            connection.send(
              JSON.stringify([
                {
                  advice: { timeout: 0 },
                  channel: "/meta/connect",
                  id: 2,
                  ext: {
                    ack: 0,
                    timesync
                  },
                  clientId
                }
              ])
            );
            // start joining
            setTimeout(() => {
              name = "KCP_" + (Date.now() + "").substr(2);
              connection.send(
                JSON.stringify([
                  {
                    clientId,
                    channel: "/service/controller",
                    id: ++messageId,
                    ext: {},
                    data: {
                      gameid: pin,
                      host: "play.kahoot.it",
                      content: JSON.stringify({
                        device: {
                          userAgent: window.navigator.userAgent,
                          screen: {
                            width: window.screen.width,
                            height: window.screen.height
                          }
                        }
                      }),
                      name,
                      type: "login"
                    }
                  }
                ])
              );
            }, 1000);
          }
        } else if (message.channel === "/meta/connect" && shoken && !closed) {
          connection.send(
            JSON.stringify([
              {
                channel: "/meta/connect",
                id: ++messageId,
                ext: {
                  ack: message.ext.ack,
                  timesync
                },
                clientId
              }
            ])
          );
        } else if (message.channel === "/service/controller") {
          if (message.data && message.data.type === "loginResponse") {
            if (message.data.error === "NONEXISTING_SESSION") {
              // session doesn't exist
              connection.send(
                JSON.stringify([
                  {
                    channel: "/meta/disconnect",
                    clientId,
                    id: ++messageId,
                    ext: {
                      timesync
                    }
                  }
                ])
              );
              connection.close();
              resetGame();
            } else {
              // Check if the client is in the game after 10 seconds
              setTimeout(() => {
                if (!window.pinCheckerNameList.includes(name)) {
                  // Uh oh! the client didn't join!
                  resetGame();
                }
              }, 10e3);
              console.log(
                "[PIN-CHECKER] - Client joined game. Connection is good."
              );
              // good. leave the game.
              connection.send(
                JSON.stringify([
                  {
                    channel: "/meta/disconnect",
                    clientId,
                    id: ++messageId,
                    ext: {
                      timesync
                    }
                  }
                ])
              );
              closed = true;
              setTimeout(() => {
                connection.close();
              }, 500);
            }
          }
        } else if (message.channel === "/service/status") {
          if (message.data.status === "LOCKED") {
            // locked, cannot test
            console.log("[PIN-CHECKER] - Game is locked. Unable to test.");
            closed = true;
            connection.send(
              JSON.stringify([
                {
                  channel: "/meta/disconnect",
                  clientId,
                  id: ++messageId,
                  ext: {
                    timesync
                  }
                }
              ])
            );
            setTimeout(() => {
              connection.close();
            }, 500);
          }
        }
      });
    };
  }

  window.pinCheckerInterval = setInterval(() => {
    if (window.pinCheckerPin) {
      createClient(window.pinCheckerPin);
    }
  }, 60 * 1000);

  /**
   * pinCheckerSendInjector
   * - Checks the sent messages to ensure events are occuring
   * - This is a small fix for a bug in Kahoot.
   *
   * @param  {String} data The sent message.
   */
  window.pinCheckerSendInjector = function pinCheckerSendInjector(data) {
    data = JSON.parse(data)[0];
    const now = Date.now();
    let content = {};
    try {
      content = JSON.parse(data.data.content);
    } catch (e) {
      /* likely no content */
    }
    if (data.data && typeof data.data.id !== "undefined") {
      for (const i in window.pinCheckerSendIds) {
        window.pinCheckerSendIds[i].add(data.data.id);
      }
      // content slides act differently, ignore them
      if (content.gameBlockType === "content") return;

      /**
       * Checks for events and attempts to make sure that it succeeds (doesn't crash)
       * - deprecated, kept in just in case for the moment
       *
       * @param  {Number} data.data.id The id of the action
       */
      switch (data.data.id) {
        case 9: {
          window.pinCheckerSendIds[now] = new Set();
          setTimeout(() => {
            if (!window.pinCheckerSendIds[now].has(1)) {
              // Restart, likely stuck
              resetGame(
                "[PIN-CHECKER] - Detected stuck on loading screen. Reloading the page."
              );
            } else {
              delete window.pinCheckerSendIds[now];
            }
          }, 60e3);
          break;
        }
        case 1: {
          window.pinCheckerSendIds[now] = new Set();
          setTimeout(() => {
            if (!window.pinCheckerSendIds[now].has(2)) {
              // Restart, likely stuck
              resetGame(
                "[PIN-CHECKER] - Detected stuck on get ready screen. Reloading the page."
              );
            } else {
              delete window.pinCheckerSendIds[now];
            }
          }, 60e3);
          break;
        }
        case 2: {
          window.pinCheckerSendIds[now] = new Set();
          // wait up to 5 minutes, assume something wrong
          setTimeout(() => {
            if (
              !window.pinCheckerSendIds[now].has(4) &&
              !window.pinCheckerSendIds[now].has(8)
            ) {
              // Restart, likely stuck
              resetGame(
                "[PIN-CHECKER] - Detected stuck on question answer. Reloading the page."
              );
            } else {
              delete window.pinCheckerSendIds[now];
            }
          }, 300e3);
          break;
        }
      }
    }
  };

  /**
   * closeError
   * - Used when the game is closed and fails to reconnect properly
   */
  window.closeError = function () {
    resetGame("[PIN-CHECKER] - Detected broken disconnected game, reloading!");
  };
}

/**
 * PinCheckerInjector - Checks messages and stores the names of players who joined within the last few seconds
 *
 * @param  {String} message The websocket message
 */
function messageInjector(socket, message) {
  function pinCheckerFalsePositiveReset() {
    window.pinCheckerFalsePositive = true;
    clearTimeout(window.pinCheckerFalsePositiveTimeout);
    window.pinCheckerFalsePositiveTimeout = setTimeout(function () {
      window.pinCheckerFalsePositive = false;
    }, 15e3);
  }
  const data = JSON.parse(message.data)[0];
  if (!socket.webSocket.pinCheckClose) {
    socket.webSocket.pinCheckClose = socket.webSocket.onclose;
    socket.webSocket.onclose = function () {
      socket.webSocket.pinCheckClose();
      setTimeout(() => {
        const stillNotConnected = document.querySelector(
          '[data-functional-selector="disconnected-page"]'
        );
        if (stillNotConnected) {
          window.closeError();
        }
      }, 30e3);
    };
  }
  if (!socket.webSocket.pinCheckSend) {
    socket.webSocket.pinCheckSend = socket.webSocket.send;
    socket.webSocket.send = function (data) {
      window.pinCheckerSendInjector(data);
      socket.webSocket.pinCheckSend(data);
    };
  }
  try {
    const part =
      document.querySelector('[data-functional-selector="game-pin"]') ||
      document.querySelector(
        '[data-functional-selector="bottom-bar-game-pin"]'
      );
    if (
      Number(part.innerText) != window.pinCheckerPin &&
      Number(part.innerText) != 0 &&
      !isNaN(Number(part.innerText))
    ) {
      window.pinCheckerPin = Number(part.innerText);
      console.log(
        "[PIN-CHECKER] - Discovered new PIN: " + window.pinCheckerPin
      );
    } else if (Number(part.innerText) == 0 || isNaN(Number(part.innerText))) {
      window.pinCheckerPin = null;
      console.log(
        "[PIN-CHECKER] - PIN is hidden or game is locked. Unable to test."
      );
    }
  } catch (err) {
    /* Unable to get pin, hidden */
  }
  if (data.data && data.data.type === "joined") {
    pinCheckerFalsePositiveReset();
    window.pinCheckerNameList.push(data.data.name);
    setTimeout(() => {
      // remove after 20 seconds (for performance)
      window.pinCheckerNameList.splice(0, 1);
    }, 20e3);
  } else if (data.data && data.data.id === 45) {
    pinCheckerFalsePositiveReset();
  }
}

window.kantibotAddHook({
  prop: "onMessage",
  condition: (target, value) =>
    typeof value === "function" &&
    typeof target.reset === "function" &&
    typeof target.onOpen === "function",
  callback: (target, value) => {
    console.log(target, value);
    target.onMessage = new Proxy(target.onMessage, {
      apply: function (target, thisArg, argumentsList) {
        messageInjector(argumentsList[0], argumentsList[1]);
        return target.apply(thisArg, argumentsList);
      }
    });
    return true;
  }
});

main();