From ae43d890a1f57502d491ebe9bab5a65192534c60 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 18 Aug 2019 16:40:15 +0300 Subject: [PATCH] Initial work on new automod --- src/Queue.ts | 4 +- src/SimpleCache.ts | 60 +++ src/plugins/Automod.ts | 808 ++++++++++++++++++++++++++++++++++ src/plugins/ZeppelinPlugin.ts | 12 +- src/utils.ts | 6 +- tslint.json | 3 +- 6 files changed, 888 insertions(+), 5 deletions(-) create mode 100644 src/SimpleCache.ts create mode 100644 src/plugins/Automod.ts diff --git a/src/Queue.ts b/src/Queue.ts index a3e519bb..cb8179bd 100644 --- a/src/Queue.ts +++ b/src/Queue.ts @@ -1,6 +1,8 @@ +import { SECONDS } from "./utils"; + type QueueFn = (...args: any[]) => Promise; -const DEFAULT_TIMEOUT = 10 * 1000; +const DEFAULT_TIMEOUT = 10 * SECONDS; export class Queue { protected running: boolean = false; diff --git a/src/SimpleCache.ts b/src/SimpleCache.ts new file mode 100644 index 00000000..a06d8d12 --- /dev/null +++ b/src/SimpleCache.ts @@ -0,0 +1,60 @@ +import Timeout = NodeJS.Timeout; + +const CLEAN_INTERVAL = 1000; + +export class SimpleCache { + protected readonly retentionTime; + protected cleanTimeout: Timeout; + protected unloaded: boolean; + + protected store: Map; + + constructor(retentionTime) { + this.retentionTime = retentionTime; + this.store = new Map(); + } + + unload() { + this.unloaded = true; + clearTimeout(this.cleanTimeout); + } + + cleanLoop() { + const now = Date.now(); + for (const [key, info] of this.store.entries()) { + if (now >= info.remove_at) { + this.store.delete(key); + } + } + + if (!this.unloaded) { + this.cleanTimeout = setTimeout(() => this.cleanLoop(), CLEAN_INTERVAL); + } + } + + set(key: string, value) { + this.store.set(key, { + remove_at: Date.now() + this.retentionTime, + value, + }); + } + + get(key: string) { + const info = this.store.get(key); + if (!info) return null; + + return info.value; + } + + has(key: string) { + return this.store.has(key); + } + + delete(key: string) { + this.store.delete(key); + } + + clear() { + this.store.clear(); + } +} diff --git a/src/plugins/Automod.ts b/src/plugins/Automod.ts new file mode 100644 index 00000000..8fed54f2 --- /dev/null +++ b/src/plugins/Automod.ts @@ -0,0 +1,808 @@ +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import * as t from "io-ts"; +import { + convertDelayStringToMS, + getEmojiInString, + getInviteCodesInString, + getRoleMentions, + getUrlsInString, + getUserMentions, + MINUTES, + noop, + tNullable, +} from "../utils"; +import { decorators as d } from "knub"; +import { mergeConfig } from "knub/dist/configUtils"; +import { Invite, Member, Message } from "eris"; +import escapeStringRegexp from "escape-string-regexp"; +import { SimpleCache } from "../SimpleCache"; +import { Queue } from "../Queue"; +import Timeout = NodeJS.Timeout; +import { ModActionsPlugin } from "./ModActions"; +import { MutesPlugin } from "./Mutes"; + +type MessageInfo = { channelId: string; messageId: string }; + +type TextTriggerWithMultipleMatchTypes = { + match_messages: boolean; + match_embeds: boolean; + match_usernames: boolean; + match_nicknames: boolean; +}; + +interface TriggerMatchResult { + type: string; +} + +interface MessageTextTriggerMatchResult extends TriggerMatchResult { + type: "message" | "embed"; + str: string; + userId: string; + messageInfo: MessageInfo; +} + +interface OtherTextTriggerMatchResult extends TriggerMatchResult { + type: "username" | "nickname"; + str: string; + userId: string; +} + +type TextTriggerMatchResult = MessageTextTriggerMatchResult | OtherTextTriggerMatchResult; + +interface TextSpamTriggerMatchResult extends TriggerMatchResult { + type: "textspam"; + actionType: RecentActionType; + channelId: string; + userId: string; + messageInfos: MessageInfo[]; +} + +interface RaidSpamTriggerMatchResult extends TriggerMatchResult { + type: "raidspam"; + actionType: RecentActionType; + channelId: string; + userIds: string[]; + messageInfos: MessageInfo[]; +} + +interface OtherSpamTriggerMatchResult extends TriggerMatchResult { + type: "otherspam"; + actionType: RecentActionType; + userIds: string[]; +} + +type AnyTriggerMatchResult = + | TextTriggerMatchResult + | TextSpamTriggerMatchResult + | RaidSpamTriggerMatchResult + | OtherSpamTriggerMatchResult; + +/** + * TRIGGERS + */ + +const MatchWordsTrigger = t.type({ + words: t.array(t.string), + case_sensitive: t.boolean, + only_full_words: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchWordsTrigger = t.TypeOf; +const defaultMatchWordsTrigger: TMatchWordsTrigger = { + words: [], + case_sensitive: false, + only_full_words: true, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const MatchRegexTrigger = t.type({ + patterns: t.array(t.string), + case_sensitive: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchRegexTrigger = t.TypeOf; +const defaultMatchRegexTrigger: Partial = { + case_sensitive: false, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const MatchInvitesTrigger = t.type({ + include_guilds: tNullable(t.array(t.string)), + exclude_guilds: tNullable(t.array(t.string)), + include_invite_codes: tNullable(t.array(t.string)), + exclude_invite_codes: tNullable(t.array(t.string)), + allow_group_dm_invites: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchInvitesTrigger = t.TypeOf; +const defaultMatchInvitesTrigger: Partial = { + allow_group_dm_invites: false, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const MatchLinksTrigger = t.type({ + include_domains: tNullable(t.array(t.string)), + exclude_domains: tNullable(t.array(t.string)), + include_subdomains: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, +}); +type TMatchLinksTrigger = t.TypeOf; +const defaultMatchLinksTrigger: Partial = { + include_subdomains: true, + match_messages: true, + match_embeds: true, + match_usernames: false, + match_nicknames: false, +}; + +const BaseSpamTrigger = t.type({ + amount: t.number, + within: t.string, +}); +const BaseTextSpamTrigger = t.intersection([ + BaseSpamTrigger, + t.type({ + per_channel: t.boolean, + }), +]); +type TBaseTextSpamTrigger = t.TypeOf; +const defaultTextSpamTrigger: Partial> = { + per_channel: true, +}; + +const MaxMessagesTrigger = BaseTextSpamTrigger; +type TMaxMessagesTrigger = t.TypeOf; +const MaxMentionsTrigger = BaseTextSpamTrigger; +type TMaxMentionsTrigger = t.TypeOf; +const MaxLinksTrigger = BaseTextSpamTrigger; +type TMaxLinksTrigger = t.TypeOf; +const MaxAttachmentsTrigger = BaseTextSpamTrigger; +type TMaxAttachmentsTrigger = t.TypeOf; +const MaxEmojisTrigger = BaseTextSpamTrigger; +type TMaxEmojisTrigger = t.TypeOf; +const MaxLinesTrigger = BaseTextSpamTrigger; +type TMaxLinesTrigger = t.TypeOf; +const MaxCharactersTrigger = BaseTextSpamTrigger; +type TMaxCharactersTrigger = t.TypeOf; +const MaxVoiceMovesTrigger = BaseSpamTrigger; +type TMaxVoiceMovesTrigger = t.TypeOf; + +/** + * ACTIONS + */ + +const CleanAction = t.boolean; + +const WarnAction = t.type({ + reason: t.string, +}); + +const MuteAction = t.type({ + duration: t.string, + reason: tNullable(t.string), +}); + +const KickAction = t.type({ + reason: tNullable(t.string), +}); + +const BanAction = t.type({ + reason: tNullable(t.string), +}); + +/** + * FULL CONFIG SCHEMA + */ + +const Rule = t.type({ + enabled: t.boolean, + name: t.string, + presets: tNullable(t.array(t.string)), + triggers: t.array( + t.type({ + match_words: tNullable(MatchWordsTrigger), + match_regex: tNullable(MatchRegexTrigger), + match_invites: tNullable(MatchInvitesTrigger), + match_links: tNullable(MatchLinksTrigger), + max_messages: tNullable(MaxMessagesTrigger), + max_mentions: tNullable(MaxMentionsTrigger), + max_links: tNullable(MaxLinksTrigger), + max_attachments: tNullable(MaxAttachmentsTrigger), + max_emojis: tNullable(MaxEmojisTrigger), + max_lines: tNullable(MaxLinesTrigger), + max_characters: tNullable(MaxCharactersTrigger), + max_voice_moves: tNullable(MaxVoiceMovesTrigger), + // TODO: Duplicates trigger + }), + ), + actions: t.type({ + clean: tNullable(CleanAction), + warn: tNullable(WarnAction), + mute: tNullable(MuteAction), + kick: tNullable(KickAction), + ban: tNullable(BanAction), + // TODO: Alert action + }), +}); +type TRule = t.TypeOf; + +const ConfigSchema = t.type({ + rules: t.record(t.string, Rule), +}); +type TConfigSchema = t.TypeOf; + +/** + * DEFAULTS + */ + +const defaultTriggers = { + match_words: defaultMatchWordsTrigger, +}; + +/** + * MISC + */ + +enum RecentActionType { + Message = 1, + Mention, + Link, + Attachment, + Emoji, + Line, + Character, + VoiceChannelMove, +} + +interface BaseRecentAction { + identifier: string; + timestamp: number; + count: number; +} + +type TextRecentAction = BaseRecentAction & { + type: + | RecentActionType.Message + | RecentActionType.Mention + | RecentActionType.Link + | RecentActionType.Attachment + | RecentActionType.Emoji + | RecentActionType.Line + | RecentActionType.Character; + messageInfo: MessageInfo; +}; + +type OtherRecentAction = BaseRecentAction & { + type: RecentActionType.VoiceChannelMove; +}; + +type RecentAction = TextRecentAction | OtherRecentAction; + +const MAX_SPAM_CHECK_TIMESPAN = 5 * MINUTES; + +const inviteCache = new SimpleCache(10 * MINUTES); + +export class AutomodPlugin extends ZeppelinPlugin { + public static pluginName = "automod"; + protected static configSchema = ConfigSchema; + public static dependencies = ["mod_actions", "mutes"]; + + protected unloaded = false; + + // Handle automod checks/actions in a queue so we don't get overlap on the same user + protected automodQueue: Queue; + + // Recent actions are used to detect "max_*" type of triggers, i.e. spam triggers + protected recentActions: RecentAction[]; + protected recentActionClearInterval: Timeout; + + // After a spam trigger is tripped and the rule's action carried out, a short "grace period" will be placed on the user. + // During this grace period, if the user repeats the same type of recent action that tripped the rule, that message will + // be deleted and no further action will be carried out. This is mainly to account for the delay between the spam message + // being posted and the bot reacting to it, during which the user could keep posting more spam. + protected spamGracePeriods: Array<{ key: string; type: RecentActionType; expiresAt: number }>; + protected spamGracePriodClearInterval: Timeout; + + protected modActions: ModActionsPlugin; + protected mutes: MutesPlugin; + + protected static preprocessStaticConfig(config) { + if (config.rules && typeof config.rules === "object") { + // Loop through each rule + for (const [name, rule] of Object.entries(config.rules)) { + if (rule == null || typeof rule !== "object") continue; + + rule["name"] = name; + + // If the rule doesn't have an explicitly set "enabled" property, set it to true + if (rule["enabled"] == null) { + rule["enabled"] = true; + } + + // Loop through the rule's triggers + if (rule["triggers"] != null && Array.isArray(rule["triggers"])) { + for (const trigger of rule["triggers"]) { + if (trigger == null || typeof trigger !== "object") continue; + // Apply default triggers to the triggers used in this rule + for (const [defaultTriggerName, defaultTrigger] of Object.entries(defaultTriggers)) { + if (trigger[defaultTriggerName] && typeof trigger[defaultTriggerName] === "object") { + trigger[defaultTriggerName] = mergeConfig({}, defaultTrigger, trigger[defaultTriggerName]); + } + } + } + } + } + } + + return config; + } + + protected static getStaticDefaultOptions() { + return { + rules: [], + }; + } + + protected onLoad() { + this.automodQueue = new Queue(); + this.modActions = this.getPlugin("mod_actions"); + } + + protected onUnload() { + this.unloaded = true; + clearInterval(this.recentActionClearInterval); + clearInterval(this.spamGracePriodClearInterval); + } + + protected evaluateMatchWordsTrigger(trigger: TMatchWordsTrigger, str: string): boolean { + for (const word of trigger.words) { + const pattern = trigger.only_full_words ? `\b${escapeStringRegexp(word)}\b` : escapeStringRegexp(word); + + const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); + return regex.test(str); + } + } + + protected evaluateMatchRegexTrigger(trigger: TMatchRegexTrigger, str: string): boolean { + // TODO: Time limit regexes + for (const pattern of trigger.patterns) { + const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); + return regex.test(str); + } + } + + protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise { + const inviteCodes = getInviteCodesInString(str); + if (inviteCodes.length === 0) return false; + + const uniqueInviteCodes = Array.from(new Set(inviteCodes)); + + for (const code of uniqueInviteCodes) { + if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) { + return true; + } + if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) { + return true; + } + } + + const invites: Array = await Promise.all( + uniqueInviteCodes.map(async code => { + if (inviteCache.has(code)) { + return inviteCache.get(code); + } else { + const invite = await this.bot.getInvite(code).catch(noop); + inviteCache.set(code, invite); + return invite; + } + }), + ); + + for (const invite of invites) { + if (!invite) return true; + if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) { + return true; + } + if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) { + return true; + } + } + + return false; + } + + protected evaluateMatchLinksTrigger(trigger: TMatchLinksTrigger, str: string): boolean { + const links = getUrlsInString(str, true); + for (const link of links) { + const normalizedHostname = link.hostname.toLowerCase(); + + if (trigger.include_domains) { + for (const domain of trigger.include_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + return true; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + return true; + } + } + } + + if (trigger.exclude_domains) { + for (const domain of trigger.exclude_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + return false; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + return false; + } + } + + return true; + } + } + + return false; + } + + protected matchTextSpamTrigger( + recentActionType: RecentActionType, + trigger: TBaseTextSpamTrigger, + msg: Message, + ): TextSpamTriggerMatchResult { + const since = msg.timestamp - convertDelayStringToMS(trigger.within); + const recentActions = trigger.per_channel + ? this.getMatchingRecentActions(recentActionType, `${msg.channel.id}-${msg.author.id}`, since) + : this.getMatchingRecentActions(recentActionType, msg.author.id, since); + if (recentActions.length > trigger.amount) { + return { + type: "textspam", + actionType: recentActionType, + channelId: trigger.per_channel ? msg.channel.id : null, + messageInfos: recentActions.map(action => (action as TextRecentAction).messageInfo), + userId: msg.author.id, + }; + } + + return null; + } + + protected async matchMultipleTextTypesOnMessage( + trigger: TextTriggerWithMultipleMatchTypes, + msg: Message, + cb, + ): Promise { + const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id }; + + if (trigger.match_messages) { + const str = msg.content; + const match = await cb(str); + if (match) return { type: "message", str, userId: msg.author.id, messageInfo }; + } + + if (trigger.match_embeds && msg.embeds.length) { + const str = JSON.stringify(msg.embeds[0]); + const match = await cb(str); + if (match) return { type: "embed", str, userId: msg.author.id, messageInfo }; + } + + if (trigger.match_usernames) { + const str = `${msg.author.username}#${msg.author.discriminator}`; + const match = await cb(str); + if (match) return { type: "username", str, userId: msg.author.id }; + } + + if (trigger.match_nicknames && msg.member.nick) { + const str = msg.member.nick; + const match = await cb(str); + if (match) return { type: "nickname", str, userId: msg.author.id }; + } + + return null; + } + + protected async matchMultipleTextTypesOnMember( + trigger: TextTriggerWithMultipleMatchTypes, + member: Member, + cb, + ): Promise { + if (trigger.match_usernames) { + const str = `${member.user.username}#${member.user.discriminator}`; + const match = await cb(str); + if (match) return { type: "username", str, userId: member.id }; + } + + if (trigger.match_nicknames && member.nick) { + const str = member.nick; + const match = await cb(str); + if (match) return { type: "nickname", str, userId: member.id }; + } + + return null; + } + + /** + * Returns whether the triggers in the rule match the given message + */ + protected async matchRuleToMessage( + rule: TRule, + msg: Message, + ): Promise { + for (const trigger of rule.triggers) { + if (trigger.match_words) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => { + return this.evaluateMatchWordsTrigger(trigger.match_words, str); + }); + if (match) return match; + } + + if (trigger.match_regex) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_regex, msg, str => { + return this.evaluateMatchRegexTrigger(trigger.match_regex, str); + }); + if (match) return match; + } + + if (trigger.match_invites) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_invites, msg, str => { + return this.evaluateMatchInvitesTrigger(trigger.match_invites, str); + }); + if (match) return match; + } + + if (trigger.match_links) { + const match = await this.matchMultipleTextTypesOnMessage(trigger.match_links, msg, str => { + return this.evaluateMatchLinksTrigger(trigger.match_links, str); + }); + if (match) return match; + } + + if (trigger.max_messages) { + const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.max_messages, msg); + if (match) return match; + } + + if (trigger.max_mentions) { + const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.max_mentions, msg); + if (match) return match; + } + + if (trigger.max_links) { + const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.max_links, msg); + if (match) return match; + } + + if (trigger.max_attachments) { + const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.max_attachments, msg); + if (match) return match; + } + + if (trigger.max_emojis) { + const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.max_emojis, msg); + if (match) return match; + } + + if (trigger.max_lines) { + const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.max_lines, msg); + if (match) return match; + } + + if (trigger.max_characters) { + const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.max_characters, msg); + if (match) return match; + } + } + + return null; + } + + /** + * Logs recent actions for spam detection purposes + */ + protected async logRecentActionsForMessage(msg: Message) { + const timestamp = msg.timestamp; + const globalIdentifier = msg.author.id; + const perChannelIdentifier = `${msg.channel.id}-${msg.author.id}`; + const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id }; + + this.recentActions.push({ + type: RecentActionType.Message, + identifier: globalIdentifier, + timestamp, + count: 1, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Message, + identifier: perChannelIdentifier, + timestamp, + count: 1, + messageInfo, + }); + + const mentionCount = getUserMentions(msg.content || "").length + getRoleMentions(msg.content || "").length; + if (mentionCount) { + this.recentActions.push({ + type: RecentActionType.Mention, + identifier: globalIdentifier, + timestamp, + count: mentionCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Mention, + identifier: perChannelIdentifier, + timestamp, + count: mentionCount, + messageInfo, + }); + } + + const linkCount = getUrlsInString(msg.content || "").length; + if (linkCount) { + this.recentActions.push({ + type: RecentActionType.Link, + identifier: globalIdentifier, + timestamp, + count: linkCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Link, + identifier: perChannelIdentifier, + timestamp, + count: linkCount, + messageInfo, + }); + } + + const attachmentCount = msg.attachments.length; + if (attachmentCount) { + this.recentActions.push({ + type: RecentActionType.Attachment, + identifier: globalIdentifier, + timestamp, + count: attachmentCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Attachment, + identifier: perChannelIdentifier, + timestamp, + count: attachmentCount, + messageInfo, + }); + } + + const emojiCount = getEmojiInString(msg.content || "").length; + if (emojiCount) { + this.recentActions.push({ + type: RecentActionType.Emoji, + identifier: globalIdentifier, + timestamp, + count: emojiCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Emoji, + identifier: perChannelIdentifier, + timestamp, + count: emojiCount, + messageInfo, + }); + } + + // + 1 is for the first line of the message (which doesn't have a line break) + const lineCount = msg.content ? msg.content.match(/\n/g).length + 1 : 0; + if (lineCount) { + this.recentActions.push({ + type: RecentActionType.Line, + identifier: globalIdentifier, + timestamp, + count: lineCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Line, + identifier: perChannelIdentifier, + timestamp, + count: lineCount, + messageInfo, + }); + } + + const characterCount = [...(msg.content || "")].length; + if (characterCount) { + this.recentActions.push({ + type: RecentActionType.Character, + identifier: globalIdentifier, + timestamp, + count: characterCount, + messageInfo, + }); + this.recentActions.push({ + type: RecentActionType.Character, + identifier: perChannelIdentifier, + timestamp, + count: characterCount, + messageInfo, + }); + } + } + + protected getMatchingRecentActions(type: RecentActionType, identifier: string, since: number) { + return this.recentActions.filter(action => { + return action.type === type && action.identifier === identifier && action.timestamp >= since; + }); + } + + protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) { + if (rule.actions.clean) { + if (matchResult.type === "message" || matchResult.type === "embed") { + await this.bot.deleteMessage(matchResult.messageInfo.channelId, matchResult.messageInfo.messageId).catch(noop); + } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { + for (const { channelId, messageId } of matchResult.messageInfos) { + await this.bot.deleteMessage(channelId, messageId).catch(noop); + } + } + } + + if (rule.actions.mute) { + const duration = rule.actions.mute.duration ? convertDelayStringToMS(rule.actions.mute.duration) : null; + const reason = rule.actions.mute.reason || "Muted automatically"; + const caseArgs = { + modId: this.bot.user.id, + extraNotes: [`Matched automod rule "${rule.name}"`], + }; + + if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { + await this.mutes.muteUser(matchResult.userId, duration, reason, caseArgs); + } else if (matchResult.type === "raidspam") { + for (const userId of matchResult.userIds) { + await this.mutes.muteUser(userId, duration, reason, caseArgs); + } + } + } + + // TODO: Other actions + } + + @d.event("messageCreate") + protected onMessageCreate(msg: Message) { + this.automodQueue.add(async () => { + if (this.unloaded) return; + + await this.logRecentActionsForMessage(msg); + + const config = this.getMatchingConfig({ message: msg }); + for (const [name, rule] of Object.entries(config.rules)) { + const matchResult = await this.matchRuleToMessage(rule, msg); + if (matchResult) { + await this.applyActionsOnMatch(rule, matchResult); + } + } + }); + } +} diff --git a/src/plugins/ZeppelinPlugin.ts b/src/plugins/ZeppelinPlugin.ts index 7848855a..d44c7218 100644 --- a/src/plugins/ZeppelinPlugin.ts +++ b/src/plugins/ZeppelinPlugin.ts @@ -56,6 +56,14 @@ export class ZeppelinPlugin extends Plug return (this.constructor as typeof ZeppelinPlugin).getStaticDefaultOptions() as IPluginOptions; } + /** + * Allows the plugin to preprocess the config before it's validated. + * Useful for e.g. adding default properties to dynamic objects. + */ + protected static preprocessStaticConfig(config: any) { + return config; + } + /** * Merges the given options and default options and decodes them according to the config schema of the plugin (if any). * Throws on any decoding/validation errors. @@ -68,11 +76,13 @@ export class ZeppelinPlugin extends Plug */ protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions { const defaultOptions: any = this.getStaticDefaultOptions(); - const mergedConfig = mergeConfig({}, defaultOptions.config || {}, options.config || {}); + let mergedConfig = mergeConfig({}, defaultOptions.config || {}, options.config || {}); const mergedOverrides = options["=overrides"] ? options["=overrides"] : (options.overrides || []).concat(defaultOptions.overrides || []); + mergedConfig = this.preprocessStaticConfig(mergedConfig); + const decodedConfig = this.configSchema ? decodeAndValidateStrict(this.configSchema, mergedConfig) : mergedConfig; if (decodedConfig instanceof StrictValidationError) { throw decodedConfig; diff --git a/src/utils.ts b/src/utils.ts index 643322c1..eb85f2e4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -175,8 +175,10 @@ export async function findRelevantAuditLogEntry( const urlRegex = /(\S+\.\S+)/g; const protocolRegex = /^[a-z]+:\/\//; -export function getUrlsInString(str: string): url.URL[] { - const matches = str.match(urlRegex) || []; +export function getUrlsInString(str: string, unique = false): url.URL[] { + let matches = str.match(urlRegex).map(m => m[0]) || []; + if (unique) matches = Array.from(new Set(matches)); + return matches.reduce((urls, match) => { if (!protocolRegex.test(match)) { match = `https://${match}`; diff --git a/tslint.json b/tslint.json index f1048f51..22f3c3c9 100644 --- a/tslint.json +++ b/tslint.json @@ -23,6 +23,7 @@ "interface-over-type-literal": false, "interface-name": false, "no-submodule-imports": false, - "no-floating-promises": true + "no-floating-promises": true, + "no-string-literal": false } }