From 1165251923b0ce6d4284368a3c05453571fe9403 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:06:31 +0100 Subject: [PATCH 1/6] feat(alerts): Google Chat as a notification destination Google Chat spaces could not receive alerts: the generic webhook ships Maple's own JSON envelope, which a Chat incoming webhook rejects. Adds a first-class `google-chat` destination that takes a space's incoming webhook URL, encrypted at rest like every other channel secret. - Each alert is a cardsV2 card: headline, summary in the alert's accent colour, severity and group rows, the chart, Open in Maple / Ask Maple AI buttons, and the sparkline, incident and time in a footer. A rule's template replaces the headline and summary, as it does for Discord. - Messages are keyed to a thread by the rule and group (`dedupeKey`) with REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD, so a resolve or reminder replies under its trigger; a space without threads posts it top-level. - Card text is a small HTML subset, so every dynamic value is escaped. - The URL is checked at save time (https, chat.googleapis.com, /v1/spaces//messages, key and token present) and the form mirrors the check. The summary shows the space id; the key and token never leave the encrypted blob, and the span records the path only, never the query. - 401/403/404 get Chat-specific messages; classification is unchanged. Also in the v2 API, Maple.AlertDestination (with a changeset), the Terraform provider, the iOS OpenAPI spec and the docs. No migration: `alert_destinations.type` is text and the config is jsonb plus an encrypted blob. --- .changeset/alchemy-google-chat-destination.md | 5 + .../src/routes/v2/alert-destinations.http.ts | 14 ++ .../Maple/Components/AlertFormatting.swift | 1 + .../MapleAPI/Sources/MapleAPI/openapi.json | 1 + .../alerting/notification-destinations.md | 9 +- .../web/src/page/alerts/destination/dialog.ts | 22 +++ apps/web/src/page/alerts/destination/view.ts | 27 +++ apps/web/src/page/alerts/shared/commands.ts | 2 + apps/web/src/page/alerts/shared/providers.ts | 16 ++ apps/web/src/ui/icons.generated.ts | 24 +++ .../alchemy-maple/src/AlertDestination.ts | 5 +- .../alerts/AlertDestinationHydration.ts | 4 + .../alerts/AlertDestinationsService.ts | 47 ++++- .../alerts/AlertsService.google-chat.test.ts | 82 +++++++++ .../src/services/alerts/delivery/dispatch.ts | 2 + .../delivery/transports/google-chat.test.ts | 142 ++++++++++++++++ .../alerts/delivery/transports/google-chat.ts | 160 ++++++++++++++++++ .../alerts/delivery/transports/render.test.ts | 15 +- packages/domain/src/http/alerts.ts | 25 +++ .../domain/src/http/v2/alert-destinations.ts | 23 ++- .../docs/resources/alert_destination.md | 4 +- .../internal/provider/resources.go | 6 +- packages/web-data/src/alerts/form-utils.ts | 20 ++- 23 files changed, 642 insertions(+), 14 deletions(-) create mode 100644 .changeset/alchemy-google-chat-destination.md create mode 100644 packages/backend/src/services/alerts/AlertsService.google-chat.test.ts create mode 100644 packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts create mode 100644 packages/backend/src/services/alerts/delivery/transports/google-chat.ts diff --git a/.changeset/alchemy-google-chat-destination.md b/.changeset/alchemy-google-chat-destination.md new file mode 100644 index 0000000000..dd956ec631 --- /dev/null +++ b/.changeset/alchemy-google-chat-destination.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/alchemy": minor +--- + +`Maple.AlertDestination` accepts `type: "google-chat"` with a `webhook_url`, a Google Chat space's incoming webhook. diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index dd2b58cadd..87cdb4fbb1 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -11,6 +11,7 @@ import { AlertDestinationNotFoundError, PagerDutyAlertDestinationConfig, TelegramAlertDestinationConfig, + GoogleChatAlertDestinationConfig, WebhookAlertDestinationConfig, } from "@maple/domain/http" import type { @@ -97,6 +98,13 @@ const toCreateRequest = (params: V2AlertDestinationCreateParams) => { chatId: params.chat_id, ...(params.enabled !== undefined ? { enabled: params.enabled } : undefined), }) + case "google-chat": + return new GoogleChatAlertDestinationConfig({ + type: "google-chat", + name: params.name, + webhookUrl: params.webhook_url, + ...(params.enabled !== undefined ? { enabled: params.enabled } : undefined), + }) case "email": return new EmailAlertDestinationConfig({ type: "email", @@ -186,6 +194,12 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati ...(params.bot_token !== undefined ? { botToken: params.bot_token } : undefined), ...(params.chat_id !== undefined ? { chatId: params.chat_id } : undefined), } + case "google-chat": + return { + type: "google-chat", + ...shared, + ...(params.webhook_url !== undefined ? { webhookUrl: params.webhook_url } : undefined), + } case "email": return { type: "email", diff --git a/apps/ios/Maple/Components/AlertFormatting.swift b/apps/ios/Maple/Components/AlertFormatting.swift index fdd1cffcc2..8ff322d722 100644 --- a/apps/ios/Maple/Components/AlertFormatting.swift +++ b/apps/ios/Maple/Components/AlertFormatting.swift @@ -169,6 +169,7 @@ extension AlertDestinationType { case .hazelOauth: "Hazel" case .discord: "Discord" case .telegram: "Telegram" + case .googleChat: "Google Chat" case .email: "Email" case .linear: "Linear" } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 259f6bbdcf..9e139630fb 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -6799,6 +6799,7 @@ "hazel-oauth", "discord", "telegram", + "google-chat", "email", "chat", "linear" diff --git a/apps/landing/src/content/docs/alerting/notification-destinations.md b/apps/landing/src/content/docs/alerting/notification-destinations.md index 0ef333186c..7be4508970 100644 --- a/apps/landing/src/content/docs/alerting/notification-destinations.md +++ b/apps/landing/src/content/docs/alerting/notification-destinations.md @@ -1,6 +1,6 @@ --- title: "Notification destinations" -description: "Route Maple alerts to Slack, email, PagerDuty, Discord, Telegram, Hazel, Linear or any HTTP endpoint. How to add a destination, send a test, and get the right credentials for each provider." +description: "Route Maple alerts to Slack, email, PagerDuty, Discord, Telegram, Google Chat, Hazel, Linear or any HTTP endpoint. How to add a destination, send a test, and get the right credentials for each provider." group: "Alerting" order: 4 --- @@ -68,6 +68,13 @@ Post alerts to a Discord channel through an incoming webhook. 1. In Discord, open **Channel settings → Integrations → Webhooks → New Webhook**. 2. Copy the webhook URL (`https://discord.com/api/webhooks/...`) into the **Discord webhook URL** field. +## Google Chat + +Post alerts to a Google Chat space through an incoming webhook. Each alert is a card with the summary, severity, chart and links back to Maple, and a resolve or reminder replies in the thread its trigger started. + +1. In Google Chat, open the space, then **Apps & integrations → Webhooks → Add webhook**. Webhooks are available in Google Workspace accounts; an admin may need to allow them. +2. Copy the webhook URL (`https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=...`) into the **Google Chat webhook URL** field. The `key` and `token` in it are the credential, so treat the URL as a secret. + ## Telegram Send alerts to a Telegram chat, group or channel through a bot you create. Telegram has no per-channel webhook, so a destination needs two values: the bot's token and the id of the chat it posts to. diff --git a/apps/web/src/page/alerts/destination/dialog.ts b/apps/web/src/page/alerts/destination/dialog.ts index efa4a678c6..fa8200190b 100644 --- a/apps/web/src/page/alerts/destination/dialog.ts +++ b/apps/web/src/page/alerts/destination/dialog.ts @@ -180,6 +180,8 @@ const createRequest = (payload: V2AlertDestinationCreateParams) => { return { payload } case "telegram": return { payload } + case "google-chat": + return { payload } case "email": return { payload } case "chat": @@ -202,6 +204,8 @@ const updateRequest = (id: AlertDestinationId, payload: V2AlertDestinationUpdate return { params, payload } case "telegram": return { params, payload } + case "google-chat": + return { params, payload } case "email": return { params, payload } case "chat": @@ -247,6 +251,19 @@ export const isValidPagerDutyKey = (key: string): boolean => /^[A-Za-z0-9]{32}$/ export const isValidTelegramToken = (token: string): boolean => /^\d{5,}:[A-Za-z0-9_-]{30,}$/.test(token.trim()) +/** Mirrors the server's check, so a pasted space link or bare token is caught before save. */ +export const isValidGoogleChatWebhookUrl = (raw: string): boolean => { + const url = URL.parse(raw.trim()) + return ( + url !== null && + url.protocol === "https:" && + url.host === "chat.googleapis.com" && + /^\/v1\/spaces\/[A-Za-z0-9_-]+\/messages$/.test(url.pathname) && + url.searchParams.has("key") && + url.searchParams.has("token") + ) +} + export const isFormReady = (form: DestinationForm, isEditing: boolean): boolean => { if (isEditing && form.name.trim().length === 0) return false switch (form.type) { @@ -254,6 +271,11 @@ export const isFormReady = (form: DestinationForm, isEditing: boolean): boolean return form.hazelOrganizationId.trim().length > 0 && form.hazelChannelId.trim().length > 0 case "discord": return isEditing || form.webhookUrl.trim().length > 0 + case "google-chat": + return ( + (isEditing && form.webhookUrl.trim().length === 0) || + isValidGoogleChatWebhookUrl(form.webhookUrl) + ) case "telegram": return ( form.telegramChatId.trim().length > 0 && diff --git a/apps/web/src/page/alerts/destination/view.ts b/apps/web/src/page/alerts/destination/view.ts index 56623f5e3d..cc8e9f4b47 100644 --- a/apps/web/src/page/alerts/destination/view.ts +++ b/apps/web/src/page/alerts/destination/view.ts @@ -296,6 +296,33 @@ const connectionFields = (h: H, model: Model, isEditing: boolean): ReadonlyArray ]), ] } + case "google-chat": { + const fieldIds = ids("google-chat-webhook", true) + return [ + fieldBlock(h, [ + fieldLabel( + h, + { ids: fieldIds, className: "text-xs", state: stateOf(form.webhookUrl) }, + "Google Chat webhook URL", + ), + textInput(h, model, { + name: "google-chat-webhook", + field: "webhookUrl", + placeholder: isEditing + ? "Leave blank to keep current webhook" + : "https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=...", + className: "font-mono text-xs", + type: "password", + hasDescription: true, + }), + fieldDescription( + h, + { ids: fieldIds, className: "text-2xs", state: stateOf(form.webhookUrl) }, + "In Google Chat: open the space → Apps & integrations → Webhooks → Add webhook, then copy the URL.", + ), + ]), + ] + } case "telegram": return telegramFields(h, model, isEditing) case "webhook": diff --git a/apps/web/src/page/alerts/shared/commands.ts b/apps/web/src/page/alerts/shared/commands.ts index 5b6f181a6d..f1807097ec 100644 --- a/apps/web/src/page/alerts/shared/commands.ts +++ b/apps/web/src/page/alerts/shared/commands.ts @@ -53,6 +53,8 @@ export const updateDestination = ( return yield* client.alertDestinations.update({ params, payload }) case "telegram": return yield* client.alertDestinations.update({ params, payload }) + case "google-chat": + return yield* client.alertDestinations.update({ params, payload }) case "email": return yield* client.alertDestinations.update({ params, payload }) case "chat": diff --git a/apps/web/src/page/alerts/shared/providers.ts b/apps/web/src/page/alerts/shared/providers.ts index b83d8ab2df..7730e06b37 100644 --- a/apps/web/src/page/alerts/shared/providers.ts +++ b/apps/web/src/page/alerts/shared/providers.ts @@ -120,6 +120,21 @@ export const PROVIDERS = new Map([ docsLabel: "Telegram setup guide", }, ], + [ + "google-chat", + { + type: "google-chat", + label: "Google Chat", + description: "Post alerts to a Google Chat space via incoming webhook.", + accent: "#00AC47", + accentBg: "rgba(0,172,71,0.16)", + accentText: "light-dark(#00762F, #34C26B)", + accentOn: INK_ON_BRIGHT_ACCENT, + fallbackIcon: "GoogleChatIcon", + docsUrl: "https://maple.dev/docs/alerting/notification-destinations#google-chat", + docsLabel: "Google Chat setup guide", + }, + ], [ "email", { @@ -196,6 +211,7 @@ export const DESTINATION_TYPES: ReadonlyArray = [ "email", "webhook", "telegram", + "google-chat", "hazel-oauth", "linear", ] diff --git a/apps/web/src/ui/icons.generated.ts b/apps/web/src/ui/icons.generated.ts index 590fc65552..bc412bb967 100644 --- a/apps/web/src/ui/icons.generated.ts +++ b/apps/web/src/ui/icons.generated.ts @@ -1330,6 +1330,30 @@ export const ICON_TREES = { ], ], ], + GoogleChatIcon: [ + "svg", + [ + ["xmlns", "http://www.w3.org/2000/svg"], + ["viewBox", "0 0 24 24"], + ["width", "7777"], + ["height", "7777"], + ["class", "__icon_class__"], + ["fill", "currentColor"], + ["aria-hidden", "true"], + ], + [ + [ + "path", + [ + [ + "d", + "M1.637 0C.733 0 0 .733 0 1.637v16.5c0 .904.733 1.636 1.637 1.636h3.955v3.323c0 .804.97 1.207 1.539.638l3.963-3.96h11.27c.903 0 1.636-.733 1.636-1.637V5.592L18.408 0Zm3.955 5.592h12.816v8.59H8.455l-2.863 2.863Z", + ], + ], + [], + ], + ], + ], LinuxIcon: [ "svg", [ diff --git a/packages/alchemy-maple/src/AlertDestination.ts b/packages/alchemy-maple/src/AlertDestination.ts index 660ca6c6fb..12b7a35ed3 100644 --- a/packages/alchemy-maple/src/AlertDestination.ts +++ b/packages/alchemy-maple/src/AlertDestination.ts @@ -32,6 +32,8 @@ type DestinationVariant = | (DestinationBaseProps & { type: "webhook"; url: string; signing_secret?: SecretInput }) | (DestinationBaseProps & { type: "discord"; webhook_url: SecretInput }) | (DestinationBaseProps & { type: "telegram"; bot_token: SecretInput; chat_id: string }) + /** A Google Chat space's incoming webhook URL. */ + | (DestinationBaseProps & { type: "google-chat"; webhook_url: SecretInput }) | (DestinationBaseProps & { type: "email"; member_user_ids: string[] }) /** A team in the Linear workspace connected on Maple's Integrations page. */ | (DestinationBaseProps & { type: "linear"; team_id: string }) @@ -53,7 +55,7 @@ export type AlertDestination = Resource< > /** - * A notification channel (PagerDuty, webhook, Discord, Telegram, workspace-member + * A notification channel (PagerDuty, webhook, Discord, Telegram, Google Chat, workspace-member * email, or a Linear team) that `Maple.AlertRule`s deliver to. Linear needs the * workspace connected in Maple first. Chat-connector and Hazel destinations use * integrations linked in Maple and are managed there. @@ -97,6 +99,7 @@ const desiredBody = (props: AlertDestinationProps): Record => { if (props.signing_secret !== undefined) body.signing_secret = unwrap(props.signing_secret) break case "discord": + case "google-chat": body.webhook_url = unwrap(props.webhook_url) break case "telegram": diff --git a/packages/backend/src/services/alerts/AlertDestinationHydration.ts b/packages/backend/src/services/alerts/AlertDestinationHydration.ts index ca7bdd32f5..b50b839362 100644 --- a/packages/backend/src/services/alerts/AlertDestinationHydration.ts +++ b/packages/backend/src/services/alerts/AlertDestinationHydration.ts @@ -54,6 +54,10 @@ const DestinationSecretConfigSchema = Schema.Union([ botToken: Schema.String, chatId: Schema.String, }), + Schema.Struct({ + type: Schema.Literal("google-chat"), + webhookUrl: Schema.String, + }), Schema.Struct({ type: Schema.Literal("chat"), // The `chat_workspaces` row id — no token here either: the connector's credential is diff --git a/packages/backend/src/services/alerts/AlertDestinationsService.ts b/packages/backend/src/services/alerts/AlertDestinationsService.ts index 45c9087da9..912932b07d 100644 --- a/packages/backend/src/services/alerts/AlertDestinationsService.ts +++ b/packages/backend/src/services/alerts/AlertDestinationsService.ts @@ -48,6 +48,7 @@ import { } from "@maple/backend/services/org/OrgMembersService" import { ChatAlertPoster, type ChatChannelChoice } from "./ChatAlertPoster" import { LinearIssueTracker } from "./LinearIssueTracker" +import { parseGoogleChatWebhookUrl } from "./delivery/transports/google-chat" import { PAGERDUTY_ROUTING_KEY_PATTERN, verifyPagerDutyRoutingKey } from "./delivery/transports/pagerduty" import { fetchTelegramChats, @@ -151,6 +152,22 @@ const summarizeWebhookUrl = (url: string) => const TELEGRAM_MALFORMED_TOKEN_MESSAGE = "Telegram bot token must look like `123456789:ABC-DEF…` — copy it from @BotFather without the `bot` prefix." +/** The space id is not a secret; the `key` and `token` query parameters are. */ +const googleChatSummary = (webhookUrl: string) => + Option.match(parseGoogleChatWebhookUrl(webhookUrl), { + onNone: () => "Google Chat space", + onSome: ({ spaceId }) => `Space ${spaceId}`, + }) + +const validateGoogleChatWebhookUrl = (rawUrl: string): Effect.Effect => + Option.isSome(parseGoogleChatWebhookUrl(rawUrl)) + ? validateDestinationUrl(rawUrl, "webhookUrl") + : Effect.fail( + makeValidationError( + "Google Chat webhook URL must look like `https://chat.googleapis.com/v1/spaces/…/messages?key=…&token=…` — copy it from the space's Apps & integrations → Webhooks.", + ), + ) + /** A chat id is not a secret, but it is also not a name — label it as what it is. */ const telegramSummary = (chatId: string) => `Chat ${chatId.trim()}` @@ -191,6 +208,7 @@ const buildPublicConfig = ( // `channelLabel` as well as the summary so the edit form can prefill it // — without that, renaming a destination would demand retyping the id. telegram: (r) => ({ summary: telegramSummary(r.chatId), channelLabel: r.chatId.trim() }), + "google-chat": (r) => ({ summary: googleChatSummary(r.webhookUrl), channelLabel: null }), }), ) @@ -214,6 +232,7 @@ const buildSecretConfig = ( botToken: r.botToken.trim(), chatId: r.chatId.trim(), }), + "google-chat": (r) => ({ type: "google-chat" as const, webhookUrl: r.webhookUrl.trim() }), }), ) @@ -540,7 +559,7 @@ export class AlertDestinationsService extends Context.Service< if (request.type === "webhook") yield* validateDestinationUrl(request.url, "url") else if (request.type === "discord") { yield* validateDestinationUrl(request.webhookUrl, "webhookUrl") - } + } else if (request.type === "google-chat") yield* validateGoogleChatWebhookUrl(request.webhookUrl) const destinationId = decodeAlertDestinationIdSync(runtime.makeUuid()) let publicConfig: DestinationPublicConfig let secretConfig: DestinationSecretConfig @@ -647,6 +666,12 @@ export class AlertDestinationsService extends Context.Service< request.webhookUrl.trim().length > 0 ) { yield* validateDestinationUrl(request.webhookUrl, "webhookUrl") + } else if ( + request.type === "google-chat" && + request.webhookUrl != null && + request.webhookUrl.trim().length > 0 + ) { + yield* validateGoogleChatWebhookUrl(request.webhookUrl) } const { nextPublicConfig, nextSecretConfig } = yield* Match.value(request).pipe( @@ -760,6 +785,26 @@ export class AlertDestinationsService extends Context.Service< : ""), } satisfies DestinationSecretConfig, }), + "google-chat": (r) => { + const nextUrl = normalizeOptionalString(r.webhookUrl) + return Effect.succeed({ + nextPublicConfig: { + summary: + nextUrl != null + ? googleChatSummary(nextUrl) + : hydrated.publicConfig.summary, + channelLabel: null, + } satisfies DestinationPublicConfig, + nextSecretConfig: { + type: "google-chat" as const, + webhookUrl: + nextUrl ?? + (hydrated.secretConfig.type === "google-chat" + ? hydrated.secretConfig.webhookUrl + : ""), + } satisfies DestinationSecretConfig, + }) + }, telegram: (r) => { const previous = hydrated.secretConfig.type === "telegram" ? hydrated.secretConfig : null diff --git a/packages/backend/src/services/alerts/AlertsService.google-chat.test.ts b/packages/backend/src/services/alerts/AlertsService.google-chat.test.ts new file mode 100644 index 0000000000..1240b7ea90 --- /dev/null +++ b/packages/backend/src/services/alerts/AlertsService.google-chat.test.ts @@ -0,0 +1,82 @@ +import { AlertValidationError } from "@maple/domain/http" +import { afterEach, assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" +import { AlertsService } from "./AlertsService" +import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" +import { + adminRoles, + asOrgId, + asUserId, + emptyWarehouseRows, + getError, + makeLayer, + makeWarehouseStub, +} from "./alerts-service-test-support" + +const trackedDbs: TestDb[] = [] + +afterEach(() => cleanupTestDbs(trackedDbs)) + +const WEBHOOK_URL = "https://chat.googleapis.com/v1/spaces/AAAAops/messages?key=api-key&token=secret-token" + +describe("AlertsService: Google Chat destinations", () => { + it.effect("rejects a URL that is not a Google Chat webhook", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const alerts = yield* AlertsService + const exit = yield* Effect.exit( + alerts.createDestination(asOrgId("org_gchat"), asUserId("user_gchat"), adminRoles, { + type: "google-chat", + name: "Ops", + webhookUrl: "https://chat.google.com/room/AAAAops", + }), + ) + const error = getError(exit) + assert.instanceOf(error, AlertValidationError) + assert.include(error.message, "chat.googleapis.com") + }).pipe( + Effect.provide(makeLayer(testDb, makeWarehouseStub({ tracesAggregateRows: emptyWarehouseRows }))), + ) + }) + + it.effect("saves a webhook without exposing its token and posts a threaded test card", () => { + const testDb = createTestDb(trackedDbs) + const requests: Array<{ url: string; body: string }> = [] + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input instanceof Request ? input.url : input), + body: String(init?.body ?? ""), + }) + return Response.json({ name: "spaces/AAAAops/messages/1" }) + }) as typeof fetch + + return Effect.gen(function* () { + const alerts = yield* AlertsService + const orgId = asOrgId("org_gchat") + const userId = asUserId("user_gchat") + const destination = yield* alerts.createDestination(orgId, userId, adminRoles, { + type: "google-chat", + name: "Ops", + webhookUrl: WEBHOOK_URL, + }) + assert.strictEqual(destination.summary, "Space AAAAops") + assert.notInclude(JSON.stringify(destination), "secret-token") + + const response = yield* alerts.testDestination(orgId, userId, adminRoles, destination.id) + assert.isTrue(response.success) + assert.lengthOf(requests, 1) + const url = new URL(requests[0]?.url ?? "") + assert.strictEqual(url.host, "chat.googleapis.com") + assert.strictEqual(url.searchParams.get("token"), "secret-token") + const body = JSON.parse(requests[0]?.body ?? "{}") + assert.strictEqual(body.cardsV2[0].cardId, "maple-alert") + assert.isString(body.thread.threadKey) + }).pipe( + Effect.provide( + makeLayer(testDb, makeWarehouseStub({ tracesAggregateRows: emptyWarehouseRows }), { + fetch: fetchImpl, + }), + ), + ) + }) +}) diff --git a/packages/backend/src/services/alerts/delivery/dispatch.ts b/packages/backend/src/services/alerts/delivery/dispatch.ts index b6a8dcb034..28b844b60c 100644 --- a/packages/backend/src/services/alerts/delivery/dispatch.ts +++ b/packages/backend/src/services/alerts/delivery/dispatch.ts @@ -14,6 +14,7 @@ import { renderTitleBody } from "../AlertDeliveryDispatch" import { discordTransport } from "./transports/discord" import { chatTransport } from "./transports/chat" import { emailTransport } from "./transports/email" +import { googleChatTransport } from "./transports/google-chat" import { hazelTransport } from "./transports/hazel" import { linearTransport } from "./transports/linear" import { pagerDutyTransport } from "./transports/pagerduty" @@ -50,6 +51,7 @@ export const dispatchDelivery = ( "hazel-oauth": (config) => runHttpTransport(hazelTransport, input(config), runtime), discord: (config) => runHttpTransport(discordTransport, input(config), runtime), telegram: (config) => runHttpTransport(telegramTransport, input(config), runtime), + "google-chat": (config) => runHttpTransport(googleChatTransport, input(config), runtime), email: (config) => runEffectTransport(emailTransport, input(config), deps), chat: (config) => runEffectTransport(chatTransport, input(config), deps), linear: (config) => runEffectTransport(linearTransport, input(config), deps), diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts new file mode 100644 index 0000000000..0a148da9d9 --- /dev/null +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts @@ -0,0 +1,142 @@ +import type { AlertDestinationRow } from "@maple/db/tables" +import { AlertDestinationId } from "@maple/domain/http" +import { OrgId } from "@maple/domain/primitives" +import { assert, describe, it } from "@effect/vitest" +import { Option, Schema } from "effect" +import type { DispatchContext } from "../context" +import type { RenderInput, SecretConfigOf } from "../Transport" +import { googleChatTransport, parseGoogleChatWebhookUrl } from "./google-chat" + +const WEBHOOK_URL = + "https://chat.googleapis.com/v1/spaces/AAAAbc-12_x/messages?key=api-key&token=secret-token" + +const destinationRow: AlertDestinationRow = { + id: Schema.decodeUnknownSync(AlertDestinationId)("7c6b5a49-3821-4e0f-9d8c-7b6a59483726"), + orgId: Schema.decodeSync(OrgId)("org_1"), + name: "Ops space", + type: "google-chat", + enabled: true, + configJson: {}, + secretCiphertext: "", + secretIv: "", + secretTag: "", + lastTestedAt: null, + lastTestError: null, + consecutiveFailures: 0, + lastFailureAt: null, + disabledAt: null, + disabledReason: null, + createdAt: 0, + updatedAt: 0, + createdBy: "user_1", + updatedBy: "user_1", +} + +const config: SecretConfigOf<"google-chat"> = { type: "google-chat", webhookUrl: WEBHOOK_URL } + +const context: DispatchContext = { + deliveryKey: "org_1:dest_1:delivery", + destination: destinationRow, + publicConfig: { summary: "Space AAAAbc-12_x", channelLabel: null }, + secretConfig: config, + ruleId: "rule_1", + ruleName: "Checkout rate", + groupKey: "checkout", + signalType: "error_rate", + severity: "critical", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + eventType: "trigger", + incidentId: "inc_1", + incidentStatus: "open", + dedupeKey: "org_1:rule_1:checkout", + windowMinutes: 5, + value: 0.08, + sampleCount: 1200, + template: null, + sentAtMs: Date.parse("2026-06-02T00:00:00.000Z"), + chartUrl: "https://web.localhost/alerts/chart/abc.png", +} + +const render = (overrides: Partial> = {}) => + googleChatTransport.render({ + config, + context, + linkUrl: "https://web.localhost/alerts/rule_1", + chatUrl: "https://web.localhost/chat?alert=rule_1", + payloadJson: "{}", + templated: null, + ...overrides, + }) + +const widgetsOf = (body: string) => JSON.parse(body).cardsV2[0].card.sections[0].widgets + +describe("parseGoogleChatWebhookUrl", () => { + it("reads the space from a webhook URL Google Chat hands out", () => { + assert.deepStrictEqual( + parseGoogleChatWebhookUrl(` ${WEBHOOK_URL} `), + Option.some({ spaceId: "AAAAbc-12_x" }), + ) + }) + + it.each([ + ["another host", "https://evil.test/v1/spaces/AAAA/messages?key=k&token=t"], + ["plain http", "http://chat.googleapis.com/v1/spaces/AAAA/messages?key=k&token=t"], + ["no token", "https://chat.googleapis.com/v1/spaces/AAAA/messages?key=k"], + ["a space link", "https://chat.google.com/room/AAAA"], + ["not a URL", "secret-token"], + ])("rejects %s", (_label, url) => { + assert.isTrue(Option.isNone(parseGoogleChatWebhookUrl(url))) + }) +}) + +describe("googleChatTransport.render", () => { + it("posts to the webhook, replying in the alert's thread", () => { + const url = new URL(render().url) + assert.strictEqual(url.searchParams.get("token"), "secret-token") + assert.strictEqual(url.searchParams.get("messageReplyOption"), "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD") + assert.strictEqual(JSON.parse(render().body).thread.threadKey, "org_1:rule_1:checkout") + }) + + it("escapes alert values inside the card's HTML text", () => { + const widgets = widgetsOf(render({ context: { ...context, groupKey: "a&c" } }).body) + const texts = widgets.flatMap( + (widget: { textParagraph?: { text: string }; decoratedText?: { text: string } }) => + widget.textParagraph?.text ?? widget.decoratedText?.text ?? [], + ) + assert.include(texts, "a<b>&c") + assert.isFalse(texts.some((text: string) => text.includes("a"))) + }) + + it("carries the chart and both links", () => { + const widgets = widgetsOf(render().body) + const image = widgets.find((widget: { image?: unknown }) => widget.image !== undefined) + assert.strictEqual(image.image.imageUrl, "https://web.localhost/alerts/chart/abc.png") + const buttons = widgets.find((widget: { buttonList?: unknown }) => widget.buttonList !== undefined) + assert.deepStrictEqual( + buttons.buttonList.buttons.map( + (button: { onClick: { openLink: { url: string } } }) => button.onClick.openLink.url, + ), + ["https://web.localhost/alerts/rule_1", "https://web.localhost/chat?alert=rule_1"], + ) + }) + + it("uses a rule's template as the title and body, without the detail rows", () => { + const body = JSON.parse( + render({ templated: { title: "Custom title", body: "line <1>\nline 2" } }).body, + ) + assert.strictEqual(body.text, "Custom title") + assert.strictEqual(body.cardsV2[0].card.header.title, "Custom title") + const widgets = widgetsOf(JSON.stringify(body)) + assert.strictEqual(widgets[0].textParagraph.text, "line <1>
line 2") + assert.isFalse( + widgets.some((widget: { decoratedText?: unknown }) => widget.decoratedText !== undefined), + ) + }) + + it("says what a revoked or deleted webhook means", () => { + assert.include(googleChatTransport.describeStatus?.(404) ?? "", "no longer exists") + assert.isNull(googleChatTransport.describeStatus?.(500) ?? null) + }) +}) diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts new file mode 100644 index 0000000000..ee98c1816f --- /dev/null +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts @@ -0,0 +1,160 @@ +import { Option } from "effect" +import { buildSummaryLine } from "../../AlertDeliveryDispatch" +import { + alertAccentColor, + displayGroupKey, + eventTypeEmoji, + formatAlertHeadline, + formatSeverityLabel, + severityEmoji, + truncate, +} from "../../alert-formatting" +import type { HttpTransport, RenderInput, SecretConfigOf } from "../Transport" + +type Config = SecretConfigOf<"google-chat"> + +const GOOGLE_CHAT_HOST = "chat.googleapis.com" +const SPACE_MESSAGES_PATH = /^\/v1\/spaces\/([A-Za-z0-9_-]+)\/messages$/ + +/** A card's text budget: well under Google Chat's 32k message cap, so an alert is one message. */ +const MAX_TEMPLATED_BODY_CHARS = 2000 + +/** + * An incoming webhook URL, as Google Chat's "Apps & integrations → Webhooks" hands it out. The + * `key` and `token` query parameters are the credential; the path names the space. + */ +export const parseGoogleChatWebhookUrl = (raw: string): Option.Option<{ readonly spaceId: string }> => + Option.liftThrowable(() => new URL(raw.trim()))().pipe( + Option.flatMap((url) => { + const space = SPACE_MESSAGES_PATH.exec(url.pathname) + return url.protocol === "https:" && + url.host === GOOGLE_CHAT_HOST && + space?.[1] !== undefined && + url.searchParams.has("key") && + url.searchParams.has("token") + ? Option.some({ spaceId: space[1] }) + : Option.none() + }), + ) + +/** Card text paragraphs take a small HTML subset, so every dynamic value is escaped first. */ +const escapeHtml = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) + +const paragraph = (html: string) => ({ textParagraph: { text: html } }) + +/** + * Replies land in the alert's own thread: `dedupeKey` is one per rule and group, so a resolve or + * renotify sits under the trigger it answers. A space without threads posts it top-level. + */ +const threadedUrl = (webhookUrl: string): string => + Option.liftThrowable(() => new URL(webhookUrl))().pipe( + Option.match({ + onNone: () => webhookUrl, + onSome: (url) => { + url.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD") + return url.toString() + }, + }), + ) + +export const buildGoogleChatMessage = (input: RenderInput) => { + const { context, templated, linkUrl, chatUrl } = input + const accent = alertAccentColor(context.eventType, context.severity) + const group = displayGroupKey(context.groupKey) + const title = templated?.title ?? `${eventTypeEmoji(context.eventType)} ${formatAlertHeadline(context)}` + const lead = templated + ? escapeHtml(truncate(templated.body, MAX_TEMPLATED_BODY_CHARS)).replace(/\n/g, "
") + : escapeHtml(buildSummaryLine(context, (value) => value)) + const footer = [ + ...(context.sparkline ? [escapeHtml(context.sparkline)] : []), + ...(context.incidentId ? [`Incident ${escapeHtml(context.incidentId)}`] : []), + ...(context.sentAtMs != null ? [new Date(context.sentAtMs).toISOString()] : []), + ].join(" \u{00B7} ") + const details = templated + ? [] + : [ + { + decoratedText: { + topLabel: "Severity", + text: `${severityEmoji(context.severity)} ${formatSeverityLabel(context.severity)}`, + }, + }, + ...(group == null ? [] : [{ decoratedText: { topLabel: "Group", text: escapeHtml(group) } }]), + ] + return { + // The notification and the text clients without cards show. + text: truncate(title, 300), + cardsV2: [ + { + cardId: "maple-alert", + card: { + header: { title: truncate(title, 200), subtitle: "\u{1F341} Maple Alerts" }, + sections: [ + { + widgets: [ + paragraph(templated ? lead : `${lead}`), + ...details, + ...(context.chartUrl + ? [ + { + image: { + imageUrl: context.chartUrl, + altText: `${context.ruleName} over the alert window`, + }, + }, + ] + : []), + { + buttonList: { + buttons: [ + { + text: "Open in Maple", + onClick: { openLink: { url: linkUrl } }, + }, + { + text: "✨ Ask Maple AI", + onClick: { openLink: { url: chatUrl } }, + }, + ], + }, + }, + ...(footer.length > 0 + ? [paragraph(`${footer}`)] + : []), + ], + }, + ], + }, + }, + ], + thread: { threadKey: context.dedupeKey }, + } +} + +export const googleChatTransport: HttpTransport = { + kind: "http", + type: "google-chat", + peerService: "google-chat", + providerLabel: "Google Chat", + render: (input: RenderInput) => ({ + url: threadedUrl(input.config.webhookUrl), + headers: { "content-type": "application/json; charset=UTF-8" }, + // User-configured URL, checked at save time; the credential is in the query string, which + // the span never records, so the path (a space id) is safe to annotate. + guarded: true, + sensitivePath: false, + body: JSON.stringify(buildGoogleChatMessage(input)), + }), + describeStatus: (status) => { + if (status === 401 || status === 403) { + return "Google Chat rejected the webhook — it was revoked, or the space no longer allows apps" + } + if (status === 404) return "Google Chat webhook no longer exists — create a new one in the space" + return null + }, + ack: (input) => ({ + providerMessage: "Delivered to Google Chat", + providerReference: input.context.dedupeKey, + }), +} diff --git a/packages/backend/src/services/alerts/delivery/transports/render.test.ts b/packages/backend/src/services/alerts/delivery/transports/render.test.ts index dc82c9050f..bd946f517a 100644 --- a/packages/backend/src/services/alerts/delivery/transports/render.test.ts +++ b/packages/backend/src/services/alerts/delivery/transports/render.test.ts @@ -6,6 +6,7 @@ import { Schema } from "effect" import type { DispatchContext } from "../context" import type { HttpRequestSpec, RenderInput } from "../Transport" import { discordTransport } from "./discord" +import { googleChatTransport } from "./google-chat" import { hazelTransport } from "./hazel" import { pagerDutyTransport } from "./pagerduty" import { telegramTransport } from "./telegram" @@ -127,6 +128,15 @@ describe("transport render: guard flags", () => { inputFor({ type: "telegram", botToken: "123456789:AA-token", chatId: "-100123" }), ), ], + [ + "google-chat", + googleChatTransport.render( + inputFor({ + type: "google-chat", + webhookUrl: "https://chat.googleapis.com/v1/spaces/AAAA/messages?key=k&token=t", + }), + ), + ], ] it("guards exactly the user-configured hosts", () => { @@ -134,13 +144,14 @@ describe("transport render: guard flags", () => { // pagerduty + telegram post to compile-time vendor constants: there // is no attacker-controlled URL to validate, so the guard would only cost a // redirect walk. - assert.deepStrictEqual(guarded, ["webhook", "discord", "hazel-oauth"]) + assert.deepStrictEqual(guarded, ["webhook", "discord", "hazel-oauth", "google-chat"]) }) it("marks exactly the providers whose token rides in the URL path", () => { const sensitive = specs.filter(([, spec]) => spec.sensitivePath).map(([name]) => name) // telegram is the first provider where this disagrees with `guarded`: a - // fixed vendor host, but the bot token rides in the path. + // fixed vendor host, but the bot token rides in the path. google-chat carries its + // credential in the query string, which no span records. assert.deepStrictEqual(sensitive, ["discord", "hazel-oauth", "telegram"]) }) diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 068b1af366..55cb666483 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -24,6 +24,7 @@ export const AlertDestinationType = Schema.Literals([ "hazel-oauth", "discord", "telegram", + "google-chat", "email", "chat", "linear", @@ -332,6 +333,17 @@ export class TelegramAlertDestinationConfig extends Schema.Class( + "GoogleChatAlertDestinationConfig", +)({ + type: Schema.Literal("google-chat"), + name: ChannelLabel, + /** Write-only: the `key` and `token` query parameters are the credential. */ + webhookUrl: NonEmptyString, + enabled: Schema.optionalKey(Schema.Boolean), +}) {} + /** * A channel in a chat workspace linked through a chat connector. Which connector posts it is read * from the workspace row, never taken from the request — the workspace must belong to the org, and @@ -387,6 +399,7 @@ export const AlertDestinationCreateRequest = Schema.Union([ HazelOAuthAlertDestinationConfig, DiscordAlertDestinationConfig, TelegramAlertDestinationConfig, + GoogleChatAlertDestinationConfig, EmailAlertDestinationConfig, ChatAlertDestinationConfig, LinearAlertDestinationConfig, @@ -439,6 +452,14 @@ export class UpdateTelegramAlertDestinationConfig extends Schema.Class( + "UpdateGoogleChatAlertDestinationConfig", +)({ + name: OptionalNonEmptyString, + webhookUrl: Schema.optionalKey(Schema.String), + enabled: Schema.optionalKey(Schema.Boolean), +}) {} + export class UpdateEmailAlertDestinationConfig extends Schema.Class( "UpdateEmailAlertDestinationConfig", )({ @@ -485,6 +506,10 @@ export const AlertDestinationUpdateRequest = Schema.Union([ type: Schema.Literal("telegram"), ...UpdateTelegramAlertDestinationConfig.fields, }), + Schema.Struct({ + type: Schema.Literal("google-chat"), + ...UpdateGoogleChatAlertDestinationConfig.fields, + }), Schema.Struct({ type: Schema.Literal("email"), ...UpdateEmailAlertDestinationConfig.fields, diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts index 79debbae91..290de2baa7 100644 --- a/packages/domain/src/http/v2/alert-destinations.ts +++ b/packages/domain/src/http/v2/alert-destinations.ts @@ -86,7 +86,7 @@ export const V2AlertDestination = Schema.Struct({ }), type: AlertDestinationType.annotate({ description: - "The delivery channel: `pagerduty`, `webhook`, `hazel-oauth`, `discord`, `telegram`, `email`, `chat` (a channel in a chat workspace linked through a chat connector), or `linear` (a team in the connected Linear workspace). Immutable after creation.", + "The delivery channel: `pagerduty`, `webhook`, `hazel-oauth`, `discord`, `telegram`, `google-chat`, `email`, `chat` (a channel in a chat workspace linked through a chat connector), or `linear` (a team in the connected Linear workspace). Immutable after creation.", examples: ["pagerduty"], }), enabled: Schema.Boolean.annotate({ @@ -137,7 +137,7 @@ export const V2AlertDestination = Schema.Struct({ identifier: "AlertDestination", title: "Alert Destination", description: - "A notification channel that alert rules deliver to (PagerDuty, generic webhook, Hazel OAuth, Discord, Telegram, workspace-member email, a channel in a linked chat workspace, or a Linear team). Channel secrets are write-only: responses carry a redacted `summary` instead.", + "A notification channel that alert rules deliver to (PagerDuty, generic webhook, Hazel OAuth, Discord, Telegram, Google Chat, workspace-member email, a channel in a linked chat workspace, or a Linear team). Channel secrets are write-only: responses carry a redacted `summary` instead.", examples: [wireExample(alertDestinationExample)], }) export type V2AlertDestination = Schema.Schema.Type @@ -235,6 +235,16 @@ const V2TelegramDestinationCreateParams = Schema.Struct({ enabled: enabledField, }).annotate({ identifier: "AlertDestinationCreateTelegram", title: "Telegram destination" }) +const V2GoogleChatDestinationCreateParams = Schema.Struct({ + type: Schema.Literal("google-chat"), + name: nameField, + webhook_url: NonEmptyString.annotate({ + description: + "The Google Chat space's incoming webhook URL (`https://chat.googleapis.com/v1/spaces/…/messages?key=…&token=…`). Write-only — never returned.", + }), + enabled: enabledField, +}).annotate({ identifier: "AlertDestinationCreateGoogleChat", title: "Google Chat destination" }) + const V2EmailDestinationCreateParams = Schema.Struct({ type: Schema.Literal("email"), name: nameField, @@ -276,6 +286,7 @@ export const V2AlertDestinationCreateParams = Schema.Union([ V2HazelOAuthDestinationCreateParams, V2DiscordDestinationCreateParams, V2TelegramDestinationCreateParams, + V2GoogleChatDestinationCreateParams, V2EmailDestinationCreateParams, V2ChatDestinationCreateParams, V2LinearDestinationCreateParams, @@ -339,6 +350,12 @@ export const V2AlertDestinationUpdateParams = Schema.Union([ chat_id: Schema.optionalKey(Schema.String), enabled: Schema.optionalKey(Schema.Boolean), }).annotate({ identifier: "AlertDestinationUpdateTelegram", title: "Telegram destination update" }), + Schema.Struct({ + type: Schema.Literal("google-chat"), + name: optionalNameField, + webhook_url: Schema.optionalKey(Schema.String), + enabled: Schema.optionalKey(Schema.Boolean), + }).annotate({ identifier: "AlertDestinationUpdateGoogleChat", title: "Google Chat destination update" }), Schema.Struct({ type: Schema.Literal("email"), name: optionalNameField, @@ -648,6 +665,6 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina OpenApi.annotations({ title: "Alert Destinations", description: - "Notification channels for alert rules — PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", + "Notification channels for alert rules — PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, Google Chat, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", }), ) {} diff --git a/packages/terraform-provider-maple/docs/resources/alert_destination.md b/packages/terraform-provider-maple/docs/resources/alert_destination.md index 357e13c956..d8fc20259f 100644 --- a/packages/terraform-provider-maple/docs/resources/alert_destination.md +++ b/packages/terraform-provider-maple/docs/resources/alert_destination.md @@ -39,7 +39,7 @@ resource "maple_alert_destination" "team_email" { ### Required - `name` (String) Display name. -- `type` (String) Channel type: `pagerduty`, `webhook`, `discord`, `telegram`, `email` or `chat`. Changing it replaces the destination. +- `type` (String) Channel type: `pagerduty`, `webhook`, `discord`, `telegram`, `google-chat`, `email` or `chat`. Changing it replaces the destination. ### Optional @@ -51,7 +51,7 @@ resource "maple_alert_destination" "team_email" { - `member_user_ids` (List of String) `email`: the organization members to email. - `signing_secret` (String, Sensitive) `webhook`: secret used to sign each request (optional). - `url` (String, Sensitive) `webhook`: the URL Maple POSTs to. Sensitive, since webhook URLs often carry a token. -- `webhook_url` (String, Sensitive) `discord`: the channel webhook URL. +- `webhook_url` (String, Sensitive) `discord`, `google-chat`: the channel webhook URL. - `workspace_id` (String) `chat`: the linked chat workspace. Changing it replaces the destination. ### Read-Only diff --git a/packages/terraform-provider-maple/internal/provider/resources.go b/packages/terraform-provider-maple/internal/provider/resources.go index 9c7876b068..6b49a5318b 100644 --- a/packages/terraform-provider-maple/internal/provider/resources.go +++ b/packages/terraform-provider-maple/internal/provider/resources.go @@ -73,8 +73,8 @@ func alertDestinationDef() resourceDef { "id": idAttribute("dest"), "type": schema.StringAttribute{ Required: true, - Description: "Channel type: `pagerduty`, `webhook`, `discord`, `telegram`, `email` or `chat`. Changing it replaces the destination.", - Validators: oneOf("pagerduty", "webhook", "discord", "telegram", "email", "chat"), + Description: "Channel type: `pagerduty`, `webhook`, `discord`, `telegram`, `google-chat`, `email` or `chat`. Changing it replaces the destination.", + Validators: oneOf("pagerduty", "webhook", "discord", "telegram", "google-chat", "email", "chat"), PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, }, "name": schema.StringAttribute{Required: true, Description: "Display name."}, @@ -82,7 +82,7 @@ func alertDestinationDef() resourceDef { "integration_key": secret("`pagerduty`: the Events API v2 integration key."), "url": schema.StringAttribute{Optional: true, Sensitive: true, Description: "`webhook`: the URL Maple POSTs to. Sensitive, since webhook URLs often carry a token."}, "signing_secret": secret("`webhook`: secret used to sign each request (optional)."), - "webhook_url": secret("`discord`: the channel webhook URL."), + "webhook_url": secret("`discord`, `google-chat`: the channel webhook URL."), "bot_token": secret("`telegram`: the bot token."), "chat_id": schema.StringAttribute{Optional: true, Description: "`telegram`: the chat to post to."}, "member_user_ids": stringList("`email`: the organization members to email.", false), diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index a9031239d9..5183f252b3 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -609,7 +609,7 @@ export type DestinationFormState = { type: AlertDestinationType name: string enabled: boolean - /** Discord incoming-webhook URL. */ + /** Discord or Google Chat incoming-webhook URL. */ webhookUrl: string integrationKey: string url: string @@ -707,6 +707,8 @@ export function suggestedDestinationName(form: DestinationFormState): string { return "Discord" case "telegram": return "Telegram" + case "google-chat": + return "Google Chat" case "email": return "Email" } @@ -793,6 +795,13 @@ export function buildDestinationCreateParamsV2(form: DestinationFormState): V2Al bot_token: form.telegramBotToken.trim(), chat_id: form.telegramChatId.trim(), } + case "google-chat": + return { + type: "google-chat", + name: form.name.trim(), + enabled: form.enabled, + webhook_url: form.webhookUrl.trim(), + } case "email": return { type: "email", @@ -880,6 +889,15 @@ export function buildDestinationUpdateParamsV2(form: DestinationFormState): V2Al ...(webhookUrl ? { webhook_url: webhookUrl } : undefined), } } + case "google-chat": { + const webhookUrl = form.webhookUrl.trim() + return { + type: "google-chat", + enabled: form.enabled, + ...(name ? { name } : undefined), + ...(webhookUrl ? { webhook_url: webhookUrl } : undefined), + } + } case "telegram": { const botToken = form.telegramBotToken.trim() const chatId = form.telegramChatId.trim() From be6f2461aace7d1a8c697f2c5d4759e2bf19797f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:22:28 +0100 Subject: [PATCH 2/6] chore(ios): regenerate the OpenAPI spec for the google-chat destination type --- apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 9e139630fb..d094bb83f3 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -13949,7 +13949,7 @@ "name": "Alert Deliveries" }, { - "description": "Notification channels for alert rules — PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", + "description": "Notification channels for alert rules — PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, Google Chat, workspace-member email, and channels in linked chat workspaces. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", "name": "Alert Destinations" }, { From 3e7af88b422a5a38aca329329dde86d211704b50 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:27:33 +0100 Subject: [PATCH 3/6] fix(alerts): render a template's Markdown in Google Chat cards A rule template's body is Markdown, and a card's text paragraph showed it literally (`**checkout**`). It now gets the subset Telegram does: bold and http(s) links, escaped first so only those tags reach Google Chat. Checked against a real Google Chat space: trigger, reminder, resolve, test and templated cards all post with 200, render, and the reminder and resolve reply in the trigger's thread. --- .../delivery/transports/google-chat.test.ts | 15 +++++++++++++++ .../alerts/delivery/transports/google-chat.ts | 14 +++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts index 0a148da9d9..8eb4976774 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts @@ -135,6 +135,21 @@ describe("googleChatTransport.render", () => { ) }) + it("renders a template's bold and http links, and nothing else", () => { + const body = JSON.parse( + render({ + templated: { + title: "t", + body: "**checkout** > 5% [runbook](https://docs.test/r) [x](javascript:alert(1))", + }, + }).body, + ) + assert.strictEqual( + body.cardsV2[0].card.sections[0].widgets[0].textParagraph.text, + 'checkout > 5% runbook [x](javascript:alert(1))', + ) + }) + it("says what a revoked or deleted webhook means", () => { assert.include(googleChatTransport.describeStatus?.(404) ?? "", "no longer exists") assert.isNull(googleChatTransport.describeStatus?.(500) ?? null) diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts index ee98c1816f..3825583422 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts @@ -41,6 +41,18 @@ export const parseGoogleChatWebhookUrl = (raw: string): Option.Option<{ readonly const escapeHtml = (value: string): string => value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) +/** + * A template's Markdown in card HTML, the same subset Telegram gets: `**b**` and `[t](url)`, with + * http(s) links only. Escaped first, so only the tags built here reach Google Chat. + */ +const markdownToCardHtml = (markdown: string): string => + escapeHtml(markdown) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, text: string, url: string) => + /^https?:\/\//i.test(url) ? `${text}` : match, + ) + .replace(/\n/g, "
") + const paragraph = (html: string) => ({ textParagraph: { text: html } }) /** @@ -64,7 +76,7 @@ export const buildGoogleChatMessage = (input: RenderInput) => { const group = displayGroupKey(context.groupKey) const title = templated?.title ?? `${eventTypeEmoji(context.eventType)} ${formatAlertHeadline(context)}` const lead = templated - ? escapeHtml(truncate(templated.body, MAX_TEMPLATED_BODY_CHARS)).replace(/\n/g, "
") + ? markdownToCardHtml(truncate(templated.body, MAX_TEMPLATED_BODY_CHARS)) : escapeHtml(buildSummaryLine(context, (value) => value)) const footer = [ ...(context.sparkline ? [escapeHtml(context.sparkline)] : []), From 63e64a74508ee343e0857135277516f73edd95af Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:28:54 +0100 Subject: [PATCH 4/6] fix(alerts): Google Chat review fixes - The webhook field says why a pasted URL is not a webhook (a space link, a missing key or token) instead of only disabling Save, and is no longer masked, so the URL can be re-read. - An empty `key` or `token` is rejected, on the server and in the form. - A rule name or template title containing `` no longer mentions people from the message text. --- .../alerts/destination/dialog.scene.test.ts | 18 ++++++++++++++++ .../web/src/page/alerts/destination/dialog.ts | 4 ++-- apps/web/src/page/alerts/destination/view.ts | 21 +++++++++++++++++-- .../delivery/transports/google-chat.test.ts | 7 +++++++ .../alerts/delivery/transports/google-chat.ts | 9 +++++--- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/web/src/page/alerts/destination/dialog.scene.test.ts b/apps/web/src/page/alerts/destination/dialog.scene.test.ts index 1397bdd3d2..052c539916 100644 --- a/apps/web/src/page/alerts/destination/dialog.scene.test.ts +++ b/apps/web/src/page/alerts/destination/dialog.scene.test.ts @@ -386,3 +386,21 @@ describe("destination dialog", () => { expect(late.outMessage).toEqual(OutMessage.CreatedDestination({ id: CREATED })) }) }) + +describe("Google Chat destination form", () => { + const form = (webhookUrl: string) => ({ ...opened().form, type: "google-chat" as const, webhookUrl }) + + test("saves only a space's webhook URL with its key and token", () => { + expect( + isFormReady(form("https://chat.googleapis.com/v1/spaces/AAAA/messages?key=k&token=t"), false), + ).toBe(true) + expect(isFormReady(form("https://chat.google.com/room/AAAA"), false)).toBe(false) + expect( + isFormReady(form("https://chat.googleapis.com/v1/spaces/AAAA/messages?key=k&token="), false), + ).toBe(false) + }) + + test("keeps the stored webhook when an edit leaves the URL blank", () => { + expect(isFormReady({ ...form(""), name: "Ops" }, true)).toBe(true) + }) +}) diff --git a/apps/web/src/page/alerts/destination/dialog.ts b/apps/web/src/page/alerts/destination/dialog.ts index fa8200190b..18df2588b1 100644 --- a/apps/web/src/page/alerts/destination/dialog.ts +++ b/apps/web/src/page/alerts/destination/dialog.ts @@ -259,8 +259,8 @@ export const isValidGoogleChatWebhookUrl = (raw: string): boolean => { url.protocol === "https:" && url.host === "chat.googleapis.com" && /^\/v1\/spaces\/[A-Za-z0-9_-]+\/messages$/.test(url.pathname) && - url.searchParams.has("key") && - url.searchParams.has("token") + (url.searchParams.get("key") ?? "").length > 0 && + (url.searchParams.get("token") ?? "").length > 0 ) } diff --git a/apps/web/src/page/alerts/destination/view.ts b/apps/web/src/page/alerts/destination/view.ts index cc8e9f4b47..c1388870d9 100644 --- a/apps/web/src/page/alerts/destination/view.ts +++ b/apps/web/src/page/alerts/destination/view.ts @@ -16,7 +16,14 @@ import { panel } from "../../../ui/panel" import { settingRow } from "../../../ui/setting-row" import { switchControl } from "../../../ui/switch" import { destinationProvider, providerLogo, providerOf, destinationTypesFor } from "../shared/providers" -import { isFormReady, isValidPagerDutyKey, isValidTelegramToken, Message, type Model } from "./dialog" +import { + isFormReady, + isValidGoogleChatWebhookUrl, + isValidPagerDutyKey, + isValidTelegramToken, + Message, + type Model, +} from "./dialog" import { chatFields } from "./chat-fields-view" import { emailFields } from "./email-fields-view" import { hazelFields } from "./hazel-fields-view" @@ -298,6 +305,8 @@ const connectionFields = (h: H, model: Model, isEditing: boolean): ReadonlyArray } case "google-chat": { const fieldIds = ids("google-chat-webhook", true) + const isBadUrl = + form.webhookUrl.trim().length > 0 && !isValidGoogleChatWebhookUrl(form.webhookUrl) return [ fieldBlock(h, [ fieldLabel( @@ -312,9 +321,17 @@ const connectionFields = (h: H, model: Model, isEditing: boolean): ReadonlyArray ? "Leave blank to keep current webhook" : "https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=...", className: "font-mono text-xs", - type: "password", hasDescription: true, }), + ...(isBadUrl + ? [ + fieldError( + h, + { ids: fieldIds, className: "text-2xs", state: stateOf(form.webhookUrl) }, + "That isn't a Google Chat webhook URL. It starts with https://chat.googleapis.com/v1/spaces/ and has a key and a token; a link to the space won't work.", + ), + ] + : []), fieldDescription( h, { ids: fieldIds, className: "text-2xs", state: stateOf(form.webhookUrl) }, diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts index 8eb4976774..4368313b64 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts @@ -84,6 +84,7 @@ describe("parseGoogleChatWebhookUrl", () => { ["another host", "https://evil.test/v1/spaces/AAAA/messages?key=k&token=t"], ["plain http", "http://chat.googleapis.com/v1/spaces/AAAA/messages?key=k&token=t"], ["no token", "https://chat.googleapis.com/v1/spaces/AAAA/messages?key=k"], + ["an empty token", "https://chat.googleapis.com/v1/spaces/AAAA/messages?key=k&token="], ["a space link", "https://chat.google.com/room/AAAA"], ["not a URL", "secret-token"], ])("rejects %s", (_label, url) => { @@ -150,6 +151,12 @@ describe("googleChatTransport.render", () => { ) }) + it("does not let a rule name mention the whole space", () => { + const body = JSON.parse(render({ context: { ...context, ruleName: " checkout" } }).body) + assert.notInclude(body.text, "") + assert.include(body.text, "checkout") + }) + it("says what a revoked or deleted webhook means", () => { assert.include(googleChatTransport.describeStatus?.(404) ?? "", "no longer exists") assert.isNull(googleChatTransport.describeStatus?.(500) ?? null) diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts index 3825583422..8c485b4a70 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts @@ -30,8 +30,8 @@ export const parseGoogleChatWebhookUrl = (raw: string): Option.Option<{ readonly return url.protocol === "https:" && url.host === GOOGLE_CHAT_HOST && space?.[1] !== undefined && - url.searchParams.has("key") && - url.searchParams.has("token") + (url.searchParams.get("key") ?? "").length > 0 && + (url.searchParams.get("token") ?? "").length > 0 ? Option.some({ spaceId: space[1] }) : Option.none() }), @@ -53,6 +53,9 @@ const markdownToCardHtml = (markdown: string): string => ) .replace(/\n/g, "
") +/** `` in a message's `text` is a mention; a rule name or template must not ping a space. */ +const neutralizeMentions = (value: string): string => value.replace(/<(?=users\/)/gi, "<\u200B") + const paragraph = (html: string) => ({ textParagraph: { text: html } }) /** @@ -96,7 +99,7 @@ export const buildGoogleChatMessage = (input: RenderInput) => { ] return { // The notification and the text clients without cards show. - text: truncate(title, 300), + text: neutralizeMentions(truncate(title, 300)), cardsV2: [ { cardId: "maple-alert", From 8b8b83afc793717e7cce5e9d13a7f93eaf19b628 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:34:35 +0100 Subject: [PATCH 5/6] refactor(alerts): one Google Chat webhook URL parser for server and form `parseGoogleChatWebhookUrl` moves to @maple/domain/http, so the save-time check and the destination form run the same code instead of two copies that could drift. --- .../web/src/page/alerts/destination/dialog.ts | 22 ++++++++---------- .../alerts/AlertDestinationsService.ts | 2 +- .../delivery/transports/google-chat.test.ts | 4 ++-- .../alerts/delivery/transports/google-chat.ts | 21 ----------------- packages/domain/src/http/alerts.ts | 23 ++++++++++++++++++- 5 files changed, 34 insertions(+), 38 deletions(-) diff --git a/apps/web/src/page/alerts/destination/dialog.ts b/apps/web/src/page/alerts/destination/dialog.ts index 18df2588b1..d67146288c 100644 --- a/apps/web/src/page/alerts/destination/dialog.ts +++ b/apps/web/src/page/alerts/destination/dialog.ts @@ -1,4 +1,9 @@ -import { type AlertDestinationDocument, AlertDestinationId, AlertDestinationType } from "@maple/domain/http" +import { + type AlertDestinationDocument, + AlertDestinationId, + AlertDestinationType, + parseGoogleChatWebhookUrl, +} from "@maple/domain/http" import { V2AlertDestinationCreateParams, V2AlertDestinationUpdateParams, @@ -251,18 +256,9 @@ export const isValidPagerDutyKey = (key: string): boolean => /^[A-Za-z0-9]{32}$/ export const isValidTelegramToken = (token: string): boolean => /^\d{5,}:[A-Za-z0-9_-]{30,}$/.test(token.trim()) -/** Mirrors the server's check, so a pasted space link or bare token is caught before save. */ -export const isValidGoogleChatWebhookUrl = (raw: string): boolean => { - const url = URL.parse(raw.trim()) - return ( - url !== null && - url.protocol === "https:" && - url.host === "chat.googleapis.com" && - /^\/v1\/spaces\/[A-Za-z0-9_-]+\/messages$/.test(url.pathname) && - (url.searchParams.get("key") ?? "").length > 0 && - (url.searchParams.get("token") ?? "").length > 0 - ) -} +/** The server's own check, so a pasted space link or bare token is caught before save. */ +export const isValidGoogleChatWebhookUrl = (raw: string): boolean => + Option.isSome(parseGoogleChatWebhookUrl(raw)) export const isFormReady = (form: DestinationForm, isEditing: boolean): boolean => { if (isEditing && form.name.trim().length === 0) return false diff --git a/packages/backend/src/services/alerts/AlertDestinationsService.ts b/packages/backend/src/services/alerts/AlertDestinationsService.ts index 912932b07d..152c7efd65 100644 --- a/packages/backend/src/services/alerts/AlertDestinationsService.ts +++ b/packages/backend/src/services/alerts/AlertDestinationsService.ts @@ -21,6 +21,7 @@ import { type ChatWorkspaceId as ChatWorkspaceIdType, type OrgId, type UserId, + parseGoogleChatWebhookUrl, } from "@maple/domain/http" import * as Orm from "@maple-dev/effect-orm/database" import * as PG from "@maple-dev/effect-orm/postgres" @@ -48,7 +49,6 @@ import { } from "@maple/backend/services/org/OrgMembersService" import { ChatAlertPoster, type ChatChannelChoice } from "./ChatAlertPoster" import { LinearIssueTracker } from "./LinearIssueTracker" -import { parseGoogleChatWebhookUrl } from "./delivery/transports/google-chat" import { PAGERDUTY_ROUTING_KEY_PATTERN, verifyPagerDutyRoutingKey } from "./delivery/transports/pagerduty" import { fetchTelegramChats, diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts index 4368313b64..78f2761d90 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts @@ -1,11 +1,11 @@ import type { AlertDestinationRow } from "@maple/db/tables" -import { AlertDestinationId } from "@maple/domain/http" +import { AlertDestinationId, parseGoogleChatWebhookUrl } from "@maple/domain/http" import { OrgId } from "@maple/domain/primitives" import { assert, describe, it } from "@effect/vitest" import { Option, Schema } from "effect" import type { DispatchContext } from "../context" import type { RenderInput, SecretConfigOf } from "../Transport" -import { googleChatTransport, parseGoogleChatWebhookUrl } from "./google-chat" +import { googleChatTransport } from "./google-chat" const WEBHOOK_URL = "https://chat.googleapis.com/v1/spaces/AAAAbc-12_x/messages?key=api-key&token=secret-token" diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts index 8c485b4a70..5dde7142d1 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts @@ -13,30 +13,9 @@ import type { HttpTransport, RenderInput, SecretConfigOf } from "../Transport" type Config = SecretConfigOf<"google-chat"> -const GOOGLE_CHAT_HOST = "chat.googleapis.com" -const SPACE_MESSAGES_PATH = /^\/v1\/spaces\/([A-Za-z0-9_-]+)\/messages$/ - /** A card's text budget: well under Google Chat's 32k message cap, so an alert is one message. */ const MAX_TEMPLATED_BODY_CHARS = 2000 -/** - * An incoming webhook URL, as Google Chat's "Apps & integrations → Webhooks" hands it out. The - * `key` and `token` query parameters are the credential; the path names the space. - */ -export const parseGoogleChatWebhookUrl = (raw: string): Option.Option<{ readonly spaceId: string }> => - Option.liftThrowable(() => new URL(raw.trim()))().pipe( - Option.flatMap((url) => { - const space = SPACE_MESSAGES_PATH.exec(url.pathname) - return url.protocol === "https:" && - url.host === GOOGLE_CHAT_HOST && - space?.[1] !== undefined && - (url.searchParams.get("key") ?? "").length > 0 && - (url.searchParams.get("token") ?? "").length > 0 - ? Option.some({ spaceId: space[1] }) - : Option.none() - }), - ) - /** Card text paragraphs take a small HTML subset, so every dynamic value is escaped first. */ const escapeHtml = (value: string): string => value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts index 55cb666483..8630e707dc 100644 --- a/packages/domain/src/http/alerts.ts +++ b/packages/domain/src/http/alerts.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Order, Schema } from "effect" +import { Array as Arr, Option, Order, Schema } from "effect" import { QueryEngineAlertReducer, QueryEngineNoDataBehavior } from "../query-engine" import { AlertDeliveryEventId, @@ -372,6 +372,27 @@ export class LinearAlertDestinationConfig extends Schema.Class => + Option.liftThrowable(() => new URL(raw.trim()))().pipe( + Option.flatMap((url) => { + const space = SPACE_MESSAGES_PATH.exec(url.pathname) + return url.protocol === "https:" && + url.host === GOOGLE_CHAT_HOST && + space?.[1] !== undefined && + (url.searchParams.get("key") ?? "").length > 0 && + (url.searchParams.get("token") ?? "").length > 0 + ? Option.some({ spaceId: space[1] }) + : Option.none() + }), + ) + export const MAX_EMAIL_RECIPIENTS = 10 /** From 0ab09d24ee2c16ce10bcf3466e81243e6ba31832 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 30 Oct 2026 21:42:25 +0100 Subject: [PATCH 6/6] fix(alerts): Google Chat headline as fallbackText, not text Google Chat draws a message's `text` above its card, so every alert showed its headline twice. The headline now goes in `fallbackText`, which is used for notifications and clients that cannot draw cards. Checked in a real space: the card renders with the headline once. --- .../alerts/delivery/transports/google-chat.test.ts | 6 +++--- .../src/services/alerts/delivery/transports/google-chat.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts index 78f2761d90..50457f0ba1 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.test.ts @@ -127,7 +127,7 @@ describe("googleChatTransport.render", () => { const body = JSON.parse( render({ templated: { title: "Custom title", body: "line <1>\nline 2" } }).body, ) - assert.strictEqual(body.text, "Custom title") + assert.strictEqual(body.fallbackText, "Custom title") assert.strictEqual(body.cardsV2[0].card.header.title, "Custom title") const widgets = widgetsOf(JSON.stringify(body)) assert.strictEqual(widgets[0].textParagraph.text, "line <1>
line 2") @@ -153,8 +153,8 @@ describe("googleChatTransport.render", () => { it("does not let a rule name mention the whole space", () => { const body = JSON.parse(render({ context: { ...context, ruleName: " checkout" } }).body) - assert.notInclude(body.text, "") - assert.include(body.text, "checkout") + assert.notInclude(body.fallbackText, "") + assert.include(body.fallbackText, "checkout") }) it("says what a revoked or deleted webhook means", () => { diff --git a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts index 5dde7142d1..e7520ee51a 100644 --- a/packages/backend/src/services/alerts/delivery/transports/google-chat.ts +++ b/packages/backend/src/services/alerts/delivery/transports/google-chat.ts @@ -32,7 +32,7 @@ const markdownToCardHtml = (markdown: string): string => ) .replace(/\n/g, "
") -/** `` in a message's `text` is a mention; a rule name or template must not ping a space. */ +/** `` is Google Chat mention syntax; a rule name or template must never ping a space. */ const neutralizeMentions = (value: string): string => value.replace(/<(?=users\/)/gi, "<\u200B") const paragraph = (html: string) => ({ textParagraph: { text: html } }) @@ -77,8 +77,9 @@ export const buildGoogleChatMessage = (input: RenderInput) => { ...(group == null ? [] : [{ decoratedText: { topLabel: "Group", text: escapeHtml(group) } }]), ] return { - // The notification and the text clients without cards show. - text: neutralizeMentions(truncate(title, 300)), + // Shown in notifications and by clients that cannot draw cards. Not `text`, which Google + // Chat renders above the card and so repeats the headline. + fallbackText: neutralizeMentions(truncate(title, 300)), cardsV2: [ { cardId: "maple-alert",