Telegram interactivity

Add inline buttons, handle taps, edit and react to messages, send media and voice notes, and message other bots over Telegram.

Turn a one-way Telegram bot into an interactive one. Your agent can put buttons under a message, receive the tap back as a normal inbound message, edit what it already sent, react with an emoji, send media and voice notes, and message another bot. Every one of these rides the same send call and MCP tools you already use for plain text.

This builds on connecting a Telegram bot. If your inbox is active, you're ready.

Sending media (photos, PDFs, files)

Attach files to a Telegram send exactly as you would on email — the same attachments field. Each attachment is an object: inline {filename, contentBase64} or a storage reference {objectId} of a Dairo object you own. Dairo routes each to its native Telegram type by content type:

  • an image (image/*) → sendPhoto (rendered inline),
  • a video (video/*) → sendVideo (native inline player),
  • OGG/Opus audio (audio/ogg, audio/opus) → sendVoice (a real voice-note bubble — perfect for an agent's spoken reply),
  • other audio (audio/*) → sendAudio (the music player),
  • anything else (PDF, doc, …) → sendDocument (a file card),
  • 2+ files → a sendMediaGroup album (mixed sets fall back to separate native sends),
  • the text/html body becomes the caption (at most 1,024 characters — a longer one is a 400, so send long text as its own message); a send with an attachment needs no body, so a caption-less voice note or photo is valid.
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_9f2b...",
    "to": ["telegram:5031234567"],
    "text": "Here's the July invoice 📄",
    "attachments": [ { "objectId": "obj_a1b2..." } ]
  }
}

Native up to 50 MB, link over that. Dairo fetches a storage {objectId} and uploads it to Telegram natively — up to Telegram's 50 MB bot-upload cap — so it bypasses the inline-JSON size limit entirely (upload a 20 MB video to a bucket, reference it by objectId, and it lands in the chat). Over 50 MB, a send returns a 413: create a Dairo share link for the file and include the link in your message text instead (Dairo won't auto-add it) — the same pattern as email.

A single photo/document can also carry buttons; an album (2+ files) cannot (Telegram albums have no keyboard), so buttons + multiple attachments is a 400.

Receiving media

When someone sends the bot a photo, document, video, audio, or voice message, Dairo downloads the file and lands it as a first-class attachment on the inbound mailbox message — the same shape as an inbound email attachment (its own storage object, with a messageId join key). The message's caption becomes the text body. A media-only message (no caption) still arrives, carrying only the attachment. Your normal read loop sees it; no special casing.

Native message types (location, poll, dice, …)

Beyond text + media, a Telegram send can carry a telegram object for the native message types — exactly one per send:

telegram.<field>Sends
location: {latitude, longitude, livePeriod?}a map point (sendLocation)
venue: {latitude, longitude, title, address}a venue (sendVenue)
contact: {phoneNumber, firstName, lastName?}a phone contact (sendContact)
poll: {question, options[], isAnonymous?, type?, allowsMultipleAnswers?, correctOptionId?}a poll or quiz — type:"quiz" + correctOptionId makes it a quiz (sendPoll)
dice: {emoji?}an animated 🎲 🎯 🏀 ⚽ 🎳 🎰 (sendDice)
sticker: "<file_id or https URL>"a sticker (sendSticker)
videoNote: "<file_id or https URL>"a round video note (sendVideoNote)

Files route by content type too: an image/gif attachment becomes an animation (sendAnimation), an image/webp becomes a sticker.

MCP — send_message (a poll)
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_9f2b...",
    "to": ["telegram:5031234567"],
    "telegram": { "poll": { "question": "Ship it?", "options": ["Yes", "Absolutely"] } }
  }
}

Voice messages from text

Your agent can send a real Telegram voice note from pure text — no audio file. Dairo synthesizes the speech (Dairo TTS) and delivers it as a native voice bubble:

MCP — send_message (a voice note)
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_9f2b...",
    "to": ["telegram:5031234567"],
    "telegram": { "voice": { "text": "Deploy #42 is live.", "voiceId": "adrian" } }
  }
}
  • text — required, up to 4000 characters (~4 minutes of speech).
  • voiceId — optional: a catalog slug (browse them with GET /v1/telegram/voices), a raw Dairo TTS voice id, or omit it for the default voice (adrian, a clean narrator).

Voice notes are delivered in the background: the send returns status: "queued" immediately, then Dairo synthesizes and delivers the note, so long text never times out the request. Read the final sent/failed outcome with GET /v1/messages/{id}.

Browsing voices

Dairo ships a catalog of thousands of voices — celebrity and character voices alongside neutral professionals. Each carries a name, description, best language, tags, and a featured flag. Browse it:

cURL — browse voices
curl "https://api.dairo.app/v1/telegram/voices?featured=true&limit=20" \
  -H "Authorization: Bearer $DAIRO_API_KEY"
# also: ?q=<search> for free-text search, ?language=de to filter by best language
# limit defaults to 50 (max 100); offset drives paging

Each row returns { slug, name, description, bestLanguage, languages, gender, tags, featured, popularity }, ordered by popularity. Pass a row's slug as the voiceId on a voice send; celebrity and character voices carry featured: true.

Built-in slash commands

Every connected Telegram bot registers a small set of built-in commands that Dairo answers itself — no agent code needed. They autocomplete in the Telegram / menu:

CommandWhat it does
/startA warm, personal welcome, written fresh by Dairo for the chat that verified the bot. Every other chat gets the default welcome.
/statusA link to the Dairo system status page.
/usageThis bot's sends over the last 24h (never the rest of your account). Answers only the chat that verified the bot.
/whoamiThis inbox's identity (bot handle + inbox id). Answers only the chat that verified the bot.
/helpThe command list.

These are read-only and handled entirely by Dairo. Every other command (your own /deploy, /summary, …) and every normal message flows to your agent as a normal inbound — so you can implement your own commands by branching on the inbound text. New bots get the built-ins registered automatically on connect.

Inline buttons on a send

Add a buttons field to any Telegram send. It is a 2-D array — an array of rows, each row an array of buttons. Each button is exactly one of:

  • url — a link button. Tapping it opens the URL (http/https/tg only).
  • callback — an action button. Tapping it surfaces the tap back to your agent (see Handling a tap). The callback string is your own opaque value — Dairo stores it and hands it back on the tap.
{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_9f2b...",
    "to": ["telegram:5031234567"],
    "text": "Deploy #42 is ready to ship to production.",
    "buttons": [
      [
        { "text": "Approve", "callback": "approve:deploy-42" },
        { "text": "Reject",  "callback": "reject:deploy-42" }
      ],
      [
        { "text": "View diff", "url": "https://dairo.app/deploys/42" }
      ]
    ]
  }
}

The row layout you send is the layout Telegram renders — two buttons side by side, then a full-width link button below.

Buttons work on a text or an html (rich) body. The grid is bounded:

LimitValue
Rowsup to 8
Buttons per rowup to 8
Button text1–64 characters
callback valueup to 200 characters (stored by Dairo, never on the wire)

Your callback string never travels on the Telegram wire — Telegram hard-caps callback_data at 64 bytes, so Dairo sends a compact reference and keeps your full callback value (and the button label) on the outbound message. The tap round-trips your real payload.

Tap feedback

By default, tapping a callback button flashes a small confirmation toast — a check mark plus the button's label (e.g. ✓ Approve) — so a tap is never silent. Override it per button:

  • feedback — the toast text to show on tap (up to 200 characters).
  • alert — set true to show a modal the user must dismiss, instead of a toast.
Buttons with feedback
{
  "buttons": [[
    { "text": "Approve", "callback": "approve:42", "feedback": "✅ Deploy approved — rolling out" },
    { "text": "Reject",  "callback": "reject:42",  "feedback": "Rejected. Nothing shipped.", "alert": true }
  ]]
}

The toast/alert is answered inside Telegram's brief callback window, so it is decided at send time, per button. For a richer, stateful response — checking off the choice, swapping the keyboard, rewriting the text — edit the message when you receive the tap.

Handling a button tap

When someone taps a callback button, Dairo does three things:

  1. Answers the tap immediately, so the tapper's client stops its loading spinner.
  2. Persists an inbound message into your mailbox and fires the message.received webhook — your agent's normal read loop sees it, no special casing. Its subject is Button tapped: <label> and its textBody is your original callback value.
  3. Records a message.button.tapped event in the event stream.

The inbound message carries the tap details in channelMetadata:

{
  "object": "message",
  "channel": "telegram",
  "direction": "inbound",
  "subject": "Button tapped: Approve",
  "textBody": "approve:deploy-42",
  "channelMetadata": {
    "chatId": 123456789,
    "kind": "callbackQuery",
    "buttonCallback": "approve:deploy-42",
    "buttonLabel": "Approve",
    "sourceMessageId": "msg_the_send_that_carried_the_button",
    "sourceTelegramMessageId": 555,
    "callbackQueryId": "cbq_...",
    "fromUsername": "alice",
    "fromIsBot": false,
    "updateId": 77
  }
}

The message.button.tapped event in the stream carries the same facts in its data:

{
  "type": "message.button.tapped",
  "data": {
    "messageId": "msg_the_inbound_tap",
    "sourceMessageId": "msg_the_send_that_carried_the_button",
    "inboxId": "inbox_9f2b...",
    "channel": "telegram",
    "direction": "inbound",
    "kind": "button_tap",
    "buttonCallback": "approve:deploy-42",
    "buttonLabel": "Approve",
    "callbackQueryId": "cbq_...",
    "chatId": 123456789,
    "sourceTelegramMessageId": 555,
    "fromIsBot": false
  }
}

Use sourceMessageId to tie the tap back to the exact send it belongs to, and buttonCallback to branch on which action the user chose. A source message can be tapped many times — each tap is its own inbound row (keyed on the callback query id), so a count of taps is a count of rows.

fromIsBot flags a tap that came from another bot — a loop guard for bot-to-bot flows. A url button never produces a tap event — it only opens its link.

Editing a message

Edit a message you already sent with manage_sent_messages { action: "edit" } (REST: POST /v1/messages/{id}/edit). Pass text or html to replace the body and/or a buttons grid to change the keyboard; supplying both text and html is a 422. This is the canonical "acknowledge, then finish" pattern — send with buttons, then edit the text to a result.

{
  "name": "manage_sent_messages",
  "arguments": {
    "action": "edit",
    "messageId": "msg_9f2b...",
    "text": "Deploy #42 shipped ✅ — approved by @alice."
  }
}

How buttons behaves on an edit:

You passResult
omit buttonsThe existing keyboard is preserved (Dairo re-sends it for you).
buttons: [[…]]The keyboard is replaced with the new grid.
buttons: []The keyboard is cleared.

Omitting buttons on a body edit preserves the keyboard even on Telegram Desktop, where a raw editMessageText without a keyboard would otherwise drop the buttons. To retire the buttons once an action is taken, pass buttons: [] explicitly.

Only Telegram messages you sent are editable. A non-Telegram id, an inbound id, or another tenant's id returns 404 (existence is never leaked).

Reacting to a message

Set the bot's emoji reaction on a message you sent or received with manage_sent_messages { action: "react" } (REST: POST /v1/messages/{id}/react). Clear it with action: "unreact".

{
  "name": "manage_sent_messages",
  "arguments": { "action": "react", "messageId": "msg_9f2b...", "emoji": "👍", "big": true }
}

emoji must be one of Telegram's allowed reaction emoji (👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🎉 🙏 …) — a normal bot cannot use a custom or premium emoji, and one that isn't on the list returns 400. big (optional) plays the big-reaction animation. Reacting to an inbound message is the lightweight way to acknowledge it without sending a reply.

Bot-to-bot & by-username messaging

A send normally goes to the chat that connected the bot. To message a specific bot, channel, or user by username instead, put an @username (or telegram:@username) in to:

{
  "name": "send_message",
  "arguments": {
    "inboxId": "inbox_9f2b...",
    "to": ["@partner_service_bot"],
    "text": "handoff: ticket-8842 needs your triage.",
    "buttons": [[ { "text": "Ack", "callback": "ack:ticket-8842" } ]]
  }
}

Because a by-username send needs no bound chat, it works even before the inbox is verified — handy for a bot that only ever talks to other bots or posts to a channel.

For bot-to-bot messaging, both bots must enable Bot-to-Bot Communication Mode in @BotFather; for a channel or user the bot must be permitted to post there. If Telegram rejects the send, Dairo surfaces a 422 with that exact guidance rather than a generic error.

Put it together: an approval loop

  1. Send a message with Approve / Reject callback buttons.
  2. Wait for the message.received webhook (or poll inbound messages).
  3. Branch on buttonCallback, matching sourceMessageId to your send.
  4. Edit the original message to the outcome and pass buttons: [] to retire the buttons — so the same message can't be actioned twice.
  5. Optionally react 👍 on the tap to acknowledge it.

Every step is one call on the surface you already use — no Telegram SDK, no webhook plumbing of your own.

Next steps

On this page