import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getSlackApi } from "../lib/api" import { SLACK_FILES_READ_SCOPE, SLACK_FILES_WRITE_SCOPE } from "../lib/scopes" import { SLACK_FILE_SCHEMA, SLACK_PROVIDER_FILE_SCHEMA, toSlackFile, } from "@automate.ax/integration-contracts/slack" const GET_UPLOAD_URL_RESPONSE_SCHEMA = z.looseObject({ file_id: z.string(), ok: z.literal(true), upload_url: z.url(), }) const COMPLETE_UPLOAD_RESPONSE_SCHEMA = z.looseObject({ files: SLACK_PROVIDER_FILE_SCHEMA.array(), ok: z.literal(true), }) const FILE_INFO_RESPONSE_SCHEMA = z.looseObject({ file: SLACK_PROVIDER_FILE_SCHEMA, ok: z.literal(true), }) const FILE_LIST_RESPONSE_SCHEMA = z.looseObject({ files: SLACK_PROVIDER_FILE_SCHEMA.array(), ok: z.literal(true), paging: z.looseObject({ count: z.number().int().nonnegative(), page: z.number().int().nonnegative(), pages: z.number().int().nonnegative(), total: z.number().int().nonnegative(), }), }) const DELETE_FILE_RESPONSE_SCHEMA = z.looseObject({ ok: z.literal(true) }) /** * Uploads a file with Slack's modern external upload flow. * * The file may optionally be shared to a channel or thread. Omitting the * conversation keeps it private to the installed app. */ export const uploadSlackFile = defineAction("Upload Slack file") .describe("Uploads a file and optionally shares it to a Slack conversation.") .account("slack", SLACK_FILES_WRITE_SCOPE) .input( z.object({ /** Accessible description for an uploaded image. */ altText: z.string().max(1000).optional(), /** Conversation where Slack should share the file. */ conversationId: z.string().min(1).optional(), /** File data, media type, size, and default name. */ file: z.instanceof(File), /** Introductory message posted with the shared file. */ initialComment: z.string().optional(), /** Filename presented by Slack instead of the File's name. */ filename: z.string().min(1).optional(), /** Syntax highlighting type for text snippets. */ snippetType: z.string().min(1).optional(), /** Parent message timestamp when sharing into a thread. */ threadTimestamp: z.string().min(1).optional(), /** Human-readable file title. */ title: z.string().min(1).optional(), }), ) .output(SLACK_FILE_SCHEMA) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const slackApi = getSlackApi(account.secret) const ticket = await slackApi.call("files.getUploadURLExternal", { form: { ...(input.altText && { alt_txt: input.altText }), filename: input.filename ?? input.file.name, length: input.file.size, ...(input.snippetType && { snippet_type: input.snippetType }), }, responseSchema: GET_UPLOAD_URL_RESPONSE_SCHEMA, }) const uploadResponse = await fetch(ticket.upload_url, { body: input.file, headers: { "Content-Type": input.file.type || "application/octet-stream", }, method: "POST", }) if (!uploadResponse.ok) { throw new Error( `Slack file upload failed with HTTP ${uploadResponse.status}.`, ) } const [file] = ( await slackApi.call("files.completeUploadExternal", { body: { ...(input.conversationId && { channel_id: input.conversationId }), files: [ { id: ticket.file_id, ...(input.title && { title: input.title }), }, ], ...(input.initialComment && { initial_comment: input.initialComment, }), ...(input.threadTimestamp && { thread_ts: input.threadTimestamp }), }, responseSchema: COMPLETE_UPLOAD_RESPONSE_SCHEMA, }) ).files if (!file) throw new Error("Slack completed the upload without a file.") return toSlackFile(file) }) /** Gets metadata for one Slack file. */ export const getSlackFile = defineAction("Get Slack file") .describe("Gets metadata for a Slack file.") .account("slack", SLACK_FILES_READ_SCOPE) .input(z.object({ fileId: z.string().min(1) })) .output(SLACK_FILE_SCHEMA) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => toSlackFile( ( await getSlackApi(account.secret).call("files.info", { query: { file: input.fileId }, responseSchema: FILE_INFO_RESPONSE_SCHEMA, }) ).file, ), ) /** Lists Slack files with provider-supported filters. */ export const listSlackFiles = defineAction("List Slack files") .describe("Lists Slack files with channel, owner, time, and type filters.") .account("slack", SLACK_FILES_READ_SCOPE) .input( z.object({ /** Restricts files to one conversation. */ conversationId: z.string().min(1).optional(), /** Number of files requested per legacy page. */ count: z.number().int().min(1).optional(), /** Legacy one-based Slack result page. */ page: z.number().int().min(1).optional(), /** Includes truncated metadata for files hidden by plan limits. */ showFilesHiddenByLimit: z.boolean().optional(), /** Inclusive lower Unix timestamp bound. */ startAt: z.number().int().nonnegative().optional(), /** Inclusive upper Unix timestamp bound. */ endAt: z.number().int().nonnegative().optional(), /** Comma-separated Slack file categories such as `images` or `canvas`. */ types: z.string().min(1).optional(), /** Restricts files to one Slack member. */ userId: z.string().min(1).optional(), }), ) .output( z.object({ files: SLACK_FILE_SCHEMA.array(), page: z.number().int().nonnegative(), pageCount: z.number().int().nonnegative(), pageSize: z.number().int().nonnegative(), total: z.number().int().nonnegative(), }), ) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { const result = await getSlackApi(account.secret).call("files.list", { query: { channel: input.conversationId, count: input.count, page: input.page, show_files_hidden_by_limit: input.showFilesHiddenByLimit, ts_from: input.startAt, ts_to: input.endAt, types: input.types, user: input.userId, }, responseSchema: FILE_LIST_RESPONSE_SCHEMA, }) return { files: result.files.map(toSlackFile), page: result.paging.page, pageCount: result.paging.pages, pageSize: result.paging.count, total: result.paging.total, } }) /** Permanently deletes a Slack file the installed bot may delete. */ export const deleteSlackFile = defineAction("Delete Slack file") .describe("Permanently deletes a Slack file.") .account("slack", SLACK_FILES_WRITE_SCOPE) .input(z.object({ fileId: z.string().min(1) })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getSlackApi(account.secret).call("files.delete", { body: { file: input.fileId }, responseSchema: DELETE_FILE_RESPONSE_SCHEMA, }) })