Skip to content

feat(alerts): add Telegram as a notification destination - #553

Merged
Makisuo merged 3 commits into
mainfrom
feat/telegram-alert-destination
Aug 20, 2026
Merged

feat(alerts): add Telegram as a notification destination#553
Makisuo merged 3 commits into
mainfrom
feat/telegram-alert-destination

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Telegram was reachable only through the generic webhook destination, which ships Maple's own JSON envelope — a shape the Bot API cannot consume. In practice there was no way to route alerts to Telegram at all.

This adds a first-class telegram destination: bot token + chat ID, encrypted at rest like every other channel secret, verified when you save it. Messages go out as HTML with an inline keyboard carrying Open in Maple and Ask Maple AI, and the alert chart as the message's link preview.

Why it looks the way it does

The transport registry made this mostly additive — a pure render() plus one arm in the exhaustive Match. Three things needed real care, and they're the parts worth reviewing:

guarded: false + sensitivePath: true is a new combination. The host is a compile-time vendor constant, but the bot token rides in the URL path. Transport.ts anticipated exactly this ("the two coincide today and a future provider could easily break that"), so the span annotates server.address and omits url.path. render.test.ts's exact-array assertions now read guarded === ["webhook","discord","hazel-oauth"] and sensitivePath === ["discord","hazel-oauth","telegram"].

Telegram lies with 200s, the same way Slack does — logical failures come back as { ok: false, error_code } with HTTP 200, so the runner's status classifier never sees them. interpret owns retryability: 401/403/404 permanent, 400 rejected, 429/5xx retryable.

HTML escaping here is a correctness problem, not just a safety one. formatThresholdSummary legitimately emits > 5%, which Telegram reads as an unclosed tag and rejects the whole message with a 400. Rather than duplicate the summary wording, buildSlackSummaryLine is now buildSummaryLine(context, em) parameterized over the emphasis marker; Telegram passes the identity and escapes the finished line, putting its bold in the title. Template bodies go through markdownToTelegramHtml, which escapes first and then adds tags — mirroring markdownToSlackMrkdwn — and restricts link targets to http/https so a template author can't aim a button at a tg:// in-app action.

Two smaller decisions:

  • Save-time verification (getMe, then getChat) mirrors verifyPagerDutyRoutingKey and fails open on anything ambiguous, so a Telegram outage can't block a save. getChat is the one that earns its keep — a valid token aimed at a group the bot was never added to is the dominant misconfiguration, and without this it surfaces only when a real alert silently fails to deliver.
  • The chat ID comes back as channelLabel, not only inside the summary. Otherwise renaming a destination would demand retyping the ID, since the token is write-only. It is not a secret, so this is safe to sync.

No migration. alert_destinations.type is text and the config is jsonb plus an encrypted blob.

Also touched

Domain + v2 schemas, the HTTP route mappers, the Maple.AlertDestination IaC resource (token-based, so it belongs in the declarative subset alongside pagerduty/webhook/discord), the web form/dialog/provider registry, a new Telegram icon, a ## Telegram docs section, and landing copy in all three locales. The iOS openapi.json is regenerated; ios:openapi:check is green.

The provider accent is #26A5E4, with the ink and label ratios computed and commented per the convention destination-provider.tsx documents: white on the brand blue is 2.77:1 so the ink is INK_ON_BRIGHT_ACCENT (6.20:1), and accentText splits light-dark(#0B6E9E, #26A5E4) for 4.81:1 / 4.87:1 against the tint on each canvas.

Testing

25 new tests. A dedicated telegram.test.ts covers render, HTML escaping (including a rule name containing < and &), the 4096-char cap, interpret classification per error code, and credential verification incl. both fail-open paths. Telegram cases were added to the render-guard specs, the delivery-span assertions (peer.service, server.address, absence of url.path), and provider dispatch.

bun typecheck and bun run lint are clean. apps/api 2247 passed, apps/web 1754 passed, packages/domain 550 passed, packages/alchemy-maple 27 passed.

Verified live in the browser against the real Bot API: a token with the bot prefix left on is caught by the local shape check, and a well-formed fake gets "Telegram rejected the bot token" from getMe.

What a reviewer should still check

I could not verify a real message landing in a chat, the templated-override path end-to-end, or the live delivery span — all need a genuine @Botfather bot and chat, which I didn't create. Those paths are covered by tests (provider dispatch asserts the full wire body; the span test asserts the attributes), but they have not been exercised against Telegram's servers. Worth one manual Send test before this ships.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Telegram was reachable only through the generic `webhook` destination,
which ships Maple's own JSON envelope — a shape the Bot API cannot
consume. So in practice there was no way to route alerts to Telegram at
all.

