diff --git a/packages/notifications/TODO.md b/packages/notifications/TODO.md index a0127c248..f5b639809 100644 --- a/packages/notifications/TODO.md +++ b/packages/notifications/TODO.md @@ -44,9 +44,9 @@ - [x] **Discord** - Webhook support - [x] **Email** - Injected send function - [x] **Webhook** - Generic HTTP webhook -- [x] **Microsoft Teams** - Adaptive Cards via webhook -- [x] **Telegram** - Bot API (sendMessage) -- [x] **Google Chat** - Cards via webhook +- [ ] **Microsoft Teams** - Adaptive Cards via webhook +- [ ] **Telegram** - Bot API (sendMessage) +- [ ] **Google Chat** - Cards via webhook - [ ] **PagerDuty** - Integration for incident management - [ ] **Opsgenie** - Alerting and on-call management - [ ] **SMS/Twilio** - SMS notifications via Twilio API diff --git a/packages/notifications/src/__tests__/providers/discord.test.ts b/packages/notifications/src/__tests__/providers/discord.test.ts new file mode 100644 index 000000000..0b6d7f12a --- /dev/null +++ b/packages/notifications/src/__tests__/providers/discord.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { SafeFetchInit } from "@databuddy/shared/ssrf-guard"; + +const safeFetchMock = mock( + (_url: string, _init?: SafeFetchInit) => + Promise.resolve(new Response(null, { status: 204 })) +); + +mock.module("@databuddy/shared/ssrf-guard", () => ({ + safeFetch: safeFetchMock, +})); + +const { buildDiscordEmbed, DiscordProvider } = await import( + "../../providers/discord" +); + +describe("buildDiscordEmbed", () => { + test("hides internal metadata and keeps user-facing fields", () => { + const embed = buildDiscordEmbed({ + title: "Anomaly detected", + message: "Traffic changed.", + metadata: { + dashboardUrl: "https://app.databuddy.cc/monitors/1", + alarmId: "internal-alarm-id", + template: "anomaly", + zScore: 7.1, + }, + }); + + expect(embed.fields).toHaveLength(1); + expect(embed.fields?.[0]?.name).toBe("Dashboard Url"); + expect(JSON.stringify(embed)).not.toContain("internal-alarm-id"); + expect(JSON.stringify(embed)).not.toContain("zScore"); + }); + + test("bounds title and description length before calling Discord", () => { + const embed = buildDiscordEmbed({ + title: "T".repeat(400), + message: "M".repeat(5000), + }); + + expect(embed.title?.length).toBe(256); + expect(embed.description?.length).toBe(4096); + }); + + test("caps fields at Discord's 25-field embed limit", () => { + const embed = buildDiscordEmbed({ + title: "Anomaly detected", + message: "Traffic changed.", + metadata: Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`field${index}`, index]) + ), + }); + + expect(embed.fields).toHaveLength(25); + }); + + test("stops adding fields before the 6000-character aggregate embed limit", () => { + const embed = buildDiscordEmbed({ + title: "Anomaly detected", + message: "M".repeat(4096), + metadata: Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [ + `field${index}`, + "V".repeat(1024), + ]) + ), + }); + + const total = + (embed.title?.length ?? 0) + + (embed.description?.length ?? 0) + + (embed.footer?.text.length ?? 0) + + (embed.fields ?? []).reduce( + (sum, field) => sum + field.name.length + field.value.length, + 0 + ); + + expect(embed.fields?.length ?? 0).toBeLessThan(25); + expect(total).toBeLessThanOrEqual(6000); + }); + + test("only surfaces a color and priority footer for elevated priority", () => { + const normal = buildDiscordEmbed({ + title: "Site alert", + message: "The site is unavailable.", + priority: "normal", + }); + expect(normal.color).toBeUndefined(); + expect(normal.footer).toBeUndefined(); + + const urgent = buildDiscordEmbed({ + title: "Site alert", + message: "The site is unavailable.", + priority: "urgent", + }); + expect(urgent.color).toBeDefined(); + expect(urgent.footer?.text).toBe("Priority: URGENT"); + }); +}); + +describe("DiscordProvider", () => { + afterEach(() => { + safeFetchMock.mockClear(); + safeFetchMock.mockImplementation((_url: string, _init?: SafeFetchInit) => + Promise.resolve(new Response(null, { status: 204 })) + ); + }); + + test("returns a failed result when no webhook URL is configured", async () => { + const provider = new DiscordProvider({ webhookUrl: "" }); + const result = await provider.send({ title: "t", message: "m" }); + + expect(result).toEqual({ + success: false, + channel: "discord", + error: "Discord webhook URL not configured", + }); + expect(safeFetchMock).not.toHaveBeenCalled(); + }); + + test("posts an embed payload and reports success on a 204 response", async () => { + const provider = new DiscordProvider({ + webhookUrl: "https://discord.com/api/webhooks/123/token", + }); + + const result = await provider.send({ + title: "Site alert", + message: "The site is unavailable.", + }); + + expect(result.success).toBe(true); + expect(result.channel).toBe("discord"); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = safeFetchMock.mock.calls[0] as [string, SafeFetchInit]; + expect(url).toBe("https://discord.com/api/webhooks/123/token"); + const body = JSON.parse(init.body as string); + expect(body.embeds[0].title).toBe("Site alert"); + }); + + test("returns a failed result when Discord responds with an error status", async () => { + safeFetchMock.mockImplementationOnce(() => + Promise.resolve(new Response("invalid webhook", { status: 404 })) + ); + + const provider = new DiscordProvider({ + webhookUrl: "https://discord.com/api/webhooks/123/token", + }); + + const result = await provider.send({ title: "t", message: "m" }); + + expect(result.success).toBe(false); + expect(result.channel).toBe("discord"); + expect(result.error).toContain("Discord API error: 404"); + }); +}); diff --git a/packages/notifications/src/client.ts b/packages/notifications/src/client.ts index d9ac0956a..a40c8fc3d 100644 --- a/packages/notifications/src/client.ts +++ b/packages/notifications/src/client.ts @@ -1,4 +1,6 @@ import type { NotificationProvider } from "./providers/base"; +import type { DiscordProviderConfig } from "./providers/discord"; +import { DiscordProvider } from "./providers/discord"; import type { EmailProviderConfig } from "./providers/email"; import { EmailProvider } from "./providers/email"; import type { SlackProviderConfig } from "./providers/slack"; @@ -17,6 +19,7 @@ export interface NotificationClientConfig { defaultRetries?: number; defaultRetryDelay?: number; defaultTimeout?: number; + discord?: DiscordProviderConfig; email?: EmailProviderConfig; slack?: SlackProviderConfig; webhook?: WebhookProviderConfig; @@ -65,6 +68,12 @@ export class NotificationClient { new WebhookProvider(withDefaults(config.webhook)) ); } + if (config.discord) { + this.providers.set( + "discord", + new DiscordProvider(withDefaults(config.discord)) + ); + } } async send( diff --git a/packages/notifications/src/providers/discord.ts b/packages/notifications/src/providers/discord.ts new file mode 100644 index 000000000..93eca1bc9 --- /dev/null +++ b/packages/notifications/src/providers/discord.ts @@ -0,0 +1,156 @@ +import type { + DiscordEmbed, + DiscordEmbedField, + DiscordPayload, + NotificationPayload, + NotificationResult, +} from "../types"; +import { BaseProvider } from "./base"; +import { + formatMetadataLabel, + isUserFacingMetadata, + truncate, +} from "./payload-utils"; + +const MAX_TITLE_LENGTH = 256; +const MAX_DESCRIPTION_LENGTH = 4096; +const MAX_FIELD_NAME_LENGTH = 256; +const MAX_FIELD_VALUE_LENGTH = 1024; +const MAX_FIELDS = 25; +const MAX_TOTAL_EMBED_LENGTH = 6000; + +const PRIORITY_COLORS: Record<"low" | "high" | "urgent", number> = { + low: 0x95_a5_a6, + high: 0xf3_9c_12, + urgent: 0xed_42_45, +}; + +export function buildDiscordEmbed(payload: NotificationPayload): DiscordEmbed { + const title = truncate(payload.title, MAX_TITLE_LENGTH); + const description = truncate(payload.message, MAX_DESCRIPTION_LENGTH); + const elevatedPriority = + payload.priority && payload.priority !== "normal" ? payload.priority : null; + const priorityStyle = elevatedPriority + ? { + color: PRIORITY_COLORS[elevatedPriority], + footer: { text: `Priority: ${elevatedPriority.toUpperCase()}` }, + } + : null; + + let total = + title.length + + description.length + + (priorityStyle?.footer.text.length ?? 0); + + const fields: DiscordEmbedField[] = []; + if (payload.metadata) { + for (const [key, value] of Object.entries(payload.metadata)) { + if (fields.length >= MAX_FIELDS) { + break; + } + if (!isUserFacingMetadata(key)) { + continue; + } + + const name = truncate(formatMetadataLabel(key), MAX_FIELD_NAME_LENGTH); + const fieldValue = truncate(String(value), MAX_FIELD_VALUE_LENGTH); + if (!(name && fieldValue)) { + continue; + } + const fieldLength = name.length + fieldValue.length; + + if (total + fieldLength > MAX_TOTAL_EMBED_LENGTH) { + break; + } + + fields.push({ inline: true, name, value: fieldValue }); + total += fieldLength; + } + } + + return { + title, + description, + ...(fields.length > 0 && { fields }), + ...priorityStyle, + }; +} + +export interface DiscordProviderConfig { + avatarUrl?: string; + retries?: number; + retryDelay?: number; + timeout?: number; + username?: string; + webhookUrl: string; +} + +export class DiscordProvider extends BaseProvider { + private readonly webhookUrl: string; + private readonly username?: string; + private readonly avatarUrl?: string; + + constructor(config: DiscordProviderConfig) { + super({ + timeout: config.timeout, + retries: config.retries, + retryDelay: config.retryDelay, + }); + this.webhookUrl = config.webhookUrl; + this.username = config.username; + this.avatarUrl = config.avatarUrl; + } + + async send(payload: NotificationPayload): Promise { + if (!this.webhookUrl) { + return { + success: false, + channel: "discord", + error: "Discord webhook URL not configured", + }; + } + + try { + const discordPayload = this.buildPayload(payload); + const response = await this.withRetry(async () => { + const res = await this.fetchWithTimeout(this.webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(discordPayload), + }); + + if (!res.ok) { + const text = await res.text().catch(() => "Unable to read response"); + throw new Error( + `Discord API error: ${res.status} ${res.statusText} - ${text.slice(0, 200)}` + ); + } + + return res; + }); + + return { + success: true, + channel: "discord", + response: { + status: response.status, + statusText: response.statusText, + }, + }; + } catch (error) { + return { + success: false, + channel: "discord", + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private buildPayload(payload: NotificationPayload): DiscordPayload { + return { + embeds: [buildDiscordEmbed(payload)], + ...(this.username && { username: this.username }), + ...(this.avatarUrl && { avatar_url: this.avatarUrl }), + }; + } +} diff --git a/packages/notifications/src/providers/index.ts b/packages/notifications/src/providers/index.ts index 177cea636..63c5db1d7 100644 --- a/packages/notifications/src/providers/index.ts +++ b/packages/notifications/src/providers/index.ts @@ -1,6 +1,8 @@ /** biome-ignore-all lint/performance/noBarrelFile: barrel file */ export type { NotificationProvider } from "./base"; export { BaseProvider } from "./base"; +export type { DiscordProviderConfig } from "./discord"; +export { DiscordProvider } from "./discord"; export type { EmailProviderConfig } from "./email"; export { EmailProvider } from "./email"; export type { SlackProviderConfig } from "./slack"; diff --git a/packages/notifications/src/providers/payload-utils.ts b/packages/notifications/src/providers/payload-utils.ts new file mode 100644 index 000000000..aca490c57 --- /dev/null +++ b/packages/notifications/src/providers/payload-utils.ts @@ -0,0 +1,24 @@ +const FIRST_CHARACTER_PATTERN = /^./; + +export function truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 1)}…`; +} + +export function isUserFacingMetadata(key: string): boolean { + return !( + key === "to" || + key === "template" || + key === "zScore" || + key.endsWith("Id") + ); +} + +export function formatMetadataLabel(key: string): string { + return key + .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") + .replaceAll(/[_-]+/g, " ") + .replace(FIRST_CHARACTER_PATTERN, (character) => character.toUpperCase()); +} diff --git a/packages/notifications/src/providers/slack.ts b/packages/notifications/src/providers/slack.ts index e632e6d1b..5c2717ec4 100644 --- a/packages/notifications/src/providers/slack.ts +++ b/packages/notifications/src/providers/slack.ts @@ -4,36 +4,17 @@ import type { SlackPayload, } from "../types"; import { BaseProvider } from "./base"; +import { + formatMetadataLabel, + isUserFacingMetadata, + truncate, +} from "./payload-utils"; const MAX_HEADER_LENGTH = 150; const MAX_MESSAGE_LENGTH = 2900; const MAX_FIELD_LENGTH = 1900; const MAX_FIELDS_PER_SECTION = 10; const MAX_BLOCKS = 50; -const FIRST_CHARACTER_PATTERN = /^./; - -function truncate(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - return `${value.slice(0, maxLength - 1)}…`; -} - -function isUserFacingMetadata(key: string): boolean { - return !( - key === "to" || - key === "template" || - key === "zScore" || - key.endsWith("Id") - ); -} - -function formatMetadataLabel(key: string): string { - return key - .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") - .replaceAll(/[_-]+/g, " ") - .replace(FIRST_CHARACTER_PATTERN, (character) => character.toUpperCase()); -} export function buildSlackBlocks( payload: NotificationPayload diff --git a/packages/notifications/src/types.ts b/packages/notifications/src/types.ts index 7aeb7137f..440581d9d 100644 --- a/packages/notifications/src/types.ts +++ b/packages/notifications/src/types.ts @@ -1,4 +1,4 @@ -export type NotificationChannel = "slack" | "email" | "webhook"; +export type NotificationChannel = "slack" | "discord" | "email" | "webhook"; export type NotificationPriority = "low" | "normal" | "high" | "urgent"; @@ -44,6 +44,27 @@ export interface SlackPayload { username?: string; } +export interface DiscordEmbedField { + inline?: boolean; + name: string; + value: string; +} + +export interface DiscordEmbed { + color?: number; + description?: string; + fields?: DiscordEmbedField[]; + footer?: { text: string }; + title?: string; +} + +export interface DiscordPayload { + avatar_url?: string; + content?: string; + embeds?: DiscordEmbed[]; + username?: string; +} + export interface EmailPayload { from?: string; html?: string;