const irc = require("irc"), fs = require("fs"), readline = require("readline"), cache = new (require("data-store"))({ path: "persistentDb.json" }), arrayPad = require('./strpad.js').arrayPad; Object.assign(global, require("./config.js")); const MySQL = USE_MYSQL ? 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 Cache() {} Cache.SetScores = function(scores) { var dataToSet = {}; for (var i in scores) if (scores[i].score) dataToSet[scores[i].name] = scores[i].score; cache.set("scores", dataToSet); cache.set("saveTs", Date.now()); } Cache.getReportedQuestions = function(questionId, username) { return cache.get("report") || {}; } Cache.reportQuestion = function(questionId, username) { var reported = cache.get("report") || {}; var questionReported = reported[questionId] || {}; reported[questionId] = questionReported; if (questionReported[username]) return; questionReported[username] = Date.now(); cache.set("report", reported); } Cache.unreportQuestion = function(questionId) { var reported = cache.get("report") || {}; if (!reported[questionId]) return; delete reported[questionId]; cache.set("report", reported); } Cache.setExportTs = function() { cache.set("mysqlTs", Date.now()); } Cache.getLastMysqlSave = function() { return cache.get("mysqlTs") || 0; } Cache.GetData = function() { return { scores: cache.get("scores"), lastSave: cache.get("saveTs") || 0, lastMysqlExport: cache.get("mysqlTs") || 0 }; } 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 (!isNaN(Number(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 response.replace(/[\w]/g, '*'); else if (hintLevel == 1) return response.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 || [ -Infinity, Infinity]; 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.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) => { var stream = fs.createReadStream(filename), reader = readline.createInterface({input: stream}), questions = [], lineNo = 0, borken = false; reader.on("line", line => { if (borken) return; try { 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 User(nick) { this.admin = false; this.name = nick; this.score = 0; } User.prototype.setModeChar = function(mode) { this.admin = !!(mode && (mode.indexOf('~') >= 0 || mode.indexOf('@') >= 0 || mode.indexOf('%') >= 0)); } User.prototype.setMode = function(mode) { if (mode == 'h' || mode == 'o' || mode == 'q' || mode == 'a') this.admin = true; }; User.prototype.unsetMode = function(mode) { if (mode == 'h' || mode == 'o' || mode == 'q' || mode == 'a') this.admin = false; }; function KnackiBot(previousData) { var _this = this; this.name = "Knackizz"; this.room = "#quizz"; this.init = false; this.password = NS_PASSWORD; this.users = {}; this.activeUsers = {}; if (previousData) { for (var i in previousData.scores) { this.users[i.toLowerCase()] = new User(i); this.users[i.toLowerCase()].score = previousData.scores[i]; } } this.mySQLExportWrapper(); this.bot = new irc.Client("irc.knacki.info", this.name, { channels: [ this.room ], userName: this.name, realName: this.name, stripColors: true }); this.bot.addListener("error", console.error); this.bot.addListener("join" +this.room, (nick) => { if (nick == _this.name) { if (USE_NS) { _this.bot.say("NickServ", "recover " +_this.name +" " +_this.password); _this.bot.say("NickServ", "identify " +_this.password); } _this.init = true; _this.reloadDb(); } else { _this.users[nick.toLowerCase()] = _this.users[nick.toLowerCase()] || new User(nick); _this.activeUsers[nick.toLowerCase()] = true; } }); this.bot.addListener("names"+this.room, (nicks) => { var oldusers = _this.users; _this.activeUsers = {}; for (var i in nicks) { var u = _this.users[i.toLowerCase()] = (_this.users[i.toLowerCase()] || new User(i)); u.setModeChar(nicks[i]); _this.activeUsers[i.toLowerCase()] = true; } }); this.bot.addListener("part"+this.room, (user) => delete _this.activeUsers[user.toLowerCase()]); this.bot.addListener("kick"+this.room, (user) => delete _this.activeUsers[user.toLowerCase()]); this.bot.addListener("+mode", (channel, by, mode, user) => { if (user === _this.name) { usersToVoice = []; for (var i in _this.users) { if (_this.users[i].score && _this.activeUsers[i]) usersToVoice.push(i); } _this.voice(usersToVoice); } else { user && _this.users[user.toLowerCase()] && _this.users[user.toLowerCase()].setMode(mode); } }); this.bot.addListener("nick", (oldNick, newNick) => { _this.users[newNick.toLowerCase()] = _this.users[newNick.toLowerCase()] || new User(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()]; }); this.bot.addListener("-mode", (channel, by, mode, user) => { user && _this.users[user.toLowerCase()] && _this.users[user.toLowerCase()].unsetMode(mode); }); this.bot.addListener("message"+this.room, (user, text) => { _this.users[user.toLowerCase()] && _this.onMessage(user, _this.users[user.toLowerCase()], text.trimEnd()); }); } KnackiBot.prototype.mySQLExportWrapper = function() { if (!USE_MYSQL) return; var msRemaining = 0, lastMySQLExport = Cache.getLastMysqlSave(); if (lastMySQLExport) msRemaining = GAME_DURATION -(Date.now() -lastMySQLExport); if (msRemaining <= 0) this.mySQLExport(); else this.exportScoresTimer = setTimeout(this.mySQLExportWrapper.bind(this), Math.min(2147483000, msRemaining)); } KnackiBot.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); }); } KnackiBot.prototype.exportScores = function() { return new Promise((ok, ko) => { var ts = Cache.getLastMysqlSave() || START_TIME; 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) return ok(); MySQL.execute("INSERT INTO " +MySQL_PERIOD_TABLE +" (start, host) VALUES(?, ?)", [ts, HOSTNAME], (err, result) => { if (err || !result.insertId) return ko(err || "Cannot get last inserted id"); MySQL.execute("INSERT INTO " +MySQL_SCORES_TABLE +"(period_id, pseudo, score) VALUES " +(",("+result.insertId+",?,?)").repeat(toSave.length /2).substr(1), toSave, (err) => { if (err) ko(err); else ok(); }); }); }); } KnackiBot.prototype.resetScores = function() { if (this.sumScores(false) > 0) { this.sendMsg("Fin de la manche ! Voici les scores finaux:"); this.sendScore(false); } for (var i in this.users) this.users[i].score = 0; Cache.SetScores({}); } KnackiBot.prototype.voice = function(username) { var usernames = Array.isArray(username) ? username : [ username ]; if (usernames.length > 10) { this.voice(usernames.slice(0, 10)); this.voice(usernames.slice(10)); return; } usernames.splice(0, 0, "MODE", this.room, "+" +"v".repeat(usernames.length)); this.bot.send.apply(this.bot, usernames); } KnackiBot.prototype.sendNotice = function(username, msg) { this.bot.notice(username, msg); } KnackiBot.prototype.sendMsg = function(msg) { this.bot.say(this.room, msg); } KnackiBot.prototype.stop = function() { this.timer && clearTimeout(this.timer); this.timer = null; this.currentQuestion = null; this.currentHint = 0; } KnackiBot.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.sendMsg("Perdu, la réponse était: " +response); this.nextQuestionWrapper(); } } KnackiBot.prototype.resetTimer = function() { this.timer && clearTimeout(this.timer); this.timer = setInterval(this.onTick.bind(this), AUTO_HINT_DELAY); } KnackiBot.prototype.sendNextHint = function(resetTimer) { this.sendMsg(this.currentQuestion.getHint(this.currentHint)); ++this.currentHint; this.lastHint = Date.now(); resetTimer !== false && this.resetTimer(); } KnackiBot.prototype.nextQuestionWrapper = function() { this.currentQuestion.end(); this.currentQuestion = null; setTimeout(this.nextQuestion.bind(this), NEXT_QUESTION_DELAY); } KnackiBot.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.sendMsg("#" +this.currentQuestion.id +" " +this.currentQuestion.question); this.sendNextHint(); } KnackiBot.prototype.start = function() { if (this.reloading) { this.sendMsg("Error: database still reloading"); return; } if (!this.currentQuestion) { this.nextQuestion(); } } KnackiBot.prototype.reloadDb = function() { var _this = this; this.stop(); this.reloading = true; initQuestionList(QUESTIONS_PATH).then(questions => { _this.reloading = false; _this.questions = questions; _this.sendMsg(questions.length +" questions loaded from database"); _this.start(); }).catch(err => { console.error(err); _this.sendMsg(err); }); } KnackiBot.prototype.delScore = function(user) { var data = this.users[user.toLowerCase()]; if (!data) { this.sendMsg("User not found..."); return; } if (!data.score) { this.sendMsg("Score for user already null"); return; } data.score = 0; this.sendMsg("Removed score"); Cache.SetScores(this.users); } KnackiBot.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; } KnackiBot.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.sendMsg("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.sendMsg(i)); } KnackiBot.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.sendMsg(responseMsg); this.sendMsg(score +" points pour " +username); return score; } KnackiBot.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; } } KnackiBot.prototype.onMessage = function(username, user, msg) { if (msg.startsWith("!reload")) { if (user.admin) this.reloadDb(); else this.sendMsg("Must be channel operator"); } else if (msg.startsWith("!aide")) { this.sendMsg("Usage: !aide | !indice | !reload | !next | !rename | !score [del pseudo] || !top"); } else if (msg === "!indice" || msg === "!conseil") { if (this.currentQuestion) { if (this.currentHint < 3) { if (Date.now() -this.lastHint > MIN_HINT_DELAY) this.sendNextHint(); } else this.sendMsg("Pas plus d'indice..."); } } else if (msg === "!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.sendMsg("La réponse était: " +response); this.nextQuestionWrapper(); } else { this.sendMsg("Must be channel operator"); } } else if (msg.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.sendNotice(username, JSON.stringify(questions)); } else this.sendMsg("Must be channel operator"); } else if (msg.startsWith("!report del ")) { if (user.admin) { var questionId = msg.substr("!report del ".length).trim(); if (questionId.startsWith('#')) questionId = questionId.substr(1); questionId = Number(questionId); if (isNaN(questionId)) { this.sendMsg("Erreur: Usage: !report del #1234"); return; } Cache.unreportQuestion(questionId); this.sendMsg("Question #" +questionId +" marquée comme restaurée"); } else this.sendMsg("Must be channel operator"); } else if (msg.startsWith("!report ")) { var questionId = msg.substr("!report ".length).trim(); if (questionId.startsWith('#')) questionId = questionId.substr(1); questionId = Number(questionId); if (isNaN(questionId)) { this.sendMsg("Erreur: Usage: !report #1234"); return; } if (!this.findQuestionById(questionId)) this.sendMsg("Erreur: question non trouvée"); else { Cache.reportQuestion(questionId, username); this.sendMsg("Question #" +questionId +" marquée comme déffectueuse par " +username); } } else if (msg.startsWith("!score del ")) { if (user.admin) this.delScore(msg.substr("!score del ".length).trim()); else this.sendMsg("Must be channel operator"); } else if (msg.startsWith("!rename")) { if (!user.admin) { this.sendMsg("Must be channel operator"); return; } var args = (msg.split(/\s+/)).splice(1); if (args.length < 2) { this.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] = new User(target); this.sendMsg("Created user " +target); } userToMod.score += sum; Cache.SetScores(this.users); } else if (msg.startsWith("!score")) { this.sendScore(true); } else if (msg.startsWith("!top")) { this.sendScore(false); } else if (this.currentQuestion) { var dieOnFailure = this.currentQuestion.isBoolean() && this.currentQuestion.isBoolean(Question.normalize(msg)); if (this.currentQuestion.check(msg)) { user.score += this.computeScore(username); Cache.SetScores(this.users); this.voice(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.sendMsg("Perdu, la réponse était: " +rep); this.nextQuestionWrapper(); } } }; new KnackiBot(Cache.GetData());