alert-dispatch.ts
1 import { loadSettings } from '@/lib/server/settings/settings-repository' 2 import type { AppNotification } from '@/types' 3 import { errorMessage } from '@/lib/shared-utils' 4 import { log } from '@/lib/server/logger' 5 6 const TAG = 'alert-dispatch' 7 8 /** In-memory rate limiter: dedupKey → last dispatch timestamp */ 9 const recentDispatches = new Map<string, number>() 10 const DEDUP_WINDOW_MS = 60_000 11 12 export async function dispatchAlert(notification: AppNotification): Promise<void> { 13 const settings = loadSettings() 14 const url = typeof settings.alertWebhookUrl === 'string' ? settings.alertWebhookUrl.trim() : '' 15 if (!url) return 16 17 const allowedEvents: string[] = Array.isArray(settings.alertWebhookEvents) 18 ? settings.alertWebhookEvents 19 : ['error'] 20 if (!allowedEvents.includes(notification.type)) return 21 22 // Rate limit by dedupKey (or notification id as fallback) 23 const dedupKey = notification.dedupKey || notification.id 24 const now = Date.now() 25 const lastSent = recentDispatches.get(dedupKey) 26 if (lastSent && now - lastSent < DEDUP_WINDOW_MS) return 27 recentDispatches.set(dedupKey, now) 28 29 // Prune stale entries on every write to bound growth 30 for (const [key, ts] of recentDispatches) { 31 if (now - ts > DEDUP_WINDOW_MS) recentDispatches.delete(key) 32 } 33 34 const webhookType = settings.alertWebhookType || 'custom' 35 let body: string 36 37 if (webhookType === 'discord') { 38 body = JSON.stringify({ 39 content: `⚠️ **${notification.title}**${notification.message ? `\n${notification.message}` : ''}`, 40 }) 41 } else if (webhookType === 'slack') { 42 body = JSON.stringify({ 43 text: `⚠️ *${notification.title}*${notification.message ? `\n${notification.message}` : ''}`, 44 }) 45 } else { 46 body = JSON.stringify({ 47 type: notification.type, 48 title: notification.title, 49 message: notification.message || null, 50 entityType: notification.entityType || null, 51 entityId: notification.entityId || null, 52 timestamp: notification.createdAt, 53 }) 54 } 55 56 try { 57 await fetch(url, { 58 method: 'POST', 59 headers: { 'Content-Type': 'application/json' }, 60 body, 61 signal: AbortSignal.timeout(5000), 62 }) 63 } catch (err: unknown) { 64 log.warn(TAG, 'Webhook delivery failed:', errorMessage(err)) 65 } 66 }