diff --git a/XiaoBot.js b/XiaoBot.js index 45c85f59..a5894da1 100644 --- a/XiaoBot.js +++ b/XiaoBot.js @@ -1,7 +1,7 @@ const { TOKEN, OWNERS, COMMAND_PREFIX, INVITE } = process.env; const path = require('path'); -const { CommandoClient } = require('discord.js-commando'); -const client = new CommandoClient({ +const Client = require('./structures/Client'); +const client = new Client({ commandPrefix: COMMAND_PREFIX, owner: OWNERS.split(','), invite: INVITE, @@ -11,6 +11,7 @@ const client = new CommandoClient({ messageCacheLifetime: 600, messageSweepInterval: 120 }); +const SequelizeProvider = require('./providers/Sequelize'); const { dBots, dBotsOrg, filterTopics, parseTopic } = require('./structures/Util'); client.registry @@ -33,12 +34,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] Shard ${client.shard.id} logged in as ${client.user.tag} (${client.user.id})!`); client.user.setActivity(`${COMMAND_PREFIX}help | Shard ${client.shard.id}`, { type: 0 }); diff --git a/package.json b/package.json index 84d6b1d4..49ce85f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xiaobot", - "version": "35.1.7", + "version": "35.2.0", "description": "Your personal server companion.", "main": "Shard.js", "scripts": { @@ -38,6 +38,9 @@ "discord.js-commando": "github:dragonfire535/discord.js-commando#test", "erlpack": "github:hammerandchisel/erlpack", "node-opus": "^0.2.6", + "pg": "^6.4.2", + "pg-hstore": "^2.3.2", + "sequelize": "^4.8.0", "snekfetch": "^3.3.0", "uws": "^8.14.1", "xml2js": "^0.4.19", diff --git a/providers/Sequelize.js b/providers/Sequelize.js new file mode 100644 index 00000000..7423d25b --- /dev/null +++ b/providers/Sequelize.js @@ -0,0 +1,239 @@ +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.STRING, + allowNull: false, + unique: true, + primaryKey: true + }, + settings: { type: Sequelize.TEXT } + }); + + /** + * @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) }, + { where: { guild: guild !== 'global' ? guild : '0' } } + ); + 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) }, + { where: { guild: guild !== 'global' ? guild : '0' } } + ); + 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) { + this.provider.settings.global[${key}] = ${val}; + } + `); + } +} + +module.exports = SequelizeProvider; diff --git a/structures/Client.js b/structures/Client.js new file mode 100644 index 00000000..893414f8 --- /dev/null +++ b/structures/Client.js @@ -0,0 +1,13 @@ +const { CommandoClient } = require('discord.js-commando'); +const Database = require('./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..57a1574d --- /dev/null +++ b/structures/PostgreSQL.js @@ -0,0 +1,26 @@ +const Sequelize = require('sequelize'); +const { DB_URL } = process.env; +const database = new Sequelize(DB_URL, { logging: false }); + +class Database { + static get db() { + return database; + } + + static start() { + database.authenticate() + .then(() => console.log('[DATABASE] Connection established successfully.')) + .then(() => console.log('[DATABASE] Synchronizing...')) + .then(() => database.sync() + .then(() => console.log('[DATABASE] Done Synchronizing!')) + .catch(err => console.error(`[DATABASE] Error synchronizing: ${err}`)) + ) + .catch(err => { + console.error(`[DATABASE] Unable to connect: ${err}`); + console.error(`[DATABASE] Reconnecting in 5 seconds...`); + setTimeout(() => Database.start(), 5000); + }); + } +} + +module.exports = Database;