Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,304 @@
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<Response> => 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<string, SlackDeliveryReport>();
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<SlackResponse> {
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<string, unknown> = {
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<SlackResponse> {
const body: Record<string, unknown> = { channel, ts, text };
if (blocks !== undefined) body["blocks"] = blocks;
return this.#request("chat.update", body);
}
public async deleteMessage(
channel: string,
ts: string
): Promise<SlackResponse> {
return this.#request("chat.delete", { channel, ts });
}
public async listChannels(limit = 100): Promise<SlackChannel[]> {
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<SlackChannel> {
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<SlackUser[]> {
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<string, unknown>,
callerSignal?: AbortSignal
): Promise<SlackResponse> {
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<Record<string, unknown>> }
): 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);
}
}
}