discord.js-akinator
Version:
A Discord.js v14 module that allows you to create an Akinator command for your discord bot in a matter of seconds.
318 lines (274 loc) • 18.3 kB
JavaScript
const Discord = require("discord.js")
const { Aki } = require("aki-api");
const fs = require("fs");
const translate = require("./translate");
const awaitInput = require("./input");
//helper function to get the user's reply from a button interaction
function getButtonReply(interaction) {
interaction = interaction.customId;
if (interaction === "✅") return "y"; //yes
else if (interaction === "❌") return "n"; //no
else if (interaction === "❓") return "i"; //don't know
else if (interaction === "👍") return "p"; //probably
else if (interaction === "👎") return "pn"; //probably not
else if (interaction === "⏪") return "b"; //back
else if (interaction === "🛑") return "s"; //stop game
else return null;
};
/**
* Akinator Game Options
* @typedef {object} gameOptions
* @prop {string} [options.language="en"] The language of the game. Defaults to `en`.
* @prop {boolean} [options.childMode=false] Whether to use Akinator's Child Mode. Defaults to `false`.
* @prop {boolean} [options.useButtons=true] Whether to use Discord's buttons instead of message input for answering questions. Defaults to `true`.
* @prop {Discord.ColorResolvable} [options.embedColor="Random"] The color of the message embeds. Defaults to `Random`.
* @prop {object} [translationCaching={}] The options for translation caching.
* @prop {boolean} [translationCaching.enabled=true] Whether to cache successful translations in a JSON file to reduce API calls and boost performance. Defaults to `true`.
* @prop {string} [translationCaching.path="./translationCache"] The path to the directory where the translation cache files are stored. Defaults to `./translationCache`.
*
* __Note:__ Paths are relative to the current working directory. (`process.cwd()`)
*/
/**
* Start a game of Akinator.
*
* Simply pass in the Discord `Message` or `CommandInteraction` sent by the user to this function to start the game.
*
* Definitions and explanations of game options can be found [here](https://github.com/WillTDA/Discord.js-akinator#code-examples).
*
* @param {Discord.Message | Discord.CommandInteraction} input The Message or Slash Command sent by the user.
* @param {gameOptions} options The options for the game.
* @returns {Promise<void>} Discord.js Akinator Game
*/
module.exports = async function (input, options) {
//check discord.js version
if (Discord.version.split(".")[0] < 14) return console.log(`Discord.js Akinator Error: Discord.js v14 or later is required.\nPlease check the README for finding a compatible version for Discord.js v${Discord.version.split(".")[0]}\nNeed help? Join our Discord server at 'https://discord.gg/P2g24jp'`);
let inputData = {};
try {
//TODO: Data type validation
//configuring game options if not specified
options.language = options.language || "en";
options.childMode = options.childMode !== undefined ? options.childMode : false;
options.useButtons = options.useButtons !== undefined ? options.useButtons : true;
options.embedColor = Discord.resolveColor(options.embedColor || "Random");
//configuring translation caching options if not specified
options.translationCaching = options.translationCaching || {};
options.translationCaching.enabled = options.translationCaching.enabled !== undefined ? options.translationCaching.enabled : true;
options.translationCaching.path = options.translationCaching.path || "./translationCache";
options.language = options.language.toLowerCase();
//error handling
if (!input) return console.log("Discord.js Akinator Error: Message or CommandInteraction was not provided.\nNeed help? Join our Discord server at 'https://discord.gg/P2g24jp'");
if (!input.client) return console.log("Discord.js Akinator Error: Message or CommandInteration provided was invalid.\nNeed help? Join our Discord server at 'https://discord.gg/P2g24jp'");
if (!input.guild) return console.log("Discord.js Akinator Error: Cannot be used in Direct Messages.\nNeed help? Join our Discord server at 'https://discord.gg/P2g24jp'");
if (!fs.existsSync(`${__dirname}/translations/${options.language}.json`)) return console.log(`Discord.js Akinator Error: Language "${options.language}" cannot be found. Examples: "en", "fr", "es", etc.\nNeed help? Join our Discord server at 'https://discord.gg/P2g24jp'`);
try {
inputData.client = input.client,
inputData.guild = input.guild,
inputData.author = input.author ? input.author : input.user,
inputData.channel = input.channel
} catch {
return console.log("Discord.js Akinator Error: Failed to parse input for use.\nJoin our Discord server for support at 'https://discord.gg/P2g24jp'");
}
//defining for easy use
let usertag = inputData.author.tag;
let avatar = inputData.author.displayAvatarURL({ dynamic: true });
//get translation object for the language
let translations = require(`${__dirname}/translations/${options.language}.json`);
let startingEmbed = {
title: `${translations.startingGame}`,
description: `**${translations.startingGameDesc}**`,
color: options.embedColor,
author: { name: usertag, icon_url: avatar }
}
let startingMessage;
if ((input.commandName !== undefined) && (!input.replied) && (!input.deferred)) { //check if it's a slash command and hasn't been replied or deferred
await input.deferReply();
startingMessage = await input.editReply({ embeds: [startingEmbed] })
} else {
if (input.commandName !== undefined) { //check if it's a slash command
startingMessage = await input.editReply({ embeds: [startingEmbed] })
}
else { startingMessage = await input.channel.send({ embeds: [startingEmbed] }) } //else, the input is a message
}
//starts the game
let aki = new Aki({ region: "en", childMode: options.childMode }); // set to en region, translation is handled later
let akiData = await aki.start();
let notFinished = true;
let hasGuessed = false;
let noResEmbed = {
title: translations.gameEnded,
description: `**${inputData.author.username}, ${translations.gameEndedDesc}**`,
color: options.embedColor,
author: { name: usertag, icon_url: avatar }
}
let akiEmbed = {
title: `${translations.question} ${aki.currentStep + 1}`,
description: `**${translations.progress}: 0%\n${await translate(akiData.question, options.language, options.translationCaching)}**`,
color: options.embedColor,
fields: [],
author: { name: usertag, icon_url: avatar }
}
if (!options.useButtons) {
akiEmbed.footer = { text: translations.stopTip }
akiEmbed.fields.push({ name: translations.pleaseType, value: `**Y** or **${translations.yes}**\n**N** or **${translations.no}**\n**I** or **IDK**\n**P** or **${translations.probably}**\n**PN** or **${translations.probablyNot}**\n**B** or **${translations.back}**` })
}
let akiMessage;
if (input.commandName !== undefined) { //check if it's a slash command
akiMessage = await input.editReply({ embeds: [akiEmbed] })
} else { akiMessage = await startingMessage.edit({ embeds: [akiEmbed] }); } //else, the input is a message
let updatedAkiEmbed = akiMessage.embeds[0];
//repeat while the game is not finished
while (notFinished) {
if (!notFinished) return;
if (aki.guess?.id_base_proposition) { //if the algorithm has guessed the answer
let guessEmbed = {
title: `${await translate(`I'm ${Math.round(aki.progress)}% sure your character is...`, options.language, options.translationCaching)}`,
description: `**${aki.guess.name_proposition}**\n${await translate(aki.guess.description_proposition, options.language, options.translationCaching)}\n\n${translations.isThisYourCharacter} ${!options.useButtons ? `**(Type Y/${translations.yes} or N/${translations.no})**` : ""}`,
color: options.embedColor,
image: { url: aki.guess.photo },
author: { name: usertag, icon_url: avatar },
fields: [
//{ name: translations.ranking, value: `**#${aki.answers[0].ranking}**`, inline: true }, //NO LONGER SUPPORTED
{ name: translations.noOfQuestions, value: `**${aki.currentStep}**`, inline: true }
],
}
await akiMessage.edit({ embeds: [guessEmbed] });
akiMessage.embeds[0] = guessEmbed;
await awaitInput(options.useButtons, inputData, akiMessage, true, translations, options.language, options.translationCaching)
.then(async response => {
if (response === null) {
notFinished = false;
akiMessage.edit({ embeds: [noResEmbed], components: [] })
return;
}
if (options.useButtons !== false) await response.deferUpdate()
let reply = getButtonReply(response) || response
const guessAnswer = reply.toLowerCase();
//if they answered yes
if (guessAnswer == "y" || guessAnswer == translations.yes.toLowerCase()) {
let finishedGameCorrect = {
title: translations.wellPlayed,
description: `**${inputData.author.username}, ${translations.guessedRightOneMoreTime}**`,
color: options.embedColor,
author: { name: usertag, icon_url: avatar },
fields: [
{ name: "Character", value: `**${aki.guess.name_proposition}**`, inline: true },
//{ name: translations.ranking, value: `**#${aki.answers[0].ranking}**`, inline: true }, //NO LONGER SUPPORTED
{ name: translations.noOfQuestions, value: `**${aki.currentStep}**`, inline: true }
]
}
if (options.useButtons) await response.editReply({ embeds: [finishedGameCorrect], components: [] })
else await akiMessage.edit({ embeds: [finishedGameCorrect], components: [] })
notFinished = false;
return;
//otherwise
} else if (guessAnswer == "n" || guessAnswer == translations.no.toLowerCase()) {
if (aki.currentStep >= 78 || hasGuessed == true) {
let finishedGameDefeated = {
title: translations.wellPlayed,
description: `**${inputData.author.username}, ${translations.defeated}**`,
color: options.embedColor,
author: { name: usertag, icon_url: avatar }
}
if (options.useButtons) await response.editReply({ embeds: [finishedGameDefeated], components: [] })
else await akiMessage.edit({ embeds: [finishedGameDefeated], components: [] })
notFinished = false;
} else {
if (options.useButtons) await response.editReply({ embeds: [guessEmbed], components: [] })
else await akiMessage.edit({ embeds: [guessEmbed], components: [] })
hasGuessed = true; // set hasGuessed to true so that the game doesn't keep guessing after the second attempt
aki.progress = 50
aki.continue(); //continue the game after the guess
}
}
});
} else if (!akiData.question) {
let finishedGameDefeated = {
title: translations.wellPlayed,
description: `**${inputData.author.username}, ${translations.defeated}**`,
color: options.embedColor,
author: { name: usertag, icon_url: avatar }
}
if (options.useButtons) await response.editReply({ embeds: [finishedGameDefeated], components: [] })
else await akiMessage.edit({ embeds: [finishedGameDefeated], components: [] })
notFinished = false; //end the game if the algorithm can't guess and there are no questions found
}
if (!notFinished) return;
if (updatedAkiEmbed !== akiMessage.embeds[0]) {
updatedAkiEmbed = {
title: `${translations.question} ${aki.currentStep + 1}`,
description: `**${translations.progress}: ${Math.round(aki.progress)}%\n${await translate(aki.question, options.language, options.translationCaching)}**`,
color: options.embedColor,
fields: [],
author: { name: usertag, icon_url: avatar }
}
if (!options.useButtons) {
updatedAkiEmbed.footer = { text: translations.stopTip }
updatedAkiEmbed.fields.push({ name: translations.pleaseType, value: `**Y** or **${translations.yes}**\n**N** or **${translations.no}**\n**I** or **IDK**\n**P** or **${translations.probably}**\n**PN** or **${translations.probablyNot}**\n**B** or **${translations.back}**` })
}
await akiMessage.edit({ embeds: [updatedAkiEmbed] })
akiMessage.embeds[0] = updatedAkiEmbed
}
await awaitInput(options.useButtons, inputData, akiMessage, false, translations, options.language, options.translationCaching)
.then(async response => {
if (response === null) {
notFinished = false;
return akiMessage.edit({ embeds: [noResEmbed], components: [] })
}
if (options.useButtons !== false) await response.deferUpdate()
let reply = getButtonReply(response) || response
const answer = reply.toLowerCase();
//assign points for the possible answers given
const answers = {
"y": 0,
"yes": 0,
"n": 1,
"no": 1,
"i": 2,
"idk": 2,
"dont know": 2,
"don't know": 2,
"i": 2,
"p": 3,
"probably": 3,
"pn": 4,
"probably not": 4,
}
let thinkingEmbed = {
title: `${translations.question} ${aki.currentStep + 1}`,
description: `**${translations.progress}: ${Math.round(aki.progress)}%\n${await translate(akiData.question, options.language, options.translationCaching)}**`,
color: options.embedColor,
fields: [],
author: { name: usertag, icon_url: avatar },
footer: { text: translations.thinking }
}
if (!options.useButtons) thinkingEmbed.fields.push({ name: translations.pleaseType, value: `**Y** or **${translations.yes}**\n**N** or **${translations.no}**\n**I** or **IDK**\n**P** or **${translations.probably}**\n**PN** or **${translations.probablyNot}**\n**B** or **${translations.back}**` })
if (options.useButtons) await response.editReply({ embeds: [thinkingEmbed], components: [] })
else await akiMessage.edit({ embeds: [thinkingEmbed], components: [] })
akiMessage.embeds[0] = thinkingEmbed
if (answer == "b" || answer == translations.back.toLowerCase()) {
if (aki.currentStep >= 1) {
akiData = await aki.back();
}
//stop the game if the user selected to stop
} else if (answer == "s" || answer == translations.stop.toLowerCase()) {
let stopEmbed = {
title: translations.gameEnded,
description: `**${inputData.author.username}, ${translations.gameForceEnd}**`,
color: options.embedColor,
author: { name: usertag, icon_url: avatar }
}
await akiMessage.edit({ embeds: [stopEmbed], components: [] })
notFinished = false;
} else {
let step = await aki.step(answers[answer]);
if (!step.guess?.id_base_proposition) akiData = step;
}
if (!notFinished) return;
});
}
} catch (e) {
//log any errors that come up
console.log("Discord.js Akinator Error:")
console.log(e);
}
};