index.js

if (Number(process.version.split('.')[0].match(/[0-9]+/)) < 10)
	throw new Error('Node 10.0.0 or higher is required. Update Node on your system.');
const { RichEmbed, GuildMember } = require('discord.js');
const { EventEmitter } = require('events');

/**
 * Options for AntiSpam instance
 * 
 * @typedef {Object} AntiSpamOptions
 * 
 * @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} [warnThreshold=7] Amount of messages sent in a row that will cause a ban
 * 
 * @property {number} [maxInterval=2000] Amount of time (in milliseconds) in which messages are considered spam
 * 
 * @property {string} [warnMessage='{@user}, Please stop spamming.'] Message that will be sent in chat upon warning a user
 * @property {string} [kickMessage='**{user_tag}** has been kicked for spamming.'] Message that will be sent in chat upon kicking a user
 * @property {string} [banMessage='**{user_tag}** has been banned for spamming.'] Message that will be sent in chat upon banning a user
 * 
 * @property {number} [maxDuplicatesWarning=7] Amount of duplicate messages that trigger a warning
 * @property {number} [maxDuplicatesKick=10] Amount of duplicate messages that trigger a kick
 * @property {number} [maxDuplicatesBan=10] Amount of duplicate messages that trigger a ban
 *
 */
const clientOptions = {
	warnThreshold: 3,
	kickThreshold: 5,
	banThreshold: 7,
	maxInterval: 2000,
	warnMessage: '{@user}, Please stop spamming.',
	kickMessage: '**{user_tag}** has been kicked for spamming.',
	banMessage: '**{user_tag}** has been banned for spamming.',
	maxDuplicatesWarning: 7,
	maxDuplicatesKick: 10,
	maxDuplicatesBan: 10,
	deleteMessagesAfterBanForPastDays: 1,
	exemptPermissions: [],
	ignoreBots: true,
	verbose: false,
	ignoredUsers: [],
	ignoredRoles: [],
	ignoredGuilds: [],
	ignoredChannels: [],
	warnEnabled: true,
	kickEnabled: true,
	banEnabled: true
};

/**
 * AntiSpam instance
 * 
 * @param {AntiSpamOptions} [options] Client options
 * 
 */
class AntiSpam extends EventEmitter {
	constructor(options = {}) {
		super();
		for (const key in clientOptions) {
			if (
				!options.hasOwnProperty(key) ||
				typeof options[key] === 'undefined' ||
				options[key] === null
			)
				options[key] = clientOptions[key];
		}
		this.options = options;
		this.data = {
			messageCache: [],
			bannedUsers: [],
			kickedUsers: [],
			warnedUsers: [],
			users: []
		};
	}

