diff --git a/src/structure/DiscordClient.js b/src/structure/DiscordClient.js index 125ce2c..0746a31 100644 --- a/src/structure/DiscordClient.js +++ b/src/structure/DiscordClient.js @@ -7,6 +7,7 @@ const StorageManager = require('./storage/StorageManager.js'); const { DefaultGuild } = require('../constants/'); const options = require('../../options.json'); +const RateLimiter = require('./client/RateLimiter'); class DiscordClient extends Client { @@ -25,6 +26,7 @@ class DiscordClient extends Client { this.storageManager = new StorageManager(this, options.storage); this.registry = new Registry(this); this.resolver = new Resolver(this); + this.rateLimiter = new RateLimiter(this); this.wrapperClasses = { ...require('./client/wrappers') diff --git a/src/structure/client/RateLimiter.js b/src/structure/client/RateLimiter.js new file mode 100644 index 0000000..f54ef60 --- /dev/null +++ b/src/structure/client/RateLimiter.js @@ -0,0 +1,203 @@ +const { TextChannel, Message } = require("discord.js"); + +class RateLimiter { + + constructor(client) { + + this.client = client; + + this.sendQueue = {}; //items to bulk send, sendQueue[CHANNEL_ID] = ARRAY[TEXT] + this.sendTimeouts = {}; //timeouts for sends in each channel + + this.sendQueueEmbed = {}; //same thing, except for things that are to be sent in one embed + this.sendEmbedTimeouts = {}; // same as above + + this.deleteQueue = {}; //items to bulk delete + this.deleteTimeouts = {}; //same as above + + this.lastSend = {}; //used by limitSend + this.lastDelete = {}; + + this.sendInterval = 7.5; //How frequently sending is allowed in seconds + this.deleteInterval = 2.5; //How frequently delete queues should be executed + + } + + /** + * Queues message deletion for bulk deletes, to avoid multiple singular delete calls + * + * @param {TextChannel} channel The channel in which to delete + * @param {Message} message The message to delete + * @returns {Promise} + * @memberof RateLimiter + */ + queueDelete(channel, message) { + + return new Promise((resolve, reject) => { + + if (!channel || !(channel instanceof TextChannel)) reject(new Error('Missing channel')); + if (!message || !(message instanceof Message)) reject(new Error('Missing message')); + if (!channel.permissionsFor(channel.guild.me).has('MANAGE_MESSAGES')) reject(new Error('Missing permission MANAGE_MESSAGES')); + + if (!this.deleteQueue[channel.id]) this.deleteQueue[channel.id] = []; + this.deleteQueue[channel.id].push({ message, resolve, reject }); + + //if(!this.deleteTimeouts[channel.id] || this.deleteTimeouts[channel.id]._destroyed) this.deleteTimeouts[channel.id] = setTimeout(this.delete.bind(this), this.deleteInterval*1000, channel); + this.delete(channel); + + }); + + } + + async delete(channel) { + + if (!this.deleteQueue[channel.id] || !this.deleteQueue[channel.id].length) return; + + const resolves = [], + rejects = [], + queue = [...this.deleteQueue[channel.id]], + deleteThese = []; + + const lastDelete = this.lastDelete[channel.id]; + const now = Math.floor(Date.now() / 1000); + if (now - lastDelete < this.deleteInterval) { + const timeout = this.deleteTimeouts[channel.id]; + if (!timeout || timeout._destroyed) + this.deleteTimeouts[channel.id] = setTimeout(this.delete.bind(this), this.deleteInterval * 1000, channel); + return; + } + this.lastDelete[channel.id] = now; + + for (const item of queue) { // Organise into arrays + const { message, resolve, reject } = item; + if (deleteThese.length <= 100) { + deleteThese.push(message); + resolves.push(resolve); + rejects.push(reject); + this.deleteQueue[channel.id].shift(); + } else { // left over messages go in next batch + this.deleteTimeouts[channel.id] = setTimeout(this.delete.bind(this), this.deleteInterval * 1000, channel); + break; + } + } + + if (deleteThese.length === 1) { + deleteThese[0].delete().then(resolves[0]).catch(rejects[0]); + } else try { + await channel.bulkDelete(deleteThese); + for (const resolve of resolves) resolve(true); + } catch (err) { + for (const reject of rejects) reject(err); + } + + } + + /** + * Queue sending of multiple messages into one + * + * @param {TextChannel} channel The channel in which to send + * @param {String} message The text to send + * @returns {Promise} Resolves when the message is sent, rejects if sending fails + * @memberof RateLimiter + */ + queueSend(channel, message) { + + return new Promise((resolve, reject) => { + + if (!channel || !(channel instanceof TextChannel)) reject(new Error('Missing channel.')); + if (!message || !message.length) reject(new Error('Missing message.')); + if (!channel.permissionsFor(channel.guild.me).has('SEND_MESSAGES')) reject(new Error('Missing permission SEND_MESSAGES')); + + //Initiate queue + if (!this.sendQueue[channel.id]) this.sendQueue[channel.id] = []; + + //Possibly check for duplicates, probably not necessary + this.sendQueue[channel.id].push({ message, resolve, reject }); + + //Check if an active timeout exists, if not, create one + if (!this.sendTimeouts[channel.id] || this.sendTimeouts[channel.id]._destroyed) + this.sendTimeouts[channel.id] = setTimeout(this.send.bind(this), this.sendInterval * 1000, channel); + + }); + + } + + async send(channel) { + + if (!this.sendQueue[channel.id] || !this.sendQueue[channel.id].length) return; + + const resolves = [], + rejects = [], + queue = [...this.sendQueue[channel.id]]; + + let sendThis = '', + temp = ''; + + //Compile all messages into one + for (const item of queue) { + const { message, resolve, reject } = item; + temp = `\n${message}`; + if (sendThis.length + temp.length > 2000) { + //Max length message, send the remaining messages at the next send + this.sendTimeouts[channel.id] = setTimeout(this.send.bind(this), this.sendInterval * 1000, [channel]); + break; + } else { + sendThis += temp; + resolves.push(resolve); + rejects.push(reject); + this.sendQueue[channel.id].shift(); + } + } + + try { + const message = await channel.send(sendThis); + for (const resolve of resolves) resolve(message); + } catch (err) { + for (const reject of rejects) reject(err); + } + + } + + queueSendEmbed(channel, message) { + //TODO + } + + async sendEmbed(channel) { + //TODO + } + + /** + * Limit sending of messages to one every x seconds. + * Useful for stopping multiple instances of "Invites aren't permitted" being sent + * + * @param {TextChannel} channel channel in which to send + * @param {String} message the message to send + * @param {Number} [limit=15] how frequently the message can send + * @param {String} utility Limit by utility, ex invitefilter or messagefilter - so they don't overlap + * @returns {Promise} The message object of the sent message + * @memberof RateLimiter + */ + limitSend(channel, message, limit = 15, utility = 'default') { + + return new Promise((resolve, reject) => { + if (!channel || !(channel instanceof TextChannel)) reject(new Error('Missing channel')); + if (!channel.permissionsFor(channel.guild.me).has('SEND_MESSAGES')) reject(new Error('Missing permission SEND_MESSAGES')); + if (!message) reject(new Error('Missing message')); + if (limit === null) limit = 15; + + const now = Date.now(); + if (!this.lastSend[channel.id]) this.lastSend[channel.id] = {}; + if (!this.lastSend[channel.id][utility]) this.lastSend[channel.id][utility] = 0; + + const lastSent = this.lastSend[channel.id][utility]; + if (now - limit * 1000 >= lastSent) { + this.lastSend[channel.id][utility] = now; + resolve(channel.send(message)); + } else resolve(false); + }); + + } + +} + +module.exports = RateLimiter; \ No newline at end of file