import { DISCORD_AUTOMOD_RULE_SCHEMA, DISCORD_PROVIDER_AUTOMOD_RULE_SCHEMA, toDiscordAutomodRule, } from "@automate.ax/integration-contracts/discord" import * as z from "zod" import { defineAction, TerminalActionError } from "../../../automation/actions" import { getDiscordApi } from "../lib/api" const AUTOMOD_ACTION_SCHEMA = z.discriminatedUnion("type", [ z.object({ customMessage: z.string().max(150).optional(), type: z.literal("blockMessage"), }), z.object({ channelId: z.string().regex(/^\d+$/), type: z.literal("sendAlert"), }), z.object({ durationSeconds: z.number().int().min(1).max(2_419_200), type: z.literal("timeoutMember"), }), z.object({ type: z.literal("blockMemberInteraction") }), ]) const AUTOMOD_ACTIONS_SCHEMA = AUTOMOD_ACTION_SCHEMA.array() .min(1) .max(3) .superRefine((actions, context) => { const seenTypes = new Set() for (const [index, action] of actions.entries()) { if (!seenTypes.has(action.type)) { seenTypes.add(action.type) continue } context.addIssue({ code: "custom", message: "Auto Moderation action types must be unique.", path: [index, "type"], }) } }) const AUTOMOD_ACTION_COMPATIBILITY = { keyword: ["blockMessage", "sendAlert", "timeoutMember"], keywordPreset: ["blockMessage", "sendAlert"], memberProfile: ["sendAlert", "blockMemberInteraction"], mentionSpam: ["blockMessage", "sendAlert", "timeoutMember"], spam: ["blockMessage", "sendAlert"], } as const const AUTOMOD_FIELDS_SCHEMA = z.object({ actions: AUTOMOD_ACTIONS_SCHEMA.optional(), allowList: z.string().max(60).array().max(100).optional(), enabled: z.boolean().optional(), eventType: z.enum(["messageSend", "memberUpdate"]).optional(), exemptChannelIds: z.string().regex(/^\d+$/).array().max(50).optional(), exemptRoleIds: z.string().regex(/^\d+$/).array().max(20).optional(), keywordFilter: z.string().max(60).array().max(1_000).optional(), mentionRaidProtectionEnabled: z.boolean().optional(), mentionTotalLimit: z.number().int().min(1).max(50).optional(), name: z.string().trim().min(1).max(100).optional(), presets: z.number().int().min(1).max(3).array().min(1).max(3).optional(), regexPatterns: z.string().max(260).array().max(10).optional(), triggerType: z.enum([ "keyword", "spam", "keywordPreset", "mentionSpam", "memberProfile", ]), }) const AUTOMOD_UPDATE_FIELDS_SCHEMA = AUTOMOD_FIELDS_SCHEMA.omit({ triggerType: true, }).partial() const AUTOMOD_TRIGGER_METADATA_FIELDS = [ "allowList", "keywordFilter", "mentionRaidProtectionEnabled", "mentionTotalLimit", "presets", "regexPatterns", ] as const const AUTOMOD_TRIGGER_METADATA_COMPATIBILITY = { keyword: ["allowList", "keywordFilter", "regexPatterns"], keywordPreset: ["allowList", "presets"], memberProfile: ["allowList", "keywordFilter", "regexPatterns"], mentionSpam: ["mentionRaidProtectionEnabled", "mentionTotalLimit"], spam: [], } as const const AUTOMOD_RESOLVED_UPDATE_FIELDS_SCHEMA = AUTOMOD_UPDATE_FIELDS_SCHEMA.extend({ triggerType: AUTOMOD_FIELDS_SCHEMA.shape.triggerType, }).superRefine((input, context) => validateAutomodFields(input, context, false), ) /** Lists Auto Moderation rules in a Discord server. */ export const listDiscordAutomodRules = defineAction( "List Discord Auto Moderation rules", ) .describe("Lists every Auto Moderation rule configured in a server.") .account("discord") .input(z.object({ guildId: z.string().regex(/^\d+$/) })) .output(DISCORD_AUTOMOD_RULE_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ( await getDiscordApi(account.secret).request( `guilds/${input.guildId}/auto-moderation/rules`, { responseSchema: DISCORD_PROVIDER_AUTOMOD_RULE_SCHEMA.array() }, ) ).map(toDiscordAutomodRule), ) /** Creates a Discord Auto Moderation rule. */ export const createDiscordAutomodRule = defineAction( "Create Discord Auto Moderation rule", ) .describe( "Creates a rule that blocks, alerts, times out, or blocks interactions.", ) .account("discord") .input( AUTOMOD_FIELDS_SCHEMA.extend({ actions: AUTOMOD_ACTIONS_SCHEMA, enabled: z.boolean().prefault(false), guildId: z.string().regex(/^\d+$/), name: z.string().trim().min(1).max(100), reason: z.string().max(512).optional(), }).superRefine((input, context) => validateAutomodFields(input, context, true), ), ) .output(DISCORD_AUTOMOD_RULE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => automodWrite(account.secret, input, "POST"), ) /** Updates a Discord Auto Moderation rule. */ export const updateDiscordAutomodRule = defineAction( "Update Discord Auto Moderation rule", ) .describe("Updates a rule's matching, actions, exemptions, or enabled state.") .account("discord") .input( AUTOMOD_UPDATE_FIELDS_SCHEMA.extend({ guildId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), ruleId: z.string().regex(/^\d+$/), }), ) .output(DISCORD_AUTOMOD_RULE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { if ( input.actions !== undefined || input.eventType !== undefined || AUTOMOD_TRIGGER_METADATA_FIELDS.some( (field) => input[field] !== undefined, ) ) { const validation = AUTOMOD_RESOLVED_UPDATE_FIELDS_SCHEMA.safeParse({ ...input, triggerType: fromProviderAutomodTriggerType( ( await getDiscordApi(account.secret).request( `guilds/${input.guildId}/auto-moderation/rules/${input.ruleId}`, { responseSchema: DISCORD_PROVIDER_AUTOMOD_RULE_SCHEMA }, ) ).trigger_type, ), }) if (!validation.success) { throw new TerminalActionError( validation.error.issues[0]?.message ?? "Invalid Auto Moderation update.", { code: "validation_failed", cause: validation.error, }, ) } } return automodWrite(account.secret, input, "PATCH") }) /** Deletes a Discord Auto Moderation rule. */ export const deleteDiscordAutomodRule = defineAction( "Delete Discord Auto Moderation rule", ) .describe("Deletes one Auto Moderation rule.") .account("discord") .input( z.object({ guildId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), ruleId: z.string().regex(/^\d+$/), }), ) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getDiscordApi(account.secret).request( `guilds/${input.guildId}/auto-moderation/rules/${input.ruleId}`, { method: "DELETE", reason: input.reason, responseSchema: z.void() }, ), ) /** * Creates or updates an Auto Moderation rule. * * @param secret - Connected Discord account secret. * @param input - Normalized rule fields. * @param method - Discord write method. */ async function automodWrite( secret: Record, input: Partial> & { guildId: string reason?: string ruleId?: string }, method: "PATCH" | "POST", ) { const actionTypes = { blockMemberInteraction: 4, blockMessage: 1, sendAlert: 2, timeoutMember: 3, } as const const triggerTypes = { keyword: 1, keywordPreset: 4, memberProfile: 6, mentionSpam: 5, spam: 3, } as const const triggerMetadata = { allow_list: input.allowList, keyword_filter: input.keywordFilter, mention_raid_protection_enabled: input.mentionRaidProtectionEnabled, mention_total_limit: input.mentionTotalLimit, presets: input.presets, regex_patterns: input.regexPatterns, } return toDiscordAutomodRule( await getDiscordApi(secret).request( `guilds/${input.guildId}/auto-moderation/rules${input.ruleId ? `/${input.ruleId}` : ""}`, { body: { actions: input.actions?.map((action) => ({ metadata: toProviderAutomodActionMetadata(action), type: actionTypes[action.type], })), enabled: input.enabled, event_type: resolveAutomodEventType(input), exempt_channels: input.exemptChannelIds, exempt_roles: input.exemptRoleIds, name: input.name, trigger_metadata: method === "POST" || Object.values(triggerMetadata).some((value) => value !== undefined) ? triggerMetadata : undefined, trigger_type: input.triggerType === undefined ? undefined : triggerTypes[input.triggerType], }, method, reason: input.reason, responseSchema: DISCORD_PROVIDER_AUTOMOD_RULE_SCHEMA, }, ), ) } /** * Converts typed action metadata to Discord's provider fields. * * @param action - Validated Auto Moderation action. */ function toProviderAutomodActionMetadata( action: z.output, ) { switch (action.type) { case "blockMessage": return { custom_message: action.customMessage } case "sendAlert": return { channel_id: action.channelId } case "timeoutMember": return { duration_seconds: action.durationSeconds } case "blockMemberInteraction": return {} } } /** * Resolves Discord's event type, including the trigger-dependent default. * * @param input - Auto Moderation event and trigger fields. */ function resolveAutomodEventType( input: Partial>, ) { const eventType = input.eventType ?? (input.triggerType === undefined ? undefined : input.triggerType === "memberProfile" ? "memberUpdate" : "messageSend") return eventType === undefined ? undefined : eventType === "messageSend" ? 1 : 2 } /** * Rejects incompatible Discord Auto Moderation fields. * * @param input - Auto Moderation event and trigger fields. * @param context - Zod refinement context. * @param requireTriggerMetadata - Whether required creation metadata must * exist. */ function validateAutomodFields( input: Partial>, context: z.RefinementCtx, requireTriggerMetadata: boolean, ) { if ( requireTriggerMetadata && input.triggerType === "mentionSpam" && input.mentionTotalLimit === undefined ) { context.addIssue({ code: "custom", message: "mentionSpam rules require mentionTotalLimit.", path: ["mentionTotalLimit"], }) } if ( requireTriggerMetadata && input.triggerType === "keywordPreset" && input.presets === undefined ) { context.addIssue({ code: "custom", message: "keywordPreset rules require at least one preset.", path: ["presets"], }) } if ( input.eventType !== undefined && input.triggerType !== undefined && (input.triggerType === "memberProfile") !== (input.eventType === "memberUpdate") ) { context.addIssue({ code: "custom", message: "memberProfile requires memberUpdate; other trigger types require messageSend.", path: ["eventType"], }) } const triggerType = input.triggerType if (triggerType === undefined) return input.actions?.forEach((action, index) => { if ( AUTOMOD_ACTION_COMPATIBILITY[triggerType].some( (type) => type === action.type, ) ) { return } context.addIssue({ code: "custom", message: `${action.type} is not supported for ${triggerType} rules.`, path: ["actions", index, "type"], }) }) const supportedMetadata: readonly string[] = AUTOMOD_TRIGGER_METADATA_COMPATIBILITY[triggerType] AUTOMOD_TRIGGER_METADATA_FIELDS.forEach((field) => { if (input[field] === undefined || supportedMetadata.includes(field)) return context.addIssue({ code: "custom", message: `${field} is not supported for ${triggerType} rules.`, path: [field], }) }) } /** * Resolves Discord's numeric Auto Moderation trigger type. * * @param triggerType - Numeric provider trigger type. * @throws When Discord returns a trigger type the integration does not support. */ function fromProviderAutomodTriggerType(triggerType: number) { switch (triggerType) { case 1: return "keyword" case 3: return "spam" case 4: return "keywordPreset" case 5: return "mentionSpam" case 6: return "memberProfile" default: throw new TerminalActionError( `Auto Moderation trigger type ${triggerType} is not supported.`, ) } }