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:
[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 sizeteloxide 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.
TELOXIDE_TOKEN=123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghiLoad 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:
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:
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:
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(())
}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):
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:
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:
cargo build --release
# Binary is at target/release/telegram-bot (~5MB stripped)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):
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-muslFROM 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;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.