Openstatus www.openstatus.dev

feat: google chat webhook (#1661)

* google chat webhook

* let's go

* ci: apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Thibault Le Ouay
autofix-ci[bot]
and committed by
GitHub
33697e8b 2ca8eef2

+964 -53
+1 -1
apps/dashboard/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 - import "./.next/types/routes.d.ts"; 3 + import "./.next/dev/types/routes.d.ts"; 4 4 5 5 // NOTE: This file should not be edited 6 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1
apps/dashboard/package.json
··· 32 32 "@openstatus/icons": "workspace:*", 33 33 "@openstatus/notification-discord": "workspace:*", 34 34 "@openstatus/notification-emails": "workspace:*", 35 + "@openstatus/notification-google-chat": "workspace:*", 35 36 "@openstatus/notification-ntfy": "workspace:*", 36 37 "@openstatus/notification-opsgenie": "workspace:*", 37 38 "@openstatus/notification-pagerduty": "workspace:*",
+231
apps/dashboard/src/components/forms/notifications/form-google-chat.tsx
··· 1 + "use client"; 2 + 3 + import { Checkbox } from "@/components/ui/checkbox"; 4 + import { 5 + FormControl, 6 + FormDescription, 7 + FormField, 8 + FormItem, 9 + FormLabel, 10 + FormMessage, 11 + } from "@/components/ui/form"; 12 + 13 + import { 14 + FormCardContent, 15 + FormCardSeparator, 16 + } from "@/components/forms/form-card"; 17 + import { useFormSheetDirty } from "@/components/forms/form-sheet"; 18 + import { Button } from "@/components/ui/button"; 19 + import { Form } from "@/components/ui/form"; 20 + import { Input } from "@/components/ui/input"; 21 + import { Label } from "@/components/ui/label"; 22 + 23 + import { useTRPC } from "@/lib/trpc/client"; 24 + import { cn } from "@/lib/utils"; 25 + import { zodResolver } from "@hookform/resolvers/zod"; 26 + import { useMutation } from "@tanstack/react-query"; 27 + import { isTRPCClientError } from "@trpc/client"; 28 + import React, { useTransition } from "react"; 29 + import { useForm } from "react-hook-form"; 30 + import { toast } from "sonner"; 31 + import { z } from "zod"; 32 + 33 + const schema = z.object({ 34 + name: z.string(), 35 + provider: z.literal("google-chat"), 36 + data: z.string(), 37 + monitors: z.array(z.number()), 38 + }); 39 + 40 + type FormValues = z.infer<typeof schema>; 41 + 42 + export function FormGoogleChat({ 43 + defaultValues, 44 + onSubmit, 45 + className, 46 + monitors, 47 + ...props 48 + }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 49 + defaultValues?: FormValues; 50 + onSubmit: (values: FormValues) => Promise<void>; 51 + monitors: { id: number; name: string }[]; 52 + }) { 53 + const form = useForm<FormValues>({ 54 + resolver: zodResolver(schema), 55 + defaultValues: defaultValues ?? { 56 + name: "", 57 + provider: "google-chat", 58 + data: "", 59 + monitors: [], 60 + }, 61 + }); 62 + const [isPending, startTransition] = useTransition(); 63 + const { setIsDirty } = useFormSheetDirty(); 64 + const trpc = useTRPC(); 65 + 66 + const sendTestMutation = useMutation( 67 + trpc.notification.sendTest.mutationOptions(), 68 + ); 69 + 70 + const formIsDirty = form.formState.isDirty; 71 + React.useEffect(() => { 72 + setIsDirty(formIsDirty); 73 + }, [formIsDirty, setIsDirty]); 74 + 75 + function submitAction(values: FormValues) { 76 + if (isPending) return; 77 + 78 + startTransition(async () => { 79 + try { 80 + const promise = onSubmit(values); 81 + toast.promise(promise, { 82 + loading: "Saving...", 83 + success: "Saved", 84 + error: (error) => { 85 + if (isTRPCClientError(error)) { 86 + return error.message; 87 + } 88 + return "Failed to save"; 89 + }, 90 + }); 91 + await promise; 92 + } catch (error) { 93 + console.error(error); 94 + } 95 + }); 96 + } 97 + 98 + function testAction() { 99 + if (isPending) return; 100 + 101 + startTransition(async () => { 102 + try { 103 + const provider = form.getValues("provider"); 104 + const data = form.getValues("data"); 105 + const promise = sendTestMutation.mutateAsync({ 106 + provider, 107 + data: { 108 + "google-chat": data, 109 + }, 110 + }); 111 + toast.promise(promise, { 112 + loading: "Sending test...", 113 + success: "Test sent", 114 + error: (error) => { 115 + if (error instanceof Error) { 116 + return error.message; 117 + } 118 + return "Failed to send test"; 119 + }, 120 + }); 121 + await promise; 122 + } catch (error) { 123 + console.error(error); 124 + } 125 + }); 126 + } 127 + 128 + return ( 129 + <Form {...form}> 130 + <form 131 + className={cn("grid gap-4", className)} 132 + onSubmit={form.handleSubmit(submitAction)} 133 + {...props} 134 + > 135 + <FormCardContent className="grid gap-4"> 136 + <FormField 137 + control={form.control} 138 + name="name" 139 + render={({ field }) => ( 140 + <FormItem> 141 + <FormLabel>Name</FormLabel> 142 + <FormControl> 143 + <Input placeholder="My Notifier" {...field} /> 144 + </FormControl> 145 + <FormMessage /> 146 + <FormDescription> 147 + Enter a descriptive name for your notifier. 148 + </FormDescription> 149 + </FormItem> 150 + )} 151 + /> 152 + <FormField 153 + control={form.control} 154 + name="data" 155 + render={({ field }) => ( 156 + <FormItem> 157 + <FormLabel>Google Chat Webhook</FormLabel> 158 + <FormControl> 159 + <Input placeholder="https://..." {...field} /> 160 + </FormControl> 161 + <FormMessage /> 162 + <FormDescription> 163 + Enter the phone number to send notifications to. 164 + </FormDescription> 165 + </FormItem> 166 + )} 167 + /> 168 + <div> 169 + <Button 170 + variant="outline" 171 + size="sm" 172 + type="button" 173 + onClick={testAction} 174 + > 175 + Send Test 176 + </Button> 177 + </div> 178 + </FormCardContent> 179 + <FormCardSeparator /> 180 + <FormCardContent> 181 + <FormField 182 + control={form.control} 183 + name="monitors" 184 + render={({ field }) => ( 185 + <FormItem> 186 + <FormLabel>Monitors</FormLabel> 187 + <FormDescription> 188 + Select the monitors you want to notify. 189 + </FormDescription> 190 + <div className="grid gap-3"> 191 + <div className="flex items-center gap-2"> 192 + <FormControl> 193 + <Checkbox 194 + id="all" 195 + checked={field.value?.length === monitors.length} 196 + onCheckedChange={(checked) => { 197 + field.onChange( 198 + checked ? monitors.map((m) => m.id) : [], 199 + ); 200 + }} 201 + /> 202 + </FormControl> 203 + <Label htmlFor="all">Select all</Label> 204 + </div> 205 + {monitors.map((item) => ( 206 + <div key={item.id} className="flex items-center gap-2"> 207 + <FormControl> 208 + <Checkbox 209 + id={String(item.id)} 210 + checked={field.value?.includes(item.id)} 211 + onCheckedChange={(checked) => { 212 + const newValue = checked 213 + ? [...(field.value || []), item.id] 214 + : field.value?.filter((id) => id !== item.id); 215 + field.onChange(newValue); 216 + }} 217 + /> 218 + </FormControl> 219 + <Label htmlFor={String(item.id)}>{item.name}</Label> 220 + </div> 221 + ))} 222 + </div> 223 + <FormMessage /> 224 + </FormItem> 225 + )} 226 + /> 227 + </FormCardContent> 228 + </form> 229 + </Form> 230 + ); 231 + }
+1
apps/dashboard/src/components/forms/notifications/form.tsx
··· 33 33 "ntfy", 34 34 "telegram", 35 35 "whatsapp", 36 + "google-chat", 36 37 ]), 37 38 data: z.record(z.string(), z.string()).or(z.string()), 38 39 monitors: z.array(z.number()),
+13 -1
apps/dashboard/src/data/notifications.client.ts
··· 1 1 import { FormDiscord } from "@/components/forms/notifications/form-discord"; 2 2 import { FormEmail } from "@/components/forms/notifications/form-email"; 3 + import { FormGoogleChat } from "@/components/forms/notifications/form-google-chat"; 3 4 import { FormNtfy } from "@/components/forms/notifications/form-ntfy"; 4 5 import { FormOpsGenie } from "@/components/forms/notifications/form-opsgenie"; 5 6 import { FormPagerDuty } from "@/components/forms/notifications/form-pagerduty"; ··· 8 9 import { FormTelegram } from "@/components/forms/notifications/form-telegram"; 9 10 import { FormWebhook } from "@/components/forms/notifications/form-webhook"; 10 11 import { FormWhatsApp } from "@/components/forms/notifications/form-whatsapp"; 11 - import { DiscordIcon, TelegramIcon, WhatsappIcon } from "@openstatus/icons"; 12 + import { 13 + DiscordIcon, 14 + GoogleIcon, 15 + TelegramIcon, 16 + WhatsappIcon, 17 + } from "@openstatus/icons"; 12 18 import { OpsGenieIcon } from "@openstatus/icons"; 13 19 import { PagerDutyIcon } from "@openstatus/icons"; 14 20 import { SlackIcon } from "@openstatus/icons"; ··· 95 101 label: "OpsGenie", 96 102 form: FormOpsGenie, 97 103 sendTest: sendTestOpsGenie, 104 + }, 105 + "google-chat": { 106 + icon: GoogleIcon, 107 + label: "Google Chat", 108 + form: FormGoogleChat, 109 + sendTest: sendTestWebhook, 98 110 }, 99 111 pagerduty: { 100 112 icon: PagerDutyIcon,
+3
apps/docs/src/content/docs/reference/notification.mdx
··· 20 20 Require a [discord webhook](https://support.discord.com/hc/en-us/articles/228383668 21 21 ) e.g. `https://discordapp.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz1234567890` 22 22 23 + ### Google Chat 24 + 25 + Require a [Google Chat webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) 23 26 ### SMS 24 27 25 28 Require a phone number (international format e.g. +14155552671)
apps/web/public/assets/changelog/google-chat.png

This is a binary file and will not be displayed.

+10
apps/web/src/content/pages/changelog/google-chat-notifications.mdx
··· 1 + --- 2 + title: "Google Chat Notifications" 3 + description: "" 4 + image: "/assets/changelog/google-chat.png" 5 + publishedAt: "2025-12-16" 6 + author: "openstatus" 7 + category: "notifications" 8 + --- 9 + 10 + You can now receive your uptime notifications via Google Chat.
+1 -1
apps/workflows/.dockerignore
··· 1 - # This file is generated by Dofigen v2.5.1 1 + # This file is generated by Dofigen v2.6.0 2 2 # See https://github.com/lenra-io/dofigen 3 3 4 4 node_modules
+8 -7
apps/workflows/Dockerfile
··· 1 - # syntax=docker/dockerfile:1.11 2 - # This file is generated by Dofigen v2.5.1 1 + # syntax=docker/dockerfile:1.19.0 2 + # This file is generated by Dofigen v2.6.0 3 3 # See https://github.com/lenra-io/dofigen 4 4 5 5 # ca-certs 6 - FROM debian@sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b AS ca-certs 6 + FROM debian@sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46 AS ca-certs 7 7 LABEL \ 8 - org.opencontainers.image.base.digest="sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b" \ 8 + org.opencontainers.image.base.digest="sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46" \ 9 9 org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" 10 10 RUN apt update && apt install -y ca-certificates && update-ca-certificates 11 11 ··· 36 36 --mount=type=bind,target=packages/emails/package.json,source=packages/emails/package.json \ 37 37 --mount=type=bind,target=packages/notifications/discord/package.json,source=packages/notifications/discord/package.json \ 38 38 --mount=type=bind,target=packages/notifications/email/package.json,source=packages/notifications/email/package.json \ 39 + --mount=type=bind,target=packages/notifications/google-chat/package.json,source=packages/notifications/google-chat/package.json \ 39 40 --mount=type=bind,target=packages/notifications/ntfy/package.json,source=packages/notifications/ntfy/package.json \ 40 41 --mount=type=bind,target=packages/notifications/opsgenie/package.json,source=packages/notifications/opsgenie/package.json \ 41 42 --mount=type=bind,target=packages/notifications/pagerduty/package.json,source=packages/notifications/pagerduty/package.json \ ··· 83 84 RUN bun install 84 85 85 86 # runtime 86 - FROM debian@sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b AS runtime 87 + FROM debian@sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46 AS runtime 87 88 LABEL \ 88 - io.dofigen.version="2.5.1" \ 89 + io.dofigen.version="2.6.0" \ 89 90 org.opencontainers.image.authors="OpenStatus Team" \ 90 - org.opencontainers.image.base.digest="sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b" \ 91 + org.opencontainers.image.base.digest="sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46" \ 91 92 org.opencontainers.image.base.name="docker.io/debian:bullseye-slim" \ 92 93 org.opencontainers.image.description="Background job processing and probe scheduling for OpenStatus" \ 93 94 org.opencontainers.image.source="https://github.com/openstatusHQ/openstatus" \
+46 -43
apps/workflows/dofigen.lock
··· 12 12 - /packages/error 13 13 - /packages/tracker 14 14 builders: 15 - build: 15 + docker: 16 16 fromImage: 17 17 path: oven/bun 18 18 digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 19 19 label: 20 20 org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 21 21 org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 22 - org.opencontainers.image.stage: build 23 22 workdir: /app/apps/workflows 24 - env: 25 - NODE_ENV: production 26 23 copy: 27 24 - paths: 28 25 - . 29 26 target: /app/ 30 - - fromBuilder: install 31 - paths: 32 - - /app/node_modules 33 - target: /app/node_modules 27 + run: 28 + - bun run src/build-docker.ts 29 + ca-certs: 30 + fromImage: 31 + path: debian 32 + digest: sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46 33 + label: 34 + org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 35 + org.opencontainers.image.base.digest: sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46 34 36 run: 35 - - bun build --compile --target bun --sourcemap --format=cjs src/index.ts --outfile=app --external '@libsql/*' --external libsql 37 + - apt update && apt install -y ca-certificates && update-ca-certificates 36 38 libsql: 37 39 fromImage: 38 40 path: oven/bun ··· 48 50 target: /app/package.json 49 51 run: 50 52 - bun install 53 + build: 54 + fromImage: 55 + path: oven/bun 56 + digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 57 + label: 58 + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 59 + org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 60 + org.opencontainers.image.stage: build 61 + workdir: /app/apps/workflows 62 + env: 63 + NODE_ENV: production 64 + copy: 65 + - paths: 66 + - . 67 + target: /app/ 68 + - fromBuilder: install 69 + paths: 70 + - /app/node_modules 71 + target: /app/node_modules 72 + run: 73 + - bun build --compile --target bun --sourcemap --format=cjs src/index.ts --outfile=app --external '@libsql/*' --external libsql 51 74 install: 52 75 fromImage: 53 76 path: oven/bun 54 77 digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 55 78 label: 79 + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 56 80 org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 57 81 org.opencontainers.image.stage: install 58 - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 59 82 workdir: /app/ 60 83 run: 61 84 - bun install --production --frozen-lockfile --verbose ··· 78 101 source: packages/notifications/discord/package.json 79 102 - target: packages/notifications/email/package.json 80 103 source: packages/notifications/email/package.json 104 + - target: packages/notifications/google-chat/package.json 105 + source: packages/notifications/google-chat/package.json 81 106 - target: packages/notifications/ntfy/package.json 82 107 source: packages/notifications/ntfy/package.json 83 108 - target: packages/notifications/opsgenie/package.json ··· 106 131 source: packages/upstash/package.json 107 132 - target: packages/theme-store/package.json 108 133 source: packages/theme-store/package.json 109 - ca-certs: 110 - fromImage: 111 - path: debian 112 - digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 113 - label: 114 - org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 115 - org.opencontainers.image.base.digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 116 - run: 117 - - apt update && apt install -y ca-certificates && update-ca-certificates 118 - docker: 119 - fromImage: 120 - path: oven/bun 121 - digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 122 - label: 123 - org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 124 - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 125 - workdir: /app/apps/workflows 126 - copy: 127 - - paths: 128 - - . 129 - target: /app/ 130 - run: 131 - - bun run src/build-docker.ts 132 134 fromImage: 133 135 path: debian 134 - digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 136 + digest: sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46 135 137 label: 136 138 org.opencontainers.image.authors: OpenStatus Team 137 - org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus 138 139 org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 139 - org.opencontainers.image.base.digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 140 + org.opencontainers.image.vendor: OpenStatus 141 + io.dofigen.version: 2.6.0 140 142 org.opencontainers.image.description: Background job processing and probe scheduling for OpenStatus 141 - org.opencontainers.image.vendor: OpenStatus 143 + org.opencontainers.image.base.digest: sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46 144 + org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus 142 145 org.opencontainers.image.title: OpenStatus Workflows 143 - io.dofigen.version: 2.5.1 144 146 workdir: /app/ 145 147 copy: 146 148 - fromBuilder: build ··· 166 168 - port: 3000 167 169 images: 168 170 docker.io: 171 + library: 172 + debian: 173 + bullseye-slim: 174 + digest: sha256:5fc1d68d490d6e22a8b182f67d2b9ed800e6dd49e997dd595a46977fe7cece46 169 175 oven: 170 176 bun: 171 177 1.3.3: 172 178 digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 173 - library: 174 - debian: 175 - bullseye-slim: 176 - digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 177 179 resources: 178 180 dofigen.yml: 179 - hash: 7e59e1efd94f649c01720f89c8966945798a8a0c4572e2fb783d6e406e5384c4 181 + hash: a1eb7b5ea5ae9202fedceba5f4e792dac4955311d07045e2338d3bec811c7cdf 180 182 content: | 181 183 ignore: 182 184 - node_modules ··· 205 207 - packages/emails/package.json 206 208 - packages/notifications/discord/package.json 207 209 - packages/notifications/email/package.json 210 + - packages/notifications/google-chat/package.json 208 211 - packages/notifications/ntfy/package.json 209 212 - packages/notifications/opsgenie/package.json 210 213 - packages/notifications/pagerduty/package.json
+1
apps/workflows/dofigen.yml
··· 25 25 - packages/emails/package.json 26 26 - packages/notifications/discord/package.json 27 27 - packages/notifications/email/package.json 28 + - packages/notifications/google-chat/package.json 28 29 - packages/notifications/ntfy/package.json 29 30 - packages/notifications/opsgenie/package.json 30 31 - packages/notifications/pagerduty/package.json
+1
apps/workflows/package.json
··· 15 15 "@openstatus/emails": "workspace:*", 16 16 "@openstatus/notification-discord": "workspace:*", 17 17 "@openstatus/notification-emails": "workspace:*", 18 + "@openstatus/notification-google-chat": "workspace:*", 18 19 "@openstatus/notification-ntfy": "workspace:*", 19 20 "@openstatus/notification-opsgenie": "workspace:*", 20 21 "@openstatus/notification-pagerduty": "workspace:*",
+10
apps/workflows/src/checker/utils.ts
··· 15 15 sendRecovery as sendEmailRecovery, 16 16 } from "@openstatus/notification-emails"; 17 17 import { 18 + sendAlert as sendGoogleChatAlert, 19 + sendDegraded as sendGoogleChatDegraded, 20 + sendRecovery as sendGoogleChatRecovery, 21 + } from "@openstatus/notification-google-chat"; 22 + import { 18 23 sendAlert as sendNtfyAlert, 19 24 sendDegraded as sendNtfyDegraded, 20 25 sendRecovery as sendNtfyRecovery, ··· 91 96 sendAlert: sendEmailAlert, 92 97 sendRecovery: sendEmailRecovery, 93 98 sendDegraded: sendEmailDegraded, 99 + }, 100 + "google-chat": { 101 + sendAlert: sendGoogleChatAlert, 102 + sendRecovery: sendGoogleChatRecovery, 103 + sendDegraded: sendGoogleChatDegraded, 94 104 }, 95 105 ntfy: { 96 106 sendAlert: sendNtfyAlert,
+1
packages/api/package.json
··· 13 13 "@openstatus/db": "workspace:*", 14 14 "@openstatus/emails": "workspace:*", 15 15 "@openstatus/error": "workspace:*", 16 + "@openstatus/notification-google-chat": "workspace:*", 16 17 "@openstatus/notification-telegram": "workspace:*", 17 18 "@openstatus/notification-twillio-whatsapp": "workspace:*", 18 19 "@openstatus/regions": "workspace:*",
+16
packages/api/src/router/notification.ts
··· 4 4 import { type SQL, and, count, db, eq, inArray } from "@openstatus/db"; 5 5 import { 6 6 NotificationDataSchema, 7 + googleChatDataSchema, 7 8 insertNotificationSchema, 8 9 monitor, 9 10 notification, ··· 17 18 18 19 import { Events } from "@openstatus/analytics"; 19 20 import { SchemaError } from "@openstatus/error"; 21 + import { sendTest as sendGoogleChatTest } from "@openstatus/notification-google-chat"; 20 22 import { sendTest as sendTelegramTest } from "@openstatus/notification-telegram"; 21 23 import { sendTest as sendWhatsAppTest } from "@openstatus/notification-twillio-whatsapp"; 24 + 22 25 import { createTRPCRouter, protectedProcedure } from "../trpc"; 23 26 24 27 export const notificationRouter = createTRPCRouter({ ··· 488 491 } 489 492 await sendWhatsAppTest({ phoneNumber: _data.data.whatsapp }); 490 493 494 + return; 495 + } 496 + if (opts.input.provider === "google-chat") { 497 + const _data = googleChatDataSchema.safeParse(opts.input.data); 498 + console.log(opts.input.data); 499 + console.log(_data); 500 + if (!_data.success) { 501 + throw new TRPCError({ 502 + code: "BAD_REQUEST", 503 + message: SchemaError.fromZod(_data.error, opts.input).message, 504 + }); 505 + } 506 + await sendGoogleChatTest(_data.data["google-chat"]); 491 507 return; 492 508 } 493 509
+1
packages/db/src/schema/notifications/constants.ts
··· 1 1 export const notificationProvider = [ 2 2 "discord", 3 3 "email", 4 + "google-chat", 4 5 "ntfy", 5 6 "pagerduty", 6 7 "opsgenie",
+7
packages/db/src/schema/notifications/validation.ts
··· 55 55 export const phoneDataSchema = z.object({ sms: phoneSchema }); 56 56 export const slackDataSchema = z.object({ slack: urlSchema }); 57 57 export const discordDataSchema = z.object({ discord: urlSchema }); 58 + export const googleChatDataSchema = z.object({ "google-chat": urlSchema }); 58 59 export const pagerdutyDataSchema = z.object({ pagerduty: z.string() }); 59 60 export const opsgenieDataSchema = z.object({ 60 61 opsgenie: z.object({ ··· 96 97 z.object({ 97 98 provider: z.literal("email"), 98 99 data: emailDataSchema, 100 + }), 101 + ), 102 + insertNotificationSchema.merge( 103 + z.object({ 104 + provider: z.literal("google-chat"), 105 + data: googleChatDataSchema, 99 106 }), 100 107 ), 101 108 insertNotificationSchema.merge(
+169
packages/notifications/google-chat/.gitignore
··· 1 + # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 + 3 + # Logs 4 + 5 + logs 6 + _.log 7 + npm-debug.log_ 8 + yarn-debug.log* 9 + yarn-error.log* 10 + lerna-debug.log* 11 + .pnpm-debug.log* 12 + 13 + # Diagnostic reports (https://nodejs.org/api/report.html) 14 + 15 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 + 17 + # Runtime data 18 + 19 + pids 20 + _.pid 21 + _.seed 22 + \*.pid.lock 23 + 24 + # Directory for instrumented libs generated by jscoverage/JSCover 25 + 26 + lib-cov 27 + 28 + # Coverage directory used by tools like istanbul 29 + 30 + coverage 31 + \*.lcov 32 + 33 + # nyc test coverage 34 + 35 + .nyc_output 36 + 37 + # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 + 39 + .grunt 40 + 41 + # Bower dependency directory (https://bower.io/) 42 + 43 + bower_components 44 + 45 + # node-waf configuration 46 + 47 + .lock-wscript 48 + 49 + # Compiled binary addons (https://nodejs.org/api/addons.html) 50 + 51 + build/Release 52 + 53 + # Dependency directories 54 + 55 + node_modules/ 56 + jspm_packages/ 57 + 58 + # Snowpack dependency directory (https://snowpack.dev/) 59 + 60 + web_modules/ 61 + 62 + # TypeScript cache 63 + 64 + \*.tsbuildinfo 65 + 66 + # Optional npm cache directory 67 + 68 + .npm 69 + 70 + # Optional eslint cache 71 + 72 + .eslintcache 73 + 74 + # Optional stylelint cache 75 + 76 + .stylelintcache 77 + 78 + # Microbundle cache 79 + 80 + .rpt2_cache/ 81 + .rts2_cache_cjs/ 82 + .rts2_cache_es/ 83 + .rts2_cache_umd/ 84 + 85 + # Optional REPL history 86 + 87 + .node_repl_history 88 + 89 + # Output of 'npm pack' 90 + 91 + \*.tgz 92 + 93 + # Yarn Integrity file 94 + 95 + .yarn-integrity 96 + 97 + # dotenv environment variable files 98 + 99 + .env 100 + .env.development.local 101 + .env.test.local 102 + .env.production.local 103 + .env.local 104 + 105 + # parcel-bundler cache (https://parceljs.org/) 106 + 107 + .cache 108 + .parcel-cache 109 + 110 + # Next.js build output 111 + 112 + .next 113 + out 114 + 115 + # Nuxt.js build / generate output 116 + 117 + .nuxt 118 + dist 119 + 120 + # Gatsby files 121 + 122 + .cache/ 123 + 124 + # Comment in the public line in if your project uses Gatsby and not Next.js 125 + 126 + # https://nextjs.org/blog/next-9-1#public-directory-support 127 + 128 + # public 129 + 130 + # vuepress build output 131 + 132 + .vuepress/dist 133 + 134 + # vuepress v2.x temp and cache directory 135 + 136 + .temp 137 + .cache 138 + 139 + # Docusaurus cache and generated files 140 + 141 + .docusaurus 142 + 143 + # Serverless directories 144 + 145 + .serverless/ 146 + 147 + # FuseBox cache 148 + 149 + .fusebox/ 150 + 151 + # DynamoDB Local files 152 + 153 + .dynamodb/ 154 + 155 + # TernJS port file 156 + 157 + .tern-port 158 + 159 + # Stores VSCode versions used for testing VSCode extensions 160 + 161 + .vscode-test 162 + 163 + # yarn v2 164 + 165 + .yarn/cache 166 + .yarn/unplugged 167 + .yarn/build-state.yml 168 + .yarn/install-state.gz 169 + .pnp.\*
+16
packages/notifications/google-chat/README.md
··· 1 + # @openstatus/notifications-discord 2 + 3 + To install dependencies: 4 + 5 + ```bash 6 + bun install 7 + ``` 8 + 9 + To run: 10 + 11 + ```bash 12 + bun run src/index.ts 13 + ``` 14 + 15 + This project was created using `bun init` in bun v1.0.0. [Bun](https://bun.sh) 16 + is a fast all-in-one JavaScript runtime.
+18
packages/notifications/google-chat/package.json
··· 1 + { 2 + "name": "@openstatus/notification-google-chat", 3 + "version": "1.0.0", 4 + "main": "src/index.ts", 5 + "scripts": { 6 + "test": "bun test" 7 + }, 8 + "dependencies": { 9 + "@openstatus/db": "workspace:*", 10 + "zod": "3.25.76" 11 + }, 12 + "devDependencies": { 13 + "@openstatus/tsconfig": "workspace:*", 14 + "@types/node": "24.0.8", 15 + "bun-types": "1.3.1", 16 + "typescript": "5.9.3" 17 + } 18 + }
+159
packages/notifications/google-chat/src/index.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 + import { selectNotificationSchema } from "@openstatus/db/src/schema"; 3 + import { sendAlert, sendDegraded, sendRecovery, sendTest } from "./index"; 4 + 5 + describe("Google Chat Notifications", () => { 6 + // biome-ignore lint/suspicious/noExplicitAny: <explanation> 7 + let fetchMock: any = undefined; 8 + 9 + beforeEach(() => { 10 + // @ts-expect-error 11 + fetchMock = spyOn(global, "fetch").mockImplementation(() => 12 + Promise.resolve(new Response(null, { status: 200 })), 13 + ); 14 + }); 15 + 16 + afterEach(() => { 17 + if (fetchMock) { 18 + fetchMock.mockRestore(); 19 + } 20 + }); 21 + 22 + const createMockMonitor = () => ({ 23 + id: "monitor-1", 24 + name: "API Health Check", 25 + url: "https://api.example.com/health", 26 + jobType: "http" as const, 27 + periodicity: "5m" as const, 28 + status: "active" as const, 29 + createdAt: new Date(), 30 + updatedAt: new Date(), 31 + region: "iad", 32 + }); 33 + 34 + const createMockNotification = () => ({ 35 + id: 1, 36 + name: "Google Chat Notification", 37 + provider: "google-chat", 38 + workspaceId: 1, 39 + createdAt: new Date(), 40 + updatedAt: new Date(), 41 + data: '{"google-chat":"https://google.com/api/webhooks/123456789/abcdefgh"}', 42 + }); 43 + 44 + test("Send Alert", async () => { 45 + const monitor = createMockMonitor(); 46 + const notification = selectNotificationSchema.parse( 47 + createMockNotification(), 48 + ); 49 + 50 + await sendAlert({ 51 + // @ts-expect-error 52 + monitor, 53 + notification, 54 + statusCode: 500, 55 + message: "Something went wrong", 56 + cronTimestamp: Date.now(), 57 + }); 58 + 59 + expect(fetchMock).toHaveBeenCalledTimes(1); 60 + const callArgs = fetchMock.mock.calls[0]; 61 + expect(callArgs[0]).toBe( 62 + "https://google.com/api/webhooks/123456789/abcdefgh", 63 + ); 64 + expect(callArgs[1].method).toBe("POST"); 65 + expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); 66 + 67 + const body = JSON.parse(callArgs[1].body); 68 + console.log(body); 69 + expect(body.text).toContain("🚨 Alert"); 70 + expect(body.text).toContain("API Health Check"); 71 + expect(body.text).toContain("Something went wrong"); 72 + }); 73 + 74 + test("Send Recovery", async () => { 75 + const monitor = createMockMonitor(); 76 + const notification = selectNotificationSchema.parse( 77 + createMockNotification(), 78 + ); 79 + 80 + await sendRecovery({ 81 + // @ts-expect-error 82 + monitor, 83 + notification, 84 + statusCode: 200, 85 + message: "Service recovered", 86 + cronTimestamp: Date.now(), 87 + }); 88 + 89 + expect(fetchMock).toHaveBeenCalledTimes(1); 90 + const callArgs = fetchMock.mock.calls[0]; 91 + const body = JSON.parse(callArgs[1].body); 92 + expect(body.text).toContain("✅ Recovered"); 93 + expect(body.text).toContain("API Health Check"); 94 + }); 95 + 96 + test("Send Degraded", async () => { 97 + const monitor = createMockMonitor(); 98 + const notification = selectNotificationSchema.parse( 99 + createMockNotification(), 100 + ); 101 + 102 + await sendDegraded({ 103 + // @ts-expect-error 104 + monitor, 105 + notification, 106 + statusCode: 503, 107 + message: "Service degraded", 108 + cronTimestamp: Date.now(), 109 + }); 110 + 111 + expect(fetchMock).toHaveBeenCalledTimes(1); 112 + const callArgs = fetchMock.mock.calls[0]; 113 + const body = JSON.parse(callArgs[1].body); 114 + expect(body.text).toContain("⚠️ Degraded"); 115 + expect(body.text).toContain("API Health Check"); 116 + }); 117 + 118 + test("Send Test Google Chat Message", async () => { 119 + const webhookUrl = "https://google.com/api/webhooks/123456789/abcdefgh"; 120 + 121 + const result = await sendTest(webhookUrl); 122 + 123 + expect(result).toBe(true); 124 + expect(fetchMock).toHaveBeenCalledTimes(1); 125 + const callArgs = fetchMock.mock.calls[0]; 126 + expect(callArgs[0]).toBe(webhookUrl); 127 + const body = JSON.parse(callArgs[1].body); 128 + expect(body.text).toContain("🧪 Test"); 129 + expect(body.text).toContain("OpenStatus"); 130 + }); 131 + 132 + test("Send Test Google Chat Message with empty webhookUrl", async () => { 133 + const result = await sendTest(""); 134 + 135 + expect(result).toBe(false); 136 + expect(fetchMock).not.toHaveBeenCalled(); 137 + }); 138 + 139 + test("Handle fetch error gracefully", async () => { 140 + fetchMock.mockImplementation(() => 141 + Promise.reject(new Error("Network error")), 142 + ); 143 + 144 + const monitor = createMockMonitor(); 145 + const notification = selectNotificationSchema.parse( 146 + createMockNotification(), 147 + ); 148 + expect( 149 + sendAlert({ 150 + // @ts-expect-error 151 + monitor, 152 + notification, 153 + statusCode: 500, 154 + message: "Error", 155 + cronTimestamp: Date.now(), 156 + }), 157 + ).rejects.toThrow(); 158 + }); 159 + });
+148
packages/notifications/google-chat/src/index.ts
··· 1 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + import { googleChatDataSchema } from "@openstatus/db/src/schema"; 3 + import type { Region } from "@openstatus/db/src/schema/constants"; 4 + 5 + const postToWebhook = async (content: string, webhookUrl: string) => { 6 + const res = await fetch(webhookUrl, { 7 + method: "POST", 8 + headers: { 9 + "Content-Type": "application/json", 10 + }, 11 + body: JSON.stringify({ 12 + text: content, 13 + }), 14 + }); 15 + if (!res.ok) { 16 + throw new Error( 17 + `Failed to send Google Chat webhook: ${res.status} ${res.statusText}`, 18 + ); 19 + } 20 + }; 21 + 22 + export const sendAlert = async ({ 23 + monitor, 24 + notification, 25 + statusCode, 26 + message, 27 + cronTimestamp, 28 + }: { 29 + monitor: Monitor; 30 + notification: Notification; 31 + statusCode?: number; 32 + message?: string; 33 + incidentId?: string; 34 + cronTimestamp: number; 35 + latency?: number; 36 + region?: Region; 37 + }) => { 38 + const notificationData = googleChatDataSchema.parse( 39 + JSON.parse(notification.data), 40 + ); 41 + const { "google-chat": webhookUrl } = notificationData; // webhook url 42 + const { name } = monitor; 43 + 44 + try { 45 + await postToWebhook( 46 + `*🚨 Alert <${monitor.url}|${name}>*\nStatus Code: ${ 47 + statusCode || "_empty_" 48 + }\nMessage: ${ 49 + message || "_empty_" 50 + }\nCron Timestamp: ${cronTimestamp} (${new Date( 51 + cronTimestamp, 52 + ).toISOString()})\n> Check your <https://www.openstatus.dev/app/|Dashboard>.\n`, 53 + webhookUrl, 54 + ); 55 + } catch (err) { 56 + console.error(err); 57 + throw err; 58 + } 59 + }; 60 + 61 + export const sendRecovery = async ({ 62 + monitor, 63 + notification, 64 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 65 + statusCode, 66 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 67 + message, 68 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 69 + incidentId, 70 + }: { 71 + monitor: Monitor; 72 + notification: Notification; 73 + statusCode?: number; 74 + message?: string; 75 + incidentId?: string; 76 + cronTimestamp: number; 77 + latency?: number; 78 + region?: Region; 79 + }) => { 80 + const notificationData = googleChatDataSchema.parse( 81 + JSON.parse(notification.data), 82 + ); 83 + const { "google-chat": webhookUrl } = notificationData; // webhook url 84 + const { name } = monitor; 85 + 86 + try { 87 + await postToWebhook( 88 + `*✅ Recovered <${monitor.url}|${name}>**\n> Check your <https://www.openstatus.dev/app/|Dashboard>.\n`, 89 + webhookUrl, 90 + ); 91 + } catch (err) { 92 + console.error(err); 93 + throw err; 94 + } 95 + }; 96 + 97 + export const sendDegraded = async ({ 98 + monitor, 99 + notification, 100 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 101 + statusCode, 102 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 103 + message, 104 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 105 + incidentId, 106 + }: { 107 + monitor: Monitor; 108 + notification: Notification; 109 + statusCode?: number; 110 + message?: string; 111 + incidentId?: string; 112 + cronTimestamp: number; 113 + latency?: number; 114 + region?: Region; 115 + }) => { 116 + const notificationData = googleChatDataSchema.parse( 117 + JSON.parse(notification.data), 118 + ); 119 + const { "google-chat": webhookUrl } = notificationData; // webhook url 120 + const { name } = monitor; 121 + 122 + try { 123 + await postToWebhook( 124 + `*⚠️ Degraded <${monitor.url}|${name}>*\n> Check your <https://www.openstatus.dev/app/|Dashboard>.\n`, 125 + webhookUrl, 126 + ); 127 + } catch (err) { 128 + console.error(err); 129 + throw err; 130 + } 131 + }; 132 + 133 + export const sendTest = async (webhookUrl: string) => { 134 + if (!webhookUrl) { 135 + return false; 136 + } 137 + console.log(webhookUrl); 138 + try { 139 + await postToWebhook( 140 + "*🧪 Test <https://www.openstatus.dev/|OpenStatus>*\nIf you can read this, your Google Chat webhook is functioning correctly!\n> Check your <https://www.openstatus.dev/app/|Dashboard>.\n", 141 + webhookUrl, 142 + ); 143 + return true; 144 + } catch (_err) { 145 + console.log(_err); 146 + return false; 147 + } 148 + };
+64
packages/notifications/google-chat/src/mock.ts
··· 1 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + import { sendAlert, sendDegraded, sendRecovery, sendTest } from "./index"; 3 + 4 + const monitor: Monitor = { 5 + id: 1, 6 + name: "OpenStatus Docs", 7 + url: "https://docs.openstatus.dev", 8 + periodicity: "10m", 9 + jobType: "http", 10 + active: true, 11 + public: true, 12 + createdAt: null, 13 + updatedAt: null, 14 + regions: ["ams", "fra"], 15 + description: "Monitor Description", 16 + headers: [], 17 + body: "", 18 + workspaceId: 1, 19 + timeout: 45000, 20 + degradedAfter: null, 21 + assertions: null, 22 + status: "active", 23 + method: "GET", 24 + deletedAt: null, 25 + otelEndpoint: null, 26 + otelHeaders: [], 27 + followRedirects: true, 28 + retry: 3, 29 + }; 30 + 31 + const notification: Notification = { 32 + id: 1, 33 + name: "Discord", 34 + data: `{ "discord": "${process.env.DISCORD_WEBHOOK}" }`, 35 + createdAt: null, 36 + updatedAt: null, 37 + workspaceId: 1, 38 + provider: "discord", 39 + }; 40 + 41 + const cronTimestamp = Date.now(); 42 + 43 + if (process.env.NODE_ENV === "development") { 44 + await sendDegraded({ 45 + monitor, 46 + notification, 47 + cronTimestamp, 48 + }); 49 + 50 + await sendAlert({ 51 + monitor, 52 + notification, 53 + statusCode: 500, 54 + cronTimestamp, 55 + }); 56 + 57 + await sendRecovery({ 58 + monitor, 59 + notification, 60 + cronTimestamp, 61 + }); 62 + 63 + await sendTest(""); 64 + }
+7
packages/notifications/google-chat/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/nextjs.json", 3 + "include": ["src", "*.ts"], 4 + "compilerOptions": { 5 + "types": ["bun-types"] 6 + } 7 + }
+31
pnpm-lock.yaml
··· 98 98 '@openstatus/notification-emails': 99 99 specifier: workspace:* 100 100 version: link:../../packages/notifications/email 101 + '@openstatus/notification-google-chat': 102 + specifier: workspace:* 103 + version: link:../../packages/notifications/google-chat 101 104 '@openstatus/notification-ntfy': 102 105 specifier: workspace:* 103 106 version: link:../../packages/notifications/ntfy ··· 1079 1082 '@openstatus/notification-emails': 1080 1083 specifier: workspace:* 1081 1084 version: link:../../packages/notifications/email 1085 + '@openstatus/notification-google-chat': 1086 + specifier: workspace:* 1087 + version: link:../../packages/notifications/google-chat 1082 1088 '@openstatus/notification-ntfy': 1083 1089 specifier: workspace:* 1084 1090 version: link:../../packages/notifications/ntfy ··· 1183 1189 '@openstatus/error': 1184 1190 specifier: workspace:* 1185 1191 version: link:../error 1192 + '@openstatus/notification-google-chat': 1193 + specifier: workspace:* 1194 + version: link:../notifications/google-chat 1186 1195 '@openstatus/notification-telegram': 1187 1196 specifier: workspace:* 1188 1197 version: link:../notifications/telegram ··· 1479 1488 '@types/react-dom': 1480 1489 specifier: 19.2.2 1481 1490 version: 19.2.2(@types/react@19.2.2) 1491 + bun-types: 1492 + specifier: 1.3.1 1493 + version: 1.3.1(@types/react@19.2.2) 1494 + typescript: 1495 + specifier: 5.9.3 1496 + version: 5.9.3 1497 + 1498 + packages/notifications/google-chat: 1499 + dependencies: 1500 + '@openstatus/db': 1501 + specifier: workspace:* 1502 + version: link:../../db 1503 + zod: 1504 + specifier: 3.25.76 1505 + version: 3.25.76 1506 + devDependencies: 1507 + '@openstatus/tsconfig': 1508 + specifier: workspace:* 1509 + version: link:../../tsconfig 1510 + '@types/node': 1511 + specifier: 24.0.8 1512 + version: 24.0.8 1482 1513 bun-types: 1483 1514 specifier: 1.3.1 1484 1515 version: 1.3.1(@types/react@19.2.2)