From eb273958a20f6c65af654d2e436a7006c390a5b9 Mon Sep 17 00:00:00 2001 From: seeyebe Date: Tue, 25 Nov 2025 15:29:09 +0200 Subject: [PATCH 1/3] feat: Add automod and/not triggers with implicit OR blocks --- backend/src/plugins/Automod/docs.ts | 58 ++++++-- backend/src/plugins/Automod/triggers/and.ts | 127 ++++++++++++++++++ .../Automod/triggers/availableTriggers.ts | 10 ++ backend/src/plugins/Automod/triggers/not.ts | 70 ++++++++++ 4 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 backend/src/plugins/Automod/triggers/and.ts create mode 100644 backend/src/plugins/Automod/triggers/not.ts diff --git a/backend/src/plugins/Automod/docs.ts b/backend/src/plugins/Automod/docs.ts index 0ba0ab383..b77c39130 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: @@ -101,6 +101,46 @@ export const automodPluginDocs: ZeppelinPluginDocs = { text: |- 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. + + ~~~yml + automod: + config: + rules: + links_except_invites: + triggers: + - not: + trigger: + match_invites: + include_domains: ['discord.gg', 'discord.com'] + - match_links: + include_domains: ['example.com'] + actions: + clean: true + warn: + reason: 'Non-invite links are not 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..9de82b6b9 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/not.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; +import { AutomodTriggerBlueprint, AutomodTriggerMatchResult, automodTrigger } from "../helpers.js"; + +interface NotTriggerMatchResultExtra { + triggerName: 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).filter((v) => v !== undefined).length === 1, { + message: "Not trigger must specify exactly 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 [subTriggerName, subTriggerConfig] = definedEntries[0]!; + const subTrigger = getAvailableTriggers()[subTriggerName]; + if (!subTrigger) { + return null; + } + + const subMatch = await subTrigger.match({ + ruleName, + pluginData, + context, + triggerConfig: subTriggerConfig, + }); + + if (subMatch) { + return null; + } + + const result: AutomodTriggerMatchResult = { + extra: { + triggerName: subTriggerName, + }, + }; + + return result; + }, + + async renderMatchInformation({ matchResult }) { + return `Did not match ${matchResult.extra.triggerName}`; + }, + }); +} From bbc06c88a9de2e88509b758ad9c93c6477cef80e Mon Sep 17 00:00:00 2001 From: seeyebe Date: Tue, 25 Nov 2025 16:08:18 +0200 Subject: [PATCH 2/3] docs: add AND/NOT automod examples and fix formatting --- backend/src/plugins/Automod/docs.ts | 44 +++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/backend/src/plugins/Automod/docs.ts b/backend/src/plugins/Automod/docs.ts index b77c39130..a73c1bb77 100644 --- a/backend/src/plugins/Automod/docs.ts +++ b/backend/src/plugins/Automod/docs.ts @@ -101,6 +101,7 @@ export const automodPluginDocs: ZeppelinPluginDocs = { text: |- 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. @@ -121,26 +122,51 @@ export const automodPluginDocs: ZeppelinPluginDocs = { clean: true warn: reason: 'Potential scam link' + ~~~ ### Negating a trigger - Use \`not\` to require a trigger *not* to match. + Use \`not\` to require a trigger *not* to match. This is useful for deny-by-default rules or allowlists. ~~~yml automod: config: rules: - links_except_invites: + only_invite_links: triggers: - - not: - trigger: - match_invites: - include_domains: ['discord.gg', 'discord.com'] - - match_links: - include_domains: ['example.com'] + - and: + triggers: + - match_links: {} + - not: + trigger: + match_invites: + allow_group_dm_invites: false + 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: 'Non-invite links are not allowed' + reason: 'Only approved links without banned keywords are allowed' ~~~ `), }; From 029418425a3daaa9bd994195013175625982b163 Mon Sep 17 00:00:00 2001 From: seeyebe Date: Tue, 25 Nov 2025 16:52:18 +0200 Subject: [PATCH 3/3] fix: automod not trigger flexibility and update docs examples. --- backend/src/plugins/Automod/docs.ts | 5 ++- backend/src/plugins/Automod/triggers/not.ts | 50 +++++++++++++-------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/backend/src/plugins/Automod/docs.ts b/backend/src/plugins/Automod/docs.ts index a73c1bb77..fddd53cc7 100644 --- a/backend/src/plugins/Automod/docs.ts +++ b/backend/src/plugins/Automod/docs.ts @@ -135,11 +135,14 @@ export const automodPluginDocs: ZeppelinPluginDocs = { triggers: - and: triggers: - - match_links: {} + - 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: diff --git a/backend/src/plugins/Automod/triggers/not.ts b/backend/src/plugins/Automod/triggers/not.ts index 9de82b6b9..7675db72d 100644 --- a/backend/src/plugins/Automod/triggers/not.ts +++ b/backend/src/plugins/Automod/triggers/not.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { AutomodTriggerBlueprint, AutomodTriggerMatchResult, automodTrigger } from "../helpers.js"; interface NotTriggerMatchResultExtra { - triggerName: string; + triggerNames: string[]; } interface CreateNotTriggerOpts { @@ -21,8 +21,8 @@ export function createNotTrigger({ getAvailableTriggers }: CreateNotTriggerOpts) return z .strictObject(schemaShape) .partial() - .refine((val) => Object.values(val).filter((v) => v !== undefined).length === 1, { - message: "Not trigger must specify exactly one trigger", + .refine((val) => Object.values(val).some((v) => v !== undefined), { + message: "Not trigger must specify at least one trigger", }); }); @@ -33,30 +33,39 @@ export function createNotTrigger({ getAvailableTriggers }: CreateNotTriggerOpts) async match({ ruleName, pluginData, context, triggerConfig }) { const definedEntries = Object.entries(triggerConfig.trigger).filter(([, v]) => v !== undefined); - if (definedEntries.length !== 1) { + if (definedEntries.length < 1) { return null; } - const [subTriggerName, subTriggerConfig] = definedEntries[0]!; - const subTrigger = getAvailableTriggers()[subTriggerName]; - if (!subTrigger) { - return null; - } + const testedNames: string[] = []; - const subMatch = await subTrigger.match({ - ruleName, - pluginData, - context, - triggerConfig: subTriggerConfig, - }); + 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) { + if (subMatch) { + return null; + } + } + + if (testedNames.length === 0) { return null; } const result: AutomodTriggerMatchResult = { extra: { - triggerName: subTriggerName, + triggerNames: testedNames, }, }; @@ -64,7 +73,12 @@ export function createNotTrigger({ getAvailableTriggers }: CreateNotTriggerOpts) }, async renderMatchInformation({ matchResult }) { - return `Did not match ${matchResult.extra.triggerName}`; + const names = matchResult.extra.triggerNames; + if (names.length === 1) { + return `Did not match ${names[0]}`; + } + + return `Did not match any of: ${names.join(", ")}`; }, }); }