import { DISCORD_CHANNEL_SCHEMA, DISCORD_PROVIDER_CHANNEL_SCHEMA, toDiscordChannel, } from "@automate.ax/integration-contracts/discord" import * as z from "zod" import { defineAction, TerminalActionError } from "../../../automation/actions" import { getDiscordApi } from "../lib/api" const DISCORD_CHANNEL_TYPE_SCHEMA = z.enum([ "text", "voice", "category", "announcement", "stage", "forum", "media", ]) const DISCORD_CHANNEL_VALIDATION_TYPE_SCHEMA = z.enum([ ...DISCORD_CHANNEL_TYPE_SCHEMA.options, "thread", ]) const DISCORD_CHANNEL_TYPE = { announcement: 5, category: 4, forum: 15, media: 16, stage: 13, text: 0, voice: 2, } as const const CHANNEL_FIELDS_SCHEMA = z.object({ bitrate: z.number().int().min(8_000).max(384_000).optional(), name: z.string().trim().min(1).max(100).optional(), nsfw: z.boolean().optional(), parentId: z.string().regex(/^\d+$/).nullable().optional(), position: z.number().int().nonnegative().optional(), rateLimitPerUser: z.number().int().min(0).max(21_600).optional(), topic: z.string().max(4096).nullable().optional(), userLimit: z.number().int().min(0).max(10_000).optional(), }) const CHANNEL_RESOLVED_UPDATE_FIELDS_SCHEMA = CHANNEL_FIELDS_SCHEMA.extend({ type: DISCORD_CHANNEL_VALIDATION_TYPE_SCHEMA, }).superRefine(validateDiscordChannelFields) /** Lists channels in one Discord server. */ export const listDiscordChannels = defineAction("List Discord channels") .describe("Lists every channel in a Discord server.") .account("discord") .input(z.object({ guildId: z.string().regex(/^\d+$/) })) .output(DISCORD_CHANNEL_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ( await getDiscordApi(account.secret).request( `guilds/${input.guildId}/channels`, { responseSchema: DISCORD_PROVIDER_CHANNEL_SCHEMA.array() }, ) ).map(toDiscordChannel), ) /** Retrieves one Discord channel or thread. */ export const getDiscordChannel = defineAction("Get Discord channel") .describe("Retrieves one channel or thread by ID.") .account("discord") .input(z.object({ channelId: z.string().regex(/^\d+$/) })) .output(DISCORD_CHANNEL_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => toDiscordChannel( await getDiscordApi(account.secret).request( `channels/${input.channelId}`, { responseSchema: DISCORD_PROVIDER_CHANNEL_SCHEMA }, ), ), ) /** Creates a channel in a Discord server. */ export const createDiscordChannel = defineAction("Create Discord channel") .describe( "Creates a text, voice, category, announcement, stage, forum, or media channel.", ) .account("discord") .input( CHANNEL_FIELDS_SCHEMA.extend({ guildId: z.string().regex(/^\d+$/), name: z.string().trim().min(1).max(100), reason: z.string().max(512).optional(), type: DISCORD_CHANNEL_TYPE_SCHEMA.prefault("text"), }).superRefine(validateDiscordChannelFields), ) .output(DISCORD_CHANNEL_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => toDiscordChannel( await getDiscordApi(account.secret).request( `guilds/${input.guildId}/channels`, { body: { bitrate: input.bitrate, name: input.name, nsfw: input.nsfw, parent_id: input.parentId, position: input.position, rate_limit_per_user: input.rateLimitPerUser, topic: input.topic, type: DISCORD_CHANNEL_TYPE[input.type], user_limit: input.userLimit, }, method: "POST", reason: input.reason, responseSchema: DISCORD_PROVIDER_CHANNEL_SCHEMA, }, ), ), ) /** Updates a Discord channel or thread. */ export const updateDiscordChannel = defineAction("Update Discord channel") .describe( "Updates mutable channel fields such as name, topic, category, or slowmode.", ) .account("discord") .input( CHANNEL_FIELDS_SCHEMA.extend({ channelId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), }), ) .output(DISCORD_CHANNEL_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { if ( input.bitrate !== undefined || input.nsfw !== undefined || input.parentId !== undefined || input.position !== undefined || input.rateLimitPerUser !== undefined || input.topic !== undefined || input.userLimit !== undefined ) { const validation = CHANNEL_RESOLVED_UPDATE_FIELDS_SCHEMA.safeParse({ ...input, type: fromProviderDiscordChannelType( ( await getDiscordApi(account.secret).request( `channels/${input.channelId}`, { responseSchema: DISCORD_PROVIDER_CHANNEL_SCHEMA }, ) ).type, ), }) if (!validation.success) { throw new TerminalActionError( validation.error.issues[0]?.message ?? "Invalid channel update.", { code: "validation_failed", cause: validation.error }, ) } } return toDiscordChannel( await getDiscordApi(account.secret).request( `channels/${input.channelId}`, { body: { bitrate: input.bitrate, name: input.name, nsfw: input.nsfw, parent_id: input.parentId, position: input.position, rate_limit_per_user: input.rateLimitPerUser, topic: input.topic, user_limit: input.userLimit, }, method: "PATCH", reason: input.reason, responseSchema: DISCORD_PROVIDER_CHANNEL_SCHEMA, }, ), ) }) /** Deletes or closes a Discord channel or thread. */ export const deleteDiscordChannel = defineAction("Delete Discord channel") .describe("Deletes a channel or closes a private thread.") .account("discord") .input( z.object({ channelId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), }), ) .output(DISCORD_CHANNEL_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => toDiscordChannel( await getDiscordApi(account.secret).request( `channels/${input.channelId}`, { method: "DELETE", reason: input.reason, responseSchema: DISCORD_PROVIDER_CHANNEL_SCHEMA, }, ), ), ) /** Creates or replaces a Discord channel permission overwrite. */ export const setDiscordChannelPermission = defineAction( "Set Discord channel permission", ) .describe("Creates or replaces a role or member permission overwrite.") .account("discord") .input( z.object({ allow: z.string().regex(/^\d+$/).prefault("0"), channelId: z.string().regex(/^\d+$/), deny: z.string().regex(/^\d+$/).prefault("0"), overwriteId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), type: z.enum(["role", "member"]), }), ) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getDiscordApi(account.secret).request( `channels/${input.channelId}/permissions/${input.overwriteId}`, { body: { allow: input.allow, deny: input.deny, type: input.type === "role" ? 0 : 1, }, method: "PUT", reason: input.reason, responseSchema: z.void(), }, ), ) /** Deletes a Discord channel permission overwrite. */ export const deleteDiscordChannelPermission = defineAction( "Delete Discord channel permission", ) .describe("Deletes a role or member permission overwrite from a channel.") .account("discord") .input( z.object({ channelId: z.string().regex(/^\d+$/), overwriteId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), }), ) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getDiscordApi(account.secret).request( `channels/${input.channelId}/permissions/${input.overwriteId}`, { method: "DELETE", reason: input.reason, responseSchema: z.void(), }, ), ) /** * Rejects fields incompatible with a Discord channel type. * * @param input - Channel fields and resolved type. * @param context - Zod refinement context. */ function validateDiscordChannelFields( input: z.output & { type: z.output }, context: z.RefinementCtx, ) { const isVoice = input.type === "voice" || input.type === "stage" const supportsTopic = ["text", "announcement", "forum", "media"].some( (type) => type === input.type, ) const supportsNsfw = ["text", "voice", "announcement", "stage", "forum"].some( (type) => type === input.type, ) const supportsParent = [ "text", "voice", "announcement", "stage", "forum", "media", ].some((type) => type === input.type) const supportsSlowmode = [ "text", "voice", "stage", "forum", "media", "thread", ].some((type) => type === input.type) if (input.topic !== undefined && !supportsTopic) { context.addIssue({ code: "custom", message: "topic is only available for text, announcement, forum, and media channels.", path: ["topic"], }) } if ( input.topic != null && (input.type === "text" || input.type === "announcement") && input.topic.length > 1_024 ) { context.addIssue({ code: "custom", message: "Text and announcement channel topics cannot exceed 1024 characters.", path: ["topic"], }) } if (!isVoice && input.bitrate !== undefined) { context.addIssue({ code: "custom", message: "bitrate is only available for voice and stage channels.", path: ["bitrate"], }) } if (!isVoice && input.userLimit !== undefined) { context.addIssue({ code: "custom", message: "userLimit is only available for voice and stage channels.", path: ["userLimit"], }) } if (input.nsfw !== undefined && !supportsNsfw) { context.addIssue({ code: "custom", message: "nsfw is only available for text, voice, announcement, stage, and forum channels.", path: ["nsfw"], }) } if (input.parentId !== undefined && !supportsParent) { context.addIssue({ code: "custom", message: "parentId is not available for category channels or threads.", path: ["parentId"], }) } if (input.position !== undefined && input.type === "thread") { context.addIssue({ code: "custom", message: "position is not available for threads.", path: ["position"], }) } if (input.rateLimitPerUser !== undefined && !supportsSlowmode) { context.addIssue({ code: "custom", message: "rateLimitPerUser is not available for category or announcement channels.", path: ["rateLimitPerUser"], }) } if (input.type === "stage" && (input.bitrate ?? 0) > 64_000) { context.addIssue({ code: "custom", message: "Stage channel bitrate cannot exceed 64000.", path: ["bitrate"], }) } if (input.type === "voice" && (input.userLimit ?? 0) > 99) { context.addIssue({ code: "custom", message: "Voice channel userLimit cannot exceed 99.", path: ["userLimit"], }) } } /** * Resolves Discord's numeric channel type for update validation. * * @param type - Numeric provider channel type. * @throws When Discord returns an unsupported channel type. */ function fromProviderDiscordChannelType(type: number) { switch (type) { case 0: return "text" case 2: return "voice" case 4: return "category" case 5: return "announcement" case 10: case 11: case 12: return "thread" case 13: return "stage" case 15: return "forum" case 16: return "media" default: throw new TerminalActionError( `Discord channel type ${type} is not supported.`, ) } }