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 { 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); }