Openstatus www.openstatus.dev

feat: telegram bot notifications (#1622)

* feat: telegram bot notifications

* chore: dofigen

* chore: docs

* wip:

* chore: content

* chore: changelog

* chore: dofigen

* test: notification packages

* fix docker

---------

Co-authored-by: Thibault Le Ouay Ducasse <thibaultleouay@gmail.com>

authored by

Maximilian Kaske
Thibault Le Ouay Ducasse
and committed by
GitHub
5de1e45a 552ff64c

+2262 -462
+1
apps/dashboard/package.json
··· 36 36 "@openstatus/notification-opsgenie": "workspace:*", 37 37 "@openstatus/notification-pagerduty": "workspace:*", 38 38 "@openstatus/notification-slack": "workspace:*", 39 + "@openstatus/notification-telegram": "workspace:*", 39 40 "@openstatus/notification-webhook": "workspace:*", 40 41 "@openstatus/react": "workspace:*", 41 42 "@openstatus/regions": "workspace:*",
+6 -1
apps/dashboard/src/components/forms/notifications/form-discord.tsx
··· 163 163 )} 164 164 /> 165 165 <div> 166 - <Button variant="outline" size="sm" onClick={testAction}> 166 + <Button 167 + variant="outline" 168 + size="sm" 169 + type="button" 170 + onClick={testAction} 171 + > 167 172 Send Test 168 173 </Button> 169 174 </div>
+241
apps/dashboard/src/components/forms/notifications/form-telegram.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 { Link } from "@/components/common/link"; 14 + import { 15 + FormCardContent, 16 + FormCardSeparator, 17 + } from "@/components/forms/form-card"; 18 + import { useFormSheetDirty } from "@/components/forms/form-sheet"; 19 + import { Button } from "@/components/ui/button"; 20 + import { Form } from "@/components/ui/form"; 21 + import { Input } from "@/components/ui/input"; 22 + import { Label } from "@/components/ui/label"; 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("telegram"), 36 + data: z.object({ 37 + chatId: z.string(), 38 + }), 39 + monitors: z.array(z.number()), 40 + }); 41 + 42 + type FormValues = z.infer<typeof schema>; 43 + 44 + export function FormTelegram({ 45 + monitors, 46 + defaultValues, 47 + onSubmit, 48 + className, 49 + ...props 50 + }: Omit<React.ComponentProps<"form">, "onSubmit"> & { 51 + defaultValues?: FormValues; 52 + onSubmit: (values: FormValues) => Promise<void>; 53 + monitors: { id: number; name: string }[]; 54 + }) { 55 + const form = useForm<FormValues>({ 56 + resolver: zodResolver(schema), 57 + defaultValues: defaultValues ?? { 58 + name: "", 59 + provider: "telegram", 60 + data: { 61 + chatId: "", 62 + }, 63 + monitors: [], 64 + }, 65 + }); 66 + const [isPending, startTransition] = useTransition(); 67 + const { setIsDirty } = useFormSheetDirty(); 68 + const trpc = useTRPC(); 69 + const sendTestMutation = useMutation( 70 + trpc.notification.sendTest.mutationOptions(), 71 + ); 72 + 73 + const formIsDirty = form.formState.isDirty; 74 + React.useEffect(() => { 75 + setIsDirty(formIsDirty); 76 + }, [formIsDirty, setIsDirty]); 77 + 78 + function submitAction(values: FormValues) { 79 + if (isPending) return; 80 + 81 + startTransition(async () => { 82 + try { 83 + const promise = onSubmit(values); 84 + toast.promise(promise, { 85 + loading: "Saving...", 86 + success: "Saved", 87 + error: (error) => { 88 + if (isTRPCClientError(error)) { 89 + return error.message; 90 + } 91 + return "Failed to save"; 92 + }, 93 + }); 94 + await promise; 95 + } catch (error) { 96 + console.error(error); 97 + } 98 + }); 99 + } 100 + 101 + function testAction() { 102 + if (isPending) return; 103 + 104 + startTransition(async () => { 105 + try { 106 + const provider = form.getValues("provider"); 107 + const data = form.getValues("data"); 108 + const promise = sendTestMutation.mutateAsync({ 109 + provider, 110 + data: { 111 + telegram: { chatId: data.chatId }, 112 + }, 113 + }); 114 + toast.promise(promise, { 115 + loading: "Sending test...", 116 + success: "Test sent", 117 + error: (error) => { 118 + if (error instanceof Error) { 119 + return error.message; 120 + } 121 + return "Failed to send test"; 122 + }, 123 + }); 124 + await promise; 125 + } catch (error) { 126 + console.error(error); 127 + } 128 + }); 129 + } 130 + 131 + return ( 132 + <Form {...form}> 133 + <form 134 + className={cn("grid gap-4", className)} 135 + onSubmit={form.handleSubmit(submitAction)} 136 + {...props} 137 + > 138 + <FormCardContent className="grid gap-4"> 139 + <FormField 140 + control={form.control} 141 + name="name" 142 + render={({ field }) => ( 143 + <FormItem> 144 + <FormLabel>Name</FormLabel> 145 + <FormControl> 146 + <Input placeholder="My Notifier" {...field} /> 147 + </FormControl> 148 + <FormMessage /> 149 + <FormDescription> 150 + Enter a descriptive name for your notifier. 151 + </FormDescription> 152 + </FormItem> 153 + )} 154 + /> 155 + <FormField 156 + control={form.control} 157 + name="data.chatId" 158 + render={({ field }) => ( 159 + <FormItem> 160 + <FormLabel>Telegram Chat ID</FormLabel> 161 + <FormControl> 162 + <Input placeholder="1234567890" {...field} /> 163 + </FormControl> 164 + <FormMessage /> 165 + <FormDescription> 166 + Enter the Telegram chat ID to send notifications to.{" "} 167 + <Link 168 + href="https://docs.openstatus.dev/reference/notification/#telegram" 169 + rel="noreferrer" 170 + target="_blank" 171 + > 172 + Learn more 173 + </Link> 174 + </FormDescription> 175 + </FormItem> 176 + )} 177 + /> 178 + <div> 179 + <Button 180 + variant="outline" 181 + size="sm" 182 + type="button" 183 + onClick={testAction} 184 + > 185 + Send Test 186 + </Button> 187 + </div> 188 + </FormCardContent> 189 + <FormCardSeparator /> 190 + <FormCardContent> 191 + <FormField 192 + control={form.control} 193 + name="monitors" 194 + render={({ field }) => ( 195 + <FormItem> 196 + <FormLabel>Monitors</FormLabel> 197 + <FormDescription> 198 + Select the monitors you want to notify. 199 + </FormDescription> 200 + <div className="grid gap-3"> 201 + <div className="flex items-center gap-2"> 202 + <FormControl> 203 + <Checkbox 204 + id="all" 205 + checked={field.value?.length === monitors.length} 206 + onCheckedChange={(checked) => { 207 + field.onChange( 208 + checked ? monitors.map((m) => m.id) : [], 209 + ); 210 + }} 211 + /> 212 + </FormControl> 213 + <Label htmlFor="all">Select all</Label> 214 + </div> 215 + {monitors.map((item) => ( 216 + <div key={item.id} className="flex items-center gap-2"> 217 + <FormControl> 218 + <Checkbox 219 + id={String(item.id)} 220 + checked={field.value?.includes(item.id)} 221 + onCheckedChange={(checked) => { 222 + const newValue = checked 223 + ? [...(field.value || []), item.id] 224 + : field.value?.filter((id) => id !== item.id); 225 + field.onChange(newValue); 226 + }} 227 + /> 228 + </FormControl> 229 + <Label htmlFor={String(item.id)}>{item.name}</Label> 230 + </div> 231 + ))} 232 + </div> 233 + <FormMessage /> 234 + </FormItem> 235 + )} 236 + /> 237 + </FormCardContent> 238 + </form> 239 + </Form> 240 + ); 241 + }
+1
apps/dashboard/src/components/forms/notifications/form.tsx
··· 31 31 "opsgenie", 32 32 "pagerduty", 33 33 "ntfy", 34 + "telegram", 34 35 ]), 35 36 data: z.record(z.string(), z.string()).or(z.string()), 36 37 monitors: z.array(z.number()),
+5 -1
apps/dashboard/src/components/forms/notifications/sheet.tsx
··· 63 63 data: 64 64 typeof defaultValues?.data === "string" 65 65 ? defaultValues?.data 66 - : defaultValues?.data?.[provider], 66 + : defaultValues?.data && 67 + typeof defaultValues.data === "object" && 68 + provider in defaultValues.data 69 + ? defaultValues.data[provider] 70 + : defaultValues?.data, 67 71 } 68 72 : undefined 69 73 }
+9 -1
apps/dashboard/src/data/notifications.client.ts
··· 5 5 import { FormPagerDuty } from "@/components/forms/notifications/form-pagerduty"; 6 6 import { FormSlack } from "@/components/forms/notifications/form-slack"; 7 7 import { FormSms } from "@/components/forms/notifications/form-sms"; 8 + import { FormTelegram } from "@/components/forms/notifications/form-telegram"; 8 9 import { FormWebhook } from "@/components/forms/notifications/form-webhook"; 9 - import { DiscordIcon } from "@openstatus/icons"; 10 + import { DiscordIcon, TelegramIcon } from "@openstatus/icons"; 10 11 import { OpsGenieIcon } from "@openstatus/icons"; 11 12 import { PagerDutyIcon } from "@openstatus/icons"; 12 13 import { SlackIcon } from "@openstatus/icons"; ··· 15 16 import { sendTest as sendTestOpsGenie } from "@openstatus/notification-opsgenie"; 16 17 import { sendTest as sendTestPagerDuty } from "@openstatus/notification-pagerduty"; 17 18 import { sendTestSlackMessage as sendTestSlack } from "@openstatus/notification-slack"; 19 + import { sendTest as sendTestTelegram } from "@openstatus/notification-telegram"; 18 20 import { sendTest as sendTestWebhook } from "@openstatus/notification-webhook"; 19 21 import { 20 22 BellIcon, ··· 103 105 label: "Ntfy", 104 106 form: FormNtfy, 105 107 sendTest: sendTestNtfy, 108 + }, 109 + telegram: { 110 + icon: TelegramIcon, 111 + label: "Telegram", 112 + form: FormTelegram, 113 + sendTest: sendTestTelegram, 106 114 }, 107 115 }; 108 116
+10
apps/docs/src/content/docs/reference/notification.mdx
··· 24 24 25 25 Require a phone number 26 26 27 + ### Telegram 28 + 29 + Requires to know the chat id. 30 + 31 + > This integration is fully functional but accessing the chat id is not smooth yet. It requires manual effort to access your chat id. **You can ask RawDataBot `@raw_info_bot` for your chat id.** 32 + 33 + The bot's id is `@openstatushq_bot` to make sure you are getting the correct notifications. 34 + 35 + Please 36 + 27 37 ### Webhook 28 38 29 39 - URL (require)
apps/web/public/assets/changelog/telegram-bot-integration.png

This is a binary file and will not be displayed.

