Browse Source

module refactoring

isundil 6 years ago
parent
commit
d68d72c43d
4 changed files with 757 additions and 638 deletions
  1. 67 0
      cache.js
  2. 20 15
      config.js
  3. 65 623
      index.js
  4. 605 0
      quizz.js

+ 67 - 0
cache.js

@@ -0,0 +1,67 @@
+
+const cache = new (require("data-store"))({ path: "persistentDb.json" });
+
+var 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.isReportedBy = function(questionId, username) {
+    var reported = cache.get("report") || {};
+    var questionReported = reported[questionId] || {};
+    reported[questionId] = questionReported;
+    return !!(questionReported[username]);
+}
+
+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.clearReports = function() {
+    cache.set("report", {});
+}
+
+Cache.unreportQuestion = function(questionId, username) {
+    var reported = cache.get("report") || {};
+    if (!reported[questionId])
+        return;
+    if (username)
+        reported[questionId][username] && delete reported[questionId][username];
+    else
+        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
+    };
+}
+
+module.exports = Cache;
+

+ 20 - 15
config.js

@@ -1,29 +1,34 @@
 
 module.exports = {
 // Gameplay params
-    AUTO_HINT_DELAY: 1 * 60 * 1000, // 1 minute
-    MIN_HINT_DELAY: 10 * 1000, // 10 seconds between 2 hint requests
-    NEXT_QUESTION_DELAY: 10 * 1000, // 10 seconds between questions
-    GAME_DURATION: 30 * 24 * 60 * 60 * 1000, // 1 month game duration
-    QUESTIONS_PATH: "./quizz.json",
-
     START_TIME: Date.now(),
 
 // IRC params
     NS_PASSWORD: "XXX",
-    USE_NS: true,
+    USE_NS: false,
 
     IRC_HOSTNAME: "irc.knacki.info",
-    IRC_ROOM: "#quizz2",
-    IRC_BOTNAME: "knackizz_",
+    IRC_BOTNAME: "knackizz",
+    MODULES: {
+    },
+
+// MySQL params
+    MySQL_DB: "irc",
+    MySQL_HOST: "localhost",
+    MySQL_USER: "root",
+    MySQL_PASS: "",
+    USE_MYSQL: false
+};
+
+module.exports.MODULES["#quizz"] = new (require('./quizz.js'))({
+    AUTO_HINT_DELAY: 1 * 60 * 1000, // 1 minute
+    MIN_HINT_DELAY: 10 * 1000, // 10 seconds between 2 hint requests
+    NEXT_QUESTION_DELAY: 20 * 1000, // 20 seconds between questions
+    GAME_DURATION: 30 * 24 * 60 * 60 * 1000, // 1 month game duration
+    QUESTIONS_PATH: "./db/quizz.json",
 
 // MySQL params
-    MySQL_DB: "XXX",
     MySQL_PERIOD_TABLE: "knackizz_period",
     MySQL_SCORES_TABLE: "knackizz_scores",
-    MySQL_HOST: "mysql.knacki.info",
-    MySQL_USER: "XXX",
-    MySQL_PASS: "XXX",
-    USE_MYSQL: true
-};
+});
 

+ 65 - 623
index.js

@@ -1,205 +1,8 @@
 
