Command handler stuff, presences, other stuff

This commit is contained in:
Isaac 2021-02-18 18:41:35 +00:00
parent 7c0b1311dc
commit bdcff221db
No known key found for this signature in database
GPG Key ID: 279D1F53391CED07
15 changed files with 435 additions and 109 deletions

36
src/commands/new.js Normal file
View File

@ -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);
}
};

View File

@ -69,10 +69,6 @@ const log = new Logger({
logToFile: config.logs.enabled, logToFile: config.logs.enabled,
keepFor: config.logs.keep_for, keepFor: config.logs.keep_for,
custom: { custom: {
listeners: {
title: 'info',
prefix: 'listeners'
},
commands: { commands: {
title: 'info', title: 'info',
prefix: 'commands' prefix: 'commands'
@ -84,6 +80,7 @@ const log = new Logger({
} }
}); });
const { selectPresence } = require('./utils/discord');
const I18n = require('@eartharoid/i18n'); const I18n = require('@eartharoid/i18n');
const { CommandManager } = require('./modules/commands'); const { CommandManager } = require('./modules/commands');
const { PluginManager } = require('./modules/plugins'); const { PluginManager } = require('./modules/plugins');
@ -93,6 +90,10 @@ const {
Intents Intents
} = require('discord.js'); } = require('discord.js');
/**
* The bot client
* @extends {Client}
*/
class Bot extends Client { class Bot extends Client {
constructor() { constructor() {
super({ super({
@ -101,6 +102,7 @@ class Bot extends Client {
'CHANNEL', 'CHANNEL',
'REACTION' 'REACTION'
], ],
presence: selectPresence(),
ws: { ws: {
intents: Intents.NON_PRIVILEGED, intents: Intents.NON_PRIVILEGED,
} }
@ -126,7 +128,6 @@ class Bot extends Client {
/** The command manager, used by internal and plugin commands */ /** The command manager, used by internal and plugin commands */
this.commands = new CommandManager(this); this.commands = new CommandManager(this);
this.commands.load(); // load internal commands
/** The plugin manager */ /** The plugin manager */
this.plugins = new PluginManager(this); this.plugins = new PluginManager(this);
@ -144,7 +145,7 @@ new Bot();
const { version } = require('../package.json'); const { version } = require('../package.json');
process.on('unhandledRejection', error => { 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(`Discord Tickets v${version}, Node v${process.versions.node} on ${process.platform}`);
log.warn('An error was not caught'); log.warn('An error was not caught');
if (error instanceof Error) log.warn(`Uncaught ${error.name}: ${error}`); if (error instanceof Error) log.warn(`Uncaught ${error.name}: ${error}`);

View File

@ -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);
}
}
};

5
src/listeners/message.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
event: 'message',
execute: (client, message) => {
}
};

View File

@ -0,0 +1,5 @@
module.exports = {
event: 'messageUpdate',
execute: (client, m1, m2) => {
}
};

View File

@ -16,9 +16,21 @@ module.exports = {
method: 'post', method: 'post',
}).catch(e => { }).catch(e => {
// fail quietly, it doesn't really matter if it didn't work // 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); 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);
}
} }
}; };

View File

@ -1,92 +1,106 @@
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
const { Client } = require('discord.js'); const { Client, GuildMember, Guild, Channel } = require('discord.js');
const fs = require('fs'); const fs = require('fs');
const { join } = require('path'); const { join } = require('path');
const { path } = require('../../utils/fs'); const { path } = require('../../utils/fs');
/** A plugin */ /**
module.exports = class Plugin { * A command
*/
module.exports = class Command {
/** /**
* Create a new Plugin * A command option choice
* @param {Client} client The Discord Client * @typedef CommandOptionChoice
* @param {String} id The plugin ID * @property {string} name - Choice name (1-100)
* @param {Object} options Plugin options * @property {(string|number)} value - choice value
* @param {String} options.name A human-friendly name (can be different to the name in package.json) */
/**
* 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 */ /** The Discord Client */
this.client = client; this.client = client;
/** The PluginManager */ /** The CommandManager */
this.manager = this.client.plugins; this.manager = this.client.commands;
// Object.assign(this, this.manager.plugins.get(id)); if (typeof data !== 'object') {
// make JSDoc happy throw new TypeError(`Expected type of data to be an object, got ${typeof data}`);
let {
version,
author,
description
} = this.manager.plugins.get(id);
/** The unique ID of the plugin (NPM package name) */
this.id = id;
/** The version of the plugin (NPM package version) */
this.version = version;
/** The plugin author's name (NPM package author) */
this.author = author;
/** 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;
} }
}
/** /**
* Create the plugin config file if it doesn't already exist * The name of the command
* @param {Object} template The default config template * @type {string}
* @returns {Boolean} True if created, false if it already existed */
*/ this.name = data.name;
createConfig(template) {
this.createDirectory(); /**
let file = join(this.directory.path, 'config.json'); * The command description
if (!fs.existsSync(file)) { * @type {string}
this.client.log.plugins(`Creating plugin config file for "${this.name}"`); */
fs.writeFileSync(file, JSON.stringify(template, null, 2)); this.description = data.description;
return true;
} else { /**
return false; * The command options
* @type {CommandOption[]}
*/
this.options = data.options;
/** True if command is internal, false if it is from a plugin */
this.internal = data.internal;
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`);
} }
/** /**
* The main function where your code should go. Create functions and event listeners here * [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
*/ */
load() {}
/**
* 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
*/
async execute(data) {}
}; };

View File

@ -1,4 +1,14 @@
module.exports = { module.exports = {
CommandManager: require('./manager'), 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,
},
}; };

View File

@ -6,7 +6,9 @@ const Command = require('./command');
const fs = require('fs'); const fs = require('fs');
const { path } = require('../../utils/fs'); const { path } = require('../../utils/fs');
/** Manages the loading of commands */ /**
* Manages the loading of commands
*/
module.exports = class CommandManager { module.exports = class CommandManager {
/** /**
* Create a CommandManager instance * Create a CommandManager instance
@ -15,6 +17,7 @@ module.exports = class CommandManager {
constructor(client) { constructor(client) {
/** The Discord Client */ /** The Discord Client */
this.client = client; this.client = client;
/** A discord.js Collection (Map) of loaded commands */ /** A discord.js Collection (Map) of loaded commands */
this.commands = new Collection(); this.commands = new Collection();
} }
@ -24,10 +27,113 @@ module.exports = class CommandManager {
const files = fs.readdirSync(path('./src/commands')) const files = fs.readdirSync(path('./src/commands'))
.filter(file => file.endsWith('.js')); .filter(file => file.endsWith('.js'));
for (const file of files) { for (let file of files) {
const cmd = require(`../commands/${file}`); try {
this.commands.set(cmd, new cmd(this.client)); 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++;
});
}
}; };

View File

@ -7,7 +7,11 @@ module.exports = client => {
for (const file of files) { for (const file of files) {
const listener = require(`../listeners/${file}`); const listener = require(`../listeners/${file}`);
const exec = (...args) => listener.execute(client, ...args);
let on = listener.once ? 'once' : 'on'; 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);
} }
}; };

View File

@ -7,7 +7,9 @@ const fs = require('fs');
const { join } = require('path'); const { join } = require('path');
const { path } = require('../../utils/fs'); const { path } = require('../../utils/fs');
/** Manages the loading of plugins */ /**
* Manages the loading of plugins
*/
module.exports = class PluginManager { module.exports = class PluginManager {
/** /**
* Create a PluginManager instance * Create a PluginManager instance
@ -16,6 +18,7 @@ module.exports = class PluginManager {
constructor(client) { constructor(client) {
/** The Discord Client */ /** The Discord Client */
this.client = client; this.client = client;
/** A discord.js Collection (Map) of loaded plugins */ /** A discord.js Collection (Map) of loaded plugins */
this.plugins = new Collection(); this.plugins = new Collection();

View File

@ -5,7 +5,9 @@ const fs = require('fs');
const { join } = require('path'); const { join } = require('path');
const { path } = require('../../utils/fs'); const { path } = require('../../utils/fs');
/** A plugin */ /**
* A plugin
*/
module.exports = class Plugin { module.exports = class Plugin {
/** /**
* Create a new Plugin * Create a new Plugin
@ -13,11 +15,9 @@ module.exports = class Plugin {
* @param {String} id The plugin ID * @param {String} id The plugin ID
* @param {Object} options Plugin options * @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.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, id, options = {}) {
/** The human-friendly name of the plugin */
this.name = options.name || id;
/** The Discord Client */ /** The Discord Client */
this.client = client; this.client = client;
@ -33,25 +33,53 @@ module.exports = class Plugin {
description description
} = this.manager.plugins.get(id); } = 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; this.id = id;
/** The version of the plugin (NPM package version) */ /**
* The version of the plugin (NPM package version)
* @type {string}
*/
this.version = version; 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; this.author = author;
/** The plugin description (NPM package description) */ /**
* The plugin description (NPM package description)
* @type {string}
*/
this.description = description; this.description = description;
this.directory = {}; let clean = this.id.replace(/@[-_a-zA-Z0-9]+\//, '');
/** 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]+\//, ''); * Information about the plugin directory
* @property {string} name - A cleaned version of the plugin's ID suitable for use in the directory name
/** The absolute path of the plugin directory */ * @property {string} path - The absolute path of the plugin directory
this.directory.path = path(`./user/plugins/${this.directory.name}`); */
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 * The main function where your code should go. Create functions and event listeners here
* @abstract
*/ */
load() {} load() {}
}; };

45
src/utils/discord.js Normal file
View File

@ -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
};
}
};

View File

@ -1,5 +1,10 @@
const { join } = require('path'); const { join } = require('path');
module.exports = { 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), path: path => join(__dirname, '../../', path),
}; };

View File

@ -34,23 +34,32 @@ module.exports = {
}, },
max_listeners: 10, max_listeners: 10,
plugins: [ 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, super_secret_setting: true,
update_notice: true, update_notice: true,
}; };