From f41d280fab3355fbcd21b240bbc2122a0128fe15 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Fri, 11 Oct 2019 01:59:56 +0300 Subject: [PATCH] Automod work. Add config examples to automod. --- dashboard/src/components/Expandable.vue | 4 + .../src/components/docs/ArgumentTypes.vue | 59 +- dashboard/src/components/docs/Plugin.vue | 15 +- dashboard/src/style/content.pcss | 3 +- dashboard/src/style/dashboard.scss | 78 --- src/data/DefaultLogMessages.json | 3 +- src/data/GuildSavedMessages.ts | 8 + src/data/LogType.ts | 1 + src/plugins/Automod.ts | 587 ++++++++++++++---- src/plugins/Logs.ts | 19 +- src/utils.ts | 20 + 11 files changed, 539 insertions(+), 258 deletions(-) delete mode 100644 dashboard/src/style/dashboard.scss diff --git a/dashboard/src/components/Expandable.vue b/dashboard/src/components/Expandable.vue index 98b2b8b0..c9f6b38b 100644 --- a/dashboard/src/components/Expandable.vue +++ b/dashboard/src/components/Expandable.vue @@ -68,6 +68,10 @@ code:not([class]) { @apply bg-gray-900; } + + .codeblock { + box-shadow: none; + } diff --git a/dashboard/src/components/docs/Plugin.vue b/dashboard/src/components/docs/Plugin.vue index 8ce75909..93b6856f 100644 --- a/dashboard/src/components/docs/Plugin.vue +++ b/dashboard/src/components/docs/Plugin.vue @@ -116,17 +116,12 @@

Config schema

-
-
-

Click to expand

