// Slack Web API client (OAuth code exchange + chat.postMessage). Pure fetch // wrapper with no module dependencies so it stays runtime-clean and // injectable for tests. const SLACK_API_BASE = "https://slack.com/api"; const OAUTH_V2_ACCESS = `${SLACK_API_BASE}/oauth.v2.access`; const CHAT_POST_MESSAGE = `${SLACK_API_BASE}/chat.postMessage`; // Slack calls run while a SlackWorkspaceIntegration row lock may be held; an unbounded // fetch would pin that lock for the lifetime of a hung connection. const FETCH_TIMEOUT_MS = 10_000; async function parseJsonResponse(response: Response): Promise { // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- runtime fetch boundary return (await response.json()) as T; } export interface SlackOauthAccessResult { ok: boolean; team?: { id: string; name?: string }; bot_user_id?: string; access_token?: string; error?: string; } export interface SlackChatPostMessageResult { ok: boolean; ts?: string; channel?: string; error?: string; /** Populated from the `Retry-After` header on an HTTP 429 response. */ retryAfterSeconds?: number; } /** * Exchanges a Slack OAuth v2 code for an access_token. * The returned `access_token` is a bot token (xoxb-...). */ export async function exchangeOauthCode( code: string, redirectUri: string, clientId: string, clientSecret: string, ): Promise { const response = await fetch(OAUTH_V2_ACCESS, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, }), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!response.ok) { throw new Error(`Slack OAuth exchange failed (${response.status})`); } return parseJsonResponse(response); } /** * Posts a message to a channel via `chat.postMessage`. */ export async function postChatMessage( botToken: string, channel: string, text: string, options?: { blocks?: unknown[] }, ): Promise { const response = await fetch(CHAT_POST_MESSAGE, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", Authorization: `Bearer ${botToken}`, }, body: JSON.stringify({ channel, text, blocks: options?.blocks }), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (response.status === 429) { // Rate limited: surface the Retry-After hint instead of throwing so the // caller can classify and persist it. const retryAfter = Number(response.headers.get("retry-after")); return { ok: false, error: "ratelimited", ...(Number.isFinite(retryAfter) ? { retryAfterSeconds: retryAfter } : {}), }; } if (!response.ok) { throw new Error(`Slack chat.postMessage failed (${response.status})`); } return parseJsonResponse(response); }