// Copyright 2019-2022 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const fs = require('fs'); const SubModule = require('./subModule.js'); delete require.cache[require.resolve('./locale/Strings.js')]; const Strings = require('./locale/Strings.js'); /** * @description Manages pet related commands. * @listens Command#language * @listens Command#lang * @listens Command#locale * @listens Command#setlanguage * @listens Command#setlang * @listens Command#setlocale * @listens Command#changelanguage * @listens Command#changelang * @listens Command#changelocale * @augments SubModule */ class LocaleManager extends SubModule { /** * @description SubModule managing language choices. */ constructor() { super(); /** @inheritdoc */ this.myName = 'LocaleManager'; /** * @description Instance of locale strings helper. * @private * @type {Strings} * @default * @constant */ this._strings = new Strings('localeManager'); this._strings.purge(); /** * @description Language mappings read from file. This maps the common ways * to type a language, to the locale string we understand. All keys are * lower-case UTF-8 strings. * @private * @type {object.<string>} * @default */ this._mappings = {english: 'en_US'}; /** * @description Locale settings set for specific guilds. If a guild is not * included in here, they are expected to use the default locale settings. * Mapped by guild ID. * @private * @type {object.<string>} * @default * @constant */ this._guilds = {}; fs.watchFile( LocaleManager._localeMapFilename, {persistent: false}, this._readLocaleMap); this._readLocaleMap(); this._commandLanguage = this._commandLanguage.bind(this); } /** @inheritdoc */ initialize() { this.command.on( new this.command.SingleCommand( [ 'language', 'lang', 'locale', 'setlanguage', 'setlang', 'setlocale', 'changelanguage', 'changelang', 'changelocale', ], this._commandLanguage, { validOnlyInGuild: true, defaultDisabled: true, permissions: this.Discord.PermissionsBitField.Flags.ManageGuild, })); this.client.guilds.cache.forEach((g) => { const fn = `${this.common.guildSaveDir}${g.id}${LocaleManager._localeSaveFile}`; this.common.readFile(fn, (err, data) => { if (err) { if (err.code !== 'ENOENT') { this.error(`Failed to read guild locale settings: ${fn}`); console.error(err); } return; } const str = data.toString(); if (!Strings.parseLocale(str)) { this.error(`${str} is not a valid locale: ${fn}`); return; } this._guilds[g.id] = str; }); }); /** * @description Inject the {@link LocaleManager.getLocale} function into * {@link SpikeyBot} instance. Will be undefined if this SubModule is not * loaded. Gets re-set to `undefined` on shutdown. * @public * @see {@link LocaleManager.getLocale} * @type {?Function} * @param {*} args Args passed directly to {@link LocaleManager.getLocale}. * @returns {?string} Locale string, or null. */ this.bot.getLocale = (...args) => this.getLocale(...args); } /** @inheritdoc */ shutdown() { this.command.removeListener('language'); fs.unwatchFile(LocaleManager._localeMapFilename, this._readLocaleMap); this.bot.getLocale = undefined; } /** * @description Read the current locale mapping information from file. * @private */ _readLocaleMap() { fs.readFile(LocaleManager._localeMapFilename, (err, data) => { if (err) { this.error('Failed to read language to locale mapping information.'); console.error(err); return; } try { const parsed = JSON.parse(data); const entries = Object.entries(parsed); let fail = false; entries.forEach((obj) => { if (obj[0].match(/(\n|\r)+/)) { fail = true; this.error( obj[0].replace(/\n/g, '\\n').replace(/\r/g, '\\r') + ': May not contain a new-line character.'); } else if (obj[0].match(/\s+/)) { fail = true; this.error(obj[0] + ': May not contain white-space.'); } else if (obj[0] !== obj[0].toLocaleLowerCase()) { fail = true; this.error(obj[0] + ': Must only be lower-case characters.'); } else if (!Strings.parseLocale(obj[1])) { fail = true; this.error(obj[0] + ': Does not map to valid locale: ' + obj[1]); } }); if (fail) { throw new Error('Found invalid keys in language to locale mapping.'); } this._mappings = parsed; this.debug('Language-Locale map updated.'); } catch (err) { this.error('Failed to parse language to locale mapping information.'); console.error(err); return; } }); } /** * @description Get the locale of the given language strings. * @public * @param {string} lang The language string to lookup in the map. * @returns {?string} Locale string, or null if unable to find. */ langToLocale(lang) { return this._mappings[lang.toLocaleLowerCase()]; } /** * @description Filename relative to cwd to find language to locale mapping * information. * @private * @static * @constant * @type {string} */ static get _localeMapFilename() { return './strings/map.json'; } /** * @description Filename relative to guild directory storing the guild's * locale settings. * @private * @static * @constant * @type {string} */ static get _localeSaveFile() { return '/locale.txt'; } /** * @description Get the locale string for a specified guild. * @public * @param {string} gId The ID of the guild to fetch. * @returns {?string} Locale string, or null if default. */ getLocale(gId) { return gId && this._guilds[gId]; } /** * @description Change the chosen locale value in a specific guild. * @public * @param {string} gId The guild ID. * @param {string} locale The valid locale string to set the value to. */ changeLocale(gId, locale) { if (!Strings.parseLocale(locale)) { throw new TypeError('Locale is not valid.'); } this._guilds[gId] = locale; const fn = `${this.common.guildSaveDir}${gId}${LocaleManager._localeSaveFile}`; this.common.mkAndWrite(fn, null, locale, (err) => { if (err) { this.error(`Failed to save locale settings: ${fn}`); console.error(err); return; } this.debug(`Updated Locale: ${gId} ${locale}`); }); } /** * @description User typed the language command. * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#language * @listens Command#lang * @listens Command#locale * @listens Command#setlanguage * @listens Command#setlang * @listens Command#setlocale * @listens Command#changelanguage * @listens Command#changelang * @listens Command#changelocale */ _commandLanguage(msg) { const text = msg.text.trim(); if (!text || text.length < 2) { this._strings.reply( this.common, msg, 'title', 'currentLocale', msg.locale || this._strings.defaultLocale); return; } const locale = this.langToLocale(text); if (!locale) { this._strings.reply(this.common, msg, 'title', 'invalidLocale'); return; } const desc = msg.channel.permissionsFor(msg.guild.members.me) .has(this.Discord.PermissionsBitField.Flags.AddReactions) ? 'fillOne' : 'confirmLocaleReact'; const emoji = '✅'; const p = this._strings.reply( this.common, msg, 'confirmLocale', desc, locale, emoji); p.then((msg_) => { msg_.react(emoji); const filter = (reaction, user) => user.id === msg.author.id && reaction.emoji.name === emoji; msg_.awaitReactions({filter, max: 1, time: 30 * 1000}) .then((reactions) => { if (reactions.size === 0) { msg_.edit({content: 'Timed out'}); msg_.reactions.removeAll().catch(() => {}); return; } msg_.edit({content: 'Confirmed'}); msg_.reactions.removeAll().catch(() => {}); this.changeLocale(msg.guild.id, locale); }); }); } } module.exports = new LocaleManager();