From 7ded84b92401f3aee620929f4c77027e024aa142 Mon Sep 17 00:00:00 2001 From: Dragory Date: Tue, 31 Jul 2018 02:42:45 +0300 Subject: [PATCH] Add spam plugin. Add clean commands. Update Knub to 9.6.0. --- package-lock.json | 11 +- package.json | 3 +- src/data/DefaultLogMessages.json | 12 +- src/data/LogType.ts | 2 + src/index.ts | 5 +- src/plugins/Censor.ts | 46 +------ src/plugins/Logs.ts | 42 ++++-- src/plugins/ModActions.ts | 11 +- src/plugins/Spam.ts | 226 +++++++++++++++++++++++++++++++ src/plugins/Utility.ts | 155 +++++++++++++++++++-- src/utils.ts | 111 ++++++++++++++- 11 files changed, 539 insertions(+), 85 deletions(-) create mode 100644 src/plugins/Spam.ts diff --git a/package-lock.json b/package-lock.json index 09ed19bf..63bce621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -781,6 +781,11 @@ "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=", "dev": true }, + "emoji-regex": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.0.tgz", + "integrity": "sha512-lnvttkzAlYW8WpFPiStPWyd/YdS02cFsYwXwWqnbKY43fMgUeUx+vzW1Zaozu34n4Fm7sxygi8+SEL6dcks/hQ==" + }, "eris": { "version": "0.8.6", "resolved": "https://registry.npmjs.org/eris/-/eris-0.8.6.tgz", @@ -2176,9 +2181,9 @@ } }, "knub": { - "version": "9.4.13", - "resolved": "https://registry.npmjs.org/knub/-/knub-9.4.13.tgz", - "integrity": "sha512-4m5IMbctg1xAe6DoYSkk1jdQNWpUb6ZkjKxJPxHEmbXtIZm11qt/AmIcASgG5pvZOM7Q/PnsbLfRyzlUTbvOLA==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/knub/-/knub-9.6.0.tgz", + "integrity": "sha512-+a/woh8WnSxBkflNjCjvfGASadz80o/0Mot81K9sr8BvcITzeDtoOBaxzeiwCb5NWNtYz/Qp9M7ZZ6Jr5U45bg==", "requires": { "escape-string-regexp": "^1.0.5", "js-yaml": "^3.9.1", diff --git a/package.json b/package.json index 3be1fcfe..e26ff678 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,12 @@ "@types/moment-timezone": "^0.5.6", "@types/node": "^8.0.50", "dotenv": "^4.0.0", + "emoji-regex": "^7.0.0", "eris": "^0.8.6", "escape-string-regexp": "^1.0.5", "humanize-duration": "^3.15.0", "knex": "^0.14.6", - "knub": "^9.4.13", + "knub": "^9.6.0", "lodash.at": "^4.6.0", "lodash.difference": "^4.5.0", "lodash.intersection": "^4.4.0", diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json index 3967876d..f7a00652 100644 --- a/src/data/DefaultLogMessages.json +++ b/src/data/DefaultLogMessages.json @@ -1,6 +1,7 @@ { "MEMBER_WARN": "⚠️ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was warned by {mod.username}#{mod.discriminator}", "MEMBER_MUTE": "🔇 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was muted by {mod.username}#{mod.discriminator}", + "MEMBER_MUTE_SPAM": "🔇 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was muted for spam in **#{channel.name}**: {description} (more than {limit} in {interval}s)", "MEMBER_UNMUTE": "🔉 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) was unmuted by {mod.username}#{mod.discriminator}", "MEMBER_MUTE_EXPIRED": "🔉 **{member.user.username}#{member.user.discriminator}**'s mute expired", "MEMBER_KICK": "👢 **{user.username}#{user.discriminator}** (`{user.id}`) was kicked by {mod.username}#{mod.discriminator}", @@ -23,10 +24,10 @@ "ROLE_DELETE": "🖊 Role **{role.name}** was deleted", "ROLE_EDIT": "🖊 Role **{role.name}** was edited", - "MESSAGE_EDIT": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message edited in **{channel.name}**:\n`B:` {before}\n`A:` {after}", - "MESSAGE_DELETE": "🗑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message deleted in **{channel.name}**:\n```{messageText}```", - "MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in **{channel.name}**", - "MESSAGE_DELETE_BARE": "🗑 message (id `{messageId}`) deleted in **{channel.name}** (no more info available due to restart)", + "MESSAGE_EDIT": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message edited in **#{channel.name}**:\n`B:` {before}\n`A:` {after}", + "MESSAGE_DELETE": "🗑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) message deleted in **#{channel.name}**:\n```{messageText}```", + "MESSAGE_DELETE_BULK": "🗑 **{count}** messages deleted in **#{channel.name}**", + "MESSAGE_DELETE_BARE": "🗑 Message (`{messageId}`) deleted in **#{channel.name}** (no more info available due to bot restart)", "VOICE_CHANNEL_JOIN": "🔸 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) joined **{channel.name}**", "VOICE_CHANNEL_MOVE": "🔹 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) moved **{oldChannel.name}** ➞ **{newChannel.name}**", @@ -34,8 +35,9 @@ "COMMAND": "🤖 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) used command in **#{channel.name}**:\n`{command}`", - "SPAM_DELETE": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) triggered spam filter: **{filterName}**", + "SPAM_DELETE": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) spam deleted in **#{channel.name}**: {description} (more than {limit} in {interval}s)", "CENSOR": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) censored message in **#{channel.name}** (`{channel.id}`) {reason}:\n```{messageText}```", + "CLEAN": "🚿 **{mod.username}#{mod.discriminator}** (`{mod.id}`) cleaned **{count}** message(s) in **#{channel.name}**", "CASE_CREATE": "✏ **{member.user.username}#{member.user.discriminator}** (`{member.id}`) manually created new **{caseType}** case (#{caseNum})" } diff --git a/src/data/LogType.ts b/src/data/LogType.ts index 1a0eeaca..856b5669 100644 --- a/src/data/LogType.ts +++ b/src/data/LogType.ts @@ -1,6 +1,7 @@ export enum LogType { MEMBER_WARN = 1, MEMBER_MUTE, + MEMBER_MUTE_SPAM, MEMBER_UNMUTE, MEMBER_MUTE_EXPIRED, MEMBER_KICK, @@ -34,6 +35,7 @@ export enum LogType { SPAM_DELETE, CENSOR, + CLEAN, CASE_CREATE } diff --git a/src/index.ts b/src/index.ts index e20f5410..21474262 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,12 +21,14 @@ import { PostPlugin } from "./plugins/Post"; import { ReactionRolesPlugin } from "./plugins/ReactionRoles"; import { CensorPlugin } from "./plugins/Censor"; import { PersistPlugin } from "./plugins/Persist"; +import { SpamPlugin } from "./plugins/Spam"; import knex from "./knex"; // Run latest database migrations logger.info("Running database migrations"); knex.migrate.latest().then(() => { const client = new Client(process.env.TOKEN); + client.setMaxListeners(100); const bot = new Knub(client, { plugins: { @@ -36,7 +38,8 @@ knex.migrate.latest().then(() => { post: PostPlugin, reaction_roles: ReactionRolesPlugin, censor: CensorPlugin, - persist: PersistPlugin + persist: PersistPlugin, + spam: SpamPlugin }, globalPlugins: { bot_control: BotControlPlugin diff --git a/src/plugins/Censor.ts b/src/plugins/Censor.ts index 0aaaee71..2eeba1ca 100644 --- a/src/plugins/Censor.ts +++ b/src/plugins/Censor.ts @@ -1,51 +1,9 @@ -import { Plugin, decorators as d } from "knub"; +import { decorators as d, Plugin } from "knub"; import { Invite, Message } from "eris"; -import url from "url"; -import tlds from "tlds"; import escapeStringRegexp from "escape-string-regexp"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; -import { stripObjectToScalars } from "../utils"; - -const urlRegex = /(\S+\.\S+)/g; -const protocolRegex = /^[a-z]+:\/\//; - -const getInviteCodesInString = (str: string): string[] => { - const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi; - const inviteCodes = []; - let match; - - // tslint:disable-next-line - while ((match = inviteCodeRegex.exec(str)) !== null) { - inviteCodes.push(match[1]); - } - - return inviteCodes; -}; - -const getUrlsInString = (str: string): url.URL[] => { - const matches = str.match(urlRegex) || []; - return matches.reduce((urls, match) => { - if (!protocolRegex.test(match)) { - match = `https://${match}`; - } - - let matchUrl: url.URL; - try { - matchUrl = new url.URL(match); - } catch (e) { - return urls; - } - - const hostnameParts = matchUrl.hostname.split("."); - const tld = hostnameParts[hostnameParts.length - 1]; - if (tlds.includes(tld)) { - urls.push(matchUrl); - } - - return urls; - }, []); -}; +import { getInviteCodesInString, getUrlsInString, stripObjectToScalars } from "../utils"; export class CensorPlugin extends Plugin { protected serverLogs: GuildLogs; diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts index 1724af5a..719a495d 100644 --- a/src/plugins/Logs.ts +++ b/src/plugins/Logs.ts @@ -158,17 +158,25 @@ export class LogsPlugin extends Plugin { const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : unknownUser; if (addedRoles.length) { - this.serverLogs.log(LogType.MEMBER_ROLE_ADD, { - member, - role: this.guild.roles.get(addedRoles[0]), - mod: stripObjectToScalars(mod) - }); + this.serverLogs.log( + LogType.MEMBER_ROLE_ADD, + { + member, + role: this.guild.roles.get(addedRoles[0]), + mod: stripObjectToScalars(mod) + }, + member.id + ); } else if (removedRoles.length) { - this.serverLogs.log(LogType.MEMBER_ROLE_REMOVE, { - member, - role: this.guild.roles.get(removedRoles[0]), - mod: stripObjectToScalars(mod) - }); + this.serverLogs.log( + LogType.MEMBER_ROLE_REMOVE, + { + member, + role: this.guild.roles.get(removedRoles[0]), + mod: stripObjectToScalars(mod) + }, + member.id + ); } } } @@ -230,7 +238,7 @@ export class LogsPlugin extends Plugin { @d.event("messageDelete") onMessageDelete(msg: Message) { - if (msg.type !== 0) return; + if (msg.type != null && msg.type !== 0) return; if (msg.member) { this.serverLogs.log( @@ -256,10 +264,14 @@ export class LogsPlugin extends Plugin { @d.event("messageDeleteBulk") onMessageDeleteBulk(messages: Message[]) { - this.serverLogs.log(LogType.MESSAGE_DELETE_BULK, { - count: messages.length, - channel: messages[0] ? messages[0].channel : null - }); + this.serverLogs.log( + LogType.MESSAGE_DELETE_BULK, + { + count: messages.length, + channel: messages[0] ? messages[0].channel : null + }, + messages[0] && messages[0].id + ); } @d.event("voiceChannelJoin") diff --git a/src/plugins/ModActions.ts b/src/plugins/ModActions.ts index b3a09d43..dc0a30ed 100644 --- a/src/plugins/ModActions.ts +++ b/src/plugins/ModActions.ts @@ -258,6 +258,11 @@ export class ModActionsPlugin extends Plugin { }); } + public async muteMember(member: Member, muteTime: number = null, reason: string = null) { + await member.addRole(this.configValue("mute_role"), reason); + await this.mutes.addOrUpdateMute(member.id, muteTime); + } + @d.command("mute", " [time:string] [reason:string$]") @d.permission("mute") async muteCmd(msg: Message, args: any) { @@ -283,8 +288,7 @@ export class ModActionsPlugin extends Plugin { // Apply "muted" role this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); - await args.member.addRole(this.configValue("mute_role"), args.reason); - await this.mutes.addOrUpdateMute(args.member.id, muteTime); + this.muteMember(args.member, muteTime, args.reason); // Create a case await this.createCase(args.member.id, msg.author.id, CaseType.Mute, null, args.reason); @@ -713,7 +717,7 @@ export class ModActionsPlugin extends Plugin { return this.displayCase(caseOrCaseId, caseLogChannelId); } - protected async createCase( + public async createCase( userId: string, modId: string, caseType: CaseType, @@ -770,6 +774,7 @@ export class ModActionsPlugin extends Plugin { if (!member) continue; try { + this.serverLogs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); await member.removeRole(this.configValue("mute_role")); } catch (e) {} // tslint:disable-line diff --git a/src/plugins/Spam.ts b/src/plugins/Spam.ts new file mode 100644 index 00000000..821e9469 --- /dev/null +++ b/src/plugins/Spam.ts @@ -0,0 +1,226 @@ +import { decorators as d, Plugin } from "knub"; +import { Message, TextChannel } from "eris"; +import { + cleanMessagesInChannel, + getEmojiInString, + getUrlsInString, + stripObjectToScalars +} from "../utils"; +import { LogType } from "../data/LogType"; +import { GuildLogs } from "../data/GuildLogs"; +import { ModActionsPlugin } from "./ModActions"; +import { CaseType } from "../data/CaseType"; + +enum RecentActionType { + Message = 1, + Mention, + Link, + Attachment, + Emoji, + Newline +} + +interface IRecentAction { + type: RecentActionType; + userId: string; + channelId: string; + timestamp: number; + count: number; +} + +export class SpamPlugin extends Plugin { + protected logs: GuildLogs; + + protected recentActions: IRecentAction[]; + + private expiryInterval; + + getDefaultOptions() { + return { + config: { + max_messages: null, + max_mentions: null, + max_links: null, + max_attachments: null, + max_emojis: null, + max_newlines: null, + max_duplicates: null + } + }; + } + + onLoad() { + this.logs = new GuildLogs(this.guildId); + this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60); + this.recentActions = []; + } + + onUnload() { + clearInterval(this.expiryInterval); + } + + addRecentAction( + type: RecentActionType, + userId: string, + channelId: string, + timestamp: number, + count = 1 + ) { + this.recentActions.push({ + type, + userId, + channelId, + timestamp, + count + }); + } + + getRecentActionCount(type: RecentActionType, userId: string, channelId: string, since: number) { + return this.recentActions.reduce((count, action) => { + if (action.timestamp < since) return count; + if (action.type !== type) return count; + if (action.channelId !== channelId) return count; + return count + action.count; + }, 0); + } + + clearRecentUserActions(type: RecentActionType, userId: string, channelId: string) { + this.recentActions = this.recentActions.filter(action => { + return action.type !== type || action.userId !== userId || action.channelId !== channelId; + }); + } + + clearOldRecentActions() { + // TODO: Figure out expiry time from longest interval in the config? + const expiryTimestamp = Date.now() - 1000 * 60 * 5; + this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp); + } + + async detectSpam( + msg: Message, + type: RecentActionType, + spamConfig: any, + actionCount: number, + description: string + ) { + if (actionCount === 0) return; + + this.addRecentAction(type, msg.author.id, msg.channel.id, msg.timestamp, actionCount); + const recentMessagesCount = this.getRecentActionCount( + type, + msg.author.id, + msg.channel.id, + msg.timestamp - 1000 * spamConfig.interval + ); + + if (recentMessagesCount > spamConfig.count) { + if (spamConfig.clean !== false) { + const cleanCount = + type === RecentActionType.Message ? spamConfig.count : spamConfig.cleanCount || 20; + + await cleanMessagesInChannel( + this.bot, + msg.channel as TextChannel, + cleanCount, + msg.author.id, + "Spam detected" + ); + this.logs.log(LogType.SPAM_DELETE, { + member: stripObjectToScalars(msg.member, ["user"]), + channel: stripObjectToScalars(msg.channel), + description, + limit: spamConfig.count, + interval: spamConfig.interval + }); + } + + if (spamConfig.mute) { + const guildData = this.knub.getGuildData(this.guildId); + const modActionsPlugin = guildData.loadedPlugins.get("mod_actions") as ModActionsPlugin; + if (!modActionsPlugin) return; + + this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, msg.member.id); + await modActionsPlugin.muteMember( + msg.member, + spamConfig.muteTime ? spamConfig.muteTime * 1000 : 120 * 1000, + "Automatic spam detection" + ); + await modActionsPlugin.createCase( + msg.member.id, + this.bot.user.id, + CaseType.Mute, + null, + "Automatic spam detection", + true + ); + this.logs.log(LogType.MEMBER_MUTE_SPAM, { + member: stripObjectToScalars(msg.member, ["user"]), + channel: stripObjectToScalars(msg.channel), + description, + limit: spamConfig.count, + interval: spamConfig.interval + }); + } + + return; + } + } + + @d.event("messageCreate") + onMessageCreate(msg: Message) { + if (msg.author.bot) return; + + const maxMessages = this.configValueForMsg(msg, "max_messages"); + if (maxMessages) { + this.detectSpam(msg, RecentActionType.Message, maxMessages, 1, "too many messages"); + } + + const maxMentions = this.configValueForMsg(msg, "max_mentions"); + if (maxMentions && (msg.mentions.length || msg.roleMentions.length)) { + this.detectSpam( + msg, + RecentActionType.Mention, + maxMentions, + msg.mentions.length + msg.roleMentions.length, + "too many mentions" + ); + } + + const maxLinks = this.configValueForMsg(msg, "max_links"); + if (maxLinks && msg.content) { + const links = getUrlsInString(msg.content); + this.detectSpam(msg, RecentActionType.Link, maxLinks, links.length, "too many links"); + } + + const maxAttachments = this.configValueForMsg(msg, "max_attachments"); + if (maxAttachments && msg.attachments.length) { + this.detectSpam( + msg, + RecentActionType.Attachment, + maxAttachments, + msg.attachments.length, + "too many attachments" + ); + } + + const maxEmoji = this.configValueForMsg(msg, "max_emoji"); + if (maxEmoji && msg.content) { + const emojiCount = getEmojiInString(msg.content).length; + this.detectSpam(msg, RecentActionType.Emoji, maxEmoji, emojiCount, "too many emoji"); + } + + const maxNewlines = this.configValueForMsg(msg, "max_newlines"); + if (maxNewlines && msg.content) { + const newlineCount = (msg.content.match(/\n/g) || []).length; + this.detectSpam( + msg, + RecentActionType.Newline, + maxNewlines, + newlineCount, + "too many newlines" + ); + } + + // TODO: Max duplicates + } +} diff --git a/src/plugins/Utility.ts b/src/plugins/Utility.ts index d6c21cf0..3159e450 100644 --- a/src/plugins/Utility.ts +++ b/src/plugins/Utility.ts @@ -1,31 +1,43 @@ -import { Plugin, decorators as d } from "knub"; -import { Message, TextChannel } from "eris"; -import { errorMessage } from "../utils"; +import { Plugin, decorators as d, reply } from "knub"; +import { Channel, Message, TextChannel, User } from "eris"; +import { errorMessage, getMessages, stripObjectToScalars, successMessage } from "../utils"; +import { GuildLogs } from "../data/GuildLogs"; +import { LogType } from "../data/LogType"; + +const MAX_SEARCH_RESULTS = 15; +const MAX_CLEAN_COUNT = 50; export class UtilityPlugin extends Plugin { + protected logs: GuildLogs; + getDefaultOptions() { return { permissions: { roles: false, - level: false + level: false, + search: false, + clean: false, + info: false }, overrides: [ - { - level: ">0", - permissions: { - level: true - } - }, { level: ">=50", permissions: { - roles: true + roles: true, + level: true, + search: true, + clean: true, + info: true } } ] }; } + onLoad() { + this.logs = new GuildLogs(this.guildId); + } + @d.command("roles") @d.permission("roles") async rolesCmd(msg: Message) { @@ -48,4 +60,125 @@ export class UtilityPlugin extends Plugin { `The permission level of ${member.username}#${member.discriminator} is **${level}**` ); } + + @d.command("search", "") + @d.permission("search") + async searchCmd(msg: Message, args: { query: string }) { + const query = args.query.toLowerCase(); + const matchingMembers = this.guild.members.filter(member => { + const fullUsername = `${member.user.username}#${member.user.discriminator}`; + if (member.nick && member.nick.toLowerCase().indexOf(query) !== -1) return true; + if (fullUsername.toLowerCase().indexOf(query) !== -1) return true; + return false; + }); + + if (matchingMembers.length > 0) { + let header; + const resultText = matchingMembers.length === 1 ? "result" : "results"; + + if (matchingMembers.length > MAX_SEARCH_RESULTS) { + header = `Found ${matchingMembers.length} ${resultText} (showing ${MAX_SEARCH_RESULTS})`; + } else { + header = `Found ${matchingMembers.length} ${resultText}`; + } + + const lines = matchingMembers.slice(0, MAX_SEARCH_RESULTS).map(member => { + return `${member.user.username}#${member.user.discriminator} (${member.id})`; + }); + lines.sort((a, b) => { + return a.toLowerCase() < b.toLowerCase() ? -1 : 1; + }); + + msg.channel.createMessage(`${header}\n\`\`\`${lines.join("\n")}\`\`\``); + } else { + msg.channel.createMessage(errorMessage("No results found")); + } + } + + async cleanMessages(channel: Channel, messageIds: string[], mod: User) { + this.logs.ignoreLog(LogType.MESSAGE_DELETE, messageIds[0]); + this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, messageIds[0]); + await this.bot.deleteMessages(channel.id, messageIds); + this.logs.log(LogType.CLEAN, { + mod: stripObjectToScalars(mod), + channel: stripObjectToScalars(channel), + count: messageIds.length + }); + } + + @d.command("clean", "") + @d.command("clean all", "") + @d.permission("clean") + async cleanAllCmd(msg: Message, args: { count: number }) { + if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { + msg.channel.createMessage( + errorMessage(`Clean count must be between 1 and ${MAX_CLEAN_COUNT}`) + ); + return; + } + + const messagesToClean = await getMessages( + msg.channel as TextChannel, + m => m.id !== msg.id, + args.count + ); + if (messagesToClean.length > 0) + await this.cleanMessages(msg.channel, messagesToClean.map(m => m.id), msg.author); + + msg.channel.createMessage( + successMessage( + `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}` + ) + ); + } + + @d.command("clean user", " ") + @d.permission("clean") + async cleanUserCmd(msg: Message, args: { userId: string; count: number }) { + if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { + msg.channel.createMessage( + errorMessage(`Clean count must be between 1 and ${MAX_CLEAN_COUNT}`) + ); + return; + } + + const messagesToClean = await getMessages( + msg.channel as TextChannel, + m => m.id !== msg.id && m.author.id === args.userId, + args.count + ); + if (messagesToClean.length > 0) + await this.cleanMessages(msg.channel, messagesToClean.map(m => m.id), msg.author); + + msg.channel.createMessage( + successMessage( + `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}` + ) + ); + } + + @d.command("clean bot", "") + @d.permission("clean") + async cleanBotCmd(msg: Message, args: { count: number }) { + if (args.count > MAX_CLEAN_COUNT || args.count <= 0) { + msg.channel.createMessage( + errorMessage(`Clean count must be between 1 and ${MAX_CLEAN_COUNT}`) + ); + return; + } + + const messagesToClean = await getMessages( + msg.channel as TextChannel, + m => m.id !== msg.id && m.author.bot, + args.count + ); + if (messagesToClean.length > 0) + await this.cleanMessages(msg.channel, messagesToClean.map(m => m.id), msg.author); + + msg.channel.createMessage( + successMessage( + `Cleaned ${messagesToClean.length} ${messagesToClean.length === 1 ? "message" : "messages"}` + ) + ); + } } diff --git a/src/utils.ts b/src/utils.ts index b1209792..5a2f36a4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,8 @@ import at = require("lodash.at"); -import { Guild, GuildAuditLogEntry } from "eris"; +import { Client, Guild, GuildAuditLogEntry, Message, TextChannel } from "eris"; +import url from "url"; +import tlds from "tlds"; +import emojiRegex from "emoji-regex"; /** * Turns a "delay string" such as "1h30m" to milliseconds @@ -72,7 +75,8 @@ export function stripObjectToScalars(obj, includedNested: string[] = []) { const stringFormatRegex = /{([^{}]+?)}/g; export function formatTemplateString(str: string, values) { return str.replace(stringFormatRegex, (match, val) => { - return (at(values, val)[0] as string) || ""; + const value = at(values, val)[0]; + return typeof value === "string" || typeof value === "number" ? String(value) : ""; }); } @@ -119,3 +123,106 @@ export async function findRelevantAuditLogEntry( return null; } } + +const urlRegex = /(\S+\.\S+)/g; +const protocolRegex = /^[a-z]+:\/\//; + +export function getUrlsInString(str: string): url.URL[] { + const matches = str.match(urlRegex) || []; + return matches.reduce((urls, match) => { + if (!protocolRegex.test(match)) { + match = `https://${match}`; + } + + let matchUrl: url.URL; + try { + matchUrl = new url.URL(match); + } catch (e) { + return urls; + } + + const hostnameParts = matchUrl.hostname.split("."); + const tld = hostnameParts[hostnameParts.length - 1]; + if (tlds.includes(tld)) { + urls.push(matchUrl); + } + + return urls; + }, []); +} + +export function getInviteCodesInString(str: string): string[] { + const inviteCodeRegex = /(?:discord.gg|discordapp.com\/invite)\/([a-z0-9]+)/gi; + const inviteCodes = []; + let match; + + // tslint:disable-next-line + while ((match = inviteCodeRegex.exec(str)) !== null) { + inviteCodes.push(match[1]); + } + + return inviteCodes; +} + +export const unicodeEmojiRegex = emojiRegex(); +export const customEmojiRegex = /<:(?:.*?):(\d+)>/g; +export const anyEmojiRegex = new RegExp( + `(?:(?:${unicodeEmojiRegex.source})|(?:${customEmojiRegex.source}))`, + "g" +); + +export function getEmojiInString(str: string): string[] { + return str.match(anyEmojiRegex) || []; +} + +export type MessageFilterFn = (msg: Message) => boolean; +export type StopFn = (msg: Message) => boolean; + +export async function getMessages( + channel: TextChannel, + filter: MessageFilterFn = null, + maxCount: number = 50, + stopFn: StopFn = null +): Promise { + let messages: Message[] = []; + let before; + + if (!filter) { + filter = () => true; + } + + while (true) { + const newMessages = await channel.getMessages(50, before); + if (newMessages.length === 0) break; + + before = newMessages[newMessages.length - 1].id; + + const filtered = newMessages.filter(filter); + messages.push(...filtered); + + if (messages.length >= maxCount) { + messages = messages.slice(0, maxCount); + break; + } + + if (stopFn && newMessages.some(stopFn)) { + break; + } + } + + return messages; +} + +export async function cleanMessagesInChannel( + bot: Client, + channel: TextChannel, + count: number, + userId: string = null, + reason: string = null +) { + const messages = await getMessages(channel, msg => !userId || msg.author.id === userId, count); + const ids = messages.map(m => m.id); + if (ids) { + await bot.deleteMessages(channel.id, ids, reason); + } +}