diff --git a/src/alerts/teams.ts b/src/alerts/teams.ts new file mode 100644 index 00000000..6619255b --- /dev/null +++ b/src/alerts/teams.ts @@ -0,0 +1,265 @@ +import type { AlertEvent, AlertSeverity } from "./types.js"; +import { getLogger } from "../logging/index.js"; +import { renderAlertTemplate } from "./templates.js"; + +const logger = getLogger().child({ component: "TeamsHandler" }); +const TIMEOUT_MS = 10_000; + +// ─── Teams Adaptive Card Color Palette ─────────────────────────────────────── + +type AdaptiveCardColor = "default" | "accent" | "good" | "warning" | "attention" | "dark" | "light"; + +function severityCardColor(event: AlertEvent): AdaptiveCardColor { + if (event.type === "alert_resolved") return "good"; + if (event.severity === "critical") return "attention"; + if (event.severity === "warning") return "warning"; + return "good"; +} + +function severityEmoji(event: AlertEvent): string { + if (event.type === "alert_resolved") return "✅"; + if (event.type === "state_changed") return "🔄"; + if (event.severity === "critical") return "🔴"; + return "⚠️"; +} + +function buildTitle(event: AlertEvent): string { + const icon = severityEmoji(event); + const contractDisplay = event.contractName ?? event.contractId; + + if (event.type === "alert_resolved") { + return `${icon} Alert Resolved — ${contractDisplay}`; + } + + if (event.type === "state_changed") { + const diffLabel = event.diff.diffType.charAt(0).toUpperCase() + event.diff.diffType.slice(1); + return `${icon} State ${diffLabel} — ${contractDisplay}`; + } + + const level = event.severity === "critical" ? "CRITICAL" : "Warning"; + return `${icon} TTL ${level} — ${contractDisplay}`; +} + +// ─── Adaptive Card Payload Builder ─────────────────────────────────────────── + +interface Fact { + title: string; + value: string; +} + +interface AdaptiveCardPayload { + type: "message"; + attachments: Array<{ + contentType: "application/vnd.microsoft.card.adaptive"; + contentUrl: null; + content: { + $schema: string; + type: "AdaptiveCard"; + version: string; + body: Array>; + }; + }>; +} + +function buildAdaptiveCard(event: AlertEvent): AdaptiveCardPayload { + const contractDisplay = event.contractName ?? event.contractId; + const facts: Fact[] = [ + { + title: "Contract", + value: contractDisplay, + }, + { + title: "Network", + value: event.network, + }, + ]; + + if (event.type === "resource_alert") { + facts.push( + { + title: "Resource", + value: event.resource.type === "cpu" ? "CPU" : "Memory", + }, + { + title: "Usage", + value: `${event.resource.usagePercent}% (${event.resource.currentUsage.toLocaleString()} / ${event.resource.limit.toLocaleString()})`, + }, + { + title: "Severity", + value: event.severity.toUpperCase(), + }, + ); + } else if (event.type === "state_changed") { + facts.push( + { + title: "Entry", + value: event.entry.label ?? event.entry.type, + }, + { + title: "Change Type", + value: event.diff.diffType, + }, + { + title: "Old Value", + value: event.diff.oldValueXdr ?? "(none)", + }, + { + title: "New Value", + value: event.diff.newValueXdr ?? "(none)", + }, + ); + } else { + facts.push( + { + title: "Entry", + value: event.entry.label ?? event.entry.type, + }, + { + title: "Remaining TTL", + value: `${event.threshold.currentRemainingLedgers.toLocaleString()} ledgers (${event.threshold.approximateTimeRemaining})`, + }, + { + title: "Alert Threshold", + value: `${event.threshold.configuredLedgers.toLocaleString()} ledgers`, + }, + { + title: "Severity", + value: event.severity.toUpperCase(), + }, + ); + } + + return { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: { + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + type: "AdaptiveCard", + version: "1.4", + body: [ + { + type: "TextBlock", + size: "Large", + weight: "Bolder", + text: buildTitle(event), + color: severityCardColor(event), + wrap: true, + }, + { + type: "FactSet", + facts, + }, + { + type: "TextBlock", + text: `Run \`sorokeep status ${event.contractId}\` for details.`, + isSubtle: true, + size: "Small", + wrap: true, + }, + ], + }, + }, + ], + }; +} + +// ─── Webhook URL Validation ─────────────────────────────────────────────────── + +function validateWebhookUrl(webhookUrl: string): void { + if (!webhookUrl) { + throw new Error( + "Teams webhook URL is required. " + + "Pass the full URL from your Microsoft Teams channel's Incoming Webhook settings.", + ); + } + + let parsed: URL; + try { + parsed = new URL(webhookUrl); + } catch { + throw new Error( + `Invalid Teams webhook URL: "${webhookUrl}". ` + + "Expected a URL like https://.webhook.office.com/webhookb2/...", + ); + } + + if (!parsed.hostname.includes("webhook.office.com")) { + throw new Error( + `Invalid Teams webhook URL: "${webhookUrl}". ` + + "Expected a URL like https://.webhook.office.com/webhookb2/...", + ); + } +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export async function sendTeamsAlert(webhookUrl: string, event: AlertEvent): Promise { + validateWebhookUrl(webhookUrl); + + logger.debug(`Sending Teams alert to webhook`, { + type: event.type, + contractId: event.contractId, + severity: event.severity, + }); + + const customMessage = renderAlertTemplate("teams", event); + let payload: any; + + if (customMessage !== null) { + try { + const parsed = JSON.parse(customMessage); + if (parsed && typeof parsed === "object") { + payload = parsed; + } else { + payload = { + type: "message", + text: customMessage, + }; + } + } catch { + payload = { + type: "message", + text: customMessage, + }; + } + } else { + payload = buildAdaptiveCard(event); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + let detail = ""; + try { + const body = (await response.json()) as { message?: string; error?: { message?: string } }; + if (body.message) { + detail = `: ${body.message}`; + } else if (body.error?.message) { + detail = `: ${body.error.message}`; + } + } catch { + // body not JSON — ignore + } + throw new Error(`Teams webhook request failed: HTTP ${response.status}${detail}`); + } + + logger.debug(`Teams alert delivered successfully`); +} diff --git a/tests/alerts/teams.test.ts b/tests/alerts/teams.test.ts new file mode 100644 index 00000000..716293fa --- /dev/null +++ b/tests/alerts/teams.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { sendTeamsAlert } from "../../src/alerts/teams.js"; +import type { AlertEvent } from "../../src/alerts/types.js"; + +const VALID_TEAMS_WEBHOOK = "https://contoso.webhook.office.com/webhookb2/12345678-1234-1234-1234-123456789012@12345678-1234-1234-1234-123456789012/IncomingWebhook/abcdef/12345678"; + +function makeAlertEvent(overrides: Partial = {}): AlertEvent { + return { + type: "threshold_crossed", + severity: "critical", + contractId: "CDEF1234ABCD5678", + contractName: "my-defi-pool", + network: "mainnet", + entry: { + keyXdr: "AAAA1234", + type: "instance", + label: "Contract Instance", + }, + threshold: { + configuredLedgers: 10_000, + currentRemainingLedgers: 1_200, + approximateTimeRemaining: "~2h 00m", + }, + firedAtLedger: 2_500_000, + timestamp: "2026-05-21T20:37:08.000Z", + ...overrides, + }; +} + +function makeOkResponse(): Response { + return new Response("1", { + status: 200, + headers: { "content-type": "text/plain" }, + }); +} + +function makeErrorResponse(status: number, message: string): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("sendTeamsAlert", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal("fetch", mockFetch); + }); + + describe("Webhook URL validation", () => { + it("throws a clear error when URL is empty", async () => { + await expect(sendTeamsAlert("", makeAlertEvent())).rejects.toThrow(/Teams webhook URL is required/); + }); + + it("throws when URL is not a valid URL string", async () => { + await expect(sendTeamsAlert("invalid-url", makeAlertEvent())).rejects.toThrow(/Invalid Teams webhook URL/); + }); + + it("throws when hostname does not match Teams webhook domain", async () => { + await expect(sendTeamsAlert("https://discord.com/api/webhooks/123/abc", makeAlertEvent())).rejects.toThrow( + /Invalid Teams webhook URL/, + ); + }); + + it("does not call fetch when URL validation fails", async () => { + await expect(sendTeamsAlert("https://example.com/hook", makeAlertEvent())).rejects.toThrow(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe("Adaptive Card JSON payload structure", () => { + it("POSTs to the configured webhook URL", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + await sendTeamsAlert(VALID_TEAMS_WEBHOOK, makeAlertEvent()); + const [url] = mockFetch.mock.calls[0]!; + expect(url).toBe(VALID_TEAMS_WEBHOOK); + }); + + it("uses HTTP POST method and application/json header", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + await sendTeamsAlert(VALID_TEAMS_WEBHOOK, makeAlertEvent()); + const [, options] = mockFetch.mock.calls[0]!; + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/json"); + }); + + it("POSTs a well-formed Adaptive Card payload", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + await sendTeamsAlert(VALID_TEAMS_WEBHOOK, makeAlertEvent()); + const [, options] = mockFetch.mock.calls[0]!; + const body = JSON.parse(options.body as string); + + expect(body.type).toBe("message"); + expect(body.attachments).toHaveLength(1); + expect(body.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive"); + + const card = body.attachments[0].content; + expect(card.$schema).toBe("http://adaptivecards.io/schemas/adaptive-card.json"); + expect(card.type).toBe("AdaptiveCard"); + expect(card.version).toBe("1.4"); + expect(Array.isArray(card.body)).toBe(true); + }); + + it("includes severity color, title, and contract name in the card body", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const event = makeAlertEvent({ severity: "critical", contractName: "my-defi-pool" }); + await sendTeamsAlert(VALID_TEAMS_WEBHOOK, event); + + const [, options] = mockFetch.mock.calls[0]!; + const body = JSON.parse(options.body as string); + const cardBody = body.attachments[0].content.body; + + const titleBlock = cardBody.find((b: { type: string }) => b.type === "TextBlock"); + expect(titleBlock).toBeDefined(); + expect(titleBlock.text).toContain("my-defi-pool"); + expect(titleBlock.color).toBe("attention"); + + const factSet = cardBody.find((b: { type: string }) => b.type === "FactSet"); + expect(factSet).toBeDefined(); + const contractFact = factSet.facts.find((f: { title: string }) => f.title === "Contract"); + expect(contractFact.value).toBe("my-defi-pool"); + }); + + it("formats resource alert events correctly", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const resourceEvent: AlertEvent = { + type: "resource_alert", + severity: "warning", + contractId: "C1234", + contractName: "resource-contract", + network: "testnet", + resource: { + type: "cpu", + currentUsage: 85_000_000, + limit: 100_000_000, + usagePercent: 85, + }, + message: "CPU usage is at 85% of limit", + timestamp: "2026-05-21T20:37:08.000Z", + }; + + await sendTeamsAlert(VALID_TEAMS_WEBHOOK, resourceEvent); + const [, options] = mockFetch.mock.calls[0]!; + const body = JSON.parse(options.body as string); + const cardBody = body.attachments[0].content.body; + + const factSet = cardBody.find((b: { type: string }) => b.type === "FactSet"); + const resourceFact = factSet.facts.find((f: { title: string }) => f.title === "Resource"); + expect(resourceFact.value).toBe("CPU"); + }); + + it("formats state change alert events correctly", async () => { + mockFetch.mockResolvedValue(makeOkResponse()); + const stateChangeEvent: AlertEvent = { + type: "state_changed", + severity: "info", + contractId: "C1234", + contractName: "state-contract", + network: "mainnet", + entry: { + keyXdr: "KEY123", + type: "data", + label: "Admin Entry", + }, + diff: { + diffType: "updated", + oldValueXdr: "OLDXDR", + newValueXdr: "NEWXDR", + }, + detectedAtLedger: 1000, + timestamp: "2026-05-21T20:37:08.000Z", + }; + + await sendTeamsAlert(VALID_TEAMS_WEBHOOK, stateChangeEvent); + const [, options] = mockFetch.mock.calls[0]!; + const body = JSON.parse(options.body as string); + const cardBody = body.attachments[0].content.body; + + const factSet = cardBody.find((b: { type: string }) => b.type === "FactSet"); + const changeTypeFact = factSet.facts.find((f: { title: string }) => f.title === "Change Type"); + expect(changeTypeFact.value).toBe("updated"); + }); + }); + + describe("Error handling", () => { + it("throws an error with status code when HTTP response is non-2xx", async () => { + mockFetch.mockResolvedValue(makeErrorResponse(400, "Bad Request")); + await expect(sendTeamsAlert(VALID_TEAMS_WEBHOOK, makeAlertEvent())).rejects.toThrow(/HTTP 400/); + }); + + it("throws an error with status code 500 when server errors", async () => { + mockFetch.mockResolvedValue(makeErrorResponse(500, "Internal Server Error")); + await expect(sendTeamsAlert(VALID_TEAMS_WEBHOOK, makeAlertEvent())).rejects.toThrow(/HTTP 500/); + }); + + it("re-throws network failures when fetch rejects", async () => { + mockFetch.mockRejectedValue(new Error("Network connection lost")); + await expect(sendTeamsAlert(VALID_TEAMS_WEBHOOK, makeAlertEvent())).rejects.toThrow(/Network connection lost/); + }); + }); +});