diff --git a/src/commands/new.js b/src/commands/new.js index d0ee6c2..98bfa1a 100644 --- a/src/commands/new.js +++ b/src/commands/new.js @@ -1,3 +1,4 @@ +const { MessageEmbed } = require('discord.js'); const { Command, OptionTypes @@ -8,7 +9,7 @@ module.exports = class NewCommand extends Command { super(client, { internal: true, name: 'new', - description: 'Create a new ticket', + description: 'Create a new support ticket', options: [ // { // name: 'category', @@ -26,11 +27,15 @@ module.exports = class NewCommand extends Command { }); } - 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); + async execute({ guild, member, channel, args}, interaction) { + console.log(args); + // console.log(channel.name); + // console.log(member.user.tag); + // console.log(guild.name); + + const i18n = this.client.i18n.get(/* GET GUILD LOCALE FROM SETTINGS */); + + return new MessageEmbed() + .setTitle(i18n('bot.version', require('../../package.json').version)); } }; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 7b41f21..af9535c 100644 --- a/src/index.js +++ b/src/index.js @@ -146,7 +146,7 @@ new Bot(); const { version } = require('../package.json'); process.on('unhandledRejection', error => { 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.notice(`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}`); log.error(error); diff --git a/src/listeners/debug.js b/src/listeners/debug.js new file mode 100644 index 0000000..607f7d4 --- /dev/null +++ b/src/listeners/debug.js @@ -0,0 +1,7 @@ +module.exports = { + event: 'debug', + execute: (client, data) => { + if (client.config.debug) + client.log.debug(data); + } +}; \ No newline at end of file diff --git a/src/listeners/interaction.js b/src/listeners/interaction.js index a479356..6913507 100644 --- a/src/listeners/interaction.js +++ b/src/listeners/interaction.js @@ -3,25 +3,22 @@ module.exports = { raw: true, execute: async (client, interaction) => { - if (interaction.type !== 2) return; + if (interaction.type === 1) { + client.log.debug('Received interaction ping, responding with pong'); + return await client.api.interactions(interaction.id, interaction.token).callback.post({ + data: { + type: 1, + } + }); + } 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); + return client.log.warn(`[COMMANDS] Received "${cmd}" command invocation, but the command manager does not have a "${cmd}" command`); try { - client.log.commands(`Executing ${cmd} command (invoked by ${data.member.user.username.tag})`); - client.commands.commands.get(cmd).execute(data); + client.commands.execute(cmd, interaction); } catch (e) { client.log.warn(`[COMMANDS] An error occurred whilst executed the ${cmd} command`); client.log.error(e); diff --git a/src/listeners/ready.js b/src/listeners/ready.js index 2a8c2d8..9bddb1e 100644 --- a/src/listeners/ready.js +++ b/src/listeners/ready.js @@ -23,6 +23,8 @@ module.exports = { client.commands.load(); // load internal commands + client.plugins.plugins.forEach(p => p.load()); // call load function for each plugin + if (client.config.presence.presences.length > 1) { const { selectPresence } = require('../utils/discord'); setInterval(() => { diff --git a/src/locales/en-GB.json b/src/locales/en-GB.json index 9e26dfe..c2b360d 100644 --- a/src/locales/en-GB.json +++ b/src/locales/en-GB.json @@ -1 +1,5 @@ -{} \ No newline at end of file +{ + "bot": { + "version": "DiscordTickets v%s by eartharoid" + } +} \ No newline at end of file diff --git a/src/modules/commands/command.js b/src/modules/commands/command.js index a81f4cd..7bc4f48 100644 --- a/src/modules/commands/command.js +++ b/src/modules/commands/command.js @@ -4,17 +4,19 @@ const { Client, GuildMember, Guild, Channel } = require('discord.js'); const fs = require('fs'); const { join } = require('path'); const { path } = require('../../utils/fs'); +const { createMessage, flags } = require('../../utils/discord'); +const Plugin = require('../plugins/plugin'); /** * A command */ module.exports = class Command { /** - * A command option choice - * @typedef CommandOptionChoice + * A command option choice + * @typedef CommandOptionChoice * @property {string} name - Choice name (1-100) * @property {(string|number)} value - choice value - */ + */ /** * A command option @@ -33,9 +35,11 @@ module.exports = class Command { * @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 {boolean} staff_only - Only allow staff to use this command? + * @param {string[]} permissions - Array of permissions needed for a user to use this command * @param {CommandOption[]} data.options - The command options, max of 10 */ - constructor(client, data) { + constructor(client, data) { /** The Discord Client */ this.client = client; @@ -59,18 +63,40 @@ module.exports = class Command { */ this.description = data.description; + /** + * Only allow staff to use this command? + * @type {boolean} + */ + this.staff_only = data.staff_only; + + /** + * Array of permissions needed for a user to use this command + * @type {string[]} + */ + this.permissions = data.permissions; + /** * The command options * @type {CommandOption[]} */ this.options = data.options; - /** True if command is internal, false if it is from a plugin */ + /** + * True if command is internal, false if it is from a plugin + * @type {boolean} + */ this.internal = data.internal; - + if (!this.internal) { + /** + * The plugin this command belongs to, if any + * @type {(undefined|Plugin)} + */ + this.plugin = this.client.plugins.plugins.find(p => p.commands?.includes(this.name)); + } + this.manager.check(data); // validate - + try { this.manager.register(this); // register the command } catch (e) { @@ -92,15 +118,83 @@ module.exports = class Command { * @property {(undefined|ApplicationCommandInteractionDataOption[])} options - Present if the option is a subcommand/subcommand group */ + /** + * [Interaction](https://discord.com/developers/docs/interactions/slash-commands#interaction) object + * @typedef {Object} Interaction + * @property {string} interaction.id - ID of the interaction + * @property {number} interaction.type - Type of interaction + * @property {ApplicationCommandInteractionData} interaction.data - Interaction data + * @property {Guild} interaction.guild- The guild object + * @property {Channel} interaction.channel- The channel object + * @property {GuildMember} interaction.member - The member object + * @property {string} interaction.token - The token used to respond to the interaction + */ + /** * 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 {Object} 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 + * @param {Interaction} interaction - Interaction object */ - async execute(data) {} + async execute(data, interaction) { } + + /** + * Defer the response to respond later + * @param {Interaction} interaction - Interaction object + * @param {boolean} secret - Ephemeral message? **NOTE: EMBEDS AND ATTACHMENTS DO NOT RENDER IF TRUE** + */ + async deferResponse(interaction, secret) { + this.client.api.interactions(interaction.id, interaction.token).callback.post({ + data: { + type: 5, + flags: flags(secret), + } + }); + } + + /** + * Send an interaction response + * @param {Interaction} interaction - Interaction object + * @param {*} content - Message content + * @param {boolean} secret - Ephemeral message? **NOTE: EMBEDS AND ATTACHMENTS DO NOT RENDER IF TRUE** + */ + async sendResponse(interaction, content, secret) { + if (typeof content === 'object') + this.client.api.interactions(interaction.id, interaction.token).callback.post({ + data: { + type: 4, + flags: flags(secret), + data: await createMessage(this.client, interaction.channel_id, content) + } + }); + else + this.client.api.interactions(interaction.id, interaction.token).callback.post({ + data: { + type: 4, + flags: flags(secret), + content + } + }); + } + + /** + * Edit the original interaction response + * @param {Interaction} interaction - Interaction object + * @param {*} content - Message content + */ + async editResponse(interaction, content) { + if (typeof content === 'object') + this.client.api.interactions(interaction.id, interaction.token).messages.patch({ + embeds: content + }); + else + this.client.api.interactions(interaction.id, interaction.token).messages.patch({ + content + }); + } }; \ No newline at end of file diff --git a/src/modules/commands/index.js b/src/modules/commands/index.js index 6ebfb7d..0427f92 100644 --- a/src/modules/commands/index.js +++ b/src/modules/commands/index.js @@ -11,4 +11,9 @@ module.exports = { CHANNEL: 7, ROLE: 8, }, + ResponseTypes: { + Pong: 1, + ChannelMessageWithSource: 4, + DeferredChannelMessageWithSource: 5, + }, }; \ No newline at end of file diff --git a/src/modules/commands/manager.js b/src/modules/commands/manager.js index 7ddca8a..fa134ae 100644 --- a/src/modules/commands/manager.js +++ b/src/modules/commands/manager.js @@ -40,8 +40,9 @@ module.exports = class CommandManager { /** 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); + const exists = this.commands.has(cmd.name); + const is_internal = (exists && cmd.internal) + || (exists && this.commands.get(cmd.name).internal); if (is_internal) { let plugin = this.client.plugins.plugins.find(p => p.commands.includes(cmd.name)); @@ -51,7 +52,7 @@ module.exports = class CommandManager { 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)) + else if (exists) throw new Error(`A non-internal command with the name "${cmd.name}" already exists`); this.commands.set(cmd.name, cmd); @@ -136,4 +137,46 @@ module.exports = class CommandManager { }); } + /** + * Execute a command + * @param {string} cmd_name - Name of the command + * @param {interaction} interaction - Command interaction + */ + async execute(cmd_name, interaction) { + if (!this.commands.has(cmd_name)) + throw new Error(`Unregistered command: "${cmd_name}"`); + + let args = {}; + if (interaction.data.options) + interaction.data.options.forEach(({ name, value }) => args[name] = value); + + let data = { args }; + data.guild = await this.client.guilds.fetch(interaction.guild_id); + data.channel = await this.client.channels.fetch(interaction.channel_id), + data.member = await data.guild.members.fetch(interaction.member.user.id); + + const cmd = this.commands.get(cmd_name); + + // if (cmd.staff_only) { + // return await cmd.sendResponse(interaction, msg, true); + // } + + const no_perm = cmd.permissions instanceof Array + && !data.member.hasPermission(cmd.permissions); + if (no_perm) { + let perms = cmd.permissions.map(p => `\`${p}\``).join(', '); + let msg = `❌ You do not have the permissions required to use this command:\n${perms}`; + return await cmd.sendResponse(interaction, msg, true); + } + + await cmd.deferResponse(interaction, true); + + this.client.log.commands(`Executing "${cmd_name}" command (invoked by ${data.member.user.tag})`); + + let res = await cmd.execute(data, interaction); // run the command + + if (typeof res === 'object' || typeof res === 'string') + cmd.sendResponse(interaction, res, res.secret); + } + }; \ No newline at end of file diff --git a/src/modules/plugins/manager.js b/src/modules/plugins/manager.js index d08d739..e8f9106 100644 --- a/src/modules/plugins/manager.js +++ b/src/modules/plugins/manager.js @@ -62,15 +62,10 @@ module.exports = class PluginManager { description }; - this.plugins.set(id, about); - try { - let plugin = new Main(this.client, id); - plugin.load(); - - this.plugins.set(id, Object.assign(about, { - name: plugin.name || id, - })); + let plugin = new Main(this.client, about); + this.plugins.set(id, plugin); + plugin.preload(); } catch (e) { if (npm) { diff --git a/src/modules/plugins/plugin.js b/src/modules/plugins/plugin.js index 77b0929..43e2538 100644 --- a/src/modules/plugins/plugin.js +++ b/src/modules/plugins/plugin.js @@ -17,7 +17,7 @@ module.exports = class Plugin { * @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 = {}) { + constructor(client, about, options = {}) { /** The Discord Client */ this.client = client; @@ -28,10 +28,11 @@ module.exports = class Plugin { // make JSDoc happy let { + id, version, author, description - } = this.manager.plugins.get(id); + } = about; /** * The human-friendly name of the plugin @@ -125,7 +126,15 @@ module.exports = class Plugin { } /** - * The main function where your code should go. Create functions and event listeners here + * The function where any code that needs to be executed before the client is ready should go. + * **This is executed _BEFORE_ the ready event** + * @abstract + */ + preload() { } + + /** + * The main function where your code should go. Create commands and event listeners here. + * **This is executed _after_ the ready event** * @abstract */ load() {} diff --git a/src/modules/tickets/index.js b/src/modules/tickets/index.js new file mode 100644 index 0000000..98f77e0 --- /dev/null +++ b/src/modules/tickets/index.js @@ -0,0 +1,3 @@ +module.exports = { + +}; \ No newline at end of file diff --git a/src/structures/textchannel.js b/src/structures/textchannel.js deleted file mode 100644 index 3fc415b..0000000 --- a/src/structures/textchannel.js +++ /dev/null @@ -1,25 +0,0 @@ -const { Structures } = require('discord.js'); - -Structures.extend('TextChannel', TextChannel => { - return class extends TextChannel { - constructor(client, data) { - super(client, data); - } - - get isTicket() { - return !!this.client.db.Ticket.findOne({ - where: { - id: this.id - } - }); - } - - get ticket() { - return new class { - constructor(channel) { - this.channel = channel; - } - }(this); - } - }; -}); \ No newline at end of file diff --git a/src/utils/discord.js b/src/utils/discord.js index 575c5e6..d6f206b 100644 --- a/src/utils/discord.js +++ b/src/utils/discord.js @@ -1,5 +1,4 @@ -// eslint-disable-next-line no-unused-vars -const { PresenceData } = require('discord.js'); +const Discord = require('discord.js'); const config = require('../../user/config'); @@ -7,10 +6,30 @@ let current_presence = -1; module.exports = { /** - * Select a presence from the config - * @returns {PresenceData} + * Resolves data and files so embeds can be sent as a response to a slash command + * @param {Discord.Client} channel_id - Text channel ID + * @param {string} channel_id - Text channel ID + * @param {*} content - Message content + * @returns {Object} */ - selectPresence() { + createMessage: async (client, channel_id, content) => { + let msg = await Discord.APIMessage.create(client.channels.resolve(channel_id), content) + .resolveData() + .resolveFiles(); + return { ...msg.data, files: msg.files }; + }, + + /** + * Generate flags + * @param {boolean} secret - Ephemeral message? + */ + flags: (secret) => secret ? 1 << 64 : undefined, + + /** + * Select a presence from the config + * @returns {Discord.PresenceData} + */ + selectPresence: () => { let length = config.presence.presences.length; if (length === 0) return {}; @@ -41,5 +60,5 @@ module.exports = { }, status }; - } + }, }; \ No newline at end of file diff --git a/user/example.config.js b/user/example.config.js index 966c97f..bdc6ce8 100644 --- a/user/example.config.js +++ b/user/example.config.js @@ -26,7 +26,8 @@ module.exports = { debug: false, defaults: { colour: '#009999', - locale: 'en-GB' + locale: 'en-GB', + log_messages: true, // required for transcripts/archives }, logs: { enabled: true, @@ -50,12 +51,12 @@ module.exports = { activity: 'for new tickets | /help', type: 'WATCHING' }, - { + /* { // an example activity: 'Minecraft', type: 'STREAMING', status: 'dnd', url: 'https://www.twitch.tv/twitch' - }, + }, */ ], randomise: true, duration: 60