diff --git a/locales/de.json b/locales/de.json index f449c37b..0561e58b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -60,7 +60,7 @@ "config": { "checking-config": "Konfiguration wird überprüft...", "done-with-checking": "Konfiguration erfolgreich überprüft.", - "creating-file": "Konfiguration %m/%f exestiert aktuell nicht, wird aber gleich erstellt...", + "creating-file": "Konfiguration %m/%f existiert aktuell nicht, wird aber gleich erstellt...", "checking-of-field-failed": "Ein Fehler bei der Überprüfung von %fieldName in %m/%f ist aufgetreten", "saved-file": "Konfiguration %f in %m wurde erfolgreich generiert.", "moduleconf-regeneration": "Modul-Konfiguration wird regeniert, es werden keine Einstellungen überschrieben.", @@ -926,5 +926,40 @@ "not-host": "Du bist nicht der Ersteller des Spiels!", "max-players": "Das Spiel ist voll!", "previous-cards": "Vorherige Karten: " + }, + "quiz": { + "what-have-i-voted": "Was habe ich gewählt?", + "vote": "Abstimmen!", + "vote-this": "Wähle diese Option, wenn du denkst, dass diese richtig ist.", + "voted-successfully": "Erfolgreich ausgewählt. Danke für deine Teilnahme.", + "not-voted-yet": "Du hast noch keine Antwort ausgewählt, also kann ich dir nicht zeigen, für was du abgestimmt hast?", + "you-voted": "Du hast **%o** als Antwort ausgewählt.", + "change-opinion": "Du kannst deine Auswahl jederzeit ändern, indem du einfach etwas anderes über dem Knopf, den du gerade angeklickt hast, auswählst.", + "cannot-change-opinion": "Du kannst deine Auswahl nicht ändern, da der Ersteller diese Funktion deaktiviert hat.", + "select-correct": "Wähle alle richtigen Antworten aus", + "this-correct": "Diese Antwort als richtig markieren", + "cmd-description": "Erstelle oder spiele Quiz", + "cmd-create-normal-description": "Erstelle ein Quiz mit bis zu 10 Antworten", + "cmd-create-bool-description": "Erstelle ein Quiz, bei dem Nutzer nur Ja oder Nein auswählen können", + "cmd-play-description": "Spiele ein Server-Quiz", + "cmd-leaderboard-description": "Zeigt das Quiz-Leaderboard des Servers", + "cmd-create-description-description": "Thema / Beschreibung des Quiz", + "cmd-create-channel-description": "Kanal, in welchem dieses Quiz erstellt werden soll", + "cmd-create-endAt-description": "Relative Dauer des Quiz", + "cmd-create-option-description": "Option Nummer %o", + "cmd-create-canchange-description": "Ob die Teilnehmer ihre Auswahl nachträglich ändern können (Standard: Nein)", + "daily-quiz-limit": "Du hast das Limit von **%l** täglichen Quiz erreicht. Du kannst %timestamp wieder Quiz spielen.", + "created": "Quiz erfolgreich in %c erstellt.", + "correct-highlighted": "Alle richtigen Antworten wurden hervorgehoben.", + "answer-correct": "✅ Deine Antwort war richtig, du hast einen Punkt fürs Leaderboard erhalten!", + "answer-wrong": "❌ Deine Antwort war falsch!", + "bool-true": "Aussage stimmt", + "bool-false": "Aussage stimmt nicht", + "leaderboard-channel-not-found": "Der Leaderboard-Kanal wurde nicht gefunden oder sein Typ ist nicht erlaubt.", + "leaderboard-notation": "**%p. %u**: %xp XP", + "your-rank": "Du hast **%xp** Punkte in Quiz gesammelt!", + "no-rank": "Du hast noch nie ein Quiz erfolgreich beendet!", + "no-quiz": "Es wurden noch keine Quiz erstellt. Serveradmins können auf https://scnx.app/glink?page=bot/configuration?query=quiz&file=quiz%7Cconfigs%2FquizList Quiz hinzufügen.", + "no-permission": "Du hast keine Berechtigung, um Quiz mit dem Befehl zu erstellen." } } diff --git a/locales/en.json b/locales/en.json index 62306365..a2ec25c6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -910,5 +910,40 @@ "not-host": "You're not the host of the game!", "max-players": "The game is full!", "previous-cards": "Previous cards: " + }, + "quiz": { + "what-have-i-voted": "What have I voted?", + "vote": "Vote!", + "vote-this": "Select this option if you think it's correct.", + "voted-successfully": "Selected successfully.", + "not-voted-yet": "You have not selected an option yet, so I cant show you what you selected?", + "you-voted": "You've selected **%o** as correct answer.", + "change-opinion": "You can change your opinion at any time by selecting another option above the button you just clicked.", + "cannot-change-opinion": "You cannot change your selection as the creator of this quiz disabled it.", + "select-correct": "Select all correct answers", + "this-correct": "Mark this answer as correct", + "cmd-description": "Create or play server quiz", + "cmd-create-normal-description": "Create a quiz with up to 10 answers", + "cmd-create-bool-description": "Create a quiz with true or false answers", + "cmd-play-description": "Play a server quiz", + "cmd-leaderboard-description": "Shows the quiz leaderboard of the server", + "cmd-create-description-description": "Title / description of the quiz", + "cmd-create-channel-description": "Channel in which the quiz should be created", + "cmd-create-endAt-description": "How long the quiz will last", + "cmd-create-option-description": "Option number %o", + "cmd-create-canchange-description": "If the players can change their opinion after voting (default: no)", + "daily-quiz-limit": "You've reached the limit of **%l** daily playable quizzes. You can play again %timestamp.", + "created": "Quiz created successfully in %c.", + "correct-highlighted": "All correct answers were highlighted.", + "answer-correct": "✅ Your answer was correct and you've received one point for the leaderboard!", + "answer-wrong": "❌ Your answer was wrong!", + "bool-true": "Statement is correct", + "bool-false": "Statement is wrong", + "leaderboard-channel-not-found": "The leaderboard channel couldn't be found or it's type is invalid.", + "leaderboard-notation": "**%p. %u**: %xp XP", + "your-rank": "You've collected **%xp** points in quiz!", + "no-rank": "You've never finished a quiz successfully!", + "no-quiz": "No quizzes have been created for this server. Trusted admins can create them on https://scnx.app/glink?page=bot/configuration?query=quiz&file=quiz%7Cconfigs%2FquizList .", + "no-permission": "You don't have enough permissions to create quiz using the command." } } diff --git a/modules/quiz/commands/quiz.js b/modules/quiz/commands/quiz.js new file mode 100644 index 00000000..6aa2fa8d --- /dev/null +++ b/modules/quiz/commands/quiz.js @@ -0,0 +1,260 @@ +const {MessageEmbed} = require('discord.js'); +const durationParser = require('parse-duration'); +const { formatDate } = require('../../../src/functions/helpers'); +const {localize} = require('../../../src/functions/localize'); +const {createQuiz} = require('../quizUtil'); + +/** + * Handles quiz create commands + * @param {Discord.ApplicationCommandInteraction} interaction + */ +async function create(interaction) { + const config = interaction.client.configurations['quiz']['config']; + if (!interaction.member.roles.cache.has(config.createAllowedRole)) return interaction.reply({content: localize('quiz', 'no-permission'), ephemeral: true}); + + let endAt; + let options = []; + let emojis = config.emojis; + if (interaction.options.getSubcommand() === 'create-bool') { + options = [{text: localize('quiz', 'bool-true')}, {text: localize('quiz', 'bool-false')}]; + emojis = [null, emojis.true, emojis.false]; + } else { + for (let step = 1; step <= 10; step++) { + if (interaction.options.getString('option' + step)) options.push({text: interaction.options.getString('option' + step)}); + } + } + + const selectOptions = []; + for (const vId in options) { + selectOptions.push({ + label: options[vId].text, + value: vId, + description: localize('quiz', 'this-correct'), + emoji: emojis[parseInt(vId) + 1] + }); + } + const msg = await interaction.reply({ + components: [{ + type: 'ACTION_ROW', + components: [{ + /* eslint-disable camelcase */ + type: 'SELECT_MENU', + custom_id: 'quiz', + placeholder: localize('quiz', 'select-correct'), + min_values: 1, + max_values: interaction.options.getSubcommand() === 'create-bool' ? 1 : options.length, + options: selectOptions + }] + }], + ephemeral: true, + fetchReply: true + }); + const collector = msg.createMessageComponentCollector({filter: i => interaction.user.id === i.user.id, componentType: 'SELECT_MENU', max: 1}); + collector.on('collect', async i => { + i.values.forEach(option => { + options[option].correct = true; + }); + + if (interaction.options.getString('duration')) endAt = new Date(new Date().getTime() + durationParser(interaction.options.getString('duration'))); + await createQuiz({ + description: interaction.options.getString('description', true), + channel: interaction.options.getChannel('channel', true), + endAt, + options, + canChangeVote: interaction.options.getBoolean('canchange') || false, + type: interaction.options.getSubcommand() === 'create-bool' ? 'bool' : 'normal' + }, interaction.client); + i.update({ + content: localize('quiz', 'created', {c: interaction.options.getChannel('channel').toString()}), + components: [] + }); + }); +} + +module.exports.subcommands = { + 'create': create, + 'create-bool': create, + 'play': async function (interaction) { + let user = await interaction.client.models['quiz']['QuizUser'].findAll({where: {userId: interaction.user.id}}); + if (user.length > 0) user = user[0]; + else user = await interaction.client.models['quiz']['QuizUser'].create({userID: interaction.user.id, dailyQuiz: 0}); + + if (user.dailyQuiz >= interaction.client.configurations['quiz']['config'].dailyQuizLimit) { + const now = new Date(); + now.setDate(now.getDate() + 1); + now.setHours(0); + now.setMinutes(0); + now.setSeconds(0); + + return interaction.reply({ + content: localize('quiz', 'daily-quiz-limit', {l: interaction.client.configurations['quiz']['config'].dailyQuizLimit, timestamp: formatDate(now)}), + ephemeral: true + }); + } + if (!interaction.client.configurations['quiz']['quizList'] || interaction.client.configurations['quiz']['quizList'].length === 0) return interaction.reply({content: localize('quiz', 'no-quiz'), ephemeral: true}); + + const updatedUser = {dailyQuiz: user.dailyQuiz + 1}; + let quiz = {}; + if (interaction.client.configurations['quiz']['config'].mode.toLowerCase() === 'continuous') { + quiz = interaction.client.configurations['quiz']['quizList'][user.nextQuizID] || interaction.client.configurations['quiz']['quizList'][0]; + updatedUser.nextQuizID = interaction.client.configurations['quiz']['quizList'][user.nextQuizID + 1] ? user.nextQuizID + 1 : 0; + } else quiz = interaction.client.configurations['quiz']['quizList'][Math.floor(Math.random() * interaction.client.configurations['quiz']['quizList'].length)]; + + quiz.channel = interaction.channel; + quiz.options = [ + ...quiz.wrongOptions.map(o => ({text: o})), + ...quiz.correctOptions.map(o => ({text: o, correct: true})) + ]; + quiz.endAt = new Date(new Date().getTime() + durationParser(quiz.duration)); + quiz.canChangeVote = false; + quiz.private = true; + createQuiz(quiz, interaction.client, interaction); + + interaction.client.models['quiz']['QuizUser'].update(updatedUser, {where: {userID: interaction.user.id}}); + }, + 'leaderboard': async function (interaction) { + const moduleStrings = interaction.client.configurations['quiz']['strings']; + const users = await interaction.client.models['quiz']['QuizUser'].findAll({ + order: [ + ['xp', 'DESC'] + ], + limit: 15 + }); + + let leaderboardString = ''; + let i = 0; + for (const user of users) { + const member = interaction.guild.members.cache.get(user.userID); + if (!member) continue; + i++; + leaderboardString = leaderboardString + localize('quiz', 'leaderboard-notation', { + p: i, + u: member.user.toString(), + xp: user.xp + }) + '\n'; + } + if (leaderboardString.length === 0) leaderboardString = localize('levels', 'no-user-on-leaderboard'); + + const embed = new MessageEmbed() + .setTitle(moduleStrings.embed.leaderboardTitle) + .setColor(moduleStrings.embed.leaderboardColor) + .setFooter({text: interaction.client.strings.footer, iconURL: interaction.client.strings.footerImgUrl}) + .setThumbnail(interaction.guild.iconURL()) + .addField(moduleStrings.embed.leaderboardSubtitle, leaderboardString); + + if (!interaction.client.strings.disableFooterTimestamp) embed.setTimestamp(); + + const components = [{ + type: 'ACTION_ROW', + components: [{ + type: 'BUTTON', + label: moduleStrings.embed.leaderboardButton, + style: 'SUCCESS', + customId: 'show-quiz-rank' + }] + }]; + + interaction.reply({embeds: [embed], components}); + } +}; + +module.exports.config = { + name: 'quiz', + description: localize('quiz', 'cmd-description'), + defaultPermission: false, + options: function () { + const options = [ + { + type: 'SUB_COMMAND', + name: 'create', + description: localize('quiz', 'cmd-create-normal-description'), + options: [{ + type: 'STRING', + name: 'description', + required: true, + description: localize('quiz', 'cmd-create-description-description') + }, + { + type: 'CHANNEL', + name: 'channel', + required: true, + channelTypes: ['GUILD_TEXT', 'GUILD_NEWS', 'GUILD_VOICE'], + description: localize('quiz', 'cmd-create-channel-description') + }, + { + type: 'STRING', + name: 'duration', + required: true, + description: localize('quiz', 'cmd-create-endAt-description') + }, + { + type: 'STRING', + name: 'option1', + required: true, + description: localize('quiz', 'cmd-create-option-description', {o: 1}) + }, + { + type: 'STRING', + name: 'option2', + required: true, + description: localize('quiz', 'cmd-create-option-description', {o: 2}) + }, + { + type: 'BOOLEAN', + name: 'canchange', + required: false, + description: localize('quiz', 'cmd-create-canchange-description') + }] + }, + { + type: 'SUB_COMMAND', + name: 'create-bool', + description: localize('quiz', 'cmd-create-bool-description'), + options: [{ + type: 'STRING', + name: 'description', + required: true, + description: localize('quiz', 'cmd-create-description-description') + }, + { + type: 'CHANNEL', + name: 'channel', + required: true, + channelTypes: ['GUILD_TEXT', 'GUILD_NEWS', 'GUILD_VOICE'], + description: localize('quiz', 'cmd-create-channel-description') + }, + { + type: 'BOOLEAN', + name: 'canchange', + required: false, + description: localize('quiz', 'cmd-create-canchange-description') + }, + { + type: 'STRING', + name: 'duration', + required: false, + description: localize('quiz', 'cmd-create-endAt-description') + }] + }, + { + type: 'SUB_COMMAND', + name: 'play', + description: localize('quiz', 'cmd-play-description') + }, + { + type: 'SUB_COMMAND', + name: 'leaderboard', + description: localize('quiz', 'cmd-leaderboard-description') + } + ]; + for (let step = 1; step <= 7; step++) { + options[0].options.push({ + type: 'STRING', + name: `option${2 + step}`, + required: false, + description: localize('quiz', 'cmd-create-option-description', {o: 2 + step}) + }); + } + return options; + } +}; diff --git a/modules/quiz/configs/config.json b/modules/quiz/configs/config.json new file mode 100644 index 00000000..3821745c --- /dev/null +++ b/modules/quiz/configs/config.json @@ -0,0 +1,146 @@ +{ + "description": { + "en": "Configure the function of the module here", + "de": "Stelle hier die Funktionen des Modules ein" + }, + "humanName": { + "en": "Configuration", + "de": "Konfiguration" + }, + "filename": "config.json", + "commandsWarnings": { + "normal": [ + "/quiz" + ] + }, + "content": [ + { + "name": "emojis", + "humanName": { + "de": "Emojis" + }, + "default": { + "en": { + "1": "1️⃣", + "2": "2️⃣", + "3": "3️⃣", + "4": "4️⃣", + "5": "5️⃣", + "6": "6️⃣", + "7": "7️⃣", + "8": "8️⃣", + "9": "9️⃣", + "true": "✅", + "false": "❌" + }, + "de": { + "1": "1️⃣", + "2": "2️⃣", + "3": "3️⃣", + "4": "4️⃣", + "5": "5️⃣", + "6": "6️⃣", + "7": "7️⃣", + "8": "8️⃣", + "9": "9️⃣", + "true": "✅", + "false": "❌" + } + }, + "description": { + "en": "You can set the emojis to use", + "de": "Du kannst die verschiedenen Emojis, die benutzt werden sollen, einstellen" + }, + "type": "keyed", + "content": { + "key": "string", + "value": "string" + }, + "disableKeyEdits": true + }, + { + "name": "dailyQuizLimit", + "humanName": { + "en": "Daily quiz limit", + "de": "Tägliches Quizlimit" + }, + "default": { + "en": 5 + }, + "description": { + "en": "How many quizzes can be played per day using /quiz play", + "de": "Wie viele Quiz pro Tag mit /quiz play gespielt werden können" + }, + "type": "integer" + }, + { + "name": "leaderboardChannel", + "humanName": { + "en": "Quiz leaderboard channel", + "de": "Quiz-Leaderboard-Kanal" + }, + "default": { + "en": "" + }, + "description": { + "en": "In which channel the quiz leaderboard is displayed", + "de": "In welchem Kanal das Quiz-Leaderboard angezeigt wird" + }, + "type": "channelID", + "content": [ + "GUILD_TEXT", + "GUILD_ANNOUNCEMENT" + ], + "allowNull": true + }, + { + "name": "createAllowedRole", + "humanName": { + "en": "Role needed to create quizzes", + "de": "Rolle zum Erstellen von Quiz" + }, + "default": { + "en": "" + }, + "description": { + "en": "Which role a user needs to have to be able to create quizzes with /quiz create/create-bool", + "de": "Welche Rolle ein Nutzer haben muss, um Quiz mit /quiz create/create-bool erstellen zu können" + }, + "type": "roleID" + }, + { + "name": "mode", + "humanName": { + "en": "Mode for quiz selection", + "de": "Modus zur Quizauswahl" + }, + "default": { + "en": "Random" + }, + "description": { + "en": "How a /quiz play quiz is selected for users", + "de": "Wie ein /quiz-play-Quiz für Nutzer ausgewählt wird" + }, + "type": "select", + "content": [ + "Random", + "Continuous" + ] + }, + { + "name": "livePreview", + "humanName": { + "en": "Live preview of results", + "de": "Live-Vorschau der Ergebnisse" + }, + "default": { + "en": false + }, + "description": { + "en": "Whether the live preview of results is enabled", + "de": "Ob die Live-Vorschau der Ergebnisse aktiviert ist" + }, + "type": "boolean" + } + ] +} diff --git a/modules/quiz/configs/quizList.json b/modules/quiz/configs/quizList.json new file mode 100644 index 00000000..27224abd --- /dev/null +++ b/modules/quiz/configs/quizList.json @@ -0,0 +1,76 @@ +{ + "description": { + "en": "Create and edit the quizzes of the server", + "de": "Erstelle und bearbeite hier die Quiz des Servers" + }, + "humanName": { + "en": "Edit quiz", + "de": "Quiz bearbeiten" + }, + "configElements": true, + "filename": "quizList.json", + "content": [ + { + "name": "description", + "humanName": { + "en": "Question or statement", + "de": "Frage oder Behauptung" + }, + "default": { + "en": "" + }, + "description": { + "en": "Title/Question of the quiz", + "de": "Titel/Frage des Quiz" + }, + "type": "string" + }, + { + "name": "duration", + "humanName": { + "en": "Time limit", + "de": "Zeitlimit" + }, + "default": { + "en": "1m" + }, + "description": { + "en": "How much time the user has to answer", + "de": "Wie viel Zeit der Nutzer zum Beantworten hat" + }, + "type": "string" + }, + { + "name": "correctOptions", + "humanName": { + "en": "Correct answers", + "de": "Richtige Antworten" + }, + "default": { + "en": [] + }, + "description": { + "en": "Correct answers", + "de": "Richtige Antworten" + }, + "type": "array", + "content": "string" + }, + { + "name": "wrongOptions", + "humanName": { + "en": "Wrong answers", + "de": "Falsche Antworten" + }, + "default": { + "en": [] + }, + "description": { + "en": "Wrong answers", + "de": "Falsche Antworten" + }, + "type": "array", + "content": "string" + } + ] +} diff --git a/modules/quiz/configs/strings.json b/modules/quiz/configs/strings.json new file mode 100644 index 00000000..ffd09041 --- /dev/null +++ b/modules/quiz/configs/strings.json @@ -0,0 +1,59 @@ +{ + "description": { + "en": "Edit the messages and strings of the module here", + "de": "Stelle hier die Nachrichten des Modules ein" + }, + "humanName": { + "en": "Nachrichten", + "de": "Nachrichten" + }, + "filename": "strings.json", + "content": [ + { + "name": "embed", + "humanName": { + "de": "Embed" + }, + "default": { + "en": { + "title": "New quiz - What's right?", + "color": "BLUE", + "options": "Today's options", + "liveView": "Live view of the results", + "expiresOn": "End of this quiz", + "thisQuizExpiresOn": "This quiz expires on %date%.", + "endedQuizTitle": "Quiz ended", + "endedQuizColor": "RED", + "leaderboardTitle": "The best quiz players", + "leaderboardSubtitle": "Quiz leaderboard", + "leaderboardColor": "GREEN", + "leaderboardButton": "View my ranking" + }, + "de": { + "title": "Neues Quiz - Was ist richtig?", + "color": "BLUE", + "options": "Mögliche antworten", + "liveView": "Live-Anzeige der Ergebnisse", + "expiresOn": "Ende dieses Quiz", + "thisQuizExpiresOn": "Dieses Quiz endet am %date%.", + "endedQuizTitle": "Quiz beendet", + "endedQuizColor": "RED", + "leaderboardTitle": "Die besten Quiz-Spieler", + "leaderboardSubtitle": "Quiz-Rangliste", + "leaderboardColor": "GREEN", + "leaderboardButton": "Meine Platzierung ansehen" + } + }, + "description": { + "en": "You can edit the settings of your embed here", + "de": "Du kannst die Einstellungen des Embeds hier bearbeiten" + }, + "type": "keyed", + "content": { + "key": "string", + "value": "string" + }, + "disableKeyEdits": true + } + ] +} diff --git a/modules/quiz/events/botReady.js b/modules/quiz/events/botReady.js new file mode 100644 index 00000000..8ae05dba --- /dev/null +++ b/modules/quiz/events/botReady.js @@ -0,0 +1,28 @@ +const {updateMessage, updateLeaderboard} = require('../quizUtil'); +const {scheduleJob} = require('node-schedule'); + +module.exports.run = async (client) => { + const quizList = await client.models['quiz']['QuizList'].findAll(); + quizList.forEach(quiz => { + if (!quiz.private && quiz.expiresAt && new Date(quiz.expiresAt).getTime() > new Date().getTime()) scheduleJob(new Date(quiz.expiresAt), async () => { + await updateMessage(await client.channels.fetch(quiz.channelID), quiz, quiz.messageID); + }); + }); + + if (client.configurations['quiz']['config'].leaderboardChannel) { + await updateLeaderboard(client, true); + const interval = setInterval(() => { + updateLeaderboard(client); + }, 300042); + client.intervals.push(interval); + } + + const job = scheduleJob('1 0 * * *', async () => { // Every day at 00:01 https://crontab.guru/#0_0_*_*_* + const users = await client.models['quiz']['QuizUser'].findAll(); + users.forEach(user => { + user.dailyQuiz = 0; + user.save(); + }); + }); + client.jobs.push(job); +}; diff --git a/modules/quiz/events/interactionCreate.js b/modules/quiz/events/interactionCreate.js new file mode 100644 index 00000000..e9edd39a --- /dev/null +++ b/modules/quiz/events/interactionCreate.js @@ -0,0 +1,96 @@ +const {updateMessage, setChanged} = require('../quizUtil'); +const {localize} = require('../../../src/functions/localize'); + +module.exports.run = async (client, interaction) => { + if (!interaction.message) return; + if (interaction.isButton() && interaction.customId === 'show-quiz-rank') { + const user = await client.models['quiz']['QuizUser'].findOne({ + where: { + userID: interaction.user.id + } + }); + if (user) return interaction.reply({content: localize('quiz', 'your-rank', {xp: user.xp}), ephemeral: true}); + else return interaction.reply({content: '⚠️ ' + localize('quiz', 'no-rank'), ephemeral: true}); + } + + const quiz = await client.models['quiz']['QuizList'].findOne({ + where: { + messageID: interaction.message.id + } + }); + if (!quiz) return; + let expired = false; + if (quiz.expiresAt || quiz.endAt) { + const date = new Date(quiz.expiresAt || quiz.endAt); + if (date.getTime() <= new Date().getTime()) expired = true; + } + + if (interaction.isButton() && interaction.customId === 'quiz-own-vote') { + let userVoteCat = null; + for (const id in quiz.votes) { + if (quiz.votes[id].includes(interaction.user.id)) userVoteCat = id; + } + if (!userVoteCat) return interaction.reply({ + content: '⚠ ' + localize('quiz', 'not-voted-yet'), + ephemeral: true + }); + let extra = ''; + if (!expired) { + if (quiz.canChangeVote) extra = '\n' + localize('quiz', 'change-opinion'); + else extra = '\n' + localize('quiz', 'cannot-change-opinion'); + } else if (quiz.options[userVoteCat - 1].correct) extra = '\n\n' + localize('quiz', 'answer-correct'); + else extra = '\n\n' + localize('quiz', 'answer-wrong'); + return interaction.reply({ + content: localize('quiz', 'you-voted', {o: quiz.options[userVoteCat - 1].text}) + extra, + ephemeral: true + }); + } + if ((interaction.isSelectMenu() && interaction.customId === 'quiz-vote') || (interaction.isButton() && interaction.customId.startsWith('quiz-vote-'))) { + if (quiz.expiresAt && new Date(quiz.expiresAt).getTime() <= new Date().getTime()) return; + + if (quiz.private) { + const user = await interaction.client.models['quiz']['QuizUser'].findAll({ + where: { + userID: interaction.user.id + } + }); + if (user.length === 0) return; + + let extra = localize('quiz', 'answer-wrong'); + if (quiz.options[interaction.isSelectMenu() ? interaction.values[0] : interaction.customId.split('-')[2]].correct) { + extra = localize('quiz', 'answer-correct'); + interaction.client.models['quiz']['QuizUser'].update({dailyXp: user[0].dailyXp + 1, xp: user[0].xp + 1}, {where: {userID: interaction.user.id}}); + setChanged(); + } + + return interaction.update({ + content: localize('quiz', 'you-voted', {o: quiz.options[interaction.isSelectMenu() ? interaction.values[0] : interaction.customId.split('-')[2]].text}) + '\n\n' + extra, + embeds: [], + components: [] + }); + } + + const o = quiz.votes; + quiz.votes = {}; + let back = false; + + for (const id in o) { + if (o[id].includes(interaction.user.id) && !quiz.canChangeVote) { + interaction.reply({content: localize('quiz', 'cannot-change-opinion'), ephemeral: true}); + back = true; + break; + } + if (o[id] && o[id].includes(interaction.user.id)) o[id].splice(o[id].indexOf(interaction.user.id), 1); + } + if (back) return; + o[(parseInt(interaction.isSelectMenu() ? interaction.values[0] : interaction.customId.split('-')[2]) + 1).toString()].push(interaction.user.id); + quiz.votes = o; + quiz.save(); + + updateMessage(interaction.channel, quiz, interaction.message.id); + interaction.reply({ + content: localize('quiz', 'voted-successfully'), + ephemeral: true + }); + } +}; diff --git a/modules/quiz/models/Quiz.js b/modules/quiz/models/Quiz.js new file mode 100644 index 00000000..513e4fe2 --- /dev/null +++ b/modules/quiz/models/Quiz.js @@ -0,0 +1,29 @@ +const {DataTypes, Model} = require('sequelize'); + +module.exports = class QuizList extends Model { + static init(sequelize) { + return super.init({ + messageID: { + type: DataTypes.STRING, + primaryKey: true + }, + description: DataTypes.STRING, + options: DataTypes.JSON, + votes: DataTypes.JSON, // {1: ["userIDHere"], 2: ["as"] } + expiresAt: DataTypes.DATE, + channelID: DataTypes.STRING, + canChangeVote: DataTypes.BOOLEAN, + private: DataTypes.BOOLEAN, + type: DataTypes.STRING // normal, bool + }, { + tableName: 'quiz_Quiz', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + 'name': 'QuizList', + 'module': 'quiz' +}; diff --git a/modules/quiz/models/QuizUser.js b/modules/quiz/models/QuizUser.js new file mode 100644 index 00000000..667c100c --- /dev/null +++ b/modules/quiz/models/QuizUser.js @@ -0,0 +1,37 @@ +const {DataTypes, Model} = require('sequelize'); + +module.exports = class QuizUser extends Model { + static init(sequelize) { + return super.init({ + userID: { + type: DataTypes.STRING, + primaryKey: true + }, + xp: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + dailyXp: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + dailyQuiz: { + type: DataTypes.INTEGER, + defaultValue: 0 + }, + nextQuizID: { + type: DataTypes.INTEGER, + defaultValue: 0 + } + }, { + tableName: 'quiz_users', + timestamps: true, + sequelize + }); + } +}; + +module.exports.config = { + 'name': 'QuizUser', + 'module': 'quiz' +}; diff --git a/modules/quiz/module.json b/modules/quiz/module.json new file mode 100644 index 00000000..f6d65550 --- /dev/null +++ b/modules/quiz/module.json @@ -0,0 +1,28 @@ +{ + "name": "quiz", + "humanReadableName": { + "en": "Quiz Module", + "de": "Quiz-Modul" + }, + "author": { + "scnxOrgID": "60", + "name": "TomatoCake", + "link": "https://github.com/DEVTomatoCake" + }, + "description": { + "en": "Create quiz for your users and let them compete against each other.", + "de": "Erstelle Quiz für deine Nutzer und lasse sie gegeneinander antreten." + }, + "events-dir": "/events", + "commands-dir": "/commands", + "models-dir": "/models", + "config-example-files": [ + "configs/config.json", + "configs/strings.json", + "configs/quizList.json" + ], + "tags": [ + "community" + ], + "openSourceURL": "https://github.com/DEVTomatoCake/ScootKit-CustomBot/tree/main/modules/quiz" +} diff --git a/modules/quiz/quizUtil.js b/modules/quiz/quizUtil.js new file mode 100644 index 00000000..2241f090 --- /dev/null +++ b/modules/quiz/quizUtil.js @@ -0,0 +1,208 @@ +/** + * Create and manage quiz + * @module quiz + */ +const {scheduleJob} = require('node-schedule'); +const {MessageEmbed} = require('discord.js'); +const {renderProgressbar, formatDate} = require('../../src/functions/helpers'); +const {localize} = require('../../src/functions/localize'); + +let changed = false; + +/** + * Sets the changed variable to true + */ +function setChanged() { + changed = true; +} + +/** + * Creates a new quiz + * @param {Object} data Data of the new quiz + * @param {Client} client Client + * @param {Discord.ApplicationCommandInteraction} interaction? Interaction if private + * @return {Promise} + */ +async function createQuiz(data, client, interaction) { + const votes = {}; + for (const vid in data.options) { + votes[parseInt(vid) + 1] = []; + } + data.votes = votes; + const id = await updateMessage(data.channel, data, null, data.private ? interaction : null); + + await client.models['quiz']['QuizList'].create({ + messageID: id, + description: data.description, + options: data.options, + channelID: data.channel.id, + expiresAt: data.endAt, + votes, + canChangeVote: data.canChangeVote, + private: data.private || false, + type: data.type + }); + + if (!data.private && data.endAt) { + client.jobs.push(scheduleJob(data.endAt, async () => { + await updateMessage(data.channel, await client.models['quiz']['QuizList'].findOne({where: {messageID: id}}), id); + })); + } +} + +/** + * Updates a quiz-message + * @param {TextChannel} channel Channel in which the message is + * @param {Object} data Data-Object (can be DB-Object) + * @param {String} mID ID of already sent message + * @param {Discord.ApplicationCommandInteraction} interaction? Interaction if private + * @return {Promise<*>} + */ +async function updateMessage(channel, data, mID = null, interaction = null) { + const strings = channel.client.configurations['quiz']['strings']; + const config = channel.client.configurations['quiz']['config']; + let emojis = config.emojis; + if (data.type === 'bool') emojis = [null, emojis.true, emojis.false]; + + let m; + if (mID && !interaction) m = await channel.messages.fetch(mID).catch(() => {}); + const embed = new MessageEmbed() + .setTitle(strings.embed.title) + .setColor(strings.embed.color) + .setDescription(data.description); + + let allVotes = 0; + const expired = (data.expiresAt || data.endAt) ? data.expiresAt <= Date.now() || data.endAt <= Date.now() : false; + for (const vid in data.votes) { + allVotes = allVotes + data.votes[vid].length; + if (expired) { + if (data.options[parseInt(vid) - 1].correct) data.votes[vid].forEach(async voter => { + const user = await channel.client.models['quiz']['QuizUser'].findAll({ + where: { + userID: voter + } + }); + if (user.length > 0) channel.client.models['quiz']['QuizUser'].update({dailyXp: user[0].dailyXp + 1, xp: user[0].xp + 1}, {where: {userID: voter}}); + else channel.client.models['quiz']['QuizUser'].create({userID: voter, dailyXp: 1, xp: 1}); + changed = true; + }); + } + } + + let s = ''; + let p = ''; + for (const id in data.options) { + const highlight = expired && data.options[id].correct ? '**' : ''; + const finishhighlight = data.options[id].correct ? '✅' : '❌'; + const percentage = 100 / allVotes * data.votes[(parseInt(id) + 1).toString()].length; + + s = s + highlight + (expired ? finishhighlight : '') + emojis[parseInt(id) + 1] + ': ' + data.options[id].text + + ((config.livePreview || expired) && !data.private ? ' `' + data.votes[(parseInt(id) + 1).toString()].length + '`' : '') + highlight + '\n'; + p = p + highlight + emojis[parseInt(id) + 1] + ' ' + renderProgressbar(percentage) + ' ' + (percentage ? percentage.toFixed(0) : '0') + + '% (' + data.votes[(parseInt(id) + 1).toString()].length + '/' + allVotes + ')' + highlight + '\n'; + } + embed.addField(strings.embed.options, s); + if ((config.livePreview || expired) && !data.private) embed.addField(strings.embed.liveView, p); + + const options = []; + for (const vId in data.options) { + options.push({ + label: data.options[vId].text, + value: vId, + description: localize('quiz', 'vote-this'), + emoji: emojis[parseInt(vId) + 1] + }); + } + if (data.expiresAt || data.endAt) { + const date = new Date(data.expiresAt || data.endAt); + if (date.getTime() <= Date.now()) { + embed.setColor(strings.embed.endedQuizColor); + embed.setTitle(strings.embed.endedQuizTitle); + embed.addField('\u200b', localize('quiz', 'correct-highlighted')); + } else { + embed.addField('\u200b', '\u200b'); + embed.addField(strings.embed.expiresOn, strings.embed.thisQuizExpiresOn.split('%date%').join(formatDate(date))); + } + } + + const components = []; + /* eslint-disable camelcase */ + if (data.type === 'bool') components.push({type: 'ACTION_ROW', components: [ + {type: 'BUTTON', customId: 'quiz-vote-0', label: localize('quiz', 'bool-true'), style: 'SUCCESS', disabled: expired}, + {type: 'BUTTON', customId: 'quiz-vote-1', label: localize('quiz', 'bool-false'), style: 'DANGER', disabled: expired} + ]}); + else components.push({type: 'ACTION_ROW', components: [{type: 'SELECT_MENU', disabled: expired, customId: 'quiz-vote', min_values: 1, max_values: 1, placeholder: localize('quiz', 'vote'), options}]}); + if (!data.private) components.push({type: 'ACTION_ROW', components: [{type: 'BUTTON', customId: 'quiz-own-vote', label: localize('quiz', 'what-have-i-voted'), style: 'SECONDARY'}]}); + + let r; + if (data.private && interaction) r = await interaction.reply({embeds: [embed], components, fetchReply: true, ephemeral: true}); + else if (m) r = await m.edit({embeds: [embed], components}); + else r = await channel.send({embeds: [embed], components}); + return r.id; +} + +/** + * Updates the quiz leaderboard + * @param {Client} client Client + * @param {Boolean} force If enabled the embed will update even if there was no registered change + * @return {Promise} + */ +async function updateLeaderboard(client, force = false) { + if (!client.configurations['quiz']['config'].leaderboardChannel) return; + if (!force && !changed) return; + const moduleStrings = client.configurations['quiz']['strings']; + const channel = await client.channels.fetch(client.configurations['quiz']['config']['leaderboardChannel']).catch(() => { + }); + if (!channel || channel.type !== 'GUILD_TEXT') return client.logger.error('[quiz] ' + localize('quiz', 'leaderboard-channel-not-found')); + const messages = (await channel.messages.fetch()).filter(msg => msg.author.id === client.user.id); + + const users = await client.models['quiz']['QuizUser'].findAll({ + order: [ + ['xp', 'DESC'] + ], + limit: 15 + }); + + let leaderboardString = ''; + let i = 0; + for (const user of users) { + const member = channel.guild.members.cache.get(user.userID); + if (!member) continue; + i++; + leaderboardString = leaderboardString + localize('quiz', 'leaderboard-notation', { + p: i, + u: member.user.toString(), + xp: user.xp + }) + '\n'; + } + if (leaderboardString.length === 0) leaderboardString = localize('levels', 'no-user-on-leaderboard'); + + const embed = new MessageEmbed() + .setTitle(moduleStrings.embed.leaderboardTitle) + .setColor(moduleStrings.embed.leaderboardColor) + .setFooter({text: client.strings.footer, iconURL: client.strings.footerImgUrl}) + .setThumbnail(channel.guild.iconURL()) + .addField(moduleStrings.embed.leaderboardSubtitle, leaderboardString); + + if (!client.strings.disableFooterTimestamp) embed.setTimestamp(); + + const components = [{ + type: 'ACTION_ROW', + components: [{ + type: 'BUTTON', + label: moduleStrings.embed.leaderboardButton, + style: 'SUCCESS', + customId: 'show-quiz-rank' + }] + }]; + + if (messages.first()) await messages.first().edit({embeds: [embed], components}); + else await channel.send({embeds: [embed], components}); +} + +module.exports = { + setChanged, + createQuiz, + updateMessage, + updateLeaderboard +};