import type { Clock, FetchLike, LoggerLike } from "@suaveplan/types"; import { SLACK_CODES, SlackApiError, SlackAuthError, SlackChannelNotFoundError, SlackConfigMissingError, type SlackError, type SlackErrorCode, SlackRateLimitedError, SlackTimeoutError, SlackWorkspaceNotFoundError, } from "../error-codes/error-codes.js"; import { recordError, recordRequest } from "../otel/metrics.js"; import { SlackApiClientOptionsSchema } from "../schemas/schemas.js"; import type { SlackApiClientOptions, SlackBlock, SlackChannel, SlackDeliveryReport, SlackMessage, SlackResponse, SlackUser, } from "../types/slack-client.types.js"; import { classifyHttpStatus, classifySlackError } from "./classify.js"; import { parseRetryAfterMs } from "./retry-after.js"; const DEFAULT_BASE_URL = "https://slack.com/api"; const DEFAULT_TIMEOUT_MS = 10_000; /** Internal default clock — exported so unit tests pin the fallback branch. */ export const defaultClock: Clock = (): number => Date.now(); /** Internal default fetch — exported so unit tests pin the fallback branch. */ export const defaultFetch: FetchLike = ( input: string | URL | Request, init?: RequestInit ): Promise => globalThis.fetch(input, init); interface RawChannel { readonly id: string; readonly name: string; readonly is_private: boolean; readonly topic?: { value: string }; readonly purpose?: { value: string }; readonly num_members?: number; } interface RawUser { readonly id: string; readonly name: string; readonly real_name?: string; readonly profile?: { email?: string }; readonly is_bot: boolean; } function mapChannel(raw: RawChannel): SlackChannel { const out: { -readonly [K in keyof SlackChannel]: SlackChannel[K]; } = { id: raw.id, name: raw.name, isPrivate: raw.is_private }; if (raw.topic?.value !== undefined) out.topic = raw.topic.value; if (raw.purpose?.value !== undefined) out.purpose = raw.purpose.value; if (raw.num_members !== undefined) out.memberCount = raw.num_members; return out; } function mapUser(raw: RawUser): SlackUser { const out: { -readonly [K in keyof SlackUser]: SlackUser[K] } = { id: raw.id, name: raw.name, isBot: raw.is_bot, }; if (raw.real_name !== undefined) out.realName = raw.real_name; if (raw.profile?.email !== undefined) out.email = raw.profile.email; return out; } /** * Slack Web API client. Methods cover `chat.postMessage`, `chat.update`, * `chat.delete`, `conversations.list`, `conversations.info`, `users.list`. * Every public method records a `slack.requests` increment and a * `slack.latency_ms` observation; failure paths also record `slack.errors`. */ export class SlackApiClient { readonly #token: string; readonly #baseUrl: string; readonly #defaultChannel: string | undefined; readonly #timeoutMs: number; readonly #fetch: FetchLike; readonly #clock: Clock; readonly #logger: LoggerLike | undefined; readonly #deliveries = new Map(); public constructor(options: SlackApiClientOptions) { const parsed = SlackApiClientOptionsSchema.safeParse(options); if (!parsed.success) { throw new SlackConfigMissingError({ message: "slack.errors.configMissing", cause: parsed.error, context: { code: SLACK_CODES.CONFIG_MISSING, field: parsed.error.issues[0]?.path.join("."), }, }); } this.#token = options.token; this.#baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; this.#defaultChannel = options.defaultChannel; this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.#fetch = options.fetch ?? defaultFetch; this.#clock = options.clock ?? defaultClock; this.#logger = options.logger; } public async sendMessage( message: SlackMessage, signal?: AbortSignal ): Promise { const channel = message.channel || this.#defaultChannel; if (channel === undefined || channel === "") { throw new SlackChannelNotFoundError({ message: "slack.errors.channelNotFound", context: { reason: "no_channel_supplied" }, }); } const body: Record = { channel, text: message.text, }; if (message.blocks !== undefined) body["blocks"] = message.blocks; if (message.threadTs !== undefined) body["thread_ts"] = message.threadTs; if (message.unfurlLinks !== undefined) body["unfurl_links"] = message.unfurlLinks; if (message.unfurlMedia !== undefined) body["unfurl_media"] = message.unfurlMedia; if (message.metadata !== undefined) body["metadata"] = message.metadata; const response = await this.#request("chat.postMessage", body, signal); if (response.ok && response.ts !== undefined) { this.#deliveries.set(response.ts, { messageId: response.ts, channel, status: "delivered", timestamp: this.#clock(), }); } return response; } public async updateMessage( channel: string, ts: string, text: string, blocks?: readonly SlackBlock[] ): Promise { const body: Record = { channel, ts, text }; if (blocks !== undefined) body["blocks"] = blocks; return this.#request("chat.update", body); } public async deleteMessage( channel: string, ts: string ): Promise { return this.#request("chat.delete", { channel, ts }); } public async listChannels(limit = 100): Promise { const res = await this.#request("conversations.list", { limit, types: "public_channel,private_channel", }); const raw = (res as unknown as { channels?: readonly RawChannel[] }) .channels; return raw === undefined ? [] : raw.map(mapChannel); } public async getChannelInfo(channelId: string): Promise { const res = await this.#request("conversations.info", { channel: channelId, }); const raw = (res as unknown as { channel: RawChannel }).channel; return mapChannel(raw); } public async listUsers(limit = 100): Promise { const res = await this.#request("users.list", { limit }); const raw = (res as unknown as { members?: readonly RawUser[] }) .members; return raw === undefined ? [] : raw.map(mapUser); } public getStatus(messageTs: string): SlackDeliveryReport | undefined { return this.#deliveries.get(messageTs); } async #request( method: string, body: Record, callerSignal?: AbortSignal ): Promise { const url = `${this.#baseUrl}/${method}`; const started = this.#clock(); const controller = new AbortController(); const onCallerAbort = (): void => controller.abort(); if (callerSignal !== undefined) { if (callerSignal.aborted) controller.abort(); else callerSignal.addEventListener("abort", onCallerAbort, { once: true, }); } const timer = setTimeout(() => controller.abort(), this.#timeoutMs); const finish = (outcome: "ok" | "error"): void => { clearTimeout(timer); if (callerSignal !== undefined) { callerSignal.removeEventListener("abort", onCallerAbort); } recordRequest(method, outcome, this.#clock() - started); }; let res: Response; try { res = await this.#fetch(url, { method: "POST", headers: { Authorization: `Bearer ${this.#token}`, "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify(body), signal: controller.signal, }); } catch (err) { finish("error"); recordError(method, SLACK_CODES.TIMEOUT); const cause = err instanceof Error ? err : new Error(String(err)); this.#logger?.warn("slack request failed", { method, cause: cause.message, }); throw new SlackTimeoutError({ message: "slack.errors.timeout", cause, context: { url, method }, }); } if (res.status === 429) { const retryAfterMs = parseRetryAfterMs(res, this.#clock); finish("error"); recordError(method, SLACK_CODES.RATE_LIMITED); throw new SlackRateLimitedError({ message: "slack.errors.rateLimited", ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), context: { url, method }, }); } if (!res.ok) { const text = await res.text(); const code = classifyHttpStatus(res.status); finish("error"); recordError(method, code); throw this.#errorForCode(code, { message: `slack.errors.${code}`, context: { url, method, status: res.status, body: text }, }); } const data = (await res.json()) as SlackResponse; if (!data.ok) { const code = classifySlackError(data.error ?? "unknown"); finish("error"); recordError(method, code); throw this.#errorForCode(code, { message: `slack.errors.${code}`, context: { url, method, slackError: data.error ?? "unknown" }, }); } finish("ok"); return data; } #errorForCode( code: SlackErrorCode, opts: { message: string; context: Readonly> } ): SlackError { switch (code) { case SLACK_CODES.AUTH_ERROR: return new SlackAuthError(opts); case SLACK_CODES.CHANNEL_NOT_FOUND: return new SlackChannelNotFoundError(opts); case SLACK_CODES.WORKSPACE_NOT_FOUND: return new SlackWorkspaceNotFoundError(opts); case SLACK_CODES.TIMEOUT: return new SlackTimeoutError(opts); default: return new SlackApiError(opts); } } }