diff --git a/backend/src/plugins/Automod/docs.ts b/backend/src/plugins/Automod/docs.ts index 0ba0ab383..fddd53cc7 100644 --- a/backend/src/plugins/Automod/docs.ts +++ b/backend/src/plugins/Automod/docs.ts @@ -11,12 +11,12 @@ export const automodPluginDocs: ZeppelinPluginDocs = { Allows specifying 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. - + 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: @@ -38,17 +38,17 @@ export const automodPluginDocs: ZeppelinPluginDocs = { 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: @@ -82,10 +82,10 @@ export const automodPluginDocs: ZeppelinPluginDocs = { my_second_filter: enabled: false ~~~ - + ### Custom status alerts This example sends an alert any time a user with a matching custom status sends a message. - + ~~~yml automod: config: @@ -102,5 +102,74 @@ export const automodPluginDocs: ZeppelinPluginDocs = { Bad custom status on user <@!{user.id}>: {matchSummary} ~~~ + + ### Combining multiple triggers + The \`and\` trigger lets you require several triggers to match before the rule fires. + + ~~~yml + automod: + config: + rules: + suspicious_links: + triggers: + - and: + triggers: + - match_words: + words: ['free nitro', 'airdrop'] + - match_links: + include_domains: ['example.com'] + actions: + clean: true + warn: + reason: 'Potential scam link' + ~~~ + + ### Negating a trigger + Use \`not\` to require a trigger *not* to match. This is useful for deny-by-default rules or allowlists. + + ~~~yml + automod: + config: + rules: + only_invite_links: + triggers: + - and: + triggers: + - match_links: + only_real_links: true + include_words: ['http'] # catch any real link + - not: + trigger: + match_invites: + allow_group_dm_invites: false + exclude_invite_codes: [] # matches any invite code + actions: + clean: true + warn: + reason: 'Only Discord invite links are allowed' + ~~~ + + ### Combining \`and\` + \`not\` + This pattern is useful for "allowlists with exceptions" or more fine-grained filtering. + + ~~~yml + automod: + config: + rules: + allow_trusted_links_only: + triggers: + - and: + triggers: + - match_links: + include_domains: ['example.com'] + - not: + trigger: + match_words: + words: ['beta', 'free'] + actions: + clean: true + warn: + reason: 'Only approved links without banned keywords are allowed' + ~~~ `), }; diff --git a/backend/src/plugins/Automod/triggers/and.ts b/backend/src/plugins/Automod/triggers/and.ts new file mode 100644 index 000000000..df8a91cf1 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/and.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; +import { AutomodTriggerBlueprint, AutomodTriggerMatchResult, automodTrigger } from "../helpers.js"; +import type { AutomodContext } from "../types.js"; + +interface AndTriggerMatchPart { + triggerName: string; + triggerConfig: unknown; + matchResult: AutomodTriggerMatchResult; +} + +interface AndTriggerMatchResultExtra { + matches: AndTriggerMatchPart[]; +} + +interface CreateAndTriggerOpts { + getAvailableTriggers: () => Record>; +} + +export function createAndTrigger({ getAvailableTriggers }: CreateAndTriggerOpts) { + const triggerConfigSchema = z.lazy(() => { + const triggers = getAvailableTriggers(); + const schemaShape: Record = {}; + + for (const [triggerName, trigger] of Object.entries(triggers)) { + schemaShape[triggerName] = trigger.configSchema; + } + + return z + .strictObject(schemaShape) + .partial() + .refine((val) => Object.values(val).some((v) => v !== undefined), { + message: "Each sub-trigger must specify at least one trigger", + }); + }); + + return automodTrigger()({ + configSchema: z.object({ + triggers: z.array(triggerConfigSchema).min(1), + }), + + async match({ ruleName, pluginData, context, triggerConfig }) { + const matches: AndTriggerMatchPart[] = []; + + for (const subTriggerItem of triggerConfig.triggers) { + const definedEntries = Object.entries(subTriggerItem).filter(([, v]) => v !== undefined); + if (definedEntries.length < 1) { + return null; + } + + let matchedEntry: AndTriggerMatchPart | null = null; + + for (const [subTriggerName, subTriggerConfig] of definedEntries) { + const subTrigger = getAvailableTriggers()[subTriggerName]; + if (!subTrigger) { + continue; + } + + const subMatch = await subTrigger.match({ + ruleName, + pluginData, + context, + triggerConfig: subTriggerConfig, + }); + + if (subMatch) { + matchedEntry = { + triggerName: subTriggerName, + triggerConfig: subTriggerConfig, + matchResult: subMatch, + }; + break; + } + } + + if (!matchedEntry) { + return null; + } + + matches.push(matchedEntry); + } + + if (matches.length === 0) { + return null; + } + + const extraContexts = matches.flatMap((match) => match.matchResult.extraContexts ?? []); + const silentClean = matches.some((match) => match.matchResult.silentClean); + + return { + extraContexts: extraContexts.length > 0 ? extraContexts : undefined, + silentClean: silentClean || undefined, + extra: { matches }, + }; + }, + + async renderMatchInformation({ ruleName, pluginData, contexts, matchResult }) { + const availableTriggers = getAvailableTriggers(); + + const parts = await Promise.all( + matchResult.extra.matches.map(async (match) => { + const trigger = availableTriggers[match.triggerName]; + if (!trigger) { + return match.triggerName; + } + + const triggerContexts: AutomodContext[] = [contexts[0], ...(match.matchResult.extraContexts ?? [])]; + + return ( + (await trigger.renderMatchInformation({ + ruleName, + pluginData, + contexts: triggerContexts, + triggerConfig: match.triggerConfig as never, + matchResult: match.matchResult, + })) ?? match.triggerName + ); + }), + ); + + if (parts.length === 1) { + return parts[0]; + } + + return `All ${parts.length} triggers matched:\n${parts.map((part) => `- ${part}`).join("\n")}`; + }, + }); +} diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index 0dc9b1b39..75bef5eeb 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -1,4 +1,6 @@ import { AutomodTriggerBlueprint } from "../helpers.js"; +import { createAndTrigger } from "./and.js"; +import { createNotTrigger } from "./not.js"; import { AntiraidLevelTrigger } from "./antiraidLevel.js"; import { AnyMessageTrigger } from "./anyMessage.js"; import { AttachmentSpamTrigger } from "./attachmentSpam.js"; @@ -78,3 +80,11 @@ export const availableTriggers: Record thread_archive: ThreadArchiveTrigger, thread_unarchive: ThreadUnarchiveTrigger, }; + +availableTriggers.and = createAndTrigger({ + getAvailableTriggers: () => availableTriggers, +}); + +availableTriggers.not = createNotTrigger({ + getAvailableTriggers: () => availableTriggers, +}); diff --git a/backend/src/plugins/Automod/triggers/not.ts b/backend/src/plugins/Automod/triggers/not.ts new file mode 100644 index 000000000..7675db72d --- /dev/null +++ b/backend/src/plugins/Automod/triggers/not.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import { AutomodTriggerBlueprint, AutomodTriggerMatchResult, automodTrigger } from "../helpers.js"; + +interface NotTriggerMatchResultExtra { + triggerNames: string[]; +} + +interface CreateNotTriggerOpts { + getAvailableTriggers: () => Record>; +} + +export function createNotTrigger({ getAvailableTriggers }: CreateNotTriggerOpts) { + const subTriggerSchema = z.lazy(() => { + const triggers = getAvailableTriggers(); + const schemaShape: Record = {}; + + for (const [triggerName, trigger] of Object.entries(triggers)) { + schemaShape[triggerName] = trigger.configSchema; + } + + return z + .strictObject(schemaShape) + .partial() + .refine((val) => Object.values(val).some((v) => v !== undefined), { + message: "Not trigger must specify at least one trigger", + }); + }); + + return automodTrigger()({ + configSchema: z.object({ + trigger: subTriggerSchema, + }), + + async match({ ruleName, pluginData, context, triggerConfig }) { + const definedEntries = Object.entries(triggerConfig.trigger).filter(([, v]) => v !== undefined); + if (definedEntries.length < 1) { + return null; + } + + const testedNames: string[] = []; + + for (const [subTriggerName, subTriggerConfig] of definedEntries) { + const subTrigger = getAvailableTriggers()[subTriggerName]; + if (!subTrigger) { + continue; + } + + testedNames.push(subTriggerName); + + const subMatch = await subTrigger.match({ + ruleName, + pluginData, + context, + triggerConfig: subTriggerConfig, + }); + + if (subMatch) { + return null; + } + } + + if (testedNames.length === 0) { + return null; + } + + const result: AutomodTriggerMatchResult = { + extra: { + triggerNames: testedNames, + }, + }; + + return result; + }, + + async renderMatchInformation({ matchResult }) { + const names = matchResult.extra.triggerNames; + if (names.length === 1) { + return `Did not match ${names[0]}`; + } + + return `Did not match any of: ${names.join(", ")}`; + }, + }); +}