Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/alchemy-google-chat-destination.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions apps/api/src/routes/v2/alert-destinations.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
AlertDestinationNotFoundError,
PagerDutyAlertDestinationConfig,
TelegramAlertDestinationConfig,
GoogleChatAlertDestinationConfig,
WebhookAlertDestinationConfig,
} from "@maple/domain/http"
import type {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/ios/Maple/Components/AlertFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
3 changes: 2 additions & 1 deletion apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6799,6 +6799,7 @@
"hazel-oauth",
"discord",
"telegram",
"google-chat",
"email",
"chat",
"linear"
Expand Down Expand Up @@ -13948,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"
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
---
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/page/alerts/destination/dialog.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
20 changes: 19 additions & 1 deletion apps/web/src/page/alerts/destination/dialog.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -180,6 +185,8 @@ const createRequest = (payload: V2AlertDestinationCreateParams) => {
return { payload }
case "telegram":
return { payload }
case "google-chat":
return { payload }
case "email":
return { payload }
case "chat":
Expand All @@ -202,6 +209,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":
Expand Down Expand Up @@ -247,13 +256,22 @@ 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())

/** 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
switch (form.type) {
case "hazel-oauth":
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 &&
Expand Down
46 changes: 45 additions & 1 deletion apps/web/src/page/alerts/destination/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -296,6 +303,43 @@ 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(
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",
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) },
"In Google Chat: open the space → Apps & integrations → Webhooks → Add webhook, then copy the URL.",
),
]),
]
}
case "telegram":
return telegramFields(h, model, isEditing)
case "webhook":
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/page/alerts/shared/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/page/alerts/shared/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,21 @@ export const PROVIDERS = new Map<AlertDestinationType, DestinationProvider>([
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",
{
Expand Down Expand Up @@ -196,6 +211,7 @@ export const DESTINATION_TYPES: ReadonlyArray<AlertDestinationType> = [
"email",
"webhook",
"telegram",
"google-chat",
"hazel-oauth",
"linear",
]
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/ui/icons.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
5 changes: 4 additions & 1 deletion packages/alchemy-maple/src/AlertDestination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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.
Expand Down Expand Up @@ -97,6 +99,7 @@ const desiredBody = (props: AlertDestinationProps): Record<string, unknown> => {
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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading