From 569a00999ac3da3a13e835b117884bcd1af9415d Mon Sep 17 00:00:00 2001 From: nolan Date: Sun, 22 Aug 2021 01:34:48 -0700 Subject: [PATCH] imported localeloader, added some option parsing to command handler. still nowhere near completion. --- options.json | 6 +- .../en_us/commands/en_us_moderation.lang | 2 + src/localization/en_us/en_us_commands.json | 8 - .../en_us/observers/en_us_commandHandler.lang | 3 + src/middleware/rest/SlashCommandManager.js | 6 +- src/structure/DiscordClient.js | 9 +- src/structure/client/Intercom.js | 4 +- src/structure/client/LocaleLoader.js | 82 +++++++++++ src/structure/client/index.js | 3 +- .../commands/moderation/MuteCommand.js | 32 +++- .../components/observers/CommandHandler.js | 139 +++++++++++++++--- src/structure/interfaces/CommandOption.js | 60 ++++++++ src/structure/interfaces/LegacyCommand.js | 0 src/structure/interfaces/Thing.js | 99 +++++++------ .../interfaces/{ => commands}/Command.js | 5 +- .../interfaces/commands/LegacyCommand.js | 8 + .../interfaces/{ => commands}/SlashCommand.js | 8 +- src/structure/interfaces/index.js | 5 +- 18 files changed, 379 insertions(+), 100 deletions(-) create mode 100644 src/localization/en_us/commands/en_us_moderation.lang delete mode 100644 src/localization/en_us/en_us_commands.json create mode 100644 src/localization/en_us/observers/en_us_commandHandler.lang create mode 100644 src/structure/client/LocaleLoader.js create mode 100644 src/structure/interfaces/CommandOption.js delete mode 100644 src/structure/interfaces/LegacyCommand.js rename src/structure/interfaces/{ => commands}/Command.js (91%) create mode 100644 src/structure/interfaces/commands/LegacyCommand.js rename src/structure/interfaces/{ => commands}/SlashCommand.js (82%) diff --git a/options.json b/options.json index dc254ab..5441e67 100644 --- a/options.json +++ b/options.json @@ -4,7 +4,11 @@ "developer": "132620781791346688", "clientId": "697791541690892369", "clientOptions": { - + "intents": [ + "GUILD", + "GUILD_MEMBERS", + "GUILD_MESSAGES" + ] }, "shardOptions": { "totalShards": "auto" diff --git a/src/localization/en_us/commands/en_us_moderation.lang b/src/localization/en_us/commands/en_us_moderation.lang new file mode 100644 index 0000000..1746dc9 --- /dev/null +++ b/src/localization/en_us/commands/en_us_moderation.lang @@ -0,0 +1,2 @@ +//Mute Command +[C_MUTE_DESCRIPTION] diff --git a/src/localization/en_us/en_us_commands.json b/src/localization/en_us/en_us_commands.json deleted file mode 100644 index daf5b39..0000000 --- a/src/localization/en_us/en_us_commands.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "developer:test": { - "description": "A test command used for testing purposes.", - "responses": { - - } - } -} \ No newline at end of file diff --git a/src/localization/en_us/observers/en_us_commandHandler.lang b/src/localization/en_us/observers/en_us_commandHandler.lang new file mode 100644 index 0000000..ac86902 --- /dev/null +++ b/src/localization/en_us/observers/en_us_commandHandler.lang @@ -0,0 +1,3 @@ +[O_COMMANDHANDLER_COMMANDNOTSYNCED] +It appears as if the command does not exist on the client. +This is an issue that should be reported to a bot developer. diff --git a/src/middleware/rest/SlashCommandManager.js b/src/middleware/rest/SlashCommandManager.js index 36b9cd4..5289b13 100644 --- a/src/middleware/rest/SlashCommandManager.js +++ b/src/middleware/rest/SlashCommandManager.js @@ -16,17 +16,17 @@ class SlashCommandManager { if(message.type === 'global') { await this.global(message.commands); } else if(message.type === 'guild') { - await this.guild(message.commands, { guilds: message.guilds }); + await this.guild(message.commands, { guilds: message.guilds, clientId: message.clientId }); } } - async guild(commands, { guilds = [] }) { + async guild(commands, { guilds = [], clientId }) { if(guilds.length === 0) guilds = this.client._options.discord.slashCommands.developerGuilds; const promises = []; for(const guild of guilds) { promises.push(this.rest.put( - Routes.applicationGuildCommands(this.client._options.discord.clientId, guild), + Routes.applicationGuildCommands(clientId, guild), { body: commands } )); } diff --git a/src/structure/DiscordClient.js b/src/structure/DiscordClient.js index be110f1..aadd329 100644 --- a/src/structure/DiscordClient.js +++ b/src/structure/DiscordClient.js @@ -1,6 +1,6 @@ const { Client, Intents } = require('discord.js'); -const { Logger, Intercom, EventHooker, Registry, Dispatcher, Resolver } = require('./client/'); +const { Logger, Intercom, EventHooker, LocaleLoader, Registry, Dispatcher, Resolver } = require('./client/'); const { Observer, Command } = require('./interfaces/'); const options = require('../../options.json'); @@ -11,13 +11,13 @@ class DiscordClient extends Client { if(!options) return null; super({ - ...options.discord.clientOptions, - intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES] + ...options.discord.clientOptions }); this.eventHooker = new EventHooker(this); this.intercom = new Intercom(this); this.logger = new Logger(this); + this.localeLoader = new LocaleLoader(this); this.registry = new Registry(this); this.dispatcher = new Dispatcher(this); @@ -36,6 +36,9 @@ class DiscordClient extends Client { const beforeTime = Date.now(); //Initialize components, localization, and observers. + + await this.localeLoader.loadLanguages(); + await this.registry.loadComponents('components/observers', Observer); await this.registry.loadComponents('components/commands', Command); diff --git a/src/structure/client/Intercom.js b/src/structure/client/Intercom.js index 9836abb..6ac2e5b 100644 --- a/src/structure/client/Intercom.js +++ b/src/structure/client/Intercom.js @@ -24,9 +24,9 @@ class Intercom { const commands = this.client.registry.components .filter((c) => c._type === 'command' && c.slash) - .map((c) => c.json); + .map((c) => c.shape); - this.send('commands', { type: 'guild', commands }); + this.send('commands', { type: 'guild', commands, clientId: this.client.application.id }); } diff --git a/src/structure/client/LocaleLoader.js b/src/structure/client/LocaleLoader.js new file mode 100644 index 0000000..bdabb87 --- /dev/null +++ b/src/structure/client/LocaleLoader.js @@ -0,0 +1,82 @@ +const path = require('path'); +const fs = require('fs'); +const chalk = require('chalk'); + +const Util = require('../../Util.js'); + +class LocaleLoader { + + constructor(client) { + + this.client = client; + + this.languages = {}; + + } + + async loadLanguages() { + + const root = path.join(process.cwd(), "src/localization"); + const directories = fs.readdirSync(root); //locale directories (en_us, fi_fi) + + for(const directory of directories) this._loadLanguage(root, directory); + + } + + _loadLanguage(root, language) { + + const directory = path.join(root, language); + const files = Util.readdirRecursive(directory); + + const combined = {}; + for(let file of files) { + file = fs.readFileSync(file, { + encoding: 'utf8' + }); + const result = this._loadFile(file); + Object.assign(combined, result); + } + + this.languages[language] = combined; + this.client.logger.info(`Language ${chalk.bold(language)} was ${chalk.bold("loaded")}.`); + + } + + _loadFile(file) { + + if(process.platform === 'win32') { + file = file.split('\n').join(''); + file = file.replace(/\r/gu, '\n'); + } + + const lines = file.split('\n'); + const parsed = {}; + + let matched = null, + text = []; + for(const line of lines) { + if(line.startsWith('//') || line.startsWith('#')) continue; + const matches = line.match(/\[([_A-Z0-9]{1,})\]/u); + if(matches) { + if (matched) { + parsed[matched] = text.join('\n').trim(); + [, matched] = matches; + text = []; + } else { + [, matched] = matches; + } + } else if (matched) { + text.push(line); + } else { + continue; + } + } + + parsed[matched] = text.join('\n').trim(); + return parsed; + + } + +} + +module.exports = LocaleLoader; \ No newline at end of file diff --git a/src/structure/client/index.js b/src/structure/client/index.js index 7c327c8..49a59c5 100644 --- a/src/structure/client/index.js +++ b/src/structure/client/index.js @@ -4,5 +4,6 @@ module.exports = { Logger: require('./Logger.js'), Dispatcher: require('./Dispatcher.js'), Registry: require('./Registry.js'), - Resolver: require('./Resolver.js') + Resolver: require('./Resolver.js'), + LocaleLoader: require('./LocaleLoader.js') }; \ No newline at end of file diff --git a/src/structure/components/commands/moderation/MuteCommand.js b/src/structure/components/commands/moderation/MuteCommand.js index 6b5dbf9..e2cf705 100644 --- a/src/structure/components/commands/moderation/MuteCommand.js +++ b/src/structure/components/commands/moderation/MuteCommand.js @@ -1,4 +1,4 @@ -const { SlashCommand } = require('../../../interfaces/'); +const { SlashCommand, CommandOption } = require('../../../interfaces/'); class MuteCommand extends SlashCommand { @@ -7,12 +7,36 @@ class MuteCommand extends SlashCommand { name: 'mute', description: "Silence people.", module: 'moderation', - arguments: [ - ] + options: [ + new CommandOption({ + name: 'targets', + description: "Provide users to mute.", + type: 'MEMBER' + }), + new CommandOption({ + name: 'reason', + description: "Provide a reason.", + type: 'STRING' + }), + new CommandOption({ + name: 'points', + description: "Assign points to the infraction.", + type: 'INTEGER', + minimum: 0, maximum: 100 + }), + new CommandOption({ + name: 'channel', + type: 'TEXT_CHANNEL' + }) + ], + guildOnly: true }); } - async execute(thing) { + async execute(interaction) { + + // console.log(interaction, interaction.options); + interaction.reply(this.resolveable); } diff --git a/src/structure/components/observers/CommandHandler.js b/src/structure/components/observers/CommandHandler.js index b43e659..0287d6e 100644 --- a/src/structure/components/observers/CommandHandler.js +++ b/src/structure/components/observers/CommandHandler.js @@ -1,4 +1,4 @@ -const { Observer } = require('../../interfaces/'); +const { Observer, Thing, CommandOption } = require('../../interfaces/'); class CommandHandler extends Observer { @@ -21,8 +21,6 @@ class CommandHandler extends Observer { async messageCreate(message) { - const { prefix } = this.client._options.discord; - if(!this.client._built || message.webhookId || message.author.bot @@ -40,17 +38,122 @@ class CommandHandler extends Observer { } async interactionCreate(interaction) { - if(!interaction.isCommand()) return undefined; + if(!interaction.isCommand() + && !interaction.isContextMenu()) return undefined; - // if(!this.client._built - // || message.guild && !message.guild.available) return undefined; + if(!this.client._built + || !interaction?.guild?.available) return undefined; const command = this._matchCommand(interaction.commandName); - console.log(interaction.commandName) - if(!command) return interaction.reply('Command is not synced with client instance.'); + const thing = new Thing(this.client, command, interaction); - interaction.reply(command.resolveable); + if(!command) return thing.reply({ locale: 'O_COMMANDHANDLER_COMMANDNOTSYNCED', emoji: 'failure', ephemeral: true }); + const response = await this._parseInteraction(thing); + + } + + async _parseInteraction(thing) { + + const { command, interaction } = thing; + + if(!interaction.guild && command.guildOnly) { + return thing.reply({ locale: 'O_COMMANDHANDLER_GUILDONLY', emoji: 'failure', ephemeral: true }); + } + + const options = []; + for(const option of interaction.options._hoistedOptions) { + const matched = command.options.find((o) => o.name === option.name); + const newOption = new CommandOption({ name: matched.name, type: matched.type, _rawValue: option.value }); + + const parsed = await this._parseOption(thing, newOption); + if(parsed.error) { + //uhh + } + + newOption.value = parsed.value; + + options.push(newOption); + } + console.log(options); + + } + + async _parseOption(thing, option) { + + const types = { + ROLES: (string) => { + + }, + MEMBERS: (string) => { + + }, + USERS: (string) => { + + }, + CHANNELS: (string) => { + + }, + TEXT_CHANNELS: (string) => { + + }, + VOICE_CHANNELS: (string) => { + + }, + STRING: (string) => { + return { error: false, value: string }; + }, + INTEGER: (integer) => { + if(option.minimum !== undefined && integer < option.minimum) return { error: true }; + if(option.maximum !== undefined && integer > option.maximum) return { error: true }; + return { error: false, value: parseInt(integer) }; + }, + BOOLEAN: (boolean) => { + return { error: false, value: boolean }; + }, + MEMBER: async (user) => { + let member = null; + try { + member = await thing.guild.members.fetch(user); + } catch(error) {} //eslint-disable-line no-empty + if(!member) return { error: true }; + return { error: false, value: member }; + }, + USER: (user) => { + return { error: false, value: user }; + }, + TEXT_CHANNEL: (channel) => { + if(channel.type !== 'GUILD_TEXT') return { error: true }; + return { error: false, value: channel }; + }, + VOICE_CHANNEL: (channel) => { + if(channel.type !== 'GUILD_VOICE') return { error: true }; + return { error: false, value: channel }; + }, + CHANNEL: (channel) => { + return { error: false, value: channel }; + }, + ROLE: (role) => { + return { error: false, value: role }; + }, + MENTIONABLE: (mentionable) => { + return { error: false, value: mentionable }; + }, + NUMBER: (number) => { + if(option.minimum !== undefined && number < option.minimum) return { error: true }; + if(option.maximum !== undefined && number > option.maximum) return { error: true }; + return { error: false, value: number }; + }, + FLOAT: (float) => { + if(option.minimum !== undefined && float < option.minimum) return { error: true }; + if(option.maximum !== undefined && float > option.maximum) return { error: true }; + return { error: false, value: parseFloat(float) }; + } + }; + + const result = types[option.type](option._rawValue); + if(result instanceof Promise) await result; + return result; } async _getCommand(message) { @@ -58,17 +161,9 @@ class CommandHandler extends Observer { //TODO: Move this somewhere else. RegExp should not be created every method call, but it requires the client user to be loaded. const mentionPattern = new RegExp(`^(<@!?${this.client.user.id}>)`, 'iu'); - const { prefix } = this.client._options.discord; - const start = message.content.slice(0, prefix.length); - let command = null, parameters = []; - if(start === prefix) { - const remaining = message.content.slice(prefix.length); - const [ commandName, ...rest ] = remaining.split(" "); - command = this._matchCommand(message, commandName); - parameters = rest.join(" "); - } else if (mentionPattern.test(message.content)) { + if(mentionPattern.test(message.content)) { const [ , commandName, ...rest] = message.content.split(" "); command = this._matchCommand(message, commandName); parameters = rest.join(" "); @@ -79,11 +174,11 @@ class CommandHandler extends Observer { } _matchCommand(commandName) { - const [ command ] = this.client.resolver.components(commandName, 'command', true); - if(!command) return null; - - return command; + return command || null; + } + + _generateError() { } diff --git a/src/structure/interfaces/CommandOption.js b/src/structure/interfaces/CommandOption.js new file mode 100644 index 0000000..db6f1eb --- /dev/null +++ b/src/structure/interfaces/CommandOption.js @@ -0,0 +1,60 @@ +const Constants = { + CommandOptionTypes: { + SUB_COMMAND: 1, + SUB_COMMAND_GROUP: 2, + ROLES: 3, //Note plurality, strings can parse users, roles, and channels. + MEMBERS: 3, + USERS: 3, + CHANNELS: 3, + TEXT_CHANNELS: 3, + VOICE_CHANNELS: 3, + STRING: 3, + INTEGER: 4, + BOOLEAN: 5, + MEMBER: 6, + USER: 6, + TEXT_CHANNEL: 7, + VOICE_CHANNEL: 7, + CHANNEL: 7, + ROLE: 8, + MENTIONABLE: 9, + NUMBER: 10, + FLOAT: 10 + } +}; + +class CommandOption { + + constructor(options = {}) { + + this.name = options.name; + this.description = options.description || "A missing description, let a bot developer know."; + + this.type = Object.keys(Constants.CommandOptionTypes).includes(options.type) ? options.type : 'STRING'; + this.required = Boolean(options.required); + this.choices = options.choices || []; //Used for STRING/INTEGER/NUMBER types. + this.options = options.options || []; //Used for SUB_COMMAND/SUB_COMMAND_GROUP types. + + this.minimum = options.minimum || undefined; //Used for INTEGER/NUMBER/FLOAT types. + this.maxiumum = options.maximum || undefined; + + this.value = undefined; + + this._rawValue = options._rawValue || null; //Raw value input from Discord. + + } + + get shape() { + return { + name: this.name, + description: this.description, + type: Constants.CommandOptionTypes[this.type], + required: this.required, + choices: this.choices, + options: this.options.map((o) => o.shape) + }; + } + +} + +module.exports = CommandOption; \ No newline at end of file diff --git a/src/structure/interfaces/LegacyCommand.js b/src/structure/interfaces/LegacyCommand.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/structure/interfaces/Thing.js b/src/structure/interfaces/Thing.js index dafdf93..d6f1741 100644 --- a/src/structure/interfaces/Thing.js +++ b/src/structure/interfaces/Thing.js @@ -1,71 +1,74 @@ -const { Emojis } = require('../../constants'); +const { Emojis } = require('../../constants/'); class Thing { - constructor(client, options = {}) { - if(!options) return null; + constructor(client, command, interaction) { - this.message = options.message || null; - this.interaction = options.interaction || null; + this.client = client; + this.interaction = interaction; + this.command = command; - this.command = options.command; //Should always be provided. - this.arguments = options.arguments; + this.options = []; - this.parameters = options.parameters || []; - - this._resolved = false; + this._guild = null; + this._channel = null; + + this._pending = null; } - async resolve() { + async reply(options = {}) { - if(this.command.showUsage && !this.parameters.length && !this.arguments.length) { - console.log('Show usage embed'); //eslint-disable-line no-console - return undefined; + if(options.locale) { + options.content = this.format(options.locale); + // delete options.locale; } - try { - const response = this.command.execute(this); - if(response instanceof Promise) await response; - this.command._invokes.successes++; - this.client.emit('commandExecute', { instance: this, type: 'SUCCESS' }); - return { error: false }; - } catch(error) { - this.command._invokes.failures++; - this.client.emit('commandExecute', { instance: this, type: 'FAILURE' }); - return { error: true, message: error }; - } - - } - - - async respond(content, options = {}) { - if(typeof content === 'string') { - if(options.emoji && Emojis[options.emoji]) { - content = `${Emojis[options.emoji]} ${content}`; - } - if(options.reply) content = `<@${this.message.author.id}> ${content}`; + if(options.emoji) { + if(!Emojis[options.emoji]) this.client.logger.warn(`Invalid emoji provided to command ${this.command.resolveable}: "${options.emoji}"`); + options.content = `${Emojis[options.emoji]}${options.content}`; + // delete options.emoji; } + this._pending = this.interaction.reply(options); + return this._pending; } - async send(options) { - if(this.type === 'MESSAGE') { - this.message.channel.send({ - - }); - } else { - this.interaction.reply({ - - }); - } + format(locale) { + const language = 'en_us'; //Default language. + //TODO: Fetch guild/user settings and switch localization. + return this.client.localeLoader.languages[language][locale]; } - get type() { - if(this.message) return 'MESSAGE'; - return 'INTERACTION'; + get guild() { + return this.interaction.guild || null; } + get channel() { + return this.interaction.channel || null; + } + + // async guild() { + // if(!this.interaction.guild) return null; + // if(this._guild) return this._guild; + // this._guild = await this.client.guilds.fetch(this.interaction.guildId); + // return this._guild; + // } + + // async channel() { + // if(!this.interaction.channel) return null; + // if(this._channel) return this._channel; + // this._channel = await this.client.channels.fetch(this.interaction.channelId); + // return this._channel; + // } + + get user() { + return this.interaction.user || null; + } + + get member() { + return this.interaction.member || null; + } } module.exports = Thing; \ No newline at end of file diff --git a/src/structure/interfaces/Command.js b/src/structure/interfaces/commands/Command.js similarity index 91% rename from src/structure/interfaces/Command.js rename to src/structure/interfaces/commands/Command.js index 0a3a198..8a1a53f 100644 --- a/src/structure/interfaces/Command.js +++ b/src/structure/interfaces/commands/Command.js @@ -1,4 +1,4 @@ -const Component = require('./Component.js'); +const Component = require('../Component.js'); class Command extends Component { @@ -25,7 +25,8 @@ class Command extends Component { this.guildOnly = Boolean(options?.guildOnly); this.archivable = options.archivable === undefined ? true : Boolean(options.archivable); - this.slash = Boolean(options?.slash); + + this.slash = Boolean(options.slash); this._invokes = { success: 0, diff --git a/src/structure/interfaces/commands/LegacyCommand.js b/src/structure/interfaces/commands/LegacyCommand.js new file mode 100644 index 0000000..aba554d --- /dev/null +++ b/src/structure/interfaces/commands/LegacyCommand.js @@ -0,0 +1,8 @@ +const { Command } = require('./Command.js'); + +class LegacyCommand extends Command { + + +} + +module.exports = LegacyCommand; \ No newline at end of file diff --git a/src/structure/interfaces/SlashCommand.js b/src/structure/interfaces/commands/SlashCommand.js similarity index 82% rename from src/structure/interfaces/SlashCommand.js rename to src/structure/interfaces/commands/SlashCommand.js index a7a445c..c38fa80 100644 --- a/src/structure/interfaces/SlashCommand.js +++ b/src/structure/interfaces/commands/SlashCommand.js @@ -1,5 +1,5 @@ const Command = require('./Command.js'); -const { Commands: CommandsConstant } = require('../../constants/'); +const { Commands: CommandsConstant } = require('../../../constants/'); class SlashCommand extends Command { @@ -21,16 +21,16 @@ class SlashCommand extends Command { this.type = Object.keys(CommandsConstant.ApplicationCommandTypes).includes(options.type) ? options.type : 'CHAT_INPUT'; this.options = options.options || []; - this.defaultPermission = options.defaultPermission || {}; + this.defaultPermission = options.defaultPermission || true; } - get json() { + get shape() { return { name: this.name, description: this.description, type: CommandsConstant.ApplicationCommandTypes[this.type], - options: this.options, + options: this.options.map((o) => o.shape), defaultPermission: this.defaultPermission }; } diff --git a/src/structure/interfaces/index.js b/src/structure/interfaces/index.js index 450e78a..a8b018d 100644 --- a/src/structure/interfaces/index.js +++ b/src/structure/interfaces/index.js @@ -2,7 +2,8 @@ module.exports = { Component: require('./Component.js'), Observer: require('./Observer.js'), Module: require('./Module.js'), - SlashCommand: require('./SlashCommand.js'), - Command: require('./Command.js'), + SlashCommand: require('./commands/SlashCommand.js'), + Command: require('./commands/Command.js'), + CommandOption: require('./CommandOption.js'), Thing: require('./Thing.js') }; \ No newline at end of file