From bdcff221db94716ca09cdfcd2fe17dc8340ec23c Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 18 Feb 2021 18:41:35 +0000 Subject: [PATCH] Command handler stuff, presences, other stuff --- src/commands/new.js | 36 ++++++++ src/index.js | 13 +-- src/listeners/interaction.js | 31 +++++++ src/listeners/message.js | 5 ++ src/listeners/messageUpdate.js | 5 ++ src/listeners/ready.js | 14 +++- src/modules/commands/command.js | 144 ++++++++++++++++++-------------- src/modules/commands/index.js | 12 ++- src/modules/commands/manager.js | 114 ++++++++++++++++++++++++- src/modules/listeners.js | 6 +- src/modules/plugins/manager.js | 5 +- src/modules/plugins/plugin.js | 68 +++++++++++---- src/utils/discord.js | 45 ++++++++++ src/utils/fs.js | 5 ++ user/example.config.js | 41 +++++---- 15 files changed, 435 insertions(+), 109 deletions(-) create mode 100644 src/commands/new.js create mode 100644 src/listeners/interaction.js create mode 100644 src/listeners/message.js create mode 100644 src/listeners/messageUpdate.js create mode 100644 src/utils/discord.js diff --git a/src/commands/new.js b/src/commands/new.js new file mode 100644 index 0000000..d0ee6c2 --- /dev/null +++ b/src/commands/new.js @@ -0,0 +1,36 @@ +const { + Command, + OptionTypes +} = require('../modules/commands'); + +module.exports = class NewCommand extends Command { + constructor(client) { + super(client, { + internal: true, + name: 'new', + description: 'Create a new ticket', + options: [ + // { + // name: 'category', + // type: OptionTypes.STRING, + // description: 'The category you would like to create a new ticket for', + // required: true, + // }, + { + name: 'topic', + type: OptionTypes.STRING, + description: 'The topic of the ticket', + required: false, + } + ] + }); + } + + async execute(data) { + console.log(data.args); + console.log(data.channel.name); + console.log(data.member.user.tag); + console.log(data.guild.name); + console.log(data.token); + } +}; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 92533f4..7b41f21 100644 --- a/src/index.js +++ b/src/index.js @@ -69,10 +69,6 @@ const log = new Logger({ logToFile: config.logs.enabled, keepFor: config.logs.keep_for, custom: { - listeners: { - title: 'info', - prefix: 'listeners' - }, commands: { title: 'info', prefix: 'commands' @@ -84,6 +80,7 @@ const log = new Logger({ } }); +const { selectPresence } = require('./utils/discord'); const I18n = require('@eartharoid/i18n'); const { CommandManager } = require('./modules/commands'); const { PluginManager } = require('./modules/plugins'); @@ -93,6 +90,10 @@ const { Intents } = require('discord.js'); +/** + * The bot client + * @extends {Client} + */ class Bot extends Client { constructor() { super({ @@ -101,6 +102,7 @@ class Bot extends Client { 'CHANNEL', 'REACTION' ], + presence: selectPresence(), ws: { intents: Intents.NON_PRIVILEGED, } @@ -126,7 +128,6 @@ class Bot extends Client { /** The command manager, used by internal and plugin commands */ this.commands = new CommandManager(this); - this.commands.load(); // load internal commands /** The plugin manager */ this.plugins = new PluginManager(this); @@ -144,7 +145,7 @@ new Bot(); const { version } = require('../package.json'); process.on('unhandledRejection', error => { - log.notice('PLEASE INCLUDE THIS INFORMATION:'); + log.notice('PLEASE INCLUDE THIS INFORMATION IF YOU ASK FOR HELP ABOUT THE FOLLOWING ERROR:'); log.warn(`Discord Tickets v${version}, Node v${process.versions.node} on ${process.platform}`); log.warn('An error was not caught'); if (error instanceof Error) log.warn(`Uncaught ${error.name}: ${error}`); diff --git a/src/listeners/interaction.js b/src/listeners/interaction.js new file mode 100644 index 0000000..a479356 --- /dev/null +++ b/src/listeners/interaction.js @@ -0,0 +1,31 @@ +module.exports = { + event: 'INTERACTION_CREATE', + raw: true, + execute: async (client, interaction) => { + + if (interaction.type !== 2) return; + + const cmd = interaction.data.name; + + if (!client.commands.commands.has(cmd)) + return client.log.warn(`Received "${cmd}" command invocation, but the command manager does not have a "${cmd}" command`); + + let data = { + args: interaction.data.options, + channel: await client.channels.fetch(interaction.channel_id), + guild: await client.guilds.fetch(interaction.guild_id), + token: interaction.token + }; + + data.member = await data.guild.members.fetch(interaction.member.user.id); + + try { + client.log.commands(`Executing ${cmd} command (invoked by ${data.member.user.username.tag})`); + client.commands.commands.get(cmd).execute(data); + } catch (e) { + client.log.warn(`[COMMANDS] An error occurred whilst executed the ${cmd} command`); + client.log.error(e); + } + + } +}; \ No newline at end of file diff --git a/src/listeners/message.js b/src/listeners/message.js new file mode 100644 index 0000000..14d9aef --- /dev/null +++ b/src/listeners/message.js @@ -0,0 +1,5 @@ +module.exports = { + event: 'message', + execute: (client, message) => { + } +}; \ No newline at end of file diff --git a/src/listeners/messageUpdate.js b/src/listeners/messageUpdate.js new file mode 100644 index 0000000..d66512d --- /dev/null +++ b/src/listeners/messageUpdate.js @@ -0,0 +1,5 @@ +module.exports = { + event: 'messageUpdate', + execute: (client, m1, m2) => { + } +}; \ No newline at end of file diff --git a/src/listeners/ready.js b/src/listeners/ready.js index 68f0a76..2a8c2d8 100644 --- a/src/listeners/ready.js +++ b/src/listeners/ready.js @@ -16,9 +16,21 @@ module.exports = { method: 'post', }).catch(e => { // fail quietly, it doesn't really matter if it didn't work - log.debug('Failed to post to discordtickets-telemetry'); + log.debug('Warning: failed to post to discordtickets-telemetry'); log.debug(e); }); } + + client.commands.load(); // load internal commands + + if (client.config.presence.presences.length > 1) { + const { selectPresence } = require('../utils/discord'); + setInterval(() => { + let presence = selectPresence(); + client.user.setPresence(presence); + client.log.debug(`Updated presence: ${presence.activity.type} ${presence.activity.name}`); + }, client.config.presence.duration * 1000); + } + } }; \ No newline at end of file diff --git a/src/modules/commands/command.js b/src/modules/commands/command.js index 024741f..a81f4cd 100644 --- a/src/modules/commands/command.js +++ b/src/modules/commands/command.js @@ -1,92 +1,106 @@ /* eslint-disable no-unused-vars */ -const { Client } = require('discord.js'); +const { Client, GuildMember, Guild, Channel } = require('discord.js'); const fs = require('fs'); const { join } = require('path'); const { path } = require('../../utils/fs'); -/** A plugin */ -module.exports = class Plugin { +/** + * A command + */ +module.exports = class Command { /** - * Create a new Plugin - * @param {Client} client The Discord Client - * @param {String} id The plugin ID - * @param {Object} options Plugin options - * @param {String} options.name A human-friendly name (can be different to the name in package.json) + * A command option choice + * @typedef CommandOptionChoice + * @property {string} name - Choice name (1-100) + * @property {(string|number)} value - choice value + */ + + /** + * A command option + * @typedef CommandOption + * @property {number} type - [ApplicationCommandOptionType](https://discord.com/developers/docs/interactions/slash-commands#applicationcommandoptiontype) + * @property {string} name - Option name (1-32) + * @property {string} description - Option description (1-100) + * @property {boolean} [required] - Required? + * @property {CommandOptionChoice[]} [choices] - Array of choices + * @property {CommandOption[]} [options] - Array of options if this option is a subcommand/subcommand group */ - constructor(client, id, options = {}) { - /** The human-friendly name of the plugin */ - this.name = options.name || id; - + + /** + * Create a new Command + * @param {Client} client - The Discord Client + * @param {Object} data - Command data + * @param {string} data.name - The name of the command (3-32) + * @param {string} data.description - The description of the command (1-100) + * @param {CommandOption[]} data.options - The command options, max of 10 + */ + constructor(client, data) { + /** The Discord Client */ this.client = client; - /** The PluginManager */ - this.manager = this.client.plugins; + /** The CommandManager */ + this.manager = this.client.commands; - // Object.assign(this, this.manager.plugins.get(id)); - // make JSDoc happy + if (typeof data !== 'object') { + throw new TypeError(`Expected type of data to be an object, got ${typeof data}`); + } - let { - version, - author, - description - } = this.manager.plugins.get(id); + /** + * The name of the command + * @type {string} + */ + this.name = data.name; - /** The unique ID of the plugin (NPM package name) */ - this.id = id; + /** + * The command description + * @type {string} + */ + this.description = data.description; - /** The version of the plugin (NPM package version) */ - this.version = version; + /** + * The command options + * @type {CommandOption[]} + */ + this.options = data.options; - /** The plugin author's name (NPM package author) */ - this.author = author; + /** True if command is internal, false if it is from a plugin */ + this.internal = data.internal; - /** The plugin description (NPM package description) */ - this.description = description; - - this.directory = {}; - /** A cleaned version of the plugin's ID suitable for use in the directory name */ - this.directory.name = this.id.replace(/@[-_a-zA-Z0-9]+\//, ''); - - /** The absolute path of the plugin directory */ - this.directory.path = path(`./user/plugins/${this.directory.name}`); - } - - /** - * Create the plugin directory if it doesn't already exist - * @returns {Boolean} True if created, false if it already existed - */ - createDirectory() { - if (!fs.existsSync(this.directory.path)) { - this.client.log.plugins(`Creating plugin directory for "${this.name}"`); - fs.mkdirSync(this.directory.path); - return true; - } else { - return false; + this.manager.check(data); // validate + + try { + this.manager.register(this); // register the command + } catch (e) { + return this.client.log.error(e); } + + this.client.api.applications(this.client.user.id).commands.post({ data }); // post command to Discord + + let internal = this.internal ? 'internal ' : ''; + this.client.log.commands(`Loaded ${internal}"${this.name}" command`); + } /** - * Create the plugin config file if it doesn't already exist - * @param {Object} template The default config template - * @returns {Boolean} True if created, false if it already existed + * [ApplicationCommandInteractionDataOption](https://discord.com/developers/docs/interactions/slash-commands#interaction-applicationcommandinteractiondataoption) + * @typedef {Object} ApplicationCommandInteractionDataOption + * @property {string} name - Name of the parameter + * @property {*} value - The value + * @property {(undefined|ApplicationCommandInteractionDataOption[])} options - Present if the option is a subcommand/subcommand group */ - createConfig(template) { - this.createDirectory(); - let file = join(this.directory.path, 'config.json'); - if (!fs.existsSync(file)) { - this.client.log.plugins(`Creating plugin config file for "${this.name}"`); - fs.writeFileSync(file, JSON.stringify(template, null, 2)); - return true; - } else { - return false; - } - } /** - * The main function where your code should go. Create functions and event listeners here + * The code to be executed when a command is invoked + * @abstract + * @param {Object} data - Object containing data about the command invocation + * @param {(undefined|ApplicationCommandInteractionDataOption[])} data.args - Command arguments + * @param {Channel} data.channel- The channel object + * @param {Guild} data.guild- The guild object + * @param {GuildMember} data.member - The member object + * @param {string} data.token - The token used to respond to the interaction */ - load() {} + async execute(data) {} }; \ No newline at end of file diff --git a/src/modules/commands/index.js b/src/modules/commands/index.js index c9cfb38..6ebfb7d 100644 --- a/src/modules/commands/index.js +++ b/src/modules/commands/index.js @@ -1,4 +1,14 @@ module.exports = { CommandManager: require('./manager'), - Command: require('./command') + Command: require('./command'), + OptionTypes: { + SUB_COMMAND: 1, + SUB_COMMAND_GROUP: 2, + STRING: 3, + INTEGER: 4, + BOOLEAN: 5, + USER: 6, + CHANNEL: 7, + ROLE: 8, + }, }; \ No newline at end of file diff --git a/src/modules/commands/manager.js b/src/modules/commands/manager.js index 3c7a3a8..7ddca8a 100644 --- a/src/modules/commands/manager.js +++ b/src/modules/commands/manager.js @@ -6,7 +6,9 @@ const Command = require('./command'); const fs = require('fs'); const { path } = require('../../utils/fs'); -/** Manages the loading of commands */ +/** + * Manages the loading of commands + */ module.exports = class CommandManager { /** * Create a CommandManager instance @@ -15,6 +17,7 @@ module.exports = class CommandManager { constructor(client) { /** The Discord Client */ this.client = client; + /** A discord.js Collection (Map) of loaded commands */ this.commands = new Collection(); } @@ -24,10 +27,113 @@ module.exports = class CommandManager { const files = fs.readdirSync(path('./src/commands')) .filter(file => file.endsWith('.js')); - for (const file of files) { - const cmd = require(`../commands/${file}`); - this.commands.set(cmd, new cmd(this.client)); + for (let file of files) { + try { + file = require(`../../commands/${file}`); + new file(this.client); + } catch (e) { + this.client.log.warn('An error occurred whilst loading an internal command'); + this.client.log.error(e); + } } } + /** Register a command */ + register(cmd) { + const is_internal = (this.commands.has(cmd.name) && cmd.internal) + || (this.commands.has(cmd.name) && this.commands.get(cmd.name).internal); + + if (is_internal) { + let plugin = this.client.plugins.plugins.find(p => p.commands.includes(cmd.name)); + if (plugin) + this.client.log.commands(`The "${plugin.name}" plugin has overridden the internal "${cmd.name}" command`); + else + this.client.log.commands(`An unknown plugin has overridden the internal "${cmd.name}" command`); + if(cmd.internal) return; + } + else if (this.commands.has(cmd.name)) + throw new Error(`A non-internal command with the name "${cmd.name}" already exists`); + + this.commands.set(cmd.name, cmd); + } + + /** Check the command data */ + check(data) { + + if (typeof data.name !== 'string') + throw new TypeError(`Expected type of command name to be a string, got ${typeof data.name}`); + + if (data.name.length < 3 || data.name.length > 32) + throw new TypeError('Length of command name must be 3-32'); + + if (typeof data.description !== 'string') + throw new TypeError(`Expected type of command description to be a string, got ${typeof data.description}`); + + if (data.name.length < 1 || data.name.length > 100) + throw new TypeError('Length of description must be 3-32'); + + if (typeof data.options !== 'undefined' && !(data.options instanceof Array)) + throw new TypeError(`Expected type of command options to be undefined or an array, got ${typeof data.options}`); + + if (data.options) + this.checkOptions(data.options); + + } + + /** Check the command data's options */ + checkOptions(options) { + let num = 0; + options.forEach(o => { + if (typeof o.type !== 'number') + throw new TypeError(`Expected type of option ${num} type to be a number, got ${typeof o.type}`); + + if (typeof o.name !== 'string') + throw new TypeError(`Expected type of option ${num} name to be a string, got ${typeof o.name}`); + + if (o.name.length < 3 || o.name.length > 32) + throw new TypeError(`Length of option ${num} name must be 3-32`); + + if (typeof o.description !== 'string') + throw new TypeError(`Expected type of option ${num} description to be a string, got ${typeof o.description}`); + + if (o.description.length < 1 || o.description.length > 100) + throw new TypeError(`Length of option ${num} description must be 1-100`); + + if (typeof o.required !== 'undefined' && typeof o.required !== 'boolean') + throw new TypeError(`Expected type of option ${num} required to be undefined or a boolean, got ${typeof o.required}`); + + if (typeof o.choices !== 'undefined' && !(o.choices instanceof Array)) + throw new TypeError(`Expected type of option ${num} choices to be undefined or an array, got ${typeof o.choices}`); + + if (o.choices) + this.checkOptionChoices(o.choices); + + if (typeof o.options !== 'undefined' && !(o.options instanceof Array)) + throw new TypeError(`Expected type of option ${num} options to be undefined or an array, got ${typeof o.options}`); + + if (o.options) + this.checkOptions(o.options); + + num++; + }); + + } + + /** Check command option choices */ + checkOptionChoices(choices) { + let num = 0; + choices.forEach(c => { + if (typeof c.name !== 'string') + throw new TypeError(`Expected type of option choice ${num} name to be a string, got ${typeof c.name}`); + + if (c.name.length < 1 || c.name.length > 100) + throw new TypeError(`Length of option choice ${num} name must be 1-100`); + + if (typeof c.value !== 'string' && typeof c.value !== 'number') + throw new TypeError(`Expected type of option choice ${num} value to be a string or number, got ${typeof c.value}`); + + num++; + }); + } + }; \ No newline at end of file diff --git a/src/modules/listeners.js b/src/modules/listeners.js index 7b80cf8..7fc3ce0 100644 --- a/src/modules/listeners.js +++ b/src/modules/listeners.js @@ -7,7 +7,11 @@ module.exports = client => { for (const file of files) { const listener = require(`../listeners/${file}`); + const exec = (...args) => listener.execute(client, ...args); let on = listener.once ? 'once' : 'on'; - client[on](listener.event, (...args) => listener.execute(client, ...args)); + if (listener.raw) + client.ws[on](listener.event, exec); + else + client[on](listener.event, exec); } }; \ No newline at end of file diff --git a/src/modules/plugins/manager.js b/src/modules/plugins/manager.js index 9397bb1..d08d739 100644 --- a/src/modules/plugins/manager.js +++ b/src/modules/plugins/manager.js @@ -7,7 +7,9 @@ const fs = require('fs'); const { join } = require('path'); const { path } = require('../../utils/fs'); -/** Manages the loading of plugins */ +/** + * Manages the loading of plugins + */ module.exports = class PluginManager { /** * Create a PluginManager instance @@ -16,6 +18,7 @@ module.exports = class PluginManager { constructor(client) { /** The Discord Client */ this.client = client; + /** A discord.js Collection (Map) of loaded plugins */ this.plugins = new Collection(); diff --git a/src/modules/plugins/plugin.js b/src/modules/plugins/plugin.js index 024741f..77b0929 100644 --- a/src/modules/plugins/plugin.js +++ b/src/modules/plugins/plugin.js @@ -5,7 +5,9 @@ const fs = require('fs'); const { join } = require('path'); const { path } = require('../../utils/fs'); -/** A plugin */ +/** + * A plugin + */ module.exports = class Plugin { /** * Create a new Plugin @@ -13,11 +15,9 @@ module.exports = class Plugin { * @param {String} id The plugin ID * @param {Object} options Plugin options * @param {String} options.name A human-friendly name (can be different to the name in package.json) + * @param {String[]} options.commands An array of command names the plugin registers */ constructor(client, id, options = {}) { - /** The human-friendly name of the plugin */ - this.name = options.name || id; - /** The Discord Client */ this.client = client; @@ -33,25 +33,53 @@ module.exports = class Plugin { description } = this.manager.plugins.get(id); - /** The unique ID of the plugin (NPM package name) */ + /** + * The human-friendly name of the plugin + * @type {string} + */ + this.name = options.name || id; + + /** + * An array of commands from this plugin + * @type {string[]} + */ + this.commands = options.commands; + + /** + * The unique ID of the plugin (NPM package name) + * @type {string} + */ this.id = id; - /** The version of the plugin (NPM package version) */ + /** + * The version of the plugin (NPM package version) + * @type {string} + */ this.version = version; - /** The plugin author's name (NPM package author) */ + /** + * The plugin author's name (NPM package author) + * @type {(undefined|string)} + */ this.author = author; - /** The plugin description (NPM package description) */ + /** + * The plugin description (NPM package description) + * @type {string} + */ this.description = description; - this.directory = {}; - - /** A cleaned version of the plugin's ID suitable for use in the directory name */ - this.directory.name = this.id.replace(/@[-_a-zA-Z0-9]+\//, ''); + let clean = this.id.replace(/@[-_a-zA-Z0-9]+\//, ''); - /** The absolute path of the plugin directory */ - this.directory.path = path(`./user/plugins/${this.directory.name}`); + /** + * Information about the plugin directory + * @property {string} name - A cleaned version of the plugin's ID suitable for use in the directory name + * @property {string} path - The absolute path of the plugin directory + */ + this.directory = { + name: clean, + path: path(`./user/plugins/${clean}`) + }; } /** @@ -85,8 +113,20 @@ module.exports = class Plugin { } } + /** + * Reset the plugin config file to the defaults + * @param {Object} template The default config template + */ + resetConfig(template) { + this.createDirectory(); + let file = join(this.directory.path, 'config.json'); + this.client.log.plugins(`Resetting plugin config file for "${this.name}"`); + fs.writeFileSync(file, JSON.stringify(template, null, 2)); + } + /** * The main function where your code should go. Create functions and event listeners here + * @abstract */ load() {} }; \ No newline at end of file diff --git a/src/utils/discord.js b/src/utils/discord.js new file mode 100644 index 0000000..575c5e6 --- /dev/null +++ b/src/utils/discord.js @@ -0,0 +1,45 @@ +// eslint-disable-next-line no-unused-vars +const { PresenceData } = require('discord.js'); + +const config = require('../../user/config'); + +let current_presence = -1; + +module.exports = { + /** + * Select a presence from the config + * @returns {PresenceData} + */ + selectPresence() { + let length = config.presence.presences.length; + if (length === 0) return {}; + + let num; + if (length === 1) + num = 0; + else if (config.presence.randomise) + num = Math.floor(Math.random() * length); + else { + current_presence = current_presence + 1; // ++ doesn't work on negative numbers + if (current_presence === length) + current_presence = 0; + num = current_presence; + } + + let { + activity: name, + status, + type, + url + } = config.presence.presences[num]; + + return { + activity: { + name, + type, + url + }, + status + }; + } +}; \ No newline at end of file diff --git a/src/utils/fs.js b/src/utils/fs.js index 4fb1d95..e069d83 100644 --- a/src/utils/fs.js +++ b/src/utils/fs.js @@ -1,5 +1,10 @@ const { join } = require('path'); module.exports = { + /** + * Make a relative path absolute + * @param {string} path - A path relative to the root of the project (like "./user/config.js") + * @returns {string} absolute path + */ path: path => join(__dirname, '../../', path), }; \ No newline at end of file diff --git a/user/example.config.js b/user/example.config.js index d27972f..966c97f 100644 --- a/user/example.config.js +++ b/user/example.config.js @@ -34,23 +34,32 @@ module.exports = { }, max_listeners: 10, plugins: [ - // 'dsctickets.plugin-name' - // 'discordtickets-portal' - ], - presences: [ - { - activity: '/new | /help', - type: 'PLAYING' - }, - { - activity: 'with tickets | /help', - type: 'PLAYING' - }, - { - activity: 'for new tickets | /help', - type: 'WATCHING' - }, ], + presence: { + presences: [ + { + activity: '/new | /help', + type: 'PLAYING', + status: 'online' + }, + { + activity: 'with tickets | /help', + type: 'PLAYING' + }, + { + activity: 'for new tickets | /help', + type: 'WATCHING' + }, + { + activity: 'Minecraft', + type: 'STREAMING', + status: 'dnd', + url: 'https://www.twitch.tv/twitch' + }, + ], + randomise: true, + duration: 60 + }, super_secret_setting: true, update_notice: true, }; \ No newline at end of file