From ccd8506c815e93c7064afa8230f61add7f62bf9b Mon Sep 17 00:00:00 2001 From: Skillz4Killz <23035000+Skillz4Killz@users.noreply.github.com> Date: Wed, 15 Jun 2022 11:25:53 -0400 Subject: [PATCH] Auto mod blame wolf (#2267) * feat(meister): add automod * fix(test); rest error with code * fix(rest): log error nicely * fix(test): disable benchmark test until gateway rewrite * fix(automod): enum should start with 1 * fix(helper): better undefined handling of metadata * fix(transfomrers): automod transformers * fix(tests): add some automod tests * Update types/shared.ts Co-authored-by: meister03 <69507874+meister03@users.noreply.github.com> * fix: changes discord made recently * fix(fmt): i hate deno fmt but i love itoh Co-authored-by: meister03 <69507874+meister03@users.noreply.github.com> Co-authored-by: LTS20050703 <87189679+lts20050703@users.noreply.github.com> --- bot.ts | 19 ++ .../AUTO_MODERATION_ACTION_EXECUTION.ts | 8 + .../automod/AUTO_MODERATION_RULE_CREATE.ts | 8 + .../automod/AUTO_MODERATION_RULE_DELETE.ts | 8 + .../automod/AUTO_MODERATION_RULE_UPDATE.ts | 8 + handlers/guilds/automod/mod.ts | 0 helpers/guilds/automod/createAutomodRule.ts | 75 ++++++ helpers/guilds/automod/deleteAutomodRule.ts | 10 + helpers/guilds/automod/editAutomodRule.ts | 73 ++++++ helpers/guilds/automod/getAutomodRule.ts | 14 + helpers/guilds/automod/getAutomodRules.ts | 17 ++ helpers/guilds/automod/mod.ts | 5 + helpers/guilds/mod.ts | 1 + plugins/permissions/src/guilds/automod.ts | 92 +++++++ rest/convertRestError.ts | 2 +- tests/guilds/automod.test.ts | 245 ++++++++++++++++++ tests/rest.ts | 11 +- transformers/automodActionExecution.ts | 33 +++ transformers/automodRule.ts | 36 +++ types/discord.ts | 104 ++++++++ types/shared.ts | 22 ++ util/constants.ts | 8 + 22 files changed, 796 insertions(+), 3 deletions(-) create mode 100644 handlers/guilds/automod/AUTO_MODERATION_ACTION_EXECUTION.ts create mode 100644 handlers/guilds/automod/AUTO_MODERATION_RULE_CREATE.ts create mode 100644 handlers/guilds/automod/AUTO_MODERATION_RULE_DELETE.ts create mode 100644 handlers/guilds/automod/AUTO_MODERATION_RULE_UPDATE.ts create mode 100644 handlers/guilds/automod/mod.ts create mode 100644 helpers/guilds/automod/createAutomodRule.ts create mode 100644 helpers/guilds/automod/deleteAutomodRule.ts create mode 100644 helpers/guilds/automod/editAutomodRule.ts create mode 100644 helpers/guilds/automod/getAutomodRule.ts create mode 100644 helpers/guilds/automod/getAutomodRules.ts create mode 100644 helpers/guilds/automod/mod.ts create mode 100644 plugins/permissions/src/guilds/automod.ts create mode 100644 tests/guilds/automod.test.ts create mode 100644 transformers/automodActionExecution.ts create mode 100644 transformers/automodRule.ts diff --git a/bot.ts b/bot.ts index 571e3cbd3..6147cc4a1 100644 --- a/bot.ts +++ b/bot.ts @@ -71,6 +71,8 @@ import { StickerPack, transformSticker, transformStickerPack } from "./transform import { GetGatewayBot, transformGatewayBot } from "./transformers/gatewayBot.ts"; import { DiscordApplicationCommandOptionChoice, + DiscordAutoModerationActionExecution, + DiscordAutoModerationRule, DiscordEmoji, DiscordGatewayPayload, DiscordInteractionDataOption, @@ -139,6 +141,11 @@ import { transformEmbedToDiscordEmbed } from "./transformers/reverse/embed.ts"; import { transformComponentToDiscordComponent } from "./transformers/reverse/component.ts"; import { getBotIdFromToken, removeTokenPrefix } from "./util/token.ts"; import { CreateShardManager } from "./gateway/manager/shardManager.ts"; +import { AutoModerationRule, transformAutoModerationRule } from "./transformers/automodRule.ts"; +import { + AutoModerationActionExecution, + transformAutoModerationActionExecution, +} from "./transformers/automodActionExecution.ts"; export function createBot(options: CreateBotOptions): Bot { const bot = { @@ -203,6 +210,10 @@ export function createEventHandlers( return { debug: events.debug ?? ignore, + automodRuleCreate: events.automodRuleCreate ?? ignore, + automodRuleUpdate: events.automodRuleUpdate ?? ignore, + automodRuleDelete: events.automodRuleDelete ?? ignore, + automodActionExecution: events.automodActionExecution ?? ignore, threadCreate: events.threadCreate ?? ignore, threadDelete: events.threadDelete ?? ignore, threadMemberUpdate: events.threadMemberUpdate ?? ignore, @@ -388,6 +399,8 @@ export interface Transformers { }; snowflake: (snowflake: string) => bigint; gatewayBot: (payload: DiscordGetGatewayBot) => GetGatewayBot; + automodRule: (bot: Bot, payload: DiscordAutoModerationRule) => AutoModerationRule; + automodActionExecution: (bot: Bot, payload: DiscordAutoModerationActionExecution) => AutoModerationActionExecution; channel: (bot: Bot, payload: { channel: DiscordChannel } & { guildId?: bigint }) => Channel; guild: (bot: Bot, payload: { guild: DiscordGuild } & { shardId: number }) => Guild; user: (bot: Bot, payload: DiscordUser) => User; @@ -437,6 +450,8 @@ export function createTransformers(options: Partial) { embed: options.reverse?.embed || transformEmbedToDiscordEmbed, component: options.reverse?.component || transformComponentToDiscordComponent, }, + automodRule: options.automodRule || transformAutoModerationRule, + automodActionExecution: options.automodActionExecution || transformAutoModerationActionExecution, activity: options.activity || transformActivity, application: options.application || transformApplication, attachment: options.attachment || transformAttachment, @@ -484,6 +499,10 @@ export type RestManager = ReturnType; export interface EventHandlers { debug: (text: string, ...args: any[]) => unknown; + automodRuleCreate: (bot: Bot, rule: AutoModerationRule) => unknown; + automodRuleUpdate: (bot: Bot, rule: AutoModerationRule) => unknown; + automodRuleDelete: (bot: Bot, rule: AutoModerationRule) => unknown; + automodActionExecution: (bot: Bot, payload: AutoModerationActionExecution) => unknown; threadCreate: (bot: Bot, thread: Channel) => unknown; threadDelete: (bot: Bot, thread: Channel) => unknown; threadMemberUpdate: (bot: Bot, payload: { diff --git a/handlers/guilds/automod/AUTO_MODERATION_ACTION_EXECUTION.ts b/handlers/guilds/automod/AUTO_MODERATION_ACTION_EXECUTION.ts new file mode 100644 index 000000000..d44220ba4 --- /dev/null +++ b/handlers/guilds/automod/AUTO_MODERATION_ACTION_EXECUTION.ts @@ -0,0 +1,8 @@ +import type { Bot } from "../../../bot.ts"; +import { DiscordAutoModerationActionExecution, DiscordGatewayPayload } from "../../../types/discord.ts"; + +/** Requires the MANAGE_GUILD permission. */ +export function handleAutoModerationActionExecution(bot: Bot, data: DiscordGatewayPayload, shardId: number) { + const payload = data.d as DiscordAutoModerationActionExecution; + bot.events.automodActionExecution(bot, bot.transformers.automodActionExecution(bot, payload)); +} diff --git a/handlers/guilds/automod/AUTO_MODERATION_RULE_CREATE.ts b/handlers/guilds/automod/AUTO_MODERATION_RULE_CREATE.ts new file mode 100644 index 000000000..a41d27b36 --- /dev/null +++ b/handlers/guilds/automod/AUTO_MODERATION_RULE_CREATE.ts @@ -0,0 +1,8 @@ +import type { Bot } from "../../../bot.ts"; +import { DiscordAutoModerationRule, DiscordGatewayPayload } from "../../../types/discord.ts"; + +/** Requires the MANAGE_GUILD permission. */ +export function handleAutoModerationRuleCreate(bot: Bot, data: DiscordGatewayPayload, shardId: number) { + const payload = data.d as DiscordAutoModerationRule; + bot.events.automodRuleCreate(bot, bot.transformers.automodRule(bot, payload)); +} diff --git a/handlers/guilds/automod/AUTO_MODERATION_RULE_DELETE.ts b/handlers/guilds/automod/AUTO_MODERATION_RULE_DELETE.ts new file mode 100644 index 000000000..28ec5f3c1 --- /dev/null +++ b/handlers/guilds/automod/AUTO_MODERATION_RULE_DELETE.ts @@ -0,0 +1,8 @@ +import type { Bot } from "../../../bot.ts"; +import { DiscordAutoModerationRule, DiscordGatewayPayload } from "../../../types/discord.ts"; + +/** Requires the MANAGE_GUILD permission. */ +export function handleAutoModerationRuleDelete(bot: Bot, data: DiscordGatewayPayload, shardId: number) { + const payload = data.d as DiscordAutoModerationRule; + bot.events.automodRuleDelete(bot, bot.transformers.automodRule(bot, payload)); +} diff --git a/handlers/guilds/automod/AUTO_MODERATION_RULE_UPDATE.ts b/handlers/guilds/automod/AUTO_MODERATION_RULE_UPDATE.ts new file mode 100644 index 000000000..4e717fc5c --- /dev/null +++ b/handlers/guilds/automod/AUTO_MODERATION_RULE_UPDATE.ts @@ -0,0 +1,8 @@ +import type { Bot } from "../../../bot.ts"; +import { DiscordAutoModerationRule, DiscordGatewayPayload } from "../../../types/discord.ts"; + +/** Requires the MANAGE_GUILD permission. */ +export function handleAutoModerationRuleUpdate(bot: Bot, data: DiscordGatewayPayload, shardId: number) { + const payload = data.d as DiscordAutoModerationRule; + bot.events.automodRuleUpdate(bot, bot.transformers.automodRule(bot, payload)); +} diff --git a/handlers/guilds/automod/mod.ts b/handlers/guilds/automod/mod.ts new file mode 100644 index 000000000..e69de29bb diff --git a/helpers/guilds/automod/createAutomodRule.ts b/helpers/guilds/automod/createAutomodRule.ts new file mode 100644 index 000000000..bc6b3d06d --- /dev/null +++ b/helpers/guilds/automod/createAutomodRule.ts @@ -0,0 +1,75 @@ +import { Bot } from "../../../bot.ts"; +import { + AutoModerationActionType, + AutoModerationEventTypes, + AutoModerationTriggerTypes, + DiscordAutoModerationRule, + DiscordAutoModerationRuleTriggerMetadataPresets, +} from "../../../types/discord.ts"; + +/** Get a rule currently configured for guild. */ +export async function createAutomodRule(bot: Bot, guildId: bigint, options: CreateAutoModerationRuleOptions) { + const rule = await bot.rest.runMethod( + bot.rest, + "POST", + bot.constants.routes.AUTOMOD_RULES(guildId), + { + name: options.name, + event_type: options.eventType, + trigger_type: options.triggerType, + trigger_metadata: { + keyword_filter: options.triggerMetadata.keywordFilter, + presets: options.triggerMetadata.presets, + }, + actions: options.actions.map((action) => ({ + type: action.type, + metadata: action.metadata + ? { + channel_id: action.metadata.channelId?.toString(), + duration_seconds: action.metadata.durationSeconds, + } + : undefined, + })), + enabled: options.enabled ?? true, + exempt_roles: options.exemptRoles?.map((id) => id.toString()), + exempt_channels: options.exemptChannels?.map((id) => id.toString()), + }, + ); + + return bot.transformers.automodRule(bot, rule); +} + +export interface CreateAutoModerationRuleOptions { + /** The name of the rule. */ + name: string; + /** The type of event to trigger the rule on. */ + eventType: AutoModerationEventTypes; + /** The type of trigger to use for the rule. */ + triggerType: AutoModerationTriggerTypes; + /** The metadata to use for the trigger. */ + triggerMetadata: { + // TODO: discord is considering renaming this before release + /** The keywords needed to match. Only present when TriggerType.Keyword */ + keywordFilter?: string[]; + /** The pre-defined lists of words to match from. Only present when TriggerType.KeywordPreset */ + presets?: DiscordAutoModerationRuleTriggerMetadataPresets[]; + }; + /** The actions that will trigger for this rule */ + actions: { + /** The type of action to take when a rule is triggered */ + type: AutoModerationActionType; + /** additional metadata needed during execution for this specific action type */ + metadata?: { + /** The id of channel to which user content should be logged. Only in SendAlertMessage */ + channelId?: bigint; + /** Timeout duration in seconds. Max is 2419200(4 weeks). Only supported for TriggerType.Keyword */ + durationSeconds?: number; + }; + }[]; + /** Whether the rule should be enabled, true by default. */ + enabled?: boolean; + /** The role ids that should not be effected by the rule */ + exemptRoles?: bigint[]; + /** The channel ids that should not be effected by the rule. */ + exemptChannels?: bigint[]; +} diff --git a/helpers/guilds/automod/deleteAutomodRule.ts b/helpers/guilds/automod/deleteAutomodRule.ts new file mode 100644 index 000000000..a69fd6a24 --- /dev/null +++ b/helpers/guilds/automod/deleteAutomodRule.ts @@ -0,0 +1,10 @@ +import { Bot } from "../../../bot.ts"; + +/** Delete a rule currently configured for guild. */ +export async function deleteAutomodRule(bot: Bot, guildId: bigint, ruleId: bigint) { + await bot.rest.runMethod( + bot.rest, + "DELETE", + bot.constants.routes.AUTOMOD_RULE(guildId, ruleId), + ); +} diff --git a/helpers/guilds/automod/editAutomodRule.ts b/helpers/guilds/automod/editAutomodRule.ts new file mode 100644 index 000000000..04b522498 --- /dev/null +++ b/helpers/guilds/automod/editAutomodRule.ts @@ -0,0 +1,73 @@ +import { Bot } from "../../../bot.ts"; +import { + AutoModerationActionType, + AutoModerationEventTypes, + DiscordAutoModerationRule, + DiscordAutoModerationRuleTriggerMetadataPresets, +} from "../../../types/discord.ts"; + +/** Edit a rule currently configured for guild. */ +export async function editAutomodRule(bot: Bot, guildId: bigint, options: Partial) { + const rule = await bot.rest.runMethod( + bot.rest, + "PATCH", + bot.constants.routes.AUTOMOD_RULES(guildId), + { + name: options.name, + event_type: options.eventType, + trigger_metadata: options.triggerMetadata + ? { + keyword_filter: options.triggerMetadata.keywordFilter, + presets: options.triggerMetadata.presets, + } + : undefined, + actions: options.actions?.map((action) => ({ + type: action.type, + metadata: { + channel_id: action.metadata.channelId?.toString(), + duration_seconds: action.metadata.durationSeconds, + }, + })), + enabled: options.enabled ?? true, + exempt_roles: options.exemptRoles?.map((id) => id.toString()), + exempt_channels: options.exemptChannels?.map((id) => id.toString()), + }, + ); + + return bot.transformers.automodRule(bot, rule); +} + +export interface EditAutoModerationRuleOptions { + /** The name of the rule. */ + name: string; + /** The type of event to trigger the rule on. */ + eventType: AutoModerationEventTypes; + /** The metadata to use for the trigger. */ + triggerMetadata: { + // TODO: discord is considering renaming this before release + /** The keywords needed to match. Only present when TriggerType.Keyword */ + keywordFilter?: string[]; + // TODO: discord is considering renaming this before release + // TODO: This may need a special type or enum + /** The pre-defined lists of words to match from. Only present when TriggerType.KeywordPreset */ + presets?: DiscordAutoModerationRuleTriggerMetadataPresets[]; + }; + /** The actions that will trigger for this rule */ + actions: { + /** The type of action to take when a rule is triggered */ + type: AutoModerationActionType; + /** additional metadata needed during execution for this specific action type */ + metadata: { + /** The id of channel to which user content should be logged. Only in SendAlertMessage */ + channelId?: bigint; + /** Timeout duration in seconds. Only supported for TriggerType.Keyword */ + durationSeconds?: number; + }; + }[]; + /** Whether the rule should be enabled. */ + enabled?: boolean; + /** The role ids that should not be effected by the rule */ + exemptRoles?: bigint[]; + /** The channel ids that should not be effected by the rule. */ + exemptChannels?: bigint[]; +} diff --git a/helpers/guilds/automod/getAutomodRule.ts b/helpers/guilds/automod/getAutomodRule.ts new file mode 100644 index 000000000..51aecf9a6 --- /dev/null +++ b/helpers/guilds/automod/getAutomodRule.ts @@ -0,0 +1,14 @@ +import { Bot } from "../../../bot.ts"; +import { DiscordAutoModerationRule } from "../../../types/discord.ts"; +import { Collection } from "../../../util/collection.ts"; + +/** Get a rule currently configured for guild. */ +export async function getAutomodRule(bot: Bot, guildId: bigint, ruleId: bigint) { + const rule = await bot.rest.runMethod( + bot.rest, + "GET", + bot.constants.routes.AUTOMOD_RULE(guildId, ruleId), + ); + + return bot.transformers.automodRule(bot, rule); +} diff --git a/helpers/guilds/automod/getAutomodRules.ts b/helpers/guilds/automod/getAutomodRules.ts new file mode 100644 index 000000000..882d504e3 --- /dev/null +++ b/helpers/guilds/automod/getAutomodRules.ts @@ -0,0 +1,17 @@ +import { Bot } from "../../../bot.ts"; +import { DiscordAutoModerationRule } from "../../../types/discord.ts"; +import { Collection } from "../../../util/collection.ts"; + +/** Get a list of all rules currently configured for guild. */ +export async function getAutomodRules(bot: Bot, guildId: bigint) { + const rules = await bot.rest.runMethod( + bot.rest, + "GET", + bot.constants.routes.AUTOMOD_RULES(guildId), + ); + + return new Collection(rules.map((r) => { + const rule = bot.transformers.automodRule(bot, r); + return [rule.id, rule]; + })); +} diff --git a/helpers/guilds/automod/mod.ts b/helpers/guilds/automod/mod.ts new file mode 100644 index 000000000..bbf671ea0 --- /dev/null +++ b/helpers/guilds/automod/mod.ts @@ -0,0 +1,5 @@ +export * from "./getAutomodRule.ts"; +export * from "./getAutomodRules.ts"; +export * from "./createAutomodRule.ts"; +export * from "./editAutomodRule.ts"; +export * from "./deleteAutomodRule.ts"; diff --git a/helpers/guilds/mod.ts b/helpers/guilds/mod.ts index a1bb3102f..f1accd7ac 100644 --- a/helpers/guilds/mod.ts +++ b/helpers/guilds/mod.ts @@ -1,4 +1,5 @@ export * from "./scheduledEvents/mod.ts"; +export * from "./automod/mod.ts"; export * from "./createGuild.ts"; export * from "./deleteGuild.ts"; diff --git a/plugins/permissions/src/guilds/automod.ts b/plugins/permissions/src/guilds/automod.ts new file mode 100644 index 000000000..af4de276a --- /dev/null +++ b/plugins/permissions/src/guilds/automod.ts @@ -0,0 +1,92 @@ +import { AutoModerationActionType, BotWithCache, PermissionStrings } from "../../deps.ts"; +import { requireBotGuildPermissions } from "../permissions.ts"; + +export function getAutomodRule(bot: BotWithCache) { + const getAutomodRuleOld = bot.helpers.getAutomodRule; + + bot.helpers.getAutomodRule = async function (guildId, ruleId) { + requireBotGuildPermissions(bot, guildId, ["MANAGE_GUILD"]); + + return await getAutomodRuleOld(guildId, ruleId); + }; +} + +export function getAutomodRules(bot: BotWithCache) { + const getAutomodRulesOld = bot.helpers.getAutomodRules; + + bot.helpers.getAutomodRules = async function (guildId) { + requireBotGuildPermissions(bot, guildId, ["MANAGE_GUILD"]); + + return await getAutomodRulesOld(guildId); + }; +} + +export function createAutomodRule(bot: BotWithCache) { + const createAutomodRuleOld = bot.helpers.createAutomodRule; + + bot.helpers.createAutomodRule = async function (guildId, options) { + requireBotGuildPermissions(bot, guildId, ["MANAGE_GUILD"]); + + for (const action of options.actions) { + // Check for maximum duration seconds + if (action.metadata?.durationSeconds && action.metadata.durationSeconds > 2419200) { + console.log( + `[Warning] Automod action duration seconds is too high: ${action.metadata.durationSeconds}. Setting to Discord's allowed maximum.`, + ); + // Discords max is 4 weeks + action.metadata.durationSeconds = 2419200; + } + + // Timeout actions require perm check + if (action.type === AutoModerationActionType.Timeout) { + requireBotGuildPermissions(bot, guildId, ["MODERATE_MEMBERS"]); + } + } + + return await createAutomodRuleOld(guildId, options); + }; +} + +export function editAutomodRule(bot: BotWithCache) { + const editAutomodRuleOld = bot.helpers.editAutomodRule; + + bot.helpers.editAutomodRule = async function (guildId, options) { + requireBotGuildPermissions(bot, guildId, ["MANAGE_GUILD"]); + + for (const action of options.actions ?? []) { + // Check for maximum duration seconds + if (action.metadata?.durationSeconds && action.metadata.durationSeconds > 2419200) { + console.log( + `[Warning] Automod action duration seconds is too high: ${action.metadata.durationSeconds}. Setting to Discord's allowed maximum.`, + ); + // Discords max is 4 weeks + action.metadata.durationSeconds = 2419200; + } + + // Timeout actions require perm check + if (action.type === AutoModerationActionType.Timeout) { + requireBotGuildPermissions(bot, guildId, ["MODERATE_MEMBERS"]); + } + } + + return await editAutomodRuleOld(guildId, options); + }; +} + +export function deleteAutomodRule(bot: BotWithCache) { + const deleteAutomodRuleOld = bot.helpers.deleteAutomodRule; + + bot.helpers.deleteAutomodRule = async function (guildId, options) { + requireBotGuildPermissions(bot, guildId, ["MANAGE_GUILD"]); + + return await deleteAutomodRuleOld(guildId, options); + }; +} + +export default function setupAutoModerationPermChecks(bot: BotWithCache) { + getAutomodRule(bot); + getAutomodRules(bot); + createAutomodRule(bot); + editAutomodRule(bot); + deleteAutomodRule(bot); +} diff --git a/rest/convertRestError.ts b/rest/convertRestError.ts index 5aa390fef..b36a263b4 100644 --- a/rest/convertRestError.ts +++ b/rest/convertRestError.ts @@ -1,6 +1,6 @@ import { RestRequestRejection } from "./rest.ts"; export function convertRestError(errorStack: Error, data: RestRequestRejection): Error { - errorStack.message = `[${data.status}] ${data.error}`; + errorStack.message = `[${data.status}] ${data.error}\n${data.body}`; return errorStack; } diff --git a/tests/guilds/automod.test.ts b/tests/guilds/automod.test.ts new file mode 100644 index 000000000..c1e8cbcbc --- /dev/null +++ b/tests/guilds/automod.test.ts @@ -0,0 +1,245 @@ +import { AutoModerationActionType, AutoModerationEventTypes, AutoModerationTriggerTypes } from "../../types/discord.ts"; +import { assertEquals, assertExists } from "../deps.ts"; +import { loadBot } from "../mod.ts"; +import { CACHED_COMMUNITY_GUILD_ID } from "../utils.ts"; + +Deno.test("[automod] Run automod tests", async (t) => { + const bot = loadBot(); + + await t.step("[automod] Create a MessageSend rule for Keyword with BlockMessage action.", async () => { + const rule = await bot.helpers.createAutomodRule(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + eventType: AutoModerationEventTypes.MessageSend, + triggerType: AutoModerationTriggerTypes.Keyword, + triggerMetadata: { + keywordFilter: ["iblamewolf"], + }, + actions: [ + { + type: AutoModerationActionType.BlockMessage, + }, + ], + }); + + assertExists(rule.id); + + const fetchedRule = await bot.helpers.getAutomodRule( + CACHED_COMMUNITY_GUILD_ID, + rule.id, + ); + assertExists(fetchedRule.id); + assertEquals(fetchedRule.name, rule.name); + assertEquals(fetchedRule.eventType, AutoModerationEventTypes.MessageSend); + assertEquals(fetchedRule.triggerType, AutoModerationTriggerTypes.Keyword); + assertEquals(fetchedRule.triggerMetadata?.keywordFilter?.[0], "iblamewolf"); + assertExists(fetchedRule.actions); + assertExists(fetchedRule.actions[0]); + assertEquals(fetchedRule.actions[0].type, AutoModerationActionType.BlockMessage); + + await bot.helpers.deleteAutomodRule(CACHED_COMMUNITY_GUILD_ID, rule.id); + }); + + await t.step("[automod] Create a MessageSend rule for Keyword with SendAlertMessage action.", async () => { + const channel = await bot.helpers.createChannel(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + }); + assertExists(channel.id); + + const rule = await bot.helpers.createAutomodRule(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + eventType: AutoModerationEventTypes.MessageSend, + triggerType: AutoModerationTriggerTypes.Keyword, + triggerMetadata: { + keywordFilter: ["iblamewolf"], + }, + actions: [ + { + type: AutoModerationActionType.SendAlertMessage, + metadata: { + channelId: channel.id, + }, + }, + ], + }); + + assertExists(rule.id); + + const fetchedRule = await bot.helpers.getAutomodRule( + CACHED_COMMUNITY_GUILD_ID, + rule.id, + ); + assertExists(fetchedRule.id); + assertEquals(fetchedRule.name, rule.name); + assertEquals(fetchedRule.eventType, AutoModerationEventTypes.MessageSend); + assertEquals(fetchedRule.triggerType, AutoModerationTriggerTypes.Keyword); + assertEquals(fetchedRule.triggerMetadata?.keywordFilter?.[0], "iblamewolf"); + assertExists(fetchedRule.actions); + assertExists(fetchedRule.actions[0]); + assertEquals(fetchedRule.actions[0].type, AutoModerationActionType.SendAlertMessage); + assertEquals(fetchedRule.actions[0].metadata?.channelId, channel.id); + + await bot.helpers.deleteAutomodRule(CACHED_COMMUNITY_GUILD_ID, rule.id); + await bot.helpers.deleteChannel(channel.id); + }); + + await t.step("[automod] Create a MessageSend rule for Keyword with Timeout action.", async () => { + const rule = await bot.helpers.createAutomodRule(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + eventType: AutoModerationEventTypes.MessageSend, + triggerType: AutoModerationTriggerTypes.Keyword, + triggerMetadata: { + keywordFilter: ["iblamewolf"], + }, + actions: [ + { + type: AutoModerationActionType.Timeout, + metadata: { + durationSeconds: 10, + }, + }, + ], + }); + + assertExists(rule.id); + + const fetchedRule = await bot.helpers.getAutomodRule( + CACHED_COMMUNITY_GUILD_ID, + rule.id, + ); + assertExists(fetchedRule.id); + assertEquals(fetchedRule.name, rule.name); + assertEquals(fetchedRule.eventType, AutoModerationEventTypes.MessageSend); + assertEquals(fetchedRule.triggerType, AutoModerationTriggerTypes.Keyword); + assertEquals(fetchedRule.triggerMetadata?.keywordFilter?.[0], "iblamewolf"); + assertExists(fetchedRule.actions); + assertExists(fetchedRule.actions[0]); + assertEquals(fetchedRule.actions[0].type, AutoModerationActionType.Timeout); + assertEquals(fetchedRule.actions[0].metadata?.durationSeconds, 10); + + await bot.helpers.deleteAutomodRule(CACHED_COMMUNITY_GUILD_ID, rule.id); + }); + + await t.step("[automod] Create a MessageSend rule for Keyword with BlockMessage & Timeout action.", async () => { + const rule = await bot.helpers.createAutomodRule(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + eventType: AutoModerationEventTypes.MessageSend, + triggerType: AutoModerationTriggerTypes.Keyword, + triggerMetadata: { + keywordFilter: ["iblamewolf"], + }, + actions: [ + { + type: AutoModerationActionType.BlockMessage, + }, + { + type: AutoModerationActionType.Timeout, + metadata: { + durationSeconds: 10, + }, + }, + ], + }); + + assertExists(rule.id); + + await bot.helpers.deleteAutomodRule(CACHED_COMMUNITY_GUILD_ID, rule.id); + }); + + await t.step("[automod] Create a MessageSend rule for Keyword with SendAlertMessage & Timeout action.", async () => { + const channel = await bot.helpers.createChannel(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + }); + assertExists(channel.id); + + const rule = await bot.helpers.createAutomodRule(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + eventType: AutoModerationEventTypes.MessageSend, + triggerType: AutoModerationTriggerTypes.Keyword, + triggerMetadata: { + keywordFilter: ["iblamewolf"], + }, + actions: [ + { + type: AutoModerationActionType.SendAlertMessage, + metadata: { + channelId: channel.id, + }, + }, + { + type: AutoModerationActionType.Timeout, + metadata: { + durationSeconds: 10, + }, + }, + ], + }); + + assertExists(rule.id); + + await bot.helpers.deleteAutomodRule(CACHED_COMMUNITY_GUILD_ID, rule.id); + await bot.helpers.deleteChannel(channel.id); + }); + + await t.step( + "[automod] Create a MessageSend rule for Keyword with BlockMessage & SendAlertMessage & Timeout action.", + async (t) => { + const channel = await bot.helpers.createChannel(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + }); + assertExists(channel.id); + + const rule = await bot.helpers.createAutomodRule(CACHED_COMMUNITY_GUILD_ID, { + name: "test", + eventType: AutoModerationEventTypes.MessageSend, + triggerType: AutoModerationTriggerTypes.Keyword, + triggerMetadata: { + keywordFilter: ["iblamewolf"], + }, + actions: [ + { + type: AutoModerationActionType.BlockMessage, + }, + { + type: AutoModerationActionType.SendAlertMessage, + metadata: { + channelId: channel.id, + }, + }, + { + type: AutoModerationActionType.Timeout, + metadata: { + durationSeconds: 10, + }, + }, + ], + }); + + assertExists(rule.id); + + // Get the rule again to make sure it was created correctly + await t.step("[automod] Get a automod rule", async () => { + const fetchedRule = await bot.helpers.getAutomodRule( + CACHED_COMMUNITY_GUILD_ID, + rule.id, + ); + assertExists(fetchedRule.id); + assertEquals(fetchedRule.name, rule.name); + assertEquals(fetchedRule.eventType, AutoModerationEventTypes.MessageSend); + assertEquals(fetchedRule.triggerType, AutoModerationTriggerTypes.Keyword); + assertEquals(fetchedRule.triggerMetadata?.keywordFilter?.[0], "iblamewolf"); + assertExists(fetchedRule.actions); + assertExists(fetchedRule.actions[0]); + assertExists(fetchedRule.actions[1].metadata); + assertExists(fetchedRule.actions[2].metadata); + assertEquals(fetchedRule.actions[1].metadata.channelId, channel.id); + assertEquals(fetchedRule.actions[2].metadata.durationSeconds, 10); + assertEquals(fetchedRule.actions[0].type, AutoModerationActionType.BlockMessage); + assertEquals(fetchedRule.actions[1].type, AutoModerationActionType.SendAlertMessage); + assertEquals(fetchedRule.actions[2].type, AutoModerationActionType.Timeout); + }); + + await bot.helpers.deleteAutomodRule(CACHED_COMMUNITY_GUILD_ID, rule.id); + await bot.helpers.deleteChannel(channel.id); + }, + ); +}); diff --git a/tests/rest.ts b/tests/rest.ts index 5258eff3c..301846ed5 100644 --- a/tests/rest.ts +++ b/tests/rest.ts @@ -79,10 +79,17 @@ async function handleRequest(conn: Deno.Conn) { ); } } catch (error) { - console.log("CATCH", requestEvent.request.url, requestEvent.request.method, requestEvent.request.body, error); + console.log( + "CATCH", + requestEvent.request.url, + requestEvent.request.method, + requestEvent.request.body, + error.code, + error, + ); requestEvent.respondWith( new Response(JSON.stringify(error), { - status: error.code, + status: error.code ?? 469, }), ); } diff --git a/transformers/automodActionExecution.ts b/transformers/automodActionExecution.ts new file mode 100644 index 000000000..bc8712087 --- /dev/null +++ b/transformers/automodActionExecution.ts @@ -0,0 +1,33 @@ +import { Bot } from "../bot.ts"; +import { DiscordAutoModerationActionExecution } from "../types/discord.ts"; +import { Optionalize } from "../types/shared.ts"; + +export function transformAutoModerationActionExecution(bot: Bot, payload: DiscordAutoModerationActionExecution) { + const rule = { + content: payload.content, + ruleTriggerType: payload.rule_trigger_type, + guildId: bot.transformers.snowflake(payload.guild_id), + ruleId: bot.transformers.snowflake(payload.rule_id), + userId: bot.transformers.snowflake(payload.user_id), + channelId: payload.channel_id ? bot.transformers.snowflake(payload.channel_id) : undefined, + messageId: payload.message_id ? bot.transformers.snowflake(payload.message_id) : undefined, + alertSystemMessageId: payload.alert_system_message_id + ? bot.transformers.snowflake(payload.alert_system_message_id) + : undefined, + matchedKeyword: payload.matched_keyword ?? "", + matchedContent: payload.matched_content ?? "", + action: { + type: payload.action.type, + metadata: { + durationSeconds: payload.action.metadata.duration_seconds, + channelId: payload.action.metadata.channel_id + ? bot.transformers.snowflake(payload.action.metadata.channel_id) + : undefined, + }, + }, + }; + + return rule as Optionalize; +} + +export interface AutoModerationActionExecution extends ReturnType {} diff --git a/transformers/automodRule.ts b/transformers/automodRule.ts new file mode 100644 index 000000000..0d77c3346 --- /dev/null +++ b/transformers/automodRule.ts @@ -0,0 +1,36 @@ +import { Bot } from "../bot.ts"; +import { DiscordAutoModerationRule } from "../types/discord.ts"; +import { Optionalize } from "../types/shared.ts"; + +export function transformAutoModerationRule(bot: Bot, payload: DiscordAutoModerationRule) { + const rule = { + name: payload.name, + eventType: payload.event_type, + triggerType: payload.trigger_type, + enabled: payload.enabled, + id: bot.transformers.snowflake(payload.id), + guildId: bot.transformers.snowflake(payload.guild_id), + creatorId: bot.transformers.snowflake(payload.creator_id), + exemptRoles: payload.exempt_roles.map((id) => bot.transformers.snowflake(id)), + exemptChannels: payload.exempt_channels.map((id) => bot.transformers.snowflake(id)), + triggerMetadata: payload.trigger_metadata + ? { + keywordFilter: payload.trigger_metadata.keyword_filter, + presets: payload.trigger_metadata.presets, + } + : undefined, + actions: payload.actions.map((action) => ({ + type: action.type, + metadata: action.metadata + ? { + channelId: action.metadata.channel_id ? bot.transformers.snowflake(action.metadata.channel_id) : undefined, + durationSeconds: action.metadata.duration_seconds, + } + : undefined, + })), + }; + + return rule as Optionalize; +} + +export interface AutoModerationRule extends ReturnType {} diff --git a/types/discord.ts b/types/discord.ts index 3a332029e..ebae26c50 100644 --- a/types/discord.ts +++ b/types/discord.ts @@ -1400,6 +1400,110 @@ export interface DiscordAuditLog { threads: DiscordChannel[]; /** List of guild scheduled events found in the audit log */ guild_scheduled_events?: DiscordScheduledEvent[]; + /** List of auto moderation rules referenced in the audit log */ + auto_moderation_rules?: DiscordAutoModerationRule[]; +} + +export interface DiscordAutoModerationRule { + /** The id of this rule */ + id: string; + /** The guild id */ + guild_id: string; + /** The name of the rule */ + name: string; + /** The id of the user who created this rule. */ + creator_id: string; + /** Indicates in what event context a rule should be checked. */ + event_type: AutoModerationEventTypes; + /** The type of trigger for this rule */ + trigger_type: AutoModerationTriggerTypes; + /** The metadata used to determine whether a rule should be triggered. */ + trigger_metadata: DiscordAutoModerationRuleTriggerMetadata; + /** Actions which will execute whenever a rule is triggered. */ + actions: DiscordAutoModerationAction[]; + /** Whether the rule is enabled. */ + enabled: boolean; + /** The role ids that are whitelisted. Max 20. */ + exempt_roles: string[]; + /** The channel ids that are whitelisted. Max 50. */ + exempt_channels: string[]; +} + +export enum AutoModerationEventTypes { + /** When a user sends a message */ + MessageSend = 1, +} + +export enum AutoModerationTriggerTypes { + Keyword = 1, + HarmfulLink, + Spam, + KeywordPreset, +} + +export interface DiscordAutoModerationRuleTriggerMetadata { + // TODO: discord is considering renaming this before release + /** The keywords needed to match. Only present when TriggerType.Keyword */ + keyword_filter?: string[]; + /** The pre-defined lists of words to match from. Only present when TriggerType.KeywordPreset */ + presets?: DiscordAutoModerationRuleTriggerMetadataPresets[]; +} + +export enum DiscordAutoModerationRuleTriggerMetadataPresets { + /** Words that may be considered forms of swearing or cursing */ + Profanity = 1, + /** Words that refer to sexually explicit behavior or activity */ + SexualContent, + /** Personal insults or words that may be considered hate speech */ + Slurs, +} + +export interface DiscordAutoModerationAction { + /** The type of action to take when a rule is triggered */ + type: AutoModerationActionType; + /** additional metadata needed during execution for this specific action type */ + metadata: DiscordAutoModerationActionMetadata; +} + +export enum AutoModerationActionType { + /** Blocks the content of a message according to the rule */ + BlockMessage = 1, + /** Logs user content to a specified channel */ + SendAlertMessage, + /** Times out user for specified duration */ + Timeout, +} + +export interface DiscordAutoModerationActionMetadata { + /** The id of channel to which user content should be logged. Only in ActionType.SendAlertMessage */ + channel_id?: string; + /** Timeout duration in seconds maximum of 2419200 seconds (4 weeks). Only supported for TriggerType.Keyword && Only in ActionType.Timeout */ + duration_seconds?: number; +} + +export interface DiscordAutoModerationActionExecution { + /** The id of the guild */ + guild_id: string; + /** The id of the rule that was executed */ + rule_id: string; + /** The id of the user which generated the content which triggered the rule */ + user_id: string; + /** The content from the user */ + content: string; + /** Action which was executed */ + action: DiscordAutoModerationAction; + /** The trigger type of the rule that was executed. */ + rule_trigger_type: AutoModerationTriggerTypes; + /** The id of the channel in which user content was posted */ + channel_id?: string | null; + /** The id of the message. Will not exist if message was blocked by automod or content was not part of any message */ + message_id?: string | null; + /** The id of any system auto moderation messages posted as a result of this action */ + alert_system_message_id?: string | null; + /** The word or phrase that triggerred the rule. */ + matched_keyword: string | null; + /** The substring in content that triggered rule */ + matched_content: string | null; } /** https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-audit-log-entry-structure */ diff --git a/types/shared.ts b/types/shared.ts index c81911a0d..e5672bf66 100644 --- a/types/shared.ts +++ b/types/shared.ts @@ -215,6 +215,8 @@ export enum GuildFeatures { PrivateThreads = "PRIVATE_THREADS", /** Guild is able to set role icons */ RoleIcons = "ROLE_ICONS", + /** Guild has set up auto moderation rules */ + AutoModeration = "AUTO_MODERATION", } /** https://discord.com/developers/docs/resources/guild#guild-object-mfa-level */ @@ -332,6 +334,7 @@ export enum MessageTypes { ThreadStarterMessage, GuildInviteReminder, ContextMenuCommand, + AutoModerationAction, } /** https://discord.com/developers/docs/resources/channel#message-object-message-activity-types */ @@ -479,6 +482,14 @@ export enum AuditLogEvents { ThreadDelete, /** Permissions were updated for a command */ ApplicationCommandPermissionUpdate = 121, + /** Auto moderation rule was created */ + AutoModerationRuleCreate = 140, + /** Auto moderation rule was updated */ + AutoModerationRuleUpdate, + /** Auto moderation rule was deleted */ + AutoModerationRuleDelete, + /** Message was blocked by AutoMod according to a rule. */ + AutoModerationBlockMessage, } export enum ScheduledEventPrivacyLevel { @@ -996,6 +1007,17 @@ export enum GatewayIntents { * - GUILD_SCHEDULED_EVENT_USER_REMOVE this is experimental and unstable. */ GuildScheduledEvents = (1 << 16), + + /** + * - AUTO_MODERATION_RULE_CREATE + * - AUTO_MODERATION_RULE_UPDATE + * - AUTO_MODERATION_RULE_DELETE + */ + AutoModerationConfiguration = (1 << 20), + /** + * - AUTO_MODERATION_ACTION_EXECUTION + */ + AutoModerationExecution = (1 << 21), } // ALIASES JUST FOR BETTER UX IN THIS CASE diff --git a/util/constants.ts b/util/constants.ts index ad1cdf5db..31acebf8c 100644 --- a/util/constants.ts +++ b/util/constants.ts @@ -41,6 +41,14 @@ export const routes = { return `/gateway/bot`; }, + // Automod Endpoints + AUTOMOD_RULES: (guildId: bigint) => { + return `/guilds/${guildId}/auto-moderation/rules`; + }, + AUTOMOD_RULE: (guildId: bigint, ruleId: bigint) => { + return `/guilds/${guildId}/auto-moderation/rules/${ruleId}`; + }, + // Channel Endpoints CHANNEL: (channelId: bigint) => { return `/channels/${channelId}`;