This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user