diff --git a/apps/api/src/routes/v2/alert-destinations.http.ts b/apps/api/src/routes/v2/alert-destinations.http.ts index 7c913daba..ea2660a9d 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 { @@ -15,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" @@ -89,6 +91,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 +173,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", @@ -204,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/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..81edf87ad 100644 --- a/apps/api/src/services/alerts/AlertDestinationsService.ts +++ b/apps/api/src/services/alerts/AlertDestinationsService.ts @@ -41,6 +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 { + fetchTelegramChats, + TELEGRAM_BOT_TOKEN_PATTERN, + verifyTelegramCredentials, + type TelegramChat, +} from "./delivery/transports/telegram" import { DestinationPublicConfigSchema, type DestinationPublicConfig, @@ -119,6 +125,12 @@ 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()}` + const buildPublicConfig = ( request: Exclude, ): DestinationPublicConfig => @@ -140,6 +152,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 +177,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(), + }), }), ) @@ -266,6 +288,10 @@ export interface AlertDestinationsServiceApi { | AlertDestinationInUseError | AlertRuleStoredConfigInvalidError > + readonly listTelegramChats: ( + roles: ReadonlyArray, + botToken: string, + ) => Effect.Effect, AlertForbiddenError | AlertValidationError> readonly testDestination: ( orgId: OrgId, userId: UserId, @@ -412,6 +438,40 @@ 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_MALFORMED_TOKEN_MESSAGE)) + } + const result = yield* verifyTelegramCredentials( + botToken, + chatId, + runtime.fetch, + runtime.deliveryTimeoutMs(), + ) + if (result.status === "invalid") { + return yield* Effect.fail(makeValidationError(result.reason)) + } + }) + + 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) { @@ -451,6 +511,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 +706,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 +753,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, @@ -820,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 { 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..bb4c4500e --- /dev/null +++ b/apps/api/src/services/alerts/delivery/transports/telegram.test.ts @@ -0,0 +1,401 @@ +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 { + fetchTelegramChats, + 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" }) + }), + ) +}) + +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 new file mode 100644 index 000000000..ccbf54d97 --- /dev/null +++ b/apps/api/src/services/alerts/delivery/transports/telegram.ts @@ -0,0 +1,377 @@ +// 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 })), + ) + +/* -------------------------------------------------------------------------- */ +/* 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/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" } } 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..e353b8699 100644 --- a/apps/landing/src/content/docs/alerting/notification-destinations.md +++ b/apps/landing/src/content/docs/alerting/notification-destinations.md @@ -1,6 +1,6 @@ --- title: "Notification destinations" -description: "Route Maple alerts to Slack, PagerDuty, Discord, or any HTTP endpoint. How to add a destination, send a test, and get the right credentials for each provider." +description: "Route Maple alerts to Slack, PagerDuty, Discord, Telegram, or any HTTP endpoint. How to add a destination, send a test, and get the right credentials for each provider." group: "Alerting" order: 0 --- @@ -54,6 +54,50 @@ Post alerts to a Discord channel via an incoming webhook. 1. In Discord: **Channel settings → Integrations → Webhooks → New Webhook**. 2. Copy the webhook URL (`https://discord.com/api/webhooks/...`) into the **Discord webhook URL** field. +## Telegram + +Send alerts to a Telegram chat, group, or channel through a bot you create. Telegram has no +per-channel webhook, so a destination needs two things: the bot's token, and the id of the chat it +should post to. + +**1. Create the bot.** In Telegram, message [@BotFather](https://t.me/BotFather), send `/newbot`, and +follow the prompts. BotFather replies with a token of the form `123456789:AAH…` — that is the **Bot +token** field. Copy it without the `bot` prefix. + +**2. Add the bot to the chat.** Invite it to the group or channel you want alerts in (for a channel, +add it as an administrator with permission to post messages). A bot cannot message a chat it isn't +a member of. + +**3. Pick the chat.** Back in Maple, paste the bot token and click **Detect chats** — Maple asks +Telegram which chats the bot can currently see and lists them by name. Pick one and the chat ID +fills itself in. + +Detection reads the bot's recent updates, so a few things are worth knowing: + +- **Adding the bot is enough.** You don't need to send a message first — Telegram notifies the bot + when it's added to a chat, and that's what Maple reads. +- **Telegram keeps about 24 hours of history.** An empty list usually means the bot was added + longer ago than that. Send it a message (or remove and re-add it) and detect again. +- **A bot with a webhook registered can't be inspected this way.** Telegram allows only one reader + at a time. If you've pointed this bot at your own webhook, enter the chat ID by hand instead. + +To find the ID manually, post a message in the chat and open +`https://api.telegram.org/bot/getUpdates`, then read `result[].message.chat.id`. Group +and channel IDs are negative (`-1001234567890`); a one-to-one chat is positive. Public channels can +use `@channelusername` instead. + +| Field | Notes | +| ------------- | --------------------------------------------------------------------------- | +| **Bot token** | From @BotFather. Write-only — never returned after saving. | +| **Chat ID** | `-1001234567890`, or `@channelusername` for a public channel. | + +When you save, Maple verifies the token and checks that the bot can actually reach that chat, so the +usual mistake — a valid token pointed at a group the bot was never added to — is caught immediately +rather than at the first real alert. + +Alerts arrive as a formatted message with **Open in Maple** and **Ask Maple AI** buttons underneath, +and the alert chart as the message preview when one is available. + ## Webhook POST a signed JSON payload to any HTTP endpoint you control — useful for custom routing, on-call tools without a native integration, or your own automation. diff --git a/apps/web/src/components/alerts/destination-dialog.tsx b/apps/web/src/components/alerts/destination-dialog.tsx index b193c55c3..bc397844d 100644 --- a/apps/web/src/components/alerts/destination-dialog.tsx +++ b/apps/web/src/components/alerts/destination-dialog.tsx @@ -25,12 +25,12 @@ import { resolveSearchQuery, } from "@/components/alerts/slack-channel-search" import { MapleApiAtomClient, retainedQuery } from "@/lib/services/common/atom-client" -import { retainedQueryV2 } from "@/lib/services/common/v2-atom-client" -import { publicError } from "@/lib/error-messages" +import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { displayError, publicError } from "@/lib/error-messages" import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" import type { HazelChannelsListResponse } from "@maple/domain/http" -import type { V2SlackChannelList } from "@maple/domain/http/v2" +import type { V2SlackChannelList, V2TelegramChat } from "@maple/domain/http/v2" import { Exit, Option } from "effect" import { Link } from "@tanstack/react-router" import { useEffect, useMemo, useState } from "react" @@ -86,6 +86,19 @@ interface DestinationDialogProps { */ const isValidPagerDutyKey = (key: string): boolean => /^[A-Za-z0-9]{32}$/.test(key.trim()) +/** + * Mirrors `TELEGRAM_BOT_TOKEN_PATTERN` on the server. Only gates the "Detect + * chats" button — the server re-checks, and it owns the message shown on save. + */ +const isValidTelegramToken = (token: string): boolean => /^\d{5,}:[A-Za-z0-9_-]{30,}$/.test(token.trim()) + +const TELEGRAM_CHAT_TYPE_LABELS = { + private: "Direct message", + group: "Group", + supergroup: "Group", + channel: "Channel", +} satisfies Record + function isFormReady(form: DestinationFormState, isEditing: boolean): boolean { if (form.name.trim().length === 0) return false switch (form.type) { @@ -98,6 +111,13 @@ function isFormReady(form: DestinationFormState, isEditing: boolean): boolean { // stored one. case "discord": return isEditing || form.webhookUrl.trim().length > 0 + case "telegram": + // The chat id is not a secret and is never returned, so editing always + // requires it; the token may stay blank to keep the stored one. + return ( + form.telegramChatId.trim().length > 0 && + (isEditing || form.telegramBotToken.trim().length > 0) + ) case "pagerduty": // Editing with a blank key keeps the stored one; otherwise require a // well-formed routing key. @@ -901,6 +921,99 @@ function FieldHelper({ provider }: { provider: DestinationProvider }) { ) } +/** + * Turns "read a negative integer out of a raw `getUpdates` payload" into + * picking a chat by name — the step where this setup otherwise fails. + * + * Only ever additive to the field: the manual input stays editable, because + * Telegram keeps updates for about 24 hours and a bot with a webhook cannot be + * inspected at all, so discovery legitimately comes back empty for setups that + * are perfectly valid. + */ +function TelegramChatPicker({ + botToken, + onSelect, +}: { + botToken: string + onSelect: (chatId: string) => void +}) { + const [chats, setChats] = useState | null>(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const detect = useAtomSet(MapleApiV2AtomClient.mutation("alertDestinations", "telegramChats"), { + mode: "promiseExit", + }) + + const tokenReady = isValidTelegramToken(botToken) + + const runDetect = async () => { + setBusy(true) + setError(null) + setChats(null) + const result = await detect({ payload: { bot_token: botToken.trim() } }) + setBusy(false) + if (Exit.isSuccess(result)) { + const found = result.value.chats + setChats(found) + // One chat is the common case — the bot was just added to a single + // group. Skip the pointless list of one and fill the field. + if (found.length === 1 && found[0] !== undefined) onSelect(found[0].id) + return + } + setError(displayError(result.cause).message) + } + + return ( +
+
+ + +
+ {error !== null ?

{error}

: null} + {chats !== null && chats.length === 0 ? ( +

+ No recent chats. Add the bot to the group or channel (or send it a message), then detect + again. Telegram only keeps the last 24 hours. +

+ ) : null} + {chats !== null && chats.length > 0 ? ( +
+ {chats.map((chat) => ( + + ))} +
+ ) : null} +
+ ) +} + export function DestinationDialog({ open, onOpenChange, @@ -1050,6 +1163,66 @@ export function DestinationDialog({
)} + {form.type === "telegram" && ( + <> +
+ + + onFormChange((current) => ({ + ...current, + telegramBotToken: event.target.value, + })) + } + placeholder={ + isEditing + ? "Leave blank to keep current token" + : "123456789:ABC-DEF..." + } + className="font-mono text-xs" + /> +

+ In Telegram: message @BotFather, send /newbot, then + copy the token it replies with. +

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

+ 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. +

+
+ + )} + {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..03d7ba993 100644 --- a/packages/domain/src/http/v2/alert-destinations.ts +++ b/packages/domain/src/http/v2/alert-destinations.ts @@ -79,7 +79,7 @@ export const V2AlertDestination = Schema.Struct({ }), type: AlertDestinationType.annotate({ description: - "The delivery channel: `slack-bot`, `pagerduty`, `webhook`, `hazel-oauth`, `discord`, or `email`. Immutable after creation.", + "The delivery channel: `slack-bot`, `pagerduty`, `webhook`, `hazel-oauth`, `discord`, `telegram`, or `email`. Immutable after creation.", examples: ["slack-bot"], }), enabled: Schema.Boolean.annotate({ @@ -93,7 +93,8 @@ export const V2AlertDestination = Schema.Struct({ examples: ["Slack bot → #incidents"], }), channel_label: Schema.NullOr(Schema.String).annotate({ - description: "Optional display label for the target channel (Slack destinations), or `null`.", + description: + "Optional display label for the target channel — the channel name for Slack, the chat ID for Telegram — or `null`.", examples: ["#incidents"], }), member_user_ids: Schema.NullOr(Schema.Array(Schema.String)).annotate({ @@ -113,7 +114,7 @@ export const V2AlertDestination = Schema.Struct({ identifier: "AlertDestination", title: "Alert Destination", description: - "A notification channel that alert rules deliver to (Slack bot, PagerDuty, generic webhook, Hazel OAuth, Discord, or workspace-member email). Channel secrets are write-only: responses carry a redacted `summary` instead.", + "A notification channel that alert rules deliver to (Slack bot, PagerDuty, generic webhook, Hazel OAuth, Discord, Telegram, or workspace-member email). Channel secrets are write-only: responses carry a redacted `summary` instead.", examples: [wireExample(alertDestinationExample)], }) export type V2AlertDestination = Schema.Schema.Type @@ -213,6 +214,20 @@ const V2DiscordDestinationCreateParams = Schema.Struct({ enabled: enabledField, }).annotate({ identifier: "AlertDestinationCreateDiscord", title: "Discord destination" }) +const V2TelegramDestinationCreateParams = Schema.Struct({ + type: Schema.Literal("telegram"), + name: nameField, + bot_token: NonEmptyString.annotate({ + description: "The bot token issued by @BotFather. Write-only — never returned.", + }), + chat_id: NonEmptyString.annotate({ + description: + "The target chat: a numeric id such as `-1001234567890`, or an `@channelusername`. The bot must be a member of the chat.", + examples: ["-1001234567890"], + }), + enabled: enabledField, +}).annotate({ identifier: "AlertDestinationCreateTelegram", title: "Telegram destination" }) + const V2EmailDestinationCreateParams = Schema.Struct({ type: Schema.Literal("email"), name: nameField, @@ -228,6 +243,7 @@ export const V2AlertDestinationCreateParams = Schema.Union([ V2WebhookDestinationCreateParams, V2HazelOAuthDestinationCreateParams, V2DiscordDestinationCreateParams, + V2TelegramDestinationCreateParams, V2EmailDestinationCreateParams, ]).annotate({ identifier: "AlertDestinationCreateParams", @@ -290,6 +306,13 @@ export const V2AlertDestinationUpdateParams = Schema.Union([ webhook_url: Schema.optionalKey(Schema.String), enabled: Schema.optionalKey(Schema.Boolean), }).annotate({ identifier: "AlertDestinationUpdateDiscord", title: "Discord destination update" }), + Schema.Struct({ + type: Schema.Literal("telegram"), + name: optionalNameField, + bot_token: Schema.optionalKey(Schema.String), + chat_id: Schema.optionalKey(Schema.String), + enabled: Schema.optionalKey(Schema.Boolean), + }).annotate({ identifier: "AlertDestinationUpdateTelegram", title: "Telegram destination update" }), Schema.Struct({ type: Schema.Literal("email"), name: optionalNameField, @@ -384,6 +407,61 @@ const emailRecipientErrors = publicErrors( AlertMemberDirectoryNotConfiguredError, AlertMemberDirectoryUnavailableError, ) +export const V2TelegramChatsParams = Schema.Struct({ + bot_token: NonEmptyString.annotate({ + description: + "The bot token to inspect. Write-only, and not stored by this call — it is used for one `getUpdates` read and discarded.", + }), +}).annotate({ + identifier: "TelegramChatsParams", + title: "Telegram chat discovery parameters", +}) +export type V2TelegramChatsParams = Schema.Schema.Type + +export const V2TelegramChat = Schema.Struct({ + id: Schema.String.annotate({ + description: + "The chat ID, as a string. Negative for groups and channels — pass it verbatim as `chat_id` when creating the destination.", + examples: ["-1001234567890"], + }), + title: Schema.String.annotate({ + description: "Display name of the chat: its title, or the username for a one-to-one chat.", + examples: ["Acme On-call"], + }), + type: Schema.Literals(["private", "group", "supergroup", "channel"]).annotate({ + description: "Telegram's chat type.", + examples: ["supergroup"], + }), +}).annotate({ + identifier: "TelegramChat", + title: "Telegram chat", + description: "A chat the bot can currently see.", + examples: [wireExample({ id: "-1001234567890", title: "Acme On-call", type: "supergroup" })], +}) +export type V2TelegramChat = Schema.Schema.Type + +export const V2TelegramChatList = Schema.Struct({ + object: Schema.Literal("alert_destination.telegram_chat_list").annotate({ + description: 'The object type — always `"alert_destination.telegram_chat_list"`.', + }), + chats: Schema.Array(V2TelegramChat).annotate({ + description: + 'The chats the bot has seen recently, most recent first. Telegram retains updates for about 24 hours, so an empty array means "nothing recent" — add the bot to the chat, or send it a message, and try again.', + }), +}).annotate({ + identifier: "TelegramChatList", + title: "Telegram chat list", + description: + "Chats discovered from the bot's pending updates. Not the standard list envelope: there is no cursor, because Telegram exposes a short retention window rather than a paginated inventory.", + examples: [ + wireExample({ + object: "alert_destination.telegram_chat_list", + chats: [{ id: "-1001234567890", title: "Acme On-call", type: "supergroup" }], + }), + ], +}) +export type V2TelegramChatList = Schema.Schema.Type + const [destinationEncryption, destinationDecryption, destinationStoredConfigInvalid] = publicErrors( AlertDestinationEncryptionError, AlertDestinationDecryptionError, @@ -434,6 +512,20 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina }), ), ) + .add( + HttpApiEndpoint.post("telegramChats", "/telegram/chats", { + payload: V2TelegramChatsParams, + success: V2TelegramChatList, + error: [alertForbidden, alertValidation], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listTelegramChats", + summary: "List the chats a Telegram bot can see", + description: + "Reads a bot's pending updates and returns the chats it can currently post to, so a destination can be created by picking a chat instead of transcribing its numeric ID. The token is used for one read and never stored. Telegram retains updates for about 24 hours; a bot with a webhook registered cannot be inspected this way. Requires an org-admin role and the `alerts:write` scope.", + }), + ), + ) .add( HttpApiEndpoint.get("retrieve", "/:id", { params: { id: AlertDestinationPublicId }, @@ -518,6 +610,6 @@ export class V2AlertDestinationsApiGroup extends HttpApiGroup.make("alertDestina OpenApi.annotations({ title: "Alert Destinations", description: - "Notification channels for alert rules — Slack bot, PagerDuty, generic webhooks, Hazel OAuth, Discord, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", + "Notification channels for alert rules — Slack bot, PagerDuty, generic webhooks, Hazel OAuth, Discord, Telegram, and workspace-member email. Create and manage destinations, then reference them from alert rules via `destination_ids`. Mutations are admin-only; channel secrets are write-only.", }), ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 49d1f7665..3c46c46da 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -162,6 +162,7 @@ describe("MapleApiV2 OpenAPI", () => { "PATCH /v2/dashboards/{id}", "PATCH /v2/scrape_targets/{id}", "POST /v2/alerts/destinations", + "POST /v2/alerts/destinations/telegram/chats", "POST /v2/alerts/destinations/{id}/test", "POST /v2/alerts/rules", "POST /v2/alerts/rules/preview",