feat: locales #27
14 changed files with 266 additions and 25 deletions
|
@ -20,7 +20,7 @@
|
||||||
"curly": ["error", "multi-line", "consistent"],
|
"curly": ["error", "multi-line", "consistent"],
|
||||||
"dot-location": ["error", "property"],
|
"dot-location": ["error", "property"],
|
||||||
"handle-callback-err": "off",
|
"handle-callback-err": "off",
|
||||||
"indent": ["error", "tab"],
|
"indent": ["error", "tab", { "SwitchCase": 1 }],
|
||||||
"keyword-spacing": "error",
|
"keyword-spacing": "error",
|
||||||
"max-nested-callbacks": ["error", { "max": 4 }],
|
"max-nested-callbacks": ["error", { "max": 4 }],
|
||||||
"max-statements-per-line": ["error", { "max": 2 }],
|
"max-statements-per-line": ["error", { "max": 2 }],
|
||||||
|
|
|
@ -27,10 +27,15 @@ services:
|
||||||
build: https://git.kennel.ml/ConfrerieDuKassoulait/Botanique.git#main
|
build: https://git.kennel.ml/ConfrerieDuKassoulait/Botanique.git#main
|
||||||
container_name: Botanique
|
container_name: Botanique
|
||||||
environment:
|
environment:
|
||||||
- TOKEN_DISCORD=your-token-goes-here
|
- TOKEN_DISCORD=ton-token-va-ici
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Variables d'environnements
|
||||||
|
| Nom | Commentaire | Valeur par défaut
|
||||||
|
| :-----------: | :-----------: | :-:
|
||||||
|
| TOKEN_DISCORD | Token Discord | Aucune
|
||||||
|
|
||||||
---
|
---
|
||||||
### Références
|
### Références
|
||||||
[Photo de profil](https://picrew.me/image_maker/1497656)
|
[Photo de profil](https://picrew.me/image_maker/1497656)
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"description": "Bot discord",
|
"description": "Bot discord",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"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",
|
"debug": "npx tsnd --respawn ./src/index.ts",
|
||||||
"lint": "npx eslint src"
|
"lint": "npx eslint src"
|
||||||
},
|
},
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Routes } from 'discord-api-types/v9';
|
||||||
import { Client } from 'discord.js';
|
import { Client } from 'discord.js';
|
||||||
import { readdir } from 'fs/promises';
|
import { readdir } from 'fs/promises';
|
||||||
|
|
||||||
/** Load all the commands */
|
/** Load all the commands. */
|
||||||
export default async (client: Client) => {
|
export default async (client: Client) => {
|
||||||
const rest = new REST({ version: '9' }).setToken(client.token ?? '');
|
const rest = new REST({ version: '9' }).setToken(client.token ?? '');
|
||||||
|
|
||||||
|
@ -25,9 +25,9 @@ export default async (client: Client) => {
|
||||||
).default;
|
).default;
|
||||||
|
|
||||||
// Add it to the collection so the interaction will work
|
// 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();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,17 +1,26 @@
|
||||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||||
import { Client, CommandInteraction, Message } from 'discord.js';
|
import { Client, CommandInteraction, Message } from 'discord.js';
|
||||||
|
import { getLocale, getLocalizations } from '../../utils/locales';
|
||||||
|
import { getFilename } from '../../utils/misc';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data: new SlashCommandBuilder()
|
data: (client: Client) => {
|
||||||
.setName('ping')
|
const filename = getFilename(__filename);
|
||||||
.setDescription('Pong!'),
|
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) => {
|
interaction: async (interaction: CommandInteraction, client: Client) => {
|
||||||
|
const loc = getLocale(client, interaction.locale);
|
||||||
|
|
||||||
const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true }) as Message;
|
const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true }) as Message;
|
||||||
|
|
||||||
interaction.editReply(
|
interaction.editReply(
|
||||||
`Roundtrip latency: \
|
`${loc?.get('c_ping1')}: \
|
||||||
${sent.createdTimestamp - interaction.createdTimestamp}ms
|
${sent.createdTimestamp - interaction.createdTimestamp}ms
|
||||||
Websocket heartbeat: ${client.ws.ping}ms.`);
|
${loc?.get('c_ping2')}: ${client.ws.ping}ms.`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import { Client, Interaction } from 'discord.js';
|
import { Client, Interaction } from 'discord.js';
|
||||||
|
import { getLocale } from '../../utils/locales';
|
||||||
|
|
||||||
/** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-interactionCreate */
|
/** https://discord.js.org/#/docs/discord.js/main/class/Client?scrollTo=e-interactionCreate */
|
||||||
export default (interaction: Interaction, client: Client) => {
|
export default (interaction: Interaction, client: Client) => {
|
||||||
if (interaction.isCommand()) {
|
if (interaction.isCommand()) {
|
||||||
const command = client.commands.get(interaction.commandName);
|
const command = client.commands.get(interaction.commandName);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
|
const loc = getLocale(client, interaction.locale);
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Sorry, the command probably no longer exists...',
|
content: loc.get('e_interacreate_no_command'),
|
||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Client } from 'discord.js';
|
import { Client } from 'discord.js';
|
||||||
import { readdir } from 'fs/promises';
|
import { readdir } from 'fs/promises';
|
||||||
|
|
||||||
/** Load all the events */
|
/** Load all the events. */
|
||||||
export default async (client: Client) => {
|
export default async (client: Client) => {
|
||||||
const events_categories = (await readdir(__dirname))
|
const events_categories = (await readdir(__dirname))
|
||||||
.filter(element => !element.endsWith('.js') && !element.endsWith('.ts'));
|
.filter(element => !element.endsWith('.js') && !element.endsWith('.ts'));
|
||||||
|
|
|
@ -4,7 +4,7 @@ import loadCommands from './commands/loader';
|
||||||
|
|
||||||
import { logStart } from './utils/misc';
|
import { logStart } from './utils/misc';
|
||||||
|
|
||||||
/** Run the bot */
|
/** Run the bot. */
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
console.log('Starting Botanique...');
|
console.log('Starting Botanique...');
|
||||||
|
|
||||||
|
|
7
src/locales/en-US.json
Normal file
7
src/locales/en-US.json
Normal file
|
@ -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"
|
||||||
|
}
|
6
src/locales/fr.json
Normal file
6
src/locales/fr.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"e_interacreate_no_command": "Désolé, la commande n'existe plus...",
|
||||||
|
|
||||||
|
"c_ping1": "Latence totale",
|
||||||
|
"c_ping2": "Latence du Websocket"
|
||||||
|
}
|
|
@ -1,27 +1,34 @@
|
||||||
import { Client, Collection, Intents } from 'discord.js';
|
import { Client, Collection, Intents } from 'discord.js';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { SlashCommandBuilder } from '@discordjs/builders';
|
import { SlashCommandBuilder } from '@discordjs/builders';
|
||||||
|
import { loadLocales } from './locales';
|
||||||
const { version } = JSON.parse(readFileSync('./package.json').toString());
|
|
||||||
|
|
||||||
declare module 'discord.js' {
|
declare module 'discord.js' {
|
||||||
// eslint-disable-next-line no-shadow
|
// eslint-disable-next-line no-shadow
|
||||||
export interface Client {
|
export interface Client {
|
||||||
|
/** Store the configuration */
|
||||||
config: {
|
config: {
|
||||||
|
/** Bot version */
|
||||||
version: string,
|
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<
|
commands: Collection<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
data: SlashCommandBuilder,
|
data: SlashCommandBuilder,
|
||||||
interaction: (interaction: CommandInteraction, client: Client) => unknown
|
interaction: (interaction: CommandInteraction, client: Client) => unknown
|
||||||
}
|
}
|
||||||
>
|
>,
|
||||||
|
/** Store all the localizations */
|
||||||
|
locales: Map<string, Map<string, string>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creation of the client and definition of its properties */
|
/** Creation of the client and definition of its properties. */
|
||||||
export default async () => {
|
export default async () => {
|
||||||
const client: Client = new Client({
|
const client: Client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
|
@ -29,14 +36,16 @@ export default async () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the client configuration
|
|
||||||
client.config = {
|
client.config = {
|
||||||
version: version,
|
version: JSON.parse(readFileSync('./package.json').toString()).version,
|
||||||
token_discord: process.env.TOKEN_DISCORD,
|
token_discord: process.env.TOKEN_DISCORD,
|
||||||
|
default_lang: 'en-US',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store the commands available
|
|
||||||
client.commands = new Collection();
|
client.commands = new Collection();
|
||||||
|
|
||||||
|
console.log('Translations progression :');
|
||||||
|
client.locales = await loadLocales(client.config.default_lang);
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
};
|
};
|
||||||
|
|
170
src/utils/locales.ts
Normal file
170
src/utils/locales.ts
Normal file
|
@ -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<string, Map<string, string>>();
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
// 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<string, Map<string, string>>, default_lang: string) => {
|
||||||
|
// Associate each lang with the number of locale it has
|
||||||
|
let locales_size = new Map<string, number>();
|
||||||
|
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}%`);
|
||||||
|
});
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Log module status
|
* Log module status.
|
||||||
* @param {string} name Module name
|
* @param {string} name Module name
|
||||||
* @param {boolean} status Module status
|
* @param {boolean} status Module status
|
||||||
* @returns String
|
* @returns String
|
||||||
|
@ -7,3 +7,32 @@
|
||||||
export const logStart = (name: string, status: boolean) => {
|
export const logStart = (name: string, status: boolean) => {
|
||||||
return `> ${name} ${status === true ? '✅' : '❌'}`;
|
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('.');
|
||||||
|
};
|
||||||
|
|
|
@ -35,7 +35,7 @@
|
||||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
// "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 '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
/* JavaScript Support */
|
/* JavaScript Support */
|
||||||
|
@ -99,5 +99,9 @@
|
||||||
/* Completeness */
|
/* Completeness */
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
}
|
},
|
||||||
|
"include": [
|
||||||
|
"./**/*.ts",
|
||||||
|
"./src/locales/*.json"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue