diff --git a/apps/landing/src/content/docs/alerting/notification-destinations.md b/apps/landing/src/content/docs/alerting/notification-destinations.md
index c5050512f..e353b8699 100644
--- a/apps/landing/src/content/docs/alerting/notification-destinations.md
+++ b/apps/landing/src/content/docs/alerting/notification-destinations.md
@@ -1,6 +1,6 @@
---
title: "Notification destinations"
-description: "Route Maple alerts to Slack, PagerDuty, Discord, or any HTTP endpoint. How to add a destination, send a test, and get the right credentials for each provider."
+description: "Route Maple alerts to Slack, PagerDuty, Discord, Telegram, or any HTTP endpoint. How to add a destination, send a test, and get the right credentials for each provider."
group: "Alerting"
order: 0
---
@@ -54,6 +54,50 @@ Post alerts to a Discord channel via an incoming webhook.
1. In Discord: **Channel settings → Integrations → Webhooks → New Webhook**.
2. Copy the webhook URL (`https://discord.com/api/webhooks/...`) into the **Discord webhook URL** field.
+## Telegram
+
+Send alerts to a Telegram chat, group, or channel through a bot you create. Telegram has no
+per-channel webhook, so a destination needs two things: the bot's token, and the id of the chat it
+should post to.
+
+**1. Create the bot.** In Telegram, message [@BotFather](https://t.me/BotFather), send `/newbot`, and
+follow the prompts. BotFather replies with a token of the form `123456789:AAH…` — that is the **Bot
+token** field. Copy it without the `bot` prefix.
+
+**2. Add the bot to the chat.** Invite it to the group or channel you want alerts in (for a channel,
+add it as an administrator with permission to post messages). A bot cannot message a chat it isn't
+a member of.
+
+**3. Pick the chat.** Back in Maple, paste the bot token and click **Detect chats** — Maple asks
+Telegram which chats the bot can currently see and lists them by name. Pick one and the chat ID
+fills itself in.
+
+Detection reads the bot's recent updates, so a few things are worth knowing:
+
+- **Adding the bot is enough.** You don't need to send a message first — Telegram notifies the bot
+ when it's added to a chat, and that's what Maple reads.
+- **Telegram keeps about 24 hours of history.** An empty list usually means the bot was added
+ longer ago than that. Send it a message (or remove and re-add it) and detect again.
+- **A bot with a webhook registered can't be inspected this way.** Telegram allows only one reader
+ at a time. If you've pointed this bot at your own webhook, enter the chat ID by hand instead.
+
+To find the ID manually, post a message in the chat and open
+`https://api.telegram.org/bot
/getUpdates`, then read `result[].message.chat.id`. Group
+and channel IDs are negative (`-1001234567890`); a one-to-one chat is positive. Public channels can
+use `@channelusername` instead.
+
+| Field | Notes |
+| ------------- | --------------------------------------------------------------------------- |
+| **Bot token** | From @BotFather. Write-only — never returned after saving. |
+| **Chat ID** | `-1001234567890`, or `@channelusername` for a public channel. |
+
+When you save, Maple verifies the token and checks that the bot can actually reach that chat, so the
+usual mistake — a valid token pointed at a group the bot was never added to — is caught immediately
+rather than at the first real alert.
+
+Alerts arrive as a formatted message with **Open in Maple** and **Ask Maple AI** buttons underneath,
+and the alert chart as the message preview when one is available.
+
## Webhook
POST a signed JSON payload to any HTTP endpoint you control — useful for custom routing, on-call tools without a native integration, or your own automation.
diff --git a/apps/web/src/components/alerts/destination-dialog.tsx b/apps/web/src/components/alerts/destination-dialog.tsx
index b193c55c3..bc397844d 100644
--- a/apps/web/src/components/alerts/destination-dialog.tsx
+++ b/apps/web/src/components/alerts/destination-dialog.tsx
@@ -25,12 +25,12 @@ import {
resolveSearchQuery,
} from "@/components/alerts/slack-channel-search"
import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client"
-import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client"
-import { publicError } from "@/lib/error-messages"
+import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client"
+import { displayError, publicError } from "@/lib/error-messages"
import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom"
import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom"
import type { HazelChannelsListResponse } from "@maple/domain/http"
-import type { V2SlackChannelList } from "@maple/domain/http/v2"
+import type { V2SlackChannelList, V2TelegramChat } from "@maple/domain/http/v2"
import { Exit, Option } from "effect"
import { Link } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react"
@@ -86,6 +86,19 @@ interface DestinationDialogProps {
*/
const isValidPagerDutyKey = (key: string): boolean => /^[A-Za-z0-9]{32}$/.test(key.trim())
+/**
+ * Mirrors `TELEGRAM_BOT_TOKEN_PATTERN` on the server. Only gates the "Detect
+ * chats" button — the server re-checks, and it owns the message shown on save.
+ */
+const isValidTelegramToken = (token: string): boolean => /^\d{5,}:[A-Za-z0-9_-]{30,}$/.test(token.trim())
+
+const TELEGRAM_CHAT_TYPE_LABELS = {
+ private: "Direct message",
+ group: "Group",
+ supergroup: "Group",
+ channel: "Channel",
+} satisfies Record
+
function isFormReady(form: DestinationFormState, isEditing: boolean): boolean {
if (form.name.trim().length === 0) return false
switch (form.type) {
@@ -98,6 +111,13 @@ function isFormReady(form: DestinationFormState, isEditing: boolean): boolean {
// stored one.
case "discord":
return isEditing || form.webhookUrl.trim().length > 0
+ case "telegram":
+ // The chat id is not a secret and is never returned, so editing always
+ // requires it; the token may stay blank to keep the stored one.
+ return (
+ form.telegramChatId.trim().length > 0 &&
+ (isEditing || form.telegramBotToken.trim().length > 0)
+ )
case "pagerduty":
// Editing with a blank key keeps the stored one; otherwise require a
// well-formed routing key.
@@ -901,6 +921,99 @@ function FieldHelper({ provider }: { provider: DestinationProvider }) {
)
}
+/**
+ * Turns "read a negative integer out of a raw `getUpdates` payload" into
+ * picking a chat by name — the step where this setup otherwise fails.
+ *
+ * Only ever additive to the field: the manual input stays editable, because
+ * Telegram keeps updates for about 24 hours and a bot with a webhook cannot be
+ * inspected at all, so discovery legitimately comes back empty for setups that
+ * are perfectly valid.
+ */
+function TelegramChatPicker({
+ botToken,
+ onSelect,
+}: {
+ botToken: string
+ onSelect: (chatId: string) => void
+}) {
+ const [chats, setChats] = useState | null>(null)
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const detect = useAtomSet(MapleApiV2AtomClient.mutation("alertDestinations", "telegramChats"), {
+ mode: "promiseExit",
+ })
+
+ const tokenReady = isValidTelegramToken(botToken)
+
+ const runDetect = async () => {
+ setBusy(true)
+ setError(null)
+ setChats(null)
+ const result = await detect({ payload: { bot_token: botToken.trim() } })
+ setBusy(false)
+ if (Exit.isSuccess(result)) {
+ const found = result.value.chats
+ setChats(found)
+ // One chat is the common case — the bot was just added to a single
+ // group. Skip the pointless list of one and fill the field.
+ if (found.length === 1 && found[0] !== undefined) onSelect(found[0].id)
+ return
+ }
+ setError(displayError(result.cause).message)
+ }
+
+ return (
+
+
+
+
+
+ {error !== null ?
{error}
: null}
+ {chats !== null && chats.length === 0 ? (
+
+ No recent chats. Add the bot to the group or channel (or send it a message), then detect
+ again. Telegram only keeps the last 24 hours.
+
+ ) : null}
+ {chats !== null && chats.length > 0 ? (
+
+ {chats.map((chat) => (
+
+ ))}
+
+ ) : null}
+
+ )
+}
+
export function DestinationDialog({
open,
onOpenChange,
@@ -1050,6 +1163,66 @@ export function DestinationDialog({
)}
+ {form.type === "telegram" && (
+ <>
+
+
+
+ onFormChange((current) => ({
+ ...current,
+ telegramBotToken: event.target.value,
+ }))
+ }
+ placeholder={
+ isEditing
+ ? "Leave blank to keep current token"
+ : "123456789:ABC-DEF..."
+ }
+ className="font-mono text-xs"
+ />
+
+ In Telegram: message @BotFather, send /newbot, then
+ copy the token it replies with.
+
+
+
diff --git a/apps/web/src/components/alerts/destination-provider.tsx b/apps/web/src/components/alerts/destination-provider.tsx
index 6dc0df224..38f320f45 100644
--- a/apps/web/src/components/alerts/destination-provider.tsx
+++ b/apps/web/src/components/alerts/destination-provider.tsx
@@ -1,6 +1,6 @@
import type { AlertDestinationType } from "@maple/domain/http"
import { useState, type ReactNode } from "react"
-import { CodeIcon, DiscordIcon, EnvelopeIcon, HazelIcon, SlackIcon } from "@/components/icons"
+import { CodeIcon, DiscordIcon, EnvelopeIcon, HazelIcon, SlackIcon, TelegramIcon } from "@/components/icons"
import { SLACK_ACCENT, SLACK_ACCENT_ON_LIGHT } from "@/components/integrations/integration-catalog"
import { cn } from "@maple/ui/lib/utils"
@@ -151,6 +151,21 @@ export const PROVIDERS: Record
= {
docsUrl: "https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks",
docsLabel: "Discord webhook docs",
},
+ telegram: {
+ type: "telegram",
+ label: "Telegram",
+ description: "Send alerts to a Telegram chat, group, or channel via a bot you create.",
+ accent: "#26A5E4",
+ accentBg: "rgba(38,165,228,0.16)",
+ // The brand blue is 1.9:1 on its own light tint — deepen it for light
+ // (4.81:1), keep the brand hue on dark (4.87:1).
+ accentText: "light-dark(#0B6E9E, #26A5E4)",
+ // A mid-luminance blue: white lands at 2.77:1, #1E1B17 at 6.20:1.
+ accentOn: INK_ON_BRIGHT_ACCENT,
+ fallbackIcon: ({ size = 22, className }) => ,
+ docsUrl: "https://maple.dev/docs/alerting/notification-destinations#telegram",
+ docsLabel: "Telegram setup guide",
+ },
email: {
type: "email",
label: "Email",
@@ -168,6 +183,7 @@ export const PROVIDERS: Record = {
export const DESTINATION_TYPES: ReadonlyArray = [
"slack-bot",
"discord",
+ "telegram",
"email",
"pagerduty",
"webhook",
diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts
index af67e4270..5a54ed52f 100644
--- a/apps/web/src/components/icons/index.ts
+++ b/apps/web/src/components/icons/index.ts
@@ -142,6 +142,7 @@ export { SquareIcon } from "./square"
export { SquareTerminalIcon } from "./square-terminal"
export { StarIcon, StarFilledIcon } from "./star"
export { SunIcon } from "./sun"
+export { TelegramIcon } from "./telegram"
export { TrashIcon } from "./trash"
export { TruckIcon } from "./truck"
export { WarpStreamIcon } from "./warpstream"
diff --git a/apps/web/src/components/icons/telegram.tsx b/apps/web/src/components/icons/telegram.tsx
new file mode 100644
index 000000000..a4415ad3b
--- /dev/null
+++ b/apps/web/src/components/icons/telegram.tsx
@@ -0,0 +1,19 @@
+import type { IconProps } from "./icon"
+
+function TelegramIcon({ size = 24, className, ...props }: IconProps) {
+ return (
+
+ )
+}
+export { TelegramIcon }
diff --git a/apps/web/src/lib/alerts/form-utils.test.ts b/apps/web/src/lib/alerts/form-utils.test.ts
index 39dce9cb6..444b01307 100644
--- a/apps/web/src/lib/alerts/form-utils.test.ts
+++ b/apps/web/src/lib/alerts/form-utils.test.ts
@@ -207,6 +207,43 @@ describe("slack-bot destination params", () => {
})
})
+describe("telegram destination params", () => {
+ it("builds create params with a trimmed token and chat id", () => {
+ const params = buildDestinationCreateParamsV2({
+ ...defaultDestinationForm("telegram"),
+ name: " On-call ",
+ telegramBotToken: " 123456789:AAtoken ",
+ telegramChatId: " -1001234567890 ",
+ })
+ expect(params).toEqual({
+ type: "telegram",
+ name: "On-call",
+ enabled: true,
+ bot_token: "123456789:AAtoken",
+ chat_id: "-1001234567890",
+ })
+ })
+
+ /**
+ * The chat id comes back on edit (via `channel_label`) but the token never
+ * does — so a rename must not blank the stored token.
+ */
+ it("keeps the stored token on update when the field is left blank", () => {
+ const params = buildDestinationUpdateParamsV2({
+ ...defaultDestinationForm("telegram"),
+ name: "Renamed",
+ telegramBotToken: "",
+ telegramChatId: "-1001234567890",
+ })
+ expect(params).toEqual({
+ type: "telegram",
+ enabled: true,
+ name: "Renamed",
+ chat_id: "-1001234567890",
+ })
+ })
+})
+
describe("raw SQL alert query validation", () => {
it("recognizes explicit value aliases and value columns", () => {
expect(rawSqlHasValueColumn("SELECT count() AS value FROM traces WHERE $__orgFilter")).toBe(true)
diff --git a/apps/web/src/lib/alerts/form-utils.ts b/apps/web/src/lib/alerts/form-utils.ts
index 16f6a7802..7f4bda795 100644
--- a/apps/web/src/lib/alerts/form-utils.ts
+++ b/apps/web/src/lib/alerts/form-utils.ts
@@ -424,6 +424,9 @@ export type DestinationFormState = {
integrationKey: string
url: string
signingSecret: string
+ /** Telegram bot token from @BotFather, and the chat it posts to. */
+ telegramBotToken: string
+ telegramChatId: string
hazelOrganizationId: string
hazelOrganizationName: string
hazelOrganizationLogoUrl: string | null
@@ -447,6 +450,8 @@ export function defaultDestinationForm(type: AlertDestinationType = "slack-bot")
integrationKey: "",
url: "",
signingSecret: "",
+ telegramBotToken: "",
+ telegramChatId: "",
hazelOrganizationId: "",
hazelOrganizationName: "",
hazelOrganizationLogoUrl: null,
@@ -470,6 +475,10 @@ export function destinationToFormState(destination: AlertDestinationDocument): D
integrationKey: "",
url: "",
signingSecret: "",
+ // The bot token is a secret and never returned; the chat id is not, and
+ // comes back as `channelLabel` so an edit doesn't demand retyping it.
+ telegramBotToken: "",
+ telegramChatId: destination.type === "telegram" ? (destination.channelLabel ?? "") : "",
hazelOrganizationId: "",
hazelOrganizationName: "",
hazelOrganizationLogoUrl: null,
@@ -530,6 +539,14 @@ export function buildDestinationCreateParamsV2(form: DestinationFormState): V2Al
enabled: form.enabled,
webhook_url: form.webhookUrl.trim(),
}
+ case "telegram":
+ return {
+ type: "telegram",
+ name: form.name.trim(),
+ enabled: form.enabled,
+ bot_token: form.telegramBotToken.trim(),
+ chat_id: form.telegramChatId.trim(),
+ }
case "email":
return {
type: "email",
@@ -609,6 +626,17 @@ export function buildDestinationUpdateParamsV2(form: DestinationFormState): V2Al
...(webhookUrl ? { webhook_url: webhookUrl } : undefined),
}
}
+ case "telegram": {
+ const botToken = form.telegramBotToken.trim()
+ const chatId = form.telegramChatId.trim()
+ return {
+ type: "telegram",
+ enabled: form.enabled,
+ ...(name ? { name } : undefined),
+ ...(botToken ? { bot_token: botToken } : undefined),
+ ...(chatId ? { chat_id: chatId } : undefined),
+ }
+ }
case "email":
return {
type: "email",
diff --git a/packages/alchemy-maple/src/AlertDestination.ts b/packages/alchemy-maple/src/AlertDestination.ts
index c37f79183..e3db1e46a 100644
--- a/packages/alchemy-maple/src/AlertDestination.ts
+++ b/packages/alchemy-maple/src/AlertDestination.ts
@@ -28,6 +28,7 @@ export type AlertDestinationProps =
| (DestinationBaseProps & { type: "pagerduty"; integration_key: SecretInput })
| (DestinationBaseProps & { type: "webhook"; url: string; signing_secret?: SecretInput })
| (DestinationBaseProps & { type: "discord"; webhook_url: SecretInput })
+ | (DestinationBaseProps & { type: "telegram"; bot_token: SecretInput; chat_id: string })
| (DestinationBaseProps & { type: "email"; member_user_ids: string[] })
export type AlertDestination = Resource<
@@ -45,9 +46,9 @@ export type AlertDestination = Resource<
>
/**
- * A notification channel (PagerDuty, webhook, Discord, or workspace-member
- * email) that `Maple.AlertRule`s deliver to. Slack and Hazel destinations use
- * their installed integrations and are managed in Maple.
+ * A notification channel (PagerDuty, webhook, Discord, Telegram, or
+ * workspace-member email) that `Maple.AlertRule`s deliver to. Slack and Hazel
+ * destinations use their installed integrations and are managed in Maple.
*
* @example
* ```typescript
@@ -64,7 +65,7 @@ const AlertDestinationResource = Resource("Maple.AlertDestinat
* Alchemy types resource props as `InputProps` — a mapped type, which
* collapses a discriminated union to the keys its members share. That erases
* every channel-specific field (`webhook_url`, `integration_key`, `url`,
- * `member_user_ids`), making the resource uncallable. Restore the union on the
+ * `bot_token`, `member_user_ids`), making the resource uncallable. Restore the union on the
* call signature; props are forwarded untouched, and `alertDestinationProps`
* keeps the round-trip honest in the type test.
*/
@@ -102,6 +103,10 @@ const desiredBody = (props: AlertDestinationProps): Record => {
case "discord":
body.webhook_url = unwrap(props.webhook_url)
break
+ case "telegram":
+ body.bot_token = unwrap(props.bot_token)
+ body.chat_id = props.chat_id
+ break
case "email":
body.member_user_ids = props.member_user_ids
break
diff --git a/packages/alchemy-maple/test/alert-destination-props.test.ts b/packages/alchemy-maple/test/alert-destination-props.test.ts
index 07c7091e1..962b0cd39 100644
--- a/packages/alchemy-maple/test/alert-destination-props.test.ts
+++ b/packages/alchemy-maple/test/alert-destination-props.test.ts
@@ -16,6 +16,12 @@ export const accepts = Effect.gen(function* () {
yield* AlertDestination("pagerduty", { type: "pagerduty", name: "p", integration_key: "k" })
yield* AlertDestination("webhook", { type: "webhook", name: "w", url: "https://x", signing_secret: "s" })
yield* AlertDestination("discord", { type: "discord", name: "d", webhook_url: "u" })
+ yield* AlertDestination("telegram", {
+ type: "telegram",
+ name: "t",
+ bot_token: "123456789:AAtoken",
+ chat_id: "-1001234567890",
+ })
yield* AlertDestination("email", { type: "email", name: "e", member_user_ids: ["u_1"] })
})
@@ -27,6 +33,8 @@ export const rejects = Effect.gen(function* () {
yield* AlertDestination("b", { type: "pagerduty", name: "x", integration_key: "k", url: "u" })
// @ts-expect-error email requires member_user_ids
yield* AlertDestination("c", { type: "email", name: "x" })
+ // @ts-expect-error telegram requires chat_id alongside the token
+ yield* AlertDestination("d", { type: "telegram", name: "x", bot_token: "123456789:AAtoken" })
})
it("keeps the compile-time destination examples", () => {
diff --git a/packages/alchemy-maple/test/contract.test.ts b/packages/alchemy-maple/test/contract.test.ts
index fa83990a6..c0923d8d6 100644
--- a/packages/alchemy-maple/test/contract.test.ts
+++ b/packages/alchemy-maple/test/contract.test.ts
@@ -104,6 +104,12 @@ describe("provider request bodies decode against the real v2 create-param schema
name: "Discord",
webhook_url: "https://discord.com/api/webhooks/x",
}),
+ _alertDestinationCreateBody({
+ type: "telegram",
+ name: "Telegram",
+ bot_token: "123456789:AAHqwertyuiopasdfghjklzxcvbnm123456",
+ chat_id: "-1001234567890",
+ }),
_alertDestinationCreateBody({
type: "email",
name: "Email",
diff --git a/packages/domain/src/http/alerts.ts b/packages/domain/src/http/alerts.ts
index a0a98d71c..52401bf61 100644
--- a/packages/domain/src/http/alerts.ts
+++ b/packages/domain/src/http/alerts.ts
@@ -22,6 +22,7 @@ export const AlertDestinationType = Schema.Literals([
"webhook",
"hazel-oauth",
"discord",
+ "telegram",
"email",
]).annotate({
identifier: "@maple/AlertDestinationType",
@@ -207,6 +208,18 @@ export class DiscordAlertDestinationConfig extends Schema.Class(
+ "TelegramAlertDestinationConfig",
+)({
+ type: Schema.Literal("telegram"),
+ name: ChannelLabel,
+ /** Bot token from @BotFather (`:`). Write-only — never returned. */
+ botToken: NonEmptyString,
+ /** Target chat: a numeric id (`-1001234567890`) or an `@channelusername`. */
+ chatId: NonEmptyString,
+ enabled: Schema.optionalKey(Schema.Boolean),
+}) {}
+
export const MAX_EMAIL_RECIPIENTS = 10
/**
@@ -234,6 +247,7 @@ export const AlertDestinationCreateRequest = Schema.Union([
WebhookAlertDestinationConfig,
HazelOAuthAlertDestinationConfig,
DiscordAlertDestinationConfig,
+ TelegramAlertDestinationConfig,
EmailAlertDestinationConfig,
])
export type AlertDestinationCreateRequest = Schema.Schema.Type
@@ -284,6 +298,15 @@ export class UpdateDiscordAlertDestinationConfig extends Schema.Class(
+ "UpdateTelegramAlertDestinationConfig",
+)({
+ name: OptionalNonEmptyString,
+ botToken: Schema.optionalKey(Schema.String),
+ chatId: Schema.optionalKey(Schema.String),
+ enabled: Schema.optionalKey(Schema.Boolean),
+}) {}
+
export class UpdateEmailAlertDestinationConfig extends Schema.Class(
"UpdateEmailAlertDestinationConfig",
)({
@@ -313,6 +336,10 @@ export const AlertDestinationUpdateRequest = Schema.Union([
type: Schema.Literal("discord"),
...UpdateDiscordAlertDestinationConfig.fields,
}),
+ Schema.Struct({
+ type: Schema.Literal("telegram"),
+ ...UpdateTelegramAlertDestinationConfig.fields,
+ }),
Schema.Struct({
type: Schema.Literal("email"),
...UpdateEmailAlertDestinationConfig.fields,
diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts
index 3967654bb..03d7ba993 100644
--- a/packages/domain/src/http/v2/alert-destinations.ts
+++ b/packages/domain/src/http/v2/alert-destinations.ts
@@ -79,7 +79,7 @@ export const V2AlertDestination = Schema.Struct({
}),
type: AlertDestinationType.annotate({
description:
- "The delivery channel: `slack-bot`, `pagerduty`, `webhook`, `hazel-oauth`, `discord`, or `email`. Immutable after creation.",
+ "The delivery channel: `slack-bot`, `pagerduty`, `webhook`, `hazel-oauth`, `discord`, `telegram`, or `email`. Immutable after creation.",
examples: ["slack-bot"],
}),
enabled: Schema.Boolean.annotate({
@@ -93,7 +93,8 @@ export const V2AlertDestination = Schema.Struct({
examples: ["Slack bot → #incidents"],
}),
channel_label: Schema.NullOr(Schema.String).annotate({
- description: "Optional display label for the target channel (Slack destinations), or `null`.",
+ description:
+ "Optional display label for the target channel — the channel name for Slack, the chat ID for Telegram — or `null`.",
examples: ["#incidents"],
}),
member_user_ids: Schema.NullOr(Schema.Array(Schema.String)).annotate({
@@ -113,7 +114,7 @@ export const V2AlertDestination = Schema.Struct({
identifier: "AlertDestination",
title: "Alert Destination",
description:
- "A notification channel that alert rules deliver to (Slack bot, PagerDuty, generic webhook, Hazel OAuth, Discord, or workspace-member email). Channel secrets are write-only: responses carry a redacted `summary` instead.",
+ "A notification channel that alert rules deliver to (Slack bot, PagerDuty, generic webhook, Hazel OAuth, Discord, Telegram, or workspace-member email). Channel secrets are write-only: responses carry a redacted `summary` instead.",
examples: [wireExample(alertDestinationExample)],
})
export type V2AlertDestination = Schema.Schema.Type
@@ -213,6 +214,20 @@ const V2DiscordDestinationCreateParams = Schema.Struct({
enabled: enabledField,
}).annotate({ identifier: "AlertDestinationCreateDiscord", title: "Discord destination" })
+const V2TelegramDestinationCreateParams = Schema.Struct({
+ type: Schema.Literal("telegram"),
+ name: nameField,
+ bot_token: NonEmptyString.annotate({
+ description: "The bot token issued by @BotFather. Write-only — never returned.",
+ }),
+ chat_id: NonEmptyString.annotate({
+ description:
+ "The target chat: a numeric id such as `-1001234567890`, or an `@channelusername`. The bot must be a member of the chat.",
+ examples: ["-1001234567890"],
+ }),
+ enabled: enabledField,
+}).annotate({ identifier: "AlertDestinationCreateTelegram", title: "Telegram destination" })
+
const V2EmailDestinationCreateParams = Schema.Struct({
type: Schema.Literal("email"),
name: nameField,
@@ -228,6 +243,7 @@ export const V2AlertDestinationCreateParams = Schema.Union([
V2WebhookDestinationCreateParams,
V2HazelOAuthDestinationCreateParams,
V2DiscordDestinationCreateParams,
+ V2TelegramDestinationCreateParams,
V2EmailDestinationCreateParams,
]).annotate({
identifier: "AlertDestinationCreateParams",
@@ -290,6 +306,13 @@ export const V2AlertDestinationUpdateParams = Schema.Union([
webhook_url: Schema.optionalKey(Schema.String),
enabled: Schema.optionalKey(Schema.Boolean),
}).annotate({ identifier: "AlertDestinationUpdateDiscord", title: "Discord destination update" }),
+ Schema.Struct({
+ type: Schema.Literal("telegram"),
+ name: optionalNameField,
+ bot_token: Schema.optionalKey(Schema.String),
+ chat_id: Schema.optionalKey(Schema.String),
+ enabled: Schema.optionalKey(Schema.Boolean),
+ }).annotate({ identifier: "AlertDestinationUpdateTelegram", title: "Telegram destination update" }),
Schema.Struct({
type: Schema.Literal("email"),
name: optionalNameField,
@@ -384,6 +407,61 @@ const emailRecipientErrors = publicErrors(
AlertMemberDirectoryNotConfiguredError,
AlertMemberDirectoryUnavailableError,
)
+export const V2TelegramChatsParams = Schema.Struct({
+ bot_token: NonEmptyString.annotate({
+ description:
+ "The bot token to inspect. Write-only, and not stored by this call — it is used for one `getUpdates` read and discarded.",
+ }),
+}).annotate({
+ identifier: "TelegramChatsParams",
+ title: "Telegram chat discovery parameters",
+})
+export type V2TelegramChatsParams = Schema.Schema.Type
+
+export const V2TelegramChat = Schema.Struct({
+ id: Schema.String.annotate({
+ description:
+ "The chat ID, as a string. Negative for groups and channels — pass it verbatim as `chat_id` when creating the destination.",
+ examples: ["-1001234567890"],
+ }),
+ title: Schema.String.annotate({
+ description: "Display name of the chat: its title, or the username for a one-to-one chat.",
+ examples: ["Acme On-call"],
+ }),
+ type: Schema.Literals(["private", "group", "supergroup", "channel"]).annotate({
+ description: "Telegram's chat type.",
+ examples: ["supergroup"],
+ }),
+}).annotate({
+ identifier: "TelegramChat",
+ title: "Telegram chat",
+ description: "A chat the bot can currently see.",
+ examples: [wireExample({ id: "-1001234567890", title: "Acme On-call", type: "supergroup" })],
+})
+export type V2TelegramChat = Schema.Schema.Type
+
+export const V2TelegramChatList = Schema.Struct({
+ object: Schema.Literal("alert_destination.telegram_chat_list").annotate({
+ description: 'The object type — always `"alert_destination.telegram_chat_list"`.',
+ }),
+ chats: Schema.Array(V2TelegramChat).annotate({
+ description:
+ 'The chats the bot has seen recently, most recent first. Telegram retains updates for about 24 hours, so an empty array means "nothing recent" — add the bot to the chat, or send it a message, and try again.',
+ }),
+}).annotate({
+ identifier: "TelegramChatList",
+ title: "Telegram chat list",
+ description:
+ "Chats discovered from the bot's pending updates. Not the standard list envelope: there is no cursor, because Telegram exposes a short retention window rather than a paginated inventory.",
+ examples: [
+ wireExample({
+ object: "alert_destination.telegram_chat_list",
+ chats: [{ id: "-1001234567890", title: "Acme On-call", type: "supergroup" }],
+ }),
+ ],
+})
+export type V2TelegramChatList = Schema.Schema.Type
+
const [destinationEncryption, destinationDecryption, destinationStoredConfigInvalid] = publicErrors(
AlertDestinationEncryptionError,
AlertDestinationDecryptionError,
@@ -434,6 +512,20 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina
}),
),
)
+ .add(
+ HttpApiEndpoint.post("telegramChats", "/telegram/chats", {
+ payload: V2TelegramChatsParams,
+ success: V2TelegramChatList,
+ error: [alertForbidden, alertValidation],
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "listTelegramChats",
+ summary: "List the chats a Telegram bot can see",
+ description:
+ "Reads a bot's pending updates and returns the chats it can currently post to, so a destination can be created by picking a chat instead of transcribing its numeric ID. The token is used for one read and never stored. Telegram retains updates for about 24 hours; a bot with a webhook registered cannot be inspected this way. Requires an org-admin role and the `alerts:write` scope.",
+ }),
+ ),
+ )
.add(
HttpApiEndpoint.get("retrieve", "/:id", {
params: { id: AlertDestinationPublicId },
@@ -518,6 +610,6 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina
OpenApi.annotations({
title: "Alert Destinations",
description:
- "Notification channels for alert rules — Slack bot, PagerDuty, generic webhooks, Hazel OAuth, Discord, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.",
+ "Notification channels for alert rules — Slack bot, PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.",
}),
) {}
diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts
index 49d1f7665..3c46c46da 100644
--- a/packages/domain/src/http/v2/openapi.test.ts
+++ b/packages/domain/src/http/v2/openapi.test.ts
@@ -162,6 +162,7 @@ describe("MapleApiV2 OpenAPI", () => {
"PATCH /v2/dashboards/{id}",
"PATCH /v2/scrape_targets/{id}",
"POST /v2/alerts/destinations",
+ "POST /v2/alerts/destinations/telegram/chats",
"POST /v2/alerts/destinations/{id}/test",
"POST /v2/alerts/rules",
"POST /v2/alerts/rules/preview",