Skip to main content

Telegram connector

Telegram is the second connector, and that is most of why it exists: the Renderer contract, the route-keyed registry, and the connector-neutral turn pipeline were all built with the claim that a second surface would drop in without copying Slack assumptions into the graph. This connector is that claim being tested. Where the claim held, this document says so briefly; where a seam had to be carved (mention detection, the reply effect, the shared dispatch), the carving is part of this connector’s landing and is documented at the seam.

What the connector is responsible for

  • receive Telegram webhook updates
  • authenticate and normalize inbound payloads
  • map Telegram identities to Mesh actors inside the owning RuntimeAgent’s perspective
  • map Telegram chats (and forum topics) to perspective-scoped Mesh conversations
  • emit normalized inbound events
  • send outbound replies back to Telegram
The not-responsible-for list is identical to Slack’s (slack.md): no inference, no persona, no memory policy, no provisioning UX, no peer-topology leaks across perspectives.

Which agent is this for?

Telegram’s payload carries no application identity: an Update names the chat and the sender, never the bot it was delivered to. So the routing key cannot come from the body the way Slack’s (api_app_id, team_id) does — it comes from the URL. Mesh registers each install’s webhook at POST /telegram/events/{bot_id}, and {bot_id} is the route:
  • agent.Route{ConnectorType: "telegram", AppID: <bot_id>, WorkspaceID: ""}
  • agent_connectors.external_app_id = the bot’s numeric user id (the prefix of the token BotFather issues, confirmed via getMe at save time)
  • external_workspace_id = NULL — Telegram has no workspace; the partial unique index from 0004 (“providers with no workspace concept get exactly one row per app id”) is doing the job it was written for
A bot id is not a secret (any chat member can read it off the bot’s profile), which is the same trust level as Slack’s routing pair arriving in an unverified body. Authentication is separate and per install:

Lookup happens before verification

Telegram signs nothing, but setWebhook accepts a secret_token that Telegram then echoes on every delivery as X-Telegram-Bot-Api-Secret-Token. Mesh GENERATES that secret itself when the connector is saved — the operator never sees or types it — stores it encrypted beside the bot token, and compares it in constant time on every request. So the order is Slack’s order: read the route from the URL, look up the install, compare the header against that install’s secret. A route miss and a secret mismatch both answer the same opaque 401; an unauthenticated caller cannot learn which bot ids this install serves.

Normalization model

Same stable contract as Slack — actor identity, conversation key, parent relationship, direction, body, body format, timestamp — and the same refusal to classify actors as human/agent/service. from.is_bot travels as the per-message AuthorIsBot claim for the engagement gate, exactly like Slack’s bot_id, and is never actor ontology.

Wire contract: Telegram’s payload, not ours

Ingress decodes the real Bot API Update. Only message updates with text are handled today; everything else (edits, channel posts, callback queries, media-only messages) is acked and dropped with a reason. The mapping: ConversationKey is telegram:<namespace>:<thread-id> — same <type>:<namespace>:<id> convention as Slack, built in exactly one place (the connector’s Normalize), with the namespace coming from the stored install (explicit conversation_namespace → agent slug, since there is no workspace id to fall back on). The send path rejects a chat_id carrying the telegram: prefix, the same channel-addressing regression guard the Slack sender carries. ConversationTitle carries chat.title when Telegram sends one (groups and channels do; private chats do not). It arrives free on every update, so unlike Slack there is no reason to leave it empty.

Sending a reply

Outbound is telegram.Outbound, a distinct type from the wire client for the same reason slack.Outbound is: sendMessage is Telegram’s API, Send is Mesh’s contract. The renderer is hardwired, not injected — a connector does not have a wire format, it is one. The reply Telegram receives is neither row: canonical Markdown translated to Telegram HTML by internal/render/telegram at the send boundary, never stored. HTML rather than MarkdownV2 deliberately: MarkdownV2 requires escaping eighteen punctuation characters in prose and shares delimiters with canonical Markdown while meaning different things by them — the exact dialect trap render/slack’s package doc describes. HTML has three escapable characters, unambiguous tag pairs, and no meaning collisions with Markdown punctuation. Replies are addressed to ChannelExternalID with reply_parameters.message_id = ParentExternalID ?: MessageExternalID in group-shaped chats (attribution in a room full of people) and no reply linkage in private chats (a DM thread is already unambiguous). Forum-topic replies also carry message_thread_id. Message length is capped at 4096 characters; chunking follows the same block → line → word → rune policy as the mrkdwn renderer, with <pre> scaffolds closed and reopened across parts.

Lifecycle reactions

telegram.Reactor acknowledges admitted turns on the triggering message via setMessageReaction:
  • Working: 👀
  • Done: the closest whitelisted emoji for what the turn was about — 👾 bug, 🫡 fix, 🤣 joke, 🎉 celebration, 🤷 could not complete, 🤔 needs your input, and so on (connector.ReactionTopic; the full table is topicEmoji in internal/connector/telegram/react.go). 👍 when no topic is known, including a declined reply.
  • Failed: 👎