Adds a first-class `telegram` destination: bot token + chat ID, encrypted
at rest like every other channel secret, verified when you save it.
Messages go out as HTML with an inline keyboard carrying "Open in Maple"
and "Ask Maple AI", and the alert chart as the message's link preview.

The transport registry made this mostly additive — a pure `render()` plus
one arm in the exhaustive `Match`. Three things needed real care:

- `guarded: false` + `sensitivePath: true` is a new combination. The host
  is a compile-time vendor constant, but the bot token rides in the URL
  path, so the span records `server.address` and must not record
  `url.path`. `Transport.ts` anticipated this and declares the two flags
  separately rather than inferring one from the other.

- Telegram reports logical failures as HTTP 200 with
  `{ ok: false, error_code }`, the same lie Slack tells. The runner's
  status classifier never sees them, so `interpret` owns retryability:
  401/403/404 permanent, 400 rejected, 429/5xx retryable.

- `parse_mode: "HTML"` needs escaping for correctness, not just safety —
  `formatThresholdSummary` legitimately emits `> 5%`, which Telegram reads
  as an unclosed tag and rejects the whole message with a 400. The Slack
  summary line is now parameterized over its emphasis marker so the
  three-branch wording stays in one place; Telegram passes the identity
  and escapes the finished line.

Save-time verification (`getMe` then `getChat`) mirrors
`verifyPagerDutyRoutingKey` and fails open on anything ambiguous, so a
Telegram outage can't block a save. `getChat` is the check that earns its
keep: a valid token aimed at a group the bot was never added to is the
dominant misconfiguration, and without it that surfaces only when a real
alert silently fails to deliver.

The chat ID comes back as `channelLabel`, not just inside the summary —
otherwise renaming a destination would demand retyping the ID, since the
token is write-only.

No migration: `alert_destinations.type` is `text` and the config is jsonb
plus an encrypted blob.
…e an ID

Step 3 of the Telegram setup was "open a raw getUpdates URL and read a
negative integer out of the JSON". That is where this flow fails in
practice, so the server does the reading now: paste the token, hit
**Detect chats**, pick a chat by name.

Adds `POST /v2/alerts/destinations/telegram/chats` — one `getUpdates`
read with a token that is used and discarded, never stored. Admin-gated
for the same reason the Slack channel list is: it reads somebody's chat
inventory and accepts an arbitrary token, so it must not be a probe any
org member can drive.

Three properties this deliberately holds:

- **It collects `my_chat_member`, not just messages.** Bots join groups
  with privacy mode ON, so an ordinary group message never reaches
  `getUpdates` — only a command, a mention, or a reply does. A
  `my_chat_member` update fires when the bot is added, regardless of
  privacy mode, which is what makes "just add the bot" sufficient. Without
  this the feature would silently find nothing for the most common setup
  there is, so it has a test that says so.

- **It never sends an `offset`.** `getUpdates` confirms and discards every
  update before `offset`, so passing one would delete the bot owner's
  pending updates as a side effect of them clicking a button in our UI.
  Also pinned by a test.

- **A webhook conflict gets its own sentence.** A bot with a webhook
  registered answers `getUpdates` with 409. That is a fixable state, not a
  bad token, and saying so is the difference between a ten-second fix and
  a support ticket.

The manual input stays editable throughout. Discovery legitimately comes
back empty for valid setups — Telegram retains updates for ~24 hours, and
a webhook-backed bot cannot be inspected at all — so the picker is only
ever additive to the field, never a replacement for it.

The new path is added to the committed v2 surface list in openapi.test,
which is the gate that required it to be a deliberate choice.
Adding `telegram` to `AlertDestinationType` broke an exhaustive Swift
switch over the enum generated from `openapi.json`. The iOS build is the
only consumer that enforces this, and `bun typecheck` never compiles it,
so it surfaced in CI rather than locally.

Same class of gate as the TypeScript `Match.discriminatorsExhaustive`
registries — it just lives in a toolchain the web-side workflow doesn't
run.
@Makisuo
Makisuo merged commit 1abbeca into main Aug 20, 2026
33 checks passed
@Makisuo
Makisuo deleted the feat/telegram-alert-destination branch August 20, 2026 22:01
@Makisuo
Makisuo deployed to pr-preview August 20, 2026 22:01 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit 89f4b76 · View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant