diff --git a/src/client/Client.js b/src/client/Client.js index bde16f3cad88..72c49b1fba1d 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -25,6 +25,7 @@ const DataResolver = require('../util/DataResolver'); const Intents = require('../util/Intents'); const Options = require('../util/Options'); const Permissions = require('../util/Permissions'); +const Sweepers = require('../util/Sweepers'); /** * The main hub for interacting with the Discord API, and the starting point for any bot. @@ -135,6 +136,12 @@ class Client extends BaseClient { */ this.channels = new ChannelManager(this); + /** + * The sweeping functions and their intervals used to periodically sweep caches + * @type {Sweepers} + */ + this.sweepers = new Sweepers(this, this.options.sweepers); + /** * The presence of the Client * @private @@ -176,7 +183,7 @@ class Client extends BaseClient { if (this.options.messageSweepInterval > 0) { process.emitWarning( - 'The message sweeping client options are deprecated, use the makeCache option with LimitedCollection instead.', + 'The message sweeping client options are deprecated, use the global sweepers instead.', 'DeprecationWarning', ); this.sweepMessageInterval = setInterval( @@ -271,6 +278,7 @@ class Client extends BaseClient { if (this.sweepMessageInterval) clearInterval(this.sweepMessageInterval); + this.sweepers.destroy(); this.ws.destroy(); this.token = null; } @@ -401,24 +409,8 @@ class Client extends BaseClient { return -1; } - const lifetimeMs = lifetime * 1_000; - const now = Date.now(); - let channels = 0; - let messages = 0; - - for (const channel of this.channels.cache.values()) { - if (!channel.messages) continue; - channels++; - - messages += channel.messages.cache.sweep( - message => now - (message.editedTimestamp ?? message.createdTimestamp) > lifetimeMs, - ); - } - - this.emit( - Events.DEBUG, - `Swept ${messages} messages older than ${lifetime} seconds in ${channels} text-based channels`, - ); + const messages = this.sweepers.sweepMessages(Sweepers.outdatedMessageSweepFilter(lifetime)()); + this.emit(Events.DEBUG, `Swept ${messages} messages older than ${lifetime} seconds`); return messages; } @@ -561,6 +553,9 @@ class Client extends BaseClient { if (typeof options.messageSweepInterval !== 'number' || isNaN(options.messageSweepInterval)) { throw new TypeError('CLIENT_INVALID_OPTION', 'messageSweepInterval', 'a number'); } + if (typeof options.sweepers !== 'object' || options.sweepers === null) { + throw new TypeError('CLIENT_INVALID_OPTION', 'sweepers', 'an object'); + } if (typeof options.invalidRequestWarningInterval !== 'number' || isNaN(options.invalidRequestWarningInterval)) { throw new TypeError('CLIENT_INVALID_OPTION', 'invalidRequestWarningInterval', 'a number'); } diff --git a/src/util/Constants.js b/src/util/Constants.js index 0043db645121..0701619df708 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -188,6 +188,7 @@ exports.Events = { ERROR: 'error', WARN: 'warn', DEBUG: 'debug', + CACHE_SWEEP: 'cacheSweep', SHARD_DISCONNECT: 'shardDisconnect', SHARD_ERROR: 'shardError', SHARD_RECONNECTING: 'shardReconnecting', @@ -431,6 +432,41 @@ exports.MessageTypes = [ 'CONTEXT_MENU_COMMAND', ]; +/** + * The name of an item to be swept in Sweepers + * * `applicationCommands` - both global and guild commands + * * `bans` + * * `emojis` + * * `invites` - accepts the `lifetime` property, using it will sweep based on expires timestamp + * * `guildMembers` + * * `messages` - accepts the `lifetime` property, using it will sweep based on edited or created timestamp + * * `presences` + * * `reactions` + * * `stageInstances` + * * `stickers` + * * `threadMembers` + * * `threads` - accepts the `lifetime` property, using it will sweep archived threads based on archived timestamp + * * `users` + * * `voiceStates` + * @typedef {string} SweeperKey + */ +exports.SweeperKeys = [ + 'applicationCommands', + 'bans', + 'emojis', + 'invites', + 'guildMembers', + 'messages', + 'presences', + 'reactions', + 'stageInstances', + 'stickers', + 'threadMembers', + 'threads', + 'users', + 'voiceStates', +]; + /** * The types of messages that are `System`. The available types are `MessageTypes` excluding: * * DEFAULT diff --git a/src/util/LimitedCollection.js b/src/util/LimitedCollection.js index b84fd1222a4e..17b270242bf4 100644 --- a/src/util/LimitedCollection.js +++ b/src/util/LimitedCollection.js @@ -2,6 +2,7 @@ const { Collection } = require('@discordjs/collection'); const { _cleanupSymbol } = require('./Constants.js'); +const Sweepers = require('./Sweepers.js'); const { TypeError } = require('../errors/DJSError.js'); /** @@ -18,8 +19,12 @@ const { TypeError } = require('../errors/DJSError.js'); * @property {?number} [maxSize=Infinity] The maximum size of the Collection * @property {?Function} [keepOverLimit=null] A function, which is passed the value and key of an entry, ran to decide * to keep an entry past the maximum size - * @property {?SweepFilter} [sweepFilter=null] A function ran every `sweepInterval` to determine how to sweep - * @property {?number} [sweepInterval=0] How frequently, in seconds, to sweep the collection. + * @property {?SweepFilter} [sweepFilter=null] DEPRECATED: There is no direct alternative to this, + * however most of its purpose is fulfilled by {@link Client#sweepers} + * A function ran every `sweepInterval` to determine how to sweep + * @property {?number} [sweepInterval=0] DEPRECATED: There is no direct alternative to this, + * however most of its purpose is fulfilled by {@link Client#sweepers} + * How frequently, in seconds, to sweep the collection. */ /** @@ -64,12 +69,14 @@ class LimitedCollection extends Collection { /** * A function called every sweep interval that returns a function passed to `sweep`. + * @deprecated in favor of {@link Client#sweepers} * @type {?SweepFilter} */ this.sweepFilter = sweepFilter; /** * The id of the interval being used to sweep. + * @deprecated in favor of {@link Client#sweepers} * @type {?Timeout} */ this.interval = @@ -97,20 +104,10 @@ class LimitedCollection extends Collection { return super.set(key, value); } - /** - * Options for generating a filter function based on lifetime - * @typedef {Object} LifetimeFilterOptions - * @property {number} [lifetime=14400] How long, in seconds, an entry should stay in the collection - * before it is considered sweepable. - * @property {Function} [getComparisonTimestamp=e => e?.createdTimestamp] A function that takes an entry, key, - * and the collection and returns a timestamp to compare against in order to determine the lifetime of the entry. - * @property {Function} [excludeFromSweep=() => false] A function that takes an entry, key, and the collection - * and returns a boolean, `true` when the entry should not be checked for sweepability. - */ - /** * Create a sweepFilter function that uses a lifetime to determine sweepability. * @param {LifetimeFilterOptions} [options={}] The options used to generate the filter function + * @deprecated Use {@link Sweepers.filterByLifetime} instead * @returns {SweepFilter} */ static filterByLifetime({ @@ -118,28 +115,7 @@ class LimitedCollection extends Collection { getComparisonTimestamp = e => e?.createdTimestamp, excludeFromSweep = () => false, } = {}) { - if (typeof lifetime !== 'number') { - throw new TypeError('INVALID_TYPE', 'lifetime', 'number'); - } - if (typeof getComparisonTimestamp !== 'function') { - throw new TypeError('INVALID_TYPE', 'getComparisonTimestamp', 'function'); - } - if (typeof excludeFromSweep !== 'function') { - throw new TypeError('INVALID_TYPE', 'excludeFromSweep', 'function'); - } - return () => { - if (lifetime <= 0) return null; - const lifetimeMs = lifetime * 1_000; - const now = Date.now(); - return (entry, key, coll) => { - if (excludeFromSweep(entry, key, coll)) { - return false; - } - const comparisonTimestamp = getComparisonTimestamp(entry, key, coll); - if (!comparisonTimestamp || typeof comparisonTimestamp !== 'number') return false; - return now - comparisonTimestamp > lifetimeMs; - }; - }; + return Sweepers.filterByLifetime({ lifetime, getComparisonTimestamp, excludeFromSweep }); } [_cleanupSymbol]() { diff --git a/src/util/Options.js b/src/util/Options.js index b31397977bbe..3180cf4713c5 100644 --- a/src/util/Options.js +++ b/src/util/Options.js @@ -37,9 +37,9 @@ * You can use your own function, or the {@link Options} class to customize the Collection used for the cache. * Overriding the cache used in `GuildManager`, `ChannelManager`, `GuildChannelManager`, `RoleManager`, * and `PermissionOverwriteManager` is unsupported and **will** break functionality - * @property {number} [messageCacheLifetime=0] DEPRECATED: Use `makeCache` with a `LimitedCollection` instead. + * @property {number} [messageCacheLifetime=0] DEPRECATED: Pass `lifetime` to `sweepers.messages` instead. * How long a message should stay in the cache until it is considered sweepable (in seconds, 0 for forever) - * @property {number} [messageSweepInterval=0] DEPRECATED: Use `makeCache` with a `LimitedCollection` instead. + * @property {number} [messageSweepInterval=0] DEPRECATED: Pass `interval` to `sweepers.messages` instead. * How frequently to remove messages from the cache that are older than the message cache lifetime * (in seconds, 0 for never) * @property {MessageMentionOptions} [allowedMentions] Default value for {@link MessageOptions#allowedMentions} @@ -70,10 +70,27 @@ * [User Agent](https://discord.com/developers/docs/reference#user-agent) header * @property {PresenceData} [presence={}] Presence data to use upon login * @property {IntentsResolvable} intents Intents to enable for this connection + * @property {SweeperOptions} [sweepers={}] Options for cache sweeping * @property {WebsocketOptions} [ws] Options for the WebSocket * @property {HTTPOptions} [http] HTTP options */ +/** + * Options for {@link Sweepers} defining the behavior of cache sweeping + * @typedef {Object} SweeperOptions + */ + +/** + * Options for sweeping a single type of item from cache + * @typedef {Object} SweepOptions + * @property {number} interval The interval (in seconds) at which to perform sweeping of the item + * @property {number} [lifetime] How long an item should stay in cache until it is considered sweepable. + * This property is only valid for the `invites`, `messages`, and `threads` keys. The `filter` property + * is mutually exclusive to this property and takes priority + * @property {GlobalSweepFilter} filter The function used to determine the function passed to the sweep method + * This property is optional when the key is `invites`, `messages`, or `threads` and `lifetime` is set + */ + /** * WebSocket options (these are left as snake_case to match the API) * @typedef {Object} WebsocketOptions @@ -125,6 +142,7 @@ class Options extends null { failIfNotExists: true, userAgentSuffix: [], presence: {}, + sweepers: {}, ws: { large_threshold: 50, compress: false, @@ -251,4 +269,19 @@ class Options extends null { } } +/** + * The default settings passed to {@link Options.sweepers} (for v14). + * The sweepers that this changes are: + * * `threads` - Sweep archived threads every hour, removing those archived more than 4 hours ago + * If you want to keep default behavior and add on top of it you can use this object and add on to it, e.g. + * `sweepers: { ...Options.defaultSweeperSettings, messages: { interval: 300, lifetime: 600 } })` + * @type {SweeperOptions} + */ +Options.defaultSweeperSettings = { + threads: { + interval: 3600, + lifetime: 14400, + }, +}; + module.exports = Options; diff --git a/src/util/Sweepers.js b/src/util/Sweepers.js new file mode 100644 index 000000000000..4f0c1891edbc --- /dev/null +++ b/src/util/Sweepers.js @@ -0,0 +1,446 @@ +'use strict'; + +const { Events, ThreadChannelTypes, SweeperKeys } = require('./Constants'); +const { TypeError } = require('../errors/DJSError.js'); + +/** + * @typedef {Function} GlobalSweepFilter + * @returns {Function|null} Return `null` to skip sweeping, otherwise a function passed to `sweep()`, + * See {@link [Collection#sweep](https://discord.js.org/#/docs/collection/main/class/Collection?scrollTo=sweep)} + * for the definition of this function. + */ + +/** + * A container for all cache sweeping intervals and their associated sweep methods. + */ +class Sweepers { + constructor(client, options) { + /** + * The client that instantiated this + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The options the sweepers were instantiated with + * @type {SweeperOptions} + */ + this.options = options; + + /** + * A record of interval timeout that is used to sweep the indicated items, or null if not being swept + * @type {Object} + */ + this.intervals = Object.fromEntries(SweeperKeys.map(key => [key, null])); + + for (const key of SweeperKeys) { + if (!(key in options)) continue; + + this._validateProperties(key); + + const clonedOptions = { ...this.options[key] }; + + // Handle cases that have a "lifetime" + if (!('filter' in clonedOptions)) { + switch (key) { + case 'invites': + clonedOptions.filter = this.constructor.expiredInviteSweepFilter(clonedOptions.lifetime); + break; + case 'messages': + clonedOptions.filter = this.constructor.outdatedMessageSweepFilter(clonedOptions.lifetime); + break; + case 'threads': + clonedOptions.filter = this.constructor.archivedThreadSweepFilter(clonedOptions.lifetime); + } + } + + this._initInterval(key, `sweep${key[0].toUpperCase()}${key.slice(1)}`, clonedOptions); + } + } + + /** + * Sweeps all guild and global application commands and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which commands will be removed from the caches. + * @returns {number} Amount of commands that were removed from the caches + */ + sweepApplicationCommands(filter) { + const { guilds, items: guildCommands } = this._sweepGuildDirectProp('commands', filter, { emit: false }); + + const globalCommands = this.client.application?.commands.cache.sweep(filter) ?? 0; + + this.client.emit( + Events.CACHE_SWEEP, + `Swept ${globalCommands} global application commands and ${guildCommands} guild commands in ${guilds} guilds.`, + ); + return guildCommands + globalCommands; + } + + /** + * Sweeps all guild bans and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which bans will be removed from the caches. + * @returns {number} Amount of bans that were removed from the caches + */ + sweepBans(filter) { + return this._sweepGuildDirectProp('bans', filter).items; + } + + /** + * Sweeps all guild emojis and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which emojis will be removed from the caches. + * @returns {number} Amount of emojis that were removed from the caches + */ + sweepEmojis(filter) { + return this._sweepGuildDirectProp('emojis', filter).items; + } + + /** + * Sweeps all guild invites and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which invites will be removed from the caches. + * @returns {number} Amount of invites that were removed from the caches + */ + sweepInvites(filter) { + return this._sweepGuildDirectProp('invites', filter).items; + } + + /** + * Sweeps all guild members and removes the ones which are indicated by the filter. + * It is highly recommended to keep the client guild member cached + * @param {Function} filter The function used to determine which guild members will be removed from the caches. + * @returns {number} Amount of guild members that were removed from the caches + */ + sweepGuildMembers(filter) { + return this._sweepGuildDirectProp('members', filter, { outputName: 'guild members' }).items; + } + + /** + * Sweeps all text-based channels' messages and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which messages will be removed from the caches. + * @returns {number} Amount of messages that were removed from the caches + * @example + * // Remove all messages older than 1800 seconds from the messages cache + * const amount = sweepers.sweepMessages( + * Sweepers.filterByLifetime({ + * lifetime: 1800, + * getComparisonTimestamp: m => m.editedTimestamp ?? m.createdTimestamp, + * })(), + * ); + * console.log(`Successfully removed ${amount} messages from the cache.`); + */ + sweepMessages(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + let channels = 0; + let messages = 0; + + for (const channel of this.client.channels.cache.values()) { + if (!channel.isText()) continue; + + channels++; + messages += channel.messages.cache.sweep(filter); + } + this.client.emit(Events.CACHE_SWEEP, `Swept ${messages} messages in ${channels} text-based channels.`); + return messages; + } + + /** + * Sweeps all presences and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which presences will be removed from the caches. + * @returns {number} Amount of presences that were removed from the caches + */ + sweepPresences(filter) { + return this._sweepGuildDirectProp('presences', filter).items; + } + + /** + * Sweeps all message reactions and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which reactions will be removed from the caches. + * @returns {number} Amount of reactions that were removed from the caches + */ + sweepReactions(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + let channels = 0; + let messages = 0; + let reactions = 0; + + for (const channel of this.client.channels.cache.values()) { + if (!channel.isText()) continue; + channels++; + + for (const message of channel.messages.cache.values()) { + messages++; + reactions += message.reactions.cache.sweep(filter); + } + } + this.client.emit( + Events.CACHE_SWEEP, + `Swept ${reactions} reactions on ${messages} messages in ${channels} text-based channels.`, + ); + return reactions; + } + + /** + * Sweeps all guild stage instances and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which stage instances will be removed from the caches. + * @returns {number} Amount of stage instances that were removed from the caches + */ + sweepStageInstances(filter) { + return this._sweepGuildDirectProp('stageInstances', filter, { outputName: 'stage instances' }).items; + } + + /** + * Sweeps all thread members and removes the ones which are indicated by the filter. + * It is highly recommended to keep the client thread member cached + * @param {Function} filter The function used to determine which thread members will be removed from the caches. + * @returns {number} Amount of thread members that were removed from the caches + */ + sweepThreadMembers(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + let threads = 0; + let members = 0; + for (const channel of this.client.channels.cache.values()) { + if (!ThreadChannelTypes.includes(channel.type)) continue; + threads++; + members += channel.members.cache.sweep(filter); + } + this.client.emit(Events.CACHE_SWEEP, `Swept ${members} thread members in ${threads} threads.`); + return members; + } + + /** + * Sweeps all threads and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which threads will be removed from the caches. + * @returns {number} filter Amount of threads that were removed from the caches + * @example + * // Remove all threads archived greater than 1 day ago from all the channel caches + * const amount = sweepers.sweepThreads( + * Sweepers.filterByLifetime({ + * getComparisonTimestamp: t => t.archivedTimestamp, + * excludeFromSweep: t => !t.archived, + * })(), + * ); + * console.log(`Successfully removed ${amount} threads from the cache.`); + */ + sweepThreads(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + let threads = 0; + for (const [key, val] of this.client.channels.cache.entries()) { + if (!ThreadChannelTypes.includes(val.type)) continue; + if (filter(val, key, this.client.channels.cache)) { + threads++; + this.client.channels._remove(key); + } + } + this.client.emit(Events.CACHE_SWEEP, `Swept ${threads} threads.`); + return threads; + } + + /** + * Sweeps all users and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which users will be removed from the caches. + * @returns {number} Amount of users that were removed from the caches + */ + sweepUsers(filter) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + const users = this.client.users.cache.sweep(filter); + + this.client.emit(Events.CACHE_SWEEP, `Swept ${users} users.`); + + return users; + } + + /** + * Sweeps all guild voice states and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which voice states will be removed from the caches. + * @returns {number} Amount of voice states that were removed from the caches + */ + sweepVoiceStates(filter) { + return this._sweepGuildDirectProp('voiceStates', filter, { outputName: 'voice states' }).items; + } + + /** + * Cancels all sweeping intervals + * @returns {void} + */ + destroy() { + for (const key of SweeperKeys) { + if (this.intervals[key]) clearInterval(this.intervals[key]); + } + } + + /** + * Options for generating a filter function based on lifetime + * @typedef {Object} LifetimeFilterOptions + * @property {number} [lifetime=14400] How long, in seconds, an entry should stay in the collection + * before it is considered sweepable. + * @property {Function} [getComparisonTimestamp=e => e?.createdTimestamp] A function that takes an entry, key, + * and the collection and returns a timestamp to compare against in order to determine the lifetime of the entry. + * @property {Function} [excludeFromSweep=() => false] A function that takes an entry, key, and the collection + * and returns a boolean, `true` when the entry should not be checked for sweepability. + */ + + /** + * Create a sweepFilter function that uses a lifetime to determine sweepability. + * @param {LifetimeFilterOptions} [options={}] The options used to generate the filter function + * @returns {GlobalSweepFilter} + */ + static filterByLifetime({ + lifetime = 14400, + getComparisonTimestamp = e => e?.createdTimestamp, + excludeFromSweep = () => false, + } = {}) { + if (typeof lifetime !== 'number') { + throw new TypeError('INVALID_TYPE', 'lifetime', 'number'); + } + if (typeof getComparisonTimestamp !== 'function') { + throw new TypeError('INVALID_TYPE', 'getComparisonTimestamp', 'function'); + } + if (typeof excludeFromSweep !== 'function') { + throw new TypeError('INVALID_TYPE', 'excludeFromSweep', 'function'); + } + return () => { + if (lifetime <= 0) return null; + const lifetimeMs = lifetime * 1_000; + const now = Date.now(); + return (entry, key, coll) => { + if (excludeFromSweep(entry, key, coll)) { + return false; + } + const comparisonTimestamp = getComparisonTimestamp(entry, key, coll); + if (!comparisonTimestamp || typeof comparisonTimestamp !== 'number') return false; + return now - comparisonTimestamp > lifetimeMs; + }; + }; + } + + /** + * Creates a sweep filter that sweeps archived threads + * @param {number} [lifetime=14400] How long a thread has to be archived to be valid for sweeping + * @returns {GlobalSweepFilter} + */ + static archivedThreadSweepFilter(lifetime = 14400) { + return this.filterByLifetime({ + lifetime, + getComparisonTimestamp: e => e.archiveTimestamp, + excludeFromSweep: e => !e.archived, + }); + } + + /** + * Creates a sweep filter that sweeps expired invites + * @param {number} [lifetime=14400] How long ago an invite has to have expired to be valid for sweeping + * @returns {GlobalSweepFilter} + */ + static expiredInviteSweepFilter(lifetime = 14400) { + return this.filterByLifetime({ + lifetime, + getComparisonTimestamp: i => i.expiresTimestamp, + }); + } + + /** + * Creates a sweep filter that sweeps outdated messages (edits taken into account) + * @param {number} [lifetime=3600] How long ago a message has to hvae been sent or edited to be valid for sweeping + * @returns {GlobalSweepFilter} + */ + static outdatedMessageSweepFilter(lifetime = 3600) { + return this.filterByLifetime({ + lifetime, + getComparisonTimestamp: m => m.editedTimestamp ?? m.createdTimestamp, + }); + } + + /** + * Configuration options for emitting the cache sweep client event + * @typedef {Object} SweepEventOptions + * @property {boolean} [emit=true] Whether to emit the client event in this method + * @property {string} [outputName] A name to output in the client event if it should differ from the key + * @private + */ + + /** + * Sweep a direct sub property of all guilds + * @param {string} key The name of the property + * @param {Function} filter Filter function passed to sweep + * @param {SweepEventOptions} [eventOptions] Options for the Client event emitted here + * @returns {Object} Object containing the number of guilds swept and the number of items swept + * @private + */ + _sweepGuildDirectProp(key, filter, { emit = true, outputName }) { + if (typeof filter !== 'function') { + throw new TypeError('INVALID_TYPE', 'filter', 'function'); + } + + let guilds = 0; + let items = 0; + + for (const guild of this.client.guilds.cache.values()) { + const { cache } = guild[key]; + + guilds++; + items += cache.sweep(filter); + } + + if (emit) { + this.client.emit(Events.CACHE_SWEEP, `Swept ${items} ${outputName ?? key} in ${guilds} guilds.`); + } + + return { guilds, items }; + } + + /** + * Validates a set of properties + * @param {string} key Key of the options object to check + * @private + */ + _validateProperties(key) { + const props = this.options[key]; + if (typeof props !== 'object') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}`, 'object', true); + } + if (typeof props.interval !== 'number') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}.interval`, 'number'); + } + // Invites, Messages, and Threads can be provided a lifetime parameter, which we use to generate the filter + if (['invites', 'messages', 'threads'].includes(key) && !('filter' in props)) { + if (typeof props.lifetime !== 'number') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}.lifetime`, 'number'); + } + return; + } + if (typeof props.filter !== 'function') { + throw new TypeError('INVALID_TYPE', `sweepers.${key}.filter`, 'function'); + } + } + + /** + * Initialize an interval for sweeping + * @param {string} intervalKey The name of the property that stores the interval for this sweeper + * @param {string} sweepKey The name of the function that sweeps the desired caches + * @param {Object} opts Validated options for a sweep + * @private + */ + _initInterval(intervalKey, sweepKey, opts) { + if (opts.interval <= 0 || opts.interval === Infinity) return; + this.intervals[intervalKey] = setInterval(() => { + const sweepFn = opts.filter(); + if (sweepFn === null) return; + if (typeof sweepFn !== 'function') throw new TypeError('SWEEP_FILTER_RETURN'); + this[sweepKey](sweepFn); + }, opts.interval * 1_000).unref(); + } +} + +module.exports = Sweepers; diff --git a/src/util/Util.js b/src/util/Util.js index 00282c645906..47ceef45b73e 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -590,14 +590,11 @@ class Util extends null { /** * Creates a sweep filter that sweeps archived threads * @param {number} [lifetime=14400] How long a thread has to be archived to be valid for sweeping + * @deprecated When not using with `makeCache` use `Sweepers.archivedThreadSweepFilter` instead * @returns {SweepFilter} */ static archivedThreadSweepFilter(lifetime = 14400) { - const filter = require('./LimitedCollection').filterByLifetime({ - lifetime, - getComparisonTimestamp: e => e.archiveTimestamp, - excludeFromSweep: e => !e.archived, - }); + const filter = require('./Sweepers').archivedThreadSweepFilter(lifetime); filter.isDefault = true; return filter; } diff --git a/typings/index.d.ts b/typings/index.d.ts index 0c8c20e57ba8..98ca305a2df8 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -545,6 +545,7 @@ export class Client extends BaseClient { public options: ClientOptions; public readyAt: If; public readonly readyTimestamp: If; + public sweepers: Sweepers; public shard: ShardClientUtil | null; public token: If; public uptime: If; @@ -564,6 +565,7 @@ export class Client extends BaseClient { public generateInvite(options?: InviteGenerationOptions): string; public login(token?: string): Promise; public isReady(): this is Client; + /** @deprecated Use {@link Sweepers#sweepMessages} instead */ public sweepMessages(lifetime?: number): number; public toJSON(): unknown; @@ -629,6 +631,7 @@ export class ClientUser extends User { export class Options extends null { private constructor(); public static defaultMakeCacheSettings: CacheWithLimitsOptions; + public static defaultSweeperSettings: SweeperOptions; public static createDefault(): ClientOptions; public static cacheWithLimits(settings?: CacheWithLimitsOptions): CacheFactory; public static cacheEverything(): CacheFactory; @@ -1365,9 +1368,12 @@ export class LimitedCollection extends Collection { public constructor(options?: LimitedCollectionOptions, iterable?: Iterable); public maxSize: number; public keepOverLimit: ((value: V, key: K, collection: this) => boolean) | null; + /** @deprecated Use Global Sweepers instead */ public interval: NodeJS.Timeout | null; + /** @deprecated Use Global Sweepers instead */ public sweepFilter: SweepFilter | null; + /** @deprecated Use `Sweepers.filterByLifetime` instead */ public static filterByLifetime(options?: LifetimeFilterOptions): SweepFilter; } @@ -2112,6 +2118,68 @@ export class StoreChannel extends GuildChannel { public type: 'GUILD_STORE'; } +export class Sweepers { + public constructor(client: Client, options: SweeperOptions); + public readonly client: Client; + public intervals: Record; + public options: SweeperOptions; + + public sweepApplicationCommands( + filter: CollectionSweepFilter< + SweeperDefinitions['applicationCommands'][0], + SweeperDefinitions['applicationCommands'][1] + >, + ): number; + public sweepBans(filter: CollectionSweepFilter): number; + public sweepEmojis( + filter: CollectionSweepFilter, + ): number; + public sweepInvites( + filter: CollectionSweepFilter, + ): number; + public sweepGuildMembers( + filter: CollectionSweepFilter, + ): number; + public sweepMessages( + filter: CollectionSweepFilter, + ): number; + public sweepPresences( + filter: CollectionSweepFilter, + ): number; + public sweepReactions( + filter: CollectionSweepFilter, + ): number; + public sweepStageInstnaces( + filter: CollectionSweepFilter, + ): number; + public sweepStickers( + filter: CollectionSweepFilter, + ): number; + public sweepThreadMembers( + filter: CollectionSweepFilter, + ): number; + public sweepThreads( + filter: CollectionSweepFilter, + ): number; + public sweepUsers( + filter: CollectionSweepFilter, + ): number; + public sweepVoiceStates( + filter: CollectionSweepFilter, + ): number; + + public static archivedThreadSweepFilter( + lifetime?: number, + ): GlobalSweepFilter; + public static expiredInviteSweepFilter( + lifetime?: number, + ): GlobalSweepFilter; + public static filterByLifetime(options?: LifetimeFilterOptions): GlobalSweepFilter; + public static outdatedMessageSweepFilter( + lifetime?: number, + ): GlobalSweepFilter; +} + export class SystemChannelFlags extends BitField { public static FLAGS: Record; public static resolve(bit?: BitFieldResolvable): number; @@ -2280,6 +2348,7 @@ export class UserFlags extends BitField { export class Util extends null { private constructor(); + /** @deprecated When not using with `makeCache` use `Sweepers.archivedThreadSweepFilter` instead */ public static archivedThreadSweepFilter(lifetime?: number): SweepFilter; public static basename(path: string, ext?: string): string; public static cleanContent(str: string, channel: TextBasedChannels): string; @@ -3742,6 +3811,7 @@ export interface ClientEvents extends BaseClientEvents { applicationCommandDelete: [command: ApplicationCommand]; /** @deprecated See [this issue](https://github.com/discord/discord-api-docs/issues/3690) for more information. */ applicationCommandUpdate: [oldCommand: ApplicationCommand | null, newCommand: ApplicationCommand]; + cacheSweep: [message: string]; channelCreate: [channel: GuildChannel]; channelDelete: [channel: DMChannel | GuildChannel]; channelPinsUpdate: [channel: TextBasedChannels, date: Date]; @@ -3821,9 +3891,9 @@ export interface ClientOptions { shards?: number | number[] | 'auto'; shardCount?: number; makeCache?: CacheFactory; - /** @deprecated Use `makeCache` with a `LimitedCollection` for `MessageManager` instead. */ + /** @deprecated Pass the value of this property as `lifetime` to `sweepers.messages` instead. */ messageCacheLifetime?: number; - /** @deprecated Use `makeCache` with a `LimitedCollection` for `MessageManager` instead. */ + /** @deprecated Pass the value of this property as `interval` to `sweepers.messages` instead. */ messageSweepInterval?: number; allowedMentions?: MessageMentionOptions; invalidRequestWarningInterval?: number; @@ -3838,6 +3908,7 @@ export interface ClientOptions { userAgentSuffix?: string[]; presence?: PresenceData; intents: BitFieldResolvable; + sweepers?: SweeperOptions; ws?: WebSocketOptions; http?: HTTPOptions; rejectOnRateLimit?: string[] | ((data: RateLimitData) => boolean | Promise); @@ -4033,6 +4104,7 @@ export interface ConstantsEvents { ERROR: 'error'; WARN: 'warn'; DEBUG: 'debug'; + CACHE_SWEEP: 'cacheSweep'; SHARD_DISCONNECT: 'shardDisconnect'; SHARD_ERROR: 'shardError'; SHARD_RECONNECTING: 'shardReconnecting'; @@ -4237,6 +4309,8 @@ export interface FileOptions { description?: string; } +export type GlobalSweepFilter = () => ((value: V, key: K, collection: Collection) => boolean) | null; + export interface GuildApplicationCommandPermissionData { id: Snowflake; permissions: ApplicationCommandPermissionData[]; @@ -5234,14 +5308,54 @@ export interface StageInstanceEditOptions { privacyLevel?: PrivacyLevel | number; } +export type SweeperKey = keyof SweeperDefinitions; + +export type CollectionSweepFilter = (value: V, key: K, collection: Collection) => boolean; + export type SweepFilter = ( collection: LimitedCollection, ) => ((value: V, key: K, collection: LimitedCollection) => boolean) | null; +export interface SweepOptions { + interval: number; + filter: GlobalSweepFilter; +} + +export interface LifetimeSweepOptions { + interval: number; + lifetime: number; + filter?: never; +} + +export interface SweeperDefinitions { + applicationCommands: [Snowflake, ApplicationCommand]; + bans: [Snowflake, GuildBan]; + emojis: [Snowflake, GuildEmoji]; + invites: [string, Invite, true]; + guildMembers: [Snowflake, GuildMember]; + messages: [Snowflake, Message, true]; + presences: [Snowflake, Presence]; + reactions: [string | Snowflake, MessageReaction]; + stageInstances: [Snowflake, StageInstance]; + stickers: [Snowflake, Sticker]; + threadMembers: [Snowflake, ThreadMember]; + threads: [Snowflake, ThreadChannel, true]; + users: [Snowflake, User]; + voiceStates: [Snowflake, VoiceState]; +} + +export type SweeperOptions = { + [K in keyof SweeperDefinitions]?: SweeperDefinitions[K][2] extends true + ? SweepOptions | LifetimeSweepOptions + : SweepOptions; +}; + export interface LimitedCollectionOptions { maxSize?: number; keepOverLimit?: (value: V, key: K, collection: LimitedCollection) => boolean; + /** @deprecated Use Global Sweepers instead */ sweepFilter?: SweepFilter; + /** @deprecated Use Global Sweepers instead */ sweepInterval?: number; }