import { DISCORD_MESSAGE_SCHEMA, DISCORD_PROVIDER_MESSAGE_SCHEMA, DISCORD_PROVIDER_USER_SCHEMA, DISCORD_USER_SCHEMA, toDiscordMessage, toDiscordUser, } from "@automate.ax/integration-contracts/discord" import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getDiscordApi } from "../lib/api" const DISCORD_EPOCH_MILLISECONDS = 1_420_070_400_000n const MAXIMUM_BULK_DELETE_AGE_MILLISECONDS = 14 * 24 * 60 * 60 * 1_000 const MESSAGE_CONTENT_SCHEMA = z.object({ content: z.string().min(1).max(2_000), suppressEmbeds: z.boolean().prefault(false), tts: z.boolean().prefault(false), }) interface MessageReference { fail_if_not_exists: boolean message_id: string } interface PinRequestInput { channelId: string messageId: string reason?: string } interface ReactionRequestInput { channelId: string emoji: string messageId: string } /** Lists messages in one Discord channel. */ export const listDiscordMessages = defineAction("List Discord messages") .describe("Lists a page of messages before, after, or around a message ID.") .account("discord") .input( z .object({ after: z.string().regex(/^\d+$/).optional(), around: z.string().regex(/^\d+$/).optional(), before: z.string().regex(/^\d+$/).optional(), channelId: z.string().regex(/^\d+$/), limit: z.number().int().min(1).max(100).prefault(50), }) .refine( ({ after, around, before }) => [after, around, before].filter(Boolean).length <= 1, { message: "Use only one of after, around, or before." }, ), ) .output(DISCORD_MESSAGE_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ( await getDiscordApi(account.secret).request( `channels/${input.channelId}/messages`, { query: { after: input.after, around: input.around, before: input.before, limit: input.limit, }, responseSchema: DISCORD_PROVIDER_MESSAGE_SCHEMA.array(), }, ) ).map(toDiscordMessage), ) /** Retrieves one Discord message. */ export const getDiscordMessage = defineAction("Get Discord message") .describe("Retrieves one message from a channel.") .account("discord") .input( z.object({ channelId: z.string().regex(/^\d+$/), messageId: z.string().regex(/^\d+$/), }), ) .output(DISCORD_MESSAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => toDiscordMessage( await getDiscordApi(account.secret).request( `channels/${input.channelId}/messages/${input.messageId}`, { responseSchema: DISCORD_PROVIDER_MESSAGE_SCHEMA }, ), ), ) /** Sends a text message to a Discord channel. */ export const sendDiscordMessage = defineAction("Send Discord message") .describe("Sends a text message to a channel or thread.") .account("discord") .input( MESSAGE_CONTENT_SCHEMA.extend({ channelId: z.string().regex(/^\d+$/), }), ) .output(DISCORD_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => createMessage(account.secret, input.channelId, input), ) /** Replies to an existing Discord message. */ export const replyToDiscordMessage = defineAction("Reply to Discord message") .describe("Sends a reply that references an existing message.") .account("discord") .input( MESSAGE_CONTENT_SCHEMA.extend({ channelId: z.string().regex(/^\d+$/), failIfNotExists: z.boolean().prefault(true), messageId: z.string().regex(/^\d+$/), }), ) .output(DISCORD_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => createMessage(account.secret, input.channelId, input, { fail_if_not_exists: input.failIfNotExists, message_id: input.messageId, }), ) /** Updates a Discord message authored by the connected bot. */ export const updateDiscordMessage = defineAction("Update Discord message") .describe( "Replaces the content or embed-suppression flag of a bot-authored message.", ) .account("discord") .input( z.object({ channelId: z.string().regex(/^\d+$/), content: z.string().max(2_000).optional(), messageId: z.string().regex(/^\d+$/), suppressEmbeds: z.boolean().optional(), }), ) .output(DISCORD_MESSAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => toDiscordMessage( await getDiscordApi(account.secret).request( `channels/${input.channelId}/messages/${input.messageId}`, { body: { content: input.content, flags: input.suppressEmbeds === undefined ? undefined : input.suppressEmbeds ? 4 : 0, }, method: "PATCH", responseSchema: DISCORD_PROVIDER_MESSAGE_SCHEMA, }, ), ), ) /** Deletes one Discord message. */ export const deleteDiscordMessage = defineAction("Delete Discord message") .describe("Deletes one message from a Discord channel.") .account("discord") .input( z.object({ channelId: z.string().regex(/^\d+$/), messageId: 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}/messages/${input.messageId}`, { method: "DELETE", reason: input.reason, responseSchema: z.void(), }, ), ) /** Bulk deletes recent Discord messages. */ export const bulkDeleteDiscordMessages = defineAction( "Bulk delete Discord messages", ) .describe("Deletes 2–100 messages that are each newer than 14 days.") .account("discord") .input( z.object({ channelId: z.string().regex(/^\d+$/), messageIds: z .string() .regex(/^\d+$/) .array() .min(2) .max(100) .refine((ids) => new Set(ids).size === ids.length, { message: "messageIds must be unique.", }) .refine((ids) => ids.every(isRecentDiscordSnowflake), { message: "Bulk-deleted messages cannot be older than 14 days.", }), reason: z.string().max(512).optional(), }), ) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => getDiscordApi(account.secret).request( `channels/${input.channelId}/messages/bulk-delete`, { body: { messages: input.messageIds }, method: "POST", reason: input.reason, responseSchema: z.void(), }, ), ) /** @param id - Discord snowflake to compare with the bulk-delete cutoff. */ function isRecentDiscordSnowflake(id: string) { return ( Number((BigInt(id) >> 22n) + DISCORD_EPOCH_MILLISECONDS) >= Date.now() - MAXIMUM_BULK_DELETE_AGE_MILLISECONDS ) } /** Adds the connected bot's reaction to a Discord message. */ export const addDiscordReaction = defineAction("Add Discord reaction") .describe("Adds a Unicode or custom emoji reaction to a message.") .account("discord") .input(reactionInputSchema()) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => reactionRequest(account.secret, input, "PUT"), ) /** Removes the connected bot's reaction from a Discord message. */ export const removeOwnDiscordReaction = defineAction( "Remove own Discord reaction", ) .describe("Removes the connected bot's Unicode or custom emoji reaction.") .account("discord") .input(reactionInputSchema()) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => reactionRequest(account.secret, input, "DELETE"), ) /** Pins a Discord message. */ export const pinDiscordMessage = defineAction("Pin Discord message") .describe("Pins a message in its channel.") .account("discord") .input(pinInputSchema()) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => pinRequest(account.secret, input, "PUT"), ) /** Unpins a Discord message. */ export const unpinDiscordMessage = defineAction("Unpin Discord message") .describe("Removes a message from its channel's pins.") .account("discord") .input(pinInputSchema()) .output(z.void()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => pinRequest(account.secret, input, "DELETE"), ) /** Ends a poll attached to a Discord message. */ export const endDiscordPoll = defineAction("End Discord poll") .describe("Closes a Discord message poll before its scheduled expiry.") .account("discord") .input( z.object({ channelId: z.string().regex(/^\d+$/), messageId: z.string().regex(/^\d+$/), }), ) .output(DISCORD_MESSAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => toDiscordMessage( await getDiscordApi(account.secret).request( `channels/${input.channelId}/polls/${input.messageId}/expire`, { method: "POST", responseSchema: DISCORD_PROVIDER_MESSAGE_SCHEMA }, ), ), ) /** Lists users who selected one answer in a Discord poll. */ export const listDiscordPollAnswerVoters = defineAction( "List Discord poll answer voters", ) .describe("Lists a page of users who selected one poll answer.") .account("discord") .input( z.object({ after: z.string().regex(/^\d+$/).optional(), answerId: z.number().int().positive(), channelId: z.string().regex(/^\d+$/), limit: z.number().int().min(1).max(100).prefault(25), messageId: z.string().regex(/^\d+$/), }), ) .output(DISCORD_USER_SCHEMA.array()) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => ( await getDiscordApi(account.secret).request( `channels/${input.channelId}/polls/${input.messageId}/answers/${input.answerId}`, { query: { after: input.after, limit: input.limit }, responseSchema: z.looseObject({ users: DISCORD_PROVIDER_USER_SCHEMA.array(), }), }, ) ).users.map(toDiscordUser), ) /** Builds the shared reaction action input schema. */ function reactionInputSchema() { return z.object({ channelId: z.string().regex(/^\d+$/), emoji: z.string().trim().min(1), messageId: z.string().regex(/^\d+$/), }) } /** Builds the shared pin action input schema. */ function pinInputSchema() { return z.object({ channelId: z.string().regex(/^\d+$/), messageId: z.string().regex(/^\d+$/), reason: z.string().max(512).optional(), }) } /** * Creates a Discord message or reply. * * @param secret - Connected Discord account secret. * @param channelId - Destination channel ID. * @param input - Message content and flags. * @param messageReference - Optional reply target. */ async function createMessage( secret: Record, channelId: string, input: z.output, messageReference?: MessageReference, ) { return toDiscordMessage( await getDiscordApi(secret).request(`channels/${channelId}/messages`, { body: { content: input.content, flags: input.suppressEmbeds ? 4 : undefined, message_reference: messageReference, tts: input.tts, }, method: "POST", responseSchema: DISCORD_PROVIDER_MESSAGE_SCHEMA, }), ) } /** * Adds or removes the connected bot's reaction. * * @param secret - Connected Discord account secret. * @param input - Message and emoji identifiers. * @param method - Discord reaction method. */ async function reactionRequest( secret: Record, input: ReactionRequestInput, method: "DELETE" | "PUT", ) { return await getDiscordApi(secret).request( `channels/${input.channelId}/messages/${input.messageId}/reactions/${encodeURIComponent(input.emoji)}/@me`, { method, responseSchema: z.void() }, ) } /** * Pins or unpins a Discord message. * * @param secret - Connected Discord account secret. * @param input - Channel, message, and audit-log fields. * @param method - Discord pin method. */ async function pinRequest( secret: Record, input: PinRequestInput, method: "DELETE" | "PUT", ) { return await getDiscordApi(secret).request( `channels/${input.channelId}/pins/${input.messageId}`, { method, reason: input.reason, responseSchema: z.void() }, ) }