KPin Checker

Check the pin of a kahoot game.

  1. // ==UserScript==
  2. // @name KPin Checker
  3. // @namespace http://tampermonkey.net/
  4. // @homepage https://theusaf.org
  5. // @version 2.0.0
  6. // @license MIT
  7. // @description Check the pin of a kahoot game.
  8. // @author theusaf
  9. // @match *://play.kahoot.it/*
  10. // @exclude *://play.kahoot.it/v2/assets/*
  11. // @copyright 2020-2023, Daniel Lau (https://github.com/theusaf/kahoot-antibot)
  12. // @grant none
  13. // @run-at document-start
  14. // ==/UserScript==
  15.  
  16. /**
  17. * PinCheckerMain - The main pin checking function
  18. */
  19. function main() {
  20. function listenForTeamMode() {
  21. document
  22. .querySelector("[data-functional-selector=team-mode-card]")
  23. .addEventListener("click", () => {
  24. console.log("[PIN-CHECKER] - Entered team mode card.");
  25. setTimeout(() => {
  26. document
  27. .querySelector("[data-functional-selector=leave-game-mode-details]")
  28. .addEventListener("click", () => {
  29. console.log("[PIN-CHECKER] - Listening again");
  30. setTimeout(() => listenForTeamMode(), 250);
  31. });
  32. document
  33. .querySelector("[data-functional-selector=start-team-mode-button]")
  34. .addEventListener("click", () => {
  35. console.log("[PIN-CHECKER] - Using team mode.");
  36. window.localStorage.pinCheckerMode = "team";
  37. });
  38. }, 250);
  39. });
  40. }
  41.  
  42. const loader = setInterval(() => {
  43. if (!document.querySelector("[data-functional-selector=team-mode-card]")) {
  44. return;
  45. }
  46. console.log("[PIN-CHECKER] - Ready!");
  47. clearInterval(loader);
  48. listenForTeamMode();
  49.  
  50. if (window.localStorage.pinCheckerAutoRelogin === "true") {
  51. const waiter = setInterval(() => {
  52. let button = document.querySelector(
  53. "[data-functional-selector=classic-mode-card]"
  54. );
  55. if (window.localStorage.pinCheckerMode === "team") {
  56. button = document.querySelector(
  57. "[data-functional-selector=team-mode-card]"
  58. );
  59. }
  60. if (button && !button.disabled) {
  61. const guestButton = document.querySelector(
  62. "[data-functional-selector=play-as-guest-button]"
  63. );
  64. if (guestButton) {
  65. guestButton.click();
  66. }
  67. button.click();
  68. if (window.localStorage.pinCheckerMode === "team") {
  69. setTimeout(() => {
  70. document
  71. .querySelector(
  72. "[data-functional-selector=start-team-mode-button]"
  73. )
  74. .click();
  75. }, 250);
  76. }
  77. window.localStorage.pinCheckerAutoRelogin = false;
  78. if (
  79. +window.localStorage.pinCheckerLastQuizIndex <=
  80. window.kantibotData.kahootInternals.services.game.core.playList
  81. .length
  82. ) {
  83. kantibotData.kahootInternals.services.game.navigation.currentQuizIndex =
  84. +window.localStorage.pinCheckerLastQuizIndex ?? 0;
  85. }
  86. clearInterval(waiter);
  87. delete window.localStorage.pinCheckerMode;
  88. delete window.localStorage.pinCheckerLastQuizIndex;
  89. // check for start button
  90. }
  91. }, 500);
  92. } else {
  93. delete window.localStorage.pinCheckerMode;
  94. }
  95. }, 500);
  96. let loadChecks = 0;
  97. const themeLoadChecker = setInterval(() => {
  98. const errorButton = document.querySelector(
  99. '[data-functional-selector="dialog-actions"]'
  100. );
  101. if (errorButton) {
  102. clearInterval(themeLoadChecker);
  103. errorButton.querySelector("button").click();
  104. } else if (++loadChecks > 10) {
  105. clearInterval(themeLoadChecker);
  106. }
  107. }, 500);
  108.  
  109. window.pinCheckerNameList = [];
  110. window.pinCheckerPin = null;
  111. window.pinCheckerSendIds = {};
  112. window.specialData = window.specialData || {};
  113. window.pinCheckerFalsePositive = false;
  114. window.pinCheckerFalsePositiveTimeout = null;
  115.  
  116. /**
  117. * ResetGame - Reloads the page
  118. */
  119. function resetGame(message) {
  120. if (window.pinCheckerFalsePositive) {
  121. return console.log(
  122. "[PIN-CHECKER] - Detected false-positive broken pin. Not restarting."
  123. );
  124. }
  125. console.error(message || "[PIN-CHECKER] - Pin Broken. Attempting restart.");
  126. window.localStorage.pinCheckerAutoRelogin = true;
  127. window.localStorage.pinCheckerLastQuizIndex =
  128. window.kantibotData.kahootInternals.services.game.navigation.currentQuizIndex;
  129. window.document.write(
  130. "<scr" +
  131. "ipt>" +
  132. `window.location = "https://play.kahoot.it/v2/${window.location.search}";` +
  133. "</scr" +
  134. "ipt>"
  135. );
  136. }
  137.  
  138. /**
  139. * concatTokens - From kahoot.js.org. Combines the tokens.
  140. *
  141. * @param {String} headerToken decoded token
  142. * @param {String} challengeToken decoded token 2
  143. * @returns {String} The final token
  144. */
  145. function concatTokens(headerToken, challengeToken) {
  146. // Combine the session token and the challenge token together to get the string needed to connect to the websocket endpoint
  147. let token = "";
  148. for (let i = 0; i < headerToken.length; i++) {
  149. const char = headerToken.charCodeAt(i),
  150. mod = challengeToken.charCodeAt(i % challengeToken.length),
  151. decodedChar = char ^ mod;
  152. token += String.fromCharCode(decodedChar);
  153. }
  154. return token;
  155. }
  156.  
  157. /**
  158. * CreateClient - Creates a Kahoot! client to join a game
  159. * This really only works because kahoot treats kahoot.it, play.kahoot.it, etc as the same thing.
  160. *
  161. * @param {Number} pin The gameid
  162. */
  163. function createClient(pin) {
  164. console.log("[PIN-CHECKER] - Creating client");
  165. pin += "";
  166. const sessionRequest = new XMLHttpRequest();
  167. sessionRequest.open("GET", "/reserve/session/" + pin);
  168. sessionRequest.send();
  169. sessionRequest.onload = function () {
  170. let sessionData;
  171. try {
  172. sessionData = JSON.parse(sessionRequest.responseText);
  173. } catch (e) {
  174. // probably not found
  175. return resetGame();
  176. }
  177. const headerToken = atob(
  178. sessionRequest.getResponseHeader("x-kahoot-session-token")
  179. );
  180. let { challenge } = sessionData;
  181. challenge = challenge.replace(/(\u0009|\u2003)/gm, "");
  182. challenge = challenge.replace(/this /gm, "this");
  183. challenge = challenge.replace(/ *\. */gm, ".");
  184. challenge = challenge.replace(/ *\( */gm, "(");
  185. challenge = challenge.replace(/ *\) */gm, ")");
  186. challenge = challenge.replace("console.", "");
  187. challenge = challenge.replace("this.angular.isObject(offset)", "true");
  188. challenge = challenge.replace("this.angular.isString(offset)", "true");
  189. challenge = challenge.replace("this.angular.isDate(offset)", "true");
  190. challenge = challenge.replace("this.angular.isArray(offset)", "true");
  191. const merger =
  192. "var _ = {" +
  193. " replace: function() {" +
  194. " var args = arguments;" +
  195. " var str = arguments[0];" +
  196. " return str.replace(args[1], args[2]);" +
  197. " }" +
  198. "}; " +
  199. "var log = function(){};" +
  200. "return ",
  201. solver = Function(merger + challenge),
  202. headerChallenge = solver(),
  203. finalToken = concatTokens(headerToken, headerChallenge),
  204. connection = new WebSocket(
  205. `wss://kahoot.it/cometd/${pin}/${finalToken}`
  206. ),
  207. timesync = {};
  208. let shoken = false,
  209. clientId = "",
  210. messageId = 2,
  211. closed = false,
  212. name = "";
  213. connection.addEventListener("error", () => {
  214. console.error(
  215. "[PIN-CHECKER] - Socket connection failed. Assuming network connection is lost and realoading page."
  216. );
  217. resetGame();
  218. });
  219. connection.addEventListener("open", () => {
  220. connection.send(
  221. JSON.stringify([
  222. {
  223. advice: {
  224. interval: 0,
  225. timeout: 60000
  226. },
  227. minimumVersion: "1.0",
  228. version: "1.0",
  229. supportedConnectionTypes: ["websocket", "long-polling"],
  230. channel: "/meta/handshake",
  231. ext: {
  232. ack: true,
  233. timesync: {
  234. l: 0,
  235. o: 0,
  236. tc: Date.now()
  237. }
  238. },
  239. id: 1
  240. }
  241. ])
  242. );
  243. });
  244. connection.addEventListener("message", (m) => {
  245. const { data } = m,
  246. [message] = JSON.parse(data);
  247. if (message.channel === "/meta/handshake" && !shoken) {
  248. if (message.ext && message.ext.timesync) {
  249. shoken = true;
  250. clientId = message.clientId;
  251. const { tc, ts, p } = message.ext.timesync,
  252. l = Math.round((Date.now() - tc - p) / 2),
  253. o = ts - tc - l;
  254. Object.assign(timesync, {
  255. l,
  256. o,
  257. get tc() {
  258. return Date.now();
  259. }
  260. });
  261. connection.send(
  262. JSON.stringify([
  263. {
  264. advice: { timeout: 0 },
  265. channel: "/meta/connect",
  266. id: 2,
  267. ext: {
  268. ack: 0,
  269. timesync
  270. },
  271. clientId
  272. }
  273. ])
  274. );
  275. // start joining
  276. setTimeout(() => {
  277. name = "KCP_" + (Date.now() + "").substr(2);
  278. connection.send(
  279. JSON.stringify([
  280. {
  281. clientId,
  282. channel: "/service/controller",
  283. id: ++messageId,
  284. ext: {},
  285. data: {
  286. gameid: pin,
  287. host: "play.kahoot.it",
  288. content: JSON.stringify({
  289. device: {
  290. userAgent: window.navigator.userAgent,
  291. screen: {
  292. width: window.screen.width,
  293. height: window.screen.height
  294. }
  295. }
  296. }),
  297. name,
  298. type: "login"
  299. }
  300. }
  301. ])
  302. );
  303. }, 1000);
  304. }
  305. } else if (message.channel === "/meta/connect" && shoken && !closed) {
  306. connection.send(
  307. JSON.stringify([
  308. {
  309. channel: "/meta/connect",
  310. id: ++messageId,
  311. ext: {
  312. ack: message.ext.ack,
  313. timesync
  314. },
  315. clientId
  316. }
  317. ])
  318. );
  319. } else if (message.channel === "/service/controller") {
  320. if (message.data && message.data.type === "loginResponse") {
  321. if (message.data.error === "NONEXISTING_SESSION") {
  322. // session doesn't exist
  323. connection.send(
  324. JSON.stringify([
  325. {
  326. channel: "/meta/disconnect",
  327. clientId,
  328. id: ++messageId,
  329. ext: {
  330. timesync
  331. }
  332. }
  333. ])
  334. );
  335. connection.close();
  336. resetGame();
  337. } else {
  338. // Check if the client is in the game after 10 seconds
  339. setTimeout(() => {
  340. if (!window.pinCheckerNameList.includes(name)) {
  341. // Uh oh! the client didn't join!
  342. resetGame();
  343. }
  344. }, 10e3);
  345. console.log(
  346. "[PIN-CHECKER] - Client joined game. Connection is good."
  347. );
  348. // good. leave the game.
  349. connection.send(
  350. JSON.stringify([
  351. {
  352. channel: "/meta/disconnect",
  353. clientId,
  354. id: ++messageId,
  355. ext: {
  356. timesync
  357. }
  358. }
  359. ])
  360. );
  361. closed = true;
  362. setTimeout(() => {
  363. connection.close();
  364. }, 500);
  365. }
  366. }
  367. } else if (message.channel === "/service/status") {
  368. if (message.data.status === "LOCKED") {
  369. // locked, cannot test
  370. console.log("[PIN-CHECKER] - Game is locked. Unable to test.");
  371. closed = true;
  372. connection.send(
  373. JSON.stringify([
  374. {
  375. channel: "/meta/disconnect",
  376. clientId,
  377. id: ++messageId,
  378. ext: {
  379. timesync
  380. }
  381. }
  382. ])
  383. );
  384. setTimeout(() => {
  385. connection.close();
  386. }, 500);
  387. }
  388. }
  389. });
  390. };
  391. }
  392.  
  393. window.pinCheckerInterval = setInterval(() => {
  394. if (window.pinCheckerPin) {
  395. createClient(window.pinCheckerPin);
  396. }
  397. }, 60 * 1000);
  398.  
  399. /**
  400. * pinCheckerSendInjector
  401. * - Checks the sent messages to ensure events are occuring
  402. * - This is a small fix for a bug in Kahoot.
  403. *
  404. * @param {String} data The sent message.
  405. */
  406. window.pinCheckerSendInjector = function pinCheckerSendInjector(data) {
  407. data = JSON.parse(data)[0];
  408. const now = Date.now();
  409. let content = {};
  410. try {
  411. content = JSON.parse(data.data.content);
  412. } catch (e) {
  413. /* likely no content */
  414. }
  415. if (data.data && typeof data.data.id !== "undefined") {
  416. for (const i in window.pinCheckerSendIds) {
  417. window.pinCheckerSendIds[i].add(data.data.id);
  418. }
  419. // content slides act differently, ignore them
  420. if (content.gameBlockType === "content") return;
  421.  
  422. /**
  423. * Checks for events and attempts to make sure that it succeeds (doesn't crash)
  424. * - deprecated, kept in just in case for the moment
  425. *
  426. * @param {Number} data.data.id The id of the action
  427. */
  428. switch (data.data.id) {
  429. case 9: {
  430. window.pinCheckerSendIds[now] = new Set();
  431. setTimeout(() => {
  432. if (!window.pinCheckerSendIds[now].has(1)) {
  433. // Restart, likely stuck
  434. resetGame(
  435. "[PIN-CHECKER] - Detected stuck on loading screen. Reloading the page."
  436. );
  437. } else {
  438. delete window.pinCheckerSendIds[now];
  439. }
  440. }, 60e3);
  441. break;
  442. }
  443. case 1: {
  444. window.pinCheckerSendIds[now] = new Set();
  445. setTimeout(() => {
  446. if (!window.pinCheckerSendIds[now].has(2)) {
  447. // Restart, likely stuck
  448. resetGame(
  449. "[PIN-CHECKER] - Detected stuck on get ready screen. Reloading the page."
  450. );
  451. } else {
  452. delete window.pinCheckerSendIds[now];
  453. }
  454. }, 60e3);
  455. break;
  456. }
  457. case 2: {
  458. window.pinCheckerSendIds[now] = new Set();
  459. // wait up to 5 minutes, assume something wrong
  460. setTimeout(() => {
  461. if (
  462. !window.pinCheckerSendIds[now].has(4) &&
  463. !window.pinCheckerSendIds[now].has(8)
  464. ) {
  465. // Restart, likely stuck
  466. resetGame(
  467. "[PIN-CHECKER] - Detected stuck on question answer. Reloading the page."
  468. );
  469. } else {
  470. delete window.pinCheckerSendIds[now];
  471. }
  472. }, 300e3);
  473. break;
  474. }
  475. }
  476. }
  477. };
  478.  
  479. /**
  480. * closeError
  481. * - Used when the game is closed and fails to reconnect properly
  482. */
  483. window.closeError = function () {
  484. resetGame("[PIN-CHECKER] - Detected broken disconnected game, reloading!");
  485. };
  486. }
  487.  
  488. /**
  489. * PinCheckerInjector - Checks messages and stores the names of players who joined within the last few seconds
  490. *
  491. * @param {String} message The websocket message
  492. */
  493. function messageInjector(socket, message) {
  494. function pinCheckerFalsePositiveReset() {
  495. window.pinCheckerFalsePositive = true;
  496. clearTimeout(window.pinCheckerFalsePositiveTimeout);
  497. window.pinCheckerFalsePositiveTimeout = setTimeout(function () {
  498. window.pinCheckerFalsePositive = false;
  499. }, 15e3);
  500. }
  501. const data = JSON.parse(message.data)[0];
  502. if (!socket.webSocket.pinCheckClose) {
  503. socket.webSocket.pinCheckClose = socket.webSocket.onclose;
  504. socket.webSocket.onclose = function () {
  505. socket.webSocket.pinCheckClose();
  506. setTimeout(() => {
  507. const stillNotConnected = document.querySelector(
  508. '[data-functional-selector="disconnected-page"]'
  509. );
  510. if (stillNotConnected) {
  511. window.closeError();
  512. }
  513. }, 30e3);
  514. };
  515. }
  516. if (!socket.webSocket.pinCheckSend) {
  517. socket.webSocket.pinCheckSend = socket.webSocket.send;
  518. socket.webSocket.send = function (data) {
  519. window.pinCheckerSendInjector(data);
  520. socket.webSocket.pinCheckSend(data);
  521. };
  522. }
  523. try {
  524. const part =
  525. document.querySelector('[data-functional-selector="game-pin"]') ||
  526. document.querySelector(
  527. '[data-functional-selector="bottom-bar-game-pin"]'
  528. );
  529. if (
  530. Number(part.innerText) != window.pinCheckerPin &&
  531. Number(part.innerText) != 0 &&
  532. !isNaN(Number(part.innerText))
  533. ) {
  534. window.pinCheckerPin = Number(part.innerText);
  535. console.log(
  536. "[PIN-CHECKER] - Discovered new PIN: " + window.pinCheckerPin
  537. );
  538. } else if (Number(part.innerText) == 0 || isNaN(Number(part.innerText))) {
  539. window.pinCheckerPin = null;
  540. console.log(
  541. "[PIN-CHECKER] - PIN is hidden or game is locked. Unable to test."
  542. );
  543. }
  544. } catch (err) {
  545. /* Unable to get pin, hidden */
  546. }
  547. if (data.data && data.data.type === "joined") {
  548. pinCheckerFalsePositiveReset();
  549. window.pinCheckerNameList.push(data.data.name);
  550. setTimeout(() => {
  551. // remove after 20 seconds (for performance)
  552. window.pinCheckerNameList.splice(0, 1);
  553. }, 20e3);
  554. } else if (data.data && data.data.id === 45) {
  555. pinCheckerFalsePositiveReset();
  556. }
  557. }
  558.  
  559. window.kantibotAddHook({
  560. prop: "onMessage",
  561. condition: (target, value) =>
  562. typeof value === "function" &&
  563. typeof target.reset === "function" &&
  564. typeof target.onOpen === "function",
  565. callback: (target, value) => {
  566. console.log(target, value);
  567. target.onMessage = new Proxy(target.onMessage, {
  568. apply: function (target, thisArg, argumentsList) {
  569. messageInjector(argumentsList[0], argumentsList[1]);
  570. return target.apply(thisArg, argumentsList);
  571. }
  572. });
  573. return true;
  574. }
  575. });
  576.  
  577. main();