import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getSlackApi, toSlackMessageBody } from "../lib/api" import { SLACK_CHAT_WRITE_SCOPE, SLACK_IM_WRITE_SCOPE, SLACK_MESSAGE_WRITE_REQUIREMENT, SLACK_PUBLIC_MESSAGE_WRITE_REQUIREMENT, } from "../lib/scopes" import { SLACK_CONVERSATION_SCHEMA, SLACK_JSON_OBJECT_SCHEMA, SLACK_MESSAGE_CONTENT_SCHEMA, SLACK_MESSAGE_SCHEMA, SLACK_PROVIDER_CONVERSATION_SCHEMA, SLACK_PROVIDER_MESSAGE_SCHEMA, toSlackConversation, toSlackMessage, } from "@automate.ax/integration-contracts/slack" const SLACK_CONVERSATION_INPUT = { /** Slack conversation ID or channel name. */ conversation: z .string() .trim() .min(1) .transform((conversation) => conversation.replace(/^#/, "")), } const OPEN_CONVERSATION_RESPONSE_SCHEMA = z.looseObject({ already_open: z.boolean().optional(), channel: SLACK_PROVIDER_CONVERSATION_SCHEMA, no_op: z.boolean().optional(), ok: z.literal(true), }) const POST_MESSAGE_RESPONSE_SCHEMA = z.looseObject({ channel: z.string(), message: SLACK_PROVIDER_MESSAGE_SCHEMA, ok: z.literal(true), ts: z.string(), }) const UPDATE_MESSAGE_RESPONSE_SCHEMA = z.looseObject({ channel: z.string(), message: SLACK_PROVIDER_MESSAGE_SCHEMA.extend({ ts: z.string().optional(), }), ok: z.literal(true), text: z.string().optional(), ts: z.string(), }) const DELETE_MESSAGE_RESPONSE_SCHEMA = z.looseObject({ channel: z.string(), ok: z.literal(true), ts: z.string(), }) const SCHEDULE_MESSAGE_RESPONSE_SCHEMA = z.looseObject({ channel: z.string(), message: SLACK_JSON_OBJECT_SCHEMA.optional(), ok: z.literal(true), post_at: z.number().int().nonnegative(), scheduled_message_id: z.string(), }) const DELETE_SCHEDULED_MESSAGE_RESPONSE_SCHEMA = z.looseObject({ ok: z.literal(true), }) const EPHEMERAL_MESSAGE_RESPONSE_SCHEMA = z.looseObject({ message_ts: z.string(), ok: z.literal(true), }) const MESSAGE_PERMALINK_RESPONSE_SCHEMA = z.looseObject({ channel: z.string(), ok: z.literal(true), permalink: z.url(), }) const SCHEDULED_MESSAGE_ITEM_SCHEMA = z.looseObject({ blocks: SLACK_JSON_OBJECT_SCHEMA.array().optional(), channel_id: z.string(), date_created: z.number().int().nonnegative(), id: z.string(), post_at: z.coerce.number().int().nonnegative(), text: z.string(), }) const LIST_SCHEDULED_MESSAGES_RESPONSE_SCHEMA = z.looseObject({ ok: z.literal(true), response_metadata: z .looseObject({ next_cursor: z.string().optional() }) .optional(), scheduled_messages: SCHEDULED_MESSAGE_ITEM_SCHEMA.array(), }) const POSTED_MESSAGE_SCHEMA = z.object({ /** Slack conversation ID containing the message. */ conversationId: z.string(), /** Rich provider response for the posted message. */ message: SLACK_MESSAGE_SCHEMA, /** Stable Slack message timestamp. */ timestamp: z.string(), }) const DELETED_MESSAGE_SCHEMA = z.object({ /** Slack conversation ID that contained the message. */ conversationId: z.string(), /** Timestamp of the deleted message. */ timestamp: z.string(), }) const SCHEDULED_MESSAGE_SCHEMA = z.object({ /** Slack conversation ID that will receive the message. */ conversationId: z.string(), /** Provider preview of the scheduled message, when returned. */ message: SLACK_JSON_OBJECT_SCHEMA.optional(), /** Unix timestamp in seconds when Slack will send the message. */ postAt: z.number().int().nonnegative(), /** Stable ID used to cancel the pending message. */ scheduledMessageId: z.string(), }) const SCHEDULED_MESSAGE_PAGE_SCHEMA = z.object({ /** Cursor for the next page, when Slack returned one. */ nextCursor: z.string().optional(), /** Pending messages scheduled by this same Slack token. */ scheduledMessages: z .object({ /** Block Kit content attached to the scheduled message. */ blocks: SLACK_JSON_OBJECT_SCHEMA.array().optional(), /** Slack conversation that will receive the message. */ conversationId: z.string(), /** Unix timestamp when Slack created the scheduled message. */ createdAt: z.number().int().nonnegative(), /** Unix timestamp when Slack will send the message. */ postAt: z.number().int().nonnegative(), /** ID accepted by the cancellation action. */ scheduledMessageId: z.string(), /** Scheduled plain-text content or accessibility fallback. */ text: z.string(), }) .array(), }) /** Opens or resumes a one-to-one direct message with a Slack member. */ export const openSlackDirectMessage = defineAction("Open Slack direct message") .describe("Opens or resumes a direct message with a Slack member.") .account("slack", SLACK_IM_WRITE_SCOPE) .input( z.object({ /** Prevent creating a new DM when none already exists. */ preventCreation: z.boolean().optional(), /** Stable Slack user ID for the other participant. */ userId: z.string().min(1), }), ) .output( z.object({ /** Whether Slack found the direct message already open. */ alreadyOpen: z.boolean(), /** Direct-message conversation. */ conversation: SLACK_CONVERSATION_SCHEMA, }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getSlackApi(account.secret).call( "conversations.open", { body: { prevent_creation: input.preventCreation ?? false, return_im: true, users: input.userId, }, responseSchema: OPEN_CONVERSATION_RESPONSE_SCHEMA, }, ) return { alreadyOpen: result.already_open ?? result.no_op ?? false, conversation: toSlackConversation(result.channel), } }) /** Sends a text, Block Kit, or legacy attachment message to Slack. */ export const sendSlackMessage = defineAction("Send Slack message") .describe("Sends a message to a Slack channel or direct conversation.") .account("slack", SLACK_MESSAGE_WRITE_REQUIREMENT) .input(SLACK_MESSAGE_CONTENT_SCHEMA.safeExtend(SLACK_CONVERSATION_INPUT)) .output(POSTED_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => postMessage(account.secret, input.conversation, input), ) /** Sends a message to a public channel without requiring bot membership. */ export const sendSlackMessageToPublicChannel = defineAction( "Send Slack message to public channel", ) .describe("Sends a message to a public channel without joining it first.") .account("slack", SLACK_PUBLIC_MESSAGE_WRITE_REQUIREMENT) .input( SLACK_MESSAGE_CONTENT_SCHEMA.safeExtend({ /** Stable ID of the destination public channel. */ channelId: z.string().min(1), }), ) .output(POSTED_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => postMessage(account.secret, input.channelId, input), ) /** Replies inside an existing Slack message thread. */ export const replyToSlackThread = defineAction("Reply to Slack thread") .describe("Posts a reply inside a Slack message thread.") .account("slack", SLACK_MESSAGE_WRITE_REQUIREMENT) .input( SLACK_MESSAGE_CONTENT_SCHEMA.safeExtend({ ...SLACK_CONVERSATION_INPUT, /** Whether the reply should also appear in the conversation timeline. */ replyBroadcast: z.boolean().optional(), /** Timestamp of the parent message receiving the reply. */ threadTimestamp: z.string().min(1), }), ) .output(POSTED_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => postMessage(account.secret, input.conversation, { ...input, replyBroadcast: input.replyBroadcast, threadTimestamp: input.threadTimestamp, }), ) /** Sends a non-persistent message visible only to one channel member. */ export const sendEphemeralSlackMessage = defineAction( "Send ephemeral Slack message", ) .describe("Sends a non-persistent Slack message to one channel member.") .account("slack", SLACK_CHAT_WRITE_SCOPE) .input( SLACK_MESSAGE_CONTENT_SCHEMA.safeExtend({ /** Slack conversation ID containing the recipient. */ conversationId: z .string() .trim() .regex(/^[CDG][A-Z0-9]+$/u, "Expected a Slack conversation ID."), /** Parent timestamp when the ephemeral message belongs in a thread. */ threadTimestamp: z.string().min(1).optional(), /** Slack member who alone should see the message. */ userId: z.string().min(1), }), ) .output( z.object({ /** Conversation containing the ephemeral message. */ conversationId: z.string(), /** Timestamp returned for the ephemeral delivery. */ timestamp: z.string(), }), ) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => ({ conversationId: input.conversationId, timestamp: ( await getSlackApi(account.secret).call("chat.postEphemeral", { body: { channel: input.conversationId, ...(input.threadTimestamp && { thread_ts: input.threadTimestamp }), ...toSlackMessageBody(input), user: input.userId, }, responseSchema: EPHEMERAL_MESSAGE_RESPONSE_SCHEMA, }) ).message_ts, })) /** * Updates a message posted by the installed Slack bot. * * Supplying `text` without `blocks` removes the previous blocks, matching * Slack's provider behavior. Pass an empty blocks array to explicitly clear * blocks while retaining other content. */ export const updateSlackMessage = defineAction("Update Slack message") .describe("Updates a message posted by the installed Slack bot.") .account("slack", SLACK_CHAT_WRITE_SCOPE) .input( SLACK_MESSAGE_CONTENT_SCHEMA.extend({ /** Conversation containing the message. */ conversationId: z.string().min(1), /** Timestamp of the message to update. */ timestamp: z.string().min(1), }), ) .output(POSTED_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getSlackApi(account.secret).call("chat.update", { body: { as_user: true, channel: input.conversationId, ts: input.timestamp, ...toSlackMessageBody(input), }, responseSchema: UPDATE_MESSAGE_RESPONSE_SCHEMA, }) return { conversationId: result.channel, message: toSlackMessage({ ...result.message, text: result.message.text || result.text || "", ts: result.message.ts ?? result.ts, }), timestamp: result.ts, } }) /** Deletes a message posted by the installed Slack bot. */ export const deleteSlackMessage = defineAction("Delete Slack message") .describe("Deletes a message posted by the installed Slack bot.") .account("slack", SLACK_CHAT_WRITE_SCOPE) .input( z.object({ /** Conversation containing the message. */ conversationId: z.string().min(1), /** Timestamp of the message to delete. */ timestamp: z.string().min(1), }), ) .output(DELETED_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getSlackApi(account.secret).call("chat.delete", { body: { channel: input.conversationId, ts: input.timestamp, }, responseSchema: DELETE_MESSAGE_RESPONSE_SCHEMA, }) return { conversationId: result.channel, timestamp: result.ts, } }) /** Schedules a Slack message for delivery up to 120 days in the future. */ export const scheduleSlackMessage = defineAction("Schedule Slack message") .describe("Schedules a Slack message for future delivery.") .account("slack", SLACK_MESSAGE_WRITE_REQUIREMENT) .input( SLACK_MESSAGE_CONTENT_SCHEMA.safeExtend({ ...SLACK_CONVERSATION_INPUT, /** Delivery time as a Date or Unix timestamp in seconds. */ postAt: z.union([z.date(), z.number().int().nonnegative()]), /** Parent message timestamp for a scheduled thread reply. */ threadTimestamp: z.string().min(1).optional(), }), ) .output(SCHEDULED_MESSAGE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const result = await getSlackApi(account.secret).call( "chat.scheduleMessage", { body: { channel: input.conversation, post_at: input.postAt instanceof Date ? Math.floor(input.postAt.getTime() / 1000) : input.postAt, ...(input.threadTimestamp && { thread_ts: input.threadTimestamp }), ...toSlackMessageBody(input), }, responseSchema: SCHEDULE_MESSAGE_RESPONSE_SCHEMA, }, ) return { conversationId: result.channel, message: result.message, postAt: result.post_at, scheduledMessageId: result.scheduled_message_id, } }) /** Cancels a pending scheduled Slack message. */ export const cancelScheduledSlackMessage = defineAction( "Cancel scheduled Slack message", ) .describe("Cancels a Slack message before its scheduled delivery.") .account("slack", SLACK_CHAT_WRITE_SCOPE) .input( z.object({ /** Conversation that was going to receive the message. */ conversationId: z.string().min(1), /** ID returned by `scheduleMessage`. */ scheduledMessageId: z.string().min(1), }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getSlackApi(account.secret).call("chat.deleteScheduledMessage", { body: { channel: input.conversationId, scheduled_message_id: input.scheduledMessageId, }, responseSchema: DELETE_SCHEDULED_MESSAGE_RESPONSE_SCHEMA, }) }) /** Lists pending messages scheduled through the Slack API by this token. */ export const listScheduledSlackMessages = defineAction( "List scheduled Slack messages", ) .describe("Lists pending messages scheduled with this Slack account.") .account("slack") .input( z.object({ /** Restricts results to one conversation. */ conversationId: z.string().min(1).optional(), /** Cursor returned by the preceding page. */ cursor: z.string().min(1).optional(), /** Latest scheduled Unix timestamp to include. */ latest: z.number().int().nonnegative().optional(), /** Maximum number of scheduled messages to request. */ limit: z.number().int().min(1).optional(), /** Earliest scheduled Unix timestamp to include. */ oldest: z.number().int().nonnegative().optional(), }), ) .output(SCHEDULED_MESSAGE_PAGE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await getSlackApi(account.secret).call( "chat.scheduledMessages.list", { query: { channel: input.conversationId, cursor: input.cursor, latest: input.latest, limit: input.limit, oldest: input.oldest, }, responseSchema: LIST_SCHEDULED_MESSAGES_RESPONSE_SCHEMA, }, ) return { nextCursor: result.response_metadata?.next_cursor || undefined, scheduledMessages: result.scheduled_messages.map((message) => ({ blocks: message.blocks, conversationId: message.channel_id, createdAt: message.date_created, postAt: message.post_at, scheduledMessageId: message.id, text: message.text, })), } }) /** Retrieves Slack's permanent URL for one message. */ export const getSlackMessagePermalink = defineAction( "Get Slack message permalink", ) .describe("Gets a permanent Slack URL for a message.") .account("slack") .input( z.object({ /** Conversation containing the message. */ conversationId: z.string().min(1), /** Timestamp identifying the message. */ timestamp: z.string().min(1), }), ) .output( z.object({ /** Conversation containing the message. */ conversationId: z.string(), /** Permanent Slack URL for the message. */ permalink: z.url(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await getSlackApi(account.secret).call("chat.getPermalink", { query: { channel: input.conversationId, message_ts: input.timestamp, }, responseSchema: MESSAGE_PERMALINK_RESPONSE_SCHEMA, }) return { conversationId: result.channel, permalink: result.permalink } }) /** * Sends a top-level message or thread reply. * * @param secret - Resolved Slack integration secret. * @param conversationId - Target Slack conversation. * @param input - Message content and optional thread fields. */ async function postMessage( secret: Record, conversationId: string, input: z.output & { replyBroadcast?: boolean threadTimestamp?: string }, ) { const result = await getSlackApi(secret).call("chat.postMessage", { body: { channel: conversationId, ...(input.replyBroadcast !== undefined && { reply_broadcast: input.replyBroadcast, }), ...(input.threadTimestamp && { thread_ts: input.threadTimestamp }), ...toSlackMessageBody(input), }, responseSchema: POST_MESSAGE_RESPONSE_SCHEMA, }) return { conversationId: result.channel, message: toSlackMessage(result.message), timestamp: result.ts, } }