diff --git a/.eslintrc.json b/.eslintrc.json index 826fc3e..e4ec140 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,7 +20,7 @@ "curly": ["error", "multi-line", "consistent"], "dot-location": ["error", "property"], "handle-callback-err": "off", - "indent": ["error", "tab"], + "indent": ["error", "tab", { "SwitchCase": 1 }], "keyword-spacing": "error", "max-nested-callbacks": ["error", { "max": 4 }], "max-statements-per-line": ["error", { "max": 2 }], diff --git a/README.md b/README.md index 519acb2..8eed6c6 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,15 @@ services: build: https://git.kennel.ml/ConfrerieDuKassoulait/Botanique.git#main container_name: Botanique environment: - - TOKEN_DISCORD=your-token-goes-here + - TOKEN_DISCORD=ton-token-va-ici restart: unless-stopped ``` +## Variables d'environnements +| Nom | Commentaire | Valeur par défaut +| :-----------: | :-----------: | :-: +| TOKEN_DISCORD | Token Discord | Aucune + --- ### Références [Photo de profil](https://picrew.me/image_maker/1497656) diff --git a/package.json b/package.json index b233f6c..597731b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Bot discord", "main": "src/index.js", "scripts": { - "main": "npx tsc && node ./dist/index.js", + "main": "rm -r dist 2> /dev/null; npx tsc && node ./dist/index.js", "debug": "npx tsnd --respawn ./src/index.ts", "lint": "npx eslint src" }, diff --git a/src/commands/loader.ts b/src/commands/loader.ts index 9dd3e24..c606616 100644 --- a/src/commands/loader.ts +++ b/src/commands/loader.ts @@ -3,7 +3,7 @@ import { Routes } from 'discord-api-types/v9'; import { Client } from 'discord.js'; import { readdir } from 'fs/promises'; -/** Load all the commands */ +/** Load all the commands. */ export default async (client: Client) => { const rest = new REST({ version: '9' }).setToken(client.token ?? ''); @@ -25,9 +25,9 @@ export default async (client: Client) => { ).default; // Add it to the collection so the interaction will work - client.commands.set(command.data.name, command); + client.commands.set(command.data(client).name, command); - return command.data.toJSON(); + return command.data(client).toJSON(); }), ); }), diff --git a/src/commands/misc/ping.ts b/src/commands/misc/ping.ts index b121102..26b586e 100644 --- a/src/commands/misc/ping.ts +++ b/src/commands/misc/ping.ts @@ -1,17 +1,26 @@ import { SlashCommandBuilder } from '@discordjs/builders'; import { Client, CommandInteraction, Message } from 'discord.js'; +import { getLocale, getLocalizations } from '../../utils/locales'; +import { getFilename } from '../../utils/misc'; export default { - data: new SlashCommandBuilder() - .setName('ping') - .setDescription('Pong!'), + data: (client: Client) => { + const filename = getFilename(__filename); + return new SlashCommandBuilder() + .setName(filename.toLowerCase()) + .setDescription(client.locales.get(client.config.default_lang)?.get(`c_${filename}_desc`) ?? '?') + .setNameLocalizations(getLocalizations(client, `c_${filename}_name`)) + .setDescriptionLocalizations(getLocalizations(client, `c_${filename}_desc`)); + }, interaction: async (interaction: CommandInteraction, client: Client) => { + const loc = getLocale(client, interaction.locale); + const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true }) as Message; interaction.editReply( - `Roundtrip latency: \ + `${loc?.get('c_ping1')}: \ ${sent.createdTimestamp - interaction.createdTimestamp}ms -Websocket heartbeat: ${client.ws.ping}ms.`); +${loc?.get('c_ping2')}: ${client.ws.ping}ms.`); }, }; diff --git a/src/events/interactions/interactionCreate.ts b/src/events/interactions/interactionCreate.ts index 70caa33..525a29a 100644 --- a/src/events/interactions/interactionCreate.ts +++ b/src/events/interactions/interactionCreate.ts @@ -1,12 +1,14 @@ import { Client, Interaction } from 'discord.js'; +import { getLocale } from '../../utils/locales'; /** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-interactionCreate */ export default (interaction: Interaction, client: Client) => { if (interaction.isCommand()) { const command = client.commands.get(interaction.commandName); if (!command) { + const loc = getLocale(client, interaction.locale); return interaction.reply({ - content: 'Sorry, the command probably no longer exists...', + content: loc.get('e_interacreate_no_command'), ephemeral: true, }); } diff --git a/src/events/loader.ts b/src/events/loader.ts index 15f2da0..b33cb65 100644 --- a/src/events/loader.ts +++ b/src/events/loader.ts @@ -1,7 +1,7 @@ import { Client } from 'discord.js'; import { readdir } from 'fs/promises'; -/** Load all the events */ +/** Load all the events. */ export default async (client: Client) => { const events_categories = (await readdir(__dirname)) .filter(element => !element.endsWith('.js') && !element.endsWith('.ts')); diff --git a/src/index.ts b/src/index.ts index 1457e74..7b85203 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import loadCommands from './commands/loader'; import { logStart } from './utils/misc'; -/** Run the bot */ +/** Run the bot. */ const run = async () => { console.log('Starting Botanique...'); diff --git a/src/locales/en-US.json b/src/locales/en-US.json new file mode 100644 index 0000000..fe704bf --- /dev/null +++ b/src/locales/en-US.json @@ -0,0 +1,7 @@ +{ + "e_interacreate_no_command": "Sorry, the command probably no longer exists...", + + "c_ping_desc": "Pong!", + "c_ping1": "Roundtrip latency", + "c_ping2": "Websocket heartbeat" +} diff --git a/src/locales/fr.json b/src/locales/fr.json new file mode 100644 index 0000000..380b6f5 --- /dev/null +++ b/src/locales/fr.json @@ -0,0 +1,6 @@ +{ + "e_interacreate_no_command": "Désolé, la commande n'existe plus...", + + "c_ping1": "Latence totale", + "c_ping2": "Latence du Websocket" +} diff --git a/src/utils/client.ts b/src/utils/client.ts index 9a60300..4ec27d7 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -1,27 +1,34 @@ import { Client, Collection, Intents } from 'discord.js'; import { readFileSync } from 'fs'; import { SlashCommandBuilder } from '@discordjs/builders'; - -const { version } = JSON.parse(readFileSync('./package.json').toString()); +import { loadLocales } from './locales'; declare module 'discord.js' { // eslint-disable-next-line no-shadow export interface Client { + /** Store the configuration */ config: { + /** Bot version */ version: string, - token_discord: string | undefined + /** Bot token from env variable */ + token_discord: string | undefined, + /** Default lang used */ + default_lang: string }, + /** Store all the slash commands */ commands: Collection< string, { data: SlashCommandBuilder, interaction: (interaction: CommandInteraction, client: Client) => unknown } - > + >, + /** Store all the localizations */ + locales: Map> } } -/** Creation of the client and definition of its properties */ +/** Creation of the client and definition of its properties. */ export default async () => { const client: Client = new Client({ intents: [ @@ -29,14 +36,16 @@ export default async () => { ], }); - // Store the client configuration client.config = { - version: version, + version: JSON.parse(readFileSync('./package.json').toString()).version, token_discord: process.env.TOKEN_DISCORD, + default_lang: 'en-US', }; - // Store the commands available client.commands = new Collection(); + console.log('Translations progression :'); + client.locales = await loadLocales(client.config.default_lang); + return client; }; diff --git a/src/utils/locales.ts b/src/utils/locales.ts new file mode 100644 index 0000000..c82f291 --- /dev/null +++ b/src/utils/locales.ts @@ -0,0 +1,170 @@ +import { Client } from 'discord.js'; +import { readdir } from 'fs/promises'; +import { removeExtension } from './misc'; + +/** + * Load the localizations files into memory. + * + * Show percentage of translations. + * @param default_lang default lang + * @returns Map of map with all the localizations + */ +export const loadLocales = async (default_lang: string) => { + // Get files from locales/ directory + const old_path = __dirname.split('/'); + old_path.pop(); + const files = await readdir(`${old_path.join('/')}/locales`); + + // Read JSON files content and load it into the memory + const locales = new Map>(); + await Promise.all( + files.map(async lang => { + // Import file + const content: { + [key: string]: string + } = await import( + `../locales/${lang}` + ); + + // Add it to the memory + locales.set( + removeExtension(lang), + new Map(Object.keys(content) + // Ignore the default key + .filter(str => str !== 'default') + .map(str => { + return [str, content[str]]; + }), + ) + ); + }) + ); + + // Check locales sanity + checkLocales(locales, default_lang); + + return locales; +}; + +/** + * Builds a dictionary, if a translation is not available, + * we fallback to default lang. + * @param client Client + * @param text Name of string to fetch + * @returns the dictionary + */ +export const getLocalizations = (client: Client, text: string) => { + const data: Record = {}; + + // Load all the localizations + client.locales.forEach((locale, lang) => { + // Fetch the text and fallback to default lang if needed + // See getLocale for more info on why we *can* fallback + const str = locale.get(text) + ?? client.locales.get(client.config.default_lang)?.get(text); + + // Store it if defined + if (str !== undefined) { + data[lang] = str; + } + }); + + return data; +}; + +/** + * Return the locale data for a lang, + * fallback to default language when a string isn't available. + * @param client Client + * @param lang Lang to fetch + * @returns the map with the desired languaged clogged with the default one + */ +export const getLocale = (client: Client, lang: string) => { + // Load default lang + const default_locales = client.locales.get(client.config.default_lang); + // Load desired lang + const desired_locales = client.locales.get(lang); + + // Get text and fallback to default lang if needed + // + // We can fallback to the default one without any problem + // because we make sure that the default language always contains + // the desired text, and that the other languages are only translations + const locales = new Map(); + default_locales?.forEach((_, key) => { + locales.set(key, desired_locales?.get(key) ?? default_locales.get(key)); + }); + + return locales; +}; + +/** + * Show percentage of translation progression. + * + * Raise an error if the default lang isn't + * the lang with most text. + * @param locales Locales loaded + * @param default_lang default lang + * @returns void + */ +const checkLocales = +async (locales: Map>, default_lang: string) => { + // Associate each lang with the number of locale it has + let locales_size = new Map(); + locales.forEach((locales_data, lang) => { + locales_size.set(lang, locales_data.size); + }); + + // Sort the map + locales_size = new Map([...locales_size.entries()] + .sort((a, b) => b[1] - a[1])); + + // Check if default lang is 100% + const [max_size_name] = locales_size.keys(); + const [max_size] = locales_size.values(); + const default_lang_size = locales_size.get(default_lang) ?? 0; + if (max_size > default_lang_size) { + // Throw error because in this case we are sure than the security + // explained in getLocale isn't true. + // However, it is possible that this condition is true + // and the security is poor, but it's better than nothing. + throw new Error( + `The default locale (${default_lang} = ${default_lang_size}) isn't complete ` + + `(${max_size_name} = ${max_size}).` + ); + } + + // Remove the default language as it is used as a reference + locales_size.delete(default_lang); + + // Displays the percentages according to the default language + // lower is bigger + const bar_size = 4; + locales_size.forEach((size, lang) => { + const percentage = (size / max_size) * 100; + // Colored bar part + const blocks = ' '.repeat(percentage / bar_size); + // Blank bar part + const blank = ' '.repeat((100 - percentage) / bar_size); + const color = () => { + switch (true) { + case percentage <= 25: + // Red + return '\x1b[41m'; + case percentage <= 50: + // Mangeta + return '\x1b[45m'; + case percentage <= 75: + // Cyan + return '\x1b[46m'; + case percentage <= 100: + // Green + return '\x1b[42m'; + default: + return ''; + } + }; + + console.log(`${lang} | ${color()}${blocks}\x1b[0m${blank} | ${percentage}%`); + }); +}; diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 11eda8a..0f76dd4 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,5 +1,5 @@ /** - * Log module status + * Log module status. * @param {string} name Module name * @param {boolean} status Module status * @returns String @@ -7,3 +7,32 @@ export const logStart = (name: string, status: boolean) => { return `> ${name} ${status === true ? '✅' : '❌'}`; }; + +/** + * Filename without path and extension. + * @param path __filename + * @returns string + */ +export const getFilename = (path: string) => { + const path_list = path.split('/'); + + // Check if filename exist + const filename_with_ext = path_list.pop(); + if (filename_with_ext === undefined) { + throw new Error(`Filename error: don't exist in ${path}`); + } + + return removeExtension(filename_with_ext); +}; + +/** + * Remove extension from a filename. + * @param filename string of the filename with an extension + * @returns string of the filename without an extension + */ +export const removeExtension = (filename: string) => { + const array = filename.split('.'); + array.pop(); + + return array.join('.'); +}; diff --git a/tsconfig.json b/tsconfig.json index be91d7a..cb551e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,7 +35,7 @@ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ + "resolveJsonModule": true, /* Enable importing .json files. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ /* JavaScript Support */ @@ -99,5 +99,9 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "include": [ + "./**/*.ts", + "./src/locales/*.json" + ] }