feat(alerts): add Telegram as a notification destination - #553
Merged
Conversation
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.
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Telegram was reachable only through the generic
webhookdestination, 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
telegramdestination: 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 exhaustiveMatch. Three things needed real care, and they're the parts worth reviewing:guarded: false+sensitivePath: trueis a new combination. The host is a compile-time vendor constant, but the bot token rides in the URL path.Transport.tsanticipated exactly this ("the two coincide today and a future provider could easily break that"), so the span annotatesserver.addressand omitsurl.path.render.test.ts's exact-array assertions now readguarded === ["webhook","discord","hazel-oauth"]andsensitivePath === ["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.interpretowns retryability: 401/403/404 permanent, 400 rejected, 429/5xx retryable.HTML escaping here is a correctness problem, not just a safety one.
formatThresholdSummarylegitimately emits> 5%, which Telegram reads as an unclosed tag and rejects the whole message with a 400. Rather than duplicate the summary wording,buildSlackSummaryLineis nowbuildSummaryLine(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 throughmarkdownToTelegramHtml, which escapes first and then adds tags — mirroringmarkdownToSlackMrkdwn— and restricts link targets to http/https so a template author can't aim a button at atg://in-app action.Two smaller decisions:
getMe, thengetChat) mirrorsverifyPagerDutyRoutingKeyand fails open on anything ambiguous, so a Telegram outage can't block a save.getChatis 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.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.typeistextand the config is jsonb plus an encrypted blob.Also touched
Domain + v2 schemas, the HTTP route mappers, the
Maple.AlertDestinationIaC 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## Telegramdocs section, and landing copy in all three locales. The iOSopenapi.jsonis regenerated;ios:openapi:checkis green.The provider accent is
#26A5E4, with the ink and label ratios computed and commented per the conventiondestination-provider.tsxdocuments: white on the brand blue is 2.77:1 so the ink isINK_ON_BRIGHT_ACCENT(6.20:1), andaccentTextsplitslight-dark(#0B6E9E, #26A5E4)for 4.81:1 / 4.87:1 against the tint on each canvas.Testing
25 new tests. A dedicated
telegram.test.tscovers render, HTML escaping (including a rule name containing<and&), the 4096-char cap,interpretclassification 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 ofurl.path), and provider dispatch.bun typecheckandbun run lintare clean.apps/api2247 passed,apps/web1754 passed,packages/domain550 passed,packages/alchemy-maple27 passed.Verified live in the browser against the real Bot API: a token with the
botprefix left on is caught by the local shape check, and a well-formed fake gets "Telegram rejected the bot token" fromgetMe.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.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.