125 lines
3.9 KiB
TypeScript
125 lines
3.9 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import {
|
|
NotificationDeliveryError,
|
|
type Notification,
|
|
type NotificationEnvelope,
|
|
type NotificationReceipt,
|
|
type Notifier,
|
|
} from './notifier';
|
|
|
|
interface SlackNotifierOptions {
|
|
botToken: string;
|
|
fetchImpl?: typeof fetch;
|
|
apiBase?: string;
|
|
}
|
|
|
|
interface SlackResponse {
|
|
ok?: boolean;
|
|
error?: string;
|
|
ts?: string;
|
|
}
|
|
|
|
export class SlackNotifier implements Notifier {
|
|
readonly provider = 'slack';
|
|
private readonly fetchImpl: typeof fetch;
|
|
private readonly apiBase: string;
|
|
|
|
constructor(private readonly options: SlackNotifierOptions) {
|
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
this.apiBase = options.apiBase ?? 'https://slack.com/api';
|
|
}
|
|
|
|
async send(
|
|
envelope: NotificationEnvelope,
|
|
signal?: AbortSignal,
|
|
): Promise<NotificationReceipt> {
|
|
let response: Response;
|
|
try {
|
|
response = await this.fetchImpl(`${this.apiBase}/chat.postMessage`, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: `Bearer ${this.options.botToken}`,
|
|
'content-type': 'application/json; charset=utf-8',
|
|
},
|
|
body: JSON.stringify({
|
|
channel: envelope.destination,
|
|
text: formatSlackNotification(envelope.notification),
|
|
client_msg_id: slackClientMessageId(envelope.idempotencyKey),
|
|
unfurl_links: false,
|
|
unfurl_media: false,
|
|
}),
|
|
signal,
|
|
});
|
|
} catch {
|
|
throw new NotificationDeliveryError('network_error', true);
|
|
}
|
|
|
|
if (response.status === 429) {
|
|
const retrySeconds = Number(response.headers.get('retry-after'));
|
|
throw new NotificationDeliveryError(
|
|
'rate_limited',
|
|
true,
|
|
Number.isFinite(retrySeconds) && retrySeconds > 0 ? retrySeconds * 1_000 : undefined,
|
|
);
|
|
}
|
|
if (response.status >= 500) {
|
|
throw new NotificationDeliveryError('slack_unavailable', true);
|
|
}
|
|
if (!response.ok) {
|
|
throw new NotificationDeliveryError('slack_http_error', false);
|
|
}
|
|
|
|
let result: SlackResponse;
|
|
try {
|
|
result = (await response.json()) as SlackResponse;
|
|
} catch {
|
|
throw new NotificationDeliveryError('invalid_slack_response', true);
|
|
}
|
|
if (!result.ok) {
|
|
const code = normaliseSlackError(result.error);
|
|
throw new NotificationDeliveryError(code, isRetryableSlackError(code));
|
|
}
|
|
return { externalId: result.ts ?? null };
|
|
}
|
|
}
|
|
|
|
export function slackClientMessageId(idempotencyKey: string): string {
|
|
const digest = createHash('sha256').update(idempotencyKey).digest('hex');
|
|
return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-4${digest.slice(13, 16)}-a${digest.slice(17, 20)}-${digest.slice(20, 32)}`;
|
|
}
|
|
|
|
function formatSlackNotification(notification: Notification): string {
|
|
if (notification.kind === 'stage_change') {
|
|
return [
|
|
`*${notification.dealName}* moved from \`${notification.fromStage}\` to \`${notification.toStage}\`.`,
|
|
`${notification.dealSide === 'demand' ? 'Demand' : 'Supply'} pipeline stage changed in PIG.`,
|
|
].join('\n');
|
|
}
|
|
|
|
return [
|
|
`*Idle capacity: ${notification.commitmentName}*`,
|
|
`${notification.gpuType} has ${formatNumber(notification.idleGpuHours)} unsold GPU-hours (${Math.round(notification.utilisation * 100)}% utilised).`,
|
|
`Idle committed cost: ${formatMoney(notification.idleCostCents)}.`,
|
|
].join('\n');
|
|
}
|
|
|
|
function formatNumber(value: number): string {
|
|
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }).format(value);
|
|
}
|
|
|
|
function formatMoney(cents: number): string {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
maximumFractionDigits: 0,
|
|
}).format(cents / 100);
|
|
}
|
|
|
|
function normaliseSlackError(error: string | undefined): string {
|
|
return error && /^[a-z0-9_]+$/i.test(error) ? error : 'unknown_slack_error';
|
|
}
|
|
|
|
function isRetryableSlackError(code: string): boolean {
|
|
return ['ratelimited', 'internal_error', 'fatal_error', 'request_timeout'].includes(code);
|
|
}
|