From 36d9466bd717b83a7b7ec831edef9ada5396225c Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 20 Aug 2020 13:53:50 +0200 Subject: [PATCH] feat(i18n): implement localisation (#197) --- package.json | 2 + src/client.js | 68 ++++- src/commands/argument.js | 111 +++++-- src/commands/base.js | 176 ++++++++--- src/commands/commands/disable.js | 37 ++- src/commands/commands/enable.js | 40 +-- src/commands/commands/groups.js | 22 +- src/commands/commands/load.js | 15 +- src/commands/commands/reload.js | 42 ++- src/commands/commands/unload.js | 28 +- src/commands/util/eval.js | 67 +++- src/commands/util/help.js | 172 ++++++++--- src/commands/util/language.js | 186 +++++++++++ src/commands/util/ping.js | 26 +- src/commands/util/prefix.js | 53 ++-- src/commands/util/unknown-command.js | 16 +- src/errors/command-format.js | 15 +- src/extensions/guild.js | 37 ++- src/extensions/message.js | 121 ++++++-- src/i18n/dev.js | 442 +++++++++++++++++++++++++++ src/providers/sqlite-sync.js | 7 + src/providers/sqlite.js | 7 + src/registry.js | 5 +- src/translator.js | 200 ++++++++++++ src/types/boolean.js | 38 ++- src/types/category-channel.js | 20 +- src/types/channel.js | 18 +- src/types/command.js | 17 +- src/types/custom-emoji.js | 15 +- src/types/float.js | 18 +- src/types/group.js | 17 +- src/types/integer.js | 18 +- src/types/member.js | 17 +- src/types/role.js | 15 +- src/types/string.js | 20 +- src/types/text-channel.js | 20 +- src/types/union.js | 8 +- src/types/user.js | 23 +- src/types/voice-channel.js | 20 +- src/util.js | 63 ++-- typings/index.d.ts | 31 +- 41 files changed, 1889 insertions(+), 384 deletions(-) create mode 100644 src/commands/util/language.js create mode 100644 src/i18n/dev.js create mode 100644 src/translator.js diff --git a/package.json b/package.json index 664562f97..9908be71e 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ }, "peerDependencies": { "@types/better-sqlite3": "^5.0.0", + "i18next": "^19.0.0", + "i18next-fs-backend": "^1.0.0", "better-sqlite3": "^5.0.0", "discord.js": "^12.0.0", "sqlite": "^3.0.0" diff --git a/src/client.js b/src/client.js index f2e46c9e8..4cc3e8ca7 100644 --- a/src/client.js +++ b/src/client.js @@ -1,7 +1,9 @@ const discord = require('discord.js'); const CommandoRegistry = require('./registry'); const CommandDispatcher = require('./dispatcher'); +const { CommandoTranslator } = require('./translator'); const GuildSettingsHelper = require('./providers/helper'); +const i18next = require('i18next'); /** * Discord.js Client with a command framework @@ -12,8 +14,10 @@ class CommandoClient extends discord.Client { * Options for a CommandoClient * @typedef {ClientOptions} CommandoClientOptions * @property {string} [commandPrefix=!] - Default command prefix + * @property {string} [defaultLanguage] - Default language * @property {number} [commandEditableDuration=30] - Time in seconds that command messages should be editable * @property {boolean} [nonCommandEditable=true] - Whether messages without commands can be edited to a command + * @property {CommandoTranslatorOptions} i18n - The configuration for the translator * @property {string|string[]|Set} [owner] - ID of the bot owner's Discord user, or multiple IDs * @property {string} [invite] - Invite URL to the bot's support server */ @@ -24,6 +28,7 @@ class CommandoClient extends discord.Client { constructor(options = {}) { if(typeof options.commandPrefix === 'undefined') options.commandPrefix = '!'; if(options.commandPrefix === null) options.commandPrefix = ''; + if(options.defaultLanguage === null) options.defaultLanguage = CommandoTranslator.DEFAULT_LANGUAGE; if(typeof options.commandEditableDuration === 'undefined') options.commandEditableDuration = 30; if(typeof options.nonCommandEditable === 'undefined') options.nonCommandEditable = true; super(options); @@ -34,6 +39,12 @@ class CommandoClient extends discord.Client { */ this.registry = new CommandoRegistry(this); + /** + * The client's translator + * @type {CommandoTranslator} + */ + this.translator = new CommandoTranslator(this, options.i18n); + /** * The client's command dispatcher * @type {CommandDispatcher} @@ -59,11 +70,24 @@ class CommandoClient extends discord.Client { */ this._commandPrefix = null; + /** + * Internal global language, controlled by the {@link CommandoClient#defaultLanguage} getter/setter + * @type {?string} + * @private + */ + this._defaultLanguage = null; + // Set up command handling - const msgErr = err => { this.emit('error', err); }; - this.on('message', message => { this.dispatcher.handleMessage(message).catch(msgErr); }); + const msgErr = err => { + this.emit('error', err); + }; + this.on('message', message => { + this.dispatcher.handleMessage(message) + .catch(msgErr); + }); this.on('messageUpdate', (oldMessage, newMessage) => { - this.dispatcher.handleMessage(newMessage, oldMessage).catch(msgErr); + this.dispatcher.handleMessage(newMessage, oldMessage) + .catch(msgErr); }); // Fetch the owner(s) @@ -71,16 +95,18 @@ class CommandoClient extends discord.Client { this.once('ready', () => { if(options.owner instanceof Array || options.owner instanceof Set) { for(const owner of options.owner) { - this.users.fetch(owner).catch(err => { - this.emit('warn', `Unable to fetch owner ${owner}.`); - this.emit('error', err); - }); + this.users.fetch(owner) + .catch(err => { + this.emit('warn', `Unable to fetch owner ${owner}.`); + this.emit('error', err); + }); } } else { - this.users.fetch(options.owner).catch(err => { - this.emit('warn', `Unable to fetch owner ${options.owner}.`); - this.emit('error', err); - }); + this.users.fetch(options.owner) + .catch(err => { + this.emit('warn', `Unable to fetch owner ${options.owner}.`); + this.emit('error', err); + }); } }); } @@ -102,6 +128,26 @@ class CommandoClient extends discord.Client { this.emit('commandPrefixChange', null, this._commandPrefix); } + /** + * Global language. + * Setting to `null` means that the default language from {@link CommandoClient#options} will be used instead. + * @type {string} + * @emits {@link CommandoClient#guildLanguageChange} + */ + get defaultLanguage() { + if(typeof this._defaultLanguage === 'undefined' || this._defaultLanguage === null) { + return this.options.defaultLanguage || CommandoTranslator.DEFAULT_LANGUAGE; + } + return this._defaultLanguage; + } + + set defaultLanguage(language) { + this._defaultLanguage = language; + if(i18next.hasResourceBundle(language, 'commando')) { + this.emit('guildLanguageChange', null, this._defaultLanguage); + } + } + /** * Owners of the bot, set by the {@link CommandoClientOptions#owner} option * If you simply need to check if a user is an owner of the bot, please instead use diff --git a/src/commands/argument.js b/src/commands/argument.js index d4ad54e31..fa93c036c 100644 --- a/src/commands/argument.js +++ b/src/commands/argument.js @@ -1,6 +1,7 @@ const { escapeMarkdown } = require('discord.js'); const { oneLine, stripIndents } = require('common-tags'); const ArgumentUnionType = require('../types/union'); +const i18next = require('i18next'); /** A fancy argument */ class Argument { @@ -149,6 +150,7 @@ class Argument { * @param {number} [promptLimit=Infinity] - Maximum number of times to prompt for the argument * @return {Promise} */ + // eslint-disable-next-line complexity async obtain(msg, val, promptLimit = Infinity) { let empty = this.isEmpty(val, msg); if(empty && this.default !== null) { @@ -166,6 +168,10 @@ class Argument { const answers = []; let valid = !empty ? await this.validate(val, msg) : false; + if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) { + valid = i18next.t(valid.key, { lng: msg.client.translator.resolveLanguage(msg) }); + } + while(!valid || typeof valid === 'string') { /* eslint-disable no-await-in-loop */ if(prompts.length >= promptLimit) { @@ -177,12 +183,33 @@ class Argument { }; } + const lng = msg.client.translator.resolveLanguage(msg); + const label = typeof this.label === 'string' || typeof this.label === 'undefined' || !this.label ? + this.label : i18next.t(this.label.key, { + lng, + interpolation: { escapeValue: false } + }); + // Prompt the user for a new value prompts.push(await msg.reply(stripIndents` - ${empty ? this.prompt : valid ? valid : `You provided an invalid ${this.label}. Please try again.`} + ${empty ? typeof this.prompt === 'string' || typeof this.prompt === 'undefined' || !this.prompt ? + this.prompt : i18next.t(this.prompt.key, { + lng, + interpolation: { escapeValue: false } + }) : + valid ? valid : i18next.t('argument.invalid_label', { + lng, + label, + interpolation: { escapeValue: false } + })} ${oneLine` - Respond with \`cancel\` to cancel the command. - ${wait ? `The command will automatically be cancelled in ${this.wait} seconds.` : ''} + ${i18next.t('common.respond_to_cancel', { lng })} + ${wait ? + i18next.t('common.command_will_be_canceled', { + lng, + seconds: this.wait + }) : + ''} `} `)); @@ -206,7 +233,9 @@ class Argument { } // See if they want to cancel - if(val.toLowerCase() === 'cancel') { + if(val.toLowerCase() === i18next.t('common.cancel_command', { + lng + })) { return { value: null, cancelled: 'user', @@ -217,6 +246,9 @@ class Argument { empty = this.isEmpty(val, msg); valid = await this.validate(val, msg); + if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) { + valid = i18next.t(valid.key, { lng }); + } /* eslint-enable no-await-in-loop */ } @@ -249,6 +281,10 @@ class Argument { let valid = val ? await this.validate(val, msg) : false; let attempts = 0; + if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) { + valid = i18next.t(valid.key, { lng: msg.client.translator.resolveLanguage(msg) }); + } + while(!valid || typeof valid === 'string') { attempts++; if(attempts > promptLimit) { @@ -260,26 +296,47 @@ class Argument { }; } + const lng = msg.client.translator.resolveLanguage(msg); + const label = typeof this.label === 'string' || typeof this.label === 'undefined' || !this.label ? + this.label : i18next.t(this.label.key, { + lng, + interpolation: { escapeValue: false } + }); + // Prompt the user for a new value if(val) { - const escaped = escapeMarkdown(val).replace(/@/g, '@\u200b'); + const escaped = escapeMarkdown(val) + .replace(/@/g, '@\u200b'); prompts.push(await msg.reply(stripIndents` - ${valid ? valid : oneLine` - You provided an invalid ${this.label}, - "${escaped.length < 1850 ? escaped : '[too long to show]'}". - Please try again. - `} + ${valid ? valid : + i18next.t('argument.invalid_label', { + lng, + label, + context: 'extended', + escaped: escaped.length < 1850 ? escaped : '[$t(common.too_long_to_show)]', + interpolation: { escapeValue: false } + })} ${oneLine` - Respond with \`cancel\` to cancel the command, or \`finish\` to finish entry up to this point. - ${wait ? `The command will automatically be cancelled in ${this.wait} seconds.` : ''} + ${i18next.t('common.respond_to_cancel_or_finish', { lng })} + ${wait ? + i18next.t('common.command_will_be_canceled', { + lng, + seconds: this.wait + }) : ''} `} `)); } else if(results.length === 0) { prompts.push(await msg.reply(stripIndents` - ${this.prompt} + ${typeof this.prompt === 'string' || typeof this.prompt === 'undefined' || !this.prompt ? + this.prompt : i18next.t(this.prompt.key, + { lng: msg.client.translator.resolveLanguage(msg) })} ${oneLine` - Respond with \`cancel\` to cancel the command, or \`finish\` to finish entry. - ${wait ? `The command will automatically be cancelled in ${this.wait} seconds, unless you respond.` : ''} + ${i18next.t('common.respond_to_cancel_or_finish', { lng })} + ${wait ? + i18next.t('common.command_will_be_canceled', { + lng, + seconds: this.wait + }) : ''} `} `)); } @@ -305,7 +362,9 @@ class Argument { // See if they want to finish or cancel const lc = val.toLowerCase(); - if(lc === 'finish') { + if(lc === i18next.t('common.finish_command', { + lng + })) { return { value: results.length > 0 ? results : null, cancelled: this.default ? null : results.length > 0 ? null : 'user', @@ -313,7 +372,9 @@ class Argument { answers }; } - if(lc === 'cancel') { + if(lc === i18next.t('common.cancel_command', { + lng + })) { return { value: null, cancelled: 'user', @@ -323,6 +384,9 @@ class Argument { } valid = await this.validate(val, msg); + if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) { + valid = i18next.t(valid.key, { lng }); + } } results.push(await this.parse(val, msg)); @@ -349,7 +413,10 @@ class Argument { * @return {boolean|string|Promise} */ validate(val, msg) { - const valid = this.validator ? this.validator(val, msg, this) : this.type.validate(val, msg, this); + let valid = this.validator ? this.validator(val, msg, this) : this.type.validate(val, msg, this); + if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) { + valid = i18next.t(valid.key, { lng: msg.client.translator.resolveLanguage(msg) }); + } if(!valid || typeof valid === 'string') return this.error || valid; if(valid instanceof Promise) return valid.then(vld => !vld || typeof vld === 'string' ? this.error || vld : vld); return valid; @@ -389,8 +456,12 @@ class Argument { if(!client) throw new Error('The argument client must be specified.'); if(typeof info !== 'object') throw new TypeError('Argument info must be an Object.'); if(typeof info.key !== 'string') throw new TypeError('Argument key must be a string.'); - if(info.label && typeof info.label !== 'string') throw new TypeError('Argument label must be a string.'); - if(typeof info.prompt !== 'string') throw new TypeError('Argument prompt must be a string.'); + if(info.label && typeof info.label !== 'string' && typeof info.label !== 'object') { + throw new TypeError('Argument label must be a string, or a CommandoTranslatable.'); + } + if(typeof info.prompt !== 'string' && typeof info.prompt !== 'object') { + throw new TypeError('Argument prompt must be a string, or a CommandoTranslatable.'); + } if(info.error && typeof info.error !== 'string') throw new TypeError('Argument error must be a string.'); if(info.type && typeof info.type !== 'string') throw new TypeError('Argument type must be a string.'); if(info.type && !info.type.includes('|') && !client.registry.types.has(info.type)) { diff --git a/src/commands/base.js b/src/commands/base.js index 822134f33..0e7022702 100644 --- a/src/commands/base.js +++ b/src/commands/base.js @@ -1,8 +1,9 @@ const path = require('path'); const { escapeMarkdown } = require('discord.js'); -const { oneLine, stripIndents } = require('common-tags'); const ArgumentCollector = require('./collector'); const { permissions } = require('../util'); +const i18next = require('i18next'); +const { CommandoTranslator } = require('../translator'); /** A command that can be run in a client */ class Command { @@ -75,7 +76,7 @@ class Command { /** * Aliases for this command - * @type {string[]} + * @type {string[], CommandoTranslatable} */ this.aliases = info.aliases || []; if(typeof info.autoAliases === 'undefined' || info.autoAliases) { @@ -105,25 +106,25 @@ class Command { /** * Short description of the command - * @type {string} + * @type {string, CommandoTranslatable} */ this.description = info.description; /** * Usage format string of the command - * @type {string} + * @type {string, CommandoTranslatable} */ this.format = info.format || null; /** * Long description of the command - * @type {?string} + * @type {?string, ?CommandoTranslatable} */ this.details = info.details || null; /** * Example usage strings - * @type {?string[]} + * @type {?string[], ?CommandoTranslatable} */ this.examples = info.examples || null; @@ -180,7 +181,12 @@ class Command { this.format = this.argsCollector.args.reduce((prev, arg) => { const wrapL = arg.default !== null ? '[' : '<'; const wrapR = arg.default !== null ? ']' : '>'; - return `${prev}${prev ? ' ' : ''}${wrapL}${arg.label}${arg.infinite ? '...' : ''}${wrapR}`; + const label = typeof arg.label === 'undefined' || typeof arg.label === 'string' || !arg.label ? + arg.label : i18next.t(arg.label.key, { + lng: client.defaultLanguage, + interpolation: { escapeValue: false } + }); + return `${prev}${prev ? ' ' : ''}${wrapL}${label}${arg.infinite ? '...' : ''}${wrapR}`; }, ''); } @@ -248,23 +254,35 @@ class Command { * @return {boolean|string} Whether the user has permission, or an error message to respond with if they don't */ hasPermission(message, ownerOverride = true) { + const lng = message.client.translator.resolveLanguage(message); if(!this.ownerOnly && !this.userPermissions) return true; if(ownerOverride && this.client.isOwner(message.author)) return true; if(this.ownerOnly && (ownerOverride || !this.client.isOwner(message.author))) { - return `The \`${this.name}\` command can only be used by the bot owner.`; + return i18next.t('base.owner_only_command', { + lng, + commandName: this.name + }); } if(message.channel.type === 'text' && this.userPermissions) { - const missing = message.channel.permissionsFor(message.author).missing(this.userPermissions); + const missing = message.channel.permissionsFor(message.author) + .missing(this.userPermissions); if(missing.length > 0) { if(missing.length === 1) { - return `The \`${this.name}\` command requires you to have the "${permissions[missing[0]]}" permission.`; + return i18next.t('base.permission_required', { + lng, + commandName: this.name, + permission: `$t(${permissions[missing[0]]})` + }); } - return oneLine` - The \`${this.name}\` command requires you to have the following permissions: - ${missing.map(perm => permissions[perm]).join(', ')} - `; + return i18next.t('base.permission_required', { + lng, + commandName: this.name, + count: missing.length, + permissionList: missing.map(perm => `$t(${permissions[perm]})`) + .join(', ') + }); } } @@ -290,7 +308,7 @@ class Command { /** * Called when the command is prevented from running - * @param {CommandMessage} message - Command message that the command is running from + * @param {CommandoMessage} message - Command message that the command is running from * @param {string} reason - Reason that the command was blocked * (built-in reasons are `guildOnly`, `nsfw`, `permission`, `throttling`, and `clientPermissions`) * @param {Object} [data] - Additional data associated with the block. Built-in reason data properties: @@ -302,30 +320,47 @@ class Command { * @returns {Promise>} */ onBlock(message, reason, data) { + const lng = message.client.translator.resolveLanguage(message); switch(reason) { case 'guildOnly': - return message.reply(`The \`${this.name}\` command must be used in a server channel.`); + return message.reply(i18next.t('base.guild_only_command', { + lng, + commandName: this.name + })); case 'nsfw': - return message.reply(`The \`${this.name}\` command can only be used in NSFW channels.`); + return message.reply(i18next.t('base.nsfw_only_command', { + lng, + commandName: this.name + })); case 'permission': { if(data.response) return message.reply(data.response); - return message.reply(`You do not have permission to use the \`${this.name}\` command.`); + return message.reply(i18next.t('base.missing_permissions', { + lng, + commandName: this.name + })); } case 'clientPermissions': { if(data.missing.length === 1) { - return message.reply( - `I need the "${permissions[data.missing[0]]}" permission for the \`${this.name}\` command to work.` - ); + return message.reply(i18next.t('base.i_need_permission', { + lng, + commandName: this.name, + permission: permissions[data.missing[0]] + })); } - return message.reply(oneLine` - I need the following permissions for the \`${this.name}\` command to work: - ${data.missing.map(perm => permissions[perm]).join(', ')} - `); + return message.reply(i18next.t('base.i_need_permission', { + lng, + commandName: this.name, + permissions: Array.isArray(data.missing) ? data.missing.map(perm => permissions[perm]) + .join(', ') : null, + count: data.missing.length + })); } case 'throttling': { - return message.reply( - `You may not use the \`${this.name}\` command again for another ${data.remaining.toFixed(1)} seconds.` - ); + return message.reply(i18next.t('base.user_ratelimited', { + lng, + commandName: this.name, + count: data.remaining.toFixed(1) + })); } default: return null; @@ -335,7 +370,7 @@ class Command { /** * Called when the command produces an error while running * @param {Error} err - Error that was thrown - * @param {CommandMessage} message - Command message that the command is running from (see {@link Command#run}) + * @param {CommandoMessage} message - Command message that the command is running from (see {@link Command#run}) * @param {Object|string|string[]} args - Arguments for the command (see {@link Command#run}) * @param {boolean} fromPattern - Whether the args are pattern matches (see {@link Command#run}) * @param {?ArgumentCollectorResult} result - Result from obtaining the arguments from the collector @@ -343,18 +378,24 @@ class Command { * @returns {Promise>} */ onError(err, message, args, fromPattern, result) { // eslint-disable-line no-unused-vars + const lng = message.client.translator.resolveLanguage(message); const owners = this.client.owners; - const ownerList = owners ? owners.map((usr, i) => { + const ownerList = Array.isArray(owners) ? owners.map((usr, i) => { const or = i === owners.length - 1 && owners.length > 1 ? 'or ' : ''; return `${or}${escapeMarkdown(usr.username)}#${usr.discriminator}`; - }).join(owners.length > 2 ? ', ' : ' ') : ''; + }) + .join(owners.length > 2 ? ', ' : ' ') : ''; const invite = this.client.options.invite; - return message.reply(stripIndents` - An error occurred while running the command: \`${err.name}: ${err.message}\` - You shouldn't ever receive an error like this. - Please contact ${ownerList || 'the bot owner'}${invite ? ` in this server: ${invite}` : '.'} - `); + return message.reply(i18next.t('base.unknown_error', { + lng, + errorName: err.name, + errorMessage: err.message, + ownerList, + invite, + ownerCount: owners.length, + interpolation: { escapeValue: false } + })); } /** @@ -429,10 +470,12 @@ class Command { * @param {string} [argString] - A string of arguments for the command * @param {string} [prefix=this.client.commandPrefix] - Prefix to use for the prefixed command format * @param {User} [user=this.client.user] - User to use for the mention command format + * @param {string} [language] - language used to translate the usage string * @return {string} */ - usage(argString, prefix = this.client.commandPrefix, user = this.client.user) { - return this.constructor.usage(`${this.name}${argString ? ` ${argString}` : ''}`, prefix, user); + usage(argString, prefix = this.client.commandPrefix, user = this.client.user, + language = this.client.defaultLanguage) { + return this.constructor.usage(`${this.name}${argString ? ` ${argString}` : ''}`, prefix, user, language); } /** @@ -476,9 +519,10 @@ class Command { * @param {string} command - A command + arg string * @param {string} [prefix] - Prefix to use for the prefixed command format * @param {User} [user] - User to use for the mention command format + * @param {string} [language] - language used to translate the usage string * @return {string} */ - static usage(command, prefix = null, user = null) { + static usage(command, prefix = null, user = null, language = CommandoTranslator.DEFAULT_LANGUAGE) { const nbcmd = command.replace(/ /g, '\xa0'); if(!prefix && !user) return `\`\`${nbcmd}\`\``; @@ -492,7 +536,8 @@ class Command { let mentionPart; if(user) mentionPart = `\`\`@${user.username.replace(/ /g, '\xa0')}#${user.discriminator}\xa0${nbcmd}\`\``; - return `${prefixPart || ''}${prefix && user ? ' or ' : ''}${mentionPart || ''}`; + return `${prefixPart || ''}${prefix && user ? ` ${i18next.t('common.or', + { lng: language })} ` : ''}${mentionPart || ''}`; } /** @@ -516,11 +561,18 @@ class Command { if(info.group !== info.group.toLowerCase()) throw new RangeError('Command group must be lowercase.'); if(typeof info.memberName !== 'string') throw new TypeError('Command memberName must be a string.'); if(info.memberName !== info.memberName.toLowerCase()) throw new Error('Command memberName must be lowercase.'); - if(typeof info.description !== 'string') throw new TypeError('Command description must be a string.'); - if('format' in info && typeof info.format !== 'string') throw new TypeError('Command format must be a string.'); - if('details' in info && typeof info.details !== 'string') throw new TypeError('Command details must be a string.'); - if(info.examples && (!Array.isArray(info.examples) || info.examples.some(ex => typeof ex !== 'string'))) { - throw new TypeError('Command examples must be an Array of strings.'); + if(typeof info.description !== 'string' && typeof info.description !== 'object') { + throw new TypeError('Command description must be a string, or a CommandoTranslatable.'); + } + if('format' in info && typeof info.format !== 'string' && typeof info.format !== 'object') { + throw new TypeError('Command format must be a string, or a CommandoTranslatable.'); + } + if('details' in info && typeof info.details !== 'string' && typeof info.details !== 'object') { + throw new TypeError('Command details must be a string, or a CommandoTranslatable.'); + } + if(info.examples && typeof info.examples !== 'object' && (!Array.isArray(info.examples) || + info.examples.some(ex => typeof ex !== 'string'))) { + throw new TypeError('Command examples must be an Array of strings, or a CommandoTranslatable.'); } if(info.clientPermissions) { if(!Array.isArray(info.clientPermissions)) { @@ -566,6 +618,40 @@ class Command { throw new TypeError('Command patterns must be an Array of regular expressions.'); } } + + /** + * Translates the translatable command info parts + * @param {Command} [command] - The command holding the command info + * @param {string} [language] - The language the command info will be translated to + * @param {TOptions} [options] - i18next translate options object + * @return {Partial} + */ + translate(command, language, options) { + if(typeof language === 'undefined') language = this.client.defaultLanguage; + if(typeof options === 'undefined') options = {}; + + return { + description: typeof command.description === 'string' || typeof command.description === 'undefined' || + !command.description ? command.description : + i18next.t(command.description.key, { lng: language, ...options }), + + format: typeof command.format === 'string' || typeof command.format === 'undefined' || !command.format ? + command.format : i18next.t(command.format.key, { + lng: language, + interpolation: { escapeValue: false }, ...options + }), + + details: typeof command.details === 'string' || typeof command.details === 'undefined' || !command.details ? + command.details : i18next.t(command.details.key, { lng: language, ...options }), + + examples: typeof command.examples === 'undefined' || Array.isArray(command.examples) || !command.examples || + typeof command.examples === 'string' ? command.examples : i18next.t(command.examples.key, { + lng: language || this.client.defaultLanguage, + returnObjects: true, + ...options + }) + }; + } } module.exports = Command; diff --git a/src/commands/commands/disable.js b/src/commands/commands/disable.js index 80e1453b6..2fd4d5890 100644 --- a/src/commands/commands/disable.js +++ b/src/commands/commands/disable.js @@ -1,5 +1,6 @@ -const { oneLine } = require('common-tags'); const Command = require('../base'); +const i18next = require('i18next'); +const { CommandoTranslatable } = require('../../translator'); module.exports = class DisableCommandCommand extends Command { constructor(client) { @@ -8,19 +9,16 @@ module.exports = class DisableCommandCommand extends Command { aliases: ['disable-command', 'cmd-off', 'command-off'], group: 'commands', memberName: 'disable', - description: 'Disables a command or command group.', - details: oneLine` - The argument must be the name/ID (partial or whole) of a command or command group. - Only administrators may use this command. - `, - examples: ['disable util', 'disable Utility', 'disable prefix'], + description: new CommandoTranslatable('command.disable.description'), + details: new CommandoTranslatable('command.disable.details'), + examples: new CommandoTranslatable('command.disable.examples'), guarded: true, args: [ { key: 'cmdOrGrp', - label: 'command/group', - prompt: 'Which command or group would you like to disable?', + label: new CommandoTranslatable('command.disable.args.cmd_or_grp.label'), + prompt: new CommandoTranslatable('command.disable.args.cmd_or_grp.prompt'), type: 'group|command' } ] @@ -33,17 +31,32 @@ module.exports = class DisableCommandCommand extends Command { } run(msg, args) { + const groupName = args.cmdOrGrp.name; + const type = args.cmdOrGrp.group ? 'command' : 'group'; + const lng = msg.client.translator.resolveLanguage(msg); if(!args.cmdOrGrp.isEnabledIn(msg.guild, true)) { return msg.reply( - `The \`${args.cmdOrGrp.name}\` ${args.cmdOrGrp.group ? 'command' : 'group'} is already disabled.` + i18next.t('command.disable.run.group_already_disabled', { + groupName, + type, + lng + }) ); } if(args.cmdOrGrp.guarded) { return msg.reply( - `You cannot disable the \`${args.cmdOrGrp.name}\` ${args.cmdOrGrp.group ? 'command' : 'group'}.` + i18next.t('command.disable.run.cannot_disable_group', { + groupName, + type, + lng + }) ); } args.cmdOrGrp.setEnabledIn(msg.guild, false); - return msg.reply(`Disabled the \`${args.cmdOrGrp.name}\` ${args.cmdOrGrp.group ? 'command' : 'group'}.`); + return msg.reply(i18next.t('command.disable.run.group_disabled', { + groupName, + type, + lng + })); } }; diff --git a/src/commands/commands/enable.js b/src/commands/commands/enable.js index 79f831cb4..88053d510 100644 --- a/src/commands/commands/enable.js +++ b/src/commands/commands/enable.js @@ -1,5 +1,6 @@ -const { oneLine } = require('common-tags'); const Command = require('../base'); +const i18next = require('i18next'); +const { CommandoTranslatable } = require('../../translator'); module.exports = class EnableCommandCommand extends Command { constructor(client) { @@ -8,19 +9,16 @@ module.exports = class EnableCommandCommand extends Command { aliases: ['enable-command', 'cmd-on', 'command-on'], group: 'commands', memberName: 'enable', - description: 'Enables a command or command group.', - details: oneLine` - The argument must be the name/ID (partial or whole) of a command or command group. - Only administrators may use this command. - `, - examples: ['enable util', 'enable Utility', 'enable prefix'], + description: new CommandoTranslatable('command.enable.description'), + details: new CommandoTranslatable('command.enable.details'), + examples: new CommandoTranslatable('command.enable.examples'), guarded: true, args: [ { key: 'cmdOrGrp', - label: 'command/group', - prompt: 'Which command or group would you like to enable?', + label: new CommandoTranslatable('command.enable.args.cmd_or_grp.label'), + prompt: new CommandoTranslatable('command.enable.args.cmd_or_grp.prompt'), type: 'group|command' } ] @@ -33,23 +31,27 @@ module.exports = class EnableCommandCommand extends Command { } run(msg, args) { + const lng = msg.client.translator.resolveLanguage(msg); const group = args.cmdOrGrp.group; + const type = args.cmdOrGrp.group ? 'command' : 'group'; if(args.cmdOrGrp.isEnabledIn(msg.guild, true)) { return msg.reply( - `The \`${args.cmdOrGrp.name}\` ${args.cmdOrGrp.group ? 'command' : 'group'} is already enabled${ - group && !group.isEnabledIn(msg.guild) ? - `, but the \`${group.name}\` group is disabled, so it still can't be used` : - '' - }.` + i18next.t('command.enable.run.group_already_enabled', { + group, + type, + disabledMessage: '$t(command.enable.run.group_disabled)', + lng + }) ); } args.cmdOrGrp.setEnabledIn(msg.guild, true); return msg.reply( - `Enabled the \`${args.cmdOrGrp.name}\` ${group ? 'command' : 'group'}${ - group && !group.isEnabledIn(msg.guild) ? - `, but the \`${group.name}\` group is disabled, so it still can't be used` : - '' - }.` + i18next.t('command.enable.run.group_enabled', { + group, + type, + disabledMessage: '$t(command.enable.run.group_disabled)', + lng + }) ); } }; diff --git a/src/commands/commands/groups.js b/src/commands/commands/groups.js index 78c710a6f..ca61d674f 100644 --- a/src/commands/commands/groups.js +++ b/src/commands/commands/groups.js @@ -1,5 +1,6 @@ -const { stripIndents } = require('common-tags'); const Command = require('../base'); +const i18next = require('i18next'); +const { CommandoTranslatable } = require('../../translator'); module.exports = class ListGroupsCommand extends Command { constructor(client) { @@ -8,8 +9,8 @@ module.exports = class ListGroupsCommand extends Command { aliases: ['list-groups', 'show-groups'], group: 'commands', memberName: 'groups', - description: 'Lists all command groups.', - details: 'Only administrators may use this command.', + description: new CommandoTranslatable('command.groups.description'), + details: new CommandoTranslatable('command.groups.details'), guarded: true }); } @@ -20,11 +21,14 @@ module.exports = class ListGroupsCommand extends Command { } run(msg) { - return msg.reply(stripIndents` - __**Groups**__ - ${this.client.registry.groups.map(grp => - `**${grp.name}:** ${grp.isEnabledIn(msg.guild) ? 'Enabled' : 'Disabled'}` - ).join('\n')} - `); + const lng = msg.client.translator.resolveLanguage(msg); + return msg.reply(i18next.t('command.groups.run.response', { + lng, + groups: `${this.client.registry.groups.map(grp => + `**${grp.name}:** ${grp.isEnabledIn(msg.guild) ? + '$t(common.enabled_uppercase)' : '$t(common.disabled_uppercase)'}` + ) + .join('\n')}` + })); } }; diff --git a/src/commands/commands/load.js b/src/commands/commands/load.js index 247c88c21..91a4e44ce 100644 --- a/src/commands/commands/load.js +++ b/src/commands/commands/load.js @@ -1,6 +1,6 @@ const fs = require('fs'); -const { oneLine } = require('common-tags'); const Command = require('../base'); +const { CommandoTranslatable } = require('../../translator'); module.exports = class LoadCommandCommand extends Command { constructor(client) { @@ -9,25 +9,22 @@ module.exports = class LoadCommandCommand extends Command { aliases: ['load-command'], group: 'commands', memberName: 'load', - description: 'Loads a new command.', - details: oneLine` - The argument must be full name of the command in the format of \`group:memberName\`. - Only the bot owner(s) may use this command. - `, - examples: ['load some-command'], + description: new CommandoTranslatable('command.load.description'), + details: new CommandoTranslatable('command.load.details'), + examples: new CommandoTranslatable('command.load.examples'), ownerOnly: true, guarded: true, args: [ { key: 'command', - prompt: 'Which command would you like to load?', + prompt: new CommandoTranslatable('command.load.args.command.prompt'), validate: val => new Promise(resolve => { if(!val) return resolve(false); const split = val.split(':'); if(split.length !== 2) return resolve(false); if(this.client.registry.findCommands(val).length > 0) { - return resolve('That command is already registered.'); + return resolve(new CommandoTranslatable('command.load.run.command_already_registered')); } const cmdPath = this.client.registry.resolveCommandPath(split[0], split[1]); fs.access(cmdPath, fs.constants.R_OK, err => err ? resolve(false) : resolve(true)); diff --git a/src/commands/commands/reload.js b/src/commands/commands/reload.js index bdb12c726..18709bd15 100644 --- a/src/commands/commands/reload.js +++ b/src/commands/commands/reload.js @@ -1,5 +1,6 @@ -const { oneLine } = require('common-tags'); const Command = require('../base'); +const i18next = require('i18next'); +const { CommandoTranslatable } = require('../../translator'); module.exports = class ReloadCommandCommand extends Command { constructor(client) { @@ -8,21 +9,17 @@ module.exports = class ReloadCommandCommand extends Command { aliases: ['reload-command'], group: 'commands', memberName: 'reload', - description: 'Reloads a command or command group.', - details: oneLine` - The argument must be the name/ID (partial or whole) of a command or command group. - Providing a command group will reload all of the commands in that group. - Only the bot owner(s) may use this command. - `, - examples: ['reload some-command'], + description: new CommandoTranslatable('command.reload.description'), + details: new CommandoTranslatable('command.reload.details'), + examples: new CommandoTranslatable('command.reload.examples'), ownerOnly: true, guarded: true, args: [ { key: 'cmdOrGrp', - label: 'command/group', - prompt: 'Which command or group would you like to reload?', + label: new CommandoTranslatable('command.reload.args.cmd_or_grp.label'), + prompt: new CommandoTranslatable('command.reload.args.cmd_or_grp.prompt'), type: 'group|command' } ] @@ -30,6 +27,7 @@ module.exports = class ReloadCommandCommand extends Command { } async run(msg, args) { + const lng = msg.client.translator.resolveLanguage(msg); const { cmdOrGrp } = args; const isCmd = Boolean(cmdOrGrp.groupID); cmdOrGrp.reload(); @@ -44,24 +42,20 @@ module.exports = class ReloadCommandCommand extends Command { } catch(err) { this.client.emit('warn', `Error when broadcasting command reload to other shards`); this.client.emit('error', err); - if(isCmd) { - await msg.reply(`Reloaded \`${cmdOrGrp.name}\` command, but failed to reload on other shards.`); - } else { - await msg.reply( - `Reloaded all of the commands in the \`${cmdOrGrp.name}\` group, but failed to reload on other shards.` - ); - } + await msg.reply(i18next.t('command.reload.run.reload_failed', { + lng, + count: isCmd ? 1 : 100 + })); return null; } } - if(isCmd) { - await msg.reply(`Reloaded \`${cmdOrGrp.name}\` command${this.client.shard ? ' on all shards' : ''}.`); - } else { - await msg.reply( - `Reloaded all of the commands in the \`${cmdOrGrp.name}\` group${this.client.shard ? ' on all shards' : ''}.` - ); - } + await msg.reply(i18next.t('command.reload.run.reload_succeed', { + lng, + onShards: this.client.shard ? ' $t(common.on_all_shards)' : '', + groupName: cmdOrGrp.name, + count: isCmd ? 1 : 100 + })); return null; } }; diff --git a/src/commands/commands/unload.js b/src/commands/commands/unload.js index c4ba04e06..d7845d2c6 100644 --- a/src/commands/commands/unload.js +++ b/src/commands/commands/unload.js @@ -1,5 +1,6 @@ -const { oneLine } = require('common-tags'); const Command = require('../base'); +const i18next = require('i18next'); +const { CommandoTranslatable } = require('../../translator'); module.exports = class UnloadCommandCommand extends Command { constructor(client) { @@ -8,19 +9,16 @@ module.exports = class UnloadCommandCommand extends Command { aliases: ['unload-command'], group: 'commands', memberName: 'unload', - description: 'Unloads a command.', - details: oneLine` - The argument must be the name/ID (partial or whole) of a command. - Only the bot owner(s) may use this command. - `, - examples: ['unload some-command'], ownerOnly: true, guarded: true, + description: new CommandoTranslatable('command.unload.description'), + details: new CommandoTranslatable('command.unload.details'), + examples: new CommandoTranslatable('command.unload.examples'), args: [ { key: 'command', - prompt: 'Which command would you like to unload?', + prompt: new CommandoTranslatable('command.unload.args.command.prompt'), type: 'command' } ] @@ -28,6 +26,7 @@ module.exports = class UnloadCommandCommand extends Command { } async run(msg, args) { + const lng = msg.client.translator.resolveLanguage(msg); args.command.unload(); if(this.client.shard) { @@ -38,12 +37,19 @@ module.exports = class UnloadCommandCommand extends Command { } catch(err) { this.client.emit('warn', `Error when broadcasting command unload to other shards`); this.client.emit('error', err); - await msg.reply(`Unloaded \`${args.command.name}\` command, but failed to unload on other shards.`); + + await msg.reply(i18next.t('command.unload.run.unload_failed', { + lng, + groupName: args.command.name + })); return null; } } - - await msg.reply(`Unloaded \`${args.command.name}\` command${this.client.shard ? ' on all shards' : ''}.`); + await msg.reply(i18next.t('command.unload.run.unload_succeed', { + lng, + onShards: this.client.shard ? ' $t(common.on_all_shards)' : '', + groupName: args.command.name + })); return null; } }; diff --git a/src/commands/util/eval.js b/src/commands/util/eval.js index 6fb7a057c..c6cd8bd99 100644 --- a/src/commands/util/eval.js +++ b/src/commands/util/eval.js @@ -3,6 +3,8 @@ const discord = require('discord.js'); const tags = require('common-tags'); const { escapeRegex } = require('../../util'); const Command = require('../base'); +const { CommandoTranslatable } = require('../../translator'); +const i18next = require('i18next'); const nl = '!!NL!!'; const nlPattern = new RegExp(nl, 'g'); @@ -13,21 +15,24 @@ module.exports = class EvalCommand extends Command { name: 'eval', group: 'util', memberName: 'eval', - description: 'Executes JavaScript code.', - details: 'Only the bot owner(s) may use this command.', + description: new CommandoTranslatable('command.eval.description'), + details: new CommandoTranslatable('command.eval.details'), ownerOnly: true, args: [ { key: 'script', - prompt: 'What code would you like to evaluate?', + prompt: new CommandoTranslatable('command.eval.args.script.prompt'), type: 'string' } ] }); this.lastResult = null; - Object.defineProperty(this, '_sensitivePattern', { value: null, configurable: true }); + Object.defineProperty(this, '_sensitivePattern', { + value: null, + configurable: true + }); } run(msg, args) { @@ -36,11 +41,15 @@ module.exports = class EvalCommand extends Command { const message = msg; const client = msg.client; const lastResult = this.lastResult; + const lng = msg.client.translator.resolveLanguage(msg); const doReply = val => { if(val instanceof Error) { - msg.reply(`Callback error: \`${val}\``); + msg.reply(i18next.t('command.eval.run.callback_error', { + lng, + val + })); } else { - const result = this.makeResultMessages(val, process.hrtime(this.hrStart)); + const result = this.makeResultMessages(val, process.hrtime(this.hrStart), lng); if(Array.isArray(result)) { for(const item of result) msg.reply(item); } else { @@ -57,12 +66,15 @@ module.exports = class EvalCommand extends Command { this.lastResult = eval(args.script); hrDiff = process.hrtime(hrStart); } catch(err) { - return msg.reply(`Error while evaluating: \`${err}\``); + return msg.reply(i18next.t('command.eval.run.evaluating_error', { + lng, + err + })); } // Prepare for callback time and respond this.hrStart = process.hrtime(); - const result = this.makeResultMessages(this.lastResult, hrDiff, args.script); + const result = this.makeResultMessages(this.lastResult, hrDiff, args.script, lng); if(Array.isArray(result)) { return result.map(item => msg.reply(item)); } else { @@ -70,32 +82,52 @@ module.exports = class EvalCommand extends Command { } } - makeResultMessages(result, hrDiff, input = null) { + makeResultMessages(result, hrDiff, input = null, lng) { const inspected = util.inspect(result, { depth: 0 }) .replace(nlPattern, '\n') .replace(this.sensitivePattern, '--snip--'); const split = inspected.split('\n'); const last = inspected.length - 1; - const prependPart = inspected[0] !== '{' && inspected[0] !== '[' && inspected[0] !== "'" ? split[0] : inspected[0]; - const appendPart = inspected[last] !== '}' && inspected[last] !== ']' && inspected[last] !== "'" ? + const prependPart = inspected[0] !== '{' && inspected[0] !== '[' && inspected[0] !== '\'' ? split[0] : inspected[0]; + const appendPart = inspected[last] !== '}' && inspected[last] !== ']' && inspected[last] !== '\'' ? split[split.length - 1] : inspected[last]; const prepend = `\`\`\`javascript\n${prependPart}\n`; const append = `\n${appendPart}\n\`\`\``; if(input) { return discord.splitMessage(tags.stripIndents` - *Executed in ${hrDiff[0] > 0 ? `${hrDiff[0]}s ` : ''}${hrDiff[1] / 1000000}ms.* + ${ + i18next.t('command.eval.run.executed_in', { + lng, + // eslint-disable-next-line id-length + s: hrDiff[0] > 0 ? `${hrDiff[0]}$t(common.s) ` : '', + ms: `${hrDiff[1] / 1000000}$t(common.ms)` + })} \`\`\`javascript ${inspected} \`\`\` - `, { maxLength: 1900, prepend, append }); + `, { + maxLength: 1900, + prepend, + append + }); } else { return discord.splitMessage(tags.stripIndents` - *Callback executed after ${hrDiff[0] > 0 ? `${hrDiff[0]}s ` : ''}${hrDiff[1] / 1000000}ms.* + ${ + i18next.t('command.eval.run.executed_after', { + lng, + // eslint-disable-next-line id-length + s: hrDiff[0] > 0 ? `${hrDiff[0]}$t(common.s) ` : '', + ms: `${hrDiff[1] / 1000000}$t(common.ms)` + })} \`\`\`javascript ${inspected} \`\`\` - `, { maxLength: 1900, prepend, append }); + `, { + maxLength: 1900, + prepend, + append + }); } } @@ -104,7 +136,10 @@ module.exports = class EvalCommand extends Command { const client = this.client; let pattern = ''; if(client.token) pattern += escapeRegex(client.token); - Object.defineProperty(this, '_sensitivePattern', { value: new RegExp(pattern, 'gi'), configurable: false }); + Object.defineProperty(this, '_sensitivePattern', { + value: new RegExp(pattern, 'gi'), + configurable: false + }); } return this._sensitivePattern; } diff --git a/src/commands/util/help.js b/src/commands/util/help.js index bb60f8b0b..88393c878 100644 --- a/src/commands/util/help.js +++ b/src/commands/util/help.js @@ -1,6 +1,9 @@ -const { stripIndents, oneLine } = require('common-tags'); +const { stripIndents } = require('common-tags'); const Command = require('../base'); const { disambiguation } = require('../../util'); +const { CommandoTranslatable } = require('../../translator'); +const i18next = require('i18next'); + module.exports = class HelpCommand extends Command { constructor(client) { @@ -9,18 +12,15 @@ module.exports = class HelpCommand extends Command { group: 'util', memberName: 'help', aliases: ['commands'], - description: 'Displays a list of available commands, or detailed information for a specified command.', - details: oneLine` - The command may be part of a command name or a whole command name. - If it isn't specified, all available commands will be listed. - `, - examples: ['help', 'help prefix'], + description: new CommandoTranslatable('command.help.description'), + details: new CommandoTranslatable('command.help.details'), + examples: new CommandoTranslatable('command.help.examples'), guarded: true, args: [ { key: 'command', - prompt: 'Which command would you like to view the help for?', + prompt: new CommandoTranslatable('command.help.args.command.prompt'), type: 'string', default: '' } @@ -32,72 +32,144 @@ module.exports = class HelpCommand extends Command { const groups = this.client.registry.groups; const commands = this.client.registry.findCommands(args.command, false, msg); const showAll = args.command && args.command.toLowerCase() === 'all'; + const lng = msg.client.translator.resolveLanguage(msg); + + if(args.command && !showAll) { if(commands.length === 1) { + const commandInfo = this.translate(commands[0], lng); + const helpDescription = i18next.t('command.help.run.description', { + lng, + name: commands[0].name, + description: commandInfo.description, + guildOnly: commands[0].guildOnly ? ' ($t(common.guild_only))' : '', + nsfw: commands[0].nsfw ? ' (NSFW)' : '', + interpolation: { escapeValue: false } + }); + + // Get the translated parts of the help command response + const helpFormat = i18next.t('command.help.run.format', { + lng, + format: msg.anyUsage(`${commands[0].name}${commandInfo.format ? ` ${commandInfo.format}` : ''}`), + interpolation: { escapeValue: false } + }); + + const helpAliases = commands[0].aliases ? i18next.t('command.help.run.aliases', { + lng, + aliases: Array.isArray(commands[0].aliases) ? commands[0].aliases.join(', ') : undefined + }) : ''; + + const helpGroup = i18next.t('command.help.run.group', { + lng, + groupName: commands[0].group.name, + groupID: commands[0].groupID, + memberName: commands[0].memberName, + interpolation: { escapeValue: false } + }); + + const helpDetails = i18next.t('command.help.run.details', { + lng, + details: commandInfo.details, + interpolation: { escapeValue: false } + }); + + const helpExamples = commandInfo.examples ? i18next.t('command.help.run.examples', { + lng, + examples: Array.isArray(commandInfo.examples) ? commandInfo.examples.join('\n') : undefined, + interpolation: { escapeValue: false }, + returnObjects: true + }) : ''; + + // Build the help command response let help = stripIndents` - ${oneLine` - __Command **${commands[0].name}**:__ ${commands[0].description} - ${commands[0].guildOnly ? ' (Usable only in servers)' : ''} - ${commands[0].nsfw ? ' (NSFW)' : ''} - `} + ${helpDescription} - **Format:** ${msg.anyUsage(`${commands[0].name}${commands[0].format ? ` ${commands[0].format}` : ''}`)} + ${helpFormat} `; - if(commands[0].aliases.length > 0) help += `\n**Aliases:** ${commands[0].aliases.join(', ')}`; - help += `\n${oneLine` - **Group:** ${commands[0].group.name} - (\`${commands[0].groupID}:${commands[0].memberName}\`) - `}`; - if(commands[0].details) help += `\n**Details:** ${commands[0].details}`; - if(commands[0].examples) help += `\n**Examples:**\n${commands[0].examples.join('\n')}`; + if(commands[0].aliases.length > 0) help += helpAliases; + + help += helpGroup; + + if(commandInfo.details) help += helpDetails; + if(commandInfo.examples) help += helpExamples; + + // Send the help command response const messages = []; try { messages.push(await msg.direct(help)); - if(msg.channel.type !== 'dm') messages.push(await msg.reply('Sent you a DM with information.')); + if(msg.channel.type !== 'dm') { + messages.push(await msg.reply(i18next.t('common.sent_dm_with_information', { lng: lng }))); + } } catch(err) { - messages.push(await msg.reply('Unable to send you the help DM. You probably have DMs disabled.')); + messages.push(await msg.reply(i18next.t('error.unable_to_send_dm', { lng: lng }))); } return messages; } else if(commands.length > 15) { - return msg.reply('Multiple commands found. Please be more specific.'); + return msg.reply(i18next.t('command.help.run.multiple_commands_error', { lng: lng })); } else if(commands.length > 1) { - return msg.reply(disambiguation(commands, 'commands')); + return msg.reply(i18next.t('error.too_many_found_with_list', + { + lng, + label: '$t(common.command_plural)', + itemList: disambiguation( + commands, null + ) + } + )); } else { - return msg.reply( - `Unable to identify command. Use ${msg.usage( - null, msg.channel.type === 'dm' ? null : undefined, msg.channel.type === 'dm' ? null : undefined - )} to view the list of all commands.` + return msg.reply(i18next.t('command.help.run.identify_command_error', + { + lng, + usage: msg.usage( + null, msg.channel.type === 'dm' ? null : undefined, msg.channel.type === 'dm' ? null : undefined + ) + }) ); } } else { const messages = []; try { - messages.push(await msg.direct(stripIndents` - ${oneLine` - To run a command in ${msg.guild ? msg.guild.name : 'any server'}, - use ${Command.usage('command', msg.guild ? msg.guild.commandPrefix : null, this.client.user)}. - For example, ${Command.usage('prefix', msg.guild ? msg.guild.commandPrefix : null, this.client.user)}. - `} - To run a command in this DM, simply use ${Command.usage('command', null, null)} with no prefix. - - Use ${this.usage('', null, null)} to view detailed information about a specific command. - Use ${this.usage('all', null, null)} to view a list of *all* commands, not just available ones. - - __**${showAll ? 'All commands' : `Available commands in ${msg.guild || 'this DM'}`}**__ - - ${groups.filter(grp => grp.commands.some(cmd => !cmd.hidden && (showAll || cmd.isUsable(msg)))) - .map(grp => stripIndents` + const guild = msg.guild ? msg.guild.name : i18next.t('common.any_server', { lng: lng }); + const commandUsage = Command.usage('command', msg.guild ? + msg.guild.commandPrefix : null, this.client.user, lng); + const example = Command.usage('prefix', msg.guild ? msg.guild.commandPrefix : null, this.client.user, lng); + const usageWithoutPrefix = Command.usage('command', null, null); + const usage = this.usage('', null, null, lng); + const usageAll = this.usage('all', null, null, lng); + const availableCommands = i18next.t(showAll ? 'common.all_commands' : 'common.available_commands', { + lng, + inGuildOrDm: msg.guild ? `$t(common.in_guild) ${msg.guild.name}` : '$t(common.in_this_dm)' + }); + const commandList = groups.filter(grp => grp.commands + .some(cmd => !cmd.hidden && (showAll || cmd.isUsable(msg)))) + .map(grp => stripIndents` __${grp.name}__ ${grp.commands.filter(cmd => !cmd.hidden && (showAll || cmd.isUsable(msg))) - .map(cmd => `**${cmd.name}:** ${cmd.description}${cmd.nsfw ? ' (NSFW)' : ''}`).join('\n') - } - `).join('\n\n') + .map(cmd => `**${cmd.name}:** ${typeof cmd.description === 'string' || !cmd.description || + typeof cmd.description === 'undefined' ? cmd.description : + i18next.t(cmd.description.key, { lng: lng })}${cmd.nsfw ? ' (NSFW)' : ''}`) + .join('\n') } - `, { split: true })); - if(msg.channel.type !== 'dm') messages.push(await msg.reply('Sent you a DM with information.')); + `) + .join('\n\n'); + messages.push(await msg.direct(i18next.t('command.help.run.command_usage', { + lng, + guild, + commandUsage, + example, + usageWithoutPrefix, + usage, + usageAll, + availableCommands, + commandList, + interpolation: { escapeValue: false } + }), { split: true })); + if(msg.channel.type !== 'dm') { + messages.push(await msg.reply(i18next.t('common.sent_dm_with_information', { lng: lng }))); + } } catch(err) { - messages.push(await msg.reply('Unable to send you the help DM. You probably have DMs disabled.')); + messages.push(await msg.reply(i18next.t('error.unable_to_send_dm', { lng: lng }))); } return messages; } diff --git a/src/commands/util/language.js b/src/commands/util/language.js new file mode 100644 index 000000000..d0ad6ea0e --- /dev/null +++ b/src/commands/util/language.js @@ -0,0 +1,186 @@ +const { stripIndents } = require('common-tags'); +const Command = require('../base'); +const i18next = require('i18next'); +const { CommandoTranslator, CommandoTranslatable } = require('../../translator'); + +module.exports = class LanguageCommand extends Command { + constructor(client) { + super(client, { + name: 'language', + group: 'util', + memberName: 'language', + description: new CommandoTranslatable('command.language.description'), + format: new CommandoTranslatable('command.language.format'), + details: new CommandoTranslatable('command.language.details'), + examples: new CommandoTranslatable('command.language.examples'), + + args: [ + { + key: 'language', + prompt: new CommandoTranslatable('command.language.args.language.prompt'), + type: 'string', + max: 15, + default: '' + }, + { + key: 'action', + prompt: new CommandoTranslatable('command.language.args.action.prompt'), + type: 'string', + oneOf: ['load', 'reload'], + default: '' + } + ] + }); + } + + async run(msg, args) { + const lng = msg.client.translator.resolveLanguage(msg); + + if(args.language && args.action) { + return this.handleActions(msg, args, lng); + } + + // Just output the language + if(!args.language) { + const language = msg.guild ? msg.guild.language : null; + + if(msg.guild) { + return msg.reply(stripIndents` + ${language ? + i18next.t('command.language.run.guild_language_is', + { + lng, + language + }) : i18next.t('command.language.run.no_guild_language', { lng })} + `); + } else { + return msg.reply( + i18next.t('command.language.run.bot_language_is', + { + lng, + language: msg.client.defaultLanguage + })); + } + } + + // Check the user's permission before changing anything + if(msg.guild) { + if(!msg.member.hasPermission('ADMINISTRATOR') && !this.client.isOwner(msg.author)) { + return msg.reply(i18next.t('command.language.run.admins_only', { lng })); + } + } else if(!this.client.isOwner(msg.author)) { + return msg.reply(i18next.t('command.language.run.owner_only', { lng })); + } + + // Save the language + const lowercase = args.language.toLowerCase(); + const language = args.language; + let response; + if(lowercase === 'default') { + // Reset the language + if(msg.guild) msg.guild.language = null; else this.client.defaultLanguage = CommandoTranslator.DEFAULT_LANGUAGE; + const current = this.client.defaultLanguage ? `\`\`${this.client.defaultLanguage}\`\`` : + this.client.defaultLanguage; + response = i18next.t('command.language.run.current_language', { + lng, + language: current + }); + } else { + // Set the language + if(!i18next.hasResourceBundle(language, 'commando')) { + const availableLanguages = i18next.languages || []; + return msg.reply(i18next.t('command.language.run.language_not_supported', { + lng, + language, + context: availableLanguages.length === 0 ? 'short' : '', + availableLanguages: availableLanguages.filter(languageCode => languageCode !== 'dev') + .join('\n- ') + })); + } + if(msg.guild) msg.guild.language = language; else this.client.defaultLanguage = language; + if(language) { + response = i18next.t('command.language.run.language_set', { + lng, + context: msg.guild ? 'guild' : undefined, + language + }); + } + } + + await msg.reply(response); + + return null; + } + + async handleActions(msg, args, lng) { + if(!this.client.isOwner(msg.author)) { + return msg.reply(i18next.t('command.language.run.action_owner_only', { lng })); + } + + let response; + switch(args.action) { + case 'load': + await i18next.loadLanguages(args.language, err => { + if(err) { + response = i18next.t('command.language.run.load_failed', + { + lng, + language: args.language, + error: JSON.stringify(err) + }); + } else { + response = i18next.t('command.language.run.load_complete', + { + lng, + context: i18next.hasResourceBundle(args.language, 'commando') ? 'succeed' : 'failed', + language: args.language + }); + } + }); + if(response) { + return msg.reply(response); + } else { + return msg.reply(i18next.t('command.language.run.load_complete', + { + lng, + context: 'failed', + language: args.language + })); + } + case 'reload': + await i18next.reloadResources(args.language, undefined, err => { + if(err) { + response = i18next.t('command.language.run.reload_failed', + { + lng, + language: args.language, + error: JSON.stringify(err) + }); + } else { + response = i18next.t('command.language.run.reload_complete', + { + lng, + context: i18next.hasResourceBundle(args.language, 'commando') ? 'succeed' : 'failed', + language: args.language + }); + } + }); + if(response) { + return msg.reply(response); + } else { + return msg.reply(i18next.t('command.language.run.reload_complete', + { + lng, + context: 'failed', + language: args.language + })); + } + default: + return msg.reply(i18next.t('command.language.run.action_not_supported', + { + lng, + action: args.action + })); + } + } +}; diff --git a/src/commands/util/ping.js b/src/commands/util/ping.js index d676f0c2c..8ae7e322c 100644 --- a/src/commands/util/ping.js +++ b/src/commands/util/ping.js @@ -1,5 +1,7 @@ -const { oneLine } = require('common-tags'); const Command = require('../base'); +const { CommandoTranslatable } = require('../../translator'); +const i18next = require('i18next'); + module.exports = class PingCommand extends Command { constructor(client) { @@ -7,7 +9,7 @@ module.exports = class PingCommand extends Command { name: 'ping', group: 'util', memberName: 'ping', - description: 'Checks the bot\'s ping to the Discord server.', + description: new CommandoTranslatable('command.ping.description'), throttling: { usages: 5, duration: 10 @@ -16,13 +18,17 @@ module.exports = class PingCommand extends Command { } async run(msg) { - const pingMsg = await msg.reply('Pinging...'); - return pingMsg.edit(oneLine` - ${msg.channel.type !== 'dm' ? `${msg.author},` : ''} - Pong! The message round-trip took ${ - (pingMsg.editedTimestamp || pingMsg.createdTimestamp) - (msg.editedTimestamp || msg.createdTimestamp) - }ms. - ${this.client.ws.ping ? `The heartbeat ping is ${Math.round(this.client.ws.ping)}ms.` : ''} - `); + const lng = msg.client.translator.resolveLanguage(msg); + const pingMsg = await msg.reply(i18next.t('command.ping.run.pinging', { + lng + })); + return pingMsg.edit(i18next.t('command.ping.run.pong', { + lng, + mention: msg.channel.type !== 'dm' ? `${msg.author},` : '', + duration: (pingMsg.editedTimestamp || pingMsg.createdTimestamp) - (msg.editedTimestamp || msg.createdTimestamp), + heartbeatPing: Math.round(this.client.ws.ping), + pingResponse: this.client.ws.ping ? + `$t(command.ping.run.heartbeat_ping)` : '' + })); } }; diff --git a/src/commands/util/prefix.js b/src/commands/util/prefix.js index 1e739066e..40be8aa85 100644 --- a/src/commands/util/prefix.js +++ b/src/commands/util/prefix.js @@ -1,5 +1,7 @@ -const { stripIndents, oneLine } = require('common-tags'); +const { stripIndents } = require('common-tags'); const Command = require('../base'); +const i18next = require('i18next'); +const { CommandoTranslatable } = require('../../translator'); module.exports = class PrefixCommand extends Command { constructor(client) { @@ -7,20 +9,15 @@ module.exports = class PrefixCommand extends Command { name: 'prefix', group: 'util', memberName: 'prefix', - description: 'Shows or sets the command prefix.', - format: '[prefix/"default"/"none"]', - details: oneLine` - If no prefix is provided, the current prefix will be shown. - If the prefix is "default", the prefix will be reset to the bot's default prefix. - If the prefix is "none", the prefix will be removed entirely, only allowing mentions to run commands. - Only administrators may change the prefix. - `, - examples: ['prefix', 'prefix -', 'prefix omg!', 'prefix default', 'prefix none'], + description: new CommandoTranslatable('command.prefix.description'), + format: new CommandoTranslatable('command.prefix.format'), + details: new CommandoTranslatable('command.prefix.details'), + examples: new CommandoTranslatable('command.prefix.examples'), args: [ { key: 'prefix', - prompt: 'What would you like to set the bot\'s prefix to?', + prompt: new CommandoTranslatable('command.prefix.args.prefix.prompt'), type: 'string', max: 15, default: '' @@ -30,22 +27,29 @@ module.exports = class PrefixCommand extends Command { } async run(msg, args) { + const lng = msg.client.translator.resolveLanguage(msg); + // Just output the prefix if(!args.prefix) { const prefix = msg.guild ? msg.guild.commandPrefix : this.client.commandPrefix; return msg.reply(stripIndents` - ${prefix ? `The command prefix is \`\`${prefix}\`\`.` : 'There is no command prefix.'} - To run commands, use ${msg.anyUsage('command')}. + ${prefix ? + i18next.t('command.prefix.run.the_prefix_is', + { + lng, + prefix + }) : i18next.t('command.prefix.run.no_command_prefix', { lng })} + ${i18next.t('command.prefix.run.how_to_run', { lng, usage: msg.anyUsage('command') })}. `); } // Check the user's permission before changing anything if(msg.guild) { if(!msg.member.hasPermission('ADMINISTRATOR') && !this.client.isOwner(msg.author)) { - return msg.reply('Only administrators may change the command prefix.'); + return msg.reply(i18next.t('command.prefix.run.admins_only', { lng })); } } else if(!this.client.isOwner(msg.author)) { - return msg.reply('Only the bot owner(s) may change the global command prefix.'); + return msg.reply(i18next.t('command.prefix.run.owner_only', { lng })); } // Save the prefix @@ -54,14 +58,25 @@ module.exports = class PrefixCommand extends Command { let response; if(lowercase === 'default') { if(msg.guild) msg.guild.commandPrefix = null; else this.client.commandPrefix = null; - const current = this.client.commandPrefix ? `\`\`${this.client.commandPrefix}\`\`` : 'no prefix'; - response = `Reset the command prefix to the default (currently ${current}).`; + const current = this.client.commandPrefix ? `\`\`${this.client.commandPrefix}\`\`` : + i18next.t('command.prefix.run.no_prefix', { lng }); + response = i18next.t('command.prefix.run.current_prefix', { + lng, + prefix: current + }); } else { if(msg.guild) msg.guild.commandPrefix = prefix; else this.client.commandPrefix = prefix; - response = prefix ? `Set the command prefix to \`\`${args.prefix}\`\`.` : 'Removed the command prefix entirely.'; + response = prefix ? i18next.t('command.prefix.run.prefix_set_to', { + lng, + prefix + }) : i18next.t('command.prefix.run.prefix_removed', { lng }); } - await msg.reply(`${response} To run commands, use ${msg.anyUsage('command')}.`); + await msg.reply(i18next.t('command.prefix.run.prefix_usage', { + lng, + response, + usage: msg.anyUsage('command') + })); return null; } }; diff --git a/src/commands/util/unknown-command.js b/src/commands/util/unknown-command.js index b16e769cc..578c9f57c 100644 --- a/src/commands/util/unknown-command.js +++ b/src/commands/util/unknown-command.js @@ -1,4 +1,6 @@ const Command = require('../base'); +const { CommandoTranslatable } = require('../../translator'); +const i18next = require('i18next'); module.exports = class UnknownCommandCommand extends Command { constructor(client) { @@ -6,20 +8,22 @@ module.exports = class UnknownCommandCommand extends Command { name: 'unknown-command', group: 'util', memberName: 'unknown-command', - description: 'Displays help information for when an unknown command is used.', - examples: ['unknown-command kickeverybodyever'], + description: new CommandoTranslatable('command.unknown_command.description'), + examples: new CommandoTranslatable('command.unknown_command.examples'), unknown: true, hidden: true }); } run(msg) { - return msg.reply( - `Unknown command. Use ${msg.anyUsage( + const lng = msg.client.translator.resolveLanguage(msg); + return msg.reply(i18next.t('command.unknown_command.run.response', { + lng, + usage: msg.anyUsage( 'help', msg.guild ? undefined : null, msg.guild ? undefined : null - )} to view the command list.` - ); + ) + })); } }; diff --git a/src/errors/command-format.js b/src/errors/command-format.js index 7de561dcb..d649985e6 100644 --- a/src/errors/command-format.js +++ b/src/errors/command-format.js @@ -1,4 +1,5 @@ const FriendlyError = require('./friendly'); +const i18next = require('i18next'); /** * Has a descriptive message for a command not having proper format @@ -9,17 +10,21 @@ class CommandFormatError extends FriendlyError { * @param {CommandoMessage} msg - The command message the error is for */ constructor(msg) { - super( - `Invalid command usage. The \`${msg.command.name}\` command's accepted format is: ${msg.usage( + const lng = msg.client.translator.resolveLanguage(msg); + super(i18next.t('invalid_command_usage', { + lng, + commandName: msg.command.name, + usage: msg.usage( msg.command.format, msg.guild ? undefined : null, msg.guild ? undefined : null - )}. Use ${msg.anyUsage( + ), + anyUsage: msg.anyUsage( `help ${msg.command.name}`, msg.guild ? undefined : null, msg.guild ? undefined : null - )} for more information.` - ); + ) + })); this.name = 'CommandFormatError'; } } diff --git a/src/extensions/guild.js b/src/extensions/guild.js index 989b555f2..58b511940 100644 --- a/src/extensions/guild.js +++ b/src/extensions/guild.js @@ -1,6 +1,7 @@ const { Structures } = require('discord.js'); const Command = require('../commands/base'); const GuildSettingsHelper = require('../providers/helper'); +const i18next = require('i18next'); module.exports = Structures.extend('Guild', Guild => { /** @@ -25,6 +26,15 @@ module.exports = Structures.extend('Guild', Guild => { * @private */ this._commandPrefix = null; + + /** + * Internal language for the guild, controlled by the {@link CommandoGuild#language} + * getter/setter + * @name CommandoGuild#_commandPrefix + * @type {?string} + * @private + */ + this._language = null; } /** @@ -49,6 +59,31 @@ module.exports = Structures.extend('Guild', Guild => { this.client.emit('commandPrefixChange', this, this._commandPrefix); } + /** + * Language in the guild. + * Setting to `null` means that the language from {@link CommandoClient#defaultLanguage} will be used instead. + * @type {string} + * @emits {@link CommandoClient#guildLanguageChange} + */ + get language() { + if(this._language === null) return this.client.defaultLanguage; + return this._language; + } + + set language(language) { + this._language = language; + if(i18next.hasResourceBundle(language, 'commando')) { + /** + * Emitted whenever a guild's language is changed + * @typedef {object} CommandoClient#guildLanguageChange + * @event CommandoClient#guildLanguageChange + * @param {?CommandoGuild} guild - Guild that the prefix was changed in (null for global) + * @param {?string} language - New language (null for default) + */ + this.client.emit('guildLanguageChange', this, this._language); + } + } + /** * Sets whether a command is enabled in the guild * @param {CommandResolvable} command - Command to set status of @@ -140,7 +175,7 @@ module.exports = Structures.extend('Guild', Guild => { * @return {string} */ commandUsage(command, user = this.client.user) { - return Command.usage(command, this.commandPrefix, user); + return Command.usage(command, this.commandPrefix, user, user.locale); } } diff --git a/src/extensions/message.js b/src/extensions/message.js index ddd24b1ca..1ffc82741 100644 --- a/src/extensions/message.js +++ b/src/extensions/message.js @@ -3,6 +3,7 @@ const { oneLine } = require('common-tags'); const Command = require('../commands/base'); const FriendlyError = require('../errors/friendly'); const CommandFormatError = require('../errors/command-format'); +const i18next = require('i18next'); module.exports = Structures.extend('Message', Message => { /** @@ -52,9 +53,9 @@ module.exports = Structures.extend('Message', Message => { /** * Initialises the message for a command - * @param {Command} [command] - Command the message triggers - * @param {string} [argString] - Argument string for the command - * @param {?Array} [patternMatches] - Command pattern matches (if from a pattern trigger) + * @param {Command} [command] - Command the message triggers + * @param {string} [argString] - Argument string for the command + * @param {?Array} [patternMatches] - Command pattern matches (if from a pattern trigger) * @return {Message} This message * @private */ @@ -76,8 +77,11 @@ module.exports = Structures.extend('Message', Message => { */ usage(argString, prefix, user = this.client.user) { if(typeof prefix === 'undefined') { - if(this.guild) prefix = this.guild.commandPrefix; - else prefix = this.client.commandPrefix; + if(this.guild) { + prefix = this.guild.commandPrefix; + } else { + prefix = this.client.commandPrefix; + } } return this.command.usage(argString, prefix, user); } @@ -92,8 +96,11 @@ module.exports = Structures.extend('Message', Message => { */ anyUsage(command, prefix, user = this.client.user) { if(typeof prefix === 'undefined') { - if(this.guild) prefix = this.guild.commandPrefix; - else prefix = this.client.commandPrefix; + if(this.guild) { + prefix = this.guild.commandPrefix; + } else { + prefix = this.client.commandPrefix; + } } return Command.usage(command, prefix, user); } @@ -106,9 +113,10 @@ module.exports = Structures.extend('Message', Message => { parseArgs() { switch(this.command.argsType) { case 'single': - return this.argString.trim().replace( - this.command.argsSingleQuotes ? /^("|')([^]*)\1$/g : /^(")([^]*)"$/g, '$2' - ); + return this.argString.trim() + .replace( + this.command.argsSingleQuotes ? /^("|')([^]*)\1$/g : /^(")([^]*)"$/g, '$2' + ); case 'multiple': return this.constructor.parseArgs(this.argString, this.command.argsCount, this.command.argsSingleQuotes); default: @@ -166,7 +174,8 @@ module.exports = Structures.extend('Message', Message => { // Ensure the client user has the required permissions if(this.channel.type === 'text' && this.command.clientPermissions) { - const missing = this.channel.permissionsFor(this.client.user).missing(this.command.clientPermissions); + const missing = this.channel.permissionsFor(this.client.user) + .missing(this.command.clientPermissions); if(missing.length > 0) { const data = { missing }; this.client.emit('commandBlock', this, 'clientPermissions', data); @@ -178,7 +187,10 @@ module.exports = Structures.extend('Message', Message => { const throttle = this.command.throttle(this.author.id); if(throttle && throttle.usages + 1 > this.command.throttling.usages) { const remaining = (throttle.start + (this.command.throttling.duration * 1000) - Date.now()) / 1000; - const data = { throttle, remaining }; + const data = { + throttle, + remaining + }; this.client.emit('commandBlock', this, 'throttling', data); return this.command.onBlock(this, 'throttling', data); } @@ -207,7 +219,8 @@ module.exports = Structures.extend('Message', Message => { * (if applicable - see {@link Command#run}) */ this.client.emit('commandCancel', this.command, collResult.cancelled, this, collResult); - return this.reply('Cancelled command.'); + const lng = this.client.translator.resolveLanguage(this); + return this.reply(i18next.t('common.canceled_command', { lng })); } args = collResult.values; } @@ -277,7 +290,8 @@ module.exports = Structures.extend('Message', Message => { if(type === 'reply' && this.channel.type === 'dm') type = 'plain'; if(type !== 'direct') { - if(this.guild && !this.channel.permissionsFor(this.client.user).has('SEND_MESSAGES')) { + if(this.guild && !this.channel.permissionsFor(this.client.user) + .has('SEND_MESSAGES')) { type = 'direct'; } } @@ -287,14 +301,26 @@ module.exports = Structures.extend('Message', Message => { switch(type) { case 'plain': if(!shouldEdit) return this.channel.send(content, options); - return this.editCurrentResponse(channelIDOrDM(this.channel), { type, content, options }); + return this.editCurrentResponse(channelIDOrDM(this.channel), { + type, + content, + options + }); case 'reply': if(!shouldEdit) return super.reply(content, options); if(options && options.split && !options.split.prepend) options.split.prepend = `${this.author}, `; - return this.editCurrentResponse(channelIDOrDM(this.channel), { type, content, options }); + return this.editCurrentResponse(channelIDOrDM(this.channel), { + type, + content, + options + }); case 'direct': if(!shouldEdit) return this.author.send(content, options); - return this.editCurrentResponse('dm', { type, content, options }); + return this.editCurrentResponse('dm', { + type, + content, + options + }); case 'code': if(!shouldEdit) return this.channel.send(content, options); if(options && options.split) { @@ -302,7 +328,11 @@ module.exports = Structures.extend('Message', Message => { if(!options.split.append) options.split.append = '\n```'; } content = `\`\`\`${lang || ''}\n${escapeMarkdown(content, true)}\n\`\`\``; - return this.editCurrentResponse(channelIDOrDM(this.channel), { type, content, options }); + return this.editCurrentResponse(channelIDOrDM(this.channel), { + type, + content, + options + }); default: throw new RangeError(`Unknown response type "${type}".`); } @@ -316,7 +346,14 @@ module.exports = Structures.extend('Message', Message => { * @private */ editResponse(response, { type, content, options }) { - if(!response) return this.respond({ type, content, options, fromEdit: true }); + if(!response) { + return this.respond({ + type, + content, + options, + fromEdit: true + }); + } if(options && options.split) content = splitMessage(content, options.split); let prepend = ''; @@ -326,8 +363,11 @@ module.exports = Structures.extend('Message', Message => { const promises = []; if(response instanceof Array) { for(let i = 0; i < content.length; i++) { - if(response.length > i) promises.push(response[i].edit(`${prepend}${content[i]}`, options)); - else promises.push(response[0].channel.send(`${prepend}${content[i]}`)); + if(response.length > i) { + promises.push(response[i].edit(`${prepend}${content[i]}`, options)); + } else { + promises.push(response[0].channel.send(`${prepend}${content[i]}`)); + } } } else { promises.push(response.edit(`${prepend}${content[0]}`, options)); @@ -371,7 +411,11 @@ module.exports = Structures.extend('Message', Message => { options = content; content = ''; } - return this.respond({ type: 'plain', content, options }); + return this.respond({ + type: 'plain', + content, + options + }); } /** @@ -385,7 +429,11 @@ module.exports = Structures.extend('Message', Message => { options = content; content = ''; } - return this.respond({ type: 'reply', content, options }); + return this.respond({ + type: 'reply', + content, + options + }); } /** @@ -399,7 +447,11 @@ module.exports = Structures.extend('Message', Message => { options = content; content = ''; } - return this.respond({ type: 'direct', content, options }); + return this.respond({ + type: 'direct', + content, + options + }); } /** @@ -416,7 +468,11 @@ module.exports = Structures.extend('Message', Message => { } if(typeof options !== 'object') options = {}; options.code = lang; - return this.respond({ type: 'code', content, options }); + return this.respond({ + type: 'code', + content, + options + }); } /** @@ -429,7 +485,11 @@ module.exports = Structures.extend('Message', Message => { embed(embed, content = '', options) { if(typeof options !== 'object') options = {}; options.embed = embed; - return this.respond({ type: 'plain', content, options }); + return this.respond({ + type: 'plain', + content, + options + }); } /** @@ -442,7 +502,11 @@ module.exports = Structures.extend('Message', Message => { replyEmbed(embed, content = '', options) { if(typeof options !== 'object') options = {}; options.embed = embed; - return this.respond({ type: 'reply', content, options }); + return this.respond({ + type: 'reply', + content, + options + }); } /** @@ -509,7 +573,8 @@ module.exports = Structures.extend('Message', Message => { // If text remains, push it to the array as-is (except for wrapping quotes, which are removed) if(match && re.lastIndex < argString.length) { const re2 = allowSingleQuote ? /^("|')([^]*)\1$/g : /^(")([^]*)"$/g; - result.push(argString.substr(re.lastIndex).replace(re2, '$2')); + result.push(argString.substr(re.lastIndex) + .replace(re2, '$2')); } return result; } diff --git a/src/i18n/dev.js b/src/i18n/dev.js new file mode 100644 index 000000000..4ed289169 --- /dev/null +++ b/src/i18n/dev.js @@ -0,0 +1,442 @@ +/* eslint-disable camelcase, max-len, id-length */ +// Commando namespace +const { oneLine, stripIndents } = require('common-tags'); + +// Error messages +const error = { + unable_to_send_dm: 'Unable to send you the help DM. You probably have DMs disabled.', + invalid_command_usage: oneLine`Invalid command usage. + The \`{{commandName}}\` command's accepted format is: {{usage}}. + Use {{anyUsage}} for more information.`, + too_many_found: 'Multiple {{what}} found. Please be more specific.', + too_many_found_with_list: 'Multiple {{label}} found, please be more specific: {{itemList}}' +}; + +// Permissions +const permission = { + administrator: 'Administrator', + view_audit_log: 'View audit log', + manage_guild: 'Manage server', + manage_roles: 'Manage roles', + manage_channels: 'Manage channels', + kick_members: 'Kick members', + ban_members: 'Ban members', + create_instant_invite: 'Create instant invite', + change_nickname: 'Change nickname', + manage_nicknames: 'Manage nicknames', + manage_emojis: 'Manage emojis', + manage_webhooks: 'Manage webhooks', + view_channel: 'Read text channels and see voice channels', + send_messages: 'Send messages', + send_tts_messages: 'Send TTS messages', + manage_messages: 'Manage messages', + embed_links: 'Embed links', + attach_files: 'Attach files', + read_message_history: 'Read message history', + mention_everyone: 'Mention everyone', + use_external_emojis: 'Use external emojis', + add_reactions: 'Add reactions', + connect: 'Connect', + speak: 'Speak', + mute_members: 'Mute members', + deafen_members: 'Deafen members', + move_members: 'Move members', + use_vad: 'Use voice activity' +}; + +// Validation for class Command +const base = { + owner_only_command: `The \`{{commandName}}\` command can only be used by the bot owner.`, + guild_only_command: `The \`{{commandName}}\` command must be used in a server channel.`, + nsfw_only_command: `The \`{{commandName}}\` command can only be used in NSFW channels.`, + permission_required: `The \`{{commandName}}\` command requires you to have the "{{permission}}" permission.`, + permission_required_plural: oneLine` + The \`{{commandName}}\` command requires you to have the following permissions: + {{permissionList}} + `, + missing_permissions: `You do not have permission to use the \`{{commandName}}\` command.`, + i_need_permission: `I need the "{{permission}}" permission for the \`{{commandName}}\` command to work.`, + i_need_permission_plural: oneLine` + I need the following permissions for the \`{{commandName}}\` command to work: + {{permissions}} + `, + user_ratelimited: `You may not use the \`{{commandName}}\` command again for another {{seconds}} seconds.`, + unknown_error: stripIndents` + An error occurred while running the command: \`{{errorName}}: {{errorMessage}}\` + You shouldn't ever receive an error like this. + $t(common.contact_owner, {\"count\": \"{{ownerCount}}\", \"invite\": \"{{invite}}\", \"ownerList\": \"{{ownerList}}\" }) + ` +}; + +// Common messages +const common = { + category_channel_plural: 'categories', + channel_plural: 'channels', + command_plural: 'commands', + emoji_plural: 'emojis', + group_plural: 'groups', + member_plural: 'members', + user_plural: 'users', + text_channel_plural: 'text channels', + voice_channel_plural: 'voice channels', + role_plural: 'roles', + or: 'or', + s: 's', + ms: 'ms', + on_all_shards: 'on all shards', + enabled: 'enabled', + enabled_uppercase: 'Enabled', + disabled: 'disabled', + disabled_uppercase: 'Disabled', + cancel_command: 'cancel', + finish_command: 'finish', + contact_owner: `Please contact the bot owner.`, + contact_owner_plural: `Please contact {{ownerList}}.`, + contact_owner_invite: `Please contact the bot owner in this server: {{invite}}.`, + contact_owner_invite_plural: `Please contact {{ownerList}} in this server: {{invite}}.`, + any_server: 'any server', + all_commands: 'All commands', + in_this_dm: 'in this DM', + in_guild: 'in', + available_commands: `Available commands {{inGuildOrDm}}`, + sent_dm_with_information: 'Sent you a DM with information.', + guild_only: 'Usable only in servers', + canceled_command: 'Cancelled command.', + respond_to_cancel: `Respond with \`$t(common.cancel_command)\` to cancel the command.`, + respond_to_cancel_or_finish: `Respond with \`$t(common.cancel_command)\` to cancel the command, or \`$t(common.finish_command)\` to finish entry up to this point.`, + too_long_to_show: 'too long to show', + command_will_be_canceled: 'The command will automatically be cancelled in {{seconds}} seconds.', + command_will_be_canceled_unless_respond: 'The command will automatically be cancelled in {{seconds}} seconds, unless you respond.' +}; + +// Translation of commands +const command = { + disable: { + description: 'Disables a command or command group.', + details: oneLine` + The argument must be the name/ID (partial or whole) of a command or command group. + Only administrators may use this command. + `, + examples: ['disable util', 'disable Utility', 'disable prefix'], + args: { + cmd_or_grp: { + label: 'command/group', + prompt: 'Which command or group would you like to disable?' + } + }, + run: { + group_already_disabled: 'The `{{groupName}}` {{type}} is already disabled.', + cannot_disable_group: 'You cannot disable the `{{groupName}}` {{type}}', + group_disabled: 'Disabled the `{{groupName}}` {{type}}.' + } + }, + enable: { + + description: 'Enables a command or command group.', + details: oneLine` + The argument must be the name/ID (partial or whole) of a command or command group. + Only administrators may use this command. + `, + examples: ['enable util', 'enable Utility', 'enable prefix'], + args: { + cmd_or_grp: { + label: 'command/group', + prompt: 'Which command or group would you like to enable?' + } + }, + run: { + group_already_enabled: `The \`{{group}}\` {{type}} is already enabled{{disabledMessage}}.`, + group_enabled: `Enabled the \`{{group}}\` {{type}}{{disabledMessage}}.`, + group_disabled: `, but the \`{{group}}\` group is disabled, so it still can't be used` + } + }, + groups: { + description: 'Lists all command groups.', + details: 'Only administrators may use this command.', + run: { + response: stripIndents` + __**Groups**__ + {{groups}} + ` + } + }, + load: { + + description: 'Loads a new command.', + details: oneLine` + The argument must be full name of the command in the format of \`group:memberName\`. + Only the bot owner(s) may use this command. + `, + examples: ['load some-command'], + args: { + command: { + prompt: 'Which command would you like to load?' + } + }, + run: { + + command_already_registered: 'That command is already registered.' + } + }, + reload: { + + description: 'Reloads a command or command group.', + details: oneLine` + The argument must be the name/ID (partial or whole) of a command or command group. + Providing a command group will reload all of the commands in that group. + Only the bot owner(s) may use this command. + `, + examples: ['reload some-command'], + args: { + cmd_or_grp: { + label: 'command/group', + prompt: 'Which command or group would you like to reload?' + } + }, + run: { + reload_failed: `Reloaded \`{{groupName}}\` command, but failed to reload on other shards.`, + reload_failed_plural: `Reloaded all of the commands in the \`{{groupName}}\` group, but failed to reload on other shards.`, + reload_succeed: `Reloaded \`{{groupName}}\` command{{onShards}}.`, + reload_succeed_plural: `Reloaded all of the commands in the \`{{groupName}}\` group{{onShards}}.` + } + }, + unload: { + + description: 'Unloads a command.', + details: oneLine` + The argument must be the name/ID (partial or whole) of a command. + Only the bot owner(s) may use this command. + `, + examples: ['unload some-command'], + args: { + command: { + prompt: 'Which command would you like to unload?' + } + }, + run: { + unload_failed: `Unloaded \`{{commandName}}\` command, but failed to unload on other shards.`, + unload_succeed: `Unloaded \`{{commandName}}\` command{{onShards}}.` + } + }, + eval: { + description: 'Executes JavaScript code.', + details: 'Only the bot owner(s) may use this command.', + args: { + script: { + prompt: 'What code would you like to evaluate?' + } + }, + run: { + callback_error: `Callback error: \`{{val}}\``, + evaluating_error: `Error while evaluating: \`{{err}}\``, + executed_in: `*Executed in {{s}}{{ms}}.*`, + executed_after: `Callback executed after {{s}}{{ms}}.` + } + }, + help: { + description: 'Displays a list of available commands, or detailed information for a specified command.', + details: oneLine` + The command may be part of a command name or a whole command name. + If it isn't specified, all available commands will be listed. + `, + examples: ['help', 'help prefix'], + args: { + command: { + prompt: 'Which command would you like to view the help for?' + } + }, + run: { + description: oneLine` + __Command **{{name}}**:__ {{description}} + {{guildOnly}} + {{nsfw}} + `, + format: '**Format:** {{format}}', + aliases: `\n**Aliases:** {{aliases}}`, + group: `\n${oneLine` + **Group:** {{groupName}} + (\`{{groupID}}:{{memberName}}\`) + `}`, + details: `\n**Details:** {{details}}`, + examples: `\n**Examples:**\n{{examples}}`, + multiple_commands_error: 'Multiple commands found. Please be more specific.', + identify_command_error: 'Unable to identify command. Use {{usage}} to view the list of all commands.', + command_usage: stripIndents` + ${oneLine` + To run a command in {{guild}}, + use {{commandUsage}}. + For example, {{example}}. + `} + To run a command in this DM, simply use {{usageWithoutPrefix}} with no prefix. + + Use {{usage}} to view detailed information about a specific command. + Use {{usageAll}} to view a list of *all* commands, not just available ones. + + __**{{availableCommands}}**__ + + {{commandList}} + ` + } + }, + ping: { + description: 'Checks the bot\'s ping to the Discord server.', + run: { + pinging: 'Pinging...', + pong: oneLine` + {{mention}} + Pong! The message round-trip took {{duration}}ms. + {{pingResponse}} + `, + heartbeat_ping: `The heartbeat ping is {{heartbeatPing}}ms.` + } + }, + prefix: { + description: 'Shows or sets the command prefix.', + format: '[prefix/"default"/"none"]', + details: oneLine` + If no prefix is provided, the current prefix will be shown. + If the prefix is "default", the prefix will be reset to the bot's default prefix. + If the prefix is "none", the prefix will be removed entirely, only allowing mentions to run commands. + Only administrators may change the prefix. + `, + examples: ['prefix', 'prefix -', 'prefix omg!', 'prefix default', 'prefix none'], + args: { + prefix: { + prompt: 'What would you like to set the bot\'s prefix to?' + } + }, + run: { + no_command_prefix: 'There is no command prefix.', + the_prefix_is: `The command prefix is \`\`{{prefix}}\`\`.`, + how_to_run: 'To run commands, use {{usage}}.', + admins_only: 'Only administrators may change the command prefix.', + owner_only: 'Only the bot owner(s) may change the global command prefix.', + current_prefix: `Reset the command prefix to the default (currently {{prefix}}).`, + no_prefix: 'no prefix', + prefix_set_to: `Set the command prefix to \`\`{{prefix}}\`\`.`, + prefix_removed: 'Removed the command prefix entirely.', + prefix_usage: `{{response}} To run commands, use {{usage}}.` + } + }, + language: { + description: 'Shows or sets the guild language.', + format: `[/"default"] and for actions [ ]`, + details: oneLine` + If no language is provided, the default language will be shown. + If the language is "default", the language will be reset to the bot's default language. + You can perform the actions "load" and "reload" on languages, to load/reload language files. + Only administrators may change the language. + `, + examples: ['language', 'language de', 'language default', 'language en load', 'language de reload'], + args: { + language: { + prompt: 'What would you like to set the bot\'s language to?' + }, + action: { + prompt: 'What action would you like to perform for the language?' + } + }, + run: { + no_guild_language: 'There is no language defined for this guild.', + guild_language_is: `The guild language is \`\`{{language}}\`\`.`, + bot_language_is: `The bot language is \`\`{{language}}\`\`.`, + admins_only: 'Only administrators may change the guild language.', + owner_only: 'Only the bot owner(s) may change the global guild language.', + action_owner_only: 'Only the bot owner(s) may perform actions on languages.', + current_language: `Reset the guild language to the default (currently {{language}}).`, + language_set: `Set the bot's global language to \`\`{{language}}\`\`.`, + language_set_guild: `Set the guild language to \`\`{{language}}\`\`.`, + no_language_file: `Language file for \`\`{{language}}\`\` could not be loaded.`, + language_not_supported: `The language \`\`{{language}}\`\` is not available. Available languages are:\`\`\`{{availableLanguages}}\`\`\``, + language_not_supported_short: `The language \`\`{{language}}\`\` is not available.`, + load_complete_succeed: `The language \`\`{{language}}\`\` has been loaded successful.`, + load_complete_failed: `The language \`\`{{language}}\`\` could not be loaded, because there are no files, for this language, to load.`, + load_failed: `The language \`\`{{language}}\`\` could not be loaded.\n\`\`\`{{error}}\`\`\``, + reload_complete_succeed: `The language \`\`{{language}}\`\` has been reloaded successful.`, + reload_complete_failed: `The language \`\`{{language}}\`\` could not be reloaded, because there are no files, for this language, to load.`, + reload_failed: `The language \`\`{{language}}\`\` could not be reloaded.\n\`\`\`{{error}}\`\`\``, + action_not_supported: `I cannot handle the action \`{{action}}\`.` + } + }, + unknown_command: { + description: 'Displays help information for when an unknown command is used.', + examples: ['unknown-command kickeverybodyever'], + run: { + response: 'Unknown command. Use {{usage}} to view the command list.' + } + } +}; + +// Translations in class Argument +const argument = { + invalid_label: 'You provided an invalid {{label}}. Please try again.', + invalid_label_extended: oneLine` + You provided an invalid {{label}}, + "{{escaped}}". + Please try again. + ` +}; + +// Argument Type translations + +const argument_type = { + boolean: { + truthy: ['t', 'yes', 'y', 'on', 'enable', 'enabled'], + falsy: ['f', 'no', 'n', 'off', 'disable', 'disabled'], + unknown_boolean: 'Unknown boolean value.' + }, + float: { + value_too_small: 'Please enter a number above or exactly {{min}}.', + value_too_big: 'Please enter a number below or exactly {{max}}.', + available_options: 'Please enter one of the following options: {{options}}' + }, + integer: { + value_too_small: 'Please enter a number above or exactly {{min}}.', + value_too_big: 'Please enter a number below or exactly {{max}}.', + available_options: 'Please enter one of the following options: {{options}}' + }, + string: { + length_too_small: 'Please keep the {{label}} above or exactly {{min}} characters.', + length_too_big: 'Please keep the {{label}} below or exactly {{max}} characters.', + available_options: 'Please enter one of the following options: {{options}}' + }, + union: { + argument_not_registered: `Couldn't parse value "{{val}}" with union type {{id}}.` + } +}; +const commandoNamespace = { + error, + permission, + base, + common, + argument, + argument_type, + command +}; + +const defaultCommandoTranslations = { + dev: { + commando: commandoNamespace + } +}; + +module.exports = { + defaultCommandoTranslations +}; + +/* +* Creates a translation file which can be used as base for other translation files. +* */ +function createTranslationFile(path) { + const fs = require('fs'); + + if(typeof path === 'undefined') path = 'commando.json'; + + const content = JSON.stringify(commandoNamespace, null, 4); + + fs.writeFile(path, content, 'utf8', err => { + console.error(err); + }); +} + +module.exports = { createTranslationFile }; diff --git a/src/providers/sqlite-sync.js b/src/providers/sqlite-sync.js index fd4b16974..89de25725 100644 --- a/src/providers/sqlite-sync.js +++ b/src/providers/sqlite-sync.js @@ -92,6 +92,7 @@ class SyncSQLiteProvider extends SettingProvider { // Listen for changes this.listeners .set('commandPrefixChange', (guild, prefix) => this.set(guild, 'prefix', prefix)) + .set('guildLanguageChange', (guild, language) => this.set(guild, 'language', language)) .set('commandStatusChange', (guild, command, enabled) => this.set(guild, `cmd-${command.name}`, enabled)) .set('groupStatusChange', (guild, group, enabled) => this.set(guild, `grp-${group.id}`, enabled)) .set('guildCreate', guild => { @@ -174,6 +175,12 @@ class SyncSQLiteProvider extends SettingProvider { else this.client._commandPrefix = settings.prefix; } + // Load the command prefix + if(typeof settings.language !== 'undefined') { + if(guild) guild._language = settings.language; + else this.client._defaultLanguage = settings.language; + } + // Load all command/group statuses for(const command of this.client.registry.commands.values()) this.setupGuildCommand(guild, command, settings); for(const group of this.client.registry.groups.values()) this.setupGuildGroup(guild, group, settings); diff --git a/src/providers/sqlite.js b/src/providers/sqlite.js index c81af1912..dba2f9700 100644 --- a/src/providers/sqlite.js +++ b/src/providers/sqlite.js @@ -96,6 +96,7 @@ class SQLiteProvider extends SettingProvider { // Listen for changes this.listeners .set('commandPrefixChange', (guild, prefix) => this.set(guild, 'prefix', prefix)) + .set('guildLanguageChange', (guild, language) => this.set(guild, 'language', language)) .set('commandStatusChange', (guild, command, enabled) => this.set(guild, `cmd-${command.name}`, enabled)) .set('groupStatusChange', (guild, group, enabled) => this.set(guild, `grp-${group.id}`, enabled)) .set('guildCreate', guild => { @@ -184,6 +185,12 @@ class SQLiteProvider extends SettingProvider { else this.client._commandPrefix = settings.prefix; } + // Load the language + if(typeof settings.language !== 'undefined') { + if(guild) guild._language = settings.language; + else this.client._defaultLanguage = settings.language; + } + // Load all command/group statuses for(const command of this.client.registry.commands.values()) this.setupGuildCommand(guild, command, settings); for(const group of this.client.registry.groups.values()) this.setupGuildGroup(guild, group, settings); diff --git a/src/registry.js b/src/registry.js index 0551bc2a9..c1a6a4bbf 100644 --- a/src/registry.js +++ b/src/registry.js @@ -298,6 +298,8 @@ class CommandoRegistry { * (requires "util" group and "string" type) * @param {boolean} [commands.prefix=true] - Whether to register the built-in prefix command * (requires "util" group and "string" type) + * @param {boolean} [commands.language=true] - Whether to register the built-in language command + * (requires "util" group and "string" type) * @param {boolean} [commands.eval=true] - Whether to register the built-in eval command * (requires "util" group and "string" type) * @param {boolean} [commands.ping=true] - Whether to register the built-in ping command (requires "util" group) @@ -309,11 +311,12 @@ class CommandoRegistry { */ registerDefaultCommands(commands = {}) { commands = { - help: true, prefix: true, ping: true, eval: true, + help: true, prefix: true, language: true, ping: true, eval: true, unknownCommand: true, commandState: true, ...commands }; if(commands.help) this.registerCommand(require('./commands/util/help')); if(commands.prefix) this.registerCommand(require('./commands/util/prefix')); + if(commands.language) this.registerCommand(require('./commands/util/language')); if(commands.ping) this.registerCommand(require('./commands/util/ping')); if(commands.eval) this.registerCommand(require('./commands/util/eval')); if(commands.unknownCommand) this.registerCommand(require('./commands/util/unknown-command')); diff --git a/src/translator.js b/src/translator.js new file mode 100644 index 000000000..57d85c03e --- /dev/null +++ b/src/translator.js @@ -0,0 +1,200 @@ +const i18next = require('i18next'); +const Backend = require('i18next-fs-backend'); +const { oneLine } = require('common-tags'); +const { defaultCommandoTranslations } = require('./i18n/dev'); + +/** + * Provides methods for translation + * However, i18next package can be used to access all it's features + * */ +class CommandoTranslator { + /** + * @typedef {Object} CommandoTranslatorOptions + * @property {?boolean} loadTranslations - Weather the translator should load translation files or only the builtins. + * @property {?boolean} debug - Sets the i18next debug flag. Use it to resolve issues when loading i18n files. + * @property {?string} localesPath - path where the i18n files are located. + * @see {@link https://www.i18next.com/how-to/add-or-load-translations#add-or-load-translations} + * @property {?TranslateOptions} overrides - Overrides the i18next options. + * @see {@link https://www.i18next.com/overview/configuration-options} + */ + + /** + * @param {CommandoClient} [client] - Client the translator is for + * @param {?CommandoTranslatorOptions} [options] - Options for the translator + */ + constructor(client, options = {}) { + // Set additional namespaces + if(Array.isArray(options.ns)) { + this.ns = options.ns; + } else if(typeof ns === 'string') { + this.ns = [options.ns]; + } else { + this.ns = []; + } + + this.client = client; + + this.loadTranslations = options.loadTranslations; + this.loadPath = options.localesPath; + this.debug = options.debug === true; + this.overrides = options.overrides || {}; + + this.options = { + lng: client.defaultLanguage, + ns: ['commando', ...this.ns], + fallbackLng: ['dev'], + defaultNS: 'commando', + debug: this.debug, + ...this.overrides + }; + + // Only loads the builtin commando translations + this.init(); + } + + static get DEFAULT_LANGUAGE() { + return 'dev'; + } + + /** + * Initializes the i18next library + * @return {Promise} + */ + async init() { + const timeLabel = `[${CommandoTranslator.name}] Initialized in`; + console.time(timeLabel); + if(this.loadTranslations) { + if(typeof this.loadPath === 'undefined') { + throw new Error( + oneLine` + A value for loadPath must be provided to load localization files. + Set loadTranslations to false if you don't want to load translation files. + `); + } + + // Loads translations + i18next.use(Backend); + await i18next.init( + { + ...this.options, + resources: defaultCommandoTranslations, + backend: { + loadPath: this.loadPath + } + }, + err => { + if(err) { + return console.log('Something went wrong loading the localization files.', err); + } else { + return null; + } + } + ); + + /* + * This loads all resource files in the passed loadPath. + * We need to do that, because we initialize i18next with commando translations only. + * This will also override the builtin commando translations, when the file for namespace "commando" does exist! + * */ + await i18next.reloadResources(this.options.fallbackLng); + } else { + await i18next.init( + { + ...this.options, + resources: defaultCommandoTranslations + }, + err => { + if(err) { + return console.log('Something went wrong initializing i18next.', err); + } else { + return null; + } + } + ); + } + + const loadedLanguages = i18next.languages || []; + console.timeEnd(timeLabel); + console.log(oneLine`[${CommandoTranslator.name}] + The following languages have been loaded: ${loadedLanguages.join(', ')}. + Default language is: ${this.client.defaultLanguage}.`); + } + + /** + * Resolves the language to translate to + * @param {?CommandoMessage} msg - Command message that triggered the command + * @return {string} + */ + resolveLanguage(msg) { + if(typeof msg === 'undefined') { + return this.client.defaultLanguage; + } else if(msg.channel.type === 'dm') { + return msg.author.user ? msg.author.user.locale || this.client.defaultLanguage : this.client.defaultLanguage; + } else { + return msg.guild ? msg.guild.language : this.client.defaultLanguage; + } + } + + + /** + * Loads additional namespaces + * @see {@link https://www.i18next.com/principles/namespaces} + * @param {string|string[]} ns - One or multiple namespaces to load + * @param {?TCallback} callback - Optional callback function + */ + async loadNamespaces(ns, callback) { + await i18next.loadNamespaces(ns, callback); + return null; + } +} + +/** + * Represents a string which can be translated + * */ +class CommandoTranslatable { + /** + * @typedef {Object} CommandoTranslatable - Represents a string which can be translated + * @property {string} key - The key which will be resolved. + * @function {string} key - Getter for the key + */ + + constructor(key) { + /** + * The key to identify the translation string + * @type {string} + * @private + */ + this._key = key; + } + + + /** + * Getter + * @name CommandoTranslatable#_key + * @type {string} + * @readonly + */ + get key() { + return this._key; + } + + /** + * Translates this Translatable. This method calls i18next.t() with the key passed through the constructor. + * @see {@link https://www.i18next.com/translation-function/essentials} + * @param {TranslateOptions} options - I18next options object + * @return {string} - The translated string + */ + translate(options) { + const isUndefined = typeof this._key === 'undefined'; + const isEmptyString = typeof this._key === 'string' && this._key.length === 0; + + if(isUndefined || isEmptyString) return ''; + + return i18next.t(this._key, options); + } +} + +module.exports = { + CommandoTranslator, + CommandoTranslatable +}; diff --git a/src/types/boolean.js b/src/types/boolean.js index 8afdc6d36..846c5ff37 100644 --- a/src/types/boolean.js +++ b/src/types/boolean.js @@ -1,23 +1,47 @@ const ArgumentType = require('./base'); +const i18next = require('i18next'); class BooleanArgumentType extends ArgumentType { constructor(client) { super(client, 'boolean'); - this.truthy = new Set(['true', 't', 'yes', 'y', 'on', 'enable', 'enabled', '1', '+']); - this.falsy = new Set(['false', 'f', 'no', 'n', 'off', 'disable', 'disabled', '0', '-']); } - validate(val) { + validate(val, msg) { const lc = val.toLowerCase(); - return this.truthy.has(lc) || this.falsy.has(lc); + const aliases = this.resolveBooleanAliases(msg); + return aliases.truthy.has(lc) || aliases.falsy.has(lc); } - parse(val) { + parse(val, msg) { const lc = val.toLowerCase(); - if(this.truthy.has(lc)) return true; - if(this.falsy.has(lc)) return false; + const aliases = this.resolveBooleanAliases(msg); + if(aliases.truthy.has(lc)) return true; + if(aliases.falsy.has(lc)) return false; throw new RangeError('Unknown boolean value.'); } + + /* + * Additional values for truthy and falsy are defined in translations + * */ + resolveBooleanAliases(msg) { + const lng = msg.client.translator.resolveLanguage(msg); + const localizedTruthyValues = i18next.t('argument_type.boolean.truthy', { + lng, + returnObjects: true + }); + const aliases = {}; + aliases.truthy = new Set(['true', '1', '+'] + .concat(Array.isArray(localizedTruthyValues) ? localizedTruthyValues : [])); + + const localizedFalsyValues = i18next.t('argument_type.boolean.falsy', { + lng, + returnObjects: true + }); + aliases.falsy = new Set(['false', '0', '-'] + .concat(Array.isArray(localizedFalsyValues) ? localizedFalsyValues : [])); + + return aliases; + } } module.exports = BooleanArgumentType; diff --git a/src/types/category-channel.js b/src/types/category-channel.js index 0b3c3c248..8a47ef280 100644 --- a/src/types/category-channel.js +++ b/src/types/category-channel.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class CategoryChannelArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class CategoryChannelArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = val.match(/^([0-9]+)$/); if(matches) { try { @@ -34,10 +36,17 @@ class CategoryChannelArgumentType extends ArgumentType { } if(exactChannels.size > 0) channels = exactChannels; return channels.size <= 15 ? - `${disambiguation( - channels.map(chan => escapeMarkdown(chan.name)), 'categories', null - )}\n` : - 'Multiple categories found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.categories)', + itemList: disambiguation( + channels.map(chan => escapeMarkdown(chan.name)), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.categories)' + }); } parse(val, msg) { @@ -59,7 +68,8 @@ function channelFilterExact(search) { } function channelFilterInexact(search) { - return chan => chan.type === 'category' && chan.name.toLowerCase().includes(search); + return chan => chan.type === 'category' && chan.name.toLowerCase() + .includes(search); } module.exports = CategoryChannelArgumentType; diff --git a/src/types/channel.js b/src/types/channel.js index f9337ae4e..89e8ab6e5 100644 --- a/src/types/channel.js +++ b/src/types/channel.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class ChannelArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class ChannelArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = val.match(/^(?:<#)?([0-9]+)>?$/); if(matches) return msg.guild.channels.cache.has(matches[1]); const search = val.toLowerCase(); @@ -24,8 +26,17 @@ class ChannelArgumentType extends ArgumentType { } if(exactChannels.size > 0) channels = exactChannels; return channels.size <= 15 ? - `${disambiguation(channels.map(chan => escapeMarkdown(chan.name)), 'channels', null)}\n` : - 'Multiple channels found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.category_channel_plural)', + itemList: disambiguation( + channels.map(chan => escapeMarkdown(chan.name)), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.category_channel_plural)' + }); } parse(val, msg) { @@ -46,7 +57,8 @@ function nameFilterExact(search) { } function nameFilterInexact(search) { - return thing => thing.name.toLowerCase().includes(search); + return thing => thing.name.toLowerCase() + .includes(search); } module.exports = ChannelArgumentType; diff --git a/src/types/command.js b/src/types/command.js index 80c7ea07d..859e18e22 100644 --- a/src/types/command.js +++ b/src/types/command.js @@ -1,19 +1,30 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class CommandArgumentType extends ArgumentType { constructor(client) { super(client, 'command'); } - validate(val) { + validate(val, msg) { + const lng = msg.client.translator.resolveLanguage(msg); const commands = this.client.registry.findCommands(val); if(commands.length === 1) return true; if(commands.length === 0) return false; return commands.length <= 15 ? - `${disambiguation(commands.map(cmd => escapeMarkdown(cmd.name)), 'commands', null)}\n` : - 'Multiple commands found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.command_plural)', + itemList: disambiguation( + commands.map(cmd => escapeMarkdown(cmd.name)), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.command_plural)' + }); } parse(val) { diff --git a/src/types/custom-emoji.js b/src/types/custom-emoji.js index 7494959a0..29b45e51f 100644 --- a/src/types/custom-emoji.js +++ b/src/types/custom-emoji.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class CustomEmojiArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class CustomEmojiArgumentType extends ArgumentType { } validate(value, msg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = value.match(/^(?:?$/); if(matches && msg.client.emojis.cache.has(matches[2])) return true; if(!msg.guild) return false; @@ -19,8 +21,17 @@ class CustomEmojiArgumentType extends ArgumentType { if(exactEmojis.size === 1) return true; if(exactEmojis.size > 0) emojis = exactEmojis; return emojis.size <= 15 ? - `${disambiguation(emojis.map(emoji => escapeMarkdown(emoji.name)), 'emojis', null)}\n` : - 'Multiple emojis found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.emoji_plural)', + itemList: disambiguation( + emojis.map(emoji => escapeMarkdown(emoji.name)), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.emoji_plural)' + }); } parse(value, msg) { diff --git a/src/types/float.js b/src/types/float.js index 7e0d852fb..9eef4d632 100644 --- a/src/types/float.js +++ b/src/types/float.js @@ -1,4 +1,5 @@ const ArgumentType = require('./base'); +const i18next = require('i18next'); class FloatArgumentType extends ArgumentType { constructor(client) { @@ -6,16 +7,27 @@ class FloatArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const float = Number.parseFloat(val); if(Number.isNaN(float)) return false; if(arg.oneOf && !arg.oneOf.includes(float)) { - return `Please enter one of the following options: ${arg.oneOf.map(opt => `\`${opt}\``).join(', ')}`; + return i18next.t('argument_type.float.available_options', { + lng, + options: arg.oneOf.map(opt => `\`${opt}\``) + .join(', ') + }); } if(arg.min !== null && typeof arg.min !== 'undefined' && float < arg.min) { - return `Please enter a number above or exactly ${arg.min}.`; + return i18next.t('argument_type.float.value_too_small', { + lng, + min: arg.min + }); } if(arg.max !== null && typeof arg.max !== 'undefined' && float > arg.max) { - return `Please enter a number below or exactly ${arg.max}.`; + return i18next.t('argument_type.float.value_too_big', { + lng, + max: arg.max + }); } return true; } diff --git a/src/types/group.js b/src/types/group.js index 19c3df8df..98b3e00eb 100644 --- a/src/types/group.js +++ b/src/types/group.js @@ -1,19 +1,30 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class GroupArgumentType extends ArgumentType { constructor(client) { super(client, 'group'); } - validate(val) { + validate(val, msg) { + const lng = msg.client.translator.resolveLanguage(msg); const groups = this.client.registry.findGroups(val); if(groups.length === 1) return true; if(groups.length === 0) return false; return groups.length <= 15 ? - `${disambiguation(groups.map(grp => escapeMarkdown(grp.name)), 'groups', null)}\n` : - 'Multiple groups found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.group_plural)', + itemList: disambiguation( + groups.map(grp => escapeMarkdown(grp.name)), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.group_plural)' + }); } parse(val) { diff --git a/src/types/integer.js b/src/types/integer.js index e02352421..aa47fc325 100644 --- a/src/types/integer.js +++ b/src/types/integer.js @@ -1,4 +1,5 @@ const ArgumentType = require('./base'); +const i18next = require('i18next'); class IntegerArgumentType extends ArgumentType { constructor(client) { @@ -6,16 +7,27 @@ class IntegerArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const int = Number.parseInt(val); if(Number.isNaN(int)) return false; if(arg.oneOf && !arg.oneOf.includes(int)) { - return `Please enter one of the following options: ${arg.oneOf.map(opt => `\`${opt}\``).join(', ')}`; + return i18next.t('argument_type.integer.available_options', { + lng, + options: arg.oneOf.map(opt => `\`${opt}\``) + .join(', ') + }); } if(arg.min !== null && typeof arg.min !== 'undefined' && int < arg.min) { - return `Please enter a number above or exactly ${arg.min}.`; + return i18next.t('argument_type.integer.value_too_small', { + lng, + min: arg.min + }); } if(arg.max !== null && typeof arg.max !== 'undefined' && int > arg.max) { - return `Please enter a number below or exactly ${arg.max}.`; + return i18next.t('argument_type.integer.value_too_big', { + lng, + max: arg.max + }); } return true; } diff --git a/src/types/member.js b/src/types/member.js index 2a80cad06..22f664c5f 100644 --- a/src/types/member.js +++ b/src/types/member.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class MemberArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class MemberArgumentType extends ArgumentType { } async validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = val.match(/^(?:<@!?)?([0-9]+)>?$/); if(matches) { try { @@ -33,10 +35,17 @@ class MemberArgumentType extends ArgumentType { } if(exactMembers.size > 0) members = exactMembers; return members.size <= 15 ? - `${disambiguation( - members.map(mem => `${escapeMarkdown(mem.user.username)}#${mem.user.discriminator}`), 'members', null - )}\n` : - 'Multiple members found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.member_plural)', + itemList: disambiguation( + members.map(mem => `${escapeMarkdown(mem.user.username)}#${mem.user.discriminator}`), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.member_plural)' + }); } parse(val, msg) { diff --git a/src/types/role.js b/src/types/role.js index ae90bb790..033e6c476 100644 --- a/src/types/role.js +++ b/src/types/role.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class RoleArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class RoleArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = val.match(/^(?:<@&)?([0-9]+)>?$/); if(matches) return msg.guild.roles.cache.has(matches[1]); const search = val.toLowerCase(); @@ -24,8 +26,17 @@ class RoleArgumentType extends ArgumentType { } if(exactRoles.size > 0) roles = exactRoles; return roles.size <= 15 ? - `${disambiguation(roles.map(role => `${escapeMarkdown(role.name)}`), 'roles', null)}\n` : - 'Multiple roles found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.role_plural)', + itemList: disambiguation( + roles.map(role => `${escapeMarkdown(role.name)}`), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.role_plural)' + }); } parse(val, msg) { diff --git a/src/types/string.js b/src/types/string.js index 7b2bc3756..23b3360a6 100644 --- a/src/types/string.js +++ b/src/types/string.js @@ -1,4 +1,5 @@ const ArgumentType = require('./base'); +const i18next = require('i18next'); class StringArgumentType extends ArgumentType { constructor(client) { @@ -6,14 +7,27 @@ class StringArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); if(arg.oneOf && !arg.oneOf.includes(val.toLowerCase())) { - return `Please enter one of the following options: ${arg.oneOf.map(opt => `\`${opt}\``).join(', ')}`; + return i18next.t('argument_type.string.available_options', { + lng, + options: arg.oneOf.map(opt => `\`${opt}\``) + .join(', ') + }); } if(arg.min !== null && typeof arg.min !== 'undefined' && val.length < arg.min) { - return `Please keep the ${arg.label} above or exactly ${arg.min} characters.`; + return i18next.t('argument_type.string.value_too_small', { + lng, + min: arg.min, + label: arg.label + }); } if(arg.max !== null && typeof arg.max !== 'undefined' && val.length > arg.max) { - return `Please keep the ${arg.label} below or exactly ${arg.max} characters.`; + return i18next.t('argument_type.string.value_too_big', { + lng, + max: arg.max, + label: arg.label + }); } return true; } diff --git a/src/types/text-channel.js b/src/types/text-channel.js index 223963c43..605d3da86 100644 --- a/src/types/text-channel.js +++ b/src/types/text-channel.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class TextChannelArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class TextChannelArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = val.match(/^(?:<#)?([0-9]+)>?$/); if(matches) { try { @@ -34,10 +36,17 @@ class TextChannelArgumentType extends ArgumentType { } if(exactChannels.size > 0) channels = exactChannels; return channels.size <= 15 ? - `${disambiguation( - channels.map(chan => escapeMarkdown(chan.name)), 'text channels', null - )}\n` : - 'Multiple text channels found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.text_channel_plural)', + itemList: disambiguation( + channels.map(chan => escapeMarkdown(chan.name)), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.text_channel_plural)' + }); } parse(val, msg) { @@ -59,7 +68,8 @@ function channelFilterExact(search) { } function channelFilterInexact(search) { - return chan => chan.type === 'text' && chan.name.toLowerCase().includes(search); + return chan => chan.type === 'text' && chan.name.toLowerCase() + .includes(search); } module.exports = TextChannelArgumentType; diff --git a/src/types/union.js b/src/types/union.js index 7beb5aa62..12aa25168 100644 --- a/src/types/union.js +++ b/src/types/union.js @@ -1,4 +1,5 @@ const ArgumentType = require('./base'); +const i18next = require('i18next'); /** * A type for command arguments that handles multiple other types @@ -31,12 +32,17 @@ class ArgumentUnionType extends ArgumentType { } async parse(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); let results = this.types.map(type => !type.isEmpty(val, msg, arg) && type.validate(val, msg, arg)); results = await Promise.all(results); for(let i = 0; i < results.length; i++) { if(results[i] && typeof results[i] !== 'string') return this.types[i].parse(val, msg, arg); } - throw new Error(`Couldn't parse value "${val}" with union type ${this.id}.`); + throw new Error(i18next.t('argument_type.union.argument_not_registered', { + lng, + val, + id: this.id + })); } isEmpty(val, msg, arg) { diff --git a/src/types/user.js b/src/types/user.js index 17c663d6e..2729fdac5 100644 --- a/src/types/user.js +++ b/src/types/user.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class UserArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class UserArgumentType extends ArgumentType { } async validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = val.match(/^(?:<@!?)?([0-9]+)>?$/); if(matches) { try { @@ -34,10 +36,17 @@ class UserArgumentType extends ArgumentType { } if(exactMembers.size > 0) members = exactMembers; return members.size <= 15 ? - `${disambiguation( - members.map(mem => `${escapeMarkdown(mem.user.username)}#${mem.user.discriminator}`), 'users', null - )}\n` : - 'Multiple users found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.user_plural)', + itemList: disambiguation( + members.map(mem => `${escapeMarkdown(mem.user.username)}#${mem.user.discriminator}`), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.user_plural)' + }); } parse(val, msg) { @@ -61,8 +70,10 @@ function memberFilterExact(search) { } function memberFilterInexact(search) { - return mem => mem.user.username.toLowerCase().includes(search) || - (mem.nickname && mem.nickname.toLowerCase().includes(search)) || + return mem => mem.user.username.toLowerCase() + .includes(search) || + (mem.nickname && mem.nickname.toLowerCase() + .includes(search)) || `${mem.user.username.toLowerCase()}#${mem.user.discriminator}`.includes(search); } diff --git a/src/types/voice-channel.js b/src/types/voice-channel.js index d77b30ddf..cfd890afe 100644 --- a/src/types/voice-channel.js +++ b/src/types/voice-channel.js @@ -1,6 +1,7 @@ const ArgumentType = require('./base'); const { disambiguation } = require('../util'); const { escapeMarkdown } = require('discord.js'); +const i18next = require('i18next'); class VoiceChannelArgumentType extends ArgumentType { constructor(client) { @@ -8,6 +9,7 @@ class VoiceChannelArgumentType extends ArgumentType { } validate(val, msg, arg) { + const lng = msg.client.translator.resolveLanguage(msg); const matches = val.match(/^([0-9]+)$/); if(matches) { try { @@ -34,10 +36,17 @@ class VoiceChannelArgumentType extends ArgumentType { } if(exactChannels.size > 0) channels = exactChannels; return channels.size <= 15 ? - `${disambiguation( - channels.map(chan => escapeMarkdown(chan.name)), 'voice channels', null - )}\n` : - 'Multiple voice channels found. Please be more specific.'; + `${i18next.t('error.too_many_found_with_list', { + lng, + label: '$t(common.voice_channel_plural)', + itemList: disambiguation( + channels.map(chan => escapeMarkdown(chan.name)), null + ) + })}\n` : + i18next.t('error.too_many_found', { + lng, + what: '$t(common.voice_channel_plural)' + }); } parse(val, msg) { @@ -59,7 +68,8 @@ function channelFilterExact(search) { } function channelFilterInexact(search) { - return chan => chan.type === 'voice' && chan.name.toLowerCase().includes(search); + return chan => chan.type === 'voice' && chan.name.toLowerCase() + .includes(search); } module.exports = VoiceChannelArgumentType; diff --git a/src/util.js b/src/util.js index 5f5030eba..12ed22d65 100644 --- a/src/util.js +++ b/src/util.js @@ -2,9 +2,10 @@ function escapeRegex(str) { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); } -function disambiguation(items, label, property = 'name') { - const itemList = items.map(item => `"${(property ? item[property] : item).replace(/ /g, '\xa0')}"`).join(', '); - return `Multiple ${label} found, please be more specific: ${itemList}`; +function disambiguation(items, property = 'name') { + const itemList = items.map(item => `"${(property ? item[property] : item).replace(/ /g, '\xa0')}"`) + .join(', '); + return itemList; } function paginate(items, page = 1, pageLength = 10) { @@ -21,34 +22,34 @@ function paginate(items, page = 1, pageLength = 10) { } const permissions = { - ADMINISTRATOR: 'Administrator', - VIEW_AUDIT_LOG: 'View audit log', - MANAGE_GUILD: 'Manage server', - MANAGE_ROLES: 'Manage roles', - MANAGE_CHANNELS: 'Manage channels', - KICK_MEMBERS: 'Kick members', - BAN_MEMBERS: 'Ban members', - CREATE_INSTANT_INVITE: 'Create instant invite', - CHANGE_NICKNAME: 'Change nickname', - MANAGE_NICKNAMES: 'Manage nicknames', - MANAGE_EMOJIS: 'Manage emojis', - MANAGE_WEBHOOKS: 'Manage webhooks', - VIEW_CHANNEL: 'Read text channels and see voice channels', - SEND_MESSAGES: 'Send messages', - SEND_TTS_MESSAGES: 'Send TTS messages', - MANAGE_MESSAGES: 'Manage messages', - EMBED_LINKS: 'Embed links', - ATTACH_FILES: 'Attach files', - READ_MESSAGE_HISTORY: 'Read message history', - MENTION_EVERYONE: 'Mention everyone', - USE_EXTERNAL_EMOJIS: 'Use external emojis', - ADD_REACTIONS: 'Add reactions', - CONNECT: 'Connect', - SPEAK: 'Speak', - MUTE_MEMBERS: 'Mute members', - DEAFEN_MEMBERS: 'Deafen members', - MOVE_MEMBERS: 'Move members', - USE_VAD: 'Use voice activity' + ADMINISTRATOR: 'permission.administrator', + VIEW_AUDIT_LOG: 'permission.view_audit_log', + MANAGE_GUILD: 'permission.manage_guild', + MANAGE_ROLES: 'permission.manage_roles', + MANAGE_CHANNELS: 'permission.manage_channels', + KICK_MEMBERS: 'permission.kick_members', + BAN_MEMBERS: 'permission.ban_members', + CREATE_INSTANT_INVITE: 'permission.create_instant_invite', + CHANGE_NICKNAME: 'permission.change_nickname', + MANAGE_NICKNAMES: 'permission.manage_nicknames', + MANAGE_EMOJIS: 'permission.manage_emojis', + MANAGE_WEBHOOKS: 'permission.manage_webhooks', + VIEW_CHANNEL: 'permission.view_channel', + SEND_MESSAGES: 'permission.send_messages', + SEND_TTS_MESSAGES: 'permission.send_tts_messages', + MANAGE_MESSAGES: 'permission.manage_messages', + EMBED_LINKS: 'permission.embed_links', + ATTACH_FILES: 'permission.attach_files', + READ_MESSAGE_HISTORY: 'permission.read_message_history', + MENTION_EVERYONE: 'permission.mention_everyone', + USE_EXTERNAL_EMOJIS: 'permission.use_external_emojis', + ADD_REACTIONS: 'permission.add_reactions', + CONNECT: 'permission.connect', + SPEAK: 'permission.speak', + MUTE_MEMBERS: 'permission.mute_members', + DEAFEN_MEMBERS: 'permission.deafen_members', + MOVE_MEMBERS: 'permission.move_members', + USE_VAD: 'permission.use_vad' }; module.exports = { diff --git a/typings/index.d.ts b/typings/index.d.ts index 5813fcdc2..e48aef601 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -1,4 +1,5 @@ declare module 'discord.js-commando' { + import {TOptions, Callback as TCallback, InitOptions as TInitOptions} from "i18next"; import { Channel, Client, ClientOptions, Collection, DMChannel, Emoji, Guild, GuildChannel, GuildMember, GuildResolvable, Message, MessageAttachment, MessageEmbed, MessageMentions, MessageOptions, MessageAdditions, MessageReaction, PermissionResolvable, PermissionString, ReactionEmoji, Role, Snowflake, StringResolvable, TextChannel, User, UserResolvable, VoiceState, Webhook } from 'discord.js'; export class Argument { @@ -215,6 +216,7 @@ declare module 'discord.js-commando' { public constructor(options?: CommandoClientOptions); private _commandPrefix: string; + private _defaultLanguage: string; public commandPrefix: string; public dispatcher: CommandDispatcher; @@ -222,6 +224,7 @@ declare module 'discord.js-commando' { public readonly owners: User[]; public provider: SettingProvider; public registry: CommandoRegistry; + public translator: CommandoTranslator; public settings: GuildSettingsHelper; public isOwner(user: UserResolvable): boolean; @@ -237,6 +240,7 @@ declare module 'discord.js-commando' { on(event: 'commandError', listener: (command: Command, err: Error, message: CommandoMessage, args: object | string | string[], fromPattern: false) => void): this; on(event: 'commandError', listener: (command: Command, err: Error, message: CommandoMessage, args: string[], fromPattern: true) => void): this; on(event: 'commandPrefixChange', listener: (guild: CommandoGuild, prefix: string) => void): this; + on(event: 'guildLanguageChange', listener: (guild: CommandoGuild, language: string) => void): this; on(event: 'commandRegister', listener: (command: Command, registry: CommandoRegistry) => void): this; on(event: 'commandReregister', listener: (newCommand: Command, oldCommand: Command) => void): this; on(event: 'commandRun', listener: (command: Command, promise: Promise, message: CommandoMessage, args: object | string | string[], fromPattern: boolean) => void): this; @@ -294,6 +298,7 @@ declare module 'discord.js-commando' { export class CommandoGuild extends Guild { private _commandPrefix: string; + private _language: string; private _commandsEnabled: object; private _groupsEnabled: object; private _settings: GuildSettingsHelper; @@ -324,7 +329,7 @@ declare module 'discord.js-commando' { public registerCommand(command: Command | Function): CommandoRegistry; public registerCommands(commands: Command[] | Function[], ignoreInvalid?: boolean): CommandoRegistry; public registerCommandsIn(options: string | {}): CommandoRegistry; - public registerDefaultCommands(commands?: { help?: boolean, prefix?: boolean, eval?: boolean, ping?: boolean, commandState?: boolean, unknownCommand?: boolean }): CommandoRegistry; + public registerDefaultCommands(commands?: { help?: boolean, prefix?: boolean, language?: boolean, eval?: boolean, ping?: boolean, commandState?: boolean, unknownCommand?: boolean }): CommandoRegistry; public registerDefaultGroups(): CommandoRegistry; public registerDefaults(): CommandoRegistry; public registerDefaultTypes(types?: { string?: boolean, integer?: boolean, float?: boolean, boolean?: boolean, user?: boolean, member?: boolean, role?: boolean, channel?: boolean, message?: boolean, command?: boolean, group?: boolean }): CommandoRegistry; @@ -413,7 +418,7 @@ declare module 'discord.js-commando' { } export class util { - public static disambiguation(items: any[], label: string, property?: string): string; + public static disambiguation(items: any[], property?: string): string; public static paginate(items: T[], page?: number, pageLength?: number): { items: T[], page: number, @@ -423,6 +428,17 @@ declare module 'discord.js-commando' { public static readonly permissions: { [K in PermissionString]: string }; } + export type TranslateOptions = TOptions | string + + export class CommandoTranslator { + constructor(client: CommandoClient, options?: CommandoTranslatorOptions) + + public init(): Promise; + public loadNamespaces(ns: string | string[], callback?: TCallback): Promise; + public translate(key: string, options?: TranslateOptions): string; + public resolveLanguage(msg?: CommandoMessage): string; + } + export const version: string; export interface ArgumentCollectorResult { @@ -484,14 +500,17 @@ declare module 'discord.js-commando' { guarded?: boolean; hidden?: boolean; unknown?: boolean; + language?: boolean; } export interface CommandoClientOptions extends ClientOptions { commandPrefix?: string; + defaultLanguage?: string; commandEditableDuration?: number; nonCommandEditable?: boolean; owner?: string | string[] | Set; invite?: string; + i18n?: CommandoTranslatorOptions; } type CommandResolvable = Command | string; @@ -506,4 +525,12 @@ declare module 'discord.js-commando' { usages: number; duration: number; } + + export interface CommandoTranslatorOptions { + ns?: string | string[]; + loadTranslations?: boolean; + localesPath?:string; + debug?:boolean; + overrides?: TInitOptions; + } }