Bots may only react with Telegram’s fixed ReactionTypeEmoji whitelist, which does not include Slack’s hourglass, X, or many topic emoji (🐛, 🗺️), so the lifecycle semantics and topics are shared but each topic is rendered with its nearest whitelisted emoji. A test pins every topic to the whitelist. If a chat restricts reactions further and refuses a topic emoji, the reactor falls back to 👍 once. Each call sets one reaction, replacing the bot’s previous reaction atomically. The target is ChannelExternalID plus MessageExternalID, never the reply parent, forum topic, or Mesh conversation key. The reactor shares the install’s bot credentials and API root with outbound replies. Reactions are best-effort: chats must permit the chosen emoji, and Telegram may reject reactions on unsupported messages. The runtime logs API failures without failing or suppressing the reply. No extra incoming update subscription is needed.

Self-recognition, not bot classification

Identical contract to Slack, with one twist: Telegram’s self-identity and its mention form are different strings. The numeric from.id equal to the bot’s own id is the self-echo test (defense in depth — Telegram does not deliver a bot its own messages), while a mention is @<bot_username> in plain text. The engagement gate therefore takes mention TOKENS from the connector (addressing.Gate.Mentions) instead of assuming Slack’s <@U…> markup: Slack supplies <@U…>, Telegram supplies @username with a token-boundary check so @daedalus_bot does not match inside @daedalus_bot_test. The username comes from getMe at save time and is stored in agent_connectors.metadata. A message from some OTHER bot is persisted as context and gated normally, never dropped — the perspective principle, unchanged. One Telegram-specific behavior worth knowing when reasoning about engagement: bots default to “privacy mode”, in which Telegram only delivers group messages that mention the bot or reply to it. Mesh does not rely on this (an operator can disable it via BotFather, and DMs are unaffected), but it means a privacy-mode bot in a group sees a pre-filtered stream — the gate still runs, it just rarely observes.

Events we ack and drop

All 200s, because a non-2xx makes Telegram redeliver and “we chose not to act” is a successful delivery:
  • updates that are not a message (edits, channel posts, joins, callbacks)
  • messages with no text (media-only, until those have normalized semantics)
  • messages with no sender (from absent — e.g. anonymous channel posts)
  • a self-authored echo (the bot’s own id — defensive, see above)
  • a duplicate update_id (durable event claim, same store as Slack’s)

Provisioning

The operator flow is deliberately shorter than Slack’s: BotFather issues a token; the operator pastes it. Mesh validates it with getMe (which also yields the bot id and username — the route and the mention token), stores the token and a generated webhook secret encrypted per install, reloads the serving registry, and then registers the webhook itself via setWebhook. Registering the webhook is an external effect performed at save time — the tools.md on_enable shape — because a Telegram connector without a webhook is configured-but-dead, and Telegram offers no manifest-style flow where the operator could do it themselves. A setWebhook failure does not roll back the save: the rows are committed and the response says the webhook registration failed so the operator can retry by re-saving, rather than re-pasting a token Mesh already holds. A re-save REUSES the stored webhook secret rather than rotating it. Rotation would open a brick window: the new secret goes live in Mesh (rows committed, registry reloaded) before setWebhook tells Telegram about it, so a failed or interrupted registration would leave Telegram delivering with the old secret against a Mesh that only accepts the new one — every update a silent 401 on a connector that was working before the re-save. Reuse keeps both sides in agreement no matter where the flow stops; a fresh secret is minted only when none exists (first save) or the stored one no longer decrypts. Both entry points use the same endpoint: the first-boot wizard’s connector step (pick Telegram instead of Slack) and adding Telegram to an existing agent from its detail page. One agent may hold Slack and Telegram installs simultaneously — installs were always a list, and the conversation graph keys connectors per (perspective, type, name), so histories never mix.

First implementation goal

Get one Telegram message into one RuntimeAgent’s perspective, persist it, and send one reply back out — through the same gate, governor, claims, runtime, and turn engine Slack uses, with the connector-specific code confined to the webhook handler, the normalizer, the renderer, and the sender. Then connect one agent to Slack and Telegram at once and prove the histories stay distinct while the agent stays one identity.

Seam ledger: what the second connector had to touch

layers.md sets the review test for a seam’s second implementation: it may touch its own package and one catalog line, and every additional file is a leak to name. Telegram is that second implementation for conversation connectors, so this is the measured spread — the honest bill, recorded here per that document’s rule, to be closed before a third connector pays it again:
  • Own packages (legitimate): internal/connector/telegram, internal/render/telegram, this document.
  • The switch, written once (a fine catalog at N=2): rebuildServing in internal/app/wire.go maps install type → serving construction.
  • Leaks — each a descriptor field the seam does not yet declare:
    • internal/agent: secret-kind constants plus the connectorSecretKinds mapping, the TelegramRoute/TelegramInstalls helpers, and the metadata-carried bot username.
    • internal/app: a per-connector ingress handler and mux route (shape of inbound auth is genuinely per-vendor; the gate/turn dispatch behind it is shared in ingress_dispatch.go — that half was generalized by this change, not leaked into).
    • internal/admin: a hand-written save handler and route, where a descriptor-driven credentials form should render.
    • web/: a wizard branch and an agent-detail card per connector.
    • internal/run: one effect constant per connector’s reply.
The contract half of the seam is now proven from both sides — one outbound interface, one shared engagement/turn dispatcher, two connectors through them. The descriptor and catalog halves are the open work: a connector descriptor (route shape, credential fields with dispositions, mention-token form) would collapse the agent/admin/web leaks the same way model.Provider collapsed the model-selection switches.