diff --git a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts index 7c5ca32e..557736f6 100644 --- a/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts +++ b/backend/src/plugins/Automod/functions/createMessageSpamTrigger.ts @@ -14,18 +14,15 @@ const MessageSpamTriggerConfig = t.type({ }); type TMessageSpamTriggerConfig = t.TypeOf; -const MessageSpamMatchResultType = t.type({ - archiveId: t.string, -}); -type TMessageSpamMatchResultType = t.TypeOf; +interface TMessageSpamMatchResultType { + archiveId: string; +} export function createMessageSpamTrigger(spamType: RecentActionType, prettyName: string) { - return automodTrigger({ + return automodTrigger()({ configType: MessageSpamTriggerConfig, defaultConfig: {}, - matchResultType: MessageSpamMatchResultType, - async match({ pluginData, context, triggerConfig }) { if (!context.message) { return; diff --git a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts index 176bfa23..3ac81fee 100644 --- a/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts +++ b/backend/src/plugins/Automod/functions/matchMultipleTextTypesOnMessage.ts @@ -13,18 +13,9 @@ type TextTriggerWithMultipleMatchTypes = { match_custom_status: boolean; }; -export const MatchableTextType = t.union([ - t.literal("message"), - t.literal("embed"), - t.literal("visiblename"), - t.literal("username"), - t.literal("nickname"), - t.literal("customstatus"), -]); +export type MatchableTextType = "message" | "embed" | "visiblename" | "username" | "nickname" | "customstatus"; -export type TMatchableTextType = t.TypeOf; - -type YieldedContent = [TMatchableTextType, string]; +type YieldedContent = [MatchableTextType, string]; /** * Generator function that allows iterating through matchable pieces of text of a SavedMessage diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts index f4951160..41ae909a 100644 --- a/backend/src/plugins/Automod/functions/runAutomod.ts +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -7,6 +7,7 @@ import { CleanAction } from "../actions/clean"; export async function runAutomod(pluginData: PluginData, context: AutomodContext) { const userId = context.user?.id || context.message?.user_id; + const user = userId && pluginData.client.users.get(userId); const member = userId && pluginData.guild.members.get(userId); const channelId = context.message?.channel_id; const channel = channelId && pluginData.guild.channels.get(channelId); @@ -21,6 +22,7 @@ export async function runAutomod(pluginData: PluginData, cont for (const [ruleName, rule] of Object.entries(config.rules)) { if (rule.enabled === false) continue; + if (!rule.affects_bots && user.bot) continue; let matchResult: AutomodTriggerMatchResult; let matchSummary: string; diff --git a/backend/src/plugins/Automod/helpers.ts b/backend/src/plugins/Automod/helpers.ts index b2c020d5..ebec79c2 100644 --- a/backend/src/plugins/Automod/helpers.ts +++ b/backend/src/plugins/Automod/helpers.ts @@ -25,20 +25,28 @@ type AutomodTriggerRenderMatchInformationFn = (m matchResult: AutomodTriggerMatchResult; }) => Awaitable; -export interface AutomodTriggerBlueprint { +export interface AutomodTriggerBlueprint { configType: TConfigType; defaultConfig: Partial>; - matchResultType: TMatchResultExtra; - - match: AutomodTriggerMatchFn, t.TypeOf>; - renderMatchInformation: AutomodTriggerRenderMatchInformationFn, t.TypeOf>; + match: AutomodTriggerMatchFn, TMatchResultExtra>; + renderMatchInformation: AutomodTriggerRenderMatchInformationFn, TMatchResultExtra>; } -export function automodTrigger( +export function automodTrigger(): ( blueprint: AutomodTriggerBlueprint, -): AutomodTriggerBlueprint { - return blueprint; +) => AutomodTriggerBlueprint; + +export function automodTrigger( + blueprint: AutomodTriggerBlueprint, +): AutomodTriggerBlueprint; + +export function automodTrigger(...args) { + if (args.length) { + return args[0]; + } else { + return automodTrigger; + } } type AutomodActionApplyFn = (meta: { diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index 31c2ef83..6f858ed6 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -9,10 +9,16 @@ import { EmojiSpamTrigger } from "./emojiSpam"; import { LineSpamTrigger } from "./lineSpam"; import { CharacterSpamTrigger } from "./characterSpam"; import { MatchRegexTrigger } from "./matchRegex"; +import { MatchInvitesTrigger } from "./matchInvites"; +import { MatchLinksTrigger } from "./matchLinks"; +import { MatchAttachmentTypeTrigger } from "./matchAttachmentType"; export const availableTriggers: Record> = { match_words: MatchWordsTrigger, match_regex: MatchRegexTrigger, + match_invites: MatchInvitesTrigger, + match_links: MatchLinksTrigger, + match_attachment_type: MatchAttachmentTypeTrigger, message_spam: MessageSpamTrigger, mention_spam: MentionSpamTrigger, @@ -26,6 +32,9 @@ export const availableTriggers: Record export const AvailableTriggers = t.type({ match_words: MatchWordsTrigger.configType, match_regex: MatchRegexTrigger.configType, + match_invites: MatchInvitesTrigger.configType, + match_links: MatchLinksTrigger.configType, + match_attachment_type: MatchAttachmentTypeTrigger.configType, message_spam: MessageSpamTrigger.configType, mention_spam: MentionSpamTrigger.configType, diff --git a/backend/src/plugins/Automod/triggers/exampleTrigger.ts b/backend/src/plugins/Automod/triggers/exampleTrigger.ts index 56770f72..7098e713 100644 --- a/backend/src/plugins/Automod/triggers/exampleTrigger.ts +++ b/backend/src/plugins/Automod/triggers/exampleTrigger.ts @@ -1,27 +1,31 @@ import * as t from "io-ts"; import { automodTrigger } from "../helpers"; -export const ExampleTrigger = automodTrigger({ +interface ExampleMatchResultType { + isBanana: boolean; +} + +export const ExampleTrigger = automodTrigger()({ configType: t.type({ - some: t.number, - value: t.string, + allowedFruits: t.array(t.string), }), - defaultConfig: {}, - - matchResultType: t.type({ - thing: t.string, - }), - - async match() { - return { - extra: { - thing: "hi", - }, - }; + defaultConfig: { + allowedFruits: ["peach", "banana"], }, - renderMatchInformation() { - return ""; + async match({ triggerConfig, context }) { + const foundFruit = triggerConfig.allowedFruits.find(fruit => context.message?.data.content === fruit); + if (foundFruit) { + return { + extra: { + isBanana: foundFruit === "banana", + }, + }; + } + }, + + renderMatchInformation({ matchResult }) { + return `Matched fruit, isBanana: ${matchResult.extra.isBanana ? "yes" : "no"}`; }, }); diff --git a/backend/src/plugins/Automod/triggers/matchAttachmentType.ts b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts new file mode 100644 index 00000000..68f96fcf --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchAttachmentType.ts @@ -0,0 +1,86 @@ +import * as t from "io-ts"; +import { transliterate } from "transliteration"; +import escapeStringRegexp from "escape-string-regexp"; +import { AnyInvite, Attachment, GuildInvite } from "eris"; +import { automodTrigger } from "../helpers"; +import { + asSingleLine, + disableCodeBlocks, + disableInlineCode, + getInviteCodesInString, + isGuildInvite, + resolveInvite, + tNullable, + verboseChannelMention, +} from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; + +interface MatchResultType { + matchedType: string; + mode: "blacklist" | "whitelist"; +} + +export const MatchAttachmentTypeTrigger = automodTrigger()({ + configType: t.type({ + filetype_blacklist: t.array(t.string), + blacklist_enabled: t.boolean, + filetype_whitelist: t.array(t.string), + whitelist_enabled: t.boolean, + }), + + defaultConfig: { + filetype_blacklist: [], + blacklist_enabled: false, + filetype_whitelist: [], + whitelist_enabled: false, + }, + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + if (!context.message.data.attachments) return null; + const attachments: any[] = context.message.data.attachments; + + for (const attachment of attachments) { + const attachmentType = attachment.filename.split(`.`).pop(); + + if (trigger.blacklist_enabled && trigger.filetype_blacklist.includes(attachmentType)) { + return { + extra: { + matchedType: attachmentType, + mode: "blacklist", + }, + }; + } + + if (trigger.whitelist_enabled && !trigger.filetype_whitelist.includes(attachmentType)) { + return { + extra: { + matchedType: attachmentType, + mode: "whitelist", + }, + }; + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + return ( + asSingleLine(` + Matched attachment type \`${disableInlineCode(matchResult.extra.matchedType)}\` + (${matchResult.extra.mode === "blacklist" ? "(blacklisted)" : "(not in whitelist)"}) + in message (\`${contexts[0].message.id}\`) in ${prettyChannel}: + `) + + "\n```" + + disableCodeBlocks(contexts[0].message.data.content) + + "```" + ); + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchInvites.ts b/backend/src/plugins/Automod/triggers/matchInvites.ts new file mode 100644 index 00000000..5617b58e --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchInvites.ts @@ -0,0 +1,105 @@ +import * as t from "io-ts"; +import { transliterate } from "transliteration"; +import escapeStringRegexp from "escape-string-regexp"; +import { AnyInvite, GuildInvite } from "eris"; +import { automodTrigger } from "../helpers"; +import { + asSingleLine, + disableCodeBlocks, + disableInlineCode, + getInviteCodesInString, + isGuildInvite, + resolveInvite, + tNullable, + verboseChannelMention, +} from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; + +interface MatchResultType { + type: MatchableTextType; + code: string; + invite?: GuildInvite; +} + +export const MatchInvitesTrigger = automodTrigger()({ + configType: 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_visible_names: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, + match_custom_status: t.boolean, + }), + + defaultConfig: { + allow_group_dm_invites: false, + match_messages: true, + match_embeds: true, + match_visible_names: false, + match_usernames: false, + match_nicknames: false, + match_custom_status: false, + }, + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { + const inviteCodes = getInviteCodesInString(str); + if (inviteCodes.length === 0) return null; + + const uniqueInviteCodes = Array.from(new Set(inviteCodes)); + + for (const code of uniqueInviteCodes) { + if (trigger.include_invite_codes && trigger.include_invite_codes.includes(code)) { + return { extra: { type, code } }; + } + if (trigger.exclude_invite_codes && !trigger.exclude_invite_codes.includes(code)) { + return { extra: { type, code } }; + } + } + + for (const code of uniqueInviteCodes) { + const invite = await resolveInvite(pluginData.client, code); + if (!invite || !isGuildInvite(invite)) return { code }; + + if (trigger.include_guilds && trigger.include_guilds.includes(invite.guild.id)) { + return { extra: { type, code, invite } }; + } + if (trigger.exclude_guilds && !trigger.exclude_guilds.includes(invite.guild.id)) { + return { extra: { type, code, invite } }; + } + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + let matchedText; + + if (matchResult.extra.invite) { + const invite = matchResult.extra.invite as GuildInvite; + matchedText = `invite code \`${matchResult.extra.code}\` (**${invite.guild.name}**, \`${invite.guild.id}\`)`; + } else { + matchedText = `invite code \`${matchResult.extra.code}\``; + } + + return ( + `${matchedText} in message (\`${contexts[0].message.id}\`) in ${prettyChannel}:\n` + + "```" + + disableCodeBlocks(contexts[0].message.data.content) + + "```" + ); + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchLinks.ts b/backend/src/plugins/Automod/triggers/matchLinks.ts new file mode 100644 index 00000000..c18ad190 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/matchLinks.ts @@ -0,0 +1,152 @@ +import * as t from "io-ts"; +import { transliterate } from "transliteration"; +import escapeStringRegexp from "escape-string-regexp"; +import { AnyInvite, GuildInvite } from "eris"; +import { automodTrigger } from "../helpers"; +import { + asSingleLine, + disableCodeBlocks, + disableInlineCode, + getInviteCodesInString, + getUrlsInString, + isGuildInvite, + resolveInvite, + tNullable, + verboseChannelMention, +} from "../../../utils"; +import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; +import { TSafeRegex } from "../../../validatorUtils"; + +interface MatchResultType { + type: MatchableTextType; + link: string; +} + +export const MatchLinksTrigger = automodTrigger()({ + configType: t.type({ + include_domains: tNullable(t.array(t.string)), + exclude_domains: tNullable(t.array(t.string)), + include_subdomains: t.boolean, + include_words: tNullable(t.array(t.string)), + exclude_words: tNullable(t.array(t.string)), + include_regex: tNullable(t.array(TSafeRegex)), + exclude_regex: tNullable(t.array(TSafeRegex)), + only_real_links: t.boolean, + match_messages: t.boolean, + match_embeds: t.boolean, + match_visible_names: t.boolean, + match_usernames: t.boolean, + match_nicknames: t.boolean, + match_custom_status: t.boolean, + }), + + defaultConfig: { + include_subdomains: true, + match_messages: true, + match_embeds: true, + match_visible_names: false, + match_usernames: false, + match_nicknames: false, + match_custom_status: false, + only_real_links: true, + }, + + async match({ pluginData, context, triggerConfig: trigger }) { + if (!context.message) { + return; + } + + typeLoop: for await (const [type, str] of matchMultipleTextTypesOnMessage(pluginData, trigger, context.message)) { + const links = getUrlsInString(str, true); + + for (const link of links) { + // "real link" = a link that Discord highlights + if (trigger.only_real_links && !link.input.match(/^https?:\/\//i)) { + continue; + } + + const normalizedHostname = link.hostname.toLowerCase(); + + // Exclude > Include + // In order of specificity, regex > word > domain + + if (trigger.exclude_regex) { + for (const pattern of trigger.exclude_regex) { + if (pattern.test(link.input)) { + continue typeLoop; + } + } + } + + if (trigger.include_regex) { + for (const pattern of trigger.include_regex) { + if (pattern.test(link.input)) { + return { extra: { type, link: link.input } }; + } + } + } + + if (trigger.exclude_words) { + for (const word of trigger.exclude_words) { + const regex = new RegExp(escapeStringRegexp(word), "i"); + if (regex.test(link.input)) { + continue typeLoop; + } + } + } + + if (trigger.include_words) { + for (const word of trigger.include_words) { + const regex = new RegExp(escapeStringRegexp(word), "i"); + if (regex.test(link.input)) { + return { extra: { type, link: link.input } }; + } + } + } + + if (trigger.exclude_domains) { + for (const domain of trigger.exclude_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + continue typeLoop; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + continue typeLoop; + } + } + + return { extra: { type, link: link.toString() } }; + } + + if (trigger.include_domains) { + for (const domain of trigger.include_domains) { + const normalizedDomain = domain.toLowerCase(); + if (normalizedDomain === normalizedHostname) { + return { extra: { type, link: domain } }; + } + if (trigger.include_subdomains && normalizedHostname.endsWith(`.${domain}`)) { + return { extra: { type, link: domain } }; + } + } + } + } + } + + return null; + }, + + renderMatchInformation({ pluginData, contexts, matchResult }) { + const channel = pluginData.guild.channels.get(contexts[0].message.channel_id); + const prettyChannel = verboseChannelMention(channel); + + return ( + asSingleLine(` + Matched link \`${disableInlineCode(matchResult.extra.link)}\` + in message (\`${contexts[0].message.id}\`) in ${prettyChannel}: + `) + + "\n```" + + disableCodeBlocks(contexts[0].message.data.content) + + "```" + ); + }, +}); diff --git a/backend/src/plugins/Automod/triggers/matchRegex.ts b/backend/src/plugins/Automod/triggers/matchRegex.ts index 20dc7f60..f76457ee 100644 --- a/backend/src/plugins/Automod/triggers/matchRegex.ts +++ b/backend/src/plugins/Automod/triggers/matchRegex.ts @@ -4,10 +4,16 @@ import escapeStringRegexp from "escape-string-regexp"; import { automodTrigger } from "../helpers"; import { disableInlineCode, verboseChannelMention } from "../../../utils"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; +import { TSafeRegex } from "../../../validatorUtils"; -export const MatchRegexTrigger = automodTrigger({ +interface MatchResultType { + pattern: string; + type: MatchableTextType; +} + +export const MatchRegexTrigger = automodTrigger()({ configType: t.type({ - patterns: t.array(t.string), + patterns: t.array(TSafeRegex), case_sensitive: t.boolean, normalize: t.boolean, match_messages: t.boolean, @@ -29,11 +35,6 @@ export const MatchRegexTrigger = automodTrigger({ match_custom_status: false, }, - matchResultType: t.type({ - pattern: t.string, - type: MatchableTextType, - }), - async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { return; @@ -44,13 +45,13 @@ export const MatchRegexTrigger = automodTrigger({ str = transliterate(str); } - for (const pattern of trigger.patterns) { - const regex = new RegExp(pattern, trigger.case_sensitive ? "" : "i"); + for (const sourceRegex of trigger.patterns) { + const regex = new RegExp(sourceRegex.source, trigger.case_sensitive ? "" : "i"); const test = regex.test(str); if (test) { return { extra: { - pattern, + pattern: sourceRegex.source, type, }, }; diff --git a/backend/src/plugins/Automod/triggers/matchWords.ts b/backend/src/plugins/Automod/triggers/matchWords.ts index 97390544..581d3362 100644 --- a/backend/src/plugins/Automod/triggers/matchWords.ts +++ b/backend/src/plugins/Automod/triggers/matchWords.ts @@ -5,7 +5,12 @@ import { automodTrigger } from "../helpers"; import { disableInlineCode, verboseChannelMention } from "../../../utils"; import { MatchableTextType, matchMultipleTextTypesOnMessage } from "../functions/matchMultipleTextTypesOnMessage"; -export const MatchWordsTrigger = automodTrigger({ +interface MatchResultType { + word: string; + type: MatchableTextType; +} + +export const MatchWordsTrigger = automodTrigger()({ configType: t.type({ words: t.array(t.string), case_sensitive: t.boolean, @@ -35,11 +40,6 @@ export const MatchWordsTrigger = automodTrigger({ match_custom_status: false, }, - matchResultType: t.type({ - word: t.string, - type: MatchableTextType, - }), - async match({ pluginData, context, triggerConfig: trigger }) { if (!context.message) { return; diff --git a/backend/src/utils.ts b/backend/src/utils.ts index a58f751b..2f45fb7f 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -1,4 +1,5 @@ import { + AnyInvite, Attachment, ChannelInvite, Client, @@ -9,6 +10,7 @@ import { GuildAuditLog, GuildAuditLogEntry, GuildChannel, + GuildInvite, Member, Message, MessageContent, @@ -1216,3 +1218,7 @@ export function trimPluginDescription(str) { export function isFullMessage(msg: PossiblyUncachedMessage): msg is Message { return (msg as Message).createdAt != null; } + +export function isGuildInvite(invite: AnyInvite): invite is GuildInvite { + return (invite as GuildInvite).guild != null; +}