import type { JsonObject } from "type-fest" import * as z from "zod" import { parseRetryAfter, retryableActionError, terminalActionError, } from "../../../automation/actions" const SLACK_API_BASE_URL = "https://slack.com/api" const SLACK_SECRET_SCHEMA = z.object({ accessToken: z.string().min(1), }) const SLACK_API_RESPONSE_SCHEMA = z.looseObject({ error: z.string().optional(), needed: z.string().optional(), ok: z.boolean(), provided: z.string().optional(), response_metadata: z .looseObject({ messages: z.string().array().optional(), }) .optional(), }) /** Scalar value serialized into one Slack query parameter. */ type SlackQueryValue = boolean | number | string | undefined /** Scalar value serialized into one Slack form field. */ type SlackFormValue = boolean | number | string | undefined /** * Serializes defined scalar Slack fields as a URL-encoded request body. * * @param form - Provider fields to serialize. */ function encodeSlackForm(form: Record) { const body = new URLSearchParams() for (const [name, value] of Object.entries(form)) { if (value !== undefined) body.set(name, String(value)) } return body } /** Mutually exclusive Slack JSON and form request bodies. */ type SlackApiBodyOptions = | { /** JSON request body for a Slack write method. */ body?: JsonObject form?: never } | { body?: never /** URL-encoded request fields for methods that require form encoding. */ form: Record } /** Options for one authenticated Slack Web API request. */ export type SlackApiCallOptions = SlackApiBodyOptions & { /** HTTP verb. Defaults to `POST` when a body is present and `GET` otherwise. */ httpMethod?: "GET" | "POST" /** URL query parameters for Slack read methods. */ query?: Record /** Schema that validates and types the successful Slack response. */ responseSchema: TSchema } /** Internal authenticated Slack Web API client used by packaged actions. */ export interface SlackApi { /** * Calls any Slack Web API method and validates the successful response. * * Slack's `ok: false` responses throw `SlackApiError`, including when Slack * returns HTTP 200. * * @param method - Slack method name such as `chat.postMessage`. * @param options - Request data and successful response schema. */ call( method: string, options: SlackApiCallOptions, ): Promise> } /** Structured error returned by an authenticated Slack Web API request. */ export class SlackApiError extends Error { /** Slack error code or an HTTP fallback code. */ readonly code: string /** Structured diagnostics retained in durable automation failures. */ readonly details: JsonObject /** Slack Web API method that failed. */ readonly method: string /** Scopes Slack reported as required for the request. */ readonly neededScopes?: string /** Scopes Slack reported on the supplied token. */ readonly providedScopes?: string /** Field-level diagnostics Slack returned with the failed request. */ readonly responseMessages?: string[] /** Retry delay from Slack's `Retry-After` header, in seconds. */ readonly retryAfter?: number /** HTTP status returned by Slack. */ readonly status: number /** * Creates a structured Slack API error. * * @param options - Provider and transport error details. * @param options.code - Slack error code or HTTP fallback. * @param options.method - Slack method that failed. * @param options.neededScopes - Scopes required by Slack. * @param options.providedScopes - Scopes granted to the token. * @param options.responseMessages - Provider field-level diagnostics. * @param options.retryAfter - Retry delay in seconds. * @param options.status - HTTP response status. */ constructor(options: { code: string method: string neededScopes?: string providedScopes?: string responseMessages?: string[] retryAfter?: number status: number }) { super(`Slack ${options.method} failed: ${options.code}`) this.name = "SlackApiError" this.code = options.code this.method = options.method this.neededScopes = options.neededScopes this.providedScopes = options.providedScopes this.responseMessages = options.responseMessages this.retryAfter = options.retryAfter this.status = options.status this.details = { method: options.method, status: options.status, ...(options.neededScopes !== undefined && { neededScopes: options.neededScopes, }), ...(options.providedScopes !== undefined && { providedScopes: options.providedScopes, }), ...(options.responseMessages !== undefined && { responseMessages: options.responseMessages, }), ...(options.retryAfter !== undefined && { retryAfter: options.retryAfter, }), } } } /** * Creates the authenticated Slack Web API client used by packaged actions. * * @param secret - Resolved Slack integration secret. */ export function getSlackApi(secret: Record): SlackApi { const { accessToken } = SLACK_SECRET_SCHEMA.parse(secret) return { call: async (method, options) => { const url = new URL(`${SLACK_API_BASE_URL}/${method}`) for (const [name, value] of Object.entries(options.query ?? {})) { if (value !== undefined) url.searchParams.set(name, String(value)) } const response = await fetch(url, { body: options.form === undefined ? options.body ? JSON.stringify(options.body) : undefined : encodeSlackForm(options.form), headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}`, ...(options.form !== undefined ? { "Content-Type": "application/x-www-form-urlencoded" } : options.body && { "Content-Type": "application/json" }), }, method: options.httpMethod ?? (options.body === undefined && options.form === undefined ? "GET" : "POST"), }) const payload = await parseSlackResponse(response, method) const envelopeResult = SLACK_API_RESPONSE_SCHEMA.safeParse(payload) if (!response.ok) { const envelope = envelopeResult.success ? envelopeResult.data : undefined const retryAt = parseRetryAfter(response.headers.get("Retry-After")) throwSlackApiError( new SlackApiError({ code: envelope?.error ?? (response.status === 429 ? "ratelimited" : `http_${response.status}`), method, neededScopes: envelope?.needed, providedScopes: envelope?.provided, responseMessages: envelope?.response_metadata?.messages, retryAfter: secondsUntil(retryAt), status: response.status, }), response, retryAt, ) } const envelope = SLACK_API_RESPONSE_SCHEMA.parse(payload) if (!envelope.ok) { const retryAt = parseRetryAfter(response.headers.get("Retry-After")) throwSlackApiError( new SlackApiError({ code: envelope.error ?? "unknown_error", method, neededScopes: envelope.needed, providedScopes: envelope.provided, responseMessages: envelope.response_metadata?.messages, retryAfter: secondsUntil(retryAt), status: response.status, }), response, retryAt, ) } return options.responseSchema.parse(payload) }, } } /** * Parses a Slack response while retaining transport metadata for invalid * bodies. * * @param response - Slack Web API response. * @param method - Slack method used for structured failures. */ async function parseSlackResponse(response: Response, method: string) { const body = await response.text() try { return JSON.parse(body) as unknown } catch { const retryAt = parseRetryAfter(response.headers.get("Retry-After")) throwSlackApiError( new SlackApiError({ code: response.status === 429 ? "ratelimited" : response.ok ? "invalid_response" : `http_${response.status}`, method, retryAfter: secondsUntil(retryAt), status: response.status, }), response, retryAt, ) } } /** * Classifies a Slack provider failure for runtime retry safety. * * @param error - Structured Slack failure. * @param response - Provider HTTP response. * @param retryAt - Provider-directed retry time, when present. * @throws The classified Slack provider failure. */ function throwSlackApiError( error: SlackApiError, response: Response, retryAt: Date | undefined, ): never { if ( response.status === 429 || error.code === "ratelimited" || error.code === "request_timeout" ) { throw retryableActionError(error, { retryAt }) } if ( error.code === "fatal_error" || error.code === "internal_error" || error.code === "invalid_response" || error.code === "rate_limited" || error.code === "service_unavailable" ) { throw error } if (response.status < 500) throw terminalActionError(error) throw error } /** * Converts an absolute retry time into Slack's integer delay field. * * @param retryAt - Parsed provider retry time. */ function secondsUntil(retryAt: Date | undefined) { return retryAt ? Math.max(0, Math.ceil((retryAt.getTime() - Date.now()) / 1_000)) : undefined } /** * Converts action input values into provider-native Slack message fields. * * @param input - Camel-cased public message fields. * @param input.attachments - Legacy Slack attachments. * @param input.blocks - Block Kit content. * @param input.text - Text content or accessibility fallback. * @param input.unfurlLinks - Whether Slack should unfurl links. * @param input.unfurlMedia - Whether Slack should unfurl media. */ export function toSlackMessageBody(input: { attachments?: JsonObject[] blocks?: JsonObject[] text?: string unfurlLinks?: boolean unfurlMedia?: boolean }): JsonObject { return { ...(input.attachments !== undefined && { attachments: input.attachments, }), ...(input.blocks !== undefined && { blocks: input.blocks }), ...(input.text !== undefined && { text: input.text }), ...(input.unfurlLinks !== undefined && { unfurl_links: input.unfurlLinks, }), ...(input.unfurlMedia !== undefined && { unfurl_media: input.unfurlMedia, }), } }