305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|