diff --git a/Xiao.js b/Xiao.js index 118fb623..407839ab 100644 --- a/Xiao.js +++ b/Xiao.js @@ -1,7 +1,7 @@ const { XIAO_TOKEN, OWNERS, XIAO_PREFIX, INVITE } = process.env; const path = require('path'); -const { CommandoClient } = require('discord.js-commando'); -const client = new CommandoClient({ +const XiaoClient = require('./structures/Client'); +const client = new XiaoClient({ commandPrefix: XIAO_PREFIX, owner: OWNERS.split(','), invite: INVITE, @@ -9,6 +9,7 @@ const client = new CommandoClient({ unknownCommandResponse: false, disabledEvents: ['TYPING_START'] }); +const SequelizeProvider = require('./providers/Sequelize'); const activities = require('./assets/json/activity'); client.registry @@ -31,12 +32,12 @@ client.registry ]) .registerDefaultCommands({ help: false, - ping: false, - prefix: false, - commandState: false + ping: false }) .registerCommandsIn(path.join(__dirname, 'commands')); +client.setProvider(new SequelizeProvider(client.database)); + client.on('ready', () => { console.log(`[READY] Logged in as ${client.user.tag}! (${client.user.id})`); client.setInterval(() => { diff --git a/assets/json/hat.json b/assets/json/hat.json new file mode 100644 index 00000000..e9172d58 --- /dev/null +++ b/assets/json/hat.json @@ -0,0 +1,7 @@ +[ + "christmas", + "anime", + "tophat", + "pilgrim", + "birthday" +] diff --git a/commands/avatar-edit/hat.js b/commands/avatar-edit/hat.js index d461865c..8afa9b51 100644 --- a/commands/avatar-edit/hat.js +++ b/commands/avatar-edit/hat.js @@ -3,7 +3,7 @@ const { createCanvas, loadImage } = require('canvas'); const snekfetch = require('snekfetch'); const path = require('path'); const { list } = require('../../util/Util'); -const hats = ['christmas', 'anime', 'tophat', 'pilgrim', 'birthday']; +const hats = require('../../assets/json/hat'); module.exports = class HatCommand extends Command { constructor(client) { @@ -12,6 +12,7 @@ module.exports = class HatCommand extends Command { group: 'avatar-edit', memberName: 'hat', description: 'Draws a hat over a user\'s avatar.', + details: `**Hats**: ${hats.join(', ')}`, throttling: { usages: 1, duration: 10 diff --git a/commands/games/akinator.js b/commands/games/akinator.js index 62fb42f9..793c84c4 100644 --- a/commands/games/akinator.js +++ b/commands/games/akinator.js @@ -30,7 +30,7 @@ module.exports = class AkinatorCommand extends Command { answers.push('end'); await msg.say(stripIndents` **${++data.step}.** ${data.question} - ${data.answers.map(answer => answer.answer).join(' | ')} | End + ${data.answers.map(answer => answer.answer).join(' | ')} `); const filter = res => res.author.id === msg.author.id && answers.includes(res.content.toLowerCase()); const msgs = await msg.channel.awaitMessages(filter, { diff --git a/commands/games/balloon-pop.js b/commands/games/balloon-pop.js index 58e11e99..0134ab29 100644 --- a/commands/games/balloon-pop.js +++ b/commands/games/balloon-pop.js @@ -51,7 +51,7 @@ module.exports = class BalloonPopCommand extends Command { } if (pump) { await msg.say(`${user} pumps the balloon!`); - remains -= randomRange(50, 100); + remains -= randomRange(25, 75); const popped = Math.floor(Math.random() * remains); if (popped <= 0) { await msg.say('The balloon pops!'); diff --git a/commands/games/math-quiz.js b/commands/games/math-quiz.js index e9695811..02c85902 100644 --- a/commands/games/math-quiz.js +++ b/commands/games/math-quiz.js @@ -19,6 +19,7 @@ module.exports = class MathQuizCommand extends Command { group: 'games', memberName: 'math-quiz', description: 'See how fast you can answer a math problem in a given time limit.', + details: `**Difficulties**: ${difficulties.join(', ')}`, args: [ { key: 'difficulty', diff --git a/commands/games/quiz.js b/commands/games/quiz.js index 13eb36f2..8031aabb 100644 --- a/commands/games/quiz.js +++ b/commands/games/quiz.js @@ -13,6 +13,10 @@ module.exports = class QuizCommand extends Command { group: 'games', memberName: 'quiz', description: 'Answer a quiz question.', + details: stripIndents` + **Types**: ${types.join(', ')} + **Difficulties**: ${difficulties.join(', ')} + `, args: [ { key: 'type', diff --git a/commands/games/sorting-hat-quiz.js b/commands/games/sorting-hat-quiz.js index c6a2bfa2..a85a8431 100644 --- a/commands/games/sorting-hat-quiz.js +++ b/commands/games/sorting-hat-quiz.js @@ -11,8 +11,7 @@ module.exports = class SortingHatQuizCommand extends Command { aliases: ['sorting-hat', 'pottermore', 'hogwarts'], group: 'games', memberName: 'sorting-hat-quiz', - description: 'Take a quiz to determine your Hogwarts house.', - details: '**Source**: ' + description: 'Take a quiz to determine your Hogwarts house.' }); this.playing = new Set(); diff --git a/commands/games/typing-test.js b/commands/games/typing-test.js index 9e10fcc2..284ed17d 100644 --- a/commands/games/typing-test.js +++ b/commands/games/typing-test.js @@ -19,6 +19,7 @@ module.exports = class TypingTestCommand extends Command { group: 'games', memberName: 'typing-test', description: 'See how fast you can type a sentence in a given time limit.', + details: `**Difficulties**: ${difficulties.join(', ')}`, args: [ { key: 'difficulty', diff --git a/commands/number-edit/temperature.js b/commands/number-edit/temperature.js index 32d6deda..443494a4 100644 --- a/commands/number-edit/temperature.js +++ b/commands/number-edit/temperature.js @@ -10,6 +10,7 @@ module.exports = class TemperatureCommand extends Command { group: 'number-edit', memberName: 'temperature', description: `Converts temperatures to/from ${list(units, 'or')}.`, + details: `**Units**: ${units.join(', ')}`, args: [ { key: 'base', diff --git a/commands/other/coolness.js b/commands/other/coolness.js index 090d0525..ea8266e4 100644 --- a/commands/other/coolness.js +++ b/commands/other/coolness.js @@ -28,6 +28,7 @@ module.exports = class CoolnessCommand extends Command { if (coolness < 1.2) return msg.say(`${user.username} is okay, nothing special.`); if (coolness < 1.4) return msg.say(`${user.username} is just not all that neat.`); if (coolness < 1.6) return msg.say(`${user.username} is awful, honestly.`); - return msg.say(`${user.username} smells like a sack of diapers.`); + if (coolness < 1.8) return msg.say(`${user.username} smells like a sack of diapers.`); + return msg.say(`${user.username} is terrible in every way.`); } }; diff --git a/commands/random/reddit.js b/commands/random/reddit.js index 4389ad02..8bcd2794 100644 --- a/commands/random/reddit.js +++ b/commands/random/reddit.js @@ -1,6 +1,6 @@ const { Command } = require('discord.js-commando'); -const { MessageEmbed } = require('discord.js'); const snekfetch = require('snekfetch'); +const { stripIndents } = require('common-tags'); module.exports = class RedditCommand extends Command { constructor(client) { @@ -10,7 +10,6 @@ module.exports = class RedditCommand extends Command { group: 'random', memberName: 'reddit', description: 'Responds with a random post from a subreddit.', - clientPermissions: ['EMBED_LINKS'], args: [ { key: 'subreddit', @@ -30,18 +29,13 @@ module.exports = class RedditCommand extends Command { const allowed = msg.channel.nsfw ? body.data.children : body.data.children.filter(post => !post.data.over_18); if (!allowed.length) return msg.say('Could not find any results.'); const post = allowed[Math.floor(Math.random() * allowed.length)].data; - const embed = new MessageEmbed() - .setColor(0xFF4500) - .setAuthor('Reddit', 'https://i.imgur.com/DSBOK0P.png') - .setURL(`https://www.reddit.com${post.permalink}`) - .setTitle(post.title) - .addField('❯ Upvotes', - post.ups, true) - .addField('❯ Downvotes', - post.downs, true) - .addField('❯ Score', - post.score, true); - return msg.embed(embed); + return msg.say(stripIndents` + **${post.title}** + + + ⬆ ${post.ups} + ⬇ ${post.downs} + `); } catch (err) { if (err.status === 403) return msg.say('This subreddit is private.'); if (err.status === 404) return msg.say('Could not find any results.'); diff --git a/commands/search/deviantart.js b/commands/search/deviantart.js index d4b2b2ab..0ac50d58 100644 --- a/commands/search/deviantart.js +++ b/commands/search/deviantart.js @@ -11,6 +11,7 @@ module.exports = class DeviantartCommand extends Command { group: 'search', memberName: 'deviantart', description: 'Responds with an image from a DeviantArt section, with optional query.', + details: `**Sections**: ${sections.join(', ')}`, args: [ { key: 'section', diff --git a/commands/search/periodic-table.js b/commands/search/periodic-table.js index b4a84885..559de8c0 100644 --- a/commands/search/periodic-table.js +++ b/commands/search/periodic-table.js @@ -3,8 +3,6 @@ const { createCanvas, loadImage, registerFont } = require('canvas'); const path = require('path'); const { elements, colors } = require('../../assets/json/periodic-table'); registerFont(path.join(__dirname, '..', '..', 'assets', 'fonts', 'Noto-Regular.ttf'), { family: 'Noto' }); -registerFont(path.join(__dirname, '..', '..', 'assets', 'fonts', 'Noto-CJK.otf'), { family: 'Noto' }); -registerFont(path.join(__dirname, '..', '..', 'assets', 'fonts', 'Noto-Emoji.ttf'), { family: 'Noto' }); module.exports = class PeriodicTableCommand extends Command { constructor(client) { diff --git a/commands/search/urban-dictionary.js b/commands/search/urban-dictionary.js index 91c8ce1d..395a16d3 100644 --- a/commands/search/urban-dictionary.js +++ b/commands/search/urban-dictionary.js @@ -1,7 +1,8 @@ const { Command } = require('discord.js-commando'); const { MessageEmbed } = require('discord.js'); const snekfetch = require('snekfetch'); -const { shorten } = require('../../util/Util'); +const { shorten, list } = require('../../util/Util'); +const types = ['random', 'top']; module.exports = class UrbanDictionaryCommand extends Command { constructor(client) { @@ -11,24 +12,36 @@ module.exports = class UrbanDictionaryCommand extends Command { group: 'search', memberName: 'urban-dictionary', description: 'Defines a word, but with Urban Dictionary.', + details: `**Types**: ${types.join(', ')}`, clientPermissions: ['EMBED_LINKS'], args: [ { key: 'word', prompt: 'What word would you like to look up?', type: 'string' + }, + { + key: 'type', + prompt: 'Do you want to get the top answer or a random one?', + type: 'string', + default: 'top', + validate: type => { + if (types.includes(type.toLowerCase())) return true; + return `Invalid type, please enter either ${list(types, 'or')}.`; + }, + parse: type => type.toLowerCase() } ] }); } - async run(msg, { word }) { + async run(msg, { word, type }) { try { const { body } = await snekfetch .get('http://api.urbandictionary.com/v0/define') .query({ term: word }); if (!body.list.length) return msg.say('Could not find any results.'); - const data = body.list[Math.floor(Math.random() * body.list.length)]; + const data = body.list[type === 'top' ? 0 : Math.floor(Math.random() * body.list.length)]; const embed = new MessageEmbed() .setColor(0x32A8F0) .setAuthor('Urban Dictionary', 'https://i.imgur.com/Fo0nRTe.png') diff --git a/commands/text-edit/binary.js b/commands/text-edit/binary.js index 9d0ec314..6ba97084 100644 --- a/commands/text-edit/binary.js +++ b/commands/text-edit/binary.js @@ -9,6 +9,7 @@ module.exports = class BinaryCommand extends Command { group: 'text-edit', memberName: 'binary', description: 'Converts text to binary.', + details: `**Modes**: ${modes.join(', ')}`, args: [ { key: 'mode', diff --git a/commands/util/reload.js b/commands/util/reload.js deleted file mode 100644 index cd64abbb..00000000 --- a/commands/util/reload.js +++ /dev/null @@ -1,27 +0,0 @@ -const { Command } = require('discord.js-commando'); - -module.exports = class ReloadCommand extends Command { - constructor(client) { - super(client, { - name: 'reload', - group: 'util', - memberName: 'reload', - description: 'Reloads a command.', - details: 'Only the bot owner(s) may use this command.', - ownerOnly: true, - guarded: true, - args: [ - { - key: 'command', - prompt: 'Which command would you like to reload?', - type: 'command' - } - ] - }); - } - - run(msg, { command }) { - command.reload(); - return msg.say(`Reloaded \`${command.name}\`.`); - } -}; diff --git a/package.json b/package.json index e4797c6c..0d45f364 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xiao", - "version": "64.0.2", + "version": "64.1.0", "description": "Your personal server companion.", "main": "Xiao.js", "scripts": { diff --git a/providers/Sequelize.js b/providers/Sequelize.js new file mode 100644 index 00000000..1811d38d --- /dev/null +++ b/providers/Sequelize.js @@ -0,0 +1,244 @@ +// Credit: https://github.com/iCrawl/Tohru/blob/master/src/providers/Sequelize.js + +const { SettingProvider } = require('discord.js-commando'); +const Sequelize = require('sequelize'); + +/** + * Uses an PostgreSQL database to store settings with guilds + * @extends {SettingProvider} + */ +class SequelizeProvider extends SettingProvider { + /** + * @external PostgreSQLDatabase + * @see {@link https://www.npmjs.com/package/sequelize} + */ + + /** + * @param {SQLDatabase} db - Database for the provider + */ + constructor(db) { + super(); + + /** + * Database that will be used for storing/retrieving settings + * @type {SQLDatabase} + */ + this.db = db; + + /** + * Client that the provider is for (set once the client is ready, after using {@link CommandoClient#setProvider}) + * @name SequelizeProvider#client + * @type {CommandoClient} + * @readonly + */ + Object.defineProperty(this, 'client', { value: null, writable: true }); + + /** + * Settings cached in memory, mapped by guild ID (or 'global') + * @type {Map} + * @private + */ + this.settings = new Map(); + + /** + * Listeners on the Client, mapped by the event name + * @type {Map} + * @private + */ + this.listeners = new Map(); + + /** + * Sequelize Model Object + * @type {SequelizeModel} + * @private + */ + this.model = this.db.define('settings', { + guild: { + type: Sequelize.BIGINT, + allowNull: false, + unique: true, + primaryKey: true + }, + settings: { type: Sequelize.TEXT } + }, { freezeTableName: true, timestamps: false }); + + /** + * @external SequelizeModel + * @see {@link http://docs.sequelizejs.com/en/latest/api/model/} + */ + } + + async init(client) { + this.client = client; + await this.db.sync(); + + // Load all settings + const rows = await this.model.findAll(); + for (const row of rows) { + let settings; + try { + settings = JSON.parse(row.dataValues.settings); + } catch (err) { + client.emit('warn', `SequelizeProvider couldn't parse the settings stored for guild ${row.dataValues.guild}.`); + continue; + } + + const guild = row.dataValues.guild !== '0' ? row.dataValues.guild : 'global'; + + this.settings.set(guild, settings); + if (guild !== 'global' && !client.guilds.has(row.dataValues.guild)) continue; + this.setupGuild(guild, settings); + } + + // Listen for changes + this.listeners + .set('commandPrefixChange', (guild, prefix) => this.set(guild, 'prefix', prefix)) + .set('commandStatusChange', (guild, command, enabled) => this.set(guild, `cmd-${command.name}`, enabled)) + .set('groupStatusChange', (guild, group, enabled) => this.set(guild, `grp-${group.id}`, enabled)) + .set('guildCreate', guild => { + const settings = this.settings.get(guild.id); + if (!settings) return; + this.setupGuild(guild.id, settings); + }) + .set('commandRegister', command => { + for (const [guild, settings] of this.settings) { + if (guild !== 'global' && !client.guilds.has(guild)) continue; + this.setupGuildCommand(client.guilds.get(guild), command, settings); + } + }) + .set('groupRegister', group => { + for (const [guild, settings] of this.settings) { + if (guild !== 'global' && !client.guilds.has(guild)) continue; + this.setupGuildGroup(client.guilds.get(guild), group, settings); + } + }); + for (const [event, listener] of this.listeners) client.on(event, listener); + } + + destroy() { + // Remove all listeners from the client + for (const [event, listener] of this.listeners) this.client.removeListener(event, listener); + this.listeners.clear(); + } + + get(guild, key, defVal) { + const settings = this.settings.get(this.constructor.getGuildID(guild)); + return settings ? typeof settings[key] !== 'undefined' ? settings[key] : defVal : defVal; + } + + async set(guild, key, val) { + guild = this.constructor.getGuildID(guild); + let settings = this.settings.get(guild); + if (!settings) { + settings = {}; + this.settings.set(guild, settings); + } + + settings[key] = val; + await this.model.upsert( + { guild: guild !== 'global' ? guild : '0', settings: JSON.stringify(settings) } + ); + if (guild === 'global') this.updateOtherShards(key, val); + return val; + } + + async remove(guild, key) { + guild = this.constructor.getGuildID(guild); + const settings = this.settings.get(guild); + if (!settings || typeof settings[key] === 'undefined') return undefined; + + const val = settings[key]; + settings[key] = undefined; + await this.model.upsert( + { guild: guild !== 'global' ? guild : '0', settings: JSON.stringify(settings) } + ); + if (guild === 'global') this.updateOtherShards(key, undefined); + return val; + } + + async clear(guild) { + guild = this.constructor.getGuildID(guild); + if (!this.settings.has(guild)) return; + this.settings.delete(guild); + await this.model.destroy({ where: { guild: guild !== 'global' ? guild : '0' } }); + } + + /** + * Loads all settings for a guild + * @param {string} guild - Guild ID to load the settings of (or 'global') + * @param {Object} settings - Settings to load + * @private + */ + setupGuild(guild, settings) { + if (typeof guild !== 'string') throw new TypeError('The guild must be a guild ID or "global".'); + guild = this.client.guilds.get(guild) || null; + + // Load the command prefix + if (typeof settings.prefix !== 'undefined') { + if (guild) guild._commandPrefix = settings.prefix; + else this.client._commandPrefix = settings.prefix; + } + + // Load all command/group statuses + for (const command of this.client.registry.commands.values()) this.setupGuildCommand(guild, command, settings); + for (const group of this.client.registry.groups.values()) this.setupGuildGroup(guild, group, settings); + } + + /** + * Sets up a command's status in a guild from the guild's settings + * @param {?Guild} guild - Guild to set the status in + * @param {Command} command - Command to set the status of + * @param {Object} settings - Settings of the guild + * @private + */ + setupGuildCommand(guild, command, settings) { + if (typeof settings[`cmd-${command.name}`] === 'undefined') return; + if (guild) { + if (!guild._commandsEnabled) guild._commandsEnabled = {}; + guild._commandsEnabled[command.name] = settings[`cmd-${command.name}`]; + } else { + command._globalEnabled = settings[`cmd-${command.name}`]; + } + } + + /** + * Sets up a group's status in a guild from the guild's settings + * @param {?Guild} guild - Guild to set the status in + * @param {CommandGroup} group - Group to set the status of + * @param {Object} settings - Settings of the guild + * @private + */ + setupGuildGroup(guild, group, settings) { + if (typeof settings[`grp-${group.id}`] === 'undefined') return; + if (guild) { + if (!guild._groupsEnabled) guild._groupsEnabled = {}; + guild._groupsEnabled[group.id] = settings[`grp-${group.id}`]; + } else { + group._globalEnabled = settings[`grp-${group.id}`]; + } + } + + /** + * Updates a global setting on all other shards if using the {@link ShardingManager}. + * @param {string} key - Key of the setting to update + * @param {*} val - Value of the setting + * @private + */ + updateOtherShards(key, val) { + if (!this.client.shard) return; + key = JSON.stringify(key); + val = typeof val !== 'undefined' ? JSON.stringify(val) : 'undefined'; + this.client.shard.broadcastEval(` + if(this.shard.id !== ${this.client.shard.id} && this.provider && this.provider.settings) { + let global = this.provider.settings.get('global'); + if (!global) { + global = {}; + this.provider.settings.set('global', global) + } + global[${key}] = ${val}; + } + `); + } +} + +module.exports = SequelizeProvider; diff --git a/structures/Client.js b/structures/Client.js new file mode 100644 index 00000000..aa93b8e0 --- /dev/null +++ b/structures/Client.js @@ -0,0 +1,13 @@ +const { CommandoClient } = require('discord.js-commando'); +const Database = require('../structures/PostgreSQL'); + +class XiaoClient extends CommandoClient { + constructor(options) { + super(options); + this.database = Database.db; + + Database.start(); + } +} + +module.exports = XiaoClient; diff --git a/structures/PostgreSQL.js b/structures/PostgreSQL.js new file mode 100644 index 00000000..ce89e8fa --- /dev/null +++ b/structures/PostgreSQL.js @@ -0,0 +1,23 @@ +const Sequelize = require('sequelize'); +const { DB_URL } = process.env; +const db = new Sequelize(DB_URL, { logging: false, operatorsAliases: false }); + +class PostgreSQL { + static get db() { + return db; + } + + static async start() { + try { + await db.authenticate(); + console.log('[DATABASE] Connection established! Syncing...'); + await db.sync(); + console.log('[DATABASE] Database sync complete!'); + } catch (err) { + console.error('[DATABASE] Unable to connect to database:', err); + setTimeout(() => PostgreSQL.start(), 5000); + } + } +} + +module.exports = PostgreSQL;