From a02de32f3d5773c1e3230439580d550940643c15 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 20:35:52 +0200 Subject: [PATCH 1/3] feat(alerts): add Telegram as a notification destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram was reachable only through the generic `webhook` destination, which ships Maple's own JSON envelope — a shape the Bot API cannot consume. So in practice there was no way to route alerts to Telegram at all. Adds a first-class `telegram` destination: bot token + chat ID, encrypted at rest like every other channel secret, verified when you save it. Messages go out as HTML with an inline keyboard carrying "Open in Maple" and "Ask Maple AI", and the alert chart as the message's link preview. The transport registry made this mostly additive — a pure `render()` plus one arm in the exhaustive `Match`. Three things needed real care: - `guarded: false` + `sensitivePath: true` is a new combination. The host is a compile-time vendor constant, but the bot token rides in the URL path, so the span records `server.address` and must not record `url.path`. `Transport.ts` anticipated this and declares the two flags separately rather than inferring one from the other. - Telegram reports logical failures as HTTP 200 with `{ ok: false, error_code }`, the same lie Slack tells. The runner's status classifier never sees them, so `interpret` owns retryability: 401/403/404 permanent, 400 rejected, 429/5xx retryable. - `parse_mode: "HTML"` needs escaping for correctness, not just safety — `formatThresholdSummary` legitimately emits `> 5%`, which Telegram reads as an unclosed tag and rejects the whole message with a 400. The Slack summary line is now parameterized over its emphasis marker so the three-branch wording stays in one place; Telegram passes the identity and escapes the finished line. Save-time verification (`getMe` then `getChat`) mirrors `verifyPagerDutyRoutingKey` and fails open on anything ambiguous, so a Telegram outage can't block a save. `getChat` is the check that earns its keep: a valid token aimed at a group the bot was never added to is the dominant misconfiguration, and without it that surfaces only when a real alert silently fails to deliver. The chat ID comes back as `channelLabel`, not just inside the summary — otherwise renaming a destination would demand retyping the ID, since the token is write-only. No migration: `alert_destinations.type` is `text` and the config is jsonb plus an encrypted blob. --- .../src/routes/v2/alert-destinations.http.ts | 16 + .../AlertDeliveryDispatch.providers.test.ts | 71 +++++ .../services/alerts/AlertDeliveryDispatch.ts | 124 ++++++-- .../alerts/AlertDestinationHydration.test.ts | 27 ++ .../alerts/AlertDestinationHydration.ts | 5 + .../alerts/AlertDestinationsService.ts | 68 +++++ .../alerts/delivery/delivery-spans.test.ts | 11 + .../src/services/alerts/delivery/dispatch.ts | 2 + .../alerts/delivery/transports/render.test.ts | 15 +- .../delivery/transports/telegram.test.ts | 280 ++++++++++++++++++ .../alerts/delivery/transports/telegram.ts | 216 ++++++++++++++ .../MapleAPI/Sources/MapleAPI/openapi.json | 3 +- apps/landing/messages/en.json | 2 +- apps/landing/messages/ja.json | 2 +- apps/landing/messages/ko.json | 2 +- .../src/components/live/LiveAlertFiring.astro | 2 +- .../alerting/notification-destinations.md | 38 ++- .../components/alerts/destination-dialog.tsx | 61 ++++ .../alerts/destination-provider.tsx | 18 +- apps/web/src/components/icons/index.ts | 1 + apps/web/src/components/icons/telegram.tsx | 19 ++ apps/web/src/lib/alerts/form-utils.test.ts | 37 +++ apps/web/src/lib/alerts/form-utils.ts | 28 ++ .../alchemy-maple/src/AlertDestination.ts | 13 +- .../test/alert-destination-props.test.ts | 8 + packages/alchemy-maple/test/contract.test.ts | 6 + packages/domain/src/http/alerts.ts | 27 ++ .../domain/src/http/v2/alert-destinations.ts | 31 +- 28 files changed, 1098 insertions(+), 35 deletions(-) create mode 100644 apps/api/src/services/alerts/delivery/transports/telegram.test.ts create mode 100644 apps/api/src/services/alerts/delivery/transports/telegram.ts create mode 100644 apps/web/src/components/icons/telegram.tsx diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index 7c913daba..c74b544ae 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -8,6 +8,7 @@ import { AlertDestinationNotFoundError, PagerDutyAlertDestinationConfig, SlackBotAlertDestinationConfig, + TelegramAlertDestinationConfig, WebhookAlertDestinationConfig, } from "@maple/domain/http" import type { @@ -89,6 +90,14 @@ const toCreateRequest = (params: V2AlertDestinationCreateParams) => { webhookUrl: params.webhook_url, ...(params.enabled !== undefined ? { enabled: params.enabled } : undefined), }) + case "telegram": + return new TelegramAlertDestinationConfig({ + type: "telegram", + name: params.name, + botToken: params.bot_token, + chatId: params.chat_id, + ...(params.enabled !== undefined ? { enabled: params.enabled } : undefined), + }) case "email": return new EmailAlertDestinationConfig({ type: "email", @@ -163,6 +172,13 @@ const toUpdateRequest = (params: V2AlertDestinationUpdateParams): AlertDestinati ...shared, ...(params.webhook_url !== undefined ? { webhookUrl: params.webhook_url } : undefined), } + case "telegram": + return { + type: "telegram", + ...shared, + ...(params.bot_token !== undefined ? { botToken: params.bot_token } : undefined), + ...(params.chat_id !== undefined ? { chatId: params.chat_id } : undefined), + } case "email": return { type: "email", diff --git a/apps/api/src/services/alerts/AlertDeliveryDispatch.providers.test.ts b/apps/api/src/services/alerts/AlertDeliveryDispatch.providers.test.ts index 42df3ccbc..0f95af3c4 100644 --- a/apps/api/src/services/alerts/AlertDeliveryDispatch.providers.test.ts +++ b/apps/api/src/services/alerts/AlertDeliveryDispatch.providers.test.ts @@ -284,6 +284,77 @@ describe("dispatchDelivery: discord", () => { ) }) +describe("dispatchDelivery: telegram", () => { + const botToken = "123456789:tok3n-in-the-path" + const context = contextFor({ type: "telegram", botToken, chatId: "-1001234567890" }) + const sent = () => + new Response(JSON.stringify({ ok: true, result: { message_id: 4242 } }), { status: 200 }) + + it.effect("posts an HTML message with both links as inline buttons", () => + Effect.gen(function* () { + const { calls, fetchFn } = recorder(sent) + const result = yield* dispatch(context, fetchFn) + + assert.lengthOf(calls, 1) + const call = calls[0]! + assert.strictEqual(call.url, `https://api.telegram.org/bot${botToken}/sendMessage`) + assert.strictEqual(call.method, "POST") + + const body = JSON.parse(call.body) + assert.strictEqual(body.chat_id, "-1001234567890") + assert.strictEqual(body.parse_mode, "HTML") + assert.include(body.text, "Checkout error rate") + assert.deepStrictEqual( + body.reply_markup.inline_keyboard[0].map((b: { url: string }) => b.url), + [LINK, CHAT], + ) + // The token authenticates via the path; it must never also ride in the body. + assert.notInclude(call.body, botToken) + + assert.deepStrictEqual(result, { + providerMessage: "Delivered to Telegram chat -1001234567890", + providerReference: "4242", + responseCode: 200, + }) + }), + ) + + /** + * Telegram reports logical failures as HTTP 200 + `{ ok: false }`, so a + * status-only reading of the response would record a silent non-delivery as + * a success. + */ + it.effect("treats a 200 with ok:false as a failure", () => + Effect.gen(function* () { + const { fetchFn } = recorder( + () => + new Response( + JSON.stringify({ + ok: false, + error_code: 403, + description: "Forbidden: bot was kicked", + }), + { status: 200 }, + ), + ) + const error = yield* Effect.flip(dispatch(context, fetchFn)) + + assert.strictEqual(error.destinationType, "telegram") + assert.include(error.message, "bot was kicked") + assert.isFalse(error.error.retryable) + }), + ) + + it.effect("does not read the caller's payload json", () => + Effect.gen(function* () { + const { calls, fetchFn } = recorder(sent) + yield* dispatch(context, fetchFn, '{"totally":"ignored"}') + + assert.notInclude(calls[0]!.body, "totally") + }), + ) +}) + describe("dispatchDelivery: hazel-oauth", () => { const config = { type: "hazel-oauth" as const, diff --git a/apps/api/src/services/alerts/AlertDeliveryDispatch.ts b/apps/api/src/services/alerts/AlertDeliveryDispatch.ts index 717c1ab88..7ef910825 100644 --- a/apps/api/src/services/alerts/AlertDeliveryDispatch.ts +++ b/apps/api/src/services/alerts/AlertDeliveryDispatch.ts @@ -113,32 +113,43 @@ const escapeSlackMrkdwn = (value: string): string => * Slack's Block Kit guidance (header as subject line, then a short clear * sentence, with details relegated to fields/context). */ -const buildSlackSummaryLine = ( - context: Pick< - DispatchContext, - | "eventType" - | "signalType" - | "signalDisplay" - | "comparator" - | "threshold" - | "thresholdUpper" - | "value" - | "windowMinutes" - >, -): string => { +type SummaryLineContext = Pick< + DispatchContext, + | "eventType" + | "signalType" + | "signalDisplay" + | "comparator" + | "threshold" + | "thresholdUpper" + | "value" + | "windowMinutes" +> + +/** + * Parameterized over `em` (the provider's emphasis marker) rather than + * duplicated per provider: the three-branch wording is the part that would + * drift, and `*bold*` is the only thing Slack and Telegram disagree on here. + * Telegram passes the identity — it escapes the finished line and puts its bold + * in the title, because `comparatorBreachPhrase` embeds `<`/`>` comparators + * that HTML mode would otherwise read as tags. + */ +const buildSummaryLine = (context: SummaryLineContext, em: (value: string) => string): string => { const signal = formatSignalLabel(context) const observed = formatSignalMetric(context.value, signalDisplayOf(context)) const window = formatWindow(context.windowMinutes) if (context.eventType === "test") { - return `This is a test notification. Live alerts fire when *${signal}* is ${comparatorBreachPhrase(context)} over a ${window} window.` + return `This is a test notification. Live alerts fire when ${em(signal)} is ${comparatorBreachPhrase(context)} over a ${window} window.` } if (context.eventType === "resolve") { - const now = context.value != null ? ` — now *${observed}*` : "" - return `*${signal}* is back within its threshold (${formatThresholdSummary(context)})${now}.` + const now = context.value != null ? ` — now ${em(observed)}` : "" + return `${em(signal)} is back within its threshold (${formatThresholdSummary(context)})${now}.` } - return `*${signal}* is *${observed}* — ${comparatorBreachPhrase(context)}, measured over the last ${window}.` + return `${em(signal)} is ${em(observed)} — ${comparatorBreachPhrase(context)}, measured over the last ${window}.` } +const buildSlackSummaryLine = (context: SummaryLineContext): string => + buildSummaryLine(context, (value) => `*${value}*`) + const buildSlackActionsBlock = (linkUrl: string, chatUrl: string) => ({ type: "actions", elements: [ @@ -273,6 +284,85 @@ export const buildDiscordEmbeds = (context: DispatchContext, linkUrl: string, ch }, ] +/* -------------------------------------------------------------------------- */ +/* Telegram */ +/* -------------------------------------------------------------------------- */ + +/** + * Telegram's `parse_mode: "HTML"` accepts only a small tag set (`b`, `i`, + * `code`, `pre`, `a`, `s`, `u`, `tg-spoiler`) and rejects the whole message + * with 400 `can't parse entities` if anything else looks like a tag. So every + * dynamic value is escaped and the markup is added afterwards — note that this + * is not merely an injection concern: `comparatorBreachPhrase` legitimately + * produces `> 5%`, which is a parse failure unescaped. + */ +const escapeTelegramHtml = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">") + +/** Telegram's hard cap on `sendMessage.text`. */ +const TELEGRAM_TEXT_LIMIT = 4096 + +const telegramFooter = (context: Pick): string => { + const parts = ["\u{1F341} Maple Alerts"] + if (context.sparkline) parts.push(`${escapeTelegramHtml(context.sparkline)}`) + if (context.incidentId) parts.push(`Incident ${escapeTelegramHtml(context.incidentId)}`) + if (context.sentAtMs != null) parts.push(new Date(context.sentAtMs).toISOString()) + return parts.join(" \u{00B7} ") +} + +const telegramDetailLine = ( + context: Pick, +): string => { + const group = displayGroupKey(context.groupKey) + const parts = [ + `Severity ${escapeTelegramHtml(formatSeverityLabel(context.severity))}`, + `Window ${escapeTelegramHtml(formatWindow(context.windowMinutes))}`, + ] + if (group != null) parts.push(`Group ${escapeTelegramHtml(group)}`) + return parts.join(" \u{00B7} ") +} + +const telegramBody = (title: string, lines: ReadonlyArray): string => + truncate([title, "", ...lines].join("\n"), TELEGRAM_TEXT_LIMIT) + +/** No link arguments: Telegram carries both links in the inline keyboard. */ +export const buildTelegramText = (context: DispatchContext): string => { + const title = `${eventTypeEmoji(context.eventType)} ${escapeTelegramHtml(context.ruleName)} \u{2014} ${escapeTelegramHtml(formatEventTypeLabel(context.eventType))}` + return telegramBody(title, [ + escapeTelegramHtml(buildSummaryLine(context, (value) => value)), + "", + telegramDetailLine(context), + telegramFooter(context), + ]) +} + +/** + * Minimal Markdown -> Telegram HTML transform for user-authored templates: + * `**b**` -> `b`, `[t](url)` -> `t`. + * + * Escaped BEFORE the rewrites, exactly as {@link markdownToSlackMrkdwn} is, so + * only the tags this function builds itself reach Telegram. Link targets are + * restricted to http/https: Telegram also resolves `tg://` URLs, which would + * let a template author aim a button at an arbitrary in-app action. + */ +const markdownToTelegramHtml = (markdown: string): string => + escapeTelegramHtml(markdown) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, text: string, url: string) => + /^https?:\/\//i.test(url) ? `${text}` : match, + ) + +export const buildTelegramTextFromTemplate = ( + title: string, + body: string, + context: Pick, +): string => + telegramBody(`${escapeTelegramHtml(title)}`, [ + markdownToTelegramHtml(body), + "", + telegramFooter(context), + ]) + /* -------------------------------------------------------------------------- */ /* Templated notifications */ /* -------------------------------------------------------------------------- */ diff --git a/apps/api/src/services/alerts/AlertDestinationHydration.test.ts b/apps/api/src/services/alerts/AlertDestinationHydration.test.ts index 617ef527c..c8a4f4b7a 100644 --- a/apps/api/src/services/alerts/AlertDestinationHydration.test.ts +++ b/apps/api/src/services/alerts/AlertDestinationHydration.test.ts @@ -108,6 +108,33 @@ describe("hydrateDestinationRow", () => { }).pipe(Effect.provide(testDb.layer)) }) + it.effect("round-trips a telegram destination without leaking the token into config_json", () => { + const testDb = createTestDb(createdDbs) + const id = asDestinationId("00000000-0000-4000-8000-000000000009") + const secretConfig: DestinationSecretConfig = { + type: "telegram", + botToken: "123456789:AAHqwertyuiopasdfghjklzxcvbnm123456", + chatId: "-1001234567890", + } + return Effect.gen(function* () { + yield* seedDestination({ + id, + type: "telegram", + publicConfig: { summary: "Chat -1001234567890", channelLabel: "-1001234567890" }, + secretConfig, + }) + const row = yield* loadDestination(id) + + // `config_json` is Electric-synced to the browser, so the bot token may + // exist only inside the encrypted blob. + assert.notInclude(JSON.stringify(row.configJson), "AAHqwerty") + + const hydrated = yield* hydrateDestinationRow(row, ENCRYPTION_KEY, hydrationErrors) + assert.deepStrictEqual(hydrated.secretConfig, secretConfig) + assert.strictEqual(hydrated.publicConfig.channelLabel, "-1001234567890") + }).pipe(Effect.provide(testDb.layer)) + }) + it.effect("hydrates a chat destination carrying optional public config keys", () => { const testDb = createTestDb(createdDbs) const id = asDestinationId("00000000-0000-4000-8000-000000000002") diff --git a/apps/api/src/services/alerts/AlertDestinationHydration.ts b/apps/api/src/services/alerts/AlertDestinationHydration.ts index eb6c89cd2..011ef186b 100644 --- a/apps/api/src/services/alerts/AlertDestinationHydration.ts +++ b/apps/api/src/services/alerts/AlertDestinationHydration.ts @@ -50,6 +50,11 @@ const DestinationSecretConfigSchema = Schema.Union([ type: Schema.Literal("discord"), webhookUrl: Schema.String, }), + Schema.Struct({ + type: Schema.Literal("telegram"), + botToken: Schema.String, + chatId: Schema.String, + }), Schema.Struct({ type: Schema.Literal("email"), // Snapshot of the selected workspace members, resolved from the auth diff --git a/apps/api/src/services/alerts/AlertDestinationsService.ts b/apps/api/src/services/alerts/AlertDestinationsService.ts index dd1096667..09eacb1fb 100644 --- a/apps/api/src/services/alerts/AlertDestinationsService.ts +++ b/apps/api/src/services/alerts/AlertDestinationsService.ts @@ -41,6 +41,7 @@ import { } from "@/services/org/OrgMembersService" import { SlackBotTokenResolver } from "@/services/integrations/slack-bot-token" import { PAGERDUTY_ROUTING_KEY_PATTERN, verifyPagerDutyRoutingKey } from "./delivery/transports/pagerduty" +import { TELEGRAM_BOT_TOKEN_PATTERN, verifyTelegramCredentials } from "./delivery/transports/telegram" import { DestinationPublicConfigSchema, type DestinationPublicConfig, @@ -119,6 +120,9 @@ const summarizeWebhookUrl = (url: string) => onSome: (parsed) => `POST ${parsed.host}`, }) +/** A chat id is not a secret, but it is also not a name — label it as what it is. */ +const telegramSummary = (chatId: string) => `Chat ${chatId.trim()}` + const buildPublicConfig = ( request: Exclude, ): DestinationPublicConfig => @@ -140,6 +144,11 @@ const buildPublicConfig = ( hazelChannelName: r.hazelChannelName, }), discord: (r) => ({ summary: summarizeWebhookUrl(r.webhookUrl), channelLabel: null }), + // The chat id, not the token: this config is Electric-synced to the + // browser, so nothing secret may appear here. It rides in + // `channelLabel` as well as the summary so the edit form can prefill it + // — without that, renaming a destination would demand retyping the id. + telegram: (r) => ({ summary: telegramSummary(r.chatId), channelLabel: r.chatId.trim() }), }), ) @@ -160,6 +169,11 @@ const buildSecretConfig = ( signingSecret: normalizeOptionalString(r.signingSecret), }), discord: (r) => ({ type: "discord" as const, webhookUrl: r.webhookUrl.trim() }), + telegram: (r) => ({ + type: "telegram" as const, + botToken: r.botToken.trim(), + chatId: r.chatId.trim(), + }), }), ) @@ -412,6 +426,28 @@ export class AlertDestinationsService extends Context.Service< } }) + const validateTelegramCredentials = Effect.fn("AlertsService.validateTelegramCredentials")(function* ( + botToken: string, + chatId: string, + ) { + if (!TELEGRAM_BOT_TOKEN_PATTERN.test(botToken)) { + return yield* Effect.fail( + makeValidationError( + "Telegram bot token must look like `123456789:ABC-DEF…` — copy it from @BotFather without the `bot` prefix.", + ), + ) + } + const result = yield* verifyTelegramCredentials( + botToken, + chatId, + runtime.fetch, + runtime.deliveryTimeoutMs(), + ) + if (result.status === "invalid") { + return yield* Effect.fail(makeValidationError(result.reason)) + } + }) + const createDestination: AlertDestinationsServiceApi["createDestination"] = Effect.fn( "AlertsService.createDestination", )(function* (orgId, userId, roles, request) { @@ -451,6 +487,9 @@ export class AlertDestinationsService extends Context.Service< : buildSecretConfig(request) } if (secretConfig.type === "pagerduty") yield* validatePagerDutyKey(secretConfig.integrationKey) + if (secretConfig.type === "telegram") { + yield* validateTelegramCredentials(secretConfig.botToken, secretConfig.chatId) + } const encryptedSecret = yield* encryptSecret( JSON.stringify(secretConfig), encryptionKey, @@ -643,6 +682,25 @@ export class AlertDestinationsService extends Context.Service< : ""), } satisfies DestinationSecretConfig, }), + telegram: (r) => { + const previous = + hydrated.secretConfig.type === "telegram" ? hydrated.secretConfig : null + const nextChatId = normalizeOptionalString(r.chatId) + return Effect.succeed({ + nextPublicConfig: { + summary: + nextChatId != null + ? telegramSummary(nextChatId) + : hydrated.publicConfig.summary, + channelLabel: nextChatId ?? hydrated.publicConfig.channelLabel, + } satisfies DestinationPublicConfig, + nextSecretConfig: { + type: "telegram" as const, + botToken: normalizeOptionalString(r.botToken) ?? previous?.botToken ?? "", + chatId: nextChatId ?? previous?.chatId ?? "", + } satisfies DestinationSecretConfig, + }) + }, email: (r) => Effect.gen(function* () { const supplied = @@ -671,6 +729,16 @@ export class AlertDestinationsService extends Context.Service< ) { yield* validatePagerDutyKey(nextSecretConfig.integrationKey) } + if ( + request.type === "telegram" && + // Either half changing can invalidate the pair — a new chat the old + // bot was never added to fails exactly like a new token would. + (normalizeOptionalString(request.botToken) != null || + normalizeOptionalString(request.chatId) != null) && + nextSecretConfig.type === "telegram" + ) { + yield* validateTelegramCredentials(nextSecretConfig.botToken, nextSecretConfig.chatId) + } const encryptedSecret = yield* encryptSecret( JSON.stringify(nextSecretConfig), encryptionKey, diff --git a/apps/api/src/services/alerts/delivery/delivery-spans.test.ts b/apps/api/src/services/alerts/delivery/delivery-spans.test.ts index 2d2e0edef..4252c6323 100644 --- a/apps/api/src/services/alerts/delivery/delivery-spans.test.ts +++ b/apps/api/src/services/alerts/delivery/delivery-spans.test.ts @@ -148,6 +148,17 @@ describe("AlertDelivery.http span", () => { expectPath: null, respond: () => new Response("", { status: 200 }), }, + { + name: "telegram", + config: { type: "telegram", botToken: "123456789:s3cr3t-token", chatId: "-100123" } as const, + peerService: "telegram", + host: "api.telegram.org", + // A fixed vendor host, but the bot token is a path segment — the first + // provider where the guard flag and the path flag disagree. + expectPath: null, + respond: () => + new Response(JSON.stringify({ ok: true, result: { message_id: 7 } }), { status: 200 }), + }, ] for (const testCase of cases) { diff --git a/apps/api/src/services/alerts/delivery/dispatch.ts b/apps/api/src/services/alerts/delivery/dispatch.ts index dbfd0911a..b5f5484f2 100644 --- a/apps/api/src/services/alerts/delivery/dispatch.ts +++ b/apps/api/src/services/alerts/delivery/dispatch.ts @@ -16,6 +16,7 @@ import { emailTransport } from "./transports/email" import { hazelTransport } from "./transports/hazel" import { pagerDutyTransport } from "./transports/pagerduty" import { makeSlackTransport } from "./transports/slack" +import { telegramTransport } from "./transports/telegram" import { webhookTransport } from "./transports/webhook" import { runEffectTransport, runHttpTransport, type TransportRuntime } from "./runTransport" import type { RenderInput } from "./Transport" @@ -65,6 +66,7 @@ export const dispatchDelivery = ( webhook: (config) => runHttpTransport(webhookTransport, input(config), runtime), "hazel-oauth": (config) => runHttpTransport(hazelTransport, input(config), runtime), discord: (config) => runHttpTransport(discordTransport, input(config), runtime), + telegram: (config) => runHttpTransport(telegramTransport, input(config), runtime), email: (config) => runEffectTransport(emailTransport, input(config), deps), }), ) diff --git a/apps/api/src/services/alerts/delivery/transports/render.test.ts b/apps/api/src/services/alerts/delivery/transports/render.test.ts index 1ec12f6a4..5dccf39c6 100644 --- a/apps/api/src/services/alerts/delivery/transports/render.test.ts +++ b/apps/api/src/services/alerts/delivery/transports/render.test.ts @@ -8,6 +8,7 @@ import { discordTransport } from "./discord" import { hazelTransport } from "./hazel" import { pagerDutyTransport } from "./pagerduty" import { makeSlackTransport } from "./slack" +import { telegramTransport } from "./telegram" import { webhookTransport } from "./webhook" /** @@ -123,19 +124,27 @@ describe("transport render: guard flags", () => { }), ), ], + [ + "telegram", + telegramTransport.render( + inputFor({ type: "telegram", botToken: "123456789:AA-token", chatId: "-100123" }), + ), + ], ] it("guards exactly the user-configured hosts", () => { const guarded = specs.filter(([, spec]) => spec.guarded).map(([name]) => name) - // slack + pagerduty post to compile-time vendor constants: there is no - // attacker-controlled URL to validate, so the guard would only cost a + // slack + pagerduty + telegram post to compile-time vendor constants: there + // is no attacker-controlled URL to validate, so the guard would only cost a // redirect walk. assert.deepStrictEqual(guarded, ["webhook", "discord", "hazel-oauth"]) }) it("marks exactly the providers whose token rides in the URL path", () => { const sensitive = specs.filter(([, spec]) => spec.sensitivePath).map(([name]) => name) - assert.deepStrictEqual(sensitive, ["discord", "hazel-oauth"]) + // telegram is the first provider where this disagrees with `guarded`: a + // fixed vendor host, but the bot token rides in the path. + assert.deepStrictEqual(sensitive, ["discord", "hazel-oauth", "telegram"]) }) it("always produces a parseable JSON body", () => { diff --git a/apps/api/src/services/alerts/delivery/transports/telegram.test.ts b/apps/api/src/services/alerts/delivery/transports/telegram.test.ts new file mode 100644 index 000000000..d934f5f44 --- /dev/null +++ b/apps/api/src/services/alerts/delivery/transports/telegram.test.ts @@ -0,0 +1,280 @@ +import type { AlertDestinationRow } from "@maple/db" +import { AlertDestinationId } from "@maple/domain/http" +import { assert, describe, it } from "@effect/vitest" +import { Effect, Result, Schema } from "effect" +import type { DispatchContext } from "../context" +import type { RenderInput, SecretConfigOf } from "../Transport" +import { TELEGRAM_BOT_TOKEN_PATTERN, telegramTransport, verifyTelegramCredentials } from "./telegram" + +const DESTINATION_ID = Schema.decodeUnknownSync(AlertDestinationId)("7c6b5a49-3821-4e0f-9d8c-7b6a59483726") + +const BOT_TOKEN = "123456789:AAHqwertyuiopasdfghjklzxcvbnm123456" +const CHAT_ID = "-1001234567890" +const LINK = "https://web.localhost/alerts" +const CHAT = "https://web.localhost/chat" + +const destinationRow: AlertDestinationRow = { + id: DESTINATION_ID, + orgId: "org_1" as AlertDestinationRow["orgId"], + name: "On-call Telegram", + type: "telegram", + enabled: true, + configJson: {}, + secretCiphertext: "", + secretIv: "", + secretTag: "", + lastTestedAt: null, + lastTestError: null, + createdAt: new Date(0), + updatedAt: new Date(0), + createdBy: "user_1", + updatedBy: "user_1", +} + +const config: SecretConfigOf<"telegram"> = { type: "telegram", botToken: BOT_TOKEN, chatId: CHAT_ID } + +const makeContext = (overrides: Partial = {}): DispatchContext => ({ + deliveryKey: "org_1:dest_1:delivery", + destination: destinationRow, + publicConfig: { summary: `Chat ${CHAT_ID}`, channelLabel: CHAT_ID }, + secretConfig: config, + ruleId: "rule_1", + ruleName: "Checkout error rate", + groupKey: "checkout", + signalType: "error_rate", + severity: "critical", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + eventType: "trigger", + incidentId: "inc_1", + incidentStatus: "open", + dedupeKey: "org_1:rule_1:checkout", + windowMinutes: 5, + value: 0.08, + sampleCount: 1200, + template: null, + sentAtMs: Date.parse("2026-06-02T00:00:00.000Z"), + ...overrides, +}) + +const inputFor = (overrides: Partial> = {}): RenderInput => ({ + config, + context: makeContext(), + linkUrl: LINK, + chatUrl: CHAT, + payloadJson: '{"canonical":"payload"}', + templated: null, + ...overrides, +}) + +/** + * Decoded rather than cast: the point of these cases is that the wire body has + * a particular shape, so asserting it is the test, not a formality. + */ +const SendMessageBody = Schema.Struct({ + chat_id: Schema.String, + text: Schema.String, + parse_mode: Schema.String, + link_preview_options: Schema.Record(Schema.String, Schema.Unknown), + reply_markup: Schema.Struct({ + inline_keyboard: Schema.Array( + Schema.Array(Schema.Struct({ text: Schema.String, url: Schema.String })), + ), + }), +}) +const decodeSendMessageBody = Schema.decodeUnknownSync(Schema.fromJsonString(SendMessageBody)) + +const render = (input: RenderInput = inputFor()) => { + const spec = telegramTransport.render(input, undefined) + return { spec, body: decodeSendMessageBody(spec.body) } +} + +describe("telegramTransport.render", () => { + it("posts to sendMessage with the token in the path and the chat in the body", () => { + const { spec, body } = render() + assert.strictEqual(spec.url, `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`) + assert.strictEqual(body.chat_id, CHAT_ID) + assert.strictEqual(body.parse_mode, "HTML") + // The token must never reach the body or a header — only the path, which + // `sensitivePath` keeps off the span. + assert.isFalse(spec.body.includes(BOT_TOKEN)) + assert.isFalse(JSON.stringify(spec.headers).includes(BOT_TOKEN)) + }) + + it("carries both links as inline keyboard buttons", () => { + const [row] = render().body.reply_markup.inline_keyboard + assert.deepStrictEqual( + row?.map((button) => button.url), + [LINK, CHAT], + ) + }) + + /** + * The reason `parse_mode: "HTML"` needs escaping is not only injection: + * `formatThresholdSummary` legitimately emits `> 5%`, which Telegram reads as + * an unclosed tag and rejects the whole message with 400. + */ + it("escapes the threshold comparator so HTML mode can parse the message", () => { + const { text } = render(inputFor({ context: makeContext({ eventType: "resolve" }) })).body + assert.include(text, ">") + assert.isFalse(/<(?!\/?(?:b|i|code|pre|a|s|u)\b)/.test(text), text) + }) + + it("escapes HTML metacharacters in the rule name", () => { + const input = inputFor({ context: makeContext({ ruleName: "Pwn & co" }) }) + const { text } = render(input).body + assert.include(text, "<b>Pwn</b> & co") + assert.isFalse(text.includes("Pwn")) + }) + + it("previews the chart when there is one, and nothing otherwise", () => { + assert.deepStrictEqual(render().body.link_preview_options, { is_disabled: true }) + const withChart = render( + inputFor({ context: makeContext({ chartUrl: "https://maple.dev/c/abc.png" }) }), + ) + assert.deepStrictEqual(withChart.body.link_preview_options, { + url: "https://maple.dev/c/abc.png", + show_above_text: true, + }) + }) + + describe("templated bodies", () => { + it("converts Markdown emphasis and links to Telegram's tag subset", () => { + const { text } = render( + inputFor({ + templated: { title: "Custom title", body: "Custom **body** [Open](https://maple.dev/x)" }, + }), + ).body + assert.include(text, "Custom title") + assert.include(text, "body") + assert.include(text, 'Open') + }) + + it("leaves a non-http link target as inert text", () => { + const { text } = render( + inputFor({ templated: { title: "T", body: "[Tap](tg://user?id=1)" } }), + ).body + assert.isFalse(text.includes(" { + const { text } = render(inputFor({ templated: { title: "T", body: "x".repeat(9000) } })).body + assert.isAtMost(text.length, 4096) + }) + }) +}) + +describe("telegramTransport.interpret", () => { + const interpret = (raw: string) => telegramTransport.interpret!(inputFor(), raw) + + it("reads the message id off a successful send", () => { + const result = interpret(JSON.stringify({ ok: true, result: { message_id: 42 } })) + assert.isTrue(Result.isSuccess(result)) + if (Result.isSuccess(result)) assert.strictEqual(result.success.providerReference, "42") + }) + + /** + * Telegram reports logical failures as HTTP 200 + `{ ok: false }`, so the + * runner's status classifier never sees them — retryability is decided here + * or not at all. + */ + it.each([ + [401, "AlertDeliveryAuthError", false], + [403, "AlertDeliveryTargetMissingError", false], + [400, "AlertDeliveryRejectedError", false], + [429, "AlertDeliveryError", true], + ] as const)("classifies error_code %i as %s", (errorCode, name, retryable) => { + const result = interpret( + JSON.stringify({ ok: false, error_code: errorCode, description: "Forbidden: bot was kicked" }), + ) + assert.isTrue(Result.isFailure(result)) + if (Result.isFailure(result)) { + assert.strictEqual(result.failure._tag, `@maple/http/errors/${name}`) + assert.strictEqual(result.failure.error.retryable, retryable) + } + }) + + it("fails rather than claiming delivery on a non-JSON response", () => { + assert.isTrue(Result.isFailure(interpret("502"))) + }) +}) + +describe("TELEGRAM_BOT_TOKEN_PATTERN", () => { + it("accepts a @BotFather token and rejects the usual wrong pastes", () => { + assert.isTrue(TELEGRAM_BOT_TOKEN_PATTERN.test(BOT_TOKEN)) + assert.isFalse(TELEGRAM_BOT_TOKEN_PATTERN.test(`bot${BOT_TOKEN}`)) + assert.isFalse(TELEGRAM_BOT_TOKEN_PATTERN.test(CHAT_ID)) + assert.isFalse(TELEGRAM_BOT_TOKEN_PATTERN.test("123456789:short")) + }) +}) + +describe("verifyTelegramCredentials", () => { + const stub = (responses: ReadonlyArray<{ status: number; body: unknown }>) => { + const calls: string[] = [] + let index = 0 + const fetchFn: typeof fetch = async (input) => { + calls.push(String(input)) + const next = responses[Math.min(index++, responses.length - 1)]! + return new Response(JSON.stringify(next.body), { + status: next.status, + headers: { "content-type": "application/json" }, + }) + } + return { fetchFn, calls } + } + + const ok = { status: 200, body: { ok: true, result: {} } } + + it.effect("is valid when both getMe and getChat succeed", () => + Effect.gen(function* () { + const { fetchFn, calls } = stub([ok, ok]) + const result = yield* verifyTelegramCredentials(BOT_TOKEN, CHAT_ID, fetchFn, 1000) + assert.deepStrictEqual(result, { status: "valid" }) + assert.strictEqual(calls.length, 2) + assert.include(calls[1]!, `chat_id=${encodeURIComponent(CHAT_ID)}`) + }), + ) + + it.effect("rejects a bad token without asking about the chat", () => + Effect.gen(function* () { + const { fetchFn, calls } = stub([{ status: 401, body: { ok: false, error_code: 401 } }]) + const result = yield* verifyTelegramCredentials(BOT_TOKEN, CHAT_ID, fetchFn, 1000) + assert.strictEqual(result.status, "invalid") + assert.strictEqual(calls.length, 1) + }), + ) + + /** The dominant misconfiguration: a real token aimed at a chat the bot was never added to. */ + it.effect("rejects a chat the bot cannot reach, quoting Telegram's reason", () => + Effect.gen(function* () { + const { fetchFn } = stub([ + ok, + { + status: 400, + body: { ok: false, error_code: 400, description: "Bad Request: chat not found" }, + }, + ]) + const result = yield* verifyTelegramCredentials(BOT_TOKEN, CHAT_ID, fetchFn, 1000) + assert.strictEqual(result.status, "invalid") + if (result.status === "invalid") assert.include(result.reason, "chat not found") + }), + ) + + it.effect("fails open on a 5xx so a Telegram outage cannot block a save", () => + Effect.gen(function* () { + const { fetchFn } = stub([{ status: 503, body: {} }]) + const result = yield* verifyTelegramCredentials(BOT_TOKEN, CHAT_ID, fetchFn, 1000) + assert.deepStrictEqual(result, { status: "unknown" }) + }), + ) + + it.effect("fails open when the request throws", () => + Effect.gen(function* () { + const fetchFn: typeof fetch = () => Promise.reject(new Error("network down")) + const result = yield* verifyTelegramCredentials(BOT_TOKEN, CHAT_ID, fetchFn, 1000) + assert.deepStrictEqual(result, { status: "unknown" }) + }), + ) +}) diff --git a/apps/api/src/services/alerts/delivery/transports/telegram.ts b/apps/api/src/services/alerts/delivery/transports/telegram.ts new file mode 100644 index 000000000..f6b82811f --- /dev/null +++ b/apps/api/src/services/alerts/delivery/transports/telegram.ts @@ -0,0 +1,216 @@ +// BOUNDARY: This module intentionally carries opaque values; callers decode them before domain use. +import { + AlertDeliveryAuthError, + AlertDeliveryError, + AlertDeliveryRejectedError, + AlertDeliveryTargetMissingError, + type AlertDeliveryFailure, +} from "@maple/domain/http" +import { Duration, Effect, Result, Schema } from "effect" +import { buildTelegramText, buildTelegramTextFromTemplate } from "../../AlertDeliveryDispatch" +import { truncate } from "../../alert-formatting" +import type { HttpTransport, ProviderAck, RenderInput, SecretConfigOf } from "../Transport" + +type Config = SecretConfigOf<"telegram"> + +const TELEGRAM_API_ORIGIN = "https://api.telegram.org" + +/** + * A @BotFather token is `:` — a numeric id, a colon, then a + * ~35-char base64url secret. Checked before the network call so the usual wrong + * paste (a chat id, or a token with the `bot` prefix left on) gets a specific + * message instead of a generic 404 from Telegram. + */ +export const TELEGRAM_BOT_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/ + +/** + * Telegram answers HTTP 200 with `{ ok: false, description, error_code }` for + * logical failures — the same shape of lie Slack tells, so the body is the + * source of truth, not the status. + */ +const TelegramResponseSchema = Schema.Struct({ + ok: Schema.optionalKey(Schema.Boolean), + description: Schema.optionalKey(Schema.String), + error_code: Schema.optionalKey(Schema.Number), + result: Schema.optionalKey(Schema.Struct({ message_id: Schema.optionalKey(Schema.Number) })), +}) +const decodeTelegramResponse = Schema.decodeUnknownResult(TelegramResponseSchema) + +const telegramError = (message: string) => new AlertDeliveryError({ message, destinationType: "telegram" }) + +/** + * Same policy as `failureForStatus` in the runner, applied to the error code + * Telegram puts in a 200 body: auth problems and a chat the bot can no longer + * reach are permanent, a malformed request is a rejection, and 429/5xx are the + * provider asking us to come back. + */ +const failureForTelegramError = (errorCode: number | undefined, message: string): AlertDeliveryFailure => { + const fields = { + message, + destinationType: "telegram" as const, + ...(errorCode === undefined ? undefined : { providerStatus: errorCode }), + } + if (errorCode === 401) return new AlertDeliveryAuthError(fields) + // 403 is "bot was blocked" / "bot is not a member of the chat" — the chat is + // unreachable until a human re-adds it, which no retry accomplishes. + if (errorCode === 403 || errorCode === 404) return new AlertDeliveryTargetMissingError(fields) + if (errorCode === 400) return new AlertDeliveryRejectedError(fields) + return new AlertDeliveryError(fields) +} + +export const telegramTransport: HttpTransport = { + kind: "http", + type: "telegram", + peerService: "telegram", + providerLabel: "Telegram", + render: (input: RenderInput) => { + const { context, templated, linkUrl, chatUrl } = input + const text = templated + ? buildTelegramTextFromTemplate(templated.title, templated.body, context) + : buildTelegramText(context) + return { + url: `${TELEGRAM_API_ORIGIN}/bot${input.config.botToken}/sendMessage`, + headers: { "content-type": "application/json" }, + // Fixed vendor host — nothing user-supplied to validate… + guarded: false, + // …but the bot token rides in the path, so the span must annotate + // `server.address` only. The first provider where these two disagree, + // which is why `sensitivePath` is declared rather than inferred. + sensitivePath: true, + body: JSON.stringify({ + chat_id: input.config.chatId, + text, + parse_mode: "HTML", + // The chart as the message's link preview: Telegram renders it inline + // above the text, so it costs no second `sendPhoto` round-trip. With + // no chart there is nothing to preview and the links are buttons, so + // previews stay off rather than unfurling one of them at random. + link_preview_options: context.chartUrl + ? { url: context.chartUrl, show_above_text: true } + : { is_disabled: true }, + reply_markup: { + inline_keyboard: [ + [ + { text: "Open in Maple", url: linkUrl }, + { text: "✨ Ask Maple AI", url: chatUrl }, + ], + ], + }, + }), + } + }, + interpret: (input, rawBody): Result.Result => { + const parsed = Result.try({ + try: (): unknown => JSON.parse(rawBody), + catch: () => telegramError("Telegram returned a non-JSON response"), + }) + if (Result.isFailure(parsed)) return Result.fail(parsed.failure) + + const decoded = decodeTelegramResponse(parsed.success) + if (Result.isFailure(decoded)) { + return Result.fail( + telegramError(`Telegram returned an unexpected response payload: ${decoded.failure.message}`), + ) + } + + const payload = decoded.success + if (!payload.ok) { + const description = payload.description ?? "unknown error" + return Result.fail( + failureForTelegramError( + payload.error_code, + `Telegram rejected the message: ${truncate(description, 500)}`, + ), + ) + } + return Result.succeed({ + providerMessage: `Delivered to Telegram chat ${input.config.chatId}`, + providerReference: payload.result?.message_id != null ? String(payload.result.message_id) : null, + }) + }, + // Unreachable: `interpret` always claims the response. Present because the + // interface requires a success shape for the no-interpret path. + ack: (input) => ({ + providerMessage: `Delivered to Telegram chat ${input.config.chatId}`, + providerReference: null, + }), +} + +export type TelegramCredentialVerification = + | { status: "valid" } + | { status: "invalid"; reason: string } + /** Network error / timeout / 429 / 5xx — can't conclude; caller should fail open. */ + | { status: "unknown" } + +const telegramApiCall = ( + url: string, + fetchFn: typeof fetch, +): Effect.Effect<{ ok: boolean; status: number; description: string }> => + Effect.tryPromise(() => fetchFn(url, { method: "GET" })).pipe( + Effect.flatMap((response) => + Effect.promise(() => response.text().catch(() => "")).pipe( + Effect.map((body) => { + const decoded = decodeTelegramResponse( + Result.getOrElse( + Result.try({ try: (): unknown => JSON.parse(body), catch: () => null }), + () => null, + ), + ) + const description = Result.getOrElse( + Result.map(decoded, (payload) => payload.description ?? ""), + () => "", + ) + return { + ok: response.ok, + status: response.status, + description: truncate(description.replace(/\s+/g, " ").trim(), 300), + } + }), + ), + ), + Effect.orElseSucceed(() => ({ ok: false, status: 0, description: "" })), + ) + +/** + * Verify a bot token and chat at save time: `getMe` proves the token, `getChat` + * proves the bot can actually see the chat it was pointed at. The second is the + * check that matters — a valid token aimed at a group the bot was never added + * to is by far the most common misconfiguration, and without this it surfaces + * only when a real alert silently fails to deliver. + * + * Never fails: anything ambiguous (transport error, timeout, 429, 5xx) collapses + * to `unknown` so the caller owns the policy and a Telegram outage cannot block + * a save. Mirrors `verifyPagerDutyRoutingKey`. + */ +export const verifyTelegramCredentials = ( + botToken: string, + chatId: string, + fetchFn: typeof fetch, + timeoutMs: number, +): Effect.Effect => + Effect.gen(function* () { + const base = `${TELEGRAM_API_ORIGIN}/bot${botToken}` + const me = yield* telegramApiCall(`${base}/getMe`, fetchFn) + if (me.status === 401 || me.status === 404) { + return { status: "invalid", reason: "Telegram rejected the bot token" } as const + } + if (!me.ok) return { status: "unknown" } as const + + const chat = yield* telegramApiCall(`${base}/getChat?chat_id=${encodeURIComponent(chatId)}`, fetchFn) + if (chat.status === 400 || chat.status === 403 || chat.status === 404) { + return { + status: "invalid", + reason: + chat.description || + "Telegram could not reach that chat — add the bot to it and check the chat ID", + } as const + } + if (!chat.ok) return { status: "unknown" } as const + return { status: "valid" } as const + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => Effect.succeed({ status: "unknown" }), + }), + Effect.orElseSucceed(() => ({ status: "unknown" as const })), + ) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 21d31faae..e10e1cc65 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -2726,6 +2726,7 @@ "webhook", "hazel-oauth", "discord", + "telegram", "email" ], "title": "Alert Destination Type", @@ -6350,7 +6351,7 @@ "name": "Alert Deliveries" }, { - "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.", + "description": "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.", "name": "Alert Destinations" }, { diff --git a/apps/landing/messages/en.json b/apps/landing/messages/en.json index 59889e1e4..22935f334 100644 --- a/apps/landing/messages/en.json +++ b/apps/landing/messages/en.json @@ -852,7 +852,7 @@ "feat_alerts_cap_4_title": "Every check on the record", "feat_alerts_cap_4_body": "Each evaluation writes its observed value, sample count and verdict — including failed queries, so a gap is visible instead of silent. When a rule stays quiet, a diagnosis panel walks the pipeline stage by stage.", "feat_alerts_cap_5_title": "Delivery that survives", - "feat_alerts_cap_5_body": "Queued dispatch with five attempts and backoff, every provider response stored, custom templates per destination, and a renotify interval while the incident stays open. Slack, PagerDuty, Discord, email, webhooks, Hazel.", + "feat_alerts_cap_5_body": "Queued dispatch with five attempts and backoff, every provider response stored, custom templates per destination, and a renotify interval while the incident stays open. Slack, PagerDuty, Discord, Telegram, email, webhooks, Hazel.", "feat_alerts_artifact_title": "From breach to page in a minute", "feat_alerts_artifact_lede": "Rules evaluate every 60 seconds. Two consecutive breaches open the incident and send the notification with the observed value attached; two healthy windows resolve it without anyone clicking close.", diff --git a/apps/landing/messages/ja.json b/apps/landing/messages/ja.json index 689d37354..36e23a502 100644 --- a/apps/landing/messages/ja.json +++ b/apps/landing/messages/ja.json @@ -852,7 +852,7 @@ "feat_alerts_cap_4_title": "\u3059\u3079\u3066\u306e\u30c1\u30a7\u30c3\u30af\u3092\u8a18\u9332", "feat_alerts_cap_4_body": "\u5404\u8a55\u4fa1\u306f\u89b3\u6e2c\u5024\u3001\u30b5\u30f3\u30d7\u30eb\u6570\u3001\u5224\u5b9a\u3092\u66f8\u304d\u8fbc\u307f\u307e\u3059\u3002\u5931\u6557\u3057\u305f\u30af\u30a8\u30ea\u3082\u542b\u307e\u308c\u308b\u305f\u3081\u3001\u6b20\u843d\u306f\u6c88\u9ed9\u3067\u306f\u306a\u304f\u53ef\u8996\u5316\u3055\u308c\u307e\u3059\u3002\u30eb\u30fc\u30eb\u304c\u9759\u304b\u306a\u307e\u307e\u306a\u3089\u3001\u8a3a\u65ad\u30d1\u30cd\u30eb\u304c\u30d1\u30a4\u30d7\u30e9\u30a4\u30f3\u3092\u6bb5\u968e\u3054\u3068\u306b\u691c\u8a3c\u3057\u307e\u3059\u3002", "feat_alerts_cap_5_title": "\u5c4a\u304f\u307e\u3067\u8ae6\u3081\u306a\u3044\u914d\u4fe1", - "feat_alerts_cap_5_body": "\u30ad\u30e5\u30fc\u914d\u4fe1\u306f\u30d0\u30c3\u30af\u30aa\u30d5\u4ed8\u304d\u3067\u6700\u59275\u56de\u8a66\u884c\u3057\u3001\u30d7\u30ed\u30d0\u30a4\u30c0\u306e\u5fdc\u7b54\u3092\u3059\u3079\u3066\u4fdd\u5b58\u3057\u307e\u3059\u3002\u5b9b\u5148\u3054\u3068\u306e\u30ab\u30b9\u30bf\u30e0\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u3068\u3001\u30a4\u30f3\u30b7\u30c7\u30f3\u30c8\u304c\u958b\u3044\u3066\u3044\u308b\u9593\u306e\u518d\u901a\u77e5\u9593\u9694\u3082\u3002Slack\u3001PagerDuty\u3001Discord\u3001\u30e1\u30fc\u30eb\u3001Webhook\u3001Hazel\u3002", + "feat_alerts_cap_5_body": "\u30ad\u30e5\u30fc\u914d\u4fe1\u306f\u30d0\u30c3\u30af\u30aa\u30d5\u4ed8\u304d\u3067\u6700\u59275\u56de\u8a66\u884c\u3057\u3001\u30d7\u30ed\u30d0\u30a4\u30c0\u306e\u5fdc\u7b54\u3092\u3059\u3079\u3066\u4fdd\u5b58\u3057\u307e\u3059\u3002\u5b9b\u5148\u3054\u3068\u306e\u30ab\u30b9\u30bf\u30e0\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u3068\u3001\u30a4\u30f3\u30b7\u30c7\u30f3\u30c8\u304c\u958b\u3044\u3066\u3044\u308b\u9593\u306e\u518d\u901a\u77e5\u9593\u9694\u3082\u3002Slack\u3001PagerDuty\u3001Discord\u3001Telegram\u3001\u30e1\u30fc\u30eb\u3001Webhook\u3001Hazel\u3002", "feat_alerts_artifact_title": "\u30d6\u30ea\u30fc\u30c1\u304b\u3089\u901a\u77e5\u307e\u30671\u5206", "feat_alerts_artifact_lede": "\u30eb\u30fc\u30eb\u306f60\u79d2\u3054\u3068\u306b\u8a55\u4fa1\u3055\u308c\u307e\u3059\u30022\u56de\u9023\u7d9a\u306e\u30d6\u30ea\u30fc\u30c1\u3067\u30a4\u30f3\u30b7\u30c7\u30f3\u30c8\u304c\u958b\u304d\u3001\u89b3\u6e2c\u5024\u4ed8\u304d\u306e\u901a\u77e5\u304c\u9001\u4fe1\u3055\u308c\u307e\u3059\u30022\u56de\u9023\u7d9a\u306e\u6b63\u5e38\u30a6\u30a3\u30f3\u30c9\u30a6\u3067\u81ea\u52d5\u89e3\u6c7a \u2014 \u8ab0\u304b\u304c\u30af\u30ed\u30fc\u30ba\u3092\u62bc\u3059\u5fc5\u8981\u306f\u3042\u308a\u307e\u305b\u3093\u3002", diff --git a/apps/landing/messages/ko.json b/apps/landing/messages/ko.json index d193a240c..51b1d5268 100644 --- a/apps/landing/messages/ko.json +++ b/apps/landing/messages/ko.json @@ -852,7 +852,7 @@ "feat_alerts_cap_4_title": "\ubaa8\ub4e0 \uccb4\ud06c\ub97c \uae30\ub85d", "feat_alerts_cap_4_body": "\uac01 \ud3c9\uac00\ub294 \uad00\uce21\uac12, \uc0d8\ud50c \uc218, \ud310\uc815\uc744 \uae30\ub85d\ud569\ub2c8\ub2e4. \uc2e4\ud328\ud55c \ucffc\ub9ac\ub3c4 \ud3ec\ud568\ub418\ubbc0\ub85c \uacf5\ubc31\uc740 \uce68\ubb35\uc774 \uc544\ub2c8\ub77c \uac00\uc2dc\ud654\ub429\ub2c8\ub2e4. \uaddc\uce59\uc774 \uc870\uc6a9\ud560 \ub54c\ub294 \uc9c4\ub2e8 \ud328\ub110\uc774 \ud30c\uc774\ud504\ub77c\uc778\uc744 \ub2e8\uacc4\ubcc4\ub85c \uc810\uac80\ud569\ub2c8\ub2e4.", "feat_alerts_cap_5_title": "\ub05d\uae4c\uc9c0 \uc804\ub2ec\ub418\ub294 \uc54c\ub9bc", - "feat_alerts_cap_5_body": "\ud050 \uae30\ubc18 \ub514\uc2a4\ud328\uce58\ub294 \ubc31\uc624\ud504\uc640 \ud568\uaed8 \ucd5c\ub300 5\ud68c \uc2dc\ub3c4\ud558\uace0, \ubaa8\ub4e0 \ud504\ub85c\ubc14\uc774\ub354 \uc751\ub2f5\uc744 \uc800\uc7a5\ud569\ub2c8\ub2e4. \ub300\uc0c1\ubcc4 \ucee4\uc2a4\ud140 \ud15c\ud50c\ub9bf\uacfc \uc778\uc2dc\ub358\ud2b8\uac00 \uc5f4\ub824 \uc788\ub294 \ub3d9\uc548\uc758 \uc7ac\uc54c\ub9bc \uac04\uaca9\ub3c4 \ud568\uaed8. Slack, PagerDuty, Discord, \uc774\uba54\uc77c, \uc6f9\ud6c5, Hazel.", + "feat_alerts_cap_5_body": "\ud050 \uae30\ubc18 \ub514\uc2a4\ud328\uce58\ub294 \ubc31\uc624\ud504\uc640 \ud568\uaed8 \ucd5c\ub300 5\ud68c \uc2dc\ub3c4\ud558\uace0, \ubaa8\ub4e0 \ud504\ub85c\ubc14\uc774\ub354 \uc751\ub2f5\uc744 \uc800\uc7a5\ud569\ub2c8\ub2e4. \ub300\uc0c1\ubcc4 \ucee4\uc2a4\ud140 \ud15c\ud50c\ub9bf\uacfc \uc778\uc2dc\ub358\ud2b8\uac00 \uc5f4\ub824 \uc788\ub294 \ub3d9\uc548\uc758 \uc7ac\uc54c\ub9bc \uac04\uaca9\ub3c4 \ud568\uaed8. Slack, PagerDuty, Discord, Telegram, \uc774\uba54\uc77c, \uc6f9\ud6c5, Hazel.", "feat_alerts_artifact_title": "\ube0c\ub9ac\uce58\uc5d0\uc11c \uc54c\ub9bc\uae4c\uc9c0 1\ubd84", "feat_alerts_artifact_lede": "\uaddc\uce59\uc740 60\ucd08\ub9c8\ub2e4 \ud3c9\uac00\ub429\ub2c8\ub2e4. \ub450 \ubc88 \uc5f0\uc18d \ube0c\ub9ac\uce58\ub418\uba74 \uc778\uc2dc\ub358\ud2b8\uac00 \uc5f4\ub9ac\uace0 \uad00\uce21\uac12\uc774 \ucca8\ubd80\ub41c \uc54c\ub9bc\uc774 \uc804\uc1a1\ub418\uba70, \ub450 \ubc88 \uc5f0\uc18d \uc815\uc0c1 \uc708\ub3c4\uc6b0\uba74 \uc790\ub3d9\uc73c\ub85c \ud574\uacb0\ub429\ub2c8\ub2e4. \ub204\uac00 \ub2eb\uae30\ub97c \ub204\ub97c \ud544\uc694\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", diff --git a/apps/landing/src/components/live/LiveAlertFiring.astro b/apps/landing/src/components/live/LiveAlertFiring.astro index 03c64dd9b..d70f6899c 100644 --- a/apps/landing/src/components/live/LiveAlertFiring.astro +++ b/apps/landing/src/components/live/LiveAlertFiring.astro @@ -20,7 +20,7 @@ const HEIGHTS = [18, 22, 15, 20, 26, 19, 24, 21, 17, 23, 28, 25, 31, 27, 34, 38, const THRESHOLD_PCT = 55; const BREACH_START = HEIGHTS.length - 2; -const DESTINATIONS = ["slack", "discord", "pagerduty", "email", "webhook", "hazel"]; +const DESTINATIONS = ["slack", "discord", "telegram", "pagerduty", "email", "webhook", "hazel"]; ---
diff --git a/apps/landing/src/content/docs/alerting/notification-destinations.md b/apps/landing/src/content/docs/alerting/notification-destinations.md index c5050512f..dbbc94a25 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,42 @@ 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. Find the chat ID.** Post any message in the chat, then open: + +``` +https://api.telegram.org/bot/getUpdates +``` + +and read `result[].message.chat.id`. Group and channel ids are negative (`-1001234567890`); a +one-to-one chat with the bot is a positive number. A public channel 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..5bb30925e 100644 --- a/apps/web/src/components/alerts/destination-dialog.tsx +++ b/apps/web/src/components/alerts/destination-dialog.tsx @@ -98,6 +98,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. @@ -1050,6 +1057,60 @@ 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. +

+
+
+ + + onFormChange((current) => ({ + ...current, + telegramChatId: event.target.value, + })) + } + placeholder="-1001234567890 or @mychannel" + className="font-mono text-xs" + /> +

+ Add the bot to the chat, then read the id from{" "} + api.telegram.org/bot<token>/getUpdates. Maple + checks the bot can reach it when you save. +

+
+ + )} + {form.type === "webhook" && ( <>
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..529f03c86 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, @@ -518,6 +541,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.", }), ) {} From 6ce1fbf9cc776bfb8b2d47f9a2de675fcfd6c6e6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 22:46:45 +0200 Subject: [PATCH 2/3] feat(alerts): detect Telegram chats instead of making users transcribe an ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the Telegram setup was "open a raw getUpdates URL and read a negative integer out of the JSON". That is where this flow fails in practice, so the server does the reading now: paste the token, hit **Detect chats**, pick a chat by name. Adds `POST /v2/alerts/destinations/telegram/chats` — one `getUpdates` read with a token that is used and discarded, never stored. Admin-gated for the same reason the Slack channel list is: it reads somebody's chat inventory and accepts an arbitrary token, so it must not be a probe any org member can drive. Three properties this deliberately holds: - **It collects `my_chat_member`, not just messages.** Bots join groups with privacy mode ON, so an ordinary group message never reaches `getUpdates` — only a command, a mention, or a reply does. A `my_chat_member` update fires when the bot is added, regardless of privacy mode, which is what makes "just add the bot" sufficient. Without this the feature would silently find nothing for the most common setup there is, so it has a test that says so. - **It never sends an `offset`.** `getUpdates` confirms and discards every update before `offset`, so passing one would delete the bot owner's pending updates as a side effect of them clicking a button in our UI. Also pinned by a test. - **A webhook conflict gets its own sentence.** A bot with a webhook registered answers `getUpdates` with 409. That is a fixable state, not a bad token, and saying so is the difference between a ten-second fix and a support ticket. The manual input stays editable throughout. Discovery legitimately comes back empty for valid setups — Telegram retains updates for ~24 hours, and a webhook-backed bot cannot be inspected at all — so the picker is only ever additive to the field, never a replacement for it. The new path is added to the committed v2 surface list in openapi.test, which is the gate that required it to be a deliberate choice. --- .../src/routes/v2/alert-destinations.http.ts | 11 ++ apps/api/src/routes/v2/v2-test-support.ts | 1 + .../alerts/AlertDestinationsService.ts | 37 +++- apps/api/src/services/alerts/AlertsService.ts | 1 + .../delivery/transports/telegram.test.ts | 123 ++++++++++++- .../alerts/delivery/transports/telegram.ts | 161 ++++++++++++++++++ .../alerting/notification-destinations.md | 26 ++- .../components/alerts/destination-dialog.tsx | 130 +++++++++++++- .../domain/src/http/v2/alert-destinations.ts | 69 ++++++++ packages/domain/src/http/v2/openapi.test.ts | 1 + 10 files changed, 535 insertions(+), 25 deletions(-) diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index c74b544ae..ea2660a9d 100644 --- a/apps/api/src/routes/v2/alert-destinations.http.ts +++ b/apps/api/src/routes/v2/alert-destinations.http.ts @@ -16,6 +16,7 @@ import type { V2AlertDestinationCreateParams, V2AlertDestinationMutationResponse, V2AlertDestinationUpdateParams, + V2TelegramChatList, } from "@maple/domain/http/v2" import { MapleApiV2, paginateArray } from "@maple/domain/http/v2" import { Effect } from "effect" @@ -220,6 +221,16 @@ export const HttpV2AlertDestinationsLive = HttpApiBuilder.group(MapleApiV2, "ale return toV2Destination(destination) }), ) + .handle("telegramChats", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const chats = yield* destinations.listTelegramChats(tenant.roles, payload.bot_token) + return { + object: "alert_destination.telegram_chat_list" as const, + chats, + } satisfies V2TelegramChatList + }), + ) .handle("create", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 1acaaa0d5..7945e314d 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -324,6 +324,7 @@ const alertDestinationStubs = { createDestination: die, updateDestination: die, deleteDestination: die, + listTelegramChats: die, testDestination: die, } diff --git a/apps/api/src/services/alerts/AlertDestinationsService.ts b/apps/api/src/services/alerts/AlertDestinationsService.ts index 09eacb1fb..81edf87ad 100644 --- a/apps/api/src/services/alerts/AlertDestinationsService.ts +++ b/apps/api/src/services/alerts/AlertDestinationsService.ts @@ -41,7 +41,12 @@ import { } from "@/services/org/OrgMembersService" import { SlackBotTokenResolver } from "@/services/integrations/slack-bot-token" import { PAGERDUTY_ROUTING_KEY_PATTERN, verifyPagerDutyRoutingKey } from "./delivery/transports/pagerduty" -import { TELEGRAM_BOT_TOKEN_PATTERN, verifyTelegramCredentials } from "./delivery/transports/telegram" +import { + fetchTelegramChats, + TELEGRAM_BOT_TOKEN_PATTERN, + verifyTelegramCredentials, + type TelegramChat, +} from "./delivery/transports/telegram" import { DestinationPublicConfigSchema, type DestinationPublicConfig, @@ -120,6 +125,9 @@ const summarizeWebhookUrl = (url: string) => onSome: (parsed) => `POST ${parsed.host}`, }) +const TELEGRAM_MALFORMED_TOKEN_MESSAGE = + "Telegram bot token must look like `123456789:ABC-DEF…` — copy it from @BotFather without the `bot` prefix." + /** A chat id is not a secret, but it is also not a name — label it as what it is. */ const telegramSummary = (chatId: string) => `Chat ${chatId.trim()}` @@ -280,6 +288,10 @@ export interface AlertDestinationsServiceApi { | AlertDestinationInUseError | AlertRuleStoredConfigInvalidError > + readonly listTelegramChats: ( + roles: ReadonlyArray, + botToken: string, + ) => Effect.Effect, AlertForbiddenError | AlertValidationError> readonly testDestination: ( orgId: OrgId, userId: UserId, @@ -431,11 +443,7 @@ export class AlertDestinationsService extends Context.Service< chatId: string, ) { if (!TELEGRAM_BOT_TOKEN_PATTERN.test(botToken)) { - return yield* Effect.fail( - makeValidationError( - "Telegram bot token must look like `123456789:ABC-DEF…` — copy it from @BotFather without the `bot` prefix.", - ), - ) + return yield* Effect.fail(makeValidationError(TELEGRAM_MALFORMED_TOKEN_MESSAGE)) } const result = yield* verifyTelegramCredentials( botToken, @@ -448,6 +456,22 @@ export class AlertDestinationsService extends Context.Service< } }) + const listTelegramChats: AlertDestinationsServiceApi["listTelegramChats"] = Effect.fn( + "AlertsService.listTelegramChats", + )(function* (roles, botToken) { + // Admin-gated for the same reason the Slack channel list is: it reads + // somebody's chat inventory, and it accepts an arbitrary token, so it + // must not be a probe any org member can drive. + yield* requireAdmin(roles) + const trimmed = botToken.trim() + if (!TELEGRAM_BOT_TOKEN_PATTERN.test(trimmed)) { + return yield* Effect.fail(makeValidationError(TELEGRAM_MALFORMED_TOKEN_MESSAGE)) + } + const result = yield* fetchTelegramChats(trimmed, runtime.fetch, runtime.deliveryTimeoutMs()) + if (result.status === "invalid") return yield* Effect.fail(makeValidationError(result.reason)) + return result.chats + }) + const createDestination: AlertDestinationsServiceApi["createDestination"] = Effect.fn( "AlertsService.createDestination", )(function* (orgId, userId, roles, request) { @@ -888,6 +912,7 @@ export class AlertDestinationsService extends Context.Service< createDestination, updateDestination, deleteDestination, + listTelegramChats, testDestination, } satisfies AlertDestinationsServiceApi }), diff --git a/apps/api/src/services/alerts/AlertsService.ts b/apps/api/src/services/alerts/AlertsService.ts index de51b720d..1f154f0ff 100644 --- a/apps/api/src/services/alerts/AlertsService.ts +++ b/apps/api/src/services/alerts/AlertsService.ts @@ -3383,6 +3383,7 @@ export class AlertsService extends Context.Service { }), ) }) + +describe("fetchTelegramChats", () => { + const respondWith = (status: number, body: unknown) => { + const calls: string[] = [] + const fetchFn: typeof fetch = async (input) => { + calls.push(String(input)) + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) + } + return { fetchFn, calls } + } + + const chatOf = (id: number, title: string, type: string) => ({ id, type, title }) + + /** + * The case this feature exists for. Bots join groups with privacy mode ON, + * so an ordinary group message never reaches `getUpdates` — only + * `my_chat_member` (fired when the bot is added) does. If this stops being + * collected, "Detect chats" silently returns nothing for the most common + * setup there is. + */ + it.effect("finds a group from the bot-added update alone, with no message sent", () => + Effect.gen(function* () { + const { fetchFn } = respondWith(200, { + ok: true, + result: [{ my_chat_member: { chat: chatOf(-1001234567890, "Acme On-call", "supergroup") } }], + }) + const result = yield* fetchTelegramChats(BOT_TOKEN, fetchFn, 1000) + assert.deepStrictEqual(result, { + status: "ok", + chats: [{ id: "-1001234567890", title: "Acme On-call", type: "supergroup" }], + }) + }), + ) + + /** + * `getUpdates` CONFIRMS (and discards) every update before `offset`, so + * sending one would delete the bot owner's pending updates as a side effect + * of them clicking a button in our UI. + */ + it.effect("does not confirm updates — no offset is ever sent", () => + Effect.gen(function* () { + const { fetchFn, calls } = respondWith(200, { ok: true, result: [] }) + yield* fetchTelegramChats(BOT_TOKEN, fetchFn, 1000) + assert.lengthOf(calls, 1) + assert.notInclude(calls[0]!, "offset") + }), + ) + + it.effect("dedupes chats and returns the most recent first", () => + Effect.gen(function* () { + const { fetchFn } = respondWith(200, { + ok: true, + result: [ + { message: { chat: chatOf(-100111, "Older", "supergroup") } }, + { message: { chat: chatOf(-100111, "Older", "supergroup") } }, + { channel_post: { chat: chatOf(-100222, "Newer", "channel") } }, + ], + }) + const result = yield* fetchTelegramChats(BOT_TOKEN, fetchFn, 1000) + assert.strictEqual(result.status, "ok") + if (result.status === "ok") { + assert.deepStrictEqual( + result.chats.map((chat) => chat.id), + ["-100222", "-100111"], + ) + } + }), + ) + + it.effect("names a one-to-one chat by username when it has no title", () => + Effect.gen(function* () { + const { fetchFn } = respondWith(200, { + ok: true, + result: [{ message: { chat: { id: 42, type: "private", username: "ada" } } }], + }) + const result = yield* fetchTelegramChats(BOT_TOKEN, fetchFn, 1000) + assert.strictEqual(result.status, "ok") + if (result.status === "ok") { + assert.deepStrictEqual(result.chats, [{ id: "42", title: "ada", type: "private" }]) + } + }), + ) + + /** A fixable state, and nothing like a bad token — it earns its own sentence. */ + it.effect("explains a webhook conflict instead of reporting a generic failure", () => + Effect.gen(function* () { + const { fetchFn } = respondWith(409, { + ok: false, + error_code: 409, + description: "Conflict: can't use getUpdates method while webhook is active", + }) + const result = yield* fetchTelegramChats(BOT_TOKEN, fetchFn, 1000) + assert.strictEqual(result.status, "invalid") + if (result.status === "invalid") assert.include(result.reason, "webhook") + }), + ) + + it.effect("reports a rejected token as such", () => + Effect.gen(function* () { + const { fetchFn } = respondWith(401, { ok: false, error_code: 401, description: "Unauthorized" }) + const result = yield* fetchTelegramChats(BOT_TOKEN, fetchFn, 1000) + assert.deepStrictEqual(result, { status: "invalid", reason: "Telegram rejected the bot token" }) + }), + ) + + it.effect("never throws when the request fails", () => + Effect.gen(function* () { + const fetchFn: typeof fetch = () => Promise.reject(new Error("network down")) + const result = yield* fetchTelegramChats(BOT_TOKEN, fetchFn, 1000) + assert.strictEqual(result.status, "invalid") + }), + ) +}) diff --git a/apps/api/src/services/alerts/delivery/transports/telegram.ts b/apps/api/src/services/alerts/delivery/transports/telegram.ts index f6b82811f..ccbf54d97 100644 --- a/apps/api/src/services/alerts/delivery/transports/telegram.ts +++ b/apps/api/src/services/alerts/delivery/transports/telegram.ts @@ -214,3 +214,164 @@ export const verifyTelegramCredentials = ( }), Effect.orElseSucceed(() => ({ status: "unknown" as const })), ) + +/* -------------------------------------------------------------------------- */ +/* Chat discovery */ +/* -------------------------------------------------------------------------- */ + +export interface TelegramChat { + readonly id: string + readonly title: string + readonly type: "private" | "group" | "supergroup" | "channel" +} + +export type TelegramChatDiscovery = + | { status: "ok"; chats: ReadonlyArray } + | { status: "invalid"; reason: string } + +/** + * Telegram identifies a chat by a signed integer — negative for groups and + * channels. Well inside 2^53 (a supergroup id is ~1e12), but it is carried as a + * string from here on because that is what the destination stores and what the + * form field holds. + */ +const TelegramChatSchema = Schema.Struct({ + id: Schema.Number, + type: Schema.String, + title: Schema.optionalKey(Schema.String), + username: Schema.optionalKey(Schema.String), + first_name: Schema.optionalKey(Schema.String), +}) + +/** + * The update kinds that can name a chat. + * + * `my_chat_member` is the one that makes this feature work at all. Bots join + * groups with privacy mode ON, so an ordinary message in a group is invisible + * to `getUpdates` unless it is a command, a mention, or a reply — meaning + * "send a message and we'll find the chat" fails for exactly the setup people + * are most likely to have. A `my_chat_member` update fires when the bot is + * added, promoted, or removed, regardless of privacy mode, so simply adding + * the bot is enough to surface the chat. + */ +const TelegramUpdateSchema = Schema.Struct({ + message: Schema.optionalKey(Schema.Struct({ chat: TelegramChatSchema })), + edited_message: Schema.optionalKey(Schema.Struct({ chat: TelegramChatSchema })), + channel_post: Schema.optionalKey(Schema.Struct({ chat: TelegramChatSchema })), + my_chat_member: Schema.optionalKey(Schema.Struct({ chat: TelegramChatSchema })), +}) + +const GetUpdatesResponseSchema = Schema.Struct({ + ok: Schema.optionalKey(Schema.Boolean), + description: Schema.optionalKey(Schema.String), + error_code: Schema.optionalKey(Schema.Number), + result: Schema.optionalKey(Schema.Array(TelegramUpdateSchema)), +}) +const decodeGetUpdates = Schema.decodeUnknownResult(GetUpdatesResponseSchema) + +const CHAT_TYPES = ["private", "group", "supergroup", "channel"] as const + +const chatLabel = (chat: Schema.Schema.Type): string => + chat.title ?? chat.username ?? chat.first_name ?? `Chat ${chat.id}` + +const narrowChatType = (raw: string): TelegramChat["type"] | null => + CHAT_TYPES.find((candidate) => candidate === raw) ?? null + +/** + * The chats a bot can currently see, for the destination form's chat picker. + * + * Transcribing a negative chat id out of a raw `getUpdates` payload is where + * this setup fails in practice, so the server does the reading. Two properties + * this deliberately holds: + * + * - **It does not consume updates.** `getUpdates` confirms (and discards) + * everything before `offset`, so passing one would delete the bot owner's + * pending updates as a side effect of them clicking a button in our UI. With + * no offset the call is a read, and it stays repeatable. + * - **It reports a webhook conflict as its own reason.** A bot with a webhook + * registered answers `getUpdates` with 409, which is a fixable state, not a + * bad token — saying so is the difference between a 10-second fix and a + * confused support ticket. + * + * Telegram only retains updates for ~24 hours, so an empty list is a normal + * answer meaning "nothing recent", not a failure. + */ +export const fetchTelegramChats = ( + botToken: string, + fetchFn: typeof fetch, + timeoutMs: number, +): Effect.Effect => + Effect.tryPromise(() => + fetchFn( + `${TELEGRAM_API_ORIGIN}/bot${botToken}/getUpdates?limit=100&timeout=0&allowed_updates=${encodeURIComponent( + JSON.stringify(["message", "edited_message", "channel_post", "my_chat_member"]), + )}`, + { method: "GET" }, + ), + ).pipe( + Effect.flatMap((response) => + Effect.promise(() => response.text().catch(() => "")).pipe( + Effect.map((raw): TelegramChatDiscovery => { + const parsed = Result.try({ try: (): unknown => JSON.parse(raw), catch: () => null }) + const decoded = decodeGetUpdates(Result.getOrElse(parsed, () => null)) + if (Result.isFailure(decoded)) { + return response.ok + ? { status: "invalid", reason: "Telegram returned an unexpected response" } + : { + status: "invalid", + reason: `Telegram rejected the request (${response.status})`, + } + } + const payload = decoded.success + if (!payload.ok) { + const description = payload.description ?? "" + if (payload.error_code === 409 || description.includes("webhook is active")) { + return { + status: "invalid", + reason: "This bot has a webhook registered, so Maple cannot read its recent chats. Delete the webhook (or enter the chat ID by hand).", + } + } + if (payload.error_code === 401) { + return { status: "invalid", reason: "Telegram rejected the bot token" } + } + return { + status: "invalid", + reason: + truncate(description.replace(/\s+/g, " ").trim(), 300) || + "Telegram rejected the request", + } + } + + const byId = new Map() + // Newest first: `getUpdates` returns ascending `update_id`, and the + // chat someone just added the bot to is the one they are looking for. + for (const update of [...(payload.result ?? [])].reverse()) { + const chat = ( + update.my_chat_member ?? + update.message ?? + update.channel_post ?? + update.edited_message + )?.chat + if (chat === undefined) continue + const type = narrowChatType(chat.type) + if (type === null) continue + const id = String(chat.id) + if (!byId.has(id)) byId.set(id, { id, title: chatLabel(chat), type }) + } + return { status: "ok", chats: [...byId.values()] } + }), + ), + ), + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => + Effect.succeed({ + status: "invalid", + reason: "Telegram did not respond in time", + }), + }), + Effect.orElseSucceed(() => ({ + status: "invalid" as const, + reason: "Could not reach Telegram", + })), + ) diff --git a/apps/landing/src/content/docs/alerting/notification-destinations.md b/apps/landing/src/content/docs/alerting/notification-destinations.md index dbbc94a25..e353b8699 100644 --- a/apps/landing/src/content/docs/alerting/notification-destinations.md +++ b/apps/landing/src/content/docs/alerting/notification-destinations.md @@ -68,15 +68,23 @@ token** field. Copy it without the `bot` prefix. add it as an administrator with permission to post messages). A bot cannot message a chat it isn't a member of. -**3. Find the chat ID.** Post any message in the chat, then open: - -``` -https://api.telegram.org/bot/getUpdates -``` - -and read `result[].message.chat.id`. Group and channel ids are negative (`-1001234567890`); a -one-to-one chat with the bot is a positive number. A public channel can use `@channelusername` -instead. +**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 | | ------------- | --------------------------------------------------------------------------- | diff --git a/apps/web/src/components/alerts/destination-dialog.tsx b/apps/web/src/components/alerts/destination-dialog.tsx index 5bb30925e..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) { @@ -908,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, @@ -1087,9 +1193,15 @@ export function DestinationDialog({

- + + onFormChange((current) => ({ + ...current, + telegramChatId: chatId, + })) + } + />

- Add the bot to the chat, then read the id from{" "} - api.telegram.org/bot<token>/getUpdates. Maple - checks the bot can reach it when you save. + Add the bot to the chat, then hit Detect chats — + or enter the id by hand. Maple checks the bot can reach it when + you save.

diff --git a/packages/domain/src/http/v2/alert-destinations.ts b/packages/domain/src/http/v2/alert-destinations.ts index 529f03c86..03d7ba993 100644 --- a/packages/domain/src/http/v2/alert-destinations.ts +++ b/packages/domain/src/http/v2/alert-destinations.ts @@ -407,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, @@ -457,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 }, 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", From 89f4b761519a98c4828dbb027ac9fc9d980f8d16 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 20 Aug 2026 22:54:14 +0200 Subject: [PATCH 3/3] fix(ios): label the telegram destination type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `telegram` to `AlertDestinationType` broke an exhaustive Swift switch over the enum generated from `openapi.json`. The iOS build is the only consumer that enforces this, and `bun typecheck` never compiles it, so it surfaced in CI rather than locally. Same class of gate as the TypeScript `Match.discriminatorsExhaustive` registries — it just lives in a toolchain the web-side workflow doesn't run. --- apps/ios/Maple/Components/AlertFormatting.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ios/Maple/Components/AlertFormatting.swift b/apps/ios/Maple/Components/AlertFormatting.swift index 3b808ee13..b8376fb94 100644 --- a/apps/ios/Maple/Components/AlertFormatting.swift +++ b/apps/ios/Maple/Components/AlertFormatting.swift @@ -165,6 +165,7 @@ extension AlertDestinationType { case .webhook: "Webhook" case .hazelOauth: "Hazel" case .discord: "Discord" + case .telegram: "Telegram" case .email: "Email" } }