From 1945a394182b651455448e2d942138ff438914c4 Mon Sep 17 00:00:00 2001 From: Sarah Vogel Date: Thu, 15 Oct 2026 17:52:39 +0200 Subject: [PATCH] feat(subscriptions): detect Microsoft Teams webhook URLs and emit Adaptive Cards (#2898) * feat(subscriptions): detect Microsoft Teams webhook URLs and emit Adaptive Cards Status-page webhook subscribers receive channel-native payloads for Slack and Discord URLs; any other URL - including Teams Workflows and the legacy Office connector - gets the generic JSON body, which Teams cannot render as a card (issue #1156). This adds a `teams` flavor: - `detectWebhookFlavor` recognizes hostnames ending in `.logic.azure.com`, `.powerplatform.com`, or `.webhook.office.com` (parsed, not substring, so a path like `?to=webhook.office.com` stays generic). - `buildTeamsPayload` emits an Adaptive Card 1.5 inside the `application/vnd.microsoft.card.adaptive` message envelope, mirroring the ms-teams monitor-alert provider's wire format. The card has a status-colored header container (attention/warning/good/accent), a FactSet with status/page /timestamp, the update message, affected components, manage/unsubscribe links, and a "View details" action. - `buildTestPayload('teams')` emits the matching test card. - Dashboard: `FLAVOR_LABELS` gets a "Microsoft Teams" entry so the subscribers table shows the right badge; the subscriber form's helper text now names Teams alongside Slack and Discord. - Docs: a Microsoft Teams section lives between Discord and Generic in the subscriber reference. Tests mirror the Slack/Discord coverage: four flavor-detection cases (plus a negative substring case and a malformed-URL fallback), three send-notification cases (envelope shape, manage/unsubscribe links, maintenance deep link), and one `buildTestPayload` case. All 54 webhook test steps pass under `deno test --no-check`. Closes part of #1156. Co-Authored-By: Claude Opus 4.7 * docs(subscriber): list Microsoft Teams in the webhook channel bullet; trim detection comment Address the Claude Code Review on #2898: - The "Adding subscribers from the dashboard" section still said webhook URLs go to Slack and Discord only; it now names Microsoft Teams alongside them, matching the dashboard form helper text and the dedicated section below. - Shorten the TEAMS_HOST_SUFFIXES comment to one line explaining why we match by hostname suffix. Co-Authored-By: Claude Opus 4.7 * ci: apply automated fixes --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../data-table/subscribers/columns.tsx | 1 + .../src/components/forms/subscriber/form.tsx | 5 +- .../pages/docs/reference/subscriber.mdx | 6 +- .../src/channels/webhook.test.ts | 124 +++++++++++++ .../subscriptions/src/channels/webhook.ts | 172 +++++++++++++++++- 5 files changed, 303 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/components/data-table/subscribers/columns.tsx b/apps/dashboard/src/components/data-table/subscribers/columns.tsx index e99309883..6246dcad8 100644 --- a/apps/dashboard/src/components/data-table/subscribers/columns.tsx +++ b/apps/dashboard/src/components/data-table/subscribers/columns.tsx @@ -20,6 +20,7 @@ type Subscriber = RouterOutputs["pageSubscriber"]["list"][number]; const FLAVOR_LABELS = { slack: "Slack", discord: "Discord", + teams: "Microsoft Teams", generic: "Webhook", } as const; diff --git a/apps/dashboard/src/components/forms/subscriber/form.tsx b/apps/dashboard/src/components/forms/subscriber/form.tsx index ab49da242..789fe22ea 100644 --- a/apps/dashboard/src/components/forms/subscriber/form.tsx +++ b/apps/dashboard/src/components/forms/subscriber/form.tsx @@ -293,8 +293,9 @@ export function FormSubscriber({ - Slack and Discord URLs receive channel-native messages; - any other URL receives a generic JSON payload. + Slack, Discord, and Microsoft Teams URLs receive + channel-native messages; any other URL receives a + generic JSON payload. {/^https?:\/\//.test(field.value) ? ( diff --git a/apps/web/src/content/pages/docs/reference/subscriber.mdx b/apps/web/src/content/pages/docs/reference/subscriber.mdx index 7e704423e..fdf76bf34 100644 --- a/apps/web/src/content/pages/docs/reference/subscriber.mdx +++ b/apps/web/src/content/pages/docs/reference/subscriber.mdx @@ -57,7 +57,7 @@ Status page administrators can manage their subscriber lists, including: Beyond public self-subscription, administrators can add subscribers directly from the dashboard. This is useful for onboarding partners, internal teams, or automation that should receive updates without going through the public opt-in flow. Each manually added subscriber uses one of three channels: - **Email** — delivers updates to a contact address. By adding an email here you confirm the contact has consented to receive status updates; no confirmation email is sent. -- **Webhook** — POSTs each update to a URL. Slack and Discord URLs receive channel-native messages; any other URL receives a generic JSON payload. +- **Webhook** — POSTs each update to a URL. Slack, Discord, and Microsoft Teams URLs receive channel-native messages; any other URL receives a generic JSON payload. - **Slack** — posts each update straight into a Slack channel through the openstatus Slack app. The bot token is resolved from the workspace integration at send time and is never stored on the subscriber. A channel can also subscribe itself with the slash command (see [Slack subscribers](#slack-subscribers)). For every channel, you can optionally: @@ -119,6 +119,10 @@ A URL starting with `https://hooks.slack.com/services/` is treated as a Slack in A URL starting with `https://discord.com/api/webhooks/` is treated as a Discord webhook. The payload uses a Discord embed with a status-colored sidebar, a title linking to the event, status and page fields, the update message, affected components, and manage/unsubscribe links. +### Microsoft Teams + +A URL whose hostname ends with `.logic.azure.com`, `.powerplatform.com`, or `.webhook.office.com` is treated as a Microsoft Teams webhook (Teams Workflows or the legacy Office connector). The payload is an [Adaptive Card 1.5](https://adaptivecards.io/) wrapped in the standard Teams message envelope — a status-colored header container, a FactSet with status, page, and timestamp, the update message, affected components, manage/unsubscribe links, and a `View details` action button. + ### Generic Any other URL receives a versioned, channel-agnostic JSON payload. Use this to forward updates into your own systems. The body is shaped as: diff --git a/packages/subscriptions/src/channels/webhook.test.ts b/packages/subscriptions/src/channels/webhook.test.ts index d17be6f40..749f75fe0 100644 --- a/packages/subscriptions/src/channels/webhook.test.ts +++ b/packages/subscriptions/src/channels/webhook.test.ts @@ -16,6 +16,10 @@ import { const SLACK_URL = "https://hooks.slack.com/services/T1/B1/XXX"; const DISCORD_URL = "https://discord.com/api/webhooks/1/xxx"; +const TEAMS_WORKFLOWS_URL = + "https://prod-123.westus.logic.azure.com:443/workflows/abc/triggers/manual/paths/invoke"; +const TEAMS_LEGACY_URL = + "https://acme.webhook.office.com/webhookb2/abc@def/IncomingWebhook/xyz/pid"; let fetchMock: Stub; @@ -330,6 +334,32 @@ describe("detectWebhookFlavor", () => { test("falls back to generic for unknown hosts", () => { expect(detectWebhookFlavor("https://example.com/webhook")).toBe("generic"); }); + + test("detects Teams Workflows URLs on *.logic.azure.com", () => { + expect(detectWebhookFlavor(TEAMS_WORKFLOWS_URL)).toBe("teams"); + }); + + test("detects legacy Office connector URLs on *.webhook.office.com", () => { + expect(detectWebhookFlavor(TEAMS_LEGACY_URL)).toBe("teams"); + }); + + test("detects Power Automate URLs on *.powerplatform.com", () => { + expect( + detectWebhookFlavor( + "https://default-abc.flow.europe.powerplatform.com/manual/paths/invoke", + ), + ).toBe("teams"); + }); + + test("does not misclassify URLs that merely contain a Teams host as a path segment", () => { + expect( + detectWebhookFlavor("https://example.com/proxy?to=webhook.office.com"), + ).toBe("generic"); + }); + + test("falls back to generic for malformed URLs", () => { + expect(detectWebhookFlavor("not-a-url")).toBe("generic"); + }); }); // ─── flavor-specific notification payloads ──────────────────────────────────── @@ -432,6 +462,84 @@ describe("sendWebhookNotifications (flavor detection)", () => { "[Demo](https://demo.openstatus.dev)", ); }); + + test("emits Teams Adaptive Card envelope for Teams URLs", async () => { + resolveFetch(new Response(null, { status: 200 })); + const sub = makeSub({ + webhookUrl: TEAMS_WORKFLOWS_URL, + pageSlug: "demo", + pageName: "Demo", + }); + + await sendWebhookNotifications([sub], makeUpdate({ id: 42 })); + + const [, init] = fetchMock.calls[0].args; + const body = JSON.parse(init?.body as string); + expect(body.type).toBe("message"); + expect(body.attachments[0].contentType).toBe( + "application/vnd.microsoft.card.adaptive", + ); + const card = body.attachments[0].content; + expect(card.type).toBe("AdaptiveCard"); + expect(card.version).toBe("1.5"); + expect(card.body[0]).toMatchObject({ + type: "Container", + style: "attention", // "investigating" → red → attention + items: [ + { + type: "TextBlock", + text: "Test Incident", + weight: "Bolder", + }, + ], + }); + expect(card.actions[0]).toMatchObject({ + type: "Action.OpenUrl", + title: "View details", + url: "https://demo.openstatus.dev/events/report/42", + }); + }); + + test("Teams payload includes manage/unsubscribe links in a TextBlock", async () => { + resolveFetch(new Response(null, { status: 200 })); + const sub = makeSub({ + webhookUrl: TEAMS_LEGACY_URL, + token: "tok-123", + pageSlug: "demo", + }); + + await sendWebhookNotifications([sub], makeUpdate()); + + const [, init] = fetchMock.calls[0].args; + const stringified = init?.body as string; + expect(stringified).toContain( + "[Manage](https://demo.openstatus.dev/manage/tok-123)", + ); + expect(stringified).toContain( + "[Unsubscribe](https://demo.openstatus.dev/unsubscribe/tok-123)", + ); + }); + + test("Teams payload uses the maintenance deep-link and a blue/accent header for maintenance", async () => { + resolveFetch(new Response(null, { status: 200 })); + const sub = makeSub({ + webhookUrl: TEAMS_WORKFLOWS_URL, + pageSlug: "demo", + }); + + await sendWebhookNotifications( + [sub], + makeUpdate({ id: 7, status: "maintenance" }), + ); + + const [, init] = fetchMock.calls[0].args; + const body = JSON.parse(init?.body as string); + const card = body.attachments[0].content; + expect(card.body[0].style).toBe("accent"); + expect(card.actions[0].url).toBe( + "https://demo.openstatus.dev/events/maintenance/7", + ); + }); }); // ─── buildGenericPayload (staged, not yet reachable in production) ──────────── @@ -584,6 +692,22 @@ describe("buildTestPayload", () => { expect(payload.embeds).toBeInstanceOf(Array); }); + test("Teams flavor returns an Adaptive Card wrapped in a Teams message envelope", () => { + const payload = buildTestPayload("teams") as { + type: string; + attachments: { + contentType: string; + content: { type: string; version: string }; + }[]; + }; + expect(payload.type).toBe("message"); + expect(payload.attachments[0].contentType).toBe( + "application/vnd.microsoft.card.adaptive", + ); + expect(payload.attachments[0].content.type).toBe("AdaptiveCard"); + expect(payload.attachments[0].content.version).toBe("1.5"); + }); + test("generic flavor returns enveloped type='test' JSON", () => { const payload = buildTestPayload("generic") as { version: string; diff --git a/packages/subscriptions/src/channels/webhook.ts b/packages/subscriptions/src/channels/webhook.ts index cc17dedb6..6fa49a4f4 100644 --- a/packages/subscriptions/src/channels/webhook.ts +++ b/packages/subscriptions/src/channels/webhook.ts @@ -6,10 +6,16 @@ import { WEBHOOK_PAYLOAD_VERSION } from "../payload"; import type { PageUpdate, Subscription } from "../types"; import { postWebhookWithRetry } from "./retry"; -export type WebhookFlavor = "slack" | "discord" | "generic"; +export type WebhookFlavor = "slack" | "discord" | "teams" | "generic"; const SLACK_PREFIX = "https://hooks.slack.com/services/"; const DISCORD_PREFIX = "https://discord.com/api/webhooks/"; +// Teams hosts vary by tenant and region, so match by hostname suffix. +const TEAMS_HOST_SUFFIXES = [ + ".logic.azure.com", + ".powerplatform.com", + ".webhook.office.com", +] as const; const TIMEOUT_MS = 5000; // 5 seconds /** @@ -19,9 +25,20 @@ const TIMEOUT_MS = 5000; // 5 seconds export function detectWebhookFlavor(url: string): WebhookFlavor { if (url.startsWith(SLACK_PREFIX)) return "slack"; if (url.startsWith(DISCORD_PREFIX)) return "discord"; + if (isTeamsWebhookUrl(url)) return "teams"; return "generic"; } +function isTeamsWebhookUrl(url: string): boolean { + let hostname: string; + try { + hostname = new URL(url).hostname.toLowerCase(); + } catch { + return false; + } + return TEAMS_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); +} + function redactWebhookUrl(url: string): string { try { return `${new URL(url).origin}/***`; @@ -258,8 +275,118 @@ function buildDiscordPayload( }; } +type TeamsContainerStyle = "attention" | "warning" | "good" | "accent"; +type TeamsTextColor = "Default" | "Accent" | "Good" | "Warning" | "Attention"; + +function teamsStyleForStatus(color: StatusColor): { + containerStyle: TeamsContainerStyle; + textColor: TeamsTextColor; +} { + switch (color) { + case "red": + return { containerStyle: "attention", textColor: "Attention" }; + case "yellow": + return { containerStyle: "warning", textColor: "Warning" }; + case "green": + return { containerStyle: "good", textColor: "Good" }; + case "blue": + return { containerStyle: "accent", textColor: "Accent" }; + } +} + +/** + * Microsoft Teams webhook payload (Workflows / legacy Office). Wraps an + * Adaptive Card 1.5 in the `application/vnd.microsoft.card.adaptive` + * envelope, mirroring the ms-teams monitor-alert provider's wire format. + */ +function buildTeamsPayload( + pageUpdate: PageUpdate, + subscription: Subscription, + links: ManagementLinks, +) { + const color = statusColor(pageUpdate.status); + const { containerStyle, textColor } = teamsStyleForStatus(color); + const eventUrl = resolveEventUrl(pageUpdate, subscription); + const pageOrigin = resolveStatusPageOrigin(subscription); + + const body: Record[] = [ + { + type: "Container", + style: containerStyle, + items: [ + { + type: "TextBlock", + text: pageUpdate.title, + weight: "Bolder", + size: "Large", + color: textColor, + wrap: true, + }, + ], + }, + { + type: "FactSet", + facts: [ + { title: "Status", value: statusLabel(pageUpdate.status) }, + { title: "Page", value: `[${subscription.pageName}](${pageOrigin})` }, + { title: "Timestamp", value: pageUpdate.date }, + ], + }, + ]; + + if (pageUpdate.message) { + body.push({ + type: "TextBlock", + text: pageUpdate.message, + wrap: true, + spacing: "Medium", + }); + } + + if (pageUpdate.pageComponents.length > 0) { + body.push({ + type: "TextBlock", + text: `**Affected:** ${pageUpdate.pageComponents.join(", ")}`, + wrap: true, + }); + } + + if (links.manageUrl && links.unsubscribeUrl) { + body.push({ + type: "TextBlock", + text: `[Manage](${links.manageUrl}) · [Unsubscribe](${links.unsubscribeUrl})`, + wrap: true, + isSubtle: true, + spacing: "Medium", + }); + } + + return { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: { + type: "AdaptiveCard", + $schema: "https://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body, + actions: [ + { + type: "Action.OpenUrl", + title: "View details", + url: eventUrl, + }, + ], + }, + }, + ], + }; +} + /** - * Generic JSON webhook payload, sent to any non-Slack/Discord URL. + * Generic JSON webhook payload, sent to any non-Slack/Discord/Teams URL. * Shape is pinned by `webhookPayloadSchema` in `../payload`. */ export function buildGenericPayload( @@ -352,6 +479,8 @@ function buildNotificationPayload( return buildSlackPayload(pageUpdate, subscription, links); case "discord": return buildDiscordPayload(pageUpdate, subscription, links); + case "teams": + return buildTeamsPayload(pageUpdate, subscription, links); case "generic": return buildGenericPayload(pageUpdate, subscription, links); } @@ -452,6 +581,45 @@ export function buildTestPayload(flavor: WebhookFlavor) { }, ], }; + case "teams": + return { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: { + type: "AdaptiveCard", + $schema: "https://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "Container", + style: "good", + items: [ + { + type: "TextBlock", + text: "Test Notification", + weight: "Bolder", + size: "Large", + color: "Good", + wrap: true, + }, + { + type: "TextBlock", + text: "Your openstatus webhook is configured correctly.", + spacing: "None", + isSubtle: true, + wrap: true, + }, + ], + }, + ], + actions: [], + }, + }, + ], + }; case "generic": return { version: WEBHOOK_PAYLOAD_VERSION, -- 2.51.2