const fs = require("fs"), readline = require("readline"), arrayPad = require('./strpad.js').arrayPad, Cache = require('./cache.js'); Object.assign(global, require("./config.js")); const MySQL = USE_MYSQL ? (function() { return require("mysql2").createConnection({host: MySQL_HOST, user: MySQL_USER, database: MySQL_DB, password: MySQL_PASS}); }) : null; const HOSTNAME = require('os').hostname(); // For Mysql bot identification function Question(id, obj) { this.id = id; this.question = obj.question; this.response = obj.response; this.normalizedResponse = ""; if (Array.isArray(obj.response)) this.normalizedResponse = this.response.map(i => Question.normalize(i)); else this.normalizedResponse = Question.normalize(this.response); } Question.normalize = function(str) { return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "").toLowerCase().trim(); } const QuestionType = { bool: {}, number: {}, string: {} }; Question.prototype.booleanValue = function(str) { str = str || (Array.isArray(this.normalizedResponse) ? this.normalizedResponse[0] : this.normalizedResponse); var index = ["non", "oui", "faux", "vrai"].indexOf(str); if (index >= 0) return index % 2 === 1; return undefined; } Question.prototype.isBoolean = function(str) { return this.booleanValue(str) !== undefined; } String.prototype.isWord = function() { return (/\W/).exec(this) === null; } Question.prototype.getQuestionType = function() { if (this.isBoolean()) return QuestionType.bool; if ((/^[0-9]+$/).exec(this.response)) return QuestionType.number; return QuestionType.string; } Question.toHint = function(response, normalizedResponse, boundaries, responseIndex, questionType, hintLevel) { if (questionType === QuestionType.string) { if (hintLevel == 0) return normalizedResponse.replace(/[\w]/g, '*'); else if (hintLevel == 1) return normalizedResponse.replace(/[\w]/g, (a, b) => b ? '*' : response.charAt(b)); else if (normalizedResponse.isWord() && !responseIndex) { var displayed = []; const revealPercent = 0.1; displayed[normalizedResponse.length -2] = 1; displayed.fill(1, 0, normalizedResponse.length-1).fill(0, Math.ceil(normalizedResponse.length * revealPercent), normalizedResponse.length); displayed.sort(()=>Math.random() > 0.5?-1:1) return normalizedResponse.replace(/./g, (a, b) => b && !displayed[b -1] ? '*':response.charAt(b)); } else return normalizedResponse.replace(/[\w]+/g, (a, wordIndex) => a.replace(/./g, (a, b) => b ? '*':response.charAt(wordIndex))); } else if (questionType === QuestionType.number) { const responseInt = Number(normalizedResponse), randomMin = ([ 30, 10, 3 ])[hintLevel], randomSpread = 5 * (5 -hintLevel); boundaries[0] = Math.max( Math.floor(responseInt -(Math.random() *randomSpread) -randomMin), boundaries[0]); boundaries[1] = Math.min( Math.ceil(responseInt +(Math.random() *randomSpread) +randomMin), boundaries[1]); return "Un nombre entre " +boundaries[0] +" et " +boundaries[1]; } else console.error("Unknown response type !"); }; Question.prototype.getHint = function(hintLevel) { var type = this.getQuestionType(); if (type === QuestionType.bool) return "Vrai / Faux ?"; else if (type === QuestionType.number && !this.boundaries) this.boundaries = (Number(this.response) >= 0 ? [ 0, Infinity] : [ -Infinity, 0 ]); if (!Array.isArray(this.response)) return Question.toHint(this.response, this.normalizedResponse, this.boundaries, 0, type, hintLevel); var hints = []; for (var i =0, len = this.response.length; i < len; ++i) hints.push(Question.toHint(this.response[i], this.normalizedResponse[i], this.boundaries, i, type, hintLevel)); return hints.length > 1 ? ("Plusieures reponses acceptees: " +hints.join(" ou ")) : hints[0]; return hints.join(" ou "); } Question.prototype.check = function(response) { response = Question.normalize(response); var boolValue = this.booleanValue(); if (boolValue !== undefined) return boolValue === this.booleanValue(response); if (Array.isArray(this.normalizedResponse)) return this.normalizedResponse.indexOf(response) >= 0; return response === this.normalizedResponse; } Question.prototype.end = function() { if (this.boundaries) delete this.boundaries; } function initQuestionList(filename) { return new Promise((ok, ko) => { console.log("Reloading question db"); var stream = fs.createReadStream(filename), reader = readline.createInterface({input: stream}), questions = [], lineNo = 0, borken = false; reader.on("line", line => { if (borken) return; try { const firstChar = line.charAt(0); if ([';', '#'].indexOf(firstChar) >= 0) { ++lineNo; return; } var question = new Question(++lineNo, JSON.parse(line)); if (question.question && question.response) questions.push(question); } catch (e) { console.error("Failed to load Database: ", e, "on line", lineNo); borken = true; reader.close(); stream.destroy(); ko("Failed to load database: syntax error on question #" +lineNo); } }); reader.on("close", () => { if (!borken) ok(questions); }); }); } function QuizzBot(config) { this.config = config; } QuizzBot.prototype.init = function(bot, chanName) { const previousData = Cache.GetData(); this.room = chanName; this.bot = bot; this.init = false; this.users = {}; this.activeUsers = {}; if (previousData) { for (var i in previousData.scores) { this.users[i.toLowerCase()] = bot.createUser(i); this.users[i.toLowerCase()].score = previousData.scores[i]; } } this.mySQLExportWrapper(); } QuizzBot.prototype.onSelfJoin = function() { this.init = true; this.reloadDb(); } QuizzBot.prototype.onJoin = function(nick) { this.users[nick.toLowerCase()] = this.users[nick.toLowerCase()] || this.bot.createUser(nick); this.activeUsers[nick.toLowerCase()] = true; } QuizzBot.prototype.onNameList = function(nicks) { this.activeUsers = {}; for (var i in nicks) { var u = this.users[i.toLowerCase()] = (this.users[i.toLowerCase()] || this.bot.createUser(i)); u.setModeChar(nicks[i]); this.activeUsers[i.toLowerCase()] = true; } } QuizzBot.prototype.onNickPart = function(nick) { delete this.activeUsers[nick.toLowerCase()]; } QuizzBot.prototype.onRename = function(oldNick, newNick) { this.users[newNick.toLowerCase()] = this.users[newNick.toLowerCase()] || this.bot.createUser(newNick); this.users[newNick.toLowerCase()].isAdmin = this.users[oldNick.toLowerCase()] && this.users[oldNick.toLowerCase()].isAdmin; this.activeUsers[newNick.toLowerCase()] = true; delete this.activeUsers[oldNick.toLowerCase()]; } QuizzBot.prototype.onMessage = function(user, text) { this.users[user.toLowerCase()] && this.onMessageInternal(user, this.users[user.toLowerCase()], text.trimEnd().replace(/\s+/, ' ')); } QuizzBot.prototype.onRemMode = function(user, mode) { this.users[user.toLowerCase()] && this.users[user.toLowerCase()].unsetMode(mode); } QuizzBot.prototype.onAddMode = function(user, mode) { if (user === this.name) { usersToVoice = []; for (var i in this.users) { if (this.users[i].score && this.activeUsers[i]) usersToVoice.push(i); } this.bot.voice(this.room, usersToVoice); } else { this.users[user.toLowerCase()] && this.users[user.toLowerCase()].setMode(mode); } } QuizzBot.prototype.stop = function() { this.timer && clearTimeout(this.timer); this.timer = null; this.currentQuestion = null; this.currentHint = 0; } QuizzBot.prototype.onTick = function() { if (!this.currentQuestion) return; if (this.currentHint < 3) this.sendNextHint(false); else { var response = Array.isArray(this.currentQuestion.response) ? this.currentQuestion.response.map(i => "\""+i+"\"").join(" ou ") : this.currentQuestion.response; this.bot.sendMsg(this.room, "Perdu, la réponse était: " +response); this.nextQuestionWrapper(); } } QuizzBot.prototype.resetTimer = function() { this.timer && clearTimeout(this.timer); this.timer = setInterval(this.onTick.bind(this), this.config.AUTO_HINT_DELAY); } QuizzBot.prototype.sendNextHint = function(resetTimer) { this.bot.sendMsg(this.room, this.currentQuestion.getHint(this.currentHint)); ++this.currentHint; this.lastHint = Date.now(); resetTimer !== false && this.resetTimer(); } QuizzBot.prototype.nextQuestionWrapper = function() { this.currentQuestion.end(); this.currentQuestion = null; setTimeout(this.nextQuestion.bind(this), this.config.NEXT_QUESTION_DELAY); } QuizzBot.prototype.nextQuestion = function() { this.currentQuestion = this.questions[Math.floor(Math.random() * this.questions.length)]; console.log(this.currentQuestion); this.currentHint = 0; this.questionDate = Date.now(); this.bot.sendMsg(this.room, "#" +this.currentQuestion.id +" " +this.currentQuestion.question); this.sendNextHint(); } QuizzBot.prototype.start = function() { if (this.reloading) { this.bot.sendMsg(this.room, "Error: database still reloading"); return; } if (!this.currentQuestion) { this.nextQuestion(); } } QuizzBot.prototype.reloadDb = function() { var _this = this; this.stop(); this.reloading = true; initQuestionList(this.config.QUESTIONS_PATH).then(questions => { _this.reloading = false; _this.questions = questions; _this.bot.sendMsg(this.room, questions.length +" questions loaded from database"); _this.start(); }).catch(err => { console.error(err); _this.bot.sendMsg(this.room, err); }); } QuizzBot.prototype.delScore = function(user) { var data = this.users[user.toLowerCase()]; if (!data) { this.bot.sendMsg(this.room, "User not found..."); return; } if (!data.score) { this.bot.sendMsg(this.room, "Score for user already null"); return; } data.score = 0; this.bot.sendMsg(this.room, "Removed score"); Cache.SetScores(this.users); } QuizzBot.prototype.sumScores = function(onlyPresent) { var score = 0; for (var i in this.users) score += (this.users[i].score && (this.activeUsers[i] || !onlyPresent)) ? this.users[i].score : 0; return score; } QuizzBot.prototype.sendScore = function(onlyPresent) { var scores = []; for (var i in this.users) this.users[i].score && (this.activeUsers[i] || !onlyPresent) && scores.push({name: this.users[i].name, score: this.users[i].score}); if (scores.length == 0) { this.bot.sendMsg(this.room, "Pas de points pour le moment"); return; } scores = scores.sort((a, b) => b.score - a.score).slice(0, 10); var index = 0; var scoreLines = arrayPad(scores.map(i => [ ((++index) +"."), i.name, (i.score +" points") ])); if (scoreLines[0].length < 30) { // merge score lines 2 by 2 var tmp = []; for (var i =0, len = scoreLines.length; i < len; i += 2) tmp.push((scoreLines[i] || "") +" - " +(scoreLines[i +1] || "")); scoreLines = tmp; } scoreLines.forEach(i => this.bot.sendMsg(this.room, i)); } QuizzBot.prototype.computeScore = function(username) { var rep = Array.isArray(this.currentQuestion.response) ? this.currentQuestion.response.map(i => '"'+i+'"').join(" ou ") : this.currentQuestion.response, responseMsg = "Réponse `" +rep +"` trouvée en " +Math.floor((Date.now() -this.questionDate) / 1000) +" secondes "; if (this.currentHint <= 1) responseMsg += "sans indice"; else if (this.currentHint === 2) responseMsg += "avec 1 seul indice"; else responseMsg += "avec " +this.currentHint +" indices"; var score = 4 - this.currentHint; this.bot.sendMsg(this.room, responseMsg); return score; } QuizzBot.prototype.findQuestionById = function(qId) { for (var i =0, len = this.questions.length; i < len; ++i) { if (this.questions[i].id === qId) return this.questions[i]; if (this.questions[i].id > qId) break; } } QuizzBot.prototype.resetScores = function() { if (this.sumScores(false) > 0) { this.bot.sendMsg(this.room, "Fin de la manche ! Voici les scores finaux:"); this.sendScore(false); } for (var i in this.users) this.users[i].score = 0; Cache.SetScores({}); } QuizzBot.prototype.onMessageInternal = function(username, user, msg) { const lmsg = msg.toLowerCase(); if (lmsg.startsWith("!reload")) { if (user.admin) this.reloadDb(); else this.bot.sendMsg(this.room, "Must be channel operator"); } else if (lmsg.startsWith("!aide")) { this.bot.sendMsg(this.room, "Usage: !aide | !indice | !reload | !next | !rename | !score [del pseudo] || !top"); } else if (lmsg === "!indice" || lmsg === "!conseil") { if (this.currentQuestion) { if (this.currentHint < 3) { if (Date.now() -this.lastHint > this.config.MIN_HINT_DELAY) this.sendNextHint(); } else this.bot.sendMsg(this.room, "Pas plus d'indice..."); } } else if (lmsg === "!next") { if (user.admin) { if (!this.currentQuestion) return; var response = Array.isArray(this.currentQuestion.response) ? this.currentQuestion.response.map(i => "\""+i+"\"").join(" ou ") : this.currentQuestion.response; this.bot.sendMsg(this.room, "La réponse était: " +response); this.nextQuestionWrapper(); } else { this.bot.sendMsg(this.room, "Must be channel operator"); } } else if (lmsg.startsWith("!report list")) { if (user.admin) { var questions = Cache.getReportedQuestions(); for (var i in questions) for (var j in questions[i]) questions[i][j] = (new Date(questions[i][j])).toLocaleString(); this.bot.sendNotice(username, JSON.stringify(questions)); } else this.bot.sendMsg(this.room, "Must be channel operator"); } else if (lmsg == ("!report clear")) { if (user.admin) { Cache.clearReports(); this.bot.sendMsg(this.room, "Toutes les questions sont marquées comme restaurées"); } else this.bot.sendMsg(this.room, "Must be channel operator"); } else if (lmsg.startsWith("!report del ")) { var questionId = msg.substr("!report del ".length).trim(); if (questionId.startsWith('#')) questionId = questionId.substr(1); questionId = Number(questionId); if (isNaN(questionId)) { this.bot.sendMsg(this.room, "Erreur: Usage: !report del #1234"); return; } if (user.admin) { Cache.unreportQuestion(questionId); this.bot.sendMsg(this.room, "Question #" +questionId +" marquée comme restaurée"); } else if (Cache.isReportedBy(questionId, username)) { Cache.unreportQuestion(questionId, username); this.bot.sendMsg(this.room, "Question #" +questionId +" n'est plus marquée comme défectueuse par " +username); } else { this.bot.sendMsg(this.room, "Must be channel operator"); } } else if (lmsg.startsWith("!report ")) { var questionId = msg.substr("!report ".length).trim(); if (questionId.startsWith('#')) questionId = questionId.substr(1); questionId = Number(questionId); if (isNaN(questionId)) { this.bot.sendMsg(this.room, "Erreur: Usage: !report #1234"); return; } if (!this.findQuestionById(questionId)) this.bot.sendMsg(this.room, "Erreur: question non trouvée"); else { Cache.reportQuestion(questionId, username); this.bot.sendMsg(this.room, "Question #" +questionId +" marquée comme défectueuse par " +username); } } else if (lmsg.startsWith("!score del ")) { if (user.admin) this.delScore(msg.substr("!score del ".length).trim()); else this.bot.sendMsg(this.room, "Must be channel operator"); } else if (lmsg.startsWith("!rename ")) { if (!user.admin) { this.bot.sendMsg(this.room, "Must be channel operator"); return; } var args = (msg.split(/\s+/)).splice(1); if (args.length < 2) { this.bot.sendNotice(username, "Usage: !rename nouveau_pseudo ancien_pseudo [ancien_pseudo...]"); return; } var sum = 0, users = [], target = args[0]; args = args.map(username => username.toLowerCase()); var userToMod = null; for (var i in this.users) { var userIndex = args.indexOf(i.toLowerCase()); if (userIndex > 0) { sum += this.users[i].score; this.users[i].score = 0; if (!this.activeUsers[i]) delete this.users[i]; } else if (userIndex == 0) userToMod = this.users[i]; } if (!userToMod) { userToMod = this.users[target] = this.bot.createUser(target); this.bot.sendMsg(this.room, "Created user " +target +" with " +sum +" points"); } else if (sum) { this.bot.sendMsg(this.room, "Added " +sum +" points to " +target); } userToMod.score += sum; Cache.SetScores(this.users); } else if (lmsg === "!score all") { if (!user.admin) { this.bot.sendMsg(this.room, "Must be channel operator"); return; } var scores = []; for (var i in this.users) this.users[i].score && scores.push({name: this.users[i].name, score: this.users[i].score}); if (scores.length == 0) { this.bot.sendMsg(this.room, "Pas de points pour le moment"); return; } scores = scores.sort((a, b) => b.score - a.score); this.bot.sendNotice(username, scores.map(i => i.name+":"+i.score).join(", ")); } else if (lmsg === "!score") { this.sendScore(true); } else if (lmsg === "!top") { this.sendScore(false); } else if (this.currentQuestion) { var dieOnFailure = this.currentQuestion.isBoolean() && this.currentQuestion.isBoolean(Question.normalize(msg)); if (this.currentQuestion.check(msg)) { const nbPts = this.computeScore(username); user.score += nbPts; Cache.SetScores(this.users); this.bot.sendMsg(this.room, nbPts +" points pour " +username +", qui cumule un total de " +user.score +" points !"); this.bot.voice(this.room, username); this.nextQuestionWrapper(); } else if (dieOnFailure) { var rep = Array.isArray(this.currentQuestion.response) ? this.currentQuestion.response.map(i => '"'+i+'"').join(" ou ") : this.currentQuestion.response; this.bot.sendMsg(this.room, "Perdu, la réponse était: " +rep); this.nextQuestionWrapper(); } } }; QuizzBot.prototype.mySQLExportWrapper = function() { if (!USE_MYSQL) return; var msRemaining = 0, lastMySQLExport = Cache.getLastMysqlSave(); if (lastMySQLExport) msRemaining = this.config.GAME_DURATION -(Date.now() -lastMySQLExport); if (msRemaining <= 0) this.mySQLExport(); else this.exportScoresTimer = setTimeout(this.mySQLExportWrapper.bind(this), Math.min(2147483000, msRemaining)); } QuizzBot.prototype.mySQLExport = function() { this.exportScores().then(() => { Cache.setExportTs(); this.resetScores(); console.log("Successfully exported scores to MySQL"); this.mySQLExportWrapper(); }).catch((errString) => { console.error("mySQL Export error saving to database: ", errString); }); } QuizzBot.prototype.exportScores = function() { console.log("Start exporting scores"); return new Promise((ok, ko) => { if (!MySQL) return ko(); var ts = Cache.getLastMysqlSave() || this.config.START_TIME; mySQL = MySQL(); ts = Math.floor(ts / 1000) *1000; ts = mySQL.escape(new Date(ts)); ts = ts.substr(1, ts.length -2); var sep = ts.lastIndexOf('.'); if (sep > 12) ts = ts.substr(0, sep); var toSave = []; for (var i in this.users) if (this.users[i].score) { toSave.push(this.users[i].name); toSave.push(this.users[i].score); } if (toSave.length == 0) { mySQL.end(); return ok(); } mySQL.execute("INSERT INTO " +this.config.MySQL_PERIOD_TABLE +" (start, host) VALUES(?, ?)", [ts, HOSTNAME], (err, result) => { if (err || !result.insertId) { mySQL.end(); return ko(err || "Cannot get last inserted id"); } mySQL.execute("INSERT INTO " +this.config.MySQL_SCORES_TABLE +"(period_id, pseudo, score) VALUES " +(",("+result.insertId+",?,?)").repeat(toSave.length /2).substr(1), toSave, (err) => { mySQL.end(); if (err) ko(err); else ok(); }); }); }); } module.exports = QuizzBot;