Skip to main content

Building a Telegram Bot in Rust with Teloxide

·6 mins
Table of Contents
Rust is an unusual choice for a Telegram bot, but the trade-offs are real: a single statically linked binary, sub-10MB Docker images, and memory usage that stays flat under load. Teloxide is the community-standard Rust Telegram library and it makes the async plumbing invisible.

Most Telegram bot tutorials reach for Python or Node. Rust is genuinely worth considering if you care about deployment simplicity (one binary, no interpreter), memory footprint, or you are already in a Rust codebase. This post walks through a complete bot using teloxide: commands, inline keyboards, a multi-step state machine, and production deployment.

Setup
#

Create a new project and add dependencies to Cargo.toml:

Cargo.toml
[package]
name = "telegram-bot"
version = "0.1.0"
edition = "2021"

[dependencies]
teloxide = { version = "0.12", features = ["macros", "dialogues"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
pretty_env_logger = "0.5"
serde = { version = "1", features = ["derive"] }

[profile.release]
strip = true
opt-level = "z"  # Optimize for binary size
Note

teloxide 0.12 requires Rust 1.70+. The macros feature enables the #[command] derive. The dialogues feature enables the state machine API. Both are needed for this post.

Bot token and environment setup
#

Get a token from @BotFather by sending /newbot. Store it in an environment variable. Never hardcode it.

.env (do not commit this file)
TELOXIDE_TOKEN=123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi

Load it at startup with std::env::var. teloxide also reads TELOXIDE_TOKEN automatically if you use Bot::from_env().

Command handler with derive macro
#

The BotCommands derive generates the command parsing and the /help text from doc comments:

src/main.rs
use teloxide::{prelude::*, utils::command::BotCommands};

#[tokio::main]
async fn main() {
    pretty_env_logger::init();
    log::info!("Starting bot...");

    let bot = Bot::from_env();

    Command::repl(bot, answer).await;
}

#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase", description = "Available commands:")]
enum Command {
    #[command(description = "Show this help message")]
    Help,
    #[command(description = "Greet a user by name")]
    Hello(String),
    #[command(description = "Show current server time")]
    Time,
}

async fn answer(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()> {
    match cmd {
        Command::Help => {
            bot.send_message(msg.chat.id, Command::descriptions().to_string())
                .await?;
        }
        Command::Hello(name) => {
            bot.send_message(msg.chat.id, format!("Hello, {}!", name))
                .await?;
        }
        Command::Time => {
            let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
            bot.send_message(msg.chat.id, format!("Server time: {now}"))
                .await?;
        }
    }
    Ok(())
}

Reply to arbitrary text messages
#

For non-command messages, use a separate handler branch. teloxide dispatchers compose multiple handlers:

src/handlers/text.rs
use teloxide::prelude::*;

pub async fn handle_text(bot: Bot, msg: Message) -> ResponseResult<()> {
    if let Some(text) = msg.text() {
        if !text.starts_with('/') {
            bot.send_message(msg.chat.id, format!("You said: {text}"))
                .await?;
        }
    }
    Ok(())
}

Inline keyboard with callback handlers
#

Inline keyboards attach buttons to a message. Each button carries a callback_data string that arrives as a CallbackQuery update, separate from Message updates:

src/handlers/keyboard.rs
use teloxide::{
    prelude::*,
    types::{InlineKeyboardButton, InlineKeyboardMarkup},
};

pub async fn send_menu(bot: Bot, chat_id: ChatId) -> ResponseResult<()> {
    let keyboard = InlineKeyboardMarkup::new(vec![
        vec![
            InlineKeyboardButton::callback("Option A", "opt_a"),
            InlineKeyboardButton::callback("Option B", "opt_b"),
        ],
        vec![InlineKeyboardButton::callback("Cancel", "cancel")],
    ]);

    bot.send_message(chat_id, "Choose an option:")
        .reply_markup(keyboard)
        .await?;
    Ok(())
}

pub async fn handle_callback(bot: Bot, q: CallbackQuery) -> ResponseResult<()> {
    // Always acknowledge the callback -- this dismisses the loading spinner.
    bot.answer_callback_query(&q.id).await?;

    let data = q.data.as_deref().unwrap_or("");
    let text = match data {
        "opt_a" => "You chose Option A",
        "opt_b" => "You chose Option B",
        "cancel" => "Cancelled",
        _ => "Unknown option",
    };

    if let Some(msg) = q.message {
        bot.edit_message_text(msg.chat().id, msg.id(), text).await?;
    }
    Ok(())
}
Important

Always call bot.answer_callback_query(&q.id) when handling a CallbackQuery. If you do not, the button spinner in Telegram keeps spinning for 30 seconds, which looks like a bug to the user.

State machine with the dialogue feature
#

Multi-step conversations (onboarding, forms) need a state machine. teloxide’s dialogue feature manages state stored in memory (or Redis for production):

src/registration.rs
use teloxide::{
    dispatching::dialogue::{self, InMemStorage},
    prelude::*,
};

type MyDialogue = Dialogue<State, InMemStorage<State>>;
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;

#[derive(Clone, Default)]
pub enum State {
    #[default]
    Start,
    AwaitingName,
    AwaitingAge { name: String },
}

pub async fn start(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, "Welcome! What is your name?")
        .await?;
    dialogue.update(State::AwaitingName).await?;
    Ok(())
}

pub async fn receive_name(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    let name = msg.text().unwrap_or("unknown").to_owned();
    bot.send_message(msg.chat.id, format!("Nice to meet you, {name}! How old are you?"))
        .await?;
    dialogue.update(State::AwaitingAge { name }).await?;
    Ok(())
}

pub async fn receive_age(
    bot: Bot,
    dialogue: MyDialogue,
    msg: Message,
    name: String,
) -> HandlerResult {
    let age: u32 = match msg.text().and_then(|t| t.parse().ok()) {
        Some(a) => a,
        None => {
            bot.send_message(msg.chat.id, "Please send a number.").await?;
            return Ok(());
        }
    };

    bot.send_message(
        msg.chat.id,
        format!("Registered: {name}, age {age}. Welcome!"),
    )
    .await?;
    dialogue.exit().await?;
    Ok(())
}

Error handling
#

teloxide uses LoggingErrorHandler for unhandled errors by default. Wrap your dispatcher explicitly:

src/main.rs (dispatcher setup)
use teloxide::{
    dispatching::dialogue::InMemStorage,
    error_handlers::LoggingErrorHandler,
    prelude::*,
};

Dispatcher::builder(bot, schema())
    .dependencies(dptree::deps![InMemStorage::<State>::new()])
    .error_handler(LoggingErrorHandler::with_custom_text(
        "An error occurred in the dispatcher",
    ))
    .enable_ctrlc_handler()
    .build()
    .dispatch()
    .await;

Deploying as a single binary
#

Build a release binary and package it in a minimal Docker image:

build release binary
cargo build --release
# Binary is at target/release/telegram-bot (~5MB stripped)
Dockerfile
FROM debian:bookworm-slim AS runtime

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

COPY target/release/telegram-bot /usr/local/bin/telegram-bot

ENV RUST_LOG=info

CMD ["telegram-bot"]

For a truly minimal image using musl (fully static binary):

cross-compile to musl
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
Dockerfile (scratch-based)
FROM scratch
COPY target/x86_64-unknown-linux-musl/release/telegram-bot /telegram-bot
ENV RUST_LOG=info
ENTRYPOINT ["/telegram-bot"]

The scratch image ends up around 7MB total.

Long polling is the default. The bot opens a long-lived HTTP connection to Telegram’s servers and receives updates as they arrive. It works through firewalls and NAT, requires no public IP, and is the correct choice for development and low-traffic production.

// Long polling is the default with Bot::from_env() + Command::repl()
// No additional configuration needed.
let bot = Bot::from_env();
Command::repl(bot, answer).await;

Webhooks require a public HTTPS endpoint. Telegram pushes updates to your URL instead of you polling. This eliminates the polling latency and is better at scale, but requires a TLS certificate and a reachable IP.

use teloxide::dispatching::update_listeners::webhooks;

let addr = ([0, 0, 0, 0], 8443).into();
let url = "https://your-domain.com/webhook".parse().unwrap();

let listener = webhooks::axum(bot.clone(), webhooks::Options::new(addr, url))
    .await
    .expect("webhook setup failed");

Dispatcher::builder(bot, schema())
    .build()
    .dispatch_with_listener(listener, LoggingErrorHandler::new())
    .await;
**Blocking in async handlers.** `teloxide` handlers run on a Tokio runtime. Calling blocking operations (file I/O, `std::thread::sleep`, synchronous HTTP) inside a handler will starve the runtime. Use `tokio::time::sleep`, `tokio::fs`, or `tokio::task::spawn_blocking` for CPU-bound work. **Not handling callback queries separately from messages.** `CallbackQuery` updates arrive on a different update type than `Message`. If your dispatcher only branches on messages, callback query taps do nothing and the button spinner never clears. Register a separate handler branch for `Update::filter_callback_query()`. **Using long polling in production at scale.** Long polling is fine for moderate traffic, but it adds latency because there is always one request in flight and Telegram batches updates. Webhooks deliver updates immediately. The cut-off is roughly 100-200 updates per second -- below that, long polling is simpler and adequate. **Keeping state in process memory for multi-instance deployments.** `InMemStorage` is per-process. If you run two replicas, a user in a dialogue on replica A will lose state if routed to replica B. Use `RedisStorage` from the `teloxide-redis-storage` crate for production multi-instance deployments. **Not calling answer_callback_query.** Every `CallbackQuery` must be acknowledged with `bot.answer_callback_query(&q.id).await?` or Telegram shows a loading spinner to the user for 30 seconds.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.