-const irc = require("irc"),
-    fs = require("fs"),
-    readline = require("readline"),
-    cache = new (require("data-store"))({ path: "persistentDb.json" }),
-    arrayPad = require('./strpad.js').arrayPad;
+const irc = require("irc");
 
 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.clearReports = function() {
-    cache.set("report", {});
-}
-
-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 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.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 User(nick) {
     this.admin = false;
     this.name = nick;
@@ -217,154 +20,95 @@ User.prototype.unsetMode = function(mode) {
         this.admin = false;
 };
 
-function KnackiBot(previousData) {
+function KnackiBot() {
     var _this = this;
     this.name = IRC_BOTNAME;
-    this.room = IRC_ROOM;
-    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.modules = MODULES;
 
-    this.mySQLExportWrapper();
+    for (var i in this.modules)
+        this.modules[i].init(this, i);
 
     this.bot = new irc.Client(IRC_HOSTNAME, this.name, {
-        channels: [ this.room ],
+        channels: Object.keys(this.modules),
         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);
+    if (USE_NS)
+        var _this = this,
+            registerHandler = 0;
+
+        var tryRegister = function() {
+            console.log("Trying to identify using NickServ");
+            _this.bot.say("NickServ", "identify " +NS_PASSWORD);
+        };
+        this.bot.addListener("raw", raw => {
+            if (raw.rawCommand == "MODE" && raw.args[0] === _this.name) {
+                var registeredMatch = (/(\+|-)[^\+-]*r/).exec(raw.args[1]);
+                if (!registeredMatch)
+                    return;
+                if (registeredMatch[1] === '-') {
+                    if (registerHandler === undefined) {
+                        registerHandler = setInterval(tryRegister, 30000);
+                        tryRegister();
+                    }
+                } else {
+                    if (registerHandler !== undefined) {
+                        clearInterval(registerHandler);
+                        registerHandler = undefined;
+                    }
+                }
             }
-            _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("registered", () => {
+            registerHandler = setInterval(tryRegister, 30000);
+            tryRegister();
+        });
+    this.bot.addListener("error", console.error);
+    this.bot.addListener("join", (chan, nick) => {
+        if (_this.modules[chan]) {
+            if (nick == _this.name)
+                _this.modules[chan].onSelfJoin();
+            else
+                _this.modules[chan].onJoin(nick);
         }
     });
-    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("names", (chan, nicks) => {
+        _this.modules[chan] && _this.modules[chan].onNameList(nicks);
     });
+    this.bot.addListener("part", (chan, nick) => { _this.modules[chan] && _this.modules[chan].onNickPart(nick); });
+    this.bot.addListener("kick", (chan, nick) => { _this.modules[chan] && _this.modules[chan].onNickPart(nick); });
     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()];
+        for (var i in _this.modules)
+            _this.modules[i].onRename(oldNick, newNick);
     });
-    this.bot.addListener("-mode", (channel, by, mode, user) => {
-        user && _this.users[user.toLowerCase()] && _this.users[user.toLowerCase()].unsetMode(mode);
+    this.bot.addListener("+mode", (chan, by, mode, user) => {
+        user && _this.modules[chan] && _this.modules[chan].onAddMode(user, mode);
     });
-    this.bot.addListener("message"+this.room, (user, text) => {
-        _this.users[user.toLowerCase()] && _this.onMessage(user, _this.users[user.toLowerCase()], text.trimEnd().replace(/\s+/, ' '));
+    this.bot.addListener("-mode", (chan, by, mode, user) => {
+        user && _this.modules[chan] && _this.modules[chan].onRemMode(user, mode);
     });
-}
-
-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);
+    this.bot.addListener("message", (user, room, text) => {
+        _this.modules[room] && _this.modules[room].onMessage(user, text);
     });
 }
 
-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.createUser = function(nick) {
+    return new User(nick);
 }
 
-KnackiBot.prototype.voice = function(username) {
+KnackiBot.prototype.voice = function(chan, username) {
     var usernames = Array.isArray(username) ? username : [ username ];
     if (usernames.length > 10)
     {
-        this.voice(usernames.slice(0, 10));
-        this.voice(usernames.slice(10));
+        this.voice(chan, usernames.slice(0, 10));
+        this.voice(chan, usernames.slice(10));
         return;
     }
-    usernames.splice(0, 0, "MODE", this.room, "+" +"v".repeat(usernames.length));
+    usernames.splice(0, 0, "MODE", chan, "+" +"v".repeat(usernames.length));
+    console.trace();
+    console.log(usernames);
     this.bot.send.apply(this.bot, usernames);
 }
 
@@ -372,311 +116,9 @@ KnackiBot.prototype.sendNotice = function(username, msg) {
     this.bot.notice(username, msg);
 }
 
-KnackiBot.prototype.sendMsg = function(msg) {
-    this.bot.say(this.room, msg);
+KnackiBot.prototype.sendMsg = function(room, msg) {
+    this.bot.say(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) {
-    const lmsg = msg.toLowerCase();
-    if (lmsg.startsWith("!reload")) {
-        if (user.admin)
-            this.reloadDb();
-        else
-            this.sendMsg("Must be channel operator");
-    }
-    else if (lmsg.startsWith("!aide")) {
-        this.sendMsg("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 > MIN_HINT_DELAY)
-                    this.sendNextHint();
-            }
-            else
-                this.sendMsg("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.sendMsg("La réponse était: " +response);
-            this.nextQuestionWrapper();
-        } else {
-            this.sendMsg("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.sendNotice(username, JSON.stringify(questions));
-        }
-        else
-            this.sendMsg("Must be channel operator");
-    }
-    else if (lmsg == ("!report clear")) {
-        if (user.admin) {
-            Cache.clearReports();
-            this.sendMsg("Toutes les questions sont marquées comme restaurées");
-        }
-        else
-            this.sendMsg("Must be channel operator");
-    }
-    else if (lmsg.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 (lmsg.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éfectueuse par " +username);
-        }
-    }
-    else if (lmsg.startsWith("!score del ")) {
-        if (user.admin)
-            this.delScore(msg.substr("!score del ".length).trim());
-        else
-            this.sendMsg("Must be channel operator");
-    }
-    else if (lmsg.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 (lmsg.startsWith("!score all")) {
-        if (!user.admin) {
-            this.sendMsg("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.sendMsg("Pas de points pour le moment");
-            return;
-        }
-        scores = scores.sort((a, b) => b.score - a.score).slice(0, 10);
-        this.sendNotice(username, scores.map(i => i.name+":"+i.score).join(", "));
-    }
-    else if (lmsg.startsWith("!score")) {
-        this.sendScore(true);
-    }
-    else if (lmsg.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());
+new KnackiBot();
 

+ 605 - 0
quizz.js

@@ -0,0 +1,605 @@
+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 ? 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.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 +" marquée comme restaurée");
+            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);
+        }
+        userToMod.score += sum;
+        Cache.SetScores(this.users);
+    }
+    else if (lmsg.startsWith("!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).slice(0, 10);
+        this.bot.sendNotice(username, scores.map(i => i.name+":"+i.score).join(", "));
+    }
+    else if (lmsg.startsWith("!score")) {
+        this.sendScore(true);
+    }
+    else if (lmsg.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)) {
+            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() {
+    return new Promise((ok, ko) => {
+        var ts = Cache.getLastMysqlSave() || this.config.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 " +this.config.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 " +this.config.MySQL_SCORES_TABLE +"(period_id, pseudo, score) VALUES " +(",("+result.insertId+",?,?)").repeat(toSave.length /2).substr(1), toSave, (err) => {
+                if (err)
+                    ko(err);
+                else
+                    ok();
+            });
+        });
+    });
+}
+
+
+module.exports = QuizzBot;
+