const Discord = require('discord.js') const { EventEmitter } = require('events') /** * @callback IgnoreMemberFunction * @param {Discord.GuildMember} member The member to check * @returns {boolean} Whether the member should be ignored */ /** * @callback IgnoreRoleFunction * @param {Discord.Collection} role The role to check * @returns {boolean} Whether the user should be ignored */ /** * @callback IgnoreGuildFunction * @param {Discord.Guild} guild The guild to check * @returns {boolean} Whether the guild should be ignored */ /** * @callback IgnoreChannelFunction * @param {Discord.Channel} channel The channel to check * @returns {boolean} Whether the channel should be ignored */ /** * Emitted when a member gets warned. * @event AntiSpamClient#warnAdd * @property {Discord.GuildMember} member The member that was warned. */ /** * Emitted when a member gets kicked. * @event AntiSpamClient#kickAdd * @property {Discord.GuildMember} member The member that was kicked. */ /** * Emitted when a member gets muted. * @event AntiSpamClient#muteAdd * @property {Discord.GuildMember} member The member that was muted. */ /** * Emitted when a member gets banned. * @event AntiSpamClient#warnAdd * @property {Discord.GuildMember} member The member that was banned. */ /** * Options for the AntiSpam client * @typedef AntiSpamClientOptions * * @property {number} [warnThreshold=3] Amount of messages sent in a row that will cause a warning. * @property {number} [kickThreshold=5] Amount of messages sent in a row that will cause a kick. * @property {number} [muteThreshold=7] Amount of messages sent in a row that will cause a mute. * @property {number} [banThreshold=4] Amount of messages sent in a row that will cause a ban. * * @property {number} [maxInterval=2000] Amount of time (ms) in which messages are considered spam. * @property {number} [maxDuplicatesInterval=2000] Amount of time (ms) in which duplicate messages are considered spam. * * @property {number} [maxDuplicatesWarn=3] Amount of duplicate messages that trigger a warning. * @property {number} [maxDuplicatesKick=5] Amount of duplicate messages that trigger a kick. * @property {number} [maxDuplicatesMute=7] Amount of duplicate messages that trigger a mute. * @property {number} [maxDuplicatesBan=10] Amount of duplicate messages that trigger a ban. * * @property {string|Discord.Snowflake} [muteRoleName='Muted'] Name or ID of the role that will be added to users if they got muted. * @property {string|Discord.Snowflake} [modLogsChannelName='mod-logs'] Name or ID of the channel in which moderation logs will be sent. * @property {boolean} [modLogsEnabled=false] Whether moderation logs are enabled. * * @property {string|Discord.MessageEmbed} [warnMessage='{@user}, Please stop spamming.'] Message that will be sent in the channel when someone is warned. * @property {string|Discord.MessageEmbed} [kickMessage='**{user_tag}** has been muted for spamming.'] Message that will be sent in the channel when someone is kicked. * @property {string|Discord.MessageEmbed} [muteMessage='**{user_tag}** has been kicked for spamming.'] Message that will be sent in the channel when someone is muted. * @property {string|Discord.MessageEmbed} [banMessage='**{user_tag}** has been banned for spamming.'] Message that will be sent in the channel when someone is banned. * * @property {boolean} [errorMessages=true] Whether the bot should send a message in the channel when it doesn't have some required permissions, like it can't kick members. * @property {string} [kickErrorMessage='Could not kick **{user_tag}** because of improper permissions.'] Message that will be sent in the channel when the bot doesn't have enough permissions to kick the member. * @property {string} [muteErrorMessage='Could not ban **{user_tag}** because of improper permissions.'] Message that will be sent in the channel when the bot doesn't have enough permissions to mute the member (to add the mute role). * @property {string} [banErrorMessage='Could not mute **{user_tag}** because of improper permissions or the mute role couldn\'t be found.'] Message that will be sent in the channel when the bot doesn't have enough permissions to ban the member. * * @property {Discord.Snowflake|string[]|IgnoreMemberFunction} [ignoredMembers=[]] Array of member IDs that are ignored. * @property {Discord.Snowflake|string[]|IgnoreRoleFunction} [ignoredRoles=[]] Array of role IDs or role names that are ignored. Members with one of these roles will be ignored. * @property {Discord.Snowflake|string[]|IgnoreGuildFunction} [ignoredGuilds=[]] Array of guild IDs or guild names that are ignored. * @property {Discord.Snowflake|string[]|IgnoreChannelFunction} [ignoredChannels=[]] Array of channel IDs or channel names that are ignored. * @property {Discord.PermissionString[]} [ignoredPermissions=[]] Users with at least one of these permissions will be ignored. * @property {boolean} [ignoreBots=true] Whether bots should be ignored. * * @property {boolean} [warnEnabled=true] Whether warn sanction is enabled. * @property {boolean} [kickEnabled=true] Whether kick sanction is enabled. * @property {boolean} [muteEnabled=true] Whether mute sanction is enabled. * @property {boolean} [banEnabled=true] Whether ban sanction is enabled. * * @property {number} [deleteMessagesAfterBanForPastDays=1] When a user is banned, their messages sent in the last x days will be deleted. * @property {boolean} [verbose=false] Extended logs from module (recommended). * @property {boolean} [debug=false] Whether to run the module in debug mode. * @property {boolean} [removeMessages=true] Whether to delete user messages after a sanction. */ /** * Cached message. * @typedef CachedMessage * * @property {Discord.Snowflake} messageID The ID of the message. * @property {Discord.Snowflake} guildID The ID of the guild where the message was sent. * @property {Discord.Snowflake} authorID The ID of the author of the message. * @property {Discord.Snowflake} channelID The ID of the channel of the message. * @property {string} content The content of the message. * @property {number} sentTimestamp The timestamp the message was sent. */ /** * Cache data for the AntiSpamClient * @typedef AntiSpamCache * * @property {Discord.Snowflake[]} warnedUsers Array of warned users. * @property {Discord.Snowflake[]} kickedUsers Array of kicked users. * @property {Discord.Snowflake[]} mutedUsers Array of muted users. * @property {Discord.Snowflake[]} bannedUsers Array of banned users. * @property {CachedMessage[]} messages Array of cached messages, used to detect spam. */ /** * Main AntiSpam class */ class AntiSpamClient extends EventEmitter { /** * @param {AntiSpamClientOptions} options The options for this AntiSpam client instance */ constructor (options) { super() /** * The options for this AntiSpam client instance * @type {AntiSpamClientOptions} */ this.options = { warnThreshold: options.warnThreshold || 3, kickThreshold: options.kickThreshold || 5, banThreshold: options.banThreshold || 7, muteThreshold: options.muteThreshold || 4, maxInterval: options.maxInterval || 2000, maxDuplicatesInterval: options.maxDuplicatesInterval || 2000, maxDuplicatesWarn: options.maxDuplicatesWarn || 7, maxDuplicatesKick: options.maxDuplicatesKick || 10, maxDuplicatesBan: options.maxDuplicatesBan || 10, maxDuplicatesMute: options.maxDuplicatesMute || 9, muteRoleName: options.muteRoleName || 'Muted', modLogsChannelName: options.modLogsChannelName || 'mod-logs', modLogsEnabled: options.modLogsEnabled || false, warnMessage: options.warnMessage || '{@user}, Please stop spamming.', muteMessage: options.muteMessage || '**{user_tag}** has been muted for spamming.', kickMessage: options.kickMessage || '**{user_tag}** has been kicked for spamming.', banMessage: options.banMessage || '**{user_tag}** has been banned for spamming.', errorMessages: options.errorMessages || true, kickErrorMessage: options.kickErrorMessage || 'Could not kick **{user_tag}** because of improper permissions.', banErrorMessage: options.banErrorMessage || 'Could not ban **{user_tag}** because of improper permissions.', muteErrorMessage: options.muteErrorMessage || 'Could not mute **{user_tag}** because of improper permissions or the mute role couldn\'t be found.', ignoredMembers: options.ignoredMembers || [], ignoredRoles: options.ignoredRoles || [], ignoredGuilds: options.ignoredGuilds || [], ignoredChannels: options.ignoredChannels || [], ignoredPermissions: options.ignoredPermissions || [], ignoreBots: options.ignoreBots || true, warnEnabled: options.warnEnabled || true, kickEnabled: options.kickEnabled || true, muteEnabled: options.muteEnabled || true, banEnabled: options.banEnabled || true, deleteMessagesAfterBanForPastDays: options.deleteMessagesAfterBanForPastDays || 1, verbose: options.verbose || false, debug: options.debug || false, removeMessages: options.removeMessages || true } /** * The cache for this AntiSpam client instance * @type {AntiSpamCache} */ this.cache = { messages: [], warnedUsers: [], kickedUsers: [], mutedUsers: [], bannedUsers: [] } } /** * Format a string and returns it. * @ignore * @param {string|Discord.MessageEmbed} string The string to format. * @param {Discord.Message} message Context message. * @returns {string|Discord.MessageEmbed} */ format (string, message) { if (typeof string === 'string') { return string .replace(/{@user}/g, message.author.toString()) .replace(/{user_tag}/g, message.author.tag) .replace(/{server_name}/g, message.guild.name) } else { const embed = new Discord.MessageEmbed(string) if (embed.description) embed.setDescription(this.format(embed.description, message)) if (embed.title) embed.setTitle(this.format(embed.title, message)) if (embed.footer && embed.footer.text) embed.footer.text = this.format(embed.footer.text, message) if (embed.author && embed.author.name) embed.author.name = this.format(embed.author.name, message) return embed } } /** * Send a message to the logs channel * @ignore * @param {Discord.Message} msg The message to check the channel with * @param {string} message The message to log * @param {Discord.Client} client The Discord client that will send the message */ log (msg, message, client) { if (this.options.modLogsEnabled) { const modLogChannel = client.channels.cache.get(this.options.modLogsChannelName) || msg.guild.channels.cache.find((channel) => channel.name === this.options.modLogsChannelName && channel.type === 'text') if (modLogChannel) { modLogChannel.send(message) } } } /** * Delete spam messages * @ignore * @param {CachedMessage[]} messages The messages to delete * @param {Discord.Client} client The Discord client that will delete the messages * @returns {Promise} */ async clearSpamMessages (messages, client) { messages.forEach((message) => { const channel = client.channels.cache.get(message.channelID) if (channel) { const msg = channel.messages.cache.get(message.messageID) if (msg && msg.deletable) msg.delete() } }) } /** * Ban a user. * @ignore * @param {Discord.Message} message Context message. * @param {Discord.GuildMember} member The member to ban. * @param {CachedMessage[]} [spamMessages] The spam messages. * @returns {Promise} Whether the member could be banned. */ async banUser (message, member, spamMessages) { if (this.options.removeMessages && spamMessages) { this.clearSpamMessages(spamMessages, message.client) } this.cache.messages = this.cache.messages.filter((u) => u.authorID !== message.author.id) this.cache.bannedUsers.push(message.author.id) if (!member.bannable) { if (this.options.verbose) { console.log(`DAntiSpam (banUser#userNotBannable): ${message.author.tag} (ID: ${message.author.id}) could not be banned, insufficient permissions`) } if (this.options.errorMessages) { message.channel.send(this.format(this.options.banErrorMessage, message)).catch((e) => { if (this.options.verbose) { console.error(`DAntiSpam (banUser#sendMissingPermMessage): ${e.message}`) } }) } return false } else { await message.member.ban({ reason: 'Spamming!', days: this.options.deleteMessagesAfterBanForPastDays }) if (this.options.errorMessages) { message.channel.send(this.format(this.options.banErrorMessage, message)).catch((e) => { if (this.options.verbose) { console.error(`DAntiSpam (banUser#sendSuccessMessage): ${e.message}`) } }) } if (this.options.modLogsEnabled) { this.log(message, `Spam detected: ${message.author} got **banned**`, message.client) } this.emit('banAdd', member) return true } } /** * Mute a user. * @ignore * @param {Discord.Message} message Context message. * @param {Discord.GuildMember} member The member to mute. * @param {CachedMessage[]} [spamMessages] The spam messages. * @returns {Promise} Whether the member could be muted. */ async muteUser (message, member, spamMessages) { if (this.options.removeMessages && spamMessages) { this.clearSpamMessages(spamMessages, message.client) } this.cache.messages = this.cache.messages.filter((u) => u.authorID !== message.author.id) this.cache.mutedUsers.push(message.author.id) const role = message.guild.roles.cache.find(role => role.name === this.options.muteRoleName) const userCanBeMuted = role && message.guild.me.hasPermission('MANAGE_ROLES') && (message.guild.me.roles.highest.position > message.member.roles.highest.position) if (!userCanBeMuted) { if (this.options.verbose) { console.log(`DAntiSpam (kickUser#userNotMutable): ${message.author.tag} (ID: ${message.author.id}) could not be muted, improper permissions or the mute role couldn't be found.`) } if (this.options.errorMessages) { await message.channel .send(this.format(this.options.muteErrorMessage, message)) .catch((e) => { if (this.options.verbose) { console.log(`DAntiSpam (muteUser#sendMissingPermMessage): ${e.message}`) } }) } return false } if (message.member.roles.cache.has(role.id)) return true await message.member.roles.add(role, 'Spamming') if (this.options.muteMessage) { await message.channel.send(this.format(this.options.muteMessage, message)).catch(e => { if (this.options.verbose) { console.error(`DAntiSpam (kickUser#sendSuccessMessage): ${e.message}`) } }) } if (this.options.modLogsEnabled) { this.log(message, `Spam detected: ${message.author} got **muted**`, message.client) } this.emit('muteAdd', member) return true } /** * Kick a user. * @ignore * @param {Discord.Message} message Context message. * @param {Discord.GuildMember} member The member to kick. * @param {CachedMessage[]} [spamMessages] The spam messages. * @returns {Promise} Whether the member could be kicked. */ async kickUser (message, member, spamMessages) { if (this.options.removeMessages && spamMessages) { this.clearSpamMessages(spamMessages, message.client) } this.cache.messages = this.cache.messages.filter((u) => u.authorID !== message.author.id) this.cache.kickedUsers.push(message.author.id) if (!member.kickable) { if (this.options.verbose) { console.log(`DAntiSpam (kickUser#userNotKickable): ${message.author.tag} (ID: ${message.author.id}) could not be kicked, insufficient permissions`) } if (this.options.errorMessages) { message.channel.send(this.format(this.options.kickErrorMessage, message)).catch((e) => { if (this.options.verbose) { console.error(`DAntiSpam (kickUser#sendMissingPermMessage): ${e.message}`) } }) } return false } else { await message.member.kick('Spamming!') if (this.options.kickMessage) { message.channel.send(this.format(this.options.kickMessage, message)).catch((e) => { if (this.options.verbose) { console.error(`DAntiSpam (kickUser#sendSuccessMessage): ${e.message}`) } }) } if (this.options.modLogsEnabled) { this.log(message, `Spam detected: ${message.author} got **kicked**`, message.client) } this.emit('kickAdd', member) return true } } /** * Warn a user. * @ignore * @param {Discord.Message} message Context message. * @param {Discord.GuildMember} member The member to warn. * @param {CachedMessage[]} [spamMessages] The spam messages. * @returns {Promise} Whether the member could be warned. */ async warnUser (message, member, spamMessages) { if (this.options.removeMessages && spamMessages) { this.clearSpamMessages(spamMessages, message.client) } this.cache.warnedUsers.push(message.author.id) this.log(message, `Spam detected: ${message.author.tag} got **warned**`, message.client) if (this.options.warnMessage) { message.channel.send(this.format(this.options.warnMessage, message)).catch((e) => { if (this.options.verbose) { console.error(`DAntiSpam (warnUser#sendSuccessMessage): ${e.message}`) } }) } this.emit('warnAdd', member) return true } /** * Checks a message. * @param {Discord.Message} message The message to check. * @returns {Promise} Whether the message has triggered a threshold. * @example * client.on('message', (msg) => { * antiSpam.message(msg); * }); */ async message (message) { const { options } = this if ( !message.guild || message.author.id === message.client.user.id || (message.guild.ownerID === message.author.id && !options.debug) || (options.ignoreBots && message.author.bot) ) { return false } const isMemberIgnored = typeof options.ignoredMembers === 'function' ? options.ignoredMembers(message.member) : options.ignoredMembers.includes(message.author.id) if (isMemberIgnored) return false const isGuildIgnored = typeof options.ignoredGuilds === 'function' ? options.ignoredGuilds(message.guild) : options.ignoredGuilds.includes(message.guild.id) if (isGuildIgnored) return false const isChannelIgnored = typeof options.ignoredChannels === 'function' ? options.ignoredChannels(message.channel) : options.ignoredChannels.includes(message.channel.id) if (isChannelIgnored) return false const member = message.member || await message.guild.members.fetch(message.author) const memberHasIgnoredRoles = typeof options.ignoredRoles === 'function' ? options.ignoredRoles(member.roles.cache) : options.ignoredRoles.some((r) => member.roles.cache.has(r)) if (memberHasIgnoredRoles) return false if (options.ignoredPermissions.some((permission) => member.hasPermission(permission))) return false const currentMessage = { messageID: message.id, guildID: message.guild.id, authorID: message.author.id, channelID: message.channel.id, content: message.content, sentTimestamp: message.createdTimestamp } this.cache.messages.push(currentMessage) const cachedMessages = this.cache.messages.filter((m) => m.authorID === message.author.id && m.guildID === message.guild.id) const duplicateMatches = cachedMessages.filter((m) => m.content === message.content && (m.sentTimestamp > (currentMessage.sentTimestamp - options.maxDuplicatesInterval))) /** * Duplicate messages sent before the threshold is triggered * @type {CachedMessage[]} */ const spamOtherDuplicates = [] if (duplicateMatches.length > 0) { let rowBroken = false cachedMessages.sort((a, b) => b.sentTimestamp - a.sentTimestamp).forEach(element => { if (rowBroken) return if (element.content !== duplicateMatches[0].content) rowBroken = true else spamOtherDuplicates.push(element) }) } const spamMatches = cachedMessages.filter((m) => m.sentTimestamp > (Date.now() - options.maxInterval)) let sanctioned = false const userCanBeBanned = options.banEnabled && !this.cache.bannedUsers.includes(message.author.id) && !sanctioned if (userCanBeBanned && (spamMatches.length >= options.banThreshold)) { this.banUser(message, member, spamMatches) sanctioned = true } else if (userCanBeBanned && (duplicateMatches.length >= options.maxDuplicatesBan)) { this.banUser(message, member, [...duplicateMatches, ...spamOtherDuplicates]) sanctioned = true } const userCanBeMuted = options.muteEnabled && !this.cache.mutedUsers.includes(message.author.id) && !sanctioned if (userCanBeMuted && (spamMatches.length >= options.muteThreshold)) { this.muteUser(message, member, spamMatches) sanctioned = true } else if (userCanBeMuted && (duplicateMatches.length >= options.maxDuplicatesMute)) { this.muteUser(message, member, [...duplicateMatches, ...spamOtherDuplicates]) sanctioned = true } const userCanBeKicked = options.kickEnabled && !this.cache.kickedUsers.includes(message.author.id) && !sanctioned if (userCanBeKicked && (spamMatches.length >= options.kickThreshold)) { this.kickUser(message, member, spamMatches) sanctioned = true } else if (userCanBeKicked && (duplicateMatches.length >= options.maxDuplicatesKick)) { this.kickUser(message, member, [...duplicateMatches, ...spamOtherDuplicates]) sanctioned = true } const userCanBeWarned = options.warnEnabled && !this.cache.warnedUsers.includes(message.author.id) && !sanctioned if (userCanBeWarned && (spamMatches.length >= options.warnThreshold)) { this.warnUser(message, member, spamMatches) sanctioned = true } else if (userCanBeWarned && (duplicateMatches.length >= options.maxDuplicatesWarn)) { this.warnUser(message, member, [...duplicateMatches, ...spamOtherDuplicates]) sanctioned = true } return sanctioned } /** * Reset the cache of this AntiSpam client instance. */ reset () { this.cache = { messages: [], warnedUsers: [], kickedUsers: [], mutedUsers: [], bannedUsers: [] } } } module.exports = AntiSpamClient