ADR-0041: Telegram adapter with chat-scoped message ids, a strict secret token and media stored as uploads
| Status | Accepted |
| Date | 2026-10-11 |
| Issue | #38 |
| Pull request | #132 |
| Related | ADR-0007, ADR-0009, ADR-0015, ADR-0016, ADR-0036, ADR-0038 |
Context and problem statement
Telegram is the cheapest real duplex channel to support (free, webhook based, no approval) and the first channel built on adapter behaviour v2 (ADR-0038). Issue #38 asks for messages, edits, button presses, reactions and media inbound; text, photos, documents, inline keyboards and typing outbound; and an admin button that registers the webhook. Four properties of the Bot API do not fit the WhatsApp-shaped assumptions of the existing adapters:
- Message ids are per chat.
message_idis a small integer unique only within its chat. Converger finds the target of a reply, edit or reaction by a provider id that must be unique per channel (Activities.get_activity_by_provider_message_id/2looks at inbound idempotency keys and outboundprovider_message_ids). An edit also carries the samemessage_idas the original. - Authentication is a shared secret, not a signature.
setWebhooktakes asecret_token, and Telegram echoes it inX-Telegram-Bot-Api-Secret-Token. The generic policy (ADR-0009) treats a missing signature as acceptable on channels withrequire_signature: false. - File URLs contain the bot token.
getFilereturns a path to be fetched fromhttps://api.telegram.org/file/bot<token>/<path>. Storing that URL in an attachment publishes the token to every consumer of the conversation. - The webhook is registered by an API call (
setWebhook), not in a provider console, and the issue wants it in the admin UI. No adapter callback existed for it.
Decision drivers
- Replies, edits and reactions must resolve to the right activity, and a re-delivered update must never create a second one (no data loss, no duplicates).
- The bot token must not appear in stored activities, delivery errors or traces.
- An invalid secret must be rejected with
401(acceptance criterion). - Nothing outside the adapter may learn about Telegram (ADR-0038: no type lists outside adapters).
Considered options
For message ids:
"<chat_id>:<message_id>"as the provider id everywhere (inbound idempotency key, outboundprovider_message_id, reference targets); edits keyed byedit_date.update_idas the idempotency key, and a separate lookup for references.- Raw
message_id.
For media:
- Download with
getFileand store as a Converger upload during the inbound request. - Store
file_idonly, and add a proxy endpoint that resolves it on read. - Store the Bot API file URL.
For the secret token: (a) generic policy only, or (b) a configured secret_token is always enforced.
For webhook registration: (a) a Telegram-specific admin button, or (b) an optional
register_webhook/2 adapter callback and a generic button.
Pros and cons of the options
Message ids: chat-scoped key (1)
- Good, because the existing reference resolution and idempotency work unchanged; outbound and inbound messages share one naming scheme.
- Bad, because the key is a Telegram convention that consumers must understand (documented).
Message ids: update_id (2)
- Good, because it is globally unique per bot.
- Bad, because replies and reactions name a
message_id, not anupdate_id, so a second index would be needed; outbound messages have noupdate_idat all.
Message ids: raw message_id (3)
- Bad, because two chats' message
41collide: a re-delivered message from one chat would be taken as a duplicate of another chat's message.
Media: store as upload (1)
- Good, because the token never leaves the adapter, the attachment is served like any other upload (signed URLs, CDN, retention), and Telegram's file links (valid for one hour) do not expire under it.
- Bad, because the webhook request does one or two extra HTTP calls per media message, and the tenant's upload allowlist and size limit apply.
Media: file_id plus proxy (2)
- Good, because the request path stays fast.
- Bad, because it is a new authenticated endpoint, the file depends on the bot staying valid, and every read hits Telegram.
Media: Bot API URL (3)
- Bad, because it leaks the bot token. Rejected.
Decision
- Message ids: option 1. Inbound messages use
"<chat_id>:<message_id>"asidempotency_key; outbound deliveries return it asprovider_message_id; replies (reply_to_message), edits and reactions set it asreply_to_provider_id. Edits are keyed"<chat>:<message>:edit:<edit_date>", so a re-delivered edit is a duplicate and a second edit is not; callback queries usecallback:<id>and reactionsupdate:<update_id>. - Participant: the chat (
chat.id) is the participant'sexternal_id, so replies go to the chat the message came from (a private chat, group or channel); the user id (from.id) is the activitysender. - Secret token: option (b). When the channel has a
secret_token, a missing or different header is{:error, _}(401) regardless ofrequire_signature; only a channel without a secret falls back to the generic policy, andrequire_signature: truemakessecret_tokenrequired (required: :with_signature). A configured secret that requests may omit protects nothing. - Media: option 1, best effort. The adapter downloads the largest photo size or the file, stores it with
Uploads.create_attachment/3and setscontentUrlto/api/v1/converger/attachments/<id>; the Telegramfile_idstays inchannelData. A file over the limit, of a type the tenant does not allow, a failed download, or a re-delivered message keeps only thefile_id: the message is never dropped because of its media. - Outbound files: a Converger upload is not reachable by Telegram, so its bytes are uploaded
(
multipart/form-data); other URLs are passed to Telegram to fetch. - Webhook registration: option (b). A new optional callback
register_webhook(channel, inbound_url)(Adapter.register_webhook/2,Channels.register_webhook/3), and the admin channel list shows Register webhook for every adapter that implements it. Successful calls are audited asregister_webhook. - HTTP client: the adapter calls
Reqdirectly, notConverger.HTTP, because the bot token is part of every Bot API URL andOpentelemetryReqrecords the URL (url.full) on the client span. Error reasons use the Bot APIdescription, never the URL. - Bot API base URL: application config only
(
config :converger, Converger.Channels.Adapters.Telegram, api_base_url: ..., for a self-hosted Bot API server), not a channel setting: a tenant-supplied URL would receive the bot token and would need the SSRF guard (ADR-0014).
Consequences
Positive
- The acceptance flow works without changes to the pipeline, the inbound controller or reference resolution.
- No stored activity, delivery error or trace contains the bot token.
- The next adapter with programmatic webhook registration (Slack, Viber, Messenger) gets the admin button by implementing one callback.
Negative and trade-offs
- Inbound media costs one
getFilecall and a download inside the webhook request (up to 20 MB). Telegram waits for the response, but very large files slow down the bot's update stream; the upload size limit caps it. answerCallbackQueryruns as a fire-and-forget task fromparse_inbound/2, which is otherwise pure: a re-delivered callback query is answered twice (harmless; Telegram ignores the second answer).- An activity with several attachments becomes several Telegram messages. If one fails after others were sent, the retry sends all of them again (Telegram has no idempotency key for sends).
- Card buttons follow the hero-card shape (
title,text,buttons[{type, title, value}]) until the rich message vocabulary (#68) defines one.
Follow-ups
- #68: typed buttons and cards; map them to inline keyboards.
- Native outbound edits (
editMessageText) and reactions (setMessageReaction) once the target's provider id is passed todeliver_activity/2. - Albums (
media_group_id) arrive as one activity per photo; grouping them is not planned yet.
Implementation
lib/converger/channels/adapters/telegram.ex: the adapter (config schema,parse_inbound/2,verify_inbound_signature/3,deliver_activity/2,send_typing/2,health_probe/1,register_webhook/2).lib/converger/channels/adapter.ex:register_webhook/2callback and dispatcher;telegramin the built-in registry.lib/converger/channels.ex:register_webhook/3with the audit entry;register_webhookadded to the audit actions.lib/converger_web/live/admin/channel_live.ex: the Register webhook button.- Tests:
test/converger/channels/adapters/telegram_test.exs(fixtures intest/fixtures/telegram/) andtest/converger_web/integration/telegram_channel_test.exs(Telegram to a WebSocket console and back,401on an invalid or missing secret, media download, edits and reactions, the admin button).
Links
- Telegram channel
- Channels and adapters, Writing an adapter
- Telegram Bot API:
Update,setWebhook,getFile,InlineKeyboardMarkup