Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/api/src/routes/v2/alert-destinations.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import {
AlertDestinationNotFoundError,
PagerDutyAlertDestinationConfig,
SlackBotAlertDestinationConfig,
TelegramAlertDestinationConfig,
WebhookAlertDestinationConfig,
} from "@maple/domain/http"
import type {
V2AlertDestination,
V2AlertDestinationCreateParams,
V2AlertDestinationMutationResponse,
V2AlertDestinationUpdateParams,
V2TelegramChatList,
} from "@maple/domain/http/v2"
import { MapleApiV2, paginateArray } from "@maple/domain/http/v2"
import { Effect } from "effect"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/routes/v2/v2-test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ const alertDestinationStubs = {
createDestination: die,
updateDestination: die,
deleteDestination: die,
listTelegramChats: die,
testDestination: die,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<b>Checkout error rate</b>")
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,
Expand Down
124 changes: 107 additions & 17 deletions apps/api/src/services/alerts/AlertDeliveryDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")

/** Telegram's hard cap on `sendMessage.text`. */
const TELEGRAM_TEXT_LIMIT = 4096

const telegramFooter = (context: Pick<DispatchContext, "sentAtMs" | "incidentId" | "sparkline">): string => {
const parts = ["\u{1F341} Maple Alerts"]
if (context.sparkline) parts.push(`<code>${escapeTelegramHtml(context.sparkline)}</code>`)
if (context.incidentId) parts.push(`Incident <code>${escapeTelegramHtml(context.incidentId)}</code>`)
if (context.sentAtMs != null) parts.push(new Date(context.sentAtMs).toISOString())
return parts.join(" \u{00B7} ")
}

const telegramDetailLine = (
context: Pick<DispatchContext, "severity" | "groupKey" | "windowMinutes">,
): string => {
const group = displayGroupKey(context.groupKey)
const parts = [
`<b>Severity</b> ${escapeTelegramHtml(formatSeverityLabel(context.severity))}`,
`<b>Window</b> ${escapeTelegramHtml(formatWindow(context.windowMinutes))}`,
]
if (group != null) parts.push(`<b>Group</b> <code>${escapeTelegramHtml(group)}</code>`)
return parts.join(" \u{00B7} ")
}

const telegramBody = (title: string, lines: ReadonlyArray<string>): 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)} <b>${escapeTelegramHtml(context.ruleName)}</b> \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>b</b>`, `[t](url)` -> `<a href="url">t</a>`.
*
* 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, "<b>$1</b>")
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, text: string, url: string) =>
/^https?:\/\//i.test(url) ? `<a href="${url.replaceAll('"', "%22")}">${text}</a>` : match,
)

export const buildTelegramTextFromTemplate = (
title: string,
body: string,
context: Pick<DispatchContext, "sentAtMs" | "incidentId" | "sparkline">,
): string =>
telegramBody(`<b>${escapeTelegramHtml(title)}</b>`, [
markdownToTelegramHtml(body),
"",
telegramFooter(context),
])

/* -------------------------------------------------------------------------- */
/* Templated notifications */
/* -------------------------------------------------------------------------- */
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/services/alerts/AlertDestinationHydration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/services/alerts/AlertDestinationHydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading