Skip to content

Commit

Permalink
Merge pull request #12 from respawn-network/feat/i18n-v12
Browse files Browse the repository at this point in the history
feat(i18n): implement localisation (discordjs#197)
  • Loading branch information
perzeuss authored Feb 17, 2021
2 parents 0604878 + 230d574 commit d6b14b9
Show file tree
Hide file tree
Showing 42 changed files with 1,974 additions and 392 deletions.
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
"eslint": "^6.3.0",
"typescript": "^3.6.2"
},
"peerDependencies": {
"@types/better-sqlite3": "^5.0.0",
"i18next": "^19.0.0",
"i18next-fs-backend": "^1.0.0",
"better-sqlite3": "^5.0.0",
"sqlite": "^3.0.0"
},
"engines": {
"node": ">=12.0.0"
}
Expand Down
68 changes: 57 additions & 11 deletions src/client.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string>} [owner] - ID of the bot owner's Discord user, or multiple IDs
* @property {string} [invite] - Invite URL to the bot's support server
*/
Expand All @@ -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);
Expand All @@ -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}
Expand All @@ -59,28 +70,43 @@ 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)
if(options.owner) {
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);
});
}
});
}
Expand All @@ -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
* <info>If you simply need to check if a user is an owner of the bot, please instead use
Expand Down
113 changes: 91 additions & 22 deletions src/commands/argument.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { escapeMarkdown } = require('discord.js');
const { oneLine, stripIndents } = require('common-tags');
const isPromise = require('is-promise');
const ArgumentUnionType = require('../types/union');
const i18next = require('i18next');

/** A fancy argument */
class Argument {
Expand Down Expand Up @@ -152,6 +153,7 @@ class Argument {
* @param {number} [promptLimit=Infinity] - Maximum number of times to prompt for the argument
* @return {Promise<ArgumentResult>}
*/
// eslint-disable-next-line complexity
async obtain(msg, val, promptLimit = Infinity) {
let empty = this.isEmpty(val, msg);
if(empty && this.default !== null) {
Expand All @@ -169,6 +171,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) {
Expand All @@ -180,12 +186,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
}) :
''}
`}
`));

Expand All @@ -209,7 +236,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',
Expand All @@ -220,6 +249,9 @@ class Argument {

empty = this.isEmpty(val, msg, responses.first());
valid = await this.validate(val, msg, responses.first());
if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) {
valid = i18next.t(valid.key, { lng });
}
/* eslint-enable no-await-in-loop */
}

Expand Down Expand Up @@ -252,6 +284,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) {
Expand All @@ -263,26 +299,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
}) : ''}
`}
`));
}
Expand All @@ -308,15 +365,19 @@ 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',
prompts,
answers
};
}
if(lc === 'cancel') {
if(lc === i18next.t('common.cancel_command', {
lng
})) {
return {
value: null,
cancelled: 'user',
Expand All @@ -326,6 +387,9 @@ class Argument {
}

valid = await this.validate(val, msg, responses.first());
if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) {
valid = i18next.t(valid.key, { lng });
}
}

results.push(await this.parse(val, msg, answers.length ? answers[answers.length - 1] : msg));
Expand Down Expand Up @@ -353,9 +417,10 @@ class Argument {
* @return {boolean|string|Promise<boolean|string>}
*/
validate(val, originalMsg, currentMsg = originalMsg) {
const valid = this.validator ?
this.validator(val, originalMsg, this, currentMsg) :
this.type.validate(val, originalMsg, this, currentMsg);
let valid = this.validator ? this.validator(val, originalMsg, this, currentMsg) : this.type.validate(val, originalMsg, this, currentMsg);
if(typeof valid !== 'string' && typeof valid !== 'undefined' && valid.key) {
valid = i18next.t(valid.key, { lng: originalMsg.client.translator.resolveLanguage(currentMsg) });
}
if(!valid || typeof valid === 'string') return this.error || valid;
if(isPromise(valid)) return valid.then(vld => !vld || typeof vld === 'string' ? this.error || vld : vld);
return valid;
Expand Down Expand Up @@ -397,8 +462,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)) {
Expand Down
Loading

0 comments on commit d6b14b9

Please sign in to comment.