Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 78 additions & 9 deletions backend/src/plugins/Automod/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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'
~~~
`),
};
127 changes: 127 additions & 0 deletions backend/src/plugins/Automod/triggers/and.ts
Original file line number Diff line number Diff line change
@@ -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<string, AutomodTriggerBlueprint<any, any>>;
}

export function createAndTrigger({ getAvailableTriggers }: CreateAndTriggerOpts) {
const triggerConfigSchema = z.lazy(() => {
const triggers = getAvailableTriggers();
const schemaShape: Record<string, z.ZodTypeAny> = {};

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<AndTriggerMatchResultExtra>()({
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")}`;
},
});
}
10 changes: 10 additions & 0 deletions backend/src/plugins/Automod/triggers/availableTriggers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -78,3 +80,11 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
thread_archive: ThreadArchiveTrigger,
thread_unarchive: ThreadUnarchiveTrigger,
};

availableTriggers.and = createAndTrigger({
getAvailableTriggers: () => availableTriggers,
});

availableTriggers.not = createNotTrigger({
getAvailableTriggers: () => availableTriggers,
});
84 changes: 84 additions & 0 deletions backend/src/plugins/Automod/triggers/not.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { z } from "zod";
import { AutomodTriggerBlueprint, AutomodTriggerMatchResult, automodTrigger } from "../helpers.js";

interface NotTriggerMatchResultExtra {
triggerNames: string[];
}

interface CreateNotTriggerOpts {
getAvailableTriggers: () => Record<string, AutomodTriggerBlueprint<any, any>>;
}

export function createNotTrigger({ getAvailableTriggers }: CreateNotTriggerOpts) {
const subTriggerSchema = z.lazy(() => {
const triggers = getAvailableTriggers();
const schemaShape: Record<string, z.ZodTypeAny> = {};

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<NotTriggerMatchResultExtra>()({
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<NotTriggerMatchResultExtra> = {
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(", ")}`;
},
});
}
Loading