import { DISCORD_PROVIDER_SCHEDULED_EVENT_SCHEMA, DISCORD_SCHEDULED_EVENT_SCHEMA, toDiscordScheduledEvent, } from "@automate.ax/integration-contracts/discord" import * as z from "zod" import { defineAction, TerminalActionError } from "../../../automation/actions" import { getDiscordApi } from "../lib/api" const EVENT_FIELDS_SCHEMA = z.object({ channelId: z.string().regex(/^\d+$/).nullable().optional(), description: z.string().max(1_000).nullable().optional(), entityType: z.enum(["stage", "voice", "external"]).optional(), location: z.string().max(100).optional(), name: z.string().trim().min(1).max(100).optional(), scheduledEndTime: z.iso.datetime({ offset: true }).nullable().optional(), scheduledStartTime: z.iso.datetime({ offset: true }).optional(), status: z.enum(["scheduled", "active", "completed", "canceled"]).optional(), }) const RESOLVED_EVENT_FIELDS_SCHEMA = EVENT_FIELDS_SCHEMA.extend({ entityType: z.enum(["stage", "voice", "external"]), scheduledStartTime: z.iso.datetime({ offset: true }), }).superRefine(validateResolvedScheduledEventFields) /** Lists scheduled events in a Discord server. */ export const listDiscordScheduledEvents = defineAction( "List Discord scheduled events", ) .describe("Lists scheduled server events with optional user counts.") .account("discord") .input( z.object({ guildId: z.string().regex(/^\d+$/), withUserCount: z.boolean().prefault(false), }), ) .output(DISCORD_SCHEDULED_EVENT_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ( await getDiscordApi(account.secret).request( `guilds/${input.guildId}/scheduled-events`, { query: { with_user_count: input.withUserCount }, responseSchema: DISCORD_PROVIDER_SCHEDULED_EVENT_SCHEMA.array(), }, ) ).map(toDiscordScheduledEvent), ) /** Creates a scheduled event in a Discord server. */ export const createDiscordScheduledEvent = defineAction( "Create Discord scheduled event", ) .describe("Creates a voice, stage, or external scheduled server event.") .account("discord") .input( EVENT_FIELDS_SCHEMA.omit({ status: true }) .extend({ entityType: z.enum(["stage", "voice", "external"]), guildId: z.string().regex(/^\d+$/), name: z.string().trim().min(1).max(100), reason: z.string().max(512).optional(), scheduledStartTime: z.iso.datetime({ offset: true }), }) .superRefine((input, context) => { validateResolvedScheduledEventFields(input, context) if (Date.parse(input.scheduledStartTime) <= Date.now()) { context.addIssue({ code: "custom", message: "scheduledStartTime must be in the future.", path: ["scheduledStartTime"], }) } }), ) .output(DISCORD_SCHEDULED_EVENT_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => scheduledEventWrite(account.secret, input, "POST"), ) /** Updates a Discord scheduled event. */ export const updateDiscordScheduledEvent = defineAction( "Update Discord scheduled event", ) .describe("Updates a scheduled event's details, timing, location, or status.") .account("discord") .input( EVENT_FIELDS_SCHEMA.extend({ eventId: z.string().regex(/^\d+$/), guildId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), }).superRefine((input, context) => { validateExternalScheduledEventFields(input, context) validateScheduledEventTimes(input, context) if ( input.entityType !== undefined && input.entityType !== "external" && !input.channelId ) { context.addIssue({ code: "custom", message: "Voice and stage events require channelId.", path: ["channelId"], }) } if ( input.entityType !== undefined && input.entityType !== "external" && input.location !== undefined ) { context.addIssue({ code: "custom", message: "Voice and stage events cannot set location.", path: ["location"], }) } }), ) .output(DISCORD_SCHEDULED_EVENT_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { if ( input.channelId !== undefined || input.entityType !== undefined || input.location !== undefined || input.scheduledEndTime !== undefined || input.scheduledStartTime !== undefined || input.status !== undefined ) { const existingEvent = await getDiscordApi(account.secret).request( `guilds/${input.guildId}/scheduled-events/${input.eventId}`, { responseSchema: DISCORD_PROVIDER_SCHEDULED_EVENT_SCHEMA }, ) const entityType = fromProviderScheduledEventType( input.entityType === undefined ? existingEvent.entity_type : { external: 3, stage: 1, voice: 2 }[input.entityType], ) if (input.status !== undefined) { validateScheduledEventStatusTransition( existingEvent.status, input.status, ) } const validation = RESOLVED_EVENT_FIELDS_SCHEMA.safeParse({ ...input, channelId: input.entityType === "external" ? null : input.channelId === undefined ? existingEvent.channel_id : input.channelId, entityType, location: entityType === "external" ? (input.location ?? existingEvent.entity_metadata?.location) : undefined, scheduledEndTime: input.scheduledEndTime === undefined ? existingEvent.scheduled_end_time : input.scheduledEndTime, scheduledStartTime: input.scheduledStartTime ?? existingEvent.scheduled_start_time, }) if (!validation.success) { throw new TerminalActionError( validation.error.issues[0]?.message ?? "Invalid scheduled-event update.", { code: "validation_failed", cause: validation.error }, ) } } return scheduledEventWrite(account.secret, input, "PATCH") }) /** Deletes a Discord scheduled event. */ export const deleteDiscordScheduledEvent = defineAction( "Delete Discord scheduled event", ) .describe("Deletes a scheduled server event.") .account("discord") .input( z.object({ eventId: z.string().regex(/^\d+$/), guildId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), }), ) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getDiscordApi(account.secret).request( `guilds/${input.guildId}/scheduled-events/${input.eventId}`, { method: "DELETE", reason: input.reason, responseSchema: z.void() }, ), ) /** * Creates or updates a scheduled server event. * * @param secret - Connected Discord account secret. * @param input - Normalized event fields. * @param method - Discord write method. */ async function scheduledEventWrite( secret: Record, input: z.output & { eventId?: string guildId: string reason?: string }, method: "PATCH" | "POST", ) { const entityTypes = { external: 3, stage: 1, voice: 2 } as const const statuses = { active: 2, canceled: 4, completed: 3, scheduled: 1, } as const return toDiscordScheduledEvent( await getDiscordApi(secret).request( `guilds/${input.guildId}/scheduled-events${input.eventId ? `/${input.eventId}` : ""}`, { body: { channel_id: input.entityType === "external" ? null : input.channelId, description: input.description, entity_metadata: method === "PATCH" && input.entityType !== undefined && input.entityType !== "external" ? null : input.location === undefined ? undefined : { location: input.location }, entity_type: input.entityType === undefined ? undefined : entityTypes[input.entityType], name: input.name, privacy_level: method === "POST" ? 2 : undefined, scheduled_end_time: input.scheduledEndTime, scheduled_start_time: input.scheduledStartTime, status: input.status === undefined ? undefined : statuses[input.status], }, method, reason: input.reason, responseSchema: DISCORD_PROVIDER_SCHEDULED_EVENT_SCHEMA, }, ), ) } /** * Enforces Discord's required fields when selecting the external entity type. * * @param input - Scheduled-event fields. * @param context - Zod refinement context. */ function validateExternalScheduledEventFields( input: z.output, context: z.RefinementCtx, ) { if (input.entityType !== "external") return if (input.channelId !== undefined && input.channelId !== null) { context.addIssue({ code: "custom", message: "External events cannot set channelId.", path: ["channelId"], }) } if (!input.location) { context.addIssue({ code: "custom", message: "External events require location.", path: ["location"], }) } if (!input.scheduledEndTime) { context.addIssue({ code: "custom", message: "External events require scheduledEndTime.", path: ["scheduledEndTime"], }) } } /** * Validates fields after the scheduled-event entity type is known. * * @param input - Resolved scheduled-event fields. * @param context - Zod refinement context. */ function validateResolvedScheduledEventFields( input: z.output, context: z.RefinementCtx, ) { validateExternalScheduledEventFields(input, context) validateScheduledEventTimes(input, context) if (input.entityType !== "external" && !input.channelId) { context.addIssue({ code: "custom", message: "Voice and stage events require channelId.", path: ["channelId"], }) } if (input.entityType !== "external" && input.location !== undefined) { context.addIssue({ code: "custom", message: "Voice and stage events cannot set location.", path: ["location"], }) } } /** * Requires a supplied scheduled end to occur after its supplied start. * * @param input - Scheduled-event fields. * @param context - Zod refinement context. */ function validateScheduledEventTimes( input: z.output, context: z.RefinementCtx, ) { if ( input.scheduledEndTime != null && input.scheduledStartTime !== undefined && Date.parse(input.scheduledEndTime) <= Date.parse(input.scheduledStartTime) ) { context.addIssue({ code: "custom", message: "scheduledEndTime must be after scheduledStartTime.", path: ["scheduledEndTime"], }) } } /** * Resolves Discord's numeric scheduled-event entity type. * * @param entityType - Numeric provider entity type. * @throws When Discord returns an unsupported entity type. */ function fromProviderScheduledEventType(entityType: number) { switch (entityType) { case 1: return "stage" case 2: return "voice" case 3: return "external" default: throw new TerminalActionError( `Discord scheduled-event entity type ${entityType} is not supported.`, ) } } /** * Enforces Discord's one-way scheduled-event status transitions. * * @param currentStatus - Numeric provider status. * @param nextStatus - Requested public status. * @throws When the requested transition is not supported by Discord. */ function validateScheduledEventStatusTransition( currentStatus: number, nextStatus: "scheduled" | "active" | "completed" | "canceled", ) { const nextStatusValue = { active: 2, canceled: 4, completed: 3, scheduled: 1, }[nextStatus] if ( (currentStatus === 1 && (nextStatusValue === 2 || nextStatusValue === 4)) || (currentStatus === 2 && nextStatusValue === 3) ) { return } throw new TerminalActionError( `Discord scheduled-event status cannot change from ${currentStatus} to ${nextStatus}.`, { code: "validation_failed" }, ) }