	async message(message) {
		if (
			message.channel.type === 'dm' ||
			message.author.id === message.client.user.id ||
			message.guild.ownerID === message.author.id
		)
			return false;

		const { options, data } = this;
		if (!message.member) message.member = await message.guild.fetchMember(message.author);
		if (
			(options.ignoreBots && message.author.bot) ||
			options.exemptPermissions.some(permission => message.member.hasPermission(permission))
		)
			return false;

		if (
			message.member.roles.some(role =>
				typeof options.ignoredRoles === 'function'
					? options.ignoredRoles(role)
					: options.ignoredRoles.includes(role.id) || options.ignoredRoles.includes(role.name)
			) ||
			(typeof options.ignoredUsers === 'function'
				? options.ignoredUsers(message.author)
				: options.ignoredUsers.includes(message.author.id)) ||
			(typeof options.ignoredGuilds === 'function'
				? options.ignoredGuilds(message.guild)
				: options.ignoredGuilds.includes(message.guild.id)) ||
			(typeof options.ignoredChannels === 'function'
				? options.ignoredChannels(message.channel)
				: options.ignoredChannels.includes(message.channel.id))
		)
			return false;

		const banUser = async () => {
			data.messageCache = data.messageCache.filter(m => m.author !== message.author.id);
			data.bannedUsers.push(message.author.id);

			if (!message.member.bannable) {
				if (options.verbose)
					console.log(
						`**${message.author.tag}** (ID: ${message.author.id}) could not be banned, insufficient permissions.`
					);
				return false;
			}

			try {
				await message.member.ban({
					reason: 'Spamming!',
					days: options.deleteMessagesAfterBanForPastDays
				});
				if (options.banMessage)
					await message.channel.send(format(options.banMessage, message)).catch(e => {
						if (options.verbose) console.error(e);
					});
				this.emit('banAdd', message.member);
				return true;
			} catch (error) {
				const emitted = this.emit('error', message, error, 'ban');
				if (emitted) return false;
				if (options.verbose)
					console.log(
						`**${message.author.tag}** (ID: ${message.author.id}) could not be banned, ${error}.`
					);
				await message.channel
					.send(`Could not ban **${message.author.tag}** because of an error: \`${error}\`.`)
					.catch(e => {
						if (options.verbose) console.error(e);
					});
				return false;
			}
		};

		const kickUser = async () => {
			data.messageCache = data.messageCache.filter(m => m.author !== message.author.id);
			data.kickedUsers.push(message.author.id);

			if (!message.member.kickable) {
				if (options.verbose)
					console.log(
						`**${message.author.tag}** (ID: ${message.author.id}) could not be kicked, insufficient permissions.`
					);
				await msg.channel
					.send(
						`Could not kick **${msg.author.tag}** because of improper permissions.`
					)
					.catch((e) => {
						if (options.verbose) console.error(e);
					});
				return false;
			}

			try {
				await message.member.kick('Spamming!');
				if (options.kickMessage)
					await message.channel.send(format(options.kickMessage, message)).catch(e => {
						if (options.verbose) console.error(e);
					});
				this.emit('kickAdd', message.member);
				return true;
			} catch (error) {
				const emitted = this.emit('error', message, error, 'kick');
				if (emitted) return false;
				if (options.verbose)
					console.log(
						`**${message.author.tag}** (ID: ${message.author.id}) could not be kicked, ${error}.`
					);
				await message.channel
					.send(`Could not kick **${message.author.tag}** because of an error: \`${error}\`.`)
					.catch(e => {
						if (options.verbose) console.error(e);
					});
				return false;
			}
		};

		const warnUser = async () => {
			data.warnedUsers.push(message.author.id);
			this.emit('warnAdd', message.member);

			if (options.warnMessage)
				await message.channel.send(format(options.warnMessage, message)).catch(e => {
					if (options.verbose) console.error(e);
				});

			return true;
		};

		data.users.push({
			time: Date.now(),
			id: message.author.id
		});

		data.messageCache.push({
			content: message.content,
			author: message.author.id
		});

		const messageMatches = data.messageCache.filter(
			m => m.content === message.content && m.author === message.author.id
		).length;
		const spamMatches = data.users.filter(
			u => u.time > Date.now() - options.maxInterval && u.id === message.author.id
		).length;

		if (
			!data.warnedUsers.includes(message.author.id) &&
			(spamMatches === options.warnThreshold || messageMatches === options.maxDuplicatesWarning)
		) {
			if (options.warnEnabled) warnUser(message);
			this.emit(
				'spamThresholdWarn',
				message.member,
				messageMatches === options.maxDuplicatesWarning
			);
			return true;
		}

		if (
			!data.kickedUsers.includes(message.author.id) &&
			(spamMatches === options.kickThreshold || messageMatches === options.maxDuplicatesKick)
		) {
			if (options.kickEnabled) await kickUser(message);
			this.emit('spamThresholdKick', message.member, messageMatches === options.maxDuplicatesKick);
			return true;
		}

		if (spamMatches === options.banThreshold || messageMatches === options.maxDuplicatesBan) {
			if (options.banEnabled) await banUser(message);
			this.emit('spamThresholdBan', message.member, messageMatches === options.maxDuplicatesBan);
			return true;
		}

		return false;
	}

	resetData() {
		this.data.messageCache = [];
		this.data.bannedUsers = [];
		this.data.kickedUsers = [];
		this.data.warnedUsers = [];
		this.data.users = [];
		return this.data;
	}
}

/**
 * Emitted when a member is warned.
 * @event AntiSpam#warnAdd
 * @param {GuildMember} member The warned member.
 */

/**
 * Emitted when a member is kicked.
 * @event AntiSpam#kickAdd
 * @param {GuildMember} member The kicked member.
 */

/**
 * Emitted when a member is banned.
 * @event AntiSpam#banAdd
 * @param {GuildMember} member The banned member.
 */

/**
 * Emitted when a member reaches the warn threshold.
 * @event AntiSpam#spamThresholdWarn
 * @param {GuildMember} member The member who reached the warn threshold.
 * @param {boolean} duplicate Whether the member reached the warn threshold by spamming the same message.
 */

/**
 * Emitted when a member reaches the kick threshold.
 * @event AntiSpam#spamThresholdKick
 * @param {GuildMember} member The member who reached the kick threshold.
 * @param {boolean} duplicate Whether the member reached the kick threshold by spamming the same message.
 */

/**
 * Emitted when a member reaches the ban threshold.
 * @event AntiSpam#spamThresholdBan
 * @param {GuildMember} member The member who reached the ban threshold.
 * @param {boolean} duplicate Whether the member reached the ban threshold by spamming the same message.
 */

module.exports = AntiSpam;

/**
 * This function formats a string by replacing some keywords with variables
 * @param {string|RichEmbed} string The non-formatted string or RichEmbed
 * @param {object} message The Discord Message object
 * @returns {string|RichEmbed} The formatted string
 */
function 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);
	const embed = new RichEmbed(string);
	if (embed.description) embed.setDescription(format(embed.description, message));
	if (embed.title) embed.setTitle(format(embed.title, message));
	if (embed.footer && embed.footer.text) embed.footer.text = format(embed.footer.text, message);
	if (embed.author && embed.author.name) embed.author.name = format(embed.author.name, message);
	return embed;
}