- - - -
-
+ + + +
diff --git a/dashboard/src/style/content.pcss b/dashboard/src/style/content.pcss index ed10a11e..d359c20c 100644 --- a/dashboard/src/style/content.pcss +++ b/dashboard/src/style/content.pcss @@ -14,6 +14,7 @@ } & h3 { + @apply text-xl; @apply font-semibold; @apply mb-1; } @@ -39,7 +40,7 @@ @apply inline-code; } - & .expandable { + & .expandable:not(.wide) { max-width: 600px; } } diff --git a/dashboard/src/style/dashboard.scss b/dashboard/src/style/dashboard.scss deleted file mode 100644 index 0e7e6186..00000000 --- a/dashboard/src/style/dashboard.scss +++ /dev/null @@ -1,78 +0,0 @@ -@import "~buefy/dist/buefy.css"; -@import "~highlight.js/styles/ocean.css"; - -$bulmaswatch-import-font: false; -$family-primary: 'Open Sans', sans-serif; -$list-background-color: transparent; - -$size-1: 2.5rem; -$size-2: 2rem; -$size-3: 1.5rem; -$size-4: 1.25rem; - -@import "~bulmaswatch/superhero/_variables"; - -$tabs-link-color: $grey-light; -$tabs-link-active-color: $grey-lighter; -$tabs-link-active-border-bottom-color: $grey-lighter; - -@import "~bulma/bulma"; -@import "~bulmaswatch/superhero/_overrides"; - - -.init-cloak { - visibility: visible !important; -} - -.z-title { - line-height: 1.125; - - &.is-1 { font-size: $size-1; } - &.is-2 { font-size: $size-2; } - &.is-3 { font-size: $size-3; } - &.is-4 { font-size: $size-4; } - &.is-5 { font-size: $size-5; } - &.is-6 { font-size: $size-6; } -} - -.z-list { - margin-left: 1.5rem; -} - -.z-ul { - list-style: disc; -} - -.mt-1 { margin-top: 1rem; } -.mt-2 { margin-top: 1.5rem; } -.mt-3 { margin-top: 2rem; } - -.mb-1 { margin-bottom: 1rem; } -.mb-2 { margin-bottom: 1.5rem; } -.mb-3 { margin-bottom: 2rem; } - -.codeblock, -.content .codeblock { - border-radius: 3px; - padding: 16px; - max-width: 970px; /* FIXME: temp fix for overflowing code blocks, look into properly later */ -} - -.codeblock .hljs { - background: transparent; - padding: 0; -} - -.menu-label { - &:not(:first-child) { - margin-top: 1.4em; - } - - &:not(:last-child) { - margin-bottom: 0.4em; - } -} - -.menu-list .router-link-active { - text-decoration: underline; -} diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json index 9aeb8081..56300459 100644 --- a/src/data/DefaultLogMessages.json +++ b/src/data/DefaultLogMessages.json @@ -58,5 +58,6 @@ "POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}", "BOT_ALERT": "⚠ {tmplEval(body)}", - "AUTOMOD_ALERT": "{text}" + "AUTOMOD_ALERT": "{text}", + "AUTOMOD_ACTION": "\uD83E\uDD16 Automod rule **{rule}** triggered by {userMention(user)}, actions taken: {actionsTaken}\n{matchSummary}" } diff --git a/src/data/GuildSavedMessages.ts b/src/data/GuildSavedMessages.ts index c1af28bf..c7a498fb 100644 --- a/src/data/GuildSavedMessages.ts +++ b/src/data/GuildSavedMessages.ts @@ -127,6 +127,14 @@ export class GuildSavedMessages extends BaseGuildRepository { return query.getMany(); } + getMultiple(messageIds: string[]): Promise { + return this.messages + .createQueryBuilder() + .where("guild_id = :guild_id", { guild_id: this.guildId }) + .andWhere("id IN (:messageIds)", { messageIds }) + .getMany(); + } + async create(data) { const isPermanent = this.toBePermanent.has(data.id); if (isPermanent) { diff --git a/src/data/LogType.ts b/src/data/LogType.ts index 69a62bda..64df22d8 100644 --- a/src/data/LogType.ts +++ b/src/data/LogType.ts @@ -59,4 +59,5 @@ export enum LogType { BOT_ALERT, AUTOMOD_ALERT, + AUTOMOD_ACTION, } diff --git a/src/plugins/Automod.ts b/src/plugins/Automod.ts index 0ed2dcc2..4a768cf4 100644 --- a/src/plugins/Automod.ts +++ b/src/plugins/Automod.ts @@ -1,4 +1,4 @@ -import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { PluginInfo, trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import * as t from "io-ts"; import { convertDelayStringToMS, @@ -7,8 +7,11 @@ import { getRoleMentions, getUrlsInString, getUserMentions, + messageSummary, MINUTES, noop, + SECONDS, + stripObjectToScalars, tNullable, } from "../utils"; import { decorators as d } from "knub"; @@ -22,12 +25,19 @@ import { ModActionsPlugin } from "./ModActions"; import { MutesPlugin } from "./Mutes"; import { LogsPlugin } from "./Logs"; import { LogType } from "../data/LogType"; +import { TSafeRegex } from "../validatorUtils"; +import { GuildSavedMessages } from "../data/GuildSavedMessages"; +import { GuildArchives } from "../data/GuildArchives"; +import { GuildLogs } from "../data/GuildLogs"; +import { SavedMessage } from "../data/entities/SavedMessage"; +import moment from "moment-timezone"; type MessageInfo = { channelId: string; messageId: string }; type TextTriggerWithMultipleMatchTypes = { match_messages: boolean; match_embeds: boolean; + match_visible_names: boolean; match_usernames: boolean; match_nicknames: boolean; }; @@ -44,7 +54,7 @@ interface MessageTextTriggerMatchResult extends TriggerMatchResult { } interface OtherTextTriggerMatchResult extends TriggerMatchResult { - type: "username" | "nickname"; + type: "username" | "nickname" | "visiblename"; str: string; userId: string; } @@ -89,6 +99,7 @@ const MatchWordsTrigger = t.type({ only_full_words: t.boolean, match_messages: t.boolean, match_embeds: t.boolean, + match_visible_names: t.boolean, match_usernames: t.boolean, match_nicknames: t.boolean, }); @@ -99,15 +110,17 @@ const defaultMatchWordsTrigger: TMatchWordsTrigger = { only_full_words: true, match_messages: true, match_embeds: true, + match_visible_names: false, match_usernames: false, match_nicknames: false, }; const MatchRegexTrigger = t.type({ - patterns: t.array(t.string), + patterns: t.array(TSafeRegex), case_sensitive: t.boolean, match_messages: t.boolean, match_embeds: t.boolean, + match_visible_names: t.boolean, match_usernames: t.boolean, match_nicknames: t.boolean, }); @@ -116,6 +129,7 @@ const defaultMatchRegexTrigger: Partial = { case_sensitive: false, match_messages: true, match_embeds: true, + match_visible_names: false, match_usernames: false, match_nicknames: false, }; @@ -128,6 +142,7 @@ const MatchInvitesTrigger = t.type({ allow_group_dm_invites: t.boolean, match_messages: t.boolean, match_embeds: t.boolean, + match_visible_names: t.boolean, match_usernames: t.boolean, match_nicknames: t.boolean, }); @@ -136,6 +151,7 @@ const defaultMatchInvitesTrigger: Partial = { allow_group_dm_invites: false, match_messages: true, match_embeds: true, + match_visible_names: false, match_usernames: false, match_nicknames: false, }; @@ -146,6 +162,7 @@ const MatchLinksTrigger = t.type({ include_subdomains: t.boolean, match_messages: t.boolean, match_embeds: t.boolean, + match_visible_names: t.boolean, match_usernames: t.boolean, match_nicknames: t.boolean, }); @@ -154,6 +171,7 @@ const defaultMatchLinksTrigger: Partial = { include_subdomains: true, match_messages: true, match_embeds: true, + match_visible_names: false, match_usernames: false, match_nicknames: false, }; @@ -173,22 +191,22 @@ 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; +const MessageSpamTrigger = BaseTextSpamTrigger; +type TMessageSpamTrigger = t.TypeOf; +const MentionSpamTrigger = BaseTextSpamTrigger; +type TMentionSpamTrigger = t.TypeOf; +const LinkSpamTrigger = BaseTextSpamTrigger; +type TLinkSpamTrigger = t.TypeOf; +const AttachmentSpamTrigger = BaseTextSpamTrigger; +type TAttachmentSpamTrigger = t.TypeOf; +const EmojiSpamTrigger = BaseTextSpamTrigger; +type TEmojiSpamTrigger = t.TypeOf; +const LineSpamTrigger = BaseTextSpamTrigger; +type TLineSpamTrigger = t.TypeOf; +const CharacterSpamTrigger = BaseTextSpamTrigger; +type TCharacterSpamTrigger = t.TypeOf; +const VoiceMoveSpamTrigger = BaseSpamTrigger; +type TVoiceMoveSpamTrigger = t.TypeOf; /** * ACTIONS @@ -217,6 +235,10 @@ const AlertAction = t.type({ text: t.string, }); +const ChangeNicknameAction = t.type({ + name: t.string, +}); + /** * FULL CONFIG SCHEMA */ @@ -231,14 +253,14 @@ const Rule = t.type({ 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), + message_spam: tNullable(MessageSpamTrigger), + mention_spam: tNullable(MentionSpamTrigger), + link_spam: tNullable(LinkSpamTrigger), + attachment_spam: tNullable(AttachmentSpamTrigger), + emoji_spam: tNullable(EmojiSpamTrigger), + line_spam: tNullable(LineSpamTrigger), + character_spam: tNullable(CharacterSpamTrigger), + // voice_move_spam: tNullable(VoiceMoveSpamTrigger), // TODO // TODO: Duplicates trigger }), ), @@ -249,6 +271,7 @@ const Rule = t.type({ kick: tNullable(KickAction), ban: tNullable(BanAction), alert: tNullable(AlertAction), + change_nickname: tNullable(ChangeNicknameAction), }), }); type TRule = t.TypeOf; @@ -264,6 +287,16 @@ type TConfigSchema = t.TypeOf; const defaultTriggers = { match_words: defaultMatchWordsTrigger, + match_regex: defaultMatchRegexTrigger, + match_invites: defaultMatchInvitesTrigger, + match_links: defaultMatchLinksTrigger, + message_spam: defaultTextSpamTrigger, + mention_spam: defaultTextSpamTrigger, + link_spam: defaultTextSpamTrigger, + attachment_spam: defaultTextSpamTrigger, + emoji_spam: defaultTextSpamTrigger, + line_spam: defaultTextSpamTrigger, + character_spam: defaultTextSpamTrigger, }; /** @@ -303,9 +336,12 @@ type OtherRecentAction = BaseRecentAction & { type: RecentActionType.VoiceChannelMove; }; -type RecentAction = TextRecentAction | OtherRecentAction; +type RecentAction = (TextRecentAction | OtherRecentAction) & { expiresAt: number }; -const MAX_SPAM_CHECK_TIMESPAN = 5 * MINUTES; +const SPAM_GRACE_PERIOD_LENGTH = 10 * SECONDS; +const RECENT_ACTION_EXPIRY_TIME = 2 * MINUTES; +const MAX_RECENTLY_DELETED_MESSAGES = 10; +const RECENT_NICKNAME_CHANGE_EXPIRY_TIME = 5 * MINUTES; const inviteCache = new SimpleCache(10 * MINUTES); @@ -314,12 +350,95 @@ export class AutomodPlugin extends ZeppelinPlugin { public static configSchema = ConfigSchema; public static dependencies = ["mod_actions", "mutes", "logs"]; + public static pluginInfo: PluginInfo = { + prettyName: "Automod", + description: trimPluginDescription(` + This plugin allows you to specify automated actions in response to triggers. Example use cases include word filtering and spam prevention. + `), + configurationGuide: trimPluginDescription(` + The automod plugin is very customizable. For a full list of available triggers, actions, and their options, see Config schema at the bottom of this page. + + ### Simple word filter + Removes any messages that contain the word 'banana' and sends a warning to the user. + Moderators (level >= 50) are ignored by the filter based on the override. + + ~~~yml + automod: + config: + rules: + my_filter: + triggers: + - match_words: + words: ['banana'] + case_sensitive: false + only_full_words: true + actions: + clean: true + warn: + reason: 'Do not talk about bananas!' + overrides: + - level: '>=50' + config: + rules: + my_filter: + enabled: false + ~~~ + + ### Spam detection + This example includes 2 filters: + + - The first one is triggered if a user sends 5 messages within 10 seconds OR 3 attachments within 60 seconds. + The messages are deleted and the user is muted for 5 minutes. + - The second filter is triggered if a user sends more than 2 emoji within 5 seconds. + The messages are deleted but the user is not muted. + + Moderators are ignored by both filters based on the override. + + ~~~yml + automod: + config: + rules: + my_spam_filter: + triggers: + - message_spam: + amount: 5 + within: 10s + - attachment_spam: + amount: 3 + within: 60s + actions: + clean: true + mute: + duration: 5m + reason: 'Auto-muted for spam' + my_second_filter: + triggers: + - message_spam: + amount: 5 + within: 10s + actions: + clean: true + mute: + duration: 5m + reason: 'Auto-muted for spam' + overrides: + - level: '>=50' + config: + rules: + my_spam_filter: + enabled: false + my_second_filter: + enabled: false + ~~~ + `), + }; + 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 + // Recent actions are used to detect spam triggers protected recentActions: RecentAction[]; protected recentActionClearInterval: Timeout; @@ -327,13 +446,24 @@ export class AutomodPlugin extends ZeppelinPlugin { // 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 spamGracePeriods: Map; // Key = identifier-actionType protected spamGracePriodClearInterval: Timeout; + protected recentlyDeletedMessages: string[]; + + protected recentNicknameChanges: Map; + protected recentNicknameChangesClearInterval: Timeout; + + protected onMessageCreateFn; + protected modActions: ModActionsPlugin; protected mutes: MutesPlugin; protected logs: LogsPlugin; + protected savedMessages: GuildSavedMessages; + protected archives: GuildArchives; + protected guildLogs: GuildLogs; + protected static preprocessStaticConfig(config) { if (config.rules && typeof config.rules === "object") { // Loop through each rule @@ -351,7 +481,7 @@ export class AutomodPlugin extends ZeppelinPlugin { 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 + // Apply default config 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]); @@ -373,31 +503,60 @@ export class AutomodPlugin extends ZeppelinPlugin { protected onLoad() { this.automodQueue = new Queue(); - this.modActions = this.getPlugin("mod_actions"); - this.logs = this.getPlugin("logs"); + + this.recentActions = []; + this.spamGracePeriods = new Map(); + this.spamGracePriodClearInterval = setInterval(() => this.clearExpiredGracePeriods(), 1 * SECONDS); + + this.recentlyDeletedMessages = []; + + this.recentNicknameChanges = new Map(); + this.recentNicknameChangesClearInterval = setInterval(() => this.clearExpiredRecentNicknameChanges(), 30 * SECONDS); + + this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); + this.archives = GuildArchives.getGuildInstance(this.guildId); + this.guildLogs = new GuildLogs(this.guildId); + + this.onMessageCreateFn = msg => this.onMessageCreate(msg); + this.savedMessages.events.on("create", this.onMessageCreateFn); + } + + protected getModActions(): ModActionsPlugin { + return this.getPlugin("mod_actions"); + } + + protected getLogs(): LogsPlugin { + return this.getPlugin("logs"); } protected onUnload() { this.unloaded = true; + this.savedMessages.events.off("create", this.onMessageCreateFn); 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 pattern = trigger.only_full_words ? `\\b${escapeStringRegexp(word)}\\b` : escapeStringRegexp(word); const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); - return regex.test(str); + const test = regex.test(str); + if (test) return true; } + + return false; } 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); + const test = regex.test(str); + if (test) return true; } + + return false; } protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise { @@ -478,19 +637,23 @@ export class AutomodPlugin extends ZeppelinPlugin { protected matchTextSpamTrigger( recentActionType: RecentActionType, trigger: TBaseTextSpamTrigger, - msg: Message, + msg: SavedMessage, ): TextSpamTriggerMatchResult { - const since = msg.timestamp - convertDelayStringToMS(trigger.within); + const since = moment.utc(msg.posted_at).valueOf() - 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) { + ? this.getMatchingRecentActions(recentActionType, `${msg.channel_id}-${msg.user_id}`, since) + : this.getMatchingRecentActions(recentActionType, msg.user_id, since); + const totalCount = recentActions.reduce((total, action) => { + return total + action.count; + }, 0); + + if (totalCount >= trigger.amount) { return { type: "textspam", actionType: recentActionType, - channelId: trigger.per_channel ? msg.channel.id : null, + channelId: trigger.per_channel ? msg.channel_id : null, messageInfos: recentActions.map(action => (action as TextRecentAction).messageInfo), - userId: msg.author.id, + userId: msg.user_id, }; } @@ -499,33 +662,40 @@ export class AutomodPlugin extends ZeppelinPlugin { protected async matchMultipleTextTypesOnMessage( trigger: TextTriggerWithMultipleMatchTypes, - msg: Message, + msg: SavedMessage, cb, ): Promise { - const messageInfo: MessageInfo = { channelId: msg.channel.id, messageId: msg.id }; + const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id }; + const member = this.guild.members.get(msg.user_id); if (trigger.match_messages) { - const str = msg.content; + const str = msg.data.content; const match = await cb(str); - if (match) return { type: "message", str, userId: msg.author.id, messageInfo }; + if (match) return { type: "message", str, userId: msg.user_id, messageInfo }; } - if (trigger.match_embeds && msg.embeds.length) { - const str = JSON.stringify(msg.embeds[0]); + if (trigger.match_embeds && msg.data.embeds && msg.data.embeds.length) { + const str = JSON.stringify(msg.data.embeds[0]); const match = await cb(str); - if (match) return { type: "embed", str, userId: msg.author.id, messageInfo }; + if (match) return { type: "embed", str, userId: msg.user_id, messageInfo }; + } + + if (trigger.match_visible_names) { + const str = member.nick || msg.data.author.username; + const match = await cb(str); + if (match) return { type: "visiblename", str, userId: msg.user_id }; } if (trigger.match_usernames) { - const str = `${msg.author.username}#${msg.author.discriminator}`; + const str = `${msg.data.author.username}#${msg.data.author.discriminator}`; const match = await cb(str); - if (match) return { type: "username", str, userId: msg.author.id }; + if (match) return { type: "username", str, userId: msg.user_id }; } - if (trigger.match_nicknames && msg.member.nick) { - const str = msg.member.nick; + if (trigger.match_nicknames && member.nick) { + const str = member.nick; const match = await cb(str); - if (match) return { type: "nickname", str, userId: msg.author.id }; + if (match) return { type: "nickname", str, userId: msg.user_id }; } return null; @@ -556,8 +726,10 @@ export class AutomodPlugin extends ZeppelinPlugin { */ protected async matchRuleToMessage( rule: TRule, - msg: Message, + msg: SavedMessage, ): Promise { + if (!rule.enabled) return; + for (const trigger of rule.triggers) { if (trigger.match_words) { const match = await this.matchMultipleTextTypesOnMessage(trigger.match_words, msg, str => { @@ -587,38 +759,38 @@ export class AutomodPlugin extends ZeppelinPlugin { if (match) return match; } - if (trigger.max_messages) { - const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.max_messages, msg); + if (trigger.message_spam) { + const match = this.matchTextSpamTrigger(RecentActionType.Message, trigger.message_spam, msg); if (match) return match; } - if (trigger.max_mentions) { - const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.max_mentions, msg); + if (trigger.mention_spam) { + const match = this.matchTextSpamTrigger(RecentActionType.Mention, trigger.mention_spam, msg); if (match) return match; } - if (trigger.max_links) { - const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.max_links, msg); + if (trigger.link_spam) { + const match = this.matchTextSpamTrigger(RecentActionType.Link, trigger.link_spam, msg); if (match) return match; } - if (trigger.max_attachments) { - const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.max_attachments, msg); + if (trigger.attachment_spam) { + const match = this.matchTextSpamTrigger(RecentActionType.Attachment, trigger.attachment_spam, msg); if (match) return match; } - if (trigger.max_emojis) { - const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.max_emojis, msg); + if (trigger.emoji_spam) { + const match = this.matchTextSpamTrigger(RecentActionType.Emoji, trigger.emoji_spam, msg); if (match) return match; } - if (trigger.max_lines) { - const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.max_lines, msg); + if (trigger.line_spam) { + const match = this.matchTextSpamTrigger(RecentActionType.Line, trigger.line_spam, msg); if (match) return match; } - if (trigger.max_characters) { - const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.max_characters, msg); + if (trigger.character_spam) { + const match = this.matchTextSpamTrigger(RecentActionType.Character, trigger.character_spam, msg); if (match) return match; } } @@ -626,23 +798,45 @@ export class AutomodPlugin extends ZeppelinPlugin { return null; } + protected async addRecentMessageAction(action: TextRecentAction) { + const gracePeriodKey = `${action.identifier}-${action.type}`; + if (this.spamGracePeriods.has(gracePeriodKey)) { + // If we're on spam detection grace period, just delete the message + if (!this.recentlyDeletedMessages.includes(action.messageInfo.messageId)) { + this.bot.deleteMessage(action.messageInfo.channelId, action.messageInfo.messageId); + + this.recentlyDeletedMessages.push(action.messageInfo.messageId); + if (this.recentlyDeletedMessages.length > MAX_RECENTLY_DELETED_MESSAGES) { + this.recentlyDeletedMessages.splice(0, this.recentlyDeletedMessages.length - MAX_RECENTLY_DELETED_MESSAGES); + } + } + + return; + } + + this.recentActions.push({ + ...action, + expiresAt: Date.now() + RECENT_ACTION_EXPIRY_TIME, + }); + } + /** * 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 }; + protected async logRecentActionsForMessage(msg: SavedMessage) { + const timestamp = moment.utc(msg.posted_at).valueOf(); + const globalIdentifier = msg.user_id; + const perChannelIdentifier = `${msg.channel_id}-${msg.user_id}`; + const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id }; - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Message, identifier: globalIdentifier, timestamp, count: 1, messageInfo, }); - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Message, identifier: perChannelIdentifier, timestamp, @@ -650,16 +844,17 @@ export class AutomodPlugin extends ZeppelinPlugin { messageInfo, }); - const mentionCount = getUserMentions(msg.content || "").length + getRoleMentions(msg.content || "").length; + const mentionCount = + getUserMentions(msg.data.content || "").length + getRoleMentions(msg.data.content || "").length; if (mentionCount) { - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Mention, identifier: globalIdentifier, timestamp, count: mentionCount, messageInfo, }); - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Mention, identifier: perChannelIdentifier, timestamp, @@ -668,16 +863,16 @@ export class AutomodPlugin extends ZeppelinPlugin { }); } - const linkCount = getUrlsInString(msg.content || "").length; + const linkCount = getUrlsInString(msg.data.content || "").length; if (linkCount) { - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Link, identifier: globalIdentifier, timestamp, count: linkCount, messageInfo, }); - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Link, identifier: perChannelIdentifier, timestamp, @@ -686,16 +881,16 @@ export class AutomodPlugin extends ZeppelinPlugin { }); } - const attachmentCount = msg.attachments.length; + const attachmentCount = msg.data.attachments && msg.data.attachments.length; if (attachmentCount) { - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Attachment, identifier: globalIdentifier, timestamp, count: attachmentCount, messageInfo, }); - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Attachment, identifier: perChannelIdentifier, timestamp, @@ -704,16 +899,16 @@ export class AutomodPlugin extends ZeppelinPlugin { }); } - const emojiCount = getEmojiInString(msg.content || "").length; + const emojiCount = getEmojiInString(msg.data.content || "").length; if (emojiCount) { - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Emoji, identifier: globalIdentifier, timestamp, count: emojiCount, messageInfo, }); - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Emoji, identifier: perChannelIdentifier, timestamp, @@ -723,16 +918,16 @@ export class AutomodPlugin extends ZeppelinPlugin { } // + 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; + const lineCount = msg.data.content ? (msg.data.content.match(/\n/g) || []).length + 1 : 0; if (lineCount) { - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Line, identifier: globalIdentifier, timestamp, count: lineCount, messageInfo, }); - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Line, identifier: perChannelIdentifier, timestamp, @@ -741,16 +936,16 @@ export class AutomodPlugin extends ZeppelinPlugin { }); } - const characterCount = [...(msg.content || "")].length; + const characterCount = [...(msg.data.content || "")].length; if (characterCount) { - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Character, identifier: globalIdentifier, timestamp, count: characterCount, messageInfo, }); - this.recentActions.push({ + this.addRecentMessageAction({ type: RecentActionType.Character, identifier: perChannelIdentifier, timestamp, @@ -766,37 +961,132 @@ export class AutomodPlugin extends ZeppelinPlugin { }); } - 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); - } + protected async activateGracePeriod(matchResult: TextSpamTriggerMatchResult) { + const expiresAt = Date.now() + SPAM_GRACE_PERIOD_LENGTH; + + // Global identifier + this.spamGracePeriods.set(`${matchResult.userId}-${matchResult.actionType}`, { expiresAt, deletedMessages: [] }); + // Per-channel identifier + this.spamGracePeriods.set(`${matchResult.channelId}-${matchResult.userId}-${matchResult.actionType}`, { + expiresAt, + deletedMessages: [], + }); + } + + protected async clearExpiredGracePeriods() { + for (const [key, info] of this.spamGracePeriods.entries()) { + if (info.expiresAt <= Date.now()) { + this.spamGracePeriods.delete(key); } } + } + + protected async clearOldRecentActions() { + this.recentActions = this.recentActions.filter(info => { + return info.expiresAt <= Date.now(); + }); + } + + protected async clearExpiredRecentNicknameChanges() { + for (const [key, info] of this.recentNicknameChanges.entries()) { + if (info.expiresAt <= Date.now()) { + this.recentNicknameChanges.delete(key); + } + } + } + + protected async clearSpecificRecentActions(type: RecentActionType, identifier: string) { + this.recentActions = this.recentActions.filter(info => { + return !(info.type === type && info.identifier === identifier); + }); + } + + protected async applyActionsOnMatch(rule: TRule, matchResult: AnyTriggerMatchResult) { + const actionsTaken = []; + + let matchSummary = null; + let caseExtraNote = null; + + if (matchResult.type === "textspam") { + this.activateGracePeriod(matchResult); + this.clearSpecificRecentActions( + matchResult.actionType, + matchResult.channelId ? `${matchResult.channelId}-${matchResult.userId}` : matchResult.userId, + ); + } + + // Match summary + let matchedMessageIds = []; + if (matchResult.type === "message" || matchResult.type === "embed") { + matchedMessageIds = [matchResult.messageInfo.messageId]; + } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { + matchedMessageIds = matchResult.messageInfos.map(m => m.messageId); + } + + if (matchedMessageIds.length > 1) { + const savedMessages = await this.savedMessages.getMultiple(matchedMessageIds); + const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); + const baseUrl = this.knub.getGlobalConfig().url; + const archiveUrl = this.archives.getUrl(baseUrl, archiveId); + matchSummary = `Deleted messages: <${archiveUrl}>`; + } else if (matchedMessageIds.length === 1) { + const message = await this.savedMessages.find(matchedMessageIds[0]); + matchSummary = `Deleted message:\n${messageSummary(message)}`; + } + + if (matchResult.type === "username") { + matchSummary = `Matched username: ${matchResult.str}`; + } else if (matchResult.type === "nickname") { + matchSummary = `Matched nickname: ${matchResult.str}`; + } else if (matchResult.type === "visiblename") { + matchSummary = `Matched visible name: ${matchResult.str}`; + } + + caseExtraNote = `Matched automod rule "${rule.name}"`; + if (matchSummary) { + caseExtraNote += `\n${matchSummary}`; + } + + // Actions + if (rule.actions.clean) { + const messagesToDelete: Array<{ channelId: string; messageId: string }> = []; + + if (matchResult.type === "message" || matchResult.type === "embed") { + messagesToDelete.push(matchResult.messageInfo); + } else if (matchResult.type === "textspam" || matchResult.type === "raidspam") { + messagesToDelete.push(...matchResult.messageInfos); + } + + for (const { channelId, messageId } of messagesToDelete) { + await this.bot.deleteMessage(channelId, messageId).catch(noop); + } + + actionsTaken.push("clean"); + } if (rule.actions.warn) { - const reason = rule.actions.mute.reason || "Warned automatically"; + const reason = rule.actions.warn.reason || "Warned automatically"; + const caseArgs = { modId: this.bot.user.id, - extraNotes: [`Matched automod rule "${rule.name}"`], + extraNotes: [caseExtraNote], }; if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { const member = await this.getMember(matchResult.userId); if (member) { - await this.modActions.warnMember(member, reason, caseArgs); + await this.getModActions().warnMember(member, reason, caseArgs); } } else if (matchResult.type === "raidspam") { for (const userId of matchResult.userIds) { const member = await this.getMember(userId); if (member) { - await this.modActions.warnMember(member, reason, caseArgs); + await this.getModActions().warnMember(member, reason, caseArgs); } } } + + actionsTaken.push("warn"); } if (rule.actions.mute) { @@ -804,7 +1094,7 @@ export class AutomodPlugin extends ZeppelinPlugin { const reason = rule.actions.mute.reason || "Muted automatically"; const caseArgs = { modId: this.bot.user.id, - extraNotes: [`Matched automod rule "${rule.name}"`], + extraNotes: [caseExtraNote], }; if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { @@ -814,60 +1104,115 @@ export class AutomodPlugin extends ZeppelinPlugin { await this.mutes.muteUser(userId, duration, reason, caseArgs); } } + + actionsTaken.push("mute"); } if (rule.actions.kick) { const reason = rule.actions.kick.reason || "Kicked automatically"; const caseArgs = { modId: this.bot.user.id, - extraNotes: [`Matched automod rule "${rule.name}"`], + extraNotes: [caseExtraNote], }; if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { const member = await this.getMember(matchResult.userId); if (member) { - await this.modActions.kickMember(member, reason, caseArgs); + await this.getModActions().kickMember(member, reason, caseArgs); } } else if (matchResult.type === "raidspam") { for (const userId of matchResult.userIds) { const member = await this.getMember(userId); if (member) { - await this.modActions.kickMember(member, reason, caseArgs); + await this.getModActions().kickMember(member, reason, caseArgs); } } } + + actionsTaken.push("kick"); } if (rule.actions.ban) { const reason = rule.actions.ban.reason || "Banned automatically"; const caseArgs = { modId: this.bot.user.id, - extraNotes: [`Matched automod rule "${rule.name}"`], + extraNotes: [caseExtraNote], }; if (matchResult.type === "message" || matchResult.type === "embed" || matchResult.type === "textspam") { - await this.modActions.banUserId(matchResult.userId, reason, caseArgs); + await this.getModActions().banUserId(matchResult.userId, reason, caseArgs); } else if (matchResult.type === "raidspam") { for (const userId of matchResult.userIds) { - await this.modActions.banUserId(userId, reason, caseArgs); + await this.getModActions().banUserId(userId, reason, caseArgs); } } + + actionsTaken.push("ban"); } - if (rule.actions.alert) { - const text = rule.actions.alert.text; - this.logs.log(LogType.AUTOMOD_ALERT, { text }); + if (rule.actions.change_nickname) { + const userIdsToChange = + matchResult.type === "raidspam" || matchResult.type === "otherspam" + ? matchResult.userIds + : [matchResult.userId]; + + for (const userId of userIdsToChange) { + if (this.recentNicknameChanges.has(userId)) continue; + this.guild + .editMember(userId, { + nick: rule.actions.change_nickname.name, + }) + .catch(() => { + this.getLogs().log(LogType.BOT_ALERT, { + body: `Failed to change the nickname of \`${userId}\``, + }); + }); + this.recentNicknameChanges.set(userId, { expiresAt: RECENT_NICKNAME_CHANGE_EXPIRY_TIME }); + } + + actionsTaken.push("nickname"); + } + + if (rule.actions.alert || matchResult.type !== "raidspam") { + const user = await this.resolveUser((matchResult as any).userId || "0"); + + if (rule.actions.alert) { + const text = rule.actions.alert.text; + this.getLogs().log(LogType.AUTOMOD_ALERT, { + rule: rule.name, + user: stripObjectToScalars(user), + text, + matchSummary, + }); + + actionsTaken.push("alert"); + } + + if (matchResult.type !== "raidspam") { + this.getLogs().log(LogType.AUTOMOD_ACTION, { + rule: rule.name, + user: stripObjectToScalars(user), + actionsTaken: actionsTaken.length ? actionsTaken.join(", ") : "", + matchSummary, + }); + } } } - @d.event("messageCreate") - protected onMessageCreate(msg: Message) { + protected onMessageCreate(msg: SavedMessage) { + if (msg.is_bot) return; + this.automodQueue.add(async () => { if (this.unloaded) return; await this.logRecentActionsForMessage(msg); - const config = this.getMatchingConfig({ message: msg }); + const member = this.guild.members.get(msg.user_id); + const config = this.getMatchingConfig({ + member, + userId: msg.user_id, + channelId: msg.channel_id, + }); for (const [name, rule] of Object.entries(config.rules)) { const matchResult = await this.matchRuleToMessage(rule, msg); if (matchResult) { diff --git a/src/plugins/Logs.ts b/src/plugins/Logs.ts index d2ab4380..4aa1d2dc 100644 --- a/src/plugins/Logs.ts +++ b/src/plugins/Logs.ts @@ -8,6 +8,7 @@ import { disableCodeBlocks, disableLinkPreviews, findRelevantAuditLogEntry, + messageSummary, noop, stripObjectToScalars, UnknownUser, @@ -205,22 +206,8 @@ export class LogsPlugin extends ZeppelinPlugin { return `<#${channel.id}> (**#${channel.name}**, \`${channel.id}\`)`; }, messageSummary: (msg: SavedMessage) => { - // Regular text content - let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "") + "```"; - - // Rich embed - const richEmbed = (msg.data.embeds || []).find(e => (e as Embed).type === "rich"); - if (richEmbed) result += "Embed:```" + disableCodeBlocks(JSON.stringify(richEmbed)) + "```"; - - // Attachments - if (msg.data.attachments) { - result += - "Attachments:\n" + - msg.data.attachments.map((a: Attachment) => disableLinkPreviews(a.url)).join("\n") + - "\n"; - } - - return result; + if (!msg) return ""; + return messageSummary(msg); }, }; diff --git a/src/utils.ts b/src/utils.ts index c306c774..7dae65a7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,7 @@ import { + Attachment, Client, + Embed, EmbedOptions, Emoji, Guild, @@ -23,6 +25,7 @@ const fsp = fs.promises; import https from "https"; import tmp from "tmp"; import { logger, waitForReaction } from "knub"; +import { SavedMessage } from "./data/entities/SavedMessage"; const delayStringMultipliers = { w: 1000 * 60 * 60 * 24 * 7, @@ -772,3 +775,20 @@ export async function confirm(bot: Client, channel: TextableChannel, userId: str msg.delete().catch(noop); return reply && reply.name === "✅"; } + +export function messageSummary(msg: SavedMessage) { + // Regular text content + let result = "```" + (msg.data.content ? disableCodeBlocks(msg.data.content) : "") + "```"; + + // Rich embed + const richEmbed = (msg.data.embeds || []).find(e => (e as Embed).type === "rich"); + if (richEmbed) result += "Embed:```" + disableCodeBlocks(JSON.stringify(richEmbed)) + "```"; + + // Attachments + if (msg.data.attachments) { + result += + "Attachments:\n" + msg.data.attachments.map((a: Attachment) => disableLinkPreviews(a.url)).join("\n") + "\n"; + } + + return result; +}