+10
apps/web/src/content/pages/changelog/telegram-bot-integration.mdx
··· 1 + --- 2 + title: "Telegram Bot Integration" 3 + description: "" 4 + image: "/assets/changelog/telegram-bot-integration.png" 5 + publishedAt: "2025-12-09" 6 + author: "openstatus" 7 + category: "notifications" 8 + --- 9 + 10 + You can connect your uptime notifications to the `@openstatushq_bot` telegram bot.
+1 -1
apps/web/src/content/pages/product/uptime-monitoring.mdx
··· 87 87 88 88 We support: 89 89 90 - - Socials: Slack, Discord 90 + - Socials: Slack, Discord, Telegram Bot 91 91 - Direct: Email, SMS 92 92 - Incident Management: OpsGenie, PagerDuty 93 93 - Custom: Webhook, Ntfy
+1
apps/workflows/Dockerfile
··· 40 40 --mount=type=bind,target=packages/notifications/opsgenie/package.json,source=packages/notifications/opsgenie/package.json \ 41 41 --mount=type=bind,target=packages/notifications/pagerduty/package.json,source=packages/notifications/pagerduty/package.json \ 42 42 --mount=type=bind,target=packages/notifications/slack/package.json,source=packages/notifications/slack/package.json \ 43 + --mount=type=bind,target=packages/notifications/telegram/package.json,source=packages/notifications/telegram/package.json \ 43 44 --mount=type=bind,target=packages/notifications/twillio-sms/package.json,source=packages/notifications/twillio-sms/package.json \ 44 45 --mount=type=bind,target=packages/notifications/webhook/package.json,source=packages/notifications/webhook/package.json \ 45 46 --mount=type=bind,target=packages/regions/package.json,source=packages/regions/package.json \
+38 -35
apps/workflows/dofigen.lock
··· 12 12 - /packages/error 13 13 - /packages/tracker 14 14 builders: 15 + build: 16 + fromImage: 17 + path: oven/bun 18 + digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 19 + label: 20 + org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 21 + org.opencontainers.image.stage: build 22 + org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 23 + workdir: /app/apps/workflows 24 + env: 25 + NODE_ENV: production 26 + copy: 27 + - paths: 28 + - . 29 + target: /app/ 30 + - fromBuilder: install 31 + paths: 32 + - /app/node_modules 33 + target: /app/node_modules 34 + run: 35 + - bun build --compile --target bun --sourcemap --format=cjs src/index.ts --outfile=app --external '@libsql/*' --external libsql 15 36 install: 16 37 fromImage: 17 38 path: oven/bun 18 39 digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 19 40 label: 20 - org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 21 41 org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 22 42 org.opencontainers.image.stage: install 43 + org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 23 44 workdir: /app/ 24 45 run: 25 46 - bun install --production --frozen-lockfile --verbose ··· 50 71 source: packages/notifications/pagerduty/package.json 51 72 - target: packages/notifications/slack/package.json 52 73 source: packages/notifications/slack/package.json 74 + - target: packages/notifications/telegram/package.json 75 + source: packages/notifications/telegram/package.json 53 76 - target: packages/notifications/twillio-sms/package.json 54 77 source: packages/notifications/twillio-sms/package.json 55 78 - target: packages/notifications/webhook/package.json ··· 71 94 path: oven/bun 72 95 digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 73 96 label: 74 - org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 75 97 org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 98 + org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 76 99 workdir: /app/apps/workflows 77 100 copy: 78 101 - paths: ··· 85 108 path: debian 86 109 digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 87 110 label: 88 - org.opencontainers.image.base.digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 89 111 org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 112 + org.opencontainers.image.base.digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 90 113 run: 91 114 - apt update && apt install -y ca-certificates && update-ca-certificates 92 115 libsql: ··· 94 117 path: oven/bun 95 118 digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 96 119 label: 97 - org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 98 120 org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 121 + org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 99 122 workdir: /app/ 100 123 copy: 101 124 - fromBuilder: docker ··· 104 127 target: /app/package.json 105 128 run: 106 129 - bun install 107 - build: 108 - fromImage: 109 - path: oven/bun 110 - digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 111 - label: 112 - org.opencontainers.image.base.name: docker.io/oven/bun:1.3.3 113 - org.opencontainers.image.base.digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 114 - org.opencontainers.image.stage: build 115 - workdir: /app/apps/workflows 116 - env: 117 - NODE_ENV: production 118 - copy: 119 - - paths: 120 - - . 121 - target: /app/ 122 - - fromBuilder: install 123 - paths: 124 - - /app/node_modules 125 - target: /app/node_modules 126 - run: 127 - - bun build --compile --target bun --sourcemap --format=cjs src/index.ts --outfile=app --external '@libsql/*' --external libsql 128 130 fromImage: 129 131 path: debian 130 132 digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 131 133 label: 132 - io.dofigen.version: 2.5.1 134 + org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus 135 + org.opencontainers.image.vendor: OpenStatus 136 + org.opencontainers.image.title: OpenStatus Workflows 133 137 org.opencontainers.image.base.name: docker.io/debian:bullseye-slim 134 138 org.opencontainers.image.base.digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 135 - org.opencontainers.image.title: OpenStatus Workflows 136 - org.opencontainers.image.vendor: OpenStatus 139 + org.opencontainers.image.authors: OpenStatus Team 140 + io.dofigen.version: 2.5.1 137 141 org.opencontainers.image.description: Background job processing and probe scheduling for OpenStatus 138 - org.opencontainers.image.authors: OpenStatus Team 139 - org.opencontainers.image.source: https://github.com/openstatusHQ/openstatus 140 142 workdir: /app/ 141 143 copy: 142 144 - fromBuilder: build ··· 162 164 - port: 3000 163 165 images: 164 166 docker.io: 165 - library: 166 - debian: 167 - bullseye-slim: 168 - digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 169 167 oven: 170 168 bun: 171 169 1.3.3: 172 170 digest: sha256:fbf8e67e9d3b806c86be7a2f2e9bae801f2d9212a21db4dcf8cc9889f5a3c9c4 171 + library: 172 + debian: 173 + bullseye-slim: 174 + digest: sha256:530a3348fc4b5734ffe1a137ddbcee6850154285251b53c3425c386ea8fac77b 173 175 resources: 174 176 dofigen.yml: 175 - hash: 3544a43f3fd756222397a837650af396178e9fde624ba9b1b32bc1943324cc02 177 + hash: 297382ef47be04c67f610380fadff8e2b9333ab28594ff6e0512b75a23d4c3b0 176 178 content: | 177 179 ignore: 178 180 - node_modules ··· 205 207 - packages/notifications/opsgenie/package.json 206 208 - packages/notifications/pagerduty/package.json 207 209 - packages/notifications/slack/package.json 210 + - packages/notifications/telegram/package.json 208 211 - packages/notifications/twillio-sms/package.json 209 212 - packages/notifications/webhook/package.json 210 213 - packages/regions/package.json
+1
apps/workflows/dofigen.yml
··· 29 29 - packages/notifications/opsgenie/package.json 30 30 - packages/notifications/pagerduty/package.json 31 31 - packages/notifications/slack/package.json 32 + - packages/notifications/telegram/package.json 32 33 - packages/notifications/twillio-sms/package.json 33 34 - packages/notifications/webhook/package.json 34 35 - packages/regions/package.json
+1
apps/workflows/package.json
··· 19 19 "@openstatus/notification-opsgenie": "workspace:*", 20 20 "@openstatus/notification-pagerduty": "workspace:*", 21 21 "@openstatus/notification-slack": "workspace:*", 22 + "@openstatus/notification-telegram": "workspace:*", 22 23 "@openstatus/notification-twillio-sms": "workspace:*", 23 24 "@openstatus/notification-webhook": "workspace:*", 24 25 "@openstatus/regions": "workspace:*",
+21 -14
apps/workflows/src/checker/utils.ts
··· 3 3 Notification, 4 4 NotificationProvider, 5 5 } from "@openstatus/db/src/schema"; 6 + import type { Region } from "@openstatus/db/src/schema/constants"; 6 7 import { 7 8 sendAlert as sendDiscordAlert, 8 9 sendDegraded as sendDiscordDegraded, ··· 19 20 sendRecovery as sendNtfyRecovery, 20 21 } from "@openstatus/notification-ntfy"; 21 22 import { 23 + sendAlert as sendOpsGenieAlert, 24 + sendDegraded as sendOpsGenieDegraded, 25 + sendRecovery as sendOpsGenieRecovery, 26 + } from "@openstatus/notification-opsgenie"; 27 + import { 28 + sendDegraded as sendPagerDutyDegraded, 29 + sendRecovery as sendPagerDutyRecovery, 30 + sendAlert as sendPagerdutyAlert, 31 + } from "@openstatus/notification-pagerduty"; 32 + import { 22 33 sendAlert as sendSlackAlert, 23 34 sendDegraded as sendSlackDegraded, 24 35 sendRecovery as sendSlackRecovery, 25 36 } from "@openstatus/notification-slack"; 26 37 import { 38 + sendAlert as sendTelegramAlert, 39 + sendDegraded as sendTelegramDegraded, 40 + sendRecovery as sendTelegramRecovery, 41 + } from "@openstatus/notification-telegram"; 42 + import { 27 43 sendAlert as sendSmsAlert, 28 44 sendDegraded as sendSmsDegraded, 29 45 sendRecovery as sendSmsRecovery, 30 46 } from "@openstatus/notification-twillio-sms"; 31 - 32 - import { 33 - sendDegraded as sendPagerDutyDegraded, 34 - sendRecovery as sendPagerDutyRecovery, 35 - sendAlert as sendPagerdutyAlert, 36 - } from "@openstatus/notification-pagerduty"; 37 - 38 - import type { Region } from "@openstatus/db/src/schema/constants"; 39 - import { 40 - sendAlert as sendOpsGenieAlert, 41 - sendDegraded as sendOpsGenieDegraded, 42 - sendRecovery as sendOpsGenieRecovery, 43 - } from "@openstatus/notification-opsgenie"; 44 - 45 47 import { 46 48 sendAlert as sendWebhookAlert, 47 49 sendDegraded as sendWebhookDegraded, ··· 114 116 sendAlert: sendWebhookAlert, 115 117 sendRecovery: sendWebhookRecovery, 116 118 sendDegraded: sendWebhookDegraded, 119 + }, 120 + telegram: { 121 + sendAlert: sendTelegramAlert, 122 + sendRecovery: sendTelegramRecovery, 123 + sendDegraded: sendTelegramDegraded, 117 124 }, 118 125 } satisfies Record<NotificationProvider, Notif>;
+1
packages/api/package.json
··· 13 13 "@openstatus/db": "workspace:*", 14 14 "@openstatus/emails": "workspace:*", 15 15 "@openstatus/error": "workspace:*", 16 + "@openstatus/notification-telegram": "workspace:*", 16 17 "@openstatus/regions": "workspace:*", 17 18 "@openstatus/tinybird": "workspace:*", 18 19 "@openstatus/upstash": "workspace:*",
+43 -6
packages/api/src/router/notification.ts
··· 11 11 notificationsToMonitors, 12 12 selectMonitorSchema, 13 13 selectNotificationSchema, 14 + telegramDataSchema, 14 15 } from "@openstatus/db/src/schema"; 15 16 16 17 import { Events } from "@openstatus/analytics"; 17 18 import { SchemaError } from "@openstatus/error"; 19 + import { sendTest as sendTelegramTest } from "@openstatus/notification-telegram"; 18 20 import { createTRPCRouter, protectedProcedure } from "../trpc"; 19 21 20 22 export const notificationRouter = createTRPCRouter({ ··· 282 284 }), 283 285 ) 284 286 .mutation(async (opts) => { 287 + console.log(opts.input); 285 288 const whereCondition: SQL[] = [ 286 289 eq(notification.id, opts.input.id), 287 290 eq(notification.workspaceId, opts.ctx.workspace.id), ··· 326 329 and(eq(notificationsToMonitors.notificationId, opts.input.id)), 327 330 ); 328 331 329 - await tx.insert(notificationsToMonitors).values( 330 - opts.input.monitors.map((monitorId) => ({ 331 - notificationId: opts.input.id, 332 - monitorId, 333 - })), 334 - ); 332 + if (opts.input.monitors.length) { 333 + await tx.insert(notificationsToMonitors).values( 334 + opts.input.monitors.map((monitorId) => ({ 335 + notificationId: opts.input.id, 336 + monitorId, 337 + })), 338 + ); 339 + } 335 340 }); 336 341 }), 337 342 ··· 444 449 ), 445 450 ) 446 451 .run(); 452 + }), 453 + 454 + sendTest: protectedProcedure 455 + .input( 456 + z.object({ 457 + provider: z.enum(notificationProvider), 458 + data: z.record( 459 + z.enum(notificationProvider), 460 + z.record(z.string(), z.string()).or(z.string()), 461 + ), 462 + }), 463 + ) 464 + .mutation(async (opts) => { 465 + if (opts.input.provider === "telegram") { 466 + const _data = telegramDataSchema.safeParse(opts.input.data); 467 + if (!_data.success) { 468 + throw new TRPCError({ 469 + code: "BAD_REQUEST", 470 + message: SchemaError.fromZod(_data.error, opts.input).message, 471 + }); 472 + } 473 + await sendTelegramTest({ 474 + chatId: _data.data.telegram.chatId, 475 + }); 476 + 477 + return; 478 + } 479 + 480 + throw new TRPCError({ 481 + code: "BAD_REQUEST", 482 + message: "Invalid provider", 483 + }); 447 484 }), 448 485 });
+1 -1
packages/api/src/router/page.ts
··· 40 40 require("../test/preload"); 41 41 } 42 42 43 - const redis = Redis.fromEnv(); 43 + const _redis = Redis.fromEnv(); 44 44 45 45 // Helper functions to reuse Vercel API logic 46 46 async function addDomainToVercel(domain: string) {
+1
packages/db/src/schema/notifications/constants.ts
··· 7 7 "slack", 8 8 "sms", 9 9 "webhook", 10 + "telegram", 10 11 ] as const;
+4
packages/db/src/schema/notifications/validation.ts
··· 62 62 region: z.enum(["us", "eu"]), 63 63 }), 64 64 }); 65 + export const telegramDataSchema = z.object({ 66 + telegram: z.object({ chatId: z.string() }), 67 + }); 65 68 66 69 export const NotificationDataSchema = z.union([ 67 70 emailDataSchema, ··· 72 75 opsgenieDataSchema, 73 76 ntfyDataSchema, 74 77 webhookDataSchema, 78 + telegramDataSchema, 75 79 ]); 76 80 77 81 export const InsertNotificationWithDataSchema = z.discriminatedUnion(
+1
packages/icons/src/index.tsx
··· 7 7 export * from "./fly"; 8 8 export * from "./railway"; 9 9 export * from "./koyeb"; 10 + export * from "./telegram";
+15
packages/icons/src/telegram.tsx
··· 1 + export function TelegramIcon(props: React.ComponentProps<"svg">) { 2 + return ( 3 + <svg 4 + role="img" 5 + viewBox="0 0 24 24" 6 + xmlns="http://www.w3.org/2000/svg" 7 + stroke="currentColor" 8 + fill="currentColor" 9 + {...props} 10 + > 11 + <title>Telegram</title> 12 + <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /> 13 + </svg> 14 + ); 15 + }
+1
packages/notifications/discord/package.json
··· 9 9 "devDependencies": { 10 10 "@openstatus/tsconfig": "workspace:*", 11 11 "@types/node": "22.10.2", 12 + "bun-types": "1.3.1", 12 13 "typescript": "5.7.2" 13 14 } 14 15 }
+166
packages/notifications/discord/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 { 4 + sendAlert, 5 + sendDegraded, 6 + sendRecovery, 7 + sendTestDiscordMessage, 8 + } from "./index"; 9 + 10 + describe("Discord Notifications", () => { 11 + // biome-ignore lint/suspicious/noExplicitAny: <explanation> 12 + let fetchMock: any = undefined; 13 + 14 + beforeEach(() => { 15 + fetchMock = spyOn(global, "fetch").mockImplementation(() => 16 + Promise.resolve(new Response(null, { status: 200 })), 17 + ); 18 + }); 19 + 20 + afterEach(() => { 21 + if (fetchMock) { 22 + fetchMock.mockRestore(); 23 + } 24 + }); 25 + 26 + const createMockMonitor = () => ({ 27 + id: "monitor-1", 28 + name: "API Health Check", 29 + url: "https://api.example.com/health", 30 + jobType: "http" as const, 31 + periodicity: "5m" as const, 32 + status: "active" as const, 33 + createdAt: new Date(), 34 + updatedAt: new Date(), 35 + region: "iad", 36 + }); 37 + 38 + const createMockNotification = () => ({ 39 + id: 1, 40 + name: "Discord Notification", 41 + provider: "discord", 42 + workspaceId: 1, 43 + createdAt: new Date(), 44 + updatedAt: new Date(), 45 + data: '{"discord":"https://discord.com/api/webhooks/123456789/abcdefgh"}', 46 + }); 47 + 48 + test("Send Alert", async () => { 49 + const monitor = createMockMonitor(); 50 + const notification = selectNotificationSchema.parse( 51 + createMockNotification(), 52 + ); 53 + 54 + await sendAlert({ 55 + // @ts-expect-error 56 + monitor, 57 + notification, 58 + statusCode: 500, 59 + message: "Something went wrong", 60 + cronTimestamp: Date.now(), 61 + }); 62 + 63 + expect(fetchMock).toHaveBeenCalledTimes(1); 64 + const callArgs = fetchMock.mock.calls[0]; 65 + expect(callArgs[0]).toBe( 66 + "https://discord.com/api/webhooks/123456789/abcdefgh", 67 + ); 68 + expect(callArgs[1].method).toBe("POST"); 69 + expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); 70 + 71 + const body = JSON.parse(callArgs[1].body); 72 + expect(body.content).toContain("🚨 Alert"); 73 + expect(body.content).toContain("API Health Check"); 74 + expect(body.content).toContain("Something went wrong"); 75 + expect(body.username).toBe("OpenStatus Notifications"); 76 + expect(body.avatar_url).toBeDefined(); 77 + }); 78 + 79 + test("Send Recovery", async () => { 80 + const monitor = createMockMonitor(); 81 + const notification = selectNotificationSchema.parse( 82 + createMockNotification(), 83 + ); 84 + 85 + await sendRecovery({ 86 + // @ts-expect-error 87 + monitor, 88 + notification, 89 + statusCode: 200, 90 + message: "Service recovered", 91 + cronTimestamp: Date.now(), 92 + }); 93 + 94 + expect(fetchMock).toHaveBeenCalledTimes(1); 95 + const callArgs = fetchMock.mock.calls[0]; 96 + const body = JSON.parse(callArgs[1].body); 97 + expect(body.content).toContain("✅ Recovered"); 98 + expect(body.content).toContain("API Health Check"); 99 + }); 100 + 101 + test("Send Degraded", async () => { 102 + const monitor = createMockMonitor(); 103 + const notification = selectNotificationSchema.parse( 104 + createMockNotification(), 105 + ); 106 + 107 + await sendDegraded({ 108 + // @ts-expect-error 109 + monitor, 110 + notification, 111 + statusCode: 503, 112 + message: "Service degraded", 113 + cronTimestamp: Date.now(), 114 + }); 115 + 116 + expect(fetchMock).toHaveBeenCalledTimes(1); 117 + const callArgs = fetchMock.mock.calls[0]; 118 + const body = JSON.parse(callArgs[1].body); 119 + expect(body.content).toContain("⚠️ Degraded"); 120 + expect(body.content).toContain("API Health Check"); 121 + }); 122 + 123 + test("Send Test Discord Message", async () => { 124 + const webhookUrl = "https://discord.com/api/webhooks/123456789/abcdefgh"; 125 + 126 + const result = await sendTestDiscordMessage(webhookUrl); 127 + 128 + expect(result).toBe(true); 129 + expect(fetchMock).toHaveBeenCalledTimes(1); 130 + const callArgs = fetchMock.mock.calls[0]; 131 + expect(callArgs[0]).toBe(webhookUrl); 132 + const body = JSON.parse(callArgs[1].body); 133 + expect(body.content).toContain("🧪 Test"); 134 + expect(body.content).toContain("OpenStatus"); 135 + }); 136 + 137 + test("Send Test Discord Message with empty webhookUrl", async () => { 138 + const result = await sendTestDiscordMessage(""); 139 + 140 + expect(result).toBe(false); 141 + expect(fetchMock).not.toHaveBeenCalled(); 142 + }); 143 + 144 + test("Handle fetch error gracefully", async () => { 145 + fetchMock.mockImplementation(() => 146 + Promise.reject(new Error("Network error")), 147 + ); 148 + 149 + const monitor = createMockMonitor(); 150 + const notification = selectNotificationSchema.parse( 151 + createMockNotification(), 152 + ); 153 + 154 + // Should not throw - function catches errors internally 155 + await sendAlert({ 156 + // @ts-expect-error 157 + monitor, 158 + notification, 159 + statusCode: 500, 160 + message: "Error", 161 + cronTimestamp: Date.now(), 162 + }); 163 + 164 + expect(fetchMock).toHaveBeenCalledTimes(1); 165 + }); 166 + });
+127 -80
packages/notifications/email/src/index.test.ts
··· 1 - import { 2 - describe, 3 - mock, 4 - // jest, 5 - test, 6 - } from "bun:test"; 7 - 1 + import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; 8 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 9 3 import { sendAlert, sendDegraded, sendRecovery } from "./index"; 4 + 5 + const sendMonitorAlertMock = mock(async (_callArgs) => {}); 10 6 11 7 mock.module("@openstatus/emails/src/client", () => ({ 12 - EmailClient: mock(() => ({ 13 - sendMonitorAlert: mock(async () => {}), 14 - })), 8 + EmailClient: mock((_args: { apiKey: string }) => { 9 + return { 10 + sendMonitorAlert: sendMonitorAlertMock, 11 + }; 12 + }), 15 13 })); 16 14 17 15 describe("Email Notifications", () => { 18 - test("Send degraded", async () => { 19 - const monitor = { 20 - id: "monitor-1", 21 - name: "API Health Check", 22 - url: "https://api.example.com/health", 23 - jobType: "http" as const, 24 - periodicity: "5m" as const, 25 - status: "active" as const, // or "down", "degraded" 26 - createdAt: new Date(), 27 - updatedAt: new Date(), 28 - region: "us-east-1", 29 - }; 16 + beforeEach(() => { 17 + sendMonitorAlertMock.mockClear(); 18 + }); 30 19 31 - const a = { 32 - id: 1, 33 - name: "email Notification", 34 - provider: "email", 35 - workspaceId: 1, 36 - createdAt: new Date(), 37 - updatedAt: new Date(), 38 - data: '{"email":"ping@openstatus.dev"}', 39 - }; 20 + afterEach(() => { 21 + sendMonitorAlertMock.mockClear(); 22 + }); 40 23 41 - const n = selectNotificationSchema.parse(a); 42 - await sendDegraded({ 24 + const createMockMonitor = () => ({ 25 + id: "monitor-1", 26 + name: "API Health Check", 27 + url: "https://api.example.com/health", 28 + jobType: "http" as const, 29 + periodicity: "5m" as const, 30 + status: "active" as const, 31 + createdAt: new Date(), 32 + updatedAt: new Date(), 33 + region: "iad", 34 + }); 35 + 36 + const createMockNotification = () => ({ 37 + id: 1, 38 + name: "Email Notification", 39 + provider: "email", 40 + workspaceId: 1, 41 + createdAt: new Date(), 42 + updatedAt: new Date(), 43 + data: '{"email":"ping@openstatus.dev"}', 44 + }); 45 + 46 + test("Send Alert", async () => { 47 + const monitor = createMockMonitor(); 48 + const notification = selectNotificationSchema.parse( 49 + createMockNotification(), 50 + ); 51 + 52 + await sendAlert({ 43 53 // @ts-expect-error 44 54 monitor, 45 - notification: n, 55 + notification, 46 56 statusCode: 500, 47 57 message: "Something went wrong", 58 + latency: 1500, 59 + region: "iad", 48 60 cronTimestamp: Date.now(), 49 61 }); 62 + 63 + expect(sendMonitorAlertMock).toHaveBeenCalledTimes(1); 64 + const callArgs = sendMonitorAlertMock.mock.calls[0][0]; 65 + expect(callArgs.name).toBe("API Health Check"); 66 + expect(callArgs.type).toBe("alert"); 67 + expect(callArgs.to).toBe("ping@openstatus.dev"); 68 + expect(callArgs.url).toBe("https://api.example.com/health"); 69 + expect(callArgs.status).toBe("500"); 70 + expect(callArgs.latency).toBe("1500ms"); 71 + expect(callArgs.message).toBe("Something went wrong"); 72 + expect(callArgs.timestamp).toBeDefined(); 50 73 }); 51 74 52 - test("Send Recovered", async () => { 53 - const monitor = { 54 - id: "monitor-1", 55 - name: "API Health Check", 56 - url: "https://api.example.com/health", 57 - jobType: "http" as const, 58 - periodicity: "5m" as const, 59 - status: "active" as const, // or "down", "degraded" 60 - createdAt: new Date(), 61 - updatedAt: new Date(), 62 - region: "us-east-1", 63 - }; 75 + test("Send Alert without optional fields", async () => { 76 + const monitor = createMockMonitor(); 77 + const notification = selectNotificationSchema.parse( 78 + createMockNotification(), 79 + ); 80 + 81 + await sendAlert({ 82 + // @ts-expect-error 83 + monitor, 84 + notification, 85 + cronTimestamp: Date.now(), 86 + }); 87 + 88 + expect(sendMonitorAlertMock).toHaveBeenCalledTimes(1); 89 + const callArgs = sendMonitorAlertMock.mock.calls[0][0]; 90 + expect(callArgs.status).toBeUndefined(); 91 + expect(callArgs.latency).toBe("N/A"); 92 + expect(callArgs.region).toBe("N/A"); 93 + }); 64 94 65 - const a = { 66 - id: 1, 67 - name: "Email Notification", 68 - provider: "email", 69 - workspaceId: 1, 70 - createdAt: new Date(), 71 - updatedAt: new Date(), 72 - data: '{"email":"ping@openstatus.dev"}', 73 - }; 95 + test("Send Recovery", async () => { 96 + const monitor = createMockMonitor(); 97 + const notification = selectNotificationSchema.parse( 98 + createMockNotification(), 99 + ); 74 100 75 - const n = selectNotificationSchema.parse(a); 76 101 await sendRecovery({ 77 102 // @ts-expect-error 78 103 monitor, 79 - notification: n, 80 - statusCode: 500, 81 - message: "Something went wrong", 104 + notification, 105 + statusCode: 200, 106 + latency: 100, 107 + region: "ams", 108 + cronTimestamp: Date.now(), 109 + }); 110 + 111 + expect(sendMonitorAlertMock).toHaveBeenCalledTimes(1); 112 + const callArgs = sendMonitorAlertMock.mock.calls[0][0]; 113 + expect(callArgs.type).toBe("recovery"); 114 + expect(callArgs.name).toBe("API Health Check"); 115 + expect(callArgs.to).toBe("ping@openstatus.dev"); 116 + expect(callArgs.status).toBe("200"); 117 + expect(callArgs.latency).toBe("100ms"); 118 + }); 119 + 120 + test("Send Degraded", async () => { 121 + const monitor = createMockMonitor(); 122 + const notification = selectNotificationSchema.parse( 123 + createMockNotification(), 124 + ); 125 + 126 + await sendDegraded({ 127 + // @ts-expect-error 128 + monitor, 129 + notification, 130 + statusCode: 503, 131 + latency: 2000, 132 + region: "lax", 82 133 cronTimestamp: Date.now(), 83 134 }); 135 + 136 + expect(sendMonitorAlertMock).toHaveBeenCalledTimes(1); 137 + const callArgs = sendMonitorAlertMock.mock.calls[0][0]; 138 + expect(callArgs.type).toBe("degraded"); 139 + expect(callArgs.name).toBe("API Health Check"); 140 + expect(callArgs.status).toBe("503"); 141 + expect(callArgs.latency).toBe("2000ms"); 84 142 }); 85 143 86 - test("Send Alert", async () => { 87 - const monitor = { 88 - id: "monitor-1", 89 - name: "API Health Check", 90 - url: "https://api.example.com/health", 91 - jobType: "http" as const, 92 - periodicity: "5m" as const, 93 - status: "active" as const, // or "down", "degraded" 94 - createdAt: new Date(), 95 - updatedAt: new Date(), 96 - region: "us-east-1", 97 - }; 98 - const a = { 144 + test("Handles invalid notification data gracefully", async () => { 145 + const monitor = createMockMonitor(); 146 + const invalidNotification = selectNotificationSchema.parse({ 99 147 id: 1, 100 - name: "PagerDuty Notification", 148 + name: "Email Notification", 101 149 provider: "email", 102 150 workspaceId: 1, 103 151 createdAt: new Date(), 104 152 updatedAt: new Date(), 105 - data: '{"email":"ping@openstatus.dev"}', 106 - }; 107 - 108 - const n = selectNotificationSchema.parse(a); 153 + data: '{"invalid":"data"}', 154 + }); 109 155 110 156 await sendAlert({ 111 157 // @ts-expect-error 112 158 monitor, 113 - notification: n, 114 - statusCode: 500, 115 - message: "Something went wrong", 159 + notification: invalidNotification, 116 160 cronTimestamp: Date.now(), 117 161 }); 162 + 163 + // Should not call sendMonitorAlert when data is invalid 164 + expect(sendMonitorAlertMock).not.toHaveBeenCalled(); 118 165 }); 119 166 });
+1
packages/notifications/ntfy/package.json
··· 9 9 "devDependencies": { 10 10 "@openstatus/tsconfig": "workspace:*", 11 11 "@types/node": "22.10.2", 12 + "bun-types": "1.3.1", 12 13 "typescript": "5.7.2" 13 14 } 14 15 }
+271
packages/notifications/ntfy/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("Ntfy Notifications", () => { 6 + // biome-ignore lint/suspicious/noExplicitAny: <explanation> 7 + let fetchMock: any = undefined; 8 + 9 + beforeEach(() => { 10 + fetchMock = spyOn(global, "fetch").mockImplementation(() => 11 + Promise.resolve(new Response(null, { status: 200 })), 12 + ); 13 + }); 14 + 15 + afterEach(() => { 16 + if (fetchMock) { 17 + fetchMock.mockRestore(); 18 + } 19 + }); 20 + 21 + const createMockMonitor = () => ({ 22 + id: "monitor-1", 23 + name: "API Health Check", 24 + url: "https://api.example.com/health", 25 + jobType: "http" as const, 26 + periodicity: "5m" as const, 27 + status: "active" as const, 28 + createdAt: new Date(), 29 + updatedAt: new Date(), 30 + region: "us-east-1", 31 + }); 32 + 33 + const createMockNotification = (withToken = false, withServerUrl = false) => { 34 + const data: { 35 + ntfy: { topic: string; token?: string; serverUrl?: string }; 36 + } = { 37 + ntfy: { 38 + topic: "my-topic", 39 + }, 40 + }; 41 + if (withToken) { 42 + data.ntfy.token = "test-token-123"; 43 + } 44 + if (withServerUrl) { 45 + data.ntfy.serverUrl = "https://ntfy.example.com"; 46 + } 47 + return { 48 + id: 1, 49 + name: "Ntfy Notification", 50 + provider: "ntfy", 51 + workspaceId: 1, 52 + createdAt: new Date(), 53 + updatedAt: new Date(), 54 + data: JSON.stringify(data), 55 + }; 56 + }; 57 + 58 + test("Send Alert with default server", async () => { 59 + const monitor = createMockMonitor(); 60 + const notification = selectNotificationSchema.parse( 61 + createMockNotification(), 62 + ); 63 + 64 + await sendAlert({ 65 + // @ts-expect-error 66 + monitor, 67 + notification, 68 + statusCode: 500, 69 + message: "Something went wrong", 70 + cronTimestamp: Date.now(), 71 + }); 72 + 73 + expect(fetchMock).toHaveBeenCalledTimes(1); 74 + const callArgs = fetchMock.mock.calls[0]; 75 + expect(callArgs[0]).toBe("https://ntfy.sh/my-topic"); 76 + expect(callArgs[1].method).toBe("post"); 77 + expect(callArgs[1].body).toContain("API Health Check"); 78 + expect(callArgs[1].body).toContain("status code 500"); 79 + expect(callArgs[1].headers).not.toHaveProperty("Authorization"); 80 + }); 81 + 82 + test("Send Alert with custom server", async () => { 83 + const monitor = createMockMonitor(); 84 + const notification = selectNotificationSchema.parse( 85 + createMockNotification(false, true), 86 + ); 87 + 88 + await sendAlert({ 89 + // @ts-expect-error 90 + monitor, 91 + notification, 92 + statusCode: 500, 93 + message: "Error", 94 + cronTimestamp: Date.now(), 95 + }); 96 + 97 + expect(fetchMock).toHaveBeenCalledTimes(1); 98 + const callArgs = fetchMock.mock.calls[0]; 99 + expect(callArgs[0]).toBe("https://ntfy.example.com/my-topic"); 100 + }); 101 + 102 + test("Send Alert with token", async () => { 103 + const monitor = createMockMonitor(); 104 + const notification = selectNotificationSchema.parse( 105 + createMockNotification(true), 106 + ); 107 + 108 + await sendAlert({ 109 + // @ts-expect-error 110 + monitor, 111 + notification, 112 + statusCode: 500, 113 + message: "Error", 114 + cronTimestamp: Date.now(), 115 + }); 116 + 117 + expect(fetchMock).toHaveBeenCalledTimes(1); 118 + const callArgs = fetchMock.mock.calls[0]; 119 + expect(callArgs[1].headers.Authorization).toBe("Bearer test-token-123"); 120 + }); 121 + 122 + test("Send Alert without statusCode", async () => { 123 + const monitor = createMockMonitor(); 124 + const notification = selectNotificationSchema.parse( 125 + createMockNotification(), 126 + ); 127 + 128 + await sendAlert({ 129 + // @ts-expect-error 130 + monitor, 131 + notification, 132 + message: "Connection timeout", 133 + cronTimestamp: Date.now(), 134 + }); 135 + 136 + expect(fetchMock).toHaveBeenCalledTimes(1); 137 + const callArgs = fetchMock.mock.calls[0]; 138 + expect(callArgs[1].body).toContain("error: Connection timeout"); 139 + }); 140 + 141 + test("Send Recovery", async () => { 142 + const monitor = createMockMonitor(); 143 + const notification = selectNotificationSchema.parse( 144 + createMockNotification(), 145 + ); 146 + 147 + await sendRecovery({ 148 + // @ts-expect-error 149 + monitor, 150 + notification, 151 + statusCode: 200, 152 + message: "Service recovered", 153 + cronTimestamp: Date.now(), 154 + }); 155 + 156 + expect(fetchMock).toHaveBeenCalledTimes(1); 157 + const callArgs = fetchMock.mock.calls[0]; 158 + expect(callArgs[1].body).toContain("is up again"); 159 + }); 160 + 161 + test("Send Recovery with token", async () => { 162 + const monitor = createMockMonitor(); 163 + const notification = selectNotificationSchema.parse( 164 + createMockNotification(true), 165 + ); 166 + 167 + await sendRecovery({ 168 + // @ts-expect-error 169 + monitor, 170 + notification, 171 + cronTimestamp: Date.now(), 172 + }); 173 + 174 + expect(fetchMock).toHaveBeenCalledTimes(1); 175 + const callArgs = fetchMock.mock.calls[0]; 176 + expect(callArgs[1].headers.Authorization).toBe("Bearer test-token-123"); 177 + }); 178 + 179 + test("Send Degraded", async () => { 180 + const monitor = createMockMonitor(); 181 + const notification = selectNotificationSchema.parse( 182 + createMockNotification(), 183 + ); 184 + 185 + await sendDegraded({ 186 + // @ts-expect-error 187 + monitor, 188 + notification, 189 + statusCode: 503, 190 + message: "Service degraded", 191 + cronTimestamp: Date.now(), 192 + }); 193 + 194 + expect(fetchMock).toHaveBeenCalledTimes(1); 195 + const callArgs = fetchMock.mock.calls[0]; 196 + expect(callArgs[1].body).toContain("is degraded"); 197 + }); 198 + 199 + test("Send Test with default server", async () => { 200 + const result = await sendTest({ 201 + topic: "test-topic", 202 + }); 203 + 204 + expect(result).toBe(true); 205 + expect(fetchMock).toHaveBeenCalledTimes(1); 206 + const callArgs = fetchMock.mock.calls[0]; 207 + expect(callArgs[0]).toBe("https://ntfy.sh/test-topic"); 208 + expect(callArgs[1].body).toBe("This is a test message from OpenStatus"); 209 + expect(callArgs[1].headers).not.toHaveProperty("Authorization"); 210 + }); 211 + 212 + test("Send Test with custom server", async () => { 213 + const result = await sendTest({ 214 + topic: "test-topic", 215 + serverUrl: "https://ntfy.example.com", 216 + }); 217 + 218 + expect(result).toBe(true); 219 + expect(fetchMock).toHaveBeenCalledTimes(1); 220 + const callArgs = fetchMock.mock.calls[0]; 221 + expect(callArgs[0]).toBe("https://ntfy.example.com/test-topic"); 222 + }); 223 + 224 + test("Send Test with token", async () => { 225 + const result = await sendTest({ 226 + topic: "test-topic", 227 + token: "test-token", 228 + }); 229 + 230 + expect(result).toBe(true); 231 + expect(fetchMock).toHaveBeenCalledTimes(1); 232 + const callArgs = fetchMock.mock.calls[0]; 233 + expect(callArgs[1].headers.Authorization).toBe("Bearer test-token"); 234 + }); 235 + 236 + test("Handle fetch error gracefully", async () => { 237 + fetchMock.mockImplementation(() => 238 + Promise.reject(new Error("Network error")), 239 + ); 240 + 241 + const monitor = createMockMonitor(); 242 + const notification = selectNotificationSchema.parse( 243 + createMockNotification(), 244 + ); 245 + 246 + // Should not throw - function catches errors internally 247 + await sendAlert({ 248 + // @ts-expect-error 249 + monitor, 250 + notification, 251 + statusCode: 500, 252 + message: "Error", 253 + cronTimestamp: Date.now(), 254 + }); 255 + 256 + expect(fetchMock).toHaveBeenCalledTimes(1); 257 + }); 258 + 259 + test("Send Test returns false on error", async () => { 260 + fetchMock.mockImplementation(() => 261 + Promise.reject(new Error("Network error")), 262 + ); 263 + 264 + const result = await sendTest({ 265 + topic: "test-topic", 266 + }); 267 + 268 + expect(result).toBe(false); 269 + expect(fetchMock).toHaveBeenCalledTimes(1); 270 + }); 271 + });
+1
packages/notifications/opsgenie/package.json
··· 14 14 "@types/node": "20.8.0", 15 15 "@types/react": "19.2.2", 16 16 "@types/react-dom": "19.2.2", 17 + "bun-types": "1.3.1", 17 18 "typescript": "5.7.2" 18 19 } 19 20 }
+160
packages/notifications/opsgenie/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, sendTest } from "./index"; 4 + 5 + describe("OpsGenie Notifications", () => { 6 + // biome-ignore lint/suspicious/noExplicitAny: <explanation> 7 + let fetchMock: any = undefined; 8 + 9 + beforeEach(() => { 10 + fetchMock = spyOn(global, "fetch").mockImplementation(() => 11 + Promise.resolve(new Response(null, { status: 200 })), 12 + ); 13 + }); 14 + 15 + afterEach(() => { 16 + if (fetchMock) { 17 + fetchMock.mockRestore(); 18 + } 19 + }); 20 + 21 + const createMockMonitor = () => ({ 22 + id: "monitor-1", 23 + name: "API Health Check", 24 + url: "https://api.example.com/health", 25 + jobType: "http" as const, 26 + periodicity: "5m" as const, 27 + status: "active" as const, 28 + createdAt: new Date(), 29 + updatedAt: new Date(), 30 + region: "us-east-1", 31 + }); 32 + 33 + const createMockNotification = (region: "eu" | "us" = "us") => ({ 34 + id: 1, 35 + name: "OpsGenie Notification", 36 + provider: "opsgenie", 37 + workspaceId: 1, 38 + createdAt: new Date(), 39 + updatedAt: new Date(), 40 + data: JSON.stringify({ 41 + opsgenie: { 42 + apiKey: "test-api-key-123", 43 + region, 44 + }, 45 + }), 46 + }); 47 + 48 + test("Send Alert with US region", async () => { 49 + const monitor = createMockMonitor(); 50 + const notification = selectNotificationSchema.parse( 51 + createMockNotification("us"), 52 + ); 53 + 54 + await sendAlert({ 55 + // @ts-expect-error 56 + monitor, 57 + notification, 58 + statusCode: 500, 59 + message: "Something went wrong", 60 + incidentId: "incident-123", 61 + cronTimestamp: Date.now(), 62 + }); 63 + 64 + expect(fetchMock).toHaveBeenCalledTimes(1); 65 + const callArgs = fetchMock.mock.calls[0]; 66 + expect(callArgs[0]).toBe("https://api.opsgenie.com/v2/alerts"); 67 + expect(callArgs[1].method).toBe("POST"); 68 + expect(callArgs[1].headers["Content-Type"]).toBe("application/json"); 69 + expect(callArgs[1].headers.Authorization).toBe("GenieKey test-api-key-123"); 70 + 71 + const body = JSON.parse(callArgs[1].body); 72 + expect(body.message).toBe("API Health Check is down"); 73 + expect(body.alias).toBe("monitor-1}-incident-123"); 74 + expect(body.details.severity).toBe("down"); 75 + expect(body.details.status).toBe(500); 76 + expect(body.details.message).toBe("Something went wrong"); 77 + }); 78 + 79 + test("Send Alert with EU region", async () => { 80 + const monitor = createMockMonitor(); 81 + const notification = selectNotificationSchema.parse( 82 + createMockNotification("eu"), 83 + ); 84 + 85 + await sendAlert({ 86 + // @ts-expect-error 87 + monitor, 88 + notification, 89 + statusCode: 500, 90 + message: "Error", 91 + incidentId: "incident-456", 92 + cronTimestamp: Date.now(), 93 + }); 94 + 95 + expect(fetchMock).toHaveBeenCalledTimes(1); 96 + const callArgs = fetchMock.mock.calls[0]; 97 + expect(callArgs[0]).toBe("https://api.eu.opsgenie.com/v2/alerts"); 98 + }); 99 + 100 + test("Send Degraded", async () => { 101 + const monitor = createMockMonitor(); 102 + const notification = selectNotificationSchema.parse( 103 + createMockNotification(), 104 + ); 105 + 106 + await sendDegraded({ 107 + // @ts-expect-error 108 + monitor, 109 + notification, 110 + statusCode: 503, 111 + message: "Service degraded", 112 + incidentId: "incident-789", 113 + cronTimestamp: Date.now(), 114 + }); 115 + 116 + expect(fetchMock).toHaveBeenCalledTimes(1); 117 + const callArgs = fetchMock.mock.calls[0]; 118 + const body = JSON.parse(callArgs[1].body); 119 + expect(body.details.severity).toBe("degraded"); 120 + expect(body.message).toBe("API Health Check is down"); 121 + }); 122 + 123 + test("Handle fetch error gracefully", async () => { 124 + fetchMock.mockImplementation(() => 125 + Promise.reject(new Error("Network error")), 126 + ); 127 + 128 + const monitor = createMockMonitor(); 129 + const notification = selectNotificationSchema.parse( 130 + createMockNotification(), 131 + ); 132 + 133 + // Should not throw - function catches errors internally 134 + await sendAlert({ 135 + // @ts-expect-error 136 + monitor, 137 + notification, 138 + statusCode: 500, 139 + message: "Error", 140 + incidentId: "incident-123", 141 + cronTimestamp: Date.now(), 142 + }); 143 + 144 + expect(fetchMock).toHaveBeenCalledTimes(1); 145 + }); 146 + 147 + test("Send Test returns false on error", async () => { 148 + fetchMock.mockImplementation(() => 149 + Promise.reject(new Error("Network error")), 150 + ); 151 + 152 + const result = await sendTest({ 153 + apiKey: "test-api-key", 154 + region: "us", 155 + }); 156 + 157 + expect(result).toBe(false); 158 + expect(fetchMock).toHaveBeenCalledTimes(1); 159 + }); 160 + });
+156 -82
packages/notifications/pagerduty/src/index.test.ts
··· 1 - import { 2 - afterEach, 3 - beforeEach, 4 - describe, 5 - expect, 6 - jest, 7 - spyOn, 8 - test, 9 - } from "bun:test"; 1 + import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 10 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 11 - import { sendAlert, sendDegraded, sendRecovery } from "./index"; 3 + import { sendAlert, sendDegraded, sendRecovery, sendTest } from "./index"; 12 4 13 5 describe("PagerDuty Notifications", () => { 14 6 // biome-ignore lint/suspicious/noExplicitAny: <explanation> ··· 22 14 }); 23 15 24 16 afterEach(() => { 25 - jest.resetAllMocks(); 17 + if (fetchMock) { 18 + fetchMock.mockRestore(); 19 + } 26 20 }); 27 21 28 - test("Send degraded", async () => { 29 - const monitor = { 30 - id: "monitor-1", 31 - name: "API Health Check", 32 - url: "https://api.example.com/health", 33 - jobType: "http" as const, 34 - periodicity: "5m" as const, 35 - status: "active" as const, // or "down", "degraded" 36 - createdAt: new Date(), 37 - updatedAt: new Date(), 38 - region: "us-east-1", 39 - }; 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: "us-east-1", 32 + }); 40 33 41 - const a = { 42 - id: 1, 43 - name: "PagerDuty Notification", 44 - provider: "pagerduty", 45 - workspaceId: 1, 46 - createdAt: new Date(), 47 - updatedAt: new Date(), 48 - data: '{"pagerduty":"{\\"integration_keys\\":[{\\"integration_key\\":\\"my_key\\",\\"name\\":\\"Default Service\\",\\"id\\":\\"ABCD\\",\\"type\\":\\"service\\"}],\\"account\\":{\\"subdomain\\":\\"test\\",\\"name\\":\\"test\\"}}"}', 49 - }; 34 + const createMockNotification = () => ({ 35 + id: 1, 36 + name: "PagerDuty Notification", 37 + provider: "pagerduty", 38 + workspaceId: 1, 39 + createdAt: new Date(), 40 + updatedAt: new Date(), 41 + data: '{"pagerduty":"{\\"integration_keys\\":[{\\"integration_key\\":\\"my_key\\",\\"name\\":\\"Default Service\\",\\"id\\":\\"ABCD\\",\\"type\\":\\"service\\"}],\\"account\\":{\\"subdomain\\":\\"test\\",\\"name\\":\\"test\\"}}"}', 42 + }); 43 + 44 + test("Send Alert", async () => { 45 + const monitor = createMockMonitor(); 46 + const notification = selectNotificationSchema.parse( 47 + createMockNotification(), 48 + ); 50 49 51 - const n = selectNotificationSchema.parse(a); 52 - await sendDegraded({ 50 + await sendAlert({ 53 51 // @ts-expect-error 54 52 monitor, 55 - notification: n, 53 + notification, 56 54 statusCode: 500, 57 55 message: "Something went wrong", 56 + incidentId: "incident-123", 58 57 cronTimestamp: Date.now(), 59 58 }); 60 - expect(fetchMock).toHaveBeenCalled(); 59 + 60 + expect(fetchMock).toHaveBeenCalledTimes(1); 61 + const callArgs = fetchMock.mock.calls[0]; 62 + expect(callArgs[0]).toBe("https://events.pagerduty.com/v2/enqueue"); 63 + expect(callArgs[1].method).toBe("POST"); 64 + 65 + const body = JSON.parse(callArgs[1].body); 66 + expect(body.routing_key).toBe("my_key"); 67 + expect(body.dedup_key).toBe("monitor-1}-incident-123"); 68 + expect(body.event_action).toBe("trigger"); 69 + expect(body.payload.summary).toBe("API Health Check is down"); 70 + expect(body.payload.severity).toBe("error"); 71 + expect(body.payload.custom_details.statusCode).toBe(500); 72 + expect(body.payload.custom_details.message).toBe("Something went wrong"); 61 73 }); 62 74 63 - test("Send Recovered", async () => { 64 - const monitor = { 65 - id: "monitor-1", 66 - name: "API Health Check", 67 - url: "https://api.example.com/health", 68 - jobType: "http" as const, 69 - periodicity: "5m" as const, 70 - status: "active" as const, // or "down", "degraded" 71 - createdAt: new Date(), 72 - updatedAt: new Date(), 73 - region: "us-east-1", 74 - }; 75 - 76 - const a = { 75 + test("Send Alert with multiple integration keys", async () => { 76 + const monitor = createMockMonitor(); 77 + const notification = selectNotificationSchema.parse({ 77 78 id: 1, 78 79 name: "PagerDuty Notification", 79 80 provider: "pagerduty", 80 81 workspaceId: 1, 81 82 createdAt: new Date(), 82 83 updatedAt: new Date(), 83 - data: '{"pagerduty":"{\\"integration_keys\\":[{\\"integration_key\\":\\"my_key\\",\\"name\\":\\"Default Service\\",\\"id\\":\\"ABCD\\",\\"type\\":\\"service\\"}],\\"account\\":{\\"subdomain\\":\\"test\\",\\"name\\":\\"test\\"}}"}', 84 - }; 84 + data: '{"pagerduty":"{\\"integration_keys\\":[{\\"integration_key\\":\\"key1\\",\\"name\\":\\"Service 1\\",\\"id\\":\\"ABCD\\",\\"type\\":\\"service\\"},{\\"integration_key\\":\\"key2\\",\\"name\\":\\"Service 2\\",\\"id\\":\\"EFGH\\",\\"type\\":\\"service\\"}],\\"account\\":{\\"subdomain\\":\\"test\\",\\"name\\":\\"test\\"}}"}', 85 + }); 85 86 86 - const n = selectNotificationSchema.parse(a); 87 - await sendRecovery({ 87 + await sendAlert({ 88 88 // @ts-expect-error 89 89 monitor, 90 - notification: n, 90 + notification, 91 91 statusCode: 500, 92 - message: "Something went wrong", 92 + message: "Error", 93 + incidentId: "incident-456", 93 94 cronTimestamp: Date.now(), 94 95 }); 95 - expect(fetchMock).toHaveBeenCalled(); 96 + 97 + expect(fetchMock).toHaveBeenCalledTimes(2); 98 + expect(fetchMock.mock.calls[0][1].body).toContain("key1"); 99 + expect(fetchMock.mock.calls[1][1].body).toContain("key2"); 96 100 }); 97 101 98 - test("Send Alert", async () => { 99 - const monitor = { 100 - id: "monitor-1", 101 - name: "API Health Check", 102 - url: "https://api.example.com/health", 103 - jobType: "http" as const, 104 - periodicity: "5m" as const, 105 - status: "active" as const, // or "down", "degraded" 106 - createdAt: new Date(), 107 - updatedAt: new Date(), 108 - region: "us-east-1", 109 - }; 110 - const a = { 111 - id: 1, 112 - name: "PagerDuty Notification", 113 - provider: "pagerduty", 114 - workspaceId: 1, 115 - createdAt: new Date(), 116 - updatedAt: new Date(), 117 - data: '{"pagerduty":"{\\"integration_keys\\":[{\\"integration_key\\":\\"my_key\\",\\"name\\":\\"Default Service\\",\\"id\\":\\"ABCD\\",\\"type\\":\\"service\\"}],\\"account\\":{\\"subdomain\\":\\"test\\",\\"name\\":\\"test\\"}}"}', 118 - }; 102 + test("Send Degraded", async () => { 103 + const monitor = createMockMonitor(); 104 + const notification = selectNotificationSchema.parse( 105 + createMockNotification(), 106 + ); 107 + 108 + await sendDegraded({ 109 + // @ts-expect-error 110 + monitor, 111 + notification, 112 + statusCode: 503, 113 + message: "Service degraded", 114 + cronTimestamp: Date.now(), 115 + }); 116 + 117 + expect(fetchMock).toHaveBeenCalledTimes(1); 118 + const callArgs = fetchMock.mock.calls[0]; 119 + const body = JSON.parse(callArgs[1].body); 120 + expect(body.payload.summary).toBe("API Health Check is degraded"); 121 + expect(body.payload.severity).toBe("warning"); 122 + expect(body.dedup_key).toBe("monitor-1}"); 123 + }); 124 + 125 + test("Send Recovery", async () => { 126 + const monitor = createMockMonitor(); 127 + const notification = selectNotificationSchema.parse( 128 + createMockNotification(), 129 + ); 130 + 131 + await sendRecovery({ 132 + // @ts-expect-error 133 + monitor, 134 + notification, 135 + statusCode: 200, 136 + message: "Service recovered", 137 + incidentId: "incident-123", 138 + cronTimestamp: Date.now(), 139 + }); 140 + 141 + expect(fetchMock).toHaveBeenCalledTimes(1); 142 + const callArgs = fetchMock.mock.calls[0]; 143 + expect(callArgs[0]).toBe("https://events.pagerduty.com/v2/enqueue"); 144 + const body = JSON.parse(callArgs[1].body); 145 + expect(body.routing_key).toBe("my_key"); 146 + expect(body.dedup_key).toBe("monitor-1}-incident-123"); 147 + expect(body.event_action).toBe("resolve"); 148 + }); 149 + 150 + test("Send Test", async () => { 151 + const result = await sendTest({ 152 + integrationKey: "test-integration-key", 153 + }); 154 + 155 + expect(result).toBe(true); 156 + expect(fetchMock).toHaveBeenCalledTimes(1); 157 + const callArgs = fetchMock.mock.calls[0]; 158 + expect(callArgs[0]).toBe("https://events.pagerduty.com/v2/enqueue"); 159 + expect(callArgs[1].method).toBe("POST"); 160 + 161 + const body = JSON.parse(callArgs[1].body); 162 + expect(body.routing_key).toBe("test-integration-key"); 163 + expect(body.dedup_key).toBe("openstatus-test"); 164 + expect(body.event_action).toBe("trigger"); 165 + expect(body.payload.summary).toBe("This is a test from OpenStatus"); 166 + expect(body.payload.severity).toBe("error"); 167 + expect(body.payload.custom_details.statusCode).toBe(418); 168 + }); 169 + 170 + test("Send Test returns false on error", async () => { 171 + fetchMock.mockImplementation(() => 172 + Promise.reject(new Error("Network error")), 173 + ); 174 + 175 + const result = await sendTest({ 176 + integrationKey: "test-key", 177 + }); 178 + 179 + expect(result).toBe(false); 180 + expect(fetchMock).toHaveBeenCalledTimes(1); 181 + }); 182 + 183 + test("Handle fetch error gracefully", async () => { 184 + fetchMock.mockImplementation(() => 185 + Promise.reject(new Error("Network error")), 186 + ); 119 187 120 - const n = selectNotificationSchema.parse(a); 188 + const monitor = createMockMonitor(); 189 + const notification = selectNotificationSchema.parse( 190 + createMockNotification(), 191 + ); 121 192 193 + // Should not throw - function catches errors internally 122 194 await sendAlert({ 123 195 // @ts-expect-error 124 196 monitor, 125 - notification: n, 197 + notification, 126 198 statusCode: 500, 127 - message: "Something went wrong", 199 + message: "Error", 200 + incidentId: "incident-123", 128 201 cronTimestamp: Date.now(), 129 202 }); 130 - expect(fetchMock).toHaveBeenCalled(); 203 + 204 + expect(fetchMock).toHaveBeenCalledTimes(1); 131 205 }); 132 206 });
+146 -87
packages/notifications/slack/src/index.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 2 + import { selectNotificationSchema } from "@openstatus/db/src/schema"; 1 3 import { 2 - afterEach, 3 - beforeEach, 4 - describe, 5 - expect, 6 - jest, 7 - spyOn, 8 - test, 9 - } from "bun:test"; 10 - import { selectNotificationSchema } from "@openstatus/db/src/schema"; 11 - import { sendAlert, sendDegraded, sendRecovery } from "./index"; 4 + sendAlert, 5 + sendDegraded, 6 + sendRecovery, 7 + sendTestSlackMessage, 8 + } from "./index"; 12 9 13 10 describe("Slack Notifications", () => { 14 11 // biome-ignore lint/suspicious/noExplicitAny: <explanation> ··· 22 19 }); 23 20 24 21 afterEach(() => { 25 - jest.resetAllMocks(); 22 + if (fetchMock) { 23 + fetchMock.mockRestore(); 24 + } 25 + }); 26 + 27 + const createMockMonitor = () => ({ 28 + id: "monitor-1", 29 + name: "API Health Check", 30 + url: "https://api.example.com/health", 31 + jobType: "http" as const, 32 + periodicity: "5m" as const, 33 + status: "active" as const, 34 + createdAt: new Date(), 35 + updatedAt: new Date(), 36 + region: "us-east-1", 26 37 }); 27 38 28 - test("Send degraded", async () => { 29 - const monitor = { 30 - id: "monitor-1", 31 - name: "API Health Check", 32 - url: "https://api.example.com/health", 33 - jobType: "http" as const, 34 - periodicity: "5m" as const, 35 - status: "active" as const, // or "down", "degraded" 36 - createdAt: new Date(), 37 - updatedAt: new Date(), 38 - region: "us-east-1", 39 - }; 39 + const createMockNotification = () => ({ 40 + id: 1, 41 + name: "Slack Notification", 42 + provider: "slack", 43 + workspaceId: 1, 44 + createdAt: new Date(), 45 + updatedAt: new Date(), 46 + data: '{"slack":"https://hooks.slack.com/services/url"}', 47 + }); 40 48 41 - const a = { 42 - id: 1, 43 - name: "slack Notification", 44 - provider: "slack", 45 - workspaceId: 1, 46 - createdAt: new Date(), 47 - updatedAt: new Date(), 48 - data: '{"slack":"https://hooks.slack.com/services/url"}', 49 - }; 49 + test("Send Alert", async () => { 50 + const monitor = createMockMonitor(); 51 + const notification = selectNotificationSchema.parse( 52 + createMockNotification(), 53 + ); 50 54 51 - const n = selectNotificationSchema.parse(a); 52 - await sendDegraded({ 55 + await sendAlert({ 53 56 // @ts-expect-error 54 57 monitor, 55 - notification: n, 58 + notification, 56 59 statusCode: 500, 57 60 message: "Something went wrong", 58 61 cronTimestamp: Date.now(), 59 62 }); 60 - expect(fetchMock).toHaveBeenCalled(); 63 + 64 + expect(fetchMock).toHaveBeenCalledTimes(1); 65 + const callArgs = fetchMock.mock.calls[0]; 66 + expect(callArgs[0]).toBe("https://hooks.slack.com/services/url"); 67 + expect(callArgs[1].method).toBe("POST"); 68 + 69 + const body = JSON.parse(callArgs[1].body); 70 + expect(body.blocks).toBeDefined(); 71 + expect(body.blocks.length).toBeGreaterThan(0); 72 + expect(body.blocks[1].text.text).toContain("🚨 Alert"); 73 + expect(body.blocks[1].text.text).toContain("API Health Check"); 74 + expect(body.blocks[1].text.text).toContain("Something went wrong"); 61 75 }); 62 76 63 - test("Send Recovered", async () => { 64 - const monitor = { 65 - id: "monitor-1", 66 - name: "API Health Check", 67 - url: "https://api.example.com/health", 68 - jobType: "http" as const, 69 - periodicity: "5m" as const, 70 - status: "active" as const, // or "down", "degraded" 71 - createdAt: new Date(), 72 - updatedAt: new Date(), 73 - region: "us-east-1", 74 - }; 77 + test("Send Alert without statusCode", async () => { 78 + const monitor = createMockMonitor(); 79 + const notification = selectNotificationSchema.parse( 80 + createMockNotification(), 81 + ); 82 + 83 + await sendAlert({ 84 + // @ts-expect-error 85 + monitor, 86 + notification, 87 + message: "Connection timeout", 88 + cronTimestamp: Date.now(), 89 + }); 90 + 91 + expect(fetchMock).toHaveBeenCalledTimes(1); 92 + const callArgs = fetchMock.mock.calls[0]; 93 + const body = JSON.parse(callArgs[1].body); 94 + expect(body.blocks[1].text.text).toContain("_empty_"); 95 + }); 75 96 76 - const a = { 77 - id: 1, 78 - name: "slack Notification", 79 - provider: "slack", 80 - workspaceId: 1, 81 - createdAt: new Date(), 82 - updatedAt: new Date(), 83 - data: '{"slack":"https://hooks.slack.com/services/url"}', 84 - }; 97 + test("Send Recovery", async () => { 98 + const monitor = createMockMonitor(); 99 + const notification = selectNotificationSchema.parse( 100 + createMockNotification(), 101 + ); 85 102 86 - const n = selectNotificationSchema.parse(a); 87 103 await sendRecovery({ 88 104 // @ts-expect-error 89 105 monitor, 90 - notification: n, 91 - statusCode: 500, 92 - message: "Something went wrong", 106 + notification, 107 + statusCode: 200, 108 + message: "Service recovered", 93 109 cronTimestamp: Date.now(), 94 110 }); 95 - expect(fetchMock).toHaveBeenCalled(); 111 + 112 + expect(fetchMock).toHaveBeenCalledTimes(1); 113 + const callArgs = fetchMock.mock.calls[0]; 114 + const body = JSON.parse(callArgs[1].body); 115 + expect(body.blocks[1].text.text).toContain("✅ Recovered"); 116 + expect(body.blocks[1].text.text).toContain("API Health Check"); 96 117 }); 97 118 98 - test("Send Alert", async () => { 99 - const monitor = { 100 - id: "monitor-1", 101 - name: "API Health Check", 102 - url: "https://api.example.com/health", 103 - jobType: "http" as const, 104 - periodicity: "5m" as const, 105 - status: "active" as const, // or "down", "degraded" 106 - createdAt: new Date(), 107 - updatedAt: new Date(), 108 - region: "us-east-1", 109 - }; 110 - const a = { 111 - id: 1, 112 - name: "slack Notification", 113 - provider: "slack", 114 - workspaceId: 1, 115 - createdAt: new Date(), 116 - updatedAt: new Date(), 117 - data: '{"slack":"https://hooks.slack.com/services/url"}', 118 - }; 119 + test("Send Degraded", async () => { 120 + const monitor = createMockMonitor(); 121 + const notification = selectNotificationSchema.parse( 122 + createMockNotification(), 123 + ); 119 124 120 - const n = selectNotificationSchema.parse(a); 125 + await sendDegraded({ 126 + // @ts-expect-error 127 + monitor, 128 + notification, 129 + statusCode: 503, 130 + message: "Service degraded", 131 + cronTimestamp: Date.now(), 132 + }); 121 133 134 + expect(fetchMock).toHaveBeenCalledTimes(1); 135 + const callArgs = fetchMock.mock.calls[0]; 136 + const body = JSON.parse(callArgs[1].body); 137 + expect(body.blocks[1].text.text).toContain("⚠️ Degraded"); 138 + expect(body.blocks[1].text.text).toContain("API Health Check"); 139 + }); 140 + 141 + test("Send Test Slack Message", async () => { 142 + const webhookUrl = "https://hooks.slack.com/services/test/url"; 143 + 144 + const result = await sendTestSlackMessage(webhookUrl); 145 + 146 + expect(result).toBe(true); 147 + expect(fetchMock).toHaveBeenCalledTimes(1); 148 + const callArgs = fetchMock.mock.calls[0]; 149 + expect(callArgs[0]).toBe(webhookUrl); 150 + 151 + const body = JSON.parse(callArgs[1].body); 152 + expect(body.blocks[1].text.text).toContain("🧪 Test"); 153 + expect(body.blocks[1].text.text).toContain("OpenStatus"); 154 + }); 155 + 156 + test("Send Test Slack Message returns false on error", async () => { 157 + fetchMock.mockImplementation(() => 158 + Promise.reject(new Error("Network error")), 159 + ); 160 + 161 + const result = await sendTestSlackMessage( 162 + "https://hooks.slack.com/services/test/url", 163 + ); 164 + 165 + expect(result).toBe(false); 166 + expect(fetchMock).toHaveBeenCalledTimes(1); 167 + }); 168 + 169 + test("Handle fetch error gracefully", async () => { 170 + fetchMock.mockImplementation(() => 171 + Promise.reject(new Error("Network error")), 172 + ); 173 + 174 + const monitor = createMockMonitor(); 175 + const notification = selectNotificationSchema.parse( 176 + createMockNotification(), 177 + ); 178 + 179 + // Should not throw - function catches errors internally 122 180 await sendAlert({ 123 181 // @ts-expect-error 124 182 monitor, 125 - notification: n, 183 + notification, 126 184 statusCode: 500, 127 - message: "Something went wrong", 185 + message: "Error", 128 186 cronTimestamp: Date.now(), 129 187 }); 130 - expect(fetchMock).toHaveBeenCalled(); 188 + 189 + expect(fetchMock).toHaveBeenCalledTimes(1); 131 190 }); 132 191 });
+169
packages/notifications/telegram/.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.\*
+48
packages/notifications/telegram/README.md
··· 1 + # @openstatus/notification-telegram 2 + To install dependencies: 3 + 4 + ```bash 5 + bun install 6 + ``` 7 + 8 + To run: 9 + 10 + ```bash 11 + bun run src/index.ts 12 + ``` 13 + 14 + This project was created using `bun init` in bun v1.0.0. [Bun](https://bun.sh) 15 + is a fast all-in-one JavaScript runtime. 16 + 17 + 18 + --- 19 + 20 + Important `@BotFather` commands to update the bot. 21 + 22 + ``` 23 + Change profile image 24 + /setuserpic 25 + Change display name 26 + /setname 27 + Change bot description 28 + /setdescription 29 + Short about text shown in bio preview 30 + /setabouttext 31 + ``` 32 + 33 + --- 34 + 35 + #### TODO 36 + 37 + This integration is very "raw". We need to let the user search for their chat id (e.g. by asking RawDataBot `@raw_info_bot`). 38 + 39 + In the future, we could work with `/setcommands` behaviors to send the value to our `/getUpdates` endpoint and access the chat id. We will need to forward a specific unique identifier with it to match the chat id with the proper notification. 40 + 41 + An improved UX option: 42 + 43 + ``` 44 + 1. user enters `/connect` command 45 + 2. we listen to the `/getUpdates` messages and generate an id, store it for a few days in redis, and send it back to the user's chat id 46 + 3. user enters generated id into openstatus dashboard to connect with chat id - we validate it from the `/getUpdates` 47 + ``` 48 +
packages/notifications/telegram/excalidraw.png

This is a binary file and will not be displayed.

+15
packages/notifications/telegram/package.json
··· 1 + { 2 + "name": "@openstatus/notification-telegram", 3 + "version": "1.0.0", 4 + "main": "src/index.ts", 5 + "dependencies": { 6 + "@openstatus/db": "workspace:*", 7 + "zod": "3.25.76" 8 + }, 9 + "devDependencies": { 10 + "@openstatus/tsconfig": "workspace:*", 11 + "@types/node": "22.10.2", 12 + "bun-types": "1.3.1", 13 + "typescript": "5.7.2" 14 + } 15 + }
+208
packages/notifications/telegram/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("Telegram Notifications", () => { 6 + // biome-ignore lint/suspicious/noExplicitAny: <explanation> 7 + let fetchMock: any = undefined; 8 + const originalEnv = process.env.TELEGRAM_BOT_TOKEN; 9 + 10 + beforeEach(() => { 11 + process.env.TELEGRAM_BOT_TOKEN = "test-bot-token-123"; 12 + fetchMock = spyOn(global, "fetch").mockImplementation(() => 13 + Promise.resolve(new Response(null, { status: 200 })), 14 + ); 15 + }); 16 + 17 + afterEach(() => { 18 + if (fetchMock) { 19 + fetchMock.mockRestore(); 20 + } 21 + if (originalEnv) { 22 + process.env.TELEGRAM_BOT_TOKEN = originalEnv; 23 + } else { 24 + process.env.TELEGRAM_BOT_TOKEN = undefined; 25 + } 26 + }); 27 + 28 + const createMockMonitor = () => ({ 29 + id: "monitor-1", 30 + name: "API Health Check", 31 + url: "https://api.example.com/health", 32 + jobType: "http" as const, 33 + periodicity: "5m" as const, 34 + status: "active" as const, 35 + createdAt: new Date(), 36 + updatedAt: new Date(), 37 + region: "us-east-1", 38 + }); 39 + 40 + const createMockNotification = () => ({ 41 + id: 1, 42 + name: "Telegram Notification", 43 + provider: "telegram", 44 + workspaceId: 1, 45 + createdAt: new Date(), 46 + updatedAt: new Date(), 47 + data: JSON.stringify({ 48 + telegram: { 49 + chatId: "123456789", 50 + }, 51 + }), 52 + }); 53 + 54 + test("Send Alert", async () => { 55 + const monitor = createMockMonitor(); 56 + const notification = selectNotificationSchema.parse( 57 + createMockNotification(), 58 + ); 59 + 60 + await sendAlert({ 61 + // @ts-expect-error 62 + monitor, 63 + notification, 64 + statusCode: 500, 65 + message: "Something went wrong", 66 + cronTimestamp: Date.now(), 67 + }); 68 + 69 + expect(fetchMock).toHaveBeenCalledTimes(1); 70 + const callArgs = fetchMock.mock.calls[0]; 71 + expect(callArgs[0]).toContain( 72 + "https://api.telegram.org/bottest-bot-token-123/sendMessage", 73 + ); 74 + expect(callArgs[0]).toContain("chat_id=123456789"); 75 + expect(callArgs[0]).toContain("API Health Check"); 76 + }); 77 + 78 + test("Send Alert without statusCode", async () => { 79 + const monitor = createMockMonitor(); 80 + const notification = selectNotificationSchema.parse( 81 + createMockNotification(), 82 + ); 83 + 84 + await sendAlert({ 85 + // @ts-expect-error 86 + monitor, 87 + notification, 88 + message: "Connection timeout", 89 + cronTimestamp: Date.now(), 90 + }); 91 + 92 + expect(fetchMock).toHaveBeenCalledTimes(1); 93 + const callArgs = fetchMock.mock.calls[0]; 94 + expect(callArgs[0]).toContain("error: Connection timeout"); 95 + }); 96 + 97 + test("Send Recovery", async () => { 98 + const monitor = createMockMonitor(); 99 + const notification = selectNotificationSchema.parse( 100 + createMockNotification(), 101 + ); 102 + 103 + await sendRecovery({ 104 + // @ts-expect-error 105 + monitor, 106 + notification, 107 + statusCode: 200, 108 + message: "Service recovered", 109 + cronTimestamp: Date.now(), 110 + }); 111 + 112 + expect(fetchMock).toHaveBeenCalledTimes(1); 113 + const callArgs = fetchMock.mock.calls[0]; 114 + expect(callArgs[0]).toContain("is up again"); 115 + }); 116 + 117 + test("Send Degraded", async () => { 118 + const monitor = createMockMonitor(); 119 + const notification = selectNotificationSchema.parse( 120 + createMockNotification(), 121 + ); 122 + 123 + await sendDegraded({ 124 + // @ts-expect-error 125 + monitor, 126 + notification, 127 + statusCode: 503, 128 + message: "Service degraded", 129 + cronTimestamp: Date.now(), 130 + }); 131 + 132 + expect(fetchMock).toHaveBeenCalledTimes(1); 133 + const callArgs = fetchMock.mock.calls[0]; 134 + expect(callArgs[0]).toContain("is degraded"); 135 + }); 136 + 137 + test("Send Test", async () => { 138 + const result = await sendTest({ 139 + chatId: "123456789", 140 + }); 141 + 142 + expect(result).toBe(true); 143 + expect(fetchMock).toHaveBeenCalledTimes(1); 144 + const callArgs = fetchMock.mock.calls[0]; 145 + expect(callArgs[0]).toContain( 146 + "https://api.telegram.org/bottest-bot-token-123/sendMessage", 147 + ); 148 + expect(callArgs[0]).toContain("chat_id=123456789"); 149 + expect(callArgs[0]).toContain("This is a test message from OpenStatus"); 150 + }); 151 + 152 + test("Send Test returns false on error", async () => { 153 + fetchMock.mockImplementation(() => 154 + Promise.reject(new Error("Network error")), 155 + ); 156 + 157 + const result = await sendTest({ 158 + chatId: "123456789", 159 + }); 160 + 161 + expect(result).toBe(false); 162 + expect(fetchMock).toHaveBeenCalledTimes(1); 163 + }); 164 + 165 + test("Handle fetch error gracefully", async () => { 166 + fetchMock.mockImplementation(() => 167 + Promise.reject(new Error("Network error")), 168 + ); 169 + 170 + const monitor = createMockMonitor(); 171 + const notification = selectNotificationSchema.parse( 172 + createMockNotification(), 173 + ); 174 + 175 + // Should not throw - function catches errors internally 176 + await sendAlert({ 177 + // @ts-expect-error 178 + monitor, 179 + notification, 180 + statusCode: 500, 181 + message: "Error", 182 + cronTimestamp: Date.now(), 183 + }); 184 + 185 + expect(fetchMock).toHaveBeenCalledTimes(1); 186 + }); 187 + 188 + test("sendMessage returns undefined when TELEGRAM_BOT_TOKEN is not set", async () => { 189 + process.env.TELEGRAM_BOT_TOKEN = undefined; 190 + 191 + const monitor = createMockMonitor(); 192 + const notification = selectNotificationSchema.parse( 193 + createMockNotification(), 194 + ); 195 + 196 + await sendAlert({ 197 + // @ts-expect-error 198 + monitor, 199 + notification, 200 + statusCode: 500, 201 + message: "Error", 202 + cronTimestamp: Date.now(), 203 + }); 204 + 205 + // Should not call fetch when token is missing 206 + expect(fetchMock).not.toHaveBeenCalled(); 207 + }); 208 + });
+132
packages/notifications/telegram/src/index.ts
··· 1 + import type { Monitor, Notification } from "@openstatus/db/src/schema"; 2 + 3 + import type { Region } from "@openstatus/db/src/schema/constants"; 4 + import { TelegramSchema } from "./schema"; 5 + 6 + export const sendAlert = async ({ 7 + monitor, 8 + notification, 9 + statusCode, 10 + message, 11 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 12 + incidentId, 13 + }: { 14 + monitor: Monitor; 15 + notification: Notification; 16 + statusCode?: number; 17 + message?: string; 18 + incidentId?: string; 19 + cronTimestamp: number; 20 + latency?: number; 21 + region?: Region; 22 + }) => { 23 + const notificationData = TelegramSchema.parse(JSON.parse(notification.data)); 24 + const { name } = monitor; 25 + 26 + const body = `Your monitor ${name} / ${monitor.url} is down with ${ 27 + statusCode ? `status code ${statusCode}` : `error: ${message}` 28 + }`; 29 + 30 + try { 31 + await sendMessage({ 32 + chatId: notificationData.telegram.chatId, 33 + message: body, 34 + }); 35 + } catch (err) { 36 + console.log(err); 37 + // Do something 38 + } 39 + }; 40 + 41 + export const sendRecovery = async ({ 42 + monitor, 43 + notification, 44 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 45 + statusCode, 46 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 47 + message, 48 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 49 + incidentId, 50 + }: { 51 + monitor: Monitor; 52 + notification: Notification; 53 + statusCode?: number; 54 + message?: string; 55 + incidentId?: string; 56 + cronTimestamp: number; 57 + latency?: number; 58 + region?: Region; 59 + }) => { 60 + const notificationData = TelegramSchema.parse(JSON.parse(notification.data)); 61 + const { name } = monitor; 62 + 63 + const body = `Your monitor ${name} / ${monitor.url} is up again`; 64 + try { 65 + await sendMessage({ 66 + chatId: notificationData.telegram.chatId, 67 + message: body, 68 + }); 69 + } catch (err) { 70 + console.log(err); 71 + // Do something 72 + } 73 + }; 74 + 75 + export const sendDegraded = async ({ 76 + monitor, 77 + notification, 78 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 79 + statusCode, 80 + // biome-ignore lint/correctness/noUnusedVariables: <explanation> 81 + message, 82 + }: { 83 + monitor: Monitor; 84 + notification: Notification; 85 + statusCode?: number; 86 + message?: string; 87 + incidentId?: string; 88 + cronTimestamp: number; 89 + latency?: number; 90 + region?: Region; 91 + }) => { 92 + const notificationData = TelegramSchema.parse(JSON.parse(notification.data)); 93 + const { name } = monitor; 94 + 95 + const body = `Your monitor ${name} / ${monitor.url} is degraded `; 96 + 97 + try { 98 + await sendMessage({ 99 + chatId: notificationData.telegram.chatId, 100 + message: body, 101 + }); 102 + } catch (err) { 103 + console.log(err); 104 + // Do something 105 + } 106 + }; 107 + 108 + export const sendTest = async ({ chatId }: { chatId: string }) => { 109 + try { 110 + await sendMessage({ 111 + chatId, 112 + message: "This is a test message from OpenStatus. You are good to go!", 113 + }); 114 + } catch (err) { 115 + console.log(err); 116 + return false; 117 + } 118 + return true; 119 + }; 120 + 121 + export async function sendMessage({ 122 + chatId, 123 + message, 124 + }: { 125 + chatId: string; 126 + message: string; 127 + }) { 128 + if (!process.env.TELEGRAM_BOT_TOKEN) return; 129 + return fetch( 130 + `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage?chat_id=${chatId}&text=${message}`, 131 + ); 132 + }
+7
packages/notifications/telegram/src/schema.ts
··· 1 + import { z } from "zod"; 2 + 3 + export const TelegramSchema = z.object({ 4 + telegram: z.object({ 5 + chatId: z.string(), 6 + }), 7 + });
+4
packages/notifications/telegram/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/nextjs.json", 3 + "include": ["src", "*.ts"] 4 + }
+133 -88
packages/notifications/twillio-sms/src/index.test.ts
··· 1 - import { 2 - afterEach, 3 - beforeEach, 4 - describe, 5 - expect, 6 - jest, 7 - spyOn, 8 - test, 9 - } from "bun:test"; 1 + import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; 10 2 import { selectNotificationSchema } from "@openstatus/db/src/schema"; 11 3 import { sendAlert, sendDegraded, sendRecovery } from "./index"; 12 4 13 - describe("Slack Notifications", () => { 5 + describe("Twilio SMS Notifications", () => { 14 6 // biome-ignore lint/suspicious/noExplicitAny: <explanation> 15 7 let fetchMock: any = undefined; 8 + const originalEnv = { 9 + TWILLIO_ACCOUNT_ID: process.env.TWILLIO_ACCOUNT_ID, 10 + TWILLIO_AUTH_TOKEN: process.env.TWILLIO_AUTH_TOKEN, 11 + }; 16 12 17 13 beforeEach(() => { 18 - // @ts-expect-error 14 + process.env.TWILLIO_ACCOUNT_ID = "test-account-id"; 15 + process.env.TWILLIO_AUTH_TOKEN = "test-auth-token"; 19 16 fetchMock = spyOn(global, "fetch").mockImplementation(() => 20 17 Promise.resolve(new Response(null, { status: 200 })), 21 18 ); 22 19 }); 23 20 24 21 afterEach(() => { 25 - jest.resetAllMocks(); 22 + if (fetchMock) { 23 + fetchMock.mockRestore(); 24 + } 25 + if (originalEnv.TWILLIO_ACCOUNT_ID) { 26 + process.env.TWILLIO_ACCOUNT_ID = originalEnv.TWILLIO_ACCOUNT_ID; 27 + } else { 28 + process.env.TWILLIO_ACCOUNT_ID = undefined; 29 + } 30 + if (originalEnv.TWILLIO_AUTH_TOKEN) { 31 + process.env.TWILLIO_AUTH_TOKEN = originalEnv.TWILLIO_AUTH_TOKEN; 32 + } else { 33 + process.env.TWILLIO_AUTH_TOKEN = undefined; 34 + } 26 35 }); 27 36 28 - test("Send degraded", async () => { 29 - const monitor = { 30 - id: "monitor-1", 31 - name: "API Health Check", 32 - url: "https://api.example.com/health", 33 - jobType: "http" as const, 34 - periodicity: "5m" as const, 35 - status: "active" as const, // or "down", "degraded" 36 - createdAt: new Date(), 37 - updatedAt: new Date(), 38 - region: "us-east-1", 39 - }; 37 + const createMockMonitor = () => ({ 38 + id: "monitor-1", 39 + name: "API Health Check", 40 + url: "https://api.example.com/health", 41 + jobType: "http" as const, 42 + periodicity: "5m" as const, 43 + status: "active" as const, 44 + createdAt: new Date(), 45 + updatedAt: new Date(), 46 + region: "us-east-1", 47 + }); 48 + 49 + const createMockNotification = () => ({ 50 + id: 1, 51 + name: "Twilio SMS Notification", 52 + provider: "sms", 53 + workspaceId: 1, 54 + createdAt: new Date(), 55 + updatedAt: new Date(), 56 + data: '{"sms":"+33623456789"}', 57 + }); 40 58 41 - const a = { 42 - id: 1, 43 - name: "slack Notification", 44 - provider: "slack", 45 - workspaceId: 1, 46 - createdAt: new Date(), 47 - updatedAt: new Date(), 48 - data: '{"sms":"+33623456789"}', 49 - }; 59 + test("Send Alert", async () => { 60 + const monitor = createMockMonitor(); 61 + const notification = selectNotificationSchema.parse( 62 + createMockNotification(), 63 + ); 50 64 51 - const n = selectNotificationSchema.parse(a); 52 - await sendDegraded({ 65 + await sendAlert({ 53 66 // @ts-expect-error 54 67 monitor, 55 - notification: n, 68 + notification, 56 69 statusCode: 500, 57 70 message: "Something went wrong", 58 71 cronTimestamp: Date.now(), 59 72 }); 60 - expect(fetchMock).toHaveBeenCalled(); 73 + 74 + expect(fetchMock).toHaveBeenCalledTimes(1); 75 + const callArgs = fetchMock.mock.calls[0]; 76 + expect(callArgs[0]).toBe( 77 + "https://api.twilio.com/2010-04-01/Accounts/test-account-id/Messages.json", 78 + ); 79 + expect(callArgs[1].method).toBe("post"); 80 + expect(callArgs[1].headers.Authorization).toBe( 81 + `Basic ${btoa("test-account-id:test-auth-token")}`, 82 + ); 83 + 84 + const formData = callArgs[1].body as FormData; 85 + expect(formData.get("To")).toBe("+33623456789"); 86 + expect(formData.get("From")).toBe("+14807252613"); 87 + expect(formData.get("Body")).toContain("API Health Check"); 88 + expect(formData.get("Body")).toContain("status code 500"); 61 89 }); 62 90 63 - test("Send Recovered", async () => { 64 - const monitor = { 65 - id: "monitor-1", 66 - name: "API Health Check", 67 - url: "https://api.example.com/health", 68 - jobType: "http" as const, 69 - periodicity: "5m" as const, 70 - status: "active" as const, // or "down", "degraded" 71 - createdAt: new Date(), 72 - updatedAt: new Date(), 73 - region: "us-east-1", 74 - }; 91 + test("Send Alert without statusCode", async () => { 92 + const monitor = createMockMonitor(); 93 + const notification = selectNotificationSchema.parse( 94 + createMockNotification(), 95 + ); 96 + 97 + await sendAlert({ 98 + // @ts-expect-error 99 + monitor, 100 + notification, 101 + message: "Connection timeout", 102 + cronTimestamp: Date.now(), 103 + }); 75 104 76 - const a = { 77 - id: 1, 78 - name: "slack Notification", 79 - provider: "slack", 80 - workspaceId: 1, 81 - createdAt: new Date(), 82 - updatedAt: new Date(), 83 - data: '{"sms":"+33623456789"}', 84 - }; 105 + expect(fetchMock).toHaveBeenCalledTimes(1); 106 + const callArgs = fetchMock.mock.calls[0]; 107 + const formData = callArgs[1].body as FormData; 108 + expect(formData.get("Body")).toContain("error: Connection timeout"); 109 + }); 110 + 111 + test("Send Recovery", async () => { 112 + const monitor = createMockMonitor(); 113 + const notification = selectNotificationSchema.parse( 114 + createMockNotification(), 115 + ); 85 116 86 - const n = selectNotificationSchema.parse(a); 87 117 await sendRecovery({ 88 118 // @ts-expect-error 89 119 monitor, 90 - notification: n, 91 - statusCode: 500, 92 - message: "Something went wrong", 120 + notification, 121 + statusCode: 200, 122 + message: "Service recovered", 93 123 cronTimestamp: Date.now(), 94 124 }); 95 - expect(fetchMock).toHaveBeenCalled(); 125 + 126 + expect(fetchMock).toHaveBeenCalledTimes(1); 127 + const callArgs = fetchMock.mock.calls[0]; 128 + const formData = callArgs[1].body as FormData; 129 + expect(formData.get("Body")).toContain("is up again"); 130 + expect(formData.get("Body")).toContain("API Health Check"); 96 131 }); 97 132 98 - test("Send Alert", async () => { 99 - const monitor = { 100 - id: "monitor-1", 101 - name: "API Health Check", 102 - url: "https://api.example.com/health", 103 - jobType: "http" as const, 104 - periodicity: "5m" as const, 105 - status: "active" as const, // or "down", "degraded" 106 - createdAt: new Date(), 107 - updatedAt: new Date(), 108 - region: "us-east-1", 109 - }; 110 - const a = { 111 - id: 1, 112 - name: "slack Notification", 113 - provider: "slack", 114 - workspaceId: 1, 115 - createdAt: new Date(), 116 - updatedAt: new Date(), 117 - data: '{"sms":"+33623456789"}', 118 - }; 133 + test("Send Degraded", async () => { 134 + const monitor = createMockMonitor(); 135 + const notification = selectNotificationSchema.parse( 136 + createMockNotification(), 137 + ); 138 + 139 + await sendDegraded({ 140 + // @ts-expect-error 141 + monitor, 142 + notification, 143 + statusCode: 503, 144 + message: "Service degraded", 145 + cronTimestamp: Date.now(), 146 + }); 147 + 148 + expect(fetchMock).toHaveBeenCalledTimes(1); 149 + const callArgs = fetchMock.mock.calls[0]; 150 + const formData = callArgs[1].body as FormData; 151 + expect(formData.get("Body")).toContain("is degraded"); 152 + expect(formData.get("Body")).toContain("API Health Check"); 153 + }); 154 + 155 + test("Handle fetch error gracefully", async () => { 156 + fetchMock.mockImplementation(() => 157 + Promise.reject(new Error("Network error")), 158 + ); 119 159 120 - const n = selectNotificationSchema.parse(a); 160 + const monitor = createMockMonitor(); 161 + const notification = selectNotificationSchema.parse( 162 + createMockNotification(), 163 + ); 121 164 165 + // Should not throw - function catches errors internally 122 166 await sendAlert({ 123 167 // @ts-expect-error 124 168 monitor, 125 - notification: n, 169 + notification, 126 170 statusCode: 500, 127 - message: "Something went wrong", 171 + message: "Error", 128 172 cronTimestamp: Date.now(), 129 173 }); 130 - expect(fetchMock).toHaveBeenCalled(); 174 + 175 + expect(fetchMock).toHaveBeenCalledTimes(1); 131 176 }); 132 177 });
+105 -65
pnpm-lock.yaml
··· 107 107 '@openstatus/notification-slack': 108 108 specifier: workspace:* 109 109 version: link:../../packages/notifications/slack 110 + '@openstatus/notification-telegram': 111 + specifier: workspace:* 112 + version: link:../../packages/notifications/telegram 110 113 '@openstatus/notification-webhook': 111 114 specifier: workspace:* 112 115 version: link:../../packages/notifications/webhook ··· 1082 1085 '@openstatus/notification-slack': 1083 1086 specifier: workspace:* 1084 1087 version: link:../../packages/notifications/slack 1088 + '@openstatus/notification-telegram': 1089 + specifier: workspace:* 1090 + version: link:../../packages/notifications/telegram 1085 1091 '@openstatus/notification-twillio-sms': 1086 1092 specifier: workspace:* 1087 1093 version: link:../../packages/notifications/twillio-sms ··· 1165 1171 '@openstatus/error': 1166 1172 specifier: workspace:* 1167 1173 version: link:../error 1174 + '@openstatus/notification-telegram': 1175 + specifier: workspace:* 1176 + version: link:../notifications/telegram 1168 1177 '@openstatus/regions': 1169 1178 specifier: workspace:* 1170 1179 version: link:../regions ··· 1403 1412 '@types/node': 1404 1413 specifier: 22.10.2 1405 1414 version: 22.10.2 1415 + bun-types: 1416 + specifier: 1.3.1 1417 + version: 1.3.1(@types/react@19.2.2) 1406 1418 typescript: 1407 1419 specifier: 5.7.2 1408 1420 version: 5.7.2 ··· 1474 1486 '@types/node': 1475 1487 specifier: 22.10.2 1476 1488 version: 22.10.2 1489 + bun-types: 1490 + specifier: 1.3.1 1491 + version: 1.3.1(@types/react@19.2.2) 1477 1492 typescript: 1478 1493 specifier: 5.7.2 1479 1494 version: 5.7.2 ··· 1508 1523 '@types/react-dom': 1509 1524 specifier: 19.2.2 1510 1525 version: 19.2.2(@types/react@19.2.2) 1526 + bun-types: 1527 + specifier: 1.3.1 1528 + version: 1.3.1(@types/react@19.2.2) 1511 1529 typescript: 1512 1530 specifier: 5.7.2 1513 1531 version: 5.7.2 ··· 1550 1568 version: 5.7.2 1551 1569 1552 1570 packages/notifications/slack: 1571 + dependencies: 1572 + '@openstatus/db': 1573 + specifier: workspace:* 1574 + version: link:../../db 1575 + zod: 1576 + specifier: 3.25.76 1577 + version: 3.25.76 1578 + devDependencies: 1579 + '@openstatus/tsconfig': 1580 + specifier: workspace:* 1581 + version: link:../../tsconfig 1582 + '@types/node': 1583 + specifier: 22.10.2 1584 + version: 22.10.2 1585 + bun-types: 1586 + specifier: 1.3.1 1587 + version: 1.3.1(@types/react@19.2.2) 1588 + typescript: 1589 + specifier: 5.7.2 1590 + version: 5.7.2 1591 + 1592 + packages/notifications/telegram: 1553 1593 dependencies: 1554 1594 '@openstatus/db': 1555 1595 specifier: workspace:* ··· 14505 14545 '@types/react': 19.2.2 14506 14546 '@types/react-dom': 19.1.9(@types/react@19.2.2) 14507 14547 14508 - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14548 + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14509 14549 dependencies: 14510 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14550 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14511 14551 react: 19.0.0 14512 14552 react-dom: 19.0.0(react@19.0.0) 14513 14553 optionalDependencies: ··· 14596 14636 '@types/react': 19.2.2 14597 14637 '@types/react-dom': 19.2.2(@types/react@19.2.2) 14598 14638 14599 - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14639 + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14600 14640 dependencies: 14601 14641 '@radix-ui/primitive': 1.1.3 14602 14642 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14603 14643 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14604 14644 '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) 14605 - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14606 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14645 + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14646 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14607 14647 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 14608 14648 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) 14609 14649 react: 19.0.0 ··· 14640 14680 '@types/react': 19.2.2 14641 14681 '@types/react-dom': 19.1.9(@types/react@19.2.2) 14642 14682 14643 - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14683 + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14644 14684 dependencies: 14645 14685 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14646 14686 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14647 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14687 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14648 14688 '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) 14649 14689 react: 19.0.0 14650 14690 react-dom: 19.0.0(react@19.0.0) ··· 14824 14864 '@types/react': 19.2.2 14825 14865 '@types/react-dom': 19.2.2(@types/react@19.2.2) 14826 14866 14827 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14867 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14828 14868 dependencies: 14829 14869 '@radix-ui/primitive': 1.1.3 14830 14870 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14831 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14871 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14832 14872 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) 14833 14873 '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.0.10)(react@19.0.0) 14834 14874 react: 19.0.0 ··· 14865 14905 '@types/react': 19.2.2 14866 14906 '@types/react-dom': 19.2.2(@types/react@19.2.2) 14867 14907 14868 - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14908 + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14869 14909 dependencies: 14870 14910 '@radix-ui/primitive': 1.1.3 14871 14911 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14872 14912 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14873 14913 '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) 14874 - '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14875 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14914 + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14915 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14876 14916 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 14877 14917 react: 19.0.0 14878 14918 react-dom: 19.0.0(react@19.0.0) ··· 14924 14964 '@types/react': 19.2.2 14925 14965 '@types/react-dom': 19.1.9(@types/react@19.2.2) 14926 14966 14927 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14967 + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 14928 14968 dependencies: 14929 14969 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 14930 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14970 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 14931 14971 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) 14932 14972 react: 19.0.0 14933 14973 react-dom: 19.0.0(react@19.0.0) ··· 15056 15096 '@types/react': 19.2.2 15057 15097 '@types/react-dom': 19.2.2(@types/react@19.2.2) 15058 15098 15059 - '@radix-ui/react-menu@2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15099 + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15060 15100 dependencies: 15061 15101 '@radix-ui/primitive': 1.1.3 15062 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15102 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15063 15103 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15064 15104 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15065 15105 '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15066 - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15106 + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15067 15107 '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.0.10)(react@19.0.0) 15068 - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15108 + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15069 15109 '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15070 - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15071 - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15072 - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15073 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15074 - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15110 + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15111 + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15112 + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15113 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15114 + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15075 15115 '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) 15076 15116 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15077 15117 aria-hidden: 1.2.6 ··· 15153 15193 '@types/react': 19.2.2 15154 15194 '@types/react-dom': 19.2.2(@types/react@19.2.2) 15155 15195 15156 - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15196 + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15157 15197 dependencies: 15158 15198 '@radix-ui/primitive': 1.1.3 15159 15199 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15160 15200 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15161 - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15201 + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15162 15202 '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.0.10)(react@19.0.0) 15163 - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15203 + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15164 15204 '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15165 - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15166 - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15167 - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15168 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15205 + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15206 + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15207 + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15208 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15169 15209 '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) 15170 15210 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 15171 15211 aria-hidden: 1.2.6 ··· 15235 15275 '@types/react': 19.2.2 15236 15276 '@types/react-dom': 19.2.2(@types/react@19.2.2) 15237 15277 15238 - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15278 + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15239 15279 dependencies: 15240 15280 '@floating-ui/react-dom': 2.1.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15241 - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15281 + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15242 15282 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15243 15283 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15244 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15284 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15245 15285 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15246 15286 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15247 15287 '@radix-ui/react-use-rect': 1.1.1(@types/react@19.0.10)(react@19.0.0) ··· 15263 15303 '@types/react': 19.2.2 15264 15304 '@types/react-dom': 19.1.9(@types/react@19.2.2) 15265 15305 15266 - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15306 + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15267 15307 dependencies: 15268 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15308 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15269 15309 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15270 15310 react: 19.0.0 15271 15311 react-dom: 19.0.0(react@19.0.0) ··· 15323 15363 '@types/react': 19.2.2 15324 15364 '@types/react-dom': 19.2.2(@types/react@19.2.2) 15325 15365 15326 - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15366 + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15327 15367 dependencies: 15328 15368 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15329 15369 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) ··· 15342 15382 '@types/react': 19.2.2 15343 15383 '@types/react-dom': 19.1.9(@types/react@19.2.2) 15344 15384 15345 - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15385 + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15346 15386 dependencies: 15347 15387 '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) 15348 15388 react: 19.0.0 ··· 15477 15517 '@types/react': 19.2.2 15478 15518 '@types/react-dom': 19.2.2(@types/react@19.2.2) 15479 15519 15480 - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15520 + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15481 15521 dependencies: 15482 15522 '@radix-ui/primitive': 1.1.3 15483 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15523 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15484 15524 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15485 15525 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15486 15526 '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15487 15527 '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15488 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15528 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15489 15529 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15490 15530 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 15491 15531 react: 19.0.0 ··· 15682 15722 '@types/react': 19.2.2 15683 15723 '@types/react-dom': 19.2.2(@types/react@19.2.2) 15684 15724 15685 - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15725 + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15686 15726 dependencies: 15687 15727 '@radix-ui/primitive': 1.1.3 15688 15728 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15689 15729 '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15690 15730 '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15691 - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15692 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15693 - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15731 + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15732 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15733 + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15694 15734 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 15695 15735 react: 19.0.0 15696 15736 react-dom: 19.0.0(react@19.0.0) ··· 15714 15754 '@types/react': 19.2.2 15715 15755 '@types/react-dom': 19.1.9(@types/react@19.2.2) 15716 15756 15717 - '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15757 + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15718 15758 dependencies: 15719 15759 '@radix-ui/primitive': 1.1.3 15720 15760 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15721 15761 '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15722 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15723 - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15724 - '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15762 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15763 + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15764 + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15725 15765 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 15726 15766 react: 19.0.0 15727 15767 react-dom: 19.0.0(react@19.0.0) ··· 15740 15780 '@types/react': 19.2.2 15741 15781 '@types/react-dom': 19.1.9(@types/react@19.2.2) 15742 15782 15743 - '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15783 + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15744 15784 dependencies: 15745 15785 '@radix-ui/primitive': 1.1.3 15746 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15786 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15747 15787 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 15748 15788 react: 19.0.0 15749 15789 react-dom: 19.0.0(react@19.0.0) ··· 15791 15831 '@types/react': 19.2.2 15792 15832 '@types/react-dom': 19.2.2(@types/react@19.2.2) 15793 15833 15794 - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15834 + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15795 15835 dependencies: 15796 15836 '@radix-ui/primitive': 1.1.3 15797 15837 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15798 15838 '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) 15799 - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15839 + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15800 15840 '@radix-ui/react-id': 1.1.1(@types/react@19.0.10)(react@19.0.0) 15801 - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15802 - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15803 - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15804 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15841 + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15842 + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15843 + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15844 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15805 15845 '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) 15806 15846 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) 15807 - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15847 + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15808 15848 react: 19.0.0 15809 15849 react-dom: 19.0.0(react@19.0.0) 15810 15850 optionalDependencies: ··· 15975 16015 '@types/react': 19.2.2 15976 16016 '@types/react-dom': 19.1.9(@types/react@19.2.2) 15977 16017 15978 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16018 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 15979 16019 dependencies: 15980 - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16020 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 15981 16021 react: 19.0.0 15982 16022 react-dom: 19.0.0(react@19.0.0) 15983 16023 optionalDependencies: ··· 16118 16158 '@babel/traverse': 7.27.0 16119 16159 '@lottiefiles/dotlottie-react': 0.13.3(react@19.0.0) 16120 16160 '@radix-ui/colors': 3.0.0 16121 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16122 - '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16123 - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16161 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16162 + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16163 + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16124 16164 '@radix-ui/react-slot': 1.2.3(@types/react@19.0.10)(react@19.0.0) 16125 - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16126 - '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16127 - '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16165 + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16166 + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16167 + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.0.4(@types/react@19.2.2))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16128 16168 '@types/node': 22.14.1 16129 16169 '@types/normalize-path': 3.0.2 16130 16170 '@types/react': 19.0.10