require("cometd-nodejs-client").adapt();
const EventEmitter = require("events"),
cometdAPI = require("cometd"),
calculateReadTime = require("./util/calculateReadTime"),
got = require("got"),
modules = require("./modules/index"),
shuffle = require("./util/shuffle"),
HostSessionData = require("./classes/HostSessionData"),
HostStartedData = require("./classes/HostStartedData"),
AckExtension = require("cometd/AckExtension"),
TimeSyncExtension = require("cometd/TimeSyncExtension"),
LiveEventDisconnect = require("./classes/LiveEventDisconnect");
// The basic Kahoot host
class Client extends EventEmitter {
/**
* constructor - Creates the Client
*
* @param {Object} options An object containing settings, controlling things such as team mode, two factor auth
* @param {Boolean} options.autoPlay Whether to automatically start, move through questions, and replay games
* @param {String} [options.gameMode=classic] The game mode of the game. Can be "classic" or "team"
* @param {Boolean} options.twoFactorAuth Whether to enable two-factor auth in the game
* @param {Boolbea} options.namerator Whether to enable the friendly nickname generator
* @param {Number} options.twoFactorInterval The time in seconds between two-factor resets
* @param {Function} options.proxy A function to handle options for http requests and websockets.
* @example // This example demonstrates proxying
* new Client({
* proxy: (type, url) => {
* // type is "http" or "ws"
* // url is the request url or got request options
* // should return a url or got request options, though 'ws' must return a url
* return url;
* }
* });
*/
constructor(options) {
super();
/**
* The time the quesiton started
*
* @name Client#questionStartTime
* @type Number
*/
this.questionStartTime = null;
/**
* The game options
*
* @name Client#questionStartTime
* @see Client
*/
this.options = options || {};
/**
* The CometD client. {@link https://docs.cometd.org/current7/reference/}
*
* @name Client#cometd
* @type CometDClient
*/
this.cometd = null;
/**
* The controllers, mapped by id
*
* @name Client#controllers
* @type Object<Player>
*/
this.controllers = {};
/**
* The current question index
*
* @name Client#currentQuestionIndex
* @type Number
*/
this.currentQuestionIndex = 0;
/**
* The current quiz index, used in playlists
*
* @name Client#currentQuizIndex
* @type Number
*/
this.currentQuizIndex = 0;
/**
* The feedback received.
*
* @see FeedbackSent
* @name Client#feedback
* @type Object[]
*/
this.feedback = [];
/**
* The game pin of the game
*
* @name Client#gameid
* @type String
*/
this.gameid = null;
/**
* The time the QuestionReady started
*
* @name Client#getReadyTime
* @type Number
*/
this.getReadyTime = null;
/**
* The shuffled steps of the jumble question
*
* @name Client#jumbleSteps
* @type Number[]
*/
this.jumbleSteps = null;
/**
* A number from setTimeout used for autoPlay and question ending
*
* @name Client#mainEventTimer
* @type Number
*/
this.mainEventTimer = null;
/**
* A number from setInterval for use in two-factor auth resets
*
* @name Client#twoFactorInterval
* @type Number
*/
this.twoFactorInterval = null;
/**
* A shuffled list of numbers representing the current pattern for the two-factor steps
*
* @name Client#twoFactorSteps
* @type Number[]
*/
this.twoFactorSteps = shuffle([0,1,2,3]);
/**
* The current quiz being played
*
* @name Client#quiz
* @type Quiz
*/
this.quiz = null;
/**
* The list of quizzes or ids
*
* @name Client#quizPlaylist
* @type String[]|Quiz[]
*/
this.quizPlaylist = [];
/**
* The recovery data
*
* @name Client#recoveryData
* @type Object
* @see {@link https://kahoot.js.org/enum/LiveEventRecoveryData}
*/
this.recoveryData = {};
/**
* The time the quiz started
*
* @name Client#startTime
* @type Number
*/
this.startTime = null;
/**
* The current state of the game
* - "lobby"
* - "start"
* - "getready"
* - "teamtalk"
* - "question"
* - "timeover"
* - "questionend"
* - "podium"
* - "quizend"
*
* @name Client#state
* @type String
*/
this.state = "lobby";
/**
* The current status of the game. Either "ACTIVE" or "LOCKED"
*
* @name Client#status
* @type String
*/
this.status = "ACTIVE";
}
/**
* get quizQuestionAnswers - The number of choices per question
*
* @returns {Number[]}
*/
get quizQuestionAnswers() {
return this.quiz.questions.map((question) => {
return question.choices ? question.choices.length : null;
});
}
/**
* async initialize - Downloads the quiz information for the game
*
* @param {String|Quiz|Array} quizId The quiz id/url to use, or a Quiz object.
* @returns {Promise<Client>} The client.
*/
async initialize(quizId) {
let restart;
if(quizId === true) {
restart = true;
quizId = this.quizPlaylist[this.currentQuizIndex];
}
if (typeof quizId === "object" && typeof quizId.push === "function") {
// is array
this.quizPlaylist = quizId;
quizId = quizId[0];
}
if(typeof quizId === "object" && typeof quizId.questions === "object") {
// is quiz object
this.quiz = quizId;
this.quizPlaylist[this.currentQuizIndex] = quizId;
return this;
}
let uuid = quizId.match(/[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}/i);
if(uuid === null) {
throw {
description: "Invalid UUID"
};
}
uuid = uuid[0];
try {
let options = `https://play.kahoot.it/rest/kahoots/${uuid}`;
if(typeof this.options.proxy === "function") {
options = this.options.proxy("http", options);
}
const response = await got(options),
data = JSON.parse(response.body);
if(data.error) {
throw data.error;
}
this.quiz = data;
this.quizPlaylist[this.currentQuizIndex] = data;
} catch(error) {
throw {
error,
description: "Failed to fetch quiz."
};
}
if(restart) {
this.recoveryData = {
data: {},
defaultQuizData: {
quizType: "quiz",
quizQuestionAnswers: this.quizQuestionAnswers,
didControllerLeave: false,
wasControllerKicked: false
},
state: 0
};
if(Object.keys(this.controllers).length > 0 && this.options.autoPlay) {
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, 15e3);
}
}
return this;
}
/**
* async start - Starts the game
*
* @returns {Promise<String>} The game pin.
*/
async start() {
try {
const sessionData = new HostSessionData(this.options);
let options = {
protocol: "https:",
host: "play.kahoot.it",
path: `/reserve/session/?${Date.now()}`,
json: sessionData
};
if(typeof this.options.proxy === "function") {
options = this.options.proxy("http", options);
}
const response = await got.post(options);
if(typeof response.headers["x-kahoot-session-token"] === "undefined") {
throw "Missing header token (ratelimited/blocked)";
}
this._token = response.headers["x-kahoot-session-token"];
this.gameid = parseInt(response.body);
await this._createHandshake();
const cometd = this.cometd;
cometd.addListener(`/controller/${this.gameid}`, (data) => {
this.message(data);
});
cometd.addListener("/service/status", (data) => {
this.message(data);
});
cometd.addListener("/service/player", (data) => {
this.message(data);
});
cometd.onListenerException = (exception) => {
this.emit("error", exception.description ? exception : {
error: exception,
description: "Unknown error"
});
};
await this.send("/service/player", new HostStartedData(this), true);
this.recoveryData = {
data: {},
defaultQuizData: {
quizType: "quiz",
quizQuestionAnswers: this.quizQuestionAnswers,
didControllerLeave: false,
wasControllerKicked: false
},
state: 0
};
if(this.twoFactorInterval === null && this.options.twoFactorAuth) {
this.twoFactorInterval = setInterval(() => {
this.resetTwoFactorAuth();
}, this.options.twoFactorInterval || 7e3);
}
return this.gameid;
} catch(error) {
throw {
error,
description: "Failed to create session"
};
}
}
/**
* message - Handles messages from the server
*
* @param {Object} message The CometD message from the server
* @param {Object} message.data The data sent from the server
* @param {String} message.channel The channel the message is in
*/
message(message) {
const {channel,data} = message;
if(channel === `/controller/${this.gameid}`) {
if(data) {
if(typeof data.id === "number") {
// has an id
switch(data.id) {
case 16: {
modules.DataRequest.call(this, data);
break;
}
case 11: {
modules.FeedbackSent.call(this, data);
break;
}
case 18: {
modules.TeamSent.call(this, data);
break;
}
case 50: {
modules.TwoFactorAnswered.call(this, data);
break;
}
case 45: {
modules.QuestionAnswered.call(this, data);
break;
}
}
} else {
// does not have an id
if(typeof data.type === "string") {
if(data.type === "joined") {
modules.PlayerJoined.call(this, data);
} else if(data.type === "left") {
modules.PlayerLeft.call(this, data);
}
}
}
}
} else if(channel === "/service/status") {
this.status = data.status;
this.emit("Status",data);
}
}
/**
* send - Wrapper for publishing data to the cometd server
*
* @param {String} channel The channel to publish the data to
* @param {Object} message The data to send
* @param {Boolean} shouldReject Whether the function should reject if the message fails to send.
* @returns {Promise<Boolean>} Whether the message was successful
*/
send(channel, message, shouldReject) {
if(this.cometd.isDisconnected()) {
return new Promise((resolve) => {
setTimeout(() => {
if(this.cometd.isDisconnected()) {
this.emit("Disconnect");
clearTimeout(this.mainEventTimer);
clearInterval(this.twoFactorInterval);
this.cometd.disconnect();
} else {
this.send.apply(this, arguments);
}
resolve();
}, 10e3);
});
return;
}
if(typeof channel === "object" && typeof channel.push === "function") {
// An array
const promises = [];
this.cometd.batch(() => {
for(let i = 0; i < channel.length; i++) {
promises.push(this.send(channel[i][0], channel[i][1], message));
}
});
return Promise.all(promises);
}
return new Promise((resolve, reject) => {
this.cometd.publish(channel, message, (ack) => {
if(shouldReject && !ack.successful) {
reject(ack.successful);
} else {
resolve(ack.successful);
}
});
});
}
/**
* _createHandshake - Creates the connection to the server
*
* @returns {Promise} - Resolves if successful, rejects if an error occurs
*/
_createHandshake() {
const cometd = new cometdAPI.CometD;
let options = `wss://play.kahoot.it/cometd/${this.gameid}/${this._token}`;
if(typeof this.options.proxy === "function") {
options = this.options.proxy("ws", options);
}
this.cometd = cometd;
cometd.registerExtension("ack",new AckExtension);
cometd.registerExtension("timesync",new TimeSyncExtension);
cometd.configure({
url: options
});
return new Promise((resolve, reject) => {
cometd.handshake((h) => {
if (h.successful) {
resolve();
} else {
reject(h);
}
});
});
}
/**
* timeOver - Ends the question
*
* @returns {Promise} @see {Client.sendQuestionResults}
*/
async timeOver() {
clearTimeout(this.mainEventTimer);
this.state = "timeover";
this.recoveryData.state = 4;
this.recoveryData.data = {
timeUp: {
gameid: this.gameid,
host: "play.kahoot.it",
id: 4,
type: "message",
content: JSON.stringify({
questionNumber: this.currentQuestionIndex
}),
cid: null
},
revealAnswer: {
hasAnswer: false
}
};
await modules.TimeOver.call(this);
/**
* Emitted when the time is up
*
* @event TimeOver
*/
this.emit("TimeOver");
return this.next();
}
/**
* sendQuestionResults - Sends the question results to the players
*
* @returns {Promise<Boolean[]>} Whether the message was successfully sent
*/
sendQuestionResults() {
clearTimeout(this.mainEventTimer);
this.state = "questionend";
this.recoveryData.state = 5;
this.recoveryData.data = {
timeUp: {
gameid: this.gameid,
host: "play.kahoot.it",
id: 4,
type: "message",
content: JSON.stringify({
questionNumber: this.currentQuestionIndex
}),
cid: null
},
revealAnswer: {
hasAnswer: false
}
};
if(this.options.autoPlay) {
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, 5e3);
}
/**
* Emitted when the question has ended
*
* @event QuestionResults
* @type Object<Player>
*/
this.emit("QuestionResults", this.controllers);
return modules.SendQuestionResults.call(this);
}
/**
* lock - Locks the game
*
* @returns {Promise} Resolves if successful, rejects if not
*/
lock() {
return modules.Lock.call(this);
}
/**
* unlock - Unlocks the game
*
* @returns {Promise} Resolves if successful, rejects if not
*/
unlock() {
return modules.Unlock.call(this);
}
/**
* async closeGame - Closes the game, disconnects from Kahoot
*/
async closeGame() {
clearTimeout(this.mainEventTimer);
clearInterval(this.twoFactorInterval);
await this.send("/service/player", new LiveEventDisconnect(this));
this.cometd.disconnect();
/**
* Emitted when the game is disconnected
*
* @event Disconnect
*/
this.emit("Disconnect");
}
/**
* startGame - Starts the game
*
* @returns {Promise<Boolean>} Whether successful or not
*/
startGame() {
this.state = "start";
this.startTime = Date.now();
this.recoveryData.data = {
quizType: "quiz",
quizQuestionAnswers: this.quizQuestionAnswers
};
this.recoveryData.state = 1;
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, 5e3);
/**
* Emitted when the quiz starts
*
* @event GameStart
* @type Quiz
*/
this.emit("GameStart", this.quiz);
return modules.Start.call(this);
}
/**
* async startQuestion - Starts the question
*
* @returns {Promise} Resolves when question is started.
*/
async startQuestion() {
clearTimeout(this.mainEventTimer);
await modules.StartQuestion.call(this);
this.questionStartTime = Date.now();
this.state = "question";
this.recoveryData.state = 3;
this.recoveryData.data = {
questionIndex: this.currentQuestionIndex,
gameBlockType: this.quiz.questions[this.currentQuestionIndex].type,
gameBlockLayout: this.quiz.questions[this.currentQuestionIndex].layout,
quizQuestionAnswers: this.quizQuestionAnswers,
timeAvailable: this.quiz.questions[this.currentQuestionIndex].time,
timeLeft: this.quiz.questions[this.currentQuestionIndex].time,
numberOfAnswersAllowed: 1
};
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, this.recoveryData.data.timeAvailable);
/**
* Emitted when the question starts
*
* @event QuestionStart
* @type Question
*/
this.emit("QuestionStart", this.quiz.questions[this.currentQuestionIndex]);
}
/**
* readyQuestion - Starts the question
*
* @returns {Promise<Boolean>} Whether successful or not
*/
readyQuestion() {
clearTimeout(this.mainEventTimer);
this.state = "getready";
this.recoveryData.state = 2;
this.recoveryData.data = {
getReady: {
questionIndex: this.currentQuestionIndex,
gameBlockType: this.quiz.questions[this.currentQuestionIndex].type,
gameBlockLayout: this.quiz.questions[this.currentQuestionIndex].layout,
quizQuestionAnswers: this.quizQuestionAnswers,
timeLeft: calculateReadTime(this.quiz.questions[this.currentQuestionIndex].question || this.quiz.questions[this.currentQuestionIndex].title)
}
};
this.getReadyTime = Date.now();
if(this.quiz.questions[this.currentQuestionIndex].type !== "content"){
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, this.recoveryData.data.getReady.timeLeft * 1e3);
} else {
if(this.options.autoPlay) {
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, 20e3);
}
}
/**
* Emitted when the question is starting up
*
* @event QuestionReady
* @type Question
*/
this.emit("QuestionReady", this.quiz.questions[this.currentQuestionIndex]);
return modules.ReadyQuestion.call(this);
}
/**
* startTeamTalk - Starts team talk
*
* @returns {Promise<Boolean>} Whether successful or not
*/
startTeamTalk() {
clearTimeout(this.mainEventTimer);
this.state = "teamtalk";
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, 5e3);
this.emit("TeamTalk");
return modules.StartTeamTalk.call(this);
}
/**
* async sendRankings - Sends the medals to players (podium)
* Used before endGame
*
* @returns {Promise<Boolean>} Resolves if successful, rejects if not
*/
async sendRankings() {
clearTimeout(this.mainEventTimer);
this.state = "podium";
await modules.SendRankings.call(this);
/**
* Emitted at the end of the game, before GameEnd
*
* @event Rankings
* @type Object<Player>
*/
this.emit("Rankings", this.controllers);
return this.next();
}
/**
* endGame - Ends the quiz. Sends the final data to all players
*
* @returns {Promise<Boolean>} Resolves if successful, rejects if not
*/
endGame() {
clearTimeout(this.mainEventTimer);
this.state = "quizend";
this.recoveryData.state = 6;
this.recoveryData.data = {};
if(this.options.autoPlay) {
this.mainEventTimer = setTimeout(() => {
this.next().catch((error) => {
this.emit("error", error);
});
}, 15e3);
}
/**
* Emitted at the end of the quiz
*
* @event GameEnd
* @type Object<Player>
*/
this.emit("GameEnd", this.controllers);
return modules.EndGame.call(this);
}
/**
* requestFeedback - Requests feedback from the user
*
* @returns {Promise<Boolean>} Whether the request was successful or not
*/
requestFeedback(){
clearTimeout(this.mainEventTimer);
this.recoveryData.state = 7;
this.recoveryData.data = {};
/**
* Emitted when feedback is requested
*
* @event FeedbackRequested
*/
this.emit("FeedbackRequested");
return modules.RequestFeedback.call(this);
}
/**
* resetTwoFactorAuth - Resets the two-factor auth and notifies clients
*
* @returns {Promise<Boolean>} Successful or not
*/
resetTwoFactorAuth() {
this.twoFactorSteps = shuffle([0,1,2,3]);
/**
* Emitted when the two factor resets
*
* @event TwoFactorAuthReset
* @type Number[]
*/
this.emit("TwoFactorAuthReset", this.twoFactorSteps);
return modules.ResetTwoFactorAuth.call(this);
}
/**
* kickPlayer - Kicks a player from a game
*
* @param {String|Player} player The cid or player to kick
* @returns {Promise<Boolean>} Resolves if the kick is successful or not
*/
kickPlayer(player) {
if(typeof player === "object") {
player = player.cid;
}
return modules.KickPlayer.call(this, player);
}
/**
* resetGame - Resets the game, removes all players
*
* @returns {Promise<Boolean>} Resolves whether successful or not
*/
async resetGame() {
clearTimeout(this.mainEventTimer);
this.state = "lobby";
this.currentQuestionIndex = 0;
this.currentQuizIndex++;
if(this.currentQuizIndex >= this.quizPlaylist.length) {
this.currentQuizIndex = 0;
}
try {
await this.initialize(true);
} catch(error) {
this.emit("error", {
error,
description: "An error ocurred while reinintializing the game"
});
return;
}
this.emit("GameReset", this.quiz);
return modules.ResetGame.call(this);
}
/**
* async replayGame - Plays the game again
*
* @returns {Promise<Boolean>} Whether the replay message was successful
*/
async replayGame() {
clearTimeout(this.mainEventTimer);
this.state = "lobby";
this.currentQuestionIndex = 0;
this.currentQuizIndex++;
if(this.currentQuizIndex >= this.quizPlaylist.length) {
this.currentQuizIndex = 0;
}
try {
await this.initialize(true);
} catch(error) {
this.emit("error", {
error,
description: "An error ocurred while reinintializing the game"
});
return;
}
/**
* Emitted when the game resets
*
* @event GameReset
* @type Quiz
*/
this.emit("GameReset", this.quiz);
return await modules.ReplayGame.call(this);
}
/**
* checkAllAnswered - Checks if all players have answered. If yes, ends the question.
*/
checkAllAnswered() {
let isDone = true;
for(const i in this.controllers) {
if(this.controllers[i].answer === null && !this.controllers[i].hasLeft && this.controllers[i].active) {
isDone = false;
break;
}
}
if(isDone) {
this.timeOver();
}
}
/**
* getPlayer - Gets the player by cid
*
* @param {String} cid The cid of the player to get
* @returns {Player} A player
*/
getPlayer(cid) {
return this.controllers[cid] || {};
}
/**
* next - Manages transitions between certain events
*
* @returns {Promise<Boolean>} Successful or not
*/
next() {
clearTimeout(this.mainEventTimer);
switch(this.state) {
case "lobby": {
return this.startGame();
}
case "start": {
return this.readyQuestion();
}
case "getready": {
if(this.quiz.questions[this.currentQuestionIndex].type === "content") {
this.currentQuestionIndex++;
return this.readyQuestion();
} else {
if(this.options.gameMode === "team") {
return this.startTeamTalk();
} else {
return this.startQuestion();
}
}
}
case "teamtalk": {
return this.startQuestion();
}
case "question": {
return this.timeOver();
}
case "timeover": {
return this.sendQuestionResults();
}
case "questionend": {
if(this.currentQuestionIndex + 1 >= this.quiz.questions.length) {
// end quiz
return this.sendRankings();
} else {
// next question
this.currentQuestionIndex++;
return this.readyQuestion();
}
}
case "podium": {
return this.endGame();
}
case "quizend": {
if(this.options.rejoinOnReset) {
return this.resetGame();
} else {
return this.replayGame();
}
}
}
}
}
// For hosting with more control over events
class CustomClient extends Client {
/**
* constructor - Create the custom client
*/
constructor(options, messageHandler) {
super(options);
const oldMessage = this.message;
this.message = (message) => {
if(typeof messageHandler === "function") {
if(messageHandler(message) === true) {
return;
}
}
oldMessage(message);
};
}
}
module.exports = {Client,CustomClient};