import * as z from "zod" import { defineAction } from "../../../automation/actions" import { getSlackApi } from "../lib/api" const SLACK_CANVASES_READ_SCOPE = "canvases:read" const SLACK_CANVASES_WRITE_SCOPE = "canvases:write" const SLACK_CANVAS_CONTENT_SCHEMA = z.object({ /** Markdown source for the canvas content. */ markdown: z.string().max(1_048_576), /** Slack currently accepts Markdown canvas content. */ type: z.literal("markdown"), }) const SLACK_CANVAS_SECTION_TYPE_SCHEMA = z.enum([ "any_header", "blockquote", "callout", "canvas_unfurl", "chart", "citation", "file_unfurl", "flexbox", "h1", "h2", "h3", "horizontal_line", "list", "message_unfurl", "sfdc_record_mention", "sfdc_record_unfurl", "table", "user_mention", "user_unfurl", ]) const SLACK_CANVAS_ACCESS_SUBJECT_SCHEMA = z.discriminatedUnion("type", [ z.object({ /** Slack channel IDs receiving or losing access. */ ids: z.string().min(1).array().min(1), type: z.literal("channels"), }), z.object({ /** Slack user IDs receiving or losing access. */ ids: z.string().min(1).array().min(1), type: z.literal("users"), }), ]) const SLACK_CANVAS_EDIT_SCHEMA = z.discriminatedUnion("operation", [ z.object({ documentContent: SLACK_CANVAS_CONTENT_SCHEMA, operation: z.enum(["insert_after", "insert_before"]), sectionId: z.string().min(1), }), z.object({ documentContent: SLACK_CANVAS_CONTENT_SCHEMA, operation: z.enum(["insert_at_start", "insert_at_end"]), }), z.object({ documentContent: SLACK_CANVAS_CONTENT_SCHEMA, operation: z.literal("replace"), sectionId: z.string().min(1).optional(), }), z.object({ operation: z.literal("delete"), sectionId: z.string().min(1), }), z.object({ operation: z.literal("rename"), titleContent: SLACK_CANVAS_CONTENT_SCHEMA, }), ]) const SLACK_OK_RESPONSE_SCHEMA = z.looseObject({ ok: z.literal(true) }) const SLACK_CANVAS_CREATE_RESPONSE_SCHEMA = z.looseObject({ canvas_id: z.string(), ok: z.literal(true), }) const SLACK_CANVAS_SECTIONS_RESPONSE_SCHEMA = z.looseObject({ ok: z.literal(true), sections: z .looseObject({ id: z.string(), }) .array(), }) /** Creates a standalone Slack Canvas, optionally tabbed in a channel. */ export const createSlackCanvas = defineAction("Create Slack Canvas") .describe("Creates a standalone Slack Canvas.") .account("slack", SLACK_CANVASES_WRITE_SCOPE) .input( z.object({ /** Channel that should receive a tab for the canvas. */ channelId: z.string().min(1).optional(), /** Initial Markdown content, limited by Slack to 1 MiB. */ documentContent: SLACK_CANVAS_CONTENT_SCHEMA.optional(), /** Initial canvas title. */ title: z.string().optional(), }), ) .output(z.object({ canvasId: z.string() })) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { return { canvasId: ( await getSlackApi(account.secret).call("canvases.create", { body: { ...(input.channelId && { channel_id: input.channelId }), ...(input.documentContent && { document_content: input.documentContent, }), ...(input.title !== undefined && { title: input.title }), }, responseSchema: SLACK_CANVAS_CREATE_RESPONSE_SCHEMA, }) ).canvas_id, } }) /** Applies one documented edit operation to a Slack Canvas. */ export const editSlackCanvas = defineAction("Edit Slack Canvas") .describe("Applies one content or title edit to a Slack Canvas.") .account("slack", SLACK_CANVASES_WRITE_SCOPE) .input( z.object({ /** Stable Slack Canvas ID. */ canvasId: z.string().min(1), /** The single edit operation Slack permits in one request. */ change: SLACK_CANVAS_EDIT_SCHEMA, }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { const { operation } = input.change await getSlackApi(account.secret).call("canvases.edit", { body: { canvas_id: input.canvasId, changes: [ { operation, ...(input.change.operation !== "delete" && input.change.operation !== "rename" && { document_content: input.change.documentContent, }), ...(input.change.operation === "rename" && { title_content: input.change.titleContent, }), ...("sectionId" in input.change && input.change.sectionId && { section_id: input.change.sectionId, }), }, ], }, responseSchema: SLACK_OK_RESPONSE_SCHEMA, }) }) /** Permanently deletes a Slack Canvas. */ export const deleteSlackCanvas = defineAction("Delete Slack Canvas") .describe("Permanently deletes a Slack Canvas.") .account("slack", SLACK_CANVASES_WRITE_SCOPE) .input(z.object({ canvasId: z.string().min(1) })) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getSlackApi(account.secret).call("canvases.delete", { body: { canvas_id: input.canvasId }, responseSchema: SLACK_OK_RESPONSE_SCHEMA, }) }) /** Grants a channel or group of users access to a Slack Canvas. */ export const setSlackCanvasAccess = defineAction("Set Slack Canvas access") .describe("Sets channel or user access to a Slack Canvas.") .account("slack", SLACK_CANVASES_WRITE_SCOPE) .input( z .object({ /** Access level to grant. Canvas ownership can only be granted to users. */ accessLevel: z.enum(["owner", "read", "write"]), /** Stable Slack Canvas ID. */ canvasId: z.string().min(1), /** Channels or users receiving access. */ subject: SLACK_CANVAS_ACCESS_SUBJECT_SCHEMA, }) .check((context) => { if ( context.value.accessLevel === "owner" && context.value.subject.type === "channels" ) { context.issues.push({ code: "custom", input: context.value, message: "Slack Canvas ownership can only be granted to users.", }) } }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getSlackApi(account.secret).call("canvases.access.set", { body: { access_level: input.accessLevel, canvas_id: input.canvasId, [input.subject.type === "channels" ? "channel_ids" : "user_ids"]: input.subject.ids, }, responseSchema: SLACK_OK_RESPONSE_SCHEMA, }) }) /** Revokes a channel or group of users' access to a Slack Canvas. */ export const deleteSlackCanvasAccess = defineAction( "Delete Slack Canvas access", ) .describe("Revokes channel or user access to a Slack Canvas.") .account("slack", SLACK_CANVASES_WRITE_SCOPE) .input( z.object({ /** Stable Slack Canvas ID. */ canvasId: z.string().min(1), /** Channels or users losing access. */ subject: SLACK_CANVAS_ACCESS_SUBJECT_SCHEMA, }), ) .output(z.void()) .retry({ replaySafety: "unsafe" }) .handler(async ({ account, input }) => { await getSlackApi(account.secret).call("canvases.access.delete", { body: { canvas_id: input.canvasId, [input.subject.type === "channels" ? "channel_ids" : "user_ids"]: input.subject.ids, }, responseSchema: SLACK_OK_RESPONSE_SCHEMA, }) }) /** Finds Slack Canvas section IDs by documented type and contained text. */ export const lookupSlackCanvasSections = defineAction( "Look up Slack Canvas sections", ) .describe("Finds section IDs within a Slack Canvas.") .account("slack", SLACK_CANVASES_READ_SCOPE) .input( z.object({ /** Stable Slack Canvas ID. */ canvasId: z.string().min(1), /** Text that matching sections must contain. */ containsText: z.string().optional(), /** Slack section categories to match. */ sectionTypes: SLACK_CANVAS_SECTION_TYPE_SCHEMA.array().min(1).optional(), }), ) .output(z.object({ sectionIds: z.string().array() })) .retry({ replaySafety: "safe" }) .handler(async ({ account, input }) => { return { sectionIds: ( await getSlackApi(account.secret).call("canvases.sections.lookup", { body: { canvas_id: input.canvasId, criteria: { ...(input.containsText !== undefined && { contains_text: input.containsText, }), ...(input.sectionTypes && { section_types: input.sectionTypes, }), }, }, responseSchema: SLACK_CANVAS_SECTIONS_RESPONSE_SCHEMA, }) ).sections.map(({ id }) => id), } })