rust-tg-bot
A complete, production-ready Telegram Bot API framework for Rust. Inspired by the architecture of python-telegram-bot, this library brings the same developer-friendly patterns to the Rust ecosystem while delivering the performance, safety, and concurrency guarantees that Rust provides.
Why rust-tg-bot?
- Familiar architecture. If you have used python-telegram-bot, you will feel right at home. The same concepts –
Application,Handler,Filter,Context– are present, adapted to Rust idioms. - Type safety. Every Telegram API type is a strongly typed Rust struct. No more guessing whether a field is a string or an integer.
- Async from the ground up. Built on tokio and reqwest, every operation is non-blocking.
- Zero-cost abstractions. Handler dispatch, filter composition, and builder patterns compile down to minimal overhead.
- Feature-gated modules. Only pull in what you need:
webhooks,job-queue,persistence,rate-limiter.
Architecture Overview
The framework is split into four crates:
| Crate | Purpose |
|---|---|
rust-tg-bot-raw | Low-level Bot API types, HTTP methods, request builders |
rust-tg-bot-ext | High-level Application, handlers, filters, context, persistence |
rust-tg-bot-macros | Proc macros (#[derive(BotCommands)]) |
rust-tg-bot | Facade crate that re-exports all three for convenience |
You will almost always depend only on rust-tg-bot in your Cargo.toml.
What You Will Learn
This documentation takes you from zero to a production deployment:
- Getting Started – Install the library, write your first bot, and run it.
- Core Concepts – Understand the building blocks:
Update,Bot, handlers, filters, context, and theApplicationlifecycle. - Guides – Build real features: command handling, inline keyboards, multi-step conversations, scheduled jobs, data persistence, webhooks, inline mode, and payments.
- Advanced – Write custom filters, handle errors gracefully, nest conversations, test your bot, and deploy to production.
- Migration – A side-by-side reference for developers moving from python-telegram-bot to Rust.
Quick Taste
Here is the smallest possible echo bot:
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult,
MessageHandler, Update, COMMAND, TEXT,
};
async fn echo(update: Arc<Update>, context: Context) -> HandlerResult {
let text = update
.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("");
if !text.is_empty() {
context.reply_text(&update, text).await?;
}
Ok(())
}
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.expect("TELEGRAM_BOT_TOKEN must be set");
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(
MessageHandler::new(TEXT() & !COMMAND(), echo), 0,
).await;
app.run_polling().await.unwrap();
}
Ready to begin? Head to Installation.
Installation
Prerequisites
Before you begin, make sure you have:
- Rust 1.75 or later. Check with
rustc --version. Install or update via rustup. - A Telegram Bot Token. Create a bot through @BotFather on Telegram. You will receive a token that looks like
123456789:ABCdefGHIjklMNOpqrsTUVwxyz.
Adding the Dependency
Add rust-tg-bot to your project:
cargo add rust-tg-bot
Or add it manually to your Cargo.toml:
[dependencies]
rust-tg-bot = "1.0.0-rc.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
You need tokio with the rt-multi-thread and macros features because the framework is fully async.
Feature Flags
The crate uses feature flags to keep the default build lean. Enable only what you need:
| Feature | Description | Default |
|---|---|---|
webhooks | Webhook-based update delivery (requires axum) | No |
job-queue | Scheduled and recurring tasks via JobQueue | No |
persistence | Base persistence trait | No |
persistence-json | JSON file persistence backend | No |
persistence-sqlite | SQLite persistence backend | No |
rate-limiter | Built-in rate limiting for API calls | No |
full | Enables all features | No |
Enable features in Cargo.toml:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["job-queue", "persistence-json"] }
Or enable everything:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["full"] }
Verifying the Installation
Create a minimal project to confirm everything works:
cargo new my-rust-tg-bot
cd my-rust-tg-bot
cargo add rust-tg-bot
cargo add tokio --features rt-multi-thread,macros
Replace src/main.rs with:
use rust_tg_bot::ext::prelude::{ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult, Update};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
context.reply_text(&update, "Hello from Rust!").await?;
Ok(())
}
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.expect("TELEGRAM_BOT_TOKEN must be set");
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
println!("Bot is running. Press Ctrl+C to stop.");
app.run_polling().await.unwrap();
}
Run it:
TELEGRAM_BOT_TOKEN="your-token-here" cargo run
Send /start to your bot on Telegram. If you see “Hello from Rust!”, you are ready. Continue to Your First Bot for a walkthrough.
Optional: Logging
The framework uses the tracing crate for structured logging. To see log output, add tracing-subscriber:
cargo add tracing-subscriber
Then initialize it at the top of main():
#![allow(unused)]
fn main() {
tracing_subscriber::fmt::init();
}
This gives you detailed output about handler dispatch, API calls, and errors – invaluable during development.
Your First Bot
This guide walks through building a complete echo bot step by step. By the end, you will understand the fundamental pattern used by every bot built with this framework.
The Echo Bot
An echo bot does one thing: it repeats back whatever text the user sends. Despite its simplicity, it demonstrates three core concepts:
- Command handlers – respond to
/startand/help. - Message handlers with filters – catch text messages that are not commands.
- The Application lifecycle – build, register handlers, and run.
Full Source
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult,
MessageHandler, Update, COMMAND, TEXT,
};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
let name = update
.effective_user()
.map(|u| u.first_name.as_str())
.unwrap_or("there");
context
.reply_text(
&update,
&format!(
"Hi {name}! I am an echo bot. Send me any message and \
I will repeat it back to you.\n\nUse /help to see \
available commands."
),
)
.await?;
Ok(())
}
async fn help(update: Arc<Update>, context: Context) -> HandlerResult {
context
.reply_text(
&update,
"Available commands:\n\
/start - Start the bot\n\
/help - Show this help message\n\n\
Send any text message and I will echo it back!",
)
.await?;
Ok(())
}
async fn echo(update: Arc<Update>, context: Context) -> HandlerResult {
let text = update
.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("");
if !text.is_empty() {
context.reply_text(&update, text).await?;
}
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.expect("TELEGRAM_BOT_TOKEN environment variable must be set");
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(CommandHandler::new("help", help), 0).await;
app.add_handler(
MessageHandler::new(TEXT() & !COMMAND(), echo), 0,
).await;
println!("Echo bot is running. Press Ctrl+C to stop.");
if let Err(e) = app.run_polling().await {
eprintln!("Error running bot: {e}");
}
}
Line-by-Line Walkthrough
Imports
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult,
MessageHandler, Update, COMMAND, TEXT,
};
}
The prelude module re-exports everything you need for common bot development. Always import specific items – avoid wildcard imports for clarity and faster compilation.
Handler Signature
Every handler function follows this signature:
#![allow(unused)]
fn main() {
async fn handler_name(update: Arc<Update>, context: Context) -> HandlerResult {
// ...
Ok(())
}
}
update: Arc<Update>– the incoming Telegram update, wrapped inArcfor cheap cloning across async tasks.context: Context– provides access to the bot instance, user/chat data, job queue, and convenience methods likereply_text.HandlerResult– an alias forResult<(), HandlerError>.
Extracting Data from Updates
#![allow(unused)]
fn main() {
let name = update
.effective_user()
.map(|u| u.first_name.as_str())
.unwrap_or("there");
}
The Update type provides typed accessor methods:
effective_user()– the user who triggered the update.effective_chat()– the chat the update originated from.effective_message()– the message associated with the update.callback_query()– the callback query, if any.
These return Option types, so you use standard Rust patterns to handle the None case.
Replying to Messages
#![allow(unused)]
fn main() {
context.reply_text(&update, "Hello!").await?;
}
context.reply_text() is a convenience method that:
- Extracts the chat ID from the update.
- Calls
bot.send_message(chat_id, text). - Returns a
Resultyou can propagate with?.
For more control, use the bot directly:
#![allow(unused)]
fn main() {
context.bot().send_message(chat_id, "Hello!")
.parse_mode(ParseMode::Html)
.await?;
}
Building the Application
#![allow(unused)]
fn main() {
let app = ApplicationBuilder::new().token(token).build();
}
ApplicationBuilder uses a typestate pattern: you cannot call .build() until you have called .token(). This is enforced at compile time.
Registering Handlers
#![allow(unused)]
fn main() {
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(MessageHandler::new(TEXT() & !COMMAND(), echo), 0).await;
}
CommandHandler::new("start", start)– matches/startand calls thestartfunction.MessageHandler::new(TEXT() & !COMMAND(), echo)– matches text messages that are NOT commands, then callsecho.- The second argument (
0) is the handler group. Groups are processed in ascending order. Within a group, the first matching handler wins.
Running
#![allow(unused)]
fn main() {
app.run_polling().await.unwrap();
}
run_polling() starts the bot in long-polling mode: it repeatedly calls Telegram’s getUpdates endpoint and dispatches incoming updates to your handlers.
Next Steps
You now understand the fundamental pattern. Every bot you build follows the same structure:
- Define async handler functions.
- Build an
ApplicationwithApplicationBuilder. - Register handlers.
- Call
run_polling()orrun_webhook().
Continue to Running Your Bot to learn about environment configuration and the different ways to receive updates.
Running Your Bot
There are two ways your bot can receive updates from Telegram: long polling and webhooks. This page covers both, along with environment configuration and graceful shutdown.
Environment Variables
The framework reads the bot token from your code, not from environment variables directly. However, the convention across all examples is:
export TELEGRAM_BOT_TOKEN="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
Then in your code:
#![allow(unused)]
fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.expect("TELEGRAM_BOT_TOKEN must be set");
}
Never hard-code your token. Use environment variables, a .env file, or a secrets manager.
Long Polling
Long polling is the simplest approach and the right choice for development and most deployments.
#![allow(unused)]
fn main() {
let app = ApplicationBuilder::new().token(token).build();
// ... register handlers ...
app.run_polling().await.unwrap();
}
run_polling() handles the full lifecycle internally:
- Calls
initialize()to set up the application. - Calls
start()to begin processing. - Enters a loop calling
getUpdateson the Telegram API. - Dispatches each update to your registered handlers.
- On
Ctrl+C, callsstop()andshutdown().
How Long Polling Works
Your bot sends a request to Telegram saying “give me any new updates.” Telegram holds the connection open (up to 30 seconds by default) until either a new update arrives or the timeout expires. This is efficient – there is no busy-waiting.
Webhooks
For production deployments behind a reverse proxy, webhooks are more efficient. Telegram pushes updates to your server instead of your server pulling them.
Enable the webhooks feature:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["webhooks"] }
Simple Webhook
use rust_tg_bot::ext::prelude::{ApplicationBuilder, Arc, Context, HandlerResult, Update};
// ... define handlers ...
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let webhook_url = std::env::var("WEBHOOK_URL").unwrap();
let app = ApplicationBuilder::new().token(token).build();
// ... register handlers ...
app.run_webhook(&webhook_url, "0.0.0.0:8443").await.unwrap();
}
Custom Webhook with axum
For full control, use the manual lifecycle and run your own axum server alongside the bot. This lets you add health checks, custom endpoints, and other routes. See the Webhooks guide for a complete example.
The Application Lifecycle
Whether you use polling or webhooks, the application goes through these stages:
initialize --> start --> idle --> stop --> shutdown
| Stage | What Happens |
|---|---|
initialize() | Calls getMe to validate the token, loads persistence data, runs post_init hook |
start() | Begins the update processing loop |
| idle | The bot is running and handling updates |
stop() | Signals the processing loop to stop, runs post_stop hook |
shutdown() | Flushes persistence, cleans up resources, runs post_shutdown hook |
When you call run_polling() or run_webhook(), all of these stages are managed automatically. You only need to call them manually if you are building a custom server setup.
Lifecycle Hooks
You can register hooks that run at specific lifecycle stages:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{ApplicationBuilder, Arc};
let post_init = Arc::new(|app: Arc<_>| Box::pin(async move {
println!("Bot initialized! Username: {:?}",
app.bot().bot_data().and_then(|d| d.username.clone()));
}) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>);
let app = ApplicationBuilder::new()
.token(token)
.post_init(post_init)
.build();
}
Available hooks:
post_init– runs afterinitialize().post_stop– runs afterstop().post_shutdown– runs aftershutdown().
Concurrent Update Processing
By default, updates are processed one at a time. For bots with high traffic, enable concurrent processing:
#![allow(unused)]
fn main() {
let app = ApplicationBuilder::new()
.token(token)
.concurrent_updates(8)
.build();
}
This allows up to 8 updates to be processed simultaneously. The value 0 is treated as 1.
Logging
Enable structured logging for debugging:
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
// ... rest of your bot ...
}
Control verbosity with the RUST_LOG environment variable:
RUST_LOG=info TELEGRAM_BOT_TOKEN="..." cargo run
RUST_LOG=rust_tg_bot=debug cargo run
RUST_LOG=trace cargo run
Next Steps
Now that your bot is running, learn about the building blocks:
- Update – what an incoming Telegram update looks like.
- Handlers – the dispatch system.
- Filters – how to route updates to the right handler.
Update
Every interaction with your bot – a message, a button press, an inline query, a payment – arrives as an Update. Understanding the Update type is fundamental to working with this framework.
The Update Struct
An Update is a strongly typed Rust struct that mirrors the Telegram Bot API Update object. It contains an update_id and exactly one of many optional fields indicating what type of event occurred.
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::Update;
}
Accessing Update Fields
The Update type provides convenience methods to avoid deeply nested Option chains:
effective_message()
Returns the Message regardless of whether the update is a regular message, edited message, channel post, or edited channel post:
#![allow(unused)]
fn main() {
async fn handler(update: Arc<Update>, context: Context) -> HandlerResult {
if let Some(msg) = update.effective_message() {
let chat_id = msg.chat.id;
let text = msg.text.as_deref().unwrap_or("");
// ...
}
Ok(())
}
}
effective_user()
Returns the User who triggered the update, regardless of the update type:
#![allow(unused)]
fn main() {
let user_name = update
.effective_user()
.map(|u| u.first_name.as_str())
.unwrap_or("Unknown");
}
effective_chat()
Returns the Chat the update originated from:
#![allow(unused)]
fn main() {
let chat_id = update
.effective_chat()
.map(|c| c.id)
.expect("update must have a chat");
}
Type-Specific Accessors
For updates that are not messages, use the specific accessors:
#![allow(unused)]
fn main() {
// Callback query (inline keyboard button press)
if let Some(cq) = update.callback_query() {
let data = cq.data.as_deref().unwrap_or("");
// ...
}
// Inline query
if let Some(iq) = update.inline_query() {
let query_text = &iq.query;
// ...
}
// Shipping query (payment flow)
if let Some(sq) = update.shipping_query() {
// ...
}
// Pre-checkout query (payment flow)
if let Some(pcq) = update.pre_checkout_query() {
// ...
}
}
Update Types
The Telegram Bot API defines many update types. Here are the most common:
| Update Type | Accessor | When It Fires |
|---|---|---|
| Message | effective_message() | User sends a text, photo, sticker, etc. |
| Edited Message | effective_message() | User edits an existing message |
| Channel Post | effective_message() | New post in a channel the bot is in |
| Callback Query | callback_query() | User presses an inline keyboard button |
| Inline Query | inline_query() | User types @yourbot query in any chat |
| Chosen Inline Result | chosen_inline_result() | User selects an inline query result |
| Shipping Query | shipping_query() | Payment: user selected a shipping address |
| Pre-Checkout Query | pre_checkout_query() | Payment: final confirmation before charging |
| Poll | poll() | Poll state changes |
| Poll Answer | poll_answer() | User votes in a poll |
| Chat Member | my_chat_member() / chat_member() | Bot or user’s membership status changes |
| Chat Join Request | chat_join_request() | User requests to join a chat |
The Arc Wrapper
Handlers receive Arc<Update> rather than Update directly:
#![allow(unused)]
fn main() {
async fn my_handler(update: Arc<Update>, context: Context) -> HandlerResult {
// ...
}
}
The Arc (atomic reference count) allows the update to be shared across multiple handler groups and async tasks without copying. You access it exactly like a regular reference – all the methods above work through Arc’s Deref implementation.
Message Fields
When you have a Message (from effective_message()), commonly used fields include:
#![allow(unused)]
fn main() {
if let Some(msg) = update.effective_message() {
// Sender info
let chat_id: i64 = msg.chat.id;
let from: Option<&User> = msg.from.as_ref();
// Content
let text: Option<&str> = msg.text.as_deref();
let entities: Option<&Vec<MessageEntity>> = msg.entities.as_ref();
// Media
let photo: Option<&Vec<PhotoSize>> = msg.photo.as_ref();
let document: Option<&Document> = msg.document.as_ref();
// Special messages
let successful_payment = msg.successful_payment.as_ref();
let new_chat_members = msg.new_chat_members.as_ref();
}
}
Next Steps
Now that you understand Update, learn how to interact with the Telegram API through the Bot object.
Bot
The Bot object is your interface to the Telegram Bot API. It wraps HTTP calls behind typed builder methods, so you never construct raw JSON payloads.
Accessing the Bot
Inside a handler, get the bot through the context:
#![allow(unused)]
fn main() {
async fn my_handler(update: Arc<Update>, context: Context) -> HandlerResult {
let bot = context.bot();
// Use bot to call Telegram API methods
Ok(())
}
}
context.bot() returns an Arc<ExtBot>, which wraps the raw Bot with framework extensions (defaults, callback data cache, rate limiting).
Sending Messages
Basic Text
#![allow(unused)]
fn main() {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
context.bot()
.send_message(chat_id, "Hello, world!")
.await?;
}
Formatted Text
Use ParseMode constants – never raw strings:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::ParseMode;
context.bot()
.send_message(chat_id, "<b>Bold</b> and <i>italic</i>")
.parse_mode(ParseMode::Html)
.await?;
}
Available parse modes:
ParseMode::HtmlParseMode::MarkdownParseMode::MarkdownV2
With Reply Markup
Attach keyboards or inline keyboards to messages:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{InlineKeyboardButton, InlineKeyboardMarkup};
let keyboard = InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("Yes", "answer_yes"),
InlineKeyboardButton::callback("No", "answer_no"),
],
]);
context.bot()
.send_message(chat_id, "Do you agree?")
.reply_markup(serde_json::to_value(keyboard).unwrap())
.await?;
}
Editing Messages
#![allow(unused)]
fn main() {
context.bot()
.edit_message_text("Updated text")
.chat_id(chat_id)
.message_id(message_id)
.await?;
}
Answering Callback Queries
When a user presses an inline keyboard button, answer the callback query to dismiss the loading indicator:
#![allow(unused)]
fn main() {
let cq = update.callback_query().unwrap();
context.bot()
.answer_callback_query(&cq.id)
.await?;
}
Optionally show a notification:
#![allow(unused)]
fn main() {
context.bot()
.answer_callback_query(&cq.id)
.text("Selection recorded!")
.show_alert(true)
.await?;
}
The Convenience Method: reply_text
For the common case of replying to whatever chat sent the update:
#![allow(unused)]
fn main() {
context.reply_text(&update, "Thanks for your message!").await?;
}
This extracts the chat ID from the update and calls send_message for you.
Other API Methods
The bot exposes builder methods for every Telegram Bot API method. Here are some common ones:
#![allow(unused)]
fn main() {
// Send a photo
context.bot()
.send_photo(chat_id, "https://example.com/photo.jpg")
.caption("A nice photo")
.await?;
// Send an invoice
context.bot()
.send_invoice(chat_id, title, description, payload, currency, prices)
.await?;
// Get chat member info
let member = context.bot()
.get_chat_member(ChatId::Id(chat_id), user_id)
.await?;
// Set webhook
context.bot()
.set_webhook("https://example.com/webhook")
.await?;
// Answer inline query
context.bot()
.answer_inline_query(&query_id, results)
.await?;
}
Bot Data
After the application initializes (calls getMe), you can access the bot’s own information:
#![allow(unused)]
fn main() {
if let Some(bot_data) = context.bot().bot_data() {
let username = bot_data.username.as_deref().unwrap_or("unknown");
let bot_id = bot_data.id;
}
}
Builder Pattern
All API method calls follow a consistent builder pattern:
#![allow(unused)]
fn main() {
context.bot()
.method_name(required_args) // Start the builder
.optional_param(value) // Chain optional parameters
.another_optional(value) // Chain more
.await?; // Await the future (IntoFuture)
}
This pattern gives you IDE autocompletion for every parameter and catches typos at compile time.
Next Steps
Now learn how updates are routed to your handler functions in Handlers.
Handlers
Handlers are the core dispatch mechanism. They determine which function runs in response to which update.
Handler Types
The framework provides several built-in handler types:
| Handler | Use Case |
|---|---|
CommandHandler | Responds to /command messages |
MessageHandler | Responds to messages matching a filter |
FnHandler | Custom predicate-based handler for any update type |
CallbackQueryHandler | Responds to inline keyboard button presses |
CommandHandler
The simplest and most common handler. Matches messages that start with a specific command:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{CommandHandler, Arc, Context, HandlerResult, Update};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
context.reply_text(&update, "Welcome!").await?;
Ok(())
}
// Register it:
app.add_handler(CommandHandler::new("start", start), 0).await;
}
CommandHandler::new("start", start) matches /start (with or without @botusername suffix).
MessageHandler
Matches messages that pass a filter:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{MessageHandler, TEXT, COMMAND};
async fn echo(update: Arc<Update>, context: Context) -> HandlerResult {
let text = update.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("");
context.reply_text(&update, text).await?;
Ok(())
}
// Match text messages that are NOT commands:
app.add_handler(
MessageHandler::new(TEXT() & !COMMAND(), echo), 0,
).await;
}
See Filters for all available filters and how to combine them.
FnHandler
The most flexible handler. You provide a predicate function that inspects the raw Update and decides whether to handle it:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::FnHandler;
// Match callback queries
app.add_handler(
FnHandler::on_callback_query(button_callback), 0,
).await;
// Match inline queries
app.add_handler(
FnHandler::on_inline_query(inline_handler), 0,
).await;
// Match shipping queries
app.add_handler(
FnHandler::on_shipping_query(shipping_handler), 0,
).await;
// Match pre-checkout queries
app.add_handler(
FnHandler::on_pre_checkout_query(precheckout_handler), 0,
).await;
}
Custom Predicates
For full control, pass a predicate closure:
#![allow(unused)]
fn main() {
app.add_handler(
FnHandler::new(
|u| {
// Return true if this handler should fire
u.effective_message()
.and_then(|m| m.successful_payment.as_ref())
.is_some()
},
successful_payment_handler,
),
0,
).await;
}
The predicate receives a &Update and returns bool. The handler function receives (Arc<Update>, Context).
Handler Groups
The second argument to add_handler is the group number:
#![allow(unused)]
fn main() {
app.add_handler(handler_a, 0).await; // Group 0
app.add_handler(handler_b, 0).await; // Group 0
app.add_handler(handler_c, 1).await; // Group 1
}
Dispatch rules:
- Groups are processed in ascending numeric order (0, then 1, then 2, …).
- Within a group, the first handler whose predicate/filter matches wins. Only that one handler fires.
- After one handler fires in a group, the dispatcher moves to the next group.
- A handler in group 1 can fire even if a handler in group 0 already fired.
This means groups let you build layered processing. For example, you might log every update in group -1 and handle commands in group 0:
#![allow(unused)]
fn main() {
// Logging handler -- fires for ALL updates
app.add_handler(
FnHandler::new(|_| true, log_update), -1,
).await;
// Command handler -- fires only for /start
app.add_handler(
CommandHandler::new("start", start), 0,
).await;
}
Handler Function Signatures
All handlers follow the same signature:
#![allow(unused)]
fn main() {
async fn my_handler(update: Arc<Update>, context: Context) -> HandlerResult {
// Your logic here
Ok(())
}
}
To pass additional state, use closures that capture shared data:
#![allow(unused)]
fn main() {
let shared_state = Arc::new(RwLock::new(HashMap::new()));
let state = Arc::clone(&shared_state);
app.add_handler(
FnHandler::new(
|u| check_command(u, "save"),
move |update, ctx| {
let s = Arc::clone(&state);
async move { save_handler(update, ctx, s).await }
},
),
0,
).await;
}
This pattern is covered in detail in the Conversations guide.
Next Steps
Learn how filters control which messages reach your handlers in Filters.
Filters
Filters determine which updates reach a handler. They are composable, type-safe predicates that inspect an Update and return whether it matches.
Built-in Filters
TEXT
Matches any message that contains text:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::TEXT;
MessageHandler::new(TEXT(), my_handler)
}
COMMAND
Matches messages that start with a bot command (/something):
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::COMMAND;
MessageHandler::new(COMMAND(), my_handler)
}
Combining Filters
Filters support bitwise operators for composition:
#![allow(unused)]
fn main() {
// Text messages that are NOT commands
TEXT() & !COMMAND()
// Text OR photo messages
// (when photo filter is available)
}
The operators:
&– AND: both filters must match.|– OR: at least one filter must match.^– XOR: exactly one filter must match.!– NOT: inverts the filter.
The F Wrapper
All filters are wrapped in the F type, which provides the operator overloads:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::F;
}
The TEXT() and COMMAND() functions return F values directly, so you can combine them immediately.
Filter Results
Filters return a FilterResult enum:
#![allow(unused)]
fn main() {
pub enum FilterResult {
NoMatch,
Match,
MatchWithData(HashMap<String, Vec<String>>),
}
}
NoMatch– the update does not match.Match– the update matches (no additional data).MatchWithData– the update matches and carries extracted data (e.g., regex capture groups).
Available Filter Modules
The framework includes filters for many update properties:
| Module | What It Matches |
|---|---|
text | Text content presence |
command | Bot commands (/start, etc.) |
chat | Chat type (private, group, supergroup, channel) |
user | User properties |
document | Document/file messages |
photo | Photo messages |
entity | Message entity types (mentions, URLs, etc.) |
forwarded | Forwarded messages |
regex | Text matching a regular expression |
status_update | Chat status changes (member joined, etc.) |
via_bot | Messages sent via inline bots |
Using Filters with MessageHandler
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{MessageHandler, TEXT, COMMAND};
// Echo non-command text
app.add_handler(
MessageHandler::new(TEXT() & !COMMAND(), echo), 0,
).await;
}
Using Predicates with FnHandler
When built-in filters are not enough, FnHandler lets you write arbitrary predicates:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::FnHandler;
// Match updates that have a callback query with specific data
app.add_handler(
FnHandler::new(
|u| {
u.callback_query()
.and_then(|cq| cq.data.as_deref())
== Some("my_button")
},
my_callback_handler,
),
0,
).await;
}
Filter Composition in Practice
Here is a real-world example that matches text messages in a specific conversation state:
#![allow(unused)]
fn main() {
fn is_text_in_state(update: &Update, conv_store: &ConvStore, state: ConvState) -> bool {
let msg = match update.effective_message() {
Some(m) => m,
None => return false,
};
// Must have text
if msg.text.is_none() {
return false;
}
// Must NOT be a command
let is_cmd = msg.entities.as_ref()
.and_then(|ents| ents.first())
.map(|e| e.entity_type == MessageEntityType::BotCommand && e.offset == 0)
.unwrap_or(false);
if is_cmd {
return false;
}
// Must be in the expected state
let chat_id = msg.chat.id;
conv_store.try_read()
.map(|guard| guard.get(&chat_id) == Some(&state))
.unwrap_or(false)
}
}
Then use it with FnHandler:
#![allow(unused)]
fn main() {
let cs_check = Arc::clone(&conv_store);
app.add_handler(
FnHandler::new(
move |u| is_text_in_state(u, &cs_check, ConvState::AskName),
move |update, ctx| {
let cs = Arc::clone(&cs);
async move { receive_name(update, ctx, cs).await }
},
),
1,
).await;
}
Next Steps
Learn about the Context object that handlers receive in Context.
Context
The Context (aliased as CallbackContext) is passed to every handler alongside the Update. It provides access to the bot, user/chat data storage, the job queue, and convenience methods.
Core Methods
bot()
Access the bot instance to call Telegram API methods:
#![allow(unused)]
fn main() {
async fn my_handler(update: Arc<Update>, context: Context) -> HandlerResult {
let bot = context.bot();
bot.send_message(chat_id, "Hello!").await?;
Ok(())
}
}
reply_text()
A shortcut for sending a text reply to the chat that sent the update:
#![allow(unused)]
fn main() {
context.reply_text(&update, "Got it!").await?;
}
This is equivalent to:
#![allow(unused)]
fn main() {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
context.bot().send_message(chat_id, "Got it!").await?;
}
User Data
Store and retrieve per-user data that persists across updates. When paired with a persistence backend, this data survives bot restarts.
Reading User Data
#![allow(unused)]
fn main() {
let user_data = context.user_data().await.unwrap_or_default();
let name = user_data.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
}
Writing User Data
#![allow(unused)]
fn main() {
use serde_json::Value as JsonValue;
context.set_user_data(
"name".to_string(),
JsonValue::String("Alice".to_string()),
).await;
}
Typed Data Guards
The data methods return DataReadGuard and DataWriteGuard types that provide typed accessors:
#![allow(unused)]
fn main() {
if let Some(guard) = context.user_data_guard().await {
let name: Option<&str> = guard.get_str("name");
let age: Option<i64> = guard.get_i64("age");
let score: Option<f64> = guard.get_f64("score");
let active: Option<bool> = guard.get_bool("active");
let ids: HashSet<i64> = guard.get_id_set("friend_ids");
}
}
Chat Data
Similar to user data, but keyed per chat:
#![allow(unused)]
fn main() {
let chat_data = context.chat_data().await;
}
Bot Data
Global data shared across all handlers:
#![allow(unused)]
fn main() {
let bot_data = context.bot_data().await;
}
Job Queue
Access the scheduled job system (requires the job-queue feature):
#![allow(unused)]
fn main() {
let jq = context.job_queue.as_ref()
.expect("job_queue should be set");
jq.once(callback, Duration::from_secs(60))
.name("reminder")
.chat_id(chat_id)
.start()
.await;
}
See the Job Queue guide for full details.
Error Information
In error handlers, the context carries information about what went wrong:
#![allow(unused)]
fn main() {
async fn error_handler(
update: Option<Arc<Update>>,
context: CallbackContext,
) -> bool {
let error_text = context.error
.as_ref()
.map(|e| format!("{e}"))
.unwrap_or_else(|| "Unknown error".to_string());
tracing::error!("Error: {error_text}");
let chat_data = context.chat_data().await;
let user_data = context.user_data().await;
false // Return false to allow other error handlers to run
}
}
Handler Arguments
The context also provides parsed arguments for command handlers:
#![allow(unused)]
fn main() {
async fn set_timer(update: Arc<Update>, context: Context) -> HandlerResult {
// For a command like "/set 30", context.args contains ["30"]
if let Some(args) = &context.args {
if let Some(seconds) = args.first().and_then(|s| s.parse::<u64>().ok()) {
// Use seconds
}
}
Ok(())
}
}
Summary
| Method | Returns | Purpose |
|---|---|---|
bot() | Arc<ExtBot> | Access the Telegram Bot API |
reply_text(&update, text) | Result<...> | Quick reply to the current chat |
user_data() | Option<HashMap<String, JsonValue>> | Per-user key-value storage |
set_user_data(key, value) | () | Store a value for the current user |
chat_data() | Option<String> | Per-chat data (debug representation) |
job_queue | Option<Arc<JobQueue>> | Scheduled task system |
error | Option<HandlerError> | Error details (in error handlers) |
args | Option<Vec<String>> | Command arguments |
Next Steps
Understand how all these pieces fit together in the Application.
Application
The Application is the central orchestrator. It owns the bot, manages handler dispatch, controls the update processing lifecycle, and coordinates persistence.
Building an Application
Use ApplicationBuilder with the typestate pattern – you cannot call .build() without first providing a token:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::ApplicationBuilder;
let app = ApplicationBuilder::new()
.token("your-token")
.build();
}
The builder returns Arc<Application>, which is cheap to clone and share across async tasks.
Builder Options
| Method | Description |
|---|---|
.token(token) | Required. The bot token from @BotFather. |
.concurrent_updates(n) | Process up to n updates simultaneously. Default: 1. |
.defaults(defaults) | Set default parse mode, timeouts, etc. |
.arbitrary_callback_data(maxsize) | Enable the callback data cache. |
.context_types(ct) | Custom context types configuration. |
.post_init(hook) | Hook that runs after initialize(). |
.post_stop(hook) | Hook that runs after stop(). |
.post_shutdown(hook) | Hook that runs after shutdown(). |
.persistence(backend) | Persistence backend (requires persistence feature). |
.job_queue(jq) | Job queue instance (requires job-queue feature). |
Example: Full Configuration
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::ApplicationBuilder;
use rust_tg_bot::ext::job_queue::JobQueue;
use rust_tg_bot::ext::persistence::json_file::JsonFilePersistence;
let jq = Arc::new(JobQueue::new());
let persistence = JsonFilePersistence::new("my_bot", true, false);
let app = ApplicationBuilder::new()
.token(token)
.concurrent_updates(4)
.job_queue(Arc::clone(&jq))
.persistence(Box::new(persistence))
.build();
}
Registering Handlers
#![allow(unused)]
fn main() {
app.add_handler(handler, group).await;
}
handler– any handler type (CommandHandler,MessageHandler,FnHandler, etc.).group– ani32group number. Lower numbers are checked first.
Handler Dispatch Flow
Update arrives
|
v
Group -1: [handler_a, handler_b] --> first match wins in this group
|
v
Group 0: [handler_c, handler_d] --> first match wins in this group
|
v
Group 1: [handler_e] --> fires if it matches
|
v
Done
Each group is independent. One handler per group can fire.
Error Handlers
Register global error handlers that catch any HandlerError:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{Arc, CallbackContext, Update};
async fn on_error(update: Option<Arc<Update>>, ctx: CallbackContext) -> bool {
tracing::error!("Handler error: {:?}", ctx.error);
false // false = let other error handlers run too
}
app.add_error_handler(
Arc::new(|update, ctx| Box::pin(on_error(update, ctx))),
true, // block_other_handlers
).await;
}
See Error Handling for a complete guide.
Lifecycle
Automatic (Recommended)
#![allow(unused)]
fn main() {
// Handles initialize -> start -> idle -> stop -> shutdown
app.run_polling().await?;
}
Manual
For custom server setups:
#![allow(unused)]
fn main() {
app.initialize().await?;
app.start().await?;
// Run your own server...
// When ready to stop:
app.stop().await?;
app.shutdown().await?;
}
Accessing the Bot
#![allow(unused)]
fn main() {
let bot = app.bot(); // Returns Arc<ExtBot>
}
Update Sender (for Webhooks)
When running a custom webhook server, use the update sender to feed updates into the application:
#![allow(unused)]
fn main() {
let tx = app.update_sender(); // Returns mpsc::Sender<RawUpdate>
// In your webhook handler:
tx.send(raw_update).await?;
}
Feature Flags Affecting Application
| Feature | What It Enables |
|---|---|
job-queue | .job_queue() on the builder |
persistence | .persistence() on the builder |
persistence-json | JSON file persistence backend |
persistence-sqlite | SQLite persistence backend |
webhooks | run_webhook() method |
rate-limiter | Rate limiting on API calls |
Next Steps
You now understand all the core concepts. Move on to the practical guides:
- Command Bots – handle commands with arguments.
- Inline Keyboards – interactive button menus.
- Conversations – multi-step interactions.
Command Bots
Commands are the primary way users interact with Telegram bots. This guide covers everything from simple command handlers to commands with arguments and deep linking.
Basic Commands
Use CommandHandler for straightforward command matching:
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult, Update,
};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
context.reply_text(&update, "Welcome! Use /help to see what I can do.").await?;
Ok(())
}
async fn help(update: Arc<Update>, context: Context) -> HandlerResult {
context.reply_text(&update,
"Available commands:\n\
/start - Start the bot\n\
/help - Show this message\n\
/settings - Bot settings"
).await?;
Ok(())
}
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(CommandHandler::new("help", help), 0).await;
app.run_polling().await.unwrap();
}
CommandHandler::new("start", start) matches both /start and /start@yourbotname, which is important for group chats where commands are addressed to specific bots.
Commands with Arguments
Parse arguments from the message text directly:
#![allow(unused)]
fn main() {
async fn set_timer(update: Arc<Update>, context: Context) -> HandlerResult {
let msg = update.effective_message().expect("must have a message");
let chat_id = msg.chat.id;
let text = msg.text.as_deref().unwrap_or("");
// Split on whitespace, skip the command itself
let args: Vec<&str> = text.split_whitespace().skip(1).collect();
let seconds: u64 = match args.first() {
Some(s) => match s.parse() {
Ok(n) if n > 0 => n,
_ => {
context.bot()
.send_message(chat_id, "Usage: /set <seconds>\nPlease provide a positive number.")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
return Ok(());
}
},
None => {
context.bot()
.send_message(chat_id, "Usage: /set <seconds>")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
return Ok(());
}
};
context.bot()
.send_message(chat_id, &format!("Timer set for {seconds} seconds!"))
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Deep Linking
Deep linking lets you pass parameters through /start commands via special URLs. When a user clicks https://t.me/yourbot?start=my-payload, Telegram sends /start my-payload to your bot.
Generating Deep Links
#![allow(unused)]
fn main() {
fn create_deep_linked_url(bot_username: &str, payload: &str, group: bool) -> String {
let param = if group { "startgroup" } else { "start" };
format!("https://t.me/{bot_username}?{param}={payload}")
}
}
Routing by Payload
Register deep-link handlers before the plain /start handler. The first matching handler wins within its group:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{FnHandler, MessageEntityType};
fn is_start_with_payload(update: &Update, payload: &str) -> bool {
let msg = match update.effective_message() {
Some(m) => m,
None => return false,
};
let text = match msg.text.as_deref() {
Some(t) => t,
None => return false,
};
let entities = match msg.entities.as_ref() {
Some(e) => e,
None => return false,
};
let is_cmd = entities.first().map_or(false, |e| {
e.entity_type == MessageEntityType::BotCommand && e.offset == 0
});
is_cmd && text.starts_with("/start") && text.contains(payload)
}
// Register deep link handlers first
app.add_handler(
FnHandler::new(
|u| is_start_with_payload(u, "referral-code"),
handle_referral,
), 0,
).await;
// Then the plain /start handler
app.add_handler(
FnHandler::new(
|u| { /* matches bare /start only */ },
start,
), 0,
).await;
}
Sending Deep Links with Inline Keyboards
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{InlineKeyboardButton, InlineKeyboardMarkup};
let bot_username = context.bot().bot_data()
.and_then(|d| d.username.clone())
.unwrap_or_default();
let url = create_deep_linked_url(&bot_username, "my-payload", false);
let keyboard = InlineKeyboardMarkup::from_button(
InlineKeyboardButton::url("Open in private chat", url),
);
context.bot()
.send_message(chat_id, "Click below to continue:")
.reply_markup(serde_json::to_value(keyboard).unwrap())
.await?;
}
Registering Commands with BotFather
For the best user experience, register your commands with @BotFather so Telegram shows them in the command menu:
- Open @BotFather.
- Send
/setcommands. - Select your bot.
- Send a list like:
start - Start the bot
help - Show help message
settings - Configure settings
set - Set a timer
Next Steps
- Inline Keyboards – add interactive buttons to your messages.
- Conversations – build multi-step command flows.
Inline Keyboards
Inline keyboards are buttons that appear directly below a message. They can trigger callback queries, open URLs, or switch to inline mode.
Building a Keyboard
Use the typed constructors – never raw JSON:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{InlineKeyboardButton, InlineKeyboardMarkup};
fn build_menu() -> InlineKeyboardMarkup {
InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("Option 1", "1"),
InlineKeyboardButton::callback("Option 2", "2"),
],
vec![
InlineKeyboardButton::callback("Option 3", "3"),
],
])
}
}
Each inner Vec is one row of buttons. Each InlineKeyboardButton::callback(text, data) creates a button that sends data as a callback query when pressed.
Single-Row Shortcut
#![allow(unused)]
fn main() {
let keyboard = InlineKeyboardMarkup::from_row(vec![
InlineKeyboardButton::callback("Yes", "yes"),
InlineKeyboardButton::callback("No", "no"),
]);
}
Single-Button Shortcut
#![allow(unused)]
fn main() {
let keyboard = InlineKeyboardMarkup::from_button(
InlineKeyboardButton::callback("Confirm", "confirm"),
);
}
Sending a Keyboard
#![allow(unused)]
fn main() {
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
let keyboard = serde_json::to_value(build_menu()).unwrap();
context.bot()
.send_message(chat_id, "Please choose an option:")
.reply_markup(keyboard)
.await?;
Ok(())
}
}
Handling Button Presses
When a user presses an inline keyboard button, Telegram sends a callback query. Handle it with FnHandler::on_callback_query:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::FnHandler;
async fn button_callback(update: Arc<Update>, context: Context) -> HandlerResult {
let cq = update.callback_query()
.expect("callback query handler received update without callback_query");
let data = cq.data.as_deref().unwrap_or("unknown");
// Answer the callback query (removes the loading indicator)
context.bot().answer_callback_query(&cq.id).await?;
// Edit the original message to show the selection
if let Some(msg) = cq.message.as_deref() {
let response_text = format!("You selected: Option {data}");
context.bot()
.edit_message_text(&response_text)
.chat_id(msg.chat().id)
.message_id(msg.message_id())
.await?;
}
Ok(())
}
// Register the callback handler
app.add_handler(FnHandler::on_callback_query(button_callback), 0).await;
}
Always Answer Callback Queries
If you do not call answer_callback_query, the user sees a perpetual loading spinner on the button. Always answer, even if you have nothing to show:
#![allow(unused)]
fn main() {
context.bot().answer_callback_query(&cq.id).await?;
}
You can also show a notification:
#![allow(unused)]
fn main() {
context.bot()
.answer_callback_query(&cq.id)
.text("Saved!")
.show_alert(true) // Shows a modal alert instead of a toast
.await?;
}
Button Types
InlineKeyboardButton supports several types:
#![allow(unused)]
fn main() {
// Callback button -- sends data back to your bot
InlineKeyboardButton::callback("Click me", "callback_data")
// URL button -- opens a link
InlineKeyboardButton::url("Visit website", "https://example.com")
}
Editing Keyboards
Update the keyboard on an existing message:
#![allow(unused)]
fn main() {
let new_keyboard = InlineKeyboardMarkup::new(vec![
vec![InlineKeyboardButton::callback("Updated Option", "new_data")],
]);
context.bot()
.edit_message_text("Updated message")
.chat_id(chat_id)
.message_id(message_id)
.reply_markup(serde_json::to_value(new_keyboard).unwrap())
.await?;
}
Complete Example
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, FnHandler,
HandlerResult, InlineKeyboardButton, InlineKeyboardMarkup, Update,
};
fn build_keyboard() -> InlineKeyboardMarkup {
InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("Option 1", "1"),
InlineKeyboardButton::callback("Option 2", "2"),
],
vec![InlineKeyboardButton::callback("Option 3", "3")],
])
}
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
let keyboard = serde_json::to_value(build_keyboard()).unwrap();
context.bot()
.send_message(chat_id, "Please choose an option:")
.reply_markup(keyboard)
.await?;
Ok(())
}
async fn button_callback(update: Arc<Update>, context: Context) -> HandlerResult {
let cq = update.callback_query().unwrap();
let data = cq.data.as_deref().unwrap_or("unknown");
context.bot().answer_callback_query(&cq.id).await?;
if let Some(msg) = cq.message.as_deref() {
context.bot()
.edit_message_text(&format!("You selected: Option {data}"))
.chat_id(msg.chat().id)
.message_id(msg.message_id())
.await?;
}
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(FnHandler::on_callback_query(button_callback), 0).await;
app.run_polling().await.unwrap();
}
Next Steps
- Conversations – build multi-step flows using state and keyboards.
- Payments – inline keyboards are also used in payment flows.
Conversations
A conversation is a multi-step interaction where the bot asks questions and the user provides answers one at a time. Each step transitions to the next state.
State Management Pattern
In Rust, conversation state is managed with a shared HashMap protected by an async RwLock:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{Arc, HashMap, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConvState {
AskName,
AskAge,
AskLocation,
AskBio,
}
type ConvStore = Arc<RwLock<HashMap<i64, ConvState>>>;
}
The key is the chat ID (or user ID), and the value is the current conversation state for that user.
Complete Conversation Bot
This bot collects a user profile through four steps: name, age, location, and bio.
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{
Application, ApplicationBuilder, Arc, Context, FnHandler,
HandlerError, HandlerResult, HashMap, MessageEntityType, RwLock, Update,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConvState {
AskName,
AskAge,
AskLocation,
AskBio,
}
type ConvStore = Arc<RwLock<HashMap<i64, ConvState>>>;
type UserDataStore = Arc<RwLock<HashMap<i64, UserProfile>>>;
#[derive(Debug, Clone, Default)]
struct UserProfile {
name: Option<String>,
age: Option<String>,
location: Option<String>,
bio: Option<String>,
}
}
Handler Functions
Each handler function receives the shared stores through closure captures:
#![allow(unused)]
fn main() {
async fn start_command(
update: Arc<Update>,
context: Context,
conv_store: ConvStore,
) -> HandlerResult {
let chat_id = update.effective_chat().unwrap().id;
conv_store.write().await.insert(chat_id, ConvState::AskName);
context.bot()
.send_message(chat_id, "Hi! What is your name? (Send /cancel to stop.)")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
async fn receive_name(
update: Arc<Update>,
context: Context,
conv_store: ConvStore,
user_data: UserDataStore,
) -> HandlerResult {
let chat_id = update.effective_chat().unwrap().id;
let text = update.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or_default();
user_data.write().await.entry(chat_id).or_default().name = Some(text.to_string());
conv_store.write().await.insert(chat_id, ConvState::AskAge);
context.bot()
.send_message(chat_id, &format!("Nice to meet you, {text}! How old are you?"))
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
async fn cancel(
update: Arc<Update>,
context: Context,
conv_store: ConvStore,
) -> HandlerResult {
let chat_id = update.effective_chat().unwrap().id;
conv_store.write().await.remove(&chat_id);
context.bot()
.send_message(chat_id, "Conversation cancelled. Send /start to begin again.")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
State-Checking Predicates
Each state handler needs a predicate that checks whether the user is in the correct state:
#![allow(unused)]
fn main() {
fn is_text_in_state(update: &Update, conv_store: &ConvStore, state: ConvState) -> bool {
let msg = match update.effective_message() {
Some(m) => m,
None => return false,
};
if msg.text.is_none() {
return false;
}
// Exclude commands
let is_cmd = msg.entities.as_ref()
.and_then(|ents| ents.first())
.map(|e| e.entity_type == MessageEntityType::BotCommand && e.offset == 0)
.unwrap_or(false);
if is_cmd {
return false;
}
let chat_id = msg.chat.id;
conv_store.try_read()
.map(|guard| guard.get(&chat_id) == Some(&state))
.unwrap_or(false)
}
}
Wiring It Together
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app: Arc<Application> = ApplicationBuilder::new().token(token).build();
let conv_store: ConvStore = Arc::new(RwLock::new(HashMap::new()));
let user_data: UserDataStore = Arc::new(RwLock::new(HashMap::new()));
// /start entry point
{
let cs = Arc::clone(&conv_store);
app.add_handler(
FnHandler::new(
|u| check_command(u, "start"),
move |update, ctx| {
let cs = Arc::clone(&cs);
async move { start_command(update, ctx, cs).await }
},
), 0,
).await;
}
// /cancel fallback
{
let cs = Arc::clone(&conv_store);
app.add_handler(
FnHandler::new(
|u| check_command(u, "cancel"),
move |update, ctx| {
let cs = Arc::clone(&cs);
async move { cancel(update, ctx, cs).await }
},
), 0,
).await;
}
// State: AskName (group 1 so commands in group 0 take priority)
{
let cs = Arc::clone(&conv_store);
let ud = Arc::clone(&user_data);
let cs_check = Arc::clone(&conv_store);
app.add_handler(
FnHandler::new(
move |u| is_text_in_state(u, &cs_check, ConvState::AskName),
move |update, ctx| {
let cs = Arc::clone(&cs);
let ud = Arc::clone(&ud);
async move { receive_name(update, ctx, cs, ud).await }
},
), 1,
).await;
}
// Repeat for AskAge, AskLocation, AskBio...
app.run_polling().await.unwrap();
}
Key Patterns
Use Group Numbers for Priority
Register command handlers (like /cancel) in group 0 and state handlers in group 1. This ensures commands always take priority over state-based text matching.
Clone Arc Before Moving Into Closures
Every Arc::clone() is cheap (it increments an atomic counter). Always clone before moving into a closure:
#![allow(unused)]
fn main() {
let cs = Arc::clone(&conv_store);
app.add_handler(
FnHandler::new(
|u| check_command(u, "start"),
move |update, ctx| {
let cs = Arc::clone(&cs);
async move { start_command(update, ctx, cs).await }
},
), 0,
).await;
}
Use try_read() in Predicates
Predicates run synchronously, so use try_read() instead of .read().await:
#![allow(unused)]
fn main() {
conv_store.try_read()
.map(|guard| guard.get(&chat_id) == Some(&state))
.unwrap_or(false)
}
Next Steps
- Persistence – save conversation state across bot restarts.
- Nested Conversations – multi-level state machines.
Job Queue
The job queue lets you schedule delayed and recurring tasks. Common use cases include reminders, periodic notifications, and cleanup routines.
Setup
Enable the job-queue feature:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["job-queue"] }
Create a JobQueue and pass it to the application builder:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use rust_tg_bot::ext::job_queue::JobQueue;
use rust_tg_bot::ext::prelude::ApplicationBuilder;
let jq = Arc::new(JobQueue::new());
let app = ApplicationBuilder::new()
.token(token)
.job_queue(Arc::clone(&jq))
.build();
}
Scheduling a One-Shot Job
Schedule a job that fires once after a delay:
#![allow(unused)]
fn main() {
use std::time::Duration;
use rust_tg_bot::ext::job_queue::{JobCallbackFn, JobContext};
async fn set_timer(
update: Arc<Update>,
context: Context,
timer_store: TimerStore,
) -> HandlerResult {
let chat_id = update.effective_chat().unwrap().id;
let seconds: u64 = 30; // from user input
// Build the callback
let bot = Arc::clone(context.bot());
let alarm_callback: JobCallbackFn = Arc::new(move |ctx: JobContext| {
let bot = Arc::clone(&bot);
Box::pin(async move {
let target_chat_id = ctx.chat_id.unwrap_or(0);
if target_chat_id == 0 {
return Ok(());
}
bot.send_message(target_chat_id, "BEEP! Timer is done!")
.await
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
Ok(())
})
});
// Schedule via the job queue
let jq = context.job_queue.as_ref().expect("job_queue should be set");
let job = jq
.once(alarm_callback, Duration::from_secs(seconds))
.name(format!("timer_{chat_id}"))
.chat_id(chat_id)
.start()
.await;
// Store the job ID for later cancellation
timer_store.write().await.insert(chat_id, job.id);
context.bot()
.send_message(chat_id, &format!("Timer set for {seconds} seconds!"))
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Job Builder Methods
The job builder returned by jq.once() supports these methods:
| Method | Description |
|---|---|
.name(name) | Human-readable name for the job |
.chat_id(id) | Associates a chat ID with the job (passed to the callback via JobContext) |
.start() | Schedules the job and returns a Job handle |
Cancelling Jobs
Cancel a job by calling schedule_removal() on its handle:
#![allow(unused)]
fn main() {
async fn unset_timer(
update: Arc<Update>,
context: Context,
timer_store: TimerStore,
) -> HandlerResult {
let chat_id = update.effective_chat().unwrap().id;
let jq = context.job_queue.as_ref().unwrap();
let removed = {
let mut store = timer_store.write().await;
if let Some(old_job_id) = store.remove(&chat_id) {
let jobs = jq.jobs().await;
for job in jobs {
if job.id == old_job_id {
job.schedule_removal();
break;
}
}
true
} else {
false
}
};
let reply = if removed {
"Timer successfully cancelled!"
} else {
"You have no active timer."
};
context.bot()
.send_message(chat_id, reply)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
JobContext
The callback receives a JobContext with metadata about the job:
#![allow(unused)]
fn main() {
let callback: JobCallbackFn = Arc::new(|ctx: JobContext| {
Box::pin(async move {
let chat_id = ctx.chat_id.unwrap_or(0);
// Use chat_id to send messages
Ok(())
})
});
}
Tracking Active Jobs
Use a shared store to map chat IDs to job IDs:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use rust_tg_bot::ext::prelude::{Arc, RwLock};
type TimerStore = Arc<RwLock<HashMap<i64, u64>>>;
}
Before scheduling a new job, cancel any existing one for the same chat:
#![allow(unused)]
fn main() {
{
let store = timer_store.read().await;
if let Some(&old_job_id) = store.get(&chat_id) {
let jobs = jq.jobs().await;
for job in jobs {
if job.id == old_job_id {
job.schedule_removal();
break;
}
}
}
}
}
Complete Timer Bot
See the timer_bot example in the repository for a full working implementation:
TELEGRAM_BOT_TOKEN="..." cargo run -p rust-tg-bot --example timer_bot --features job-queue
Commands:
/start– show usage/set 30– set a 30-second timer/unset– cancel the active timer
Next Steps
- Persistence – persist job data across restarts.
- Error Handling – handle job callback failures.
Persistence
Persistence lets your bot remember data across restarts. Without it, all user data, chat data, and bot-wide data live only in memory and vanish when the process exits.
How It Works
The framework provides a BasePersistence trait that defines how data is stored and loaded. You pick an implementation, pass it to ApplicationBuilder, and the Application handles the rest – loading data at startup, flushing changes periodically, and saving on shutdown.
Three backends ship out of the box:
| Backend | Feature Flag | Best For |
|---|---|---|
DictPersistence | persistence | Testing, prototyping (in-memory only) |
JsonFilePersistence | persistence-json | Simple bots, human-readable storage |
SqlitePersistence | persistence-sqlite | Production bots, concurrent access |
Enabling Persistence
Add the appropriate feature to your Cargo.toml:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["persistence-json"] }
Or for SQLite:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["persistence-sqlite"] }
JsonFilePersistence
The most common choice for getting started. Stores all data in one or more JSON files on disk.
use rust_tg_bot::ext::persistence::json_file::JsonFilePersistence;
use rust_tg_bot::ext::prelude::{
Application, ApplicationBuilder, Arc, CommandHandler, Context,
HandlerResult, Update,
};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
context.reply_text(&update, "Hello! Your data will persist.").await?;
Ok(())
}
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
// Arguments: file prefix, single-file mode, pretty-print
let persistence = JsonFilePersistence::new("my_bot_data", true, false);
let app: Arc<Application> = ApplicationBuilder::new()
.token(token)
.persistence(Box::new(persistence))
.build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.run_polling().await.unwrap();
}
The three arguments to JsonFilePersistence::new:
- File prefix – the base name for the JSON file(s). Single-file mode creates
my_bot_data.json. - Single-file mode –
truestores everything in one file;falsecreates separate files per data category (my_bot_data_user_data.json,my_bot_data_chat_data.json, etc.). - Pretty-print –
trueformats the JSON with indentation for debugging;falseis compact.
SqlitePersistence
For production bots that need reliability under concurrent load:
use rust_tg_bot::ext::persistence::sqlite::SqlitePersistence;
use rust_tg_bot::ext::prelude::{Application, ApplicationBuilder, Arc};
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let persistence = SqlitePersistence::open("bot.db")
.expect("failed to open SQLite database");
let app: Arc<Application> = ApplicationBuilder::new()
.token(token)
.persistence(Box::new(persistence))
.build();
// ... register handlers ...
app.run_polling().await.unwrap();
}
SqlitePersistence::open creates the database file and initialises the schema automatically. It uses WAL journal mode for better concurrent read performance and wraps the connection in a tokio::sync::Mutex to prevent SQLITE_BUSY errors.
For testing, use the in-memory variant:
#![allow(unused)]
fn main() {
let persistence = SqlitePersistence::in_memory()
.expect("failed to create in-memory SQLite database");
}
Accessing Data from Handlers
Once persistence is configured, Context gives you access to three data scopes.
User Data
Scoped to the user who triggered the update. Each user gets their own HashMap<String, JsonValue>.
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{
Arc, Context, HandlerResult, JsonValue, Update,
};
async fn save_preference(update: Arc<Update>, context: Context) -> HandlerResult {
// Read current user data (returns a cloned snapshot)
let user_data = context.user_data().await.unwrap_or_default();
let visit_count = user_data
.get("visits")
.and_then(|v| v.as_i64())
.unwrap_or(0);
// Write a new value
context
.set_user_data("visits".to_string(), JsonValue::from(visit_count + 1))
.await;
context
.reply_text(&update, &format!("Visit count: {}", visit_count + 1))
.await?;
Ok(())
}
}
Chat Data
Scoped to the chat where the update originated. Useful for group settings.
#![allow(unused)]
fn main() {
async fn set_welcome(update: Arc<Update>, context: Context) -> HandlerResult {
let text = update
.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("Welcome!");
context
.set_chat_data(
"welcome_message".to_string(),
JsonValue::String(text.to_string()),
)
.await;
context.reply_text(&update, "Welcome message updated.").await?;
Ok(())
}
}
Bot Data
Shared across all users and chats. Accessed through typed guards that wrap tokio::sync::RwLock.
#![allow(unused)]
fn main() {
async fn global_counter(update: Arc<Update>, context: Context) -> HandlerResult {
// Read with a typed guard
let count = {
let data = context.bot_data().await;
data.get_i64("global_count").unwrap_or(0)
};
// Write with a typed guard
{
let mut data = context.bot_data_mut().await;
data.set_i64("global_count", count + 1);
}
context
.reply_text(&update, &format!("Global message count: {}", count + 1))
.await?;
Ok(())
}
}
DataReadGuard and DataWriteGuard
The bot_data() and bot_data_mut() methods return typed guards that provide convenience accessors. These eliminate manual get().and_then(|v| v.as_*) chains.
DataReadGuard
#![allow(unused)]
fn main() {
let data = context.bot_data().await;
data.get_str("name"); // Option<&str>
data.get_i64("count"); // Option<i64>
data.get_f64("ratio"); // Option<f64>
data.get_bool("enabled"); // Option<bool>
data.get("raw_key"); // Option<&Value>
data.get_id_set("user_ids"); // HashSet<i64>
data.raw(); // &HashMap<String, Value>
data.is_empty(); // bool
data.len(); // usize
}
DataWriteGuard
#![allow(unused)]
fn main() {
let mut data = context.bot_data_mut().await;
data.set_str("name", "MyBot");
data.set_i64("count", 42);
data.set_bool("enabled", true);
data.insert("key".to_string(), JsonValue::Array(vec![]));
data.add_to_id_set("user_ids", 12345);
data.remove_from_id_set("user_ids", 12345);
data.remove("old_key");
data.entry("key".to_string()); // HashMap Entry API
data.raw_mut(); // &mut HashMap<String, Value>
}
Both guards implement Deref (and DerefMut for the write guard) to HashMap<String, Value>, so you can also use standard HashMap methods directly.
The BasePersistence Trait
If the built-in backends do not fit your needs, implement BasePersistence yourself. The trait requires Send + Sync because it is stored behind an Arc and accessed from multiple async tasks. It uses native async fn in traits (stabilised in Rust 1.75) – no async_trait macro needed.
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::persistence::base::{
BasePersistence, PersistenceInput, PersistenceResult,
};
use std::collections::HashMap;
#[derive(Debug)]
struct RedisPersistence {
// your connection pool
}
impl BasePersistence for RedisPersistence {
async fn get_user_data(&self) -> PersistenceResult<HashMap<i64, JsonMap>> {
// load from Redis
todo!()
}
async fn update_user_data(
&self, user_id: i64, data: &JsonMap,
) -> PersistenceResult<()> {
// save to Redis
todo!()
}
async fn get_chat_data(&self) -> PersistenceResult<HashMap<i64, JsonMap>> {
todo!()
}
async fn update_chat_data(
&self, chat_id: i64, data: &JsonMap,
) -> PersistenceResult<()> {
todo!()
}
async fn get_bot_data(&self) -> PersistenceResult<JsonMap> {
todo!()
}
async fn update_bot_data(&self, data: &JsonMap) -> PersistenceResult<()> {
todo!()
}
async fn get_callback_data(&self) -> PersistenceResult<Option<CdcData>> {
todo!()
}
async fn update_callback_data(&self, data: &CdcData) -> PersistenceResult<()> {
todo!()
}
async fn get_conversations(
&self, name: &str,
) -> PersistenceResult<ConversationDict> {
todo!()
}
async fn update_conversation(
&self,
name: &str,
key: &ConversationKey,
new_state: Option<&serde_json::Value>,
) -> PersistenceResult<()> {
todo!()
}
async fn drop_chat_data(&self, chat_id: i64) -> PersistenceResult<()> {
todo!()
}
async fn drop_user_data(&self, user_id: i64) -> PersistenceResult<()> {
todo!()
}
async fn flush(&self) -> PersistenceResult<()> {
// flush pending writes
todo!()
}
}
}
Key trait methods:
| Method | Purpose |
|---|---|
get_user_data / get_chat_data / get_bot_data | Load data at startup |
update_user_data / update_chat_data / update_bot_data | Persist changes |
get_conversations / update_conversation | Store conversation state |
get_callback_data / update_callback_data | Store callback data cache |
drop_chat_data / drop_user_data | Delete data for a specific entity |
flush | Called on shutdown to save pending writes |
update_interval | How often (in seconds) the Application flushes (default: 60) |
store_data | Returns PersistenceInput controlling which categories are persisted |
refresh_user_data / refresh_chat_data / refresh_bot_data | Optional hooks called before dispatching |
Complete Example
This example collects facts about the user and persists them across restarts:
use rust_tg_bot::ext::persistence::json_file::JsonFilePersistence;
use rust_tg_bot::ext::prelude::{
Application, ApplicationBuilder, Arc, CommandHandler, Context,
HandlerResult, JsonValue, MessageHandler, Update, COMMAND, TEXT,
};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
let user_data = context.user_data().await.unwrap_or_default();
let name = update
.effective_user()
.map(|u| u.first_name.clone())
.unwrap_or_else(|| "stranger".to_string());
if user_data.is_empty() || user_data.keys().all(|k| k.starts_with('_')) {
context
.reply_text(
&update,
&format!("Welcome, {name}! Tell me something about yourself."),
)
.await?;
} else {
let facts: Vec<String> = user_data
.iter()
.filter(|(k, _)| !k.starts_with('_'))
.map(|(k, v)| format!("{k}: {}", v.as_str().unwrap_or("?")))
.collect();
context
.reply_text(
&update,
&format!(
"Welcome back, {name}! I remember:\n{}",
facts.join("\n"),
),
)
.await?;
}
Ok(())
}
async fn remember(update: Arc<Update>, context: Context) -> HandlerResult {
let text = update
.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("");
if let Some((key, value)) = text.split_once(':') {
context
.set_user_data(
key.trim().to_string(),
JsonValue::String(value.trim().to_string()),
)
.await;
context.reply_text(&update, "Got it! I will remember that.").await?;
} else {
context
.reply_text(&update, "Send facts as 'key: value'.")
.await?;
}
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let persistence = JsonFilePersistence::new("remember_bot", true, false);
let app: Arc<Application> = ApplicationBuilder::new()
.token(token)
.persistence(Box::new(persistence))
.build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(
MessageHandler::new(TEXT() & !COMMAND(), remember), 0,
).await;
app.run_polling().await.unwrap();
}
Next Steps
- Webhooks – run your bot in webhook mode for production.
- Error Handling – handle persistence errors gracefully.
Webhooks
Long polling is great for development, but production bots should use webhooks. Instead of your bot repeatedly asking Telegram “any new updates?”, Telegram pushes updates to your server the moment they arrive.
Polling vs Webhooks
| Polling | Webhooks | |
|---|---|---|
| Setup | Zero config | Requires HTTPS endpoint |
| Latency | Depends on poll interval | Near-instant |
| Resource usage | Constant network calls | Idle until update arrives |
| Best for | Development, low-traffic bots | Production, high-traffic bots |
Built-in Webhook Server
The framework includes a built-in webhook server. Enable it with the webhooks feature:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["webhooks"] }
Then use run_webhook() instead of run_polling():
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult,
Update, WebhookConfig,
};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
context.reply_text(&update, "Hello from a webhook bot!").await?;
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
let config = WebhookConfig {
listen: "0.0.0.0".into(),
port: 8443,
url_path: "/webhook".into(),
webhook_url: Some("https://your.domain/webhook".into()),
secret_token: Some("my-secret-token".into()),
..Default::default()
};
app.run_webhook(config).await.unwrap();
}
WebhookConfig
The WebhookConfig struct controls all webhook behaviour:
| Field | Type | Default | Purpose |
|---|---|---|---|
listen | String | "127.0.0.1" | Address to bind the HTTP server |
port | u16 | 80 | Port to listen on |
url_path | String | "" | URL path for the webhook endpoint |
webhook_url | Option<String> | None | Full public URL Telegram will POST to |
secret_token | Option<String> | None | Token for validating requests from Telegram |
bootstrap_retries | i32 | 0 | Retries when setting the webhook on Telegram |
drop_pending_updates | bool | false | Discard updates that arrived while offline |
allowed_updates | Option<Vec<String>> | None | Filter which update types you receive |
max_connections | u32 | 40 | Max simultaneous connections from Telegram |
Secret Token Validation
Telegram sends a X-Telegram-Bot-Api-Secret-Token header with every webhook request. When you set secret_token in WebhookConfig, the built-in server automatically validates this header and rejects requests that do not match.
#![allow(unused)]
fn main() {
let config = WebhookConfig {
secret_token: Some("a-random-string-only-you-know".into()),
..Default::default()
};
}
Generate a strong random secret at deployment time, for example with openssl rand -hex 32. Always set a secret token in production to prevent third parties from sending fake updates to your endpoint.
Custom Webhook with Axum
For bots that need custom HTTP routes alongside the Telegram webhook, bypass the built-in server and run your own axum application. This gives you full control over routing, middleware, and additional endpoints.
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::Router;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use rust_tg_bot::ext::prelude::{
Application, ApplicationBuilder, Arc, CommandHandler, Context,
HandlerResult, ParseMode, Update,
};
use rust_tg_bot::raw::types::update::Update as RawUpdate;
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
let chat_id = update
.effective_chat()
.map(|c| c.id)
.expect("start command must originate from a chat");
context
.bot()
.send_message(chat_id, "Hello from a custom webhook server!")
.parse_mode(ParseMode::Html)
.await?;
Ok(())
}
async fn handle_webhook(
axum::extract::State(state): axum::extract::State<AppState>,
body: axum::body::Bytes,
) -> impl IntoResponse {
let update: RawUpdate = match serde_json::from_slice(&body) {
Ok(u) => u,
Err(_) => return StatusCode::BAD_REQUEST,
};
if let Err(e) = state.update_tx.send(update).await {
tracing::error!("Failed to enqueue update: {e}");
return StatusCode::INTERNAL_SERVER_ERROR;
}
StatusCode::OK
}
async fn healthcheck() -> &'static str {
"The bot is running fine"
}
#[derive(Clone)]
struct AppState {
update_tx: mpsc::Sender<RawUpdate>,
app: Arc<Application>,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let webhook_url = std::env::var("WEBHOOK_URL").unwrap();
let app = ApplicationBuilder::new().token(&token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
// Initialize and start the Application manually
app.initialize().await.expect("Failed to initialize");
app.start().await.expect("Failed to start");
// Set the webhook on Telegram's side
let full_url = format!("{webhook_url}/telegram");
app.bot()
.set_webhook(&full_url)
.await
.expect("Failed to set webhook");
// Build the axum router
let state = AppState {
update_tx: app.update_sender(),
app: Arc::clone(&app),
};
let router = Router::new()
.route("/telegram", post(handle_webhook))
.route("/healthcheck", get(healthcheck))
.with_state(state);
let listener = TcpListener::bind("0.0.0.0:8443").await.unwrap();
println!("Custom webhook server listening on 0.0.0.0:8443");
axum::serve(listener, router).await.unwrap();
// Cleanup on shutdown
app.stop().await.ok();
app.shutdown().await.ok();
}
The key steps when running a custom server:
- Call
app.initialize()andapp.start()instead ofrun_polling()orrun_webhook(). - Set the webhook URL on Telegram with
app.bot().set_webhook(url).await. - Use
app.update_sender()to get thempsc::Senderchannel and forward parsed updates into it. - Call
app.stop()andapp.shutdown()when your server exits.
Production Setup Behind a Reverse Proxy
In production, you typically run the bot behind nginx or another reverse proxy that handles TLS termination.
Nginx Configuration
server {
listen 443 ssl http2;
server_name bot.example.com;
ssl_certificate /etc/letsencrypt/live/bot.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bot.example.com/privkey.pem;
location /webhook {
proxy_pass http://127.0.0.1:8080/webhook;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Telegram-Bot-Api-Secret-Token $http_x_rust_tg_bot_api_secret_token;
}
}
Then configure your bot to listen on localhost:
#![allow(unused)]
fn main() {
let config = WebhookConfig {
listen: "127.0.0.1".into(),
port: 8080,
url_path: "/webhook".into(),
webhook_url: Some("https://bot.example.com/webhook".into()),
secret_token: Some("my-secret-token".into()),
..Default::default()
};
}
Allowed Ports
Telegram only sends webhook requests to these ports: 443, 80, 88, 8443. When using a reverse proxy, only the proxy needs to listen on one of these ports. Your bot can listen on any internal port.
Switching Between Modes
A common pattern is to use polling in development and webhooks in production:
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
// ... register handlers ...
if let Ok(webhook_url) = std::env::var("WEBHOOK_URL") {
let config = WebhookConfig {
webhook_url: Some(webhook_url),
secret_token: std::env::var("WEBHOOK_SECRET").ok(),
port: std::env::var("PORT")
.unwrap_or_else(|_| "8443".into())
.parse()
.unwrap(),
..Default::default()
};
app.run_webhook(config).await.unwrap();
} else {
app.run_polling().await.unwrap();
}
}
Removing a Webhook
To switch back to polling, you must delete the webhook first. The framework does this automatically when you call run_polling(), but you can also do it manually:
#![allow(unused)]
fn main() {
app.bot().delete_webhook(false).await?;
}
The boolean argument controls whether to drop pending updates (true) or keep them (false).
Next Steps
- Deployment – full production deployment strategies including Docker and systemd.
- Error Handling – handle webhook failures gracefully.
Inline Mode
Inline mode lets users interact with your bot directly from the message input field of any chat. When a user types @yourbotname query, Telegram sends an inline query to your bot, and you respond with a list of results the user can pick from.
Prerequisites
Before your bot can receive inline queries, you must enable inline mode through @BotFather:
- Send
/mybotsand select your bot. - Choose “Bot Settings” then “Inline Mode”.
- Turn it on.
Handling Inline Queries
Register an inline query handler with FnHandler::on_inline_query:
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, FnHandler,
HandlerResult, Update,
};
use rust_tg_bot::raw::types::inline::inline_query_result_article::InlineQueryResultArticle;
use rust_tg_bot::raw::types::inline::input_message_content::InputMessageContent;
use rust_tg_bot::raw::types::inline::input_text_message_content::InputTextMessageContent;
async fn inline_query_handler(update: Arc<Update>, context: Context) -> HandlerResult {
let iq = match update.inline_query() {
Some(q) => q,
None => return Ok(()),
};
if iq.query.is_empty() {
return Ok(());
}
let content = InputTextMessageContent::new(iq.query.to_uppercase());
let article = InlineQueryResultArticle::new(
format!("caps-{}", iq.id),
"CAPS",
InputMessageContent::Text(content),
);
let results = vec![
serde_json::to_value(article).expect("article serialization"),
];
context
.bot()
.answer_inline_query(&iq.id, results)
.await?;
Ok(())
}
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(FnHandler::on_inline_query(inline_query_handler), 0)
.await;
app.run_polling().await.unwrap();
}
Building Results
InlineQueryResultArticle
The most common result type. It shows a title and optional description, and sends a message when the user taps it.
#![allow(unused)]
fn main() {
use rust_tg_bot::raw::types::inline::inline_query_result_article::InlineQueryResultArticle;
use rust_tg_bot::raw::types::inline::input_message_content::InputMessageContent;
use rust_tg_bot::raw::types::inline::input_text_message_content::InputTextMessageContent;
let content = InputTextMessageContent::new("Hello, world!");
let article = InlineQueryResultArticle::new(
"unique-id-1", // Unique result ID
"Say Hello", // Title shown to the user
InputMessageContent::Text(content),
);
}
InputTextMessageContent
Controls the message that gets sent when the user selects a result.
#![allow(unused)]
fn main() {
// Plain text
let plain = InputTextMessageContent::new("Just plain text");
// HTML formatted
let html = InputTextMessageContent::new("<b>Bold</b> and <i>italic</i>")
.parse_mode("HTML");
// MarkdownV2 formatted
let markdown = InputTextMessageContent::new("*Bold* and _italic_")
.parse_mode("MarkdownV2");
}
Multiple Results
Most inline bots offer several options. Each result needs a unique ID within the response:
#![allow(unused)]
fn main() {
async fn inline_query_handler(update: Arc<Update>, context: Context) -> HandlerResult {
let iq = match update.inline_query() {
Some(q) => q,
None => return Ok(()),
};
let query = &iq.query;
if query.is_empty() {
return Ok(());
}
// Escape HTML special characters
let escaped = query
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
let caps_content = InputTextMessageContent::new(query.to_uppercase());
let bold_content = InputTextMessageContent::new(format!("<b>{escaped}</b>"))
.parse_mode("HTML");
let italic_content = InputTextMessageContent::new(format!("<i>{escaped}</i>"))
.parse_mode("HTML");
let results = vec![
serde_json::to_value(InlineQueryResultArticle::new(
format!("caps-{}", iq.id),
"Caps",
InputMessageContent::Text(caps_content),
)).expect("serialization"),
serde_json::to_value(InlineQueryResultArticle::new(
format!("bold-{}", iq.id),
"Bold",
InputMessageContent::Text(bold_content),
)).expect("serialization"),
serde_json::to_value(InlineQueryResultArticle::new(
format!("italic-{}", iq.id),
"Italic",
InputMessageContent::Text(italic_content),
)).expect("serialization"),
];
context
.bot()
.answer_inline_query(&iq.id, results)
.await?;
Ok(())
}
}
Combining with Commands
Inline bots typically also respond to direct messages. Register both command handlers and the inline query handler:
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
context
.reply_text(
&update,
"Hi! Use me inline by typing @botusername <query> in any chat.",
)
.await?;
Ok(())
}
async fn help_command(update: Arc<Update>, context: Context) -> HandlerResult {
context
.reply_text(
&update,
"Type @botusername <text> in any chat. I will offer \
CAPS, Bold, and Italic transformations.",
)
.await?;
Ok(())
}
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(CommandHandler::new("help", help_command), 0).await;
app.add_handler(FnHandler::on_inline_query(inline_query_handler), 0).await;
println!("Inline bot is running. Press Ctrl+C to stop.");
println!("Remember to enable inline mode with @BotFather!");
app.run_polling().await.unwrap();
}
Tips
- Empty queries. Always check if the query is empty. Users often trigger inline mode accidentally by typing
@botnamewithout a query. - Result limits. Telegram allows up to 50 results per
answer_inline_querycall. - Caching. Telegram caches results for 300 seconds by default. Use the
cache_timebuilder method onanswer_inline_queryto adjust. - Unique IDs. Every result in a single response must have a unique
id. Using the inline query’s ownidas a prefix (e.g.,format!("caps-{}", iq.id)) is a reliable pattern. - HTML escaping. Always escape user input before embedding it in HTML-formatted content to prevent injection.
- Serialization. Each result must be serialized to
serde_json::Valueviaserde_json::to_value()before passing toanswer_inline_query. - 10-second deadline. You must call
answer_inline_querywithin 10 seconds of receiving the query, or Telegram will show an error to the user.
Next Steps
- Payments – accept payments directly through Telegram.
- Inline Keyboards – the related feature for buttons within messages.
Payments
The Telegram Payments API lets your bot accept credit card and other payments directly in chat. You need a payment provider token from BotFather and a provider account (Stripe in test mode works for development).
Getting a Provider Token
- Send
/mybotsto@BotFather, select your bot, then choose Payments. - Pick a provider (Stripe for testing).
- Complete the provider’s onboarding to receive a token like
284685040:TEST:....
Set it as an environment variable: PAYMENT_PROVIDER_TOKEN.
Sending an Invoice
Use bot.send_invoice() to create a payment message in the chat:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{
Application, ApplicationBuilder, Arc, CommandHandler, Context,
FnHandler, HandlerError, HandlerResult, Update,
};
use rust_tg_bot::raw::types::payment::labeled_price::LabeledPrice;
async fn start_callback(update: Arc<Update>, context: Context) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
context
.bot()
.send_message(
chat_id,
"Use /shipping to receive an invoice with shipping, \
or /noshipping for an invoice without shipping.",
)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Prices are always in the smallest currency unit (cents for USD, pence for GBP, etc.).
Invoice with Shipping
When you need to charge different shipping rates based on the destination, set is_flexible(true) and need_shipping_address(true):
#![allow(unused)]
fn main() {
async fn start_with_shipping(
update: Arc<Update>,
context: Context,
provider_token: String,
) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
let prices = vec![
serde_json::to_value(LabeledPrice::new("Test Item", 100))
.expect("price serialization"),
];
context
.bot()
.send_invoice(
chat_id,
"Payment Example", // title
"Example payment process", // description
"Custom-Payload", // your internal payload
"USD", // currency
prices, // price list
)
.provider_token(&provider_token)
.need_name(true)
.need_phone_number(true)
.need_email(true)
.need_shipping_address(true)
.is_flexible(true)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Invoice without Shipping
For digital goods or services that do not require a shipping address:
#![allow(unused)]
fn main() {
async fn start_without_shipping(
update: Arc<Update>,
context: Context,
provider_token: String,
) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
let prices = vec![
serde_json::to_value(LabeledPrice::new("Test Item", 100))
.expect("price serialization"),
];
context
.bot()
.send_invoice(
chat_id,
"Payment Example",
"Example payment process",
"Custom-Payload",
"USD",
prices,
)
.provider_token(&provider_token)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Handling Shipping Queries
When is_flexible is true, Telegram sends a ShippingQuery after the user enters their address. Register a handler with FnHandler::on_shipping_query:
#![allow(unused)]
fn main() {
use rust_tg_bot::raw::types::payment::shipping_option::ShippingOption;
async fn shipping_callback(update: Arc<Update>, context: Context) -> HandlerResult {
let query = update
.shipping_query()
.expect("shipping handler requires shipping_query");
// Verify the payload matches your bot
if query.invoice_payload != "Custom-Payload" {
context
.bot()
.answer_shipping_query(&query.id, false)
.error_message("Something went wrong...")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
return Ok(());
}
// Define available shipping options
let options = vec![
serde_json::to_value(ShippingOption::new(
"1",
"Shipping Option A",
vec![LabeledPrice::new("A", 100)],
)).expect("shipping option serialization"),
serde_json::to_value(ShippingOption::new(
"2",
"Shipping Option B",
vec![LabeledPrice::new("B1", 150), LabeledPrice::new("B2", 200)],
)).expect("shipping option serialization"),
];
context
.bot()
.answer_shipping_query(&query.id, true)
.shipping_options(options)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Register the handler:
#![allow(unused)]
fn main() {
app.add_handler(FnHandler::on_shipping_query(shipping_callback), 0).await;
}
Pre-Checkout Confirmation
Before Telegram charges the user, it sends a PreCheckoutQuery. You must answer within 10 seconds. This is your last chance to validate the order.
#![allow(unused)]
fn main() {
async fn precheckout_callback(update: Arc<Update>, context: Context) -> HandlerResult {
let query = update
.pre_checkout_query()
.expect("pre-checkout handler requires pre_checkout_query");
if query.invoice_payload != "Custom-Payload" {
context
.bot()
.answer_pre_checkout_query(&query.id, false)
.error_message("Something went wrong...")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
} else {
context
.bot()
.answer_pre_checkout_query(&query.id, true)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
}
Ok(())
}
}
Register the handler:
#![allow(unused)]
fn main() {
app.add_handler(FnHandler::on_pre_checkout_query(precheckout_callback), 0).await;
}
Handling Successful Payments
After the charge completes, Telegram sends a regular Message containing a SuccessfulPayment object. Use a FnHandler with a predicate that checks for the successful_payment field:
#![allow(unused)]
fn main() {
async fn successful_payment_callback(
update: Arc<Update>,
context: Context,
) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
context
.bot()
.send_message(chat_id, "Thank you for your payment.")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
// Register with a predicate that checks for successful_payment
app.add_handler(
FnHandler::new(
|u| {
u.effective_message()
.and_then(|m| m.successful_payment.as_ref())
.is_some()
},
successful_payment_callback,
),
0,
).await;
}
LabeledPrice and ShippingOption
LabeledPrice
Represents a single price component. The amount is in the smallest currency unit.
#![allow(unused)]
fn main() {
use rust_tg_bot::raw::types::payment::labeled_price::LabeledPrice;
let item = LabeledPrice::new("Widget", 1500); // $15.00
let shipping = LabeledPrice::new("Shipping", 500); // $5.00
let discount = LabeledPrice::new("Discount", -200); // -$2.00
// Total shown to user: $18.00
}
Negative amounts create discount lines. The final total must be positive.
ShippingOption
Groups one or more LabeledPrice items under a named shipping method:
#![allow(unused)]
fn main() {
use rust_tg_bot::raw::types::payment::shipping_option::ShippingOption;
let standard = ShippingOption::new(
"standard",
"Standard (5-7 days)",
vec![LabeledPrice::new("Standard Shipping", 500)],
);
let express = ShippingOption::new(
"express",
"Express (1-2 days)",
vec![
LabeledPrice::new("Express Shipping", 1500),
LabeledPrice::new("Insurance", 200),
],
);
}
Telegram Stars
Telegram Stars are a digital currency that users can use to pay for digital goods. To accept Stars, use "XTR" as the currency and omit the provider token:
#![allow(unused)]
fn main() {
async fn send_stars_invoice(
update: Arc<Update>,
context: Context,
) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
let prices = vec![
serde_json::to_value(LabeledPrice::new("Premium Access", 100))
.expect("price serialization"),
];
context
.bot()
.send_invoice(
chat_id,
"Premium Access",
"Unlock premium features for your account",
"premium-payload",
"XTR", // Telegram Stars currency code
prices,
)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
With Stars, the provider_token is not required – Telegram handles the payment directly.
Complete Example
use rust_tg_bot::ext::prelude::{
Application, ApplicationBuilder, Arc, CommandHandler, Context,
FnHandler, HandlerError, HandlerResult, MessageEntityType, Update,
};
use rust_tg_bot::raw::types::payment::labeled_price::LabeledPrice;
use rust_tg_bot::raw::types::payment::shipping_option::ShippingOption;
fn check_command(update: &Update, expected: &str) -> bool {
let msg = match update.effective_message() {
Some(m) => m,
None => return false,
};
let text = match msg.text.as_deref() {
Some(t) => t,
None => return false,
};
msg.entities
.as_ref()
.and_then(|e| e.first())
.map_or(false, |e| {
e.entity_type == MessageEntityType::BotCommand
&& e.offset == 0
&& text[1..e.length as usize]
.split('@')
.next()
.unwrap_or("")
.eq_ignore_ascii_case(expected)
})
}
// ... define start_callback, start_with_shipping, start_without_shipping,
// shipping_callback, precheckout_callback, successful_payment_callback
// (as shown above) ...
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let provider_token = std::env::var("PAYMENT_PROVIDER_TOKEN").unwrap();
let app: Arc<Application> = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start_callback), 0).await;
// /shipping command
{
let pt = provider_token.clone();
app.add_handler(
FnHandler::new(
|u| check_command(u, "shipping"),
move |update, ctx| {
let pt = pt.clone();
async move { start_with_shipping(update, ctx, pt).await }
},
),
0,
).await;
}
// /noshipping command
{
let pt = provider_token.clone();
app.add_handler(
FnHandler::new(
|u| check_command(u, "noshipping"),
move |update, ctx| {
let pt = pt.clone();
async move { start_without_shipping(update, ctx, pt).await }
},
),
0,
).await;
}
app.add_handler(FnHandler::on_shipping_query(shipping_callback), 0).await;
app.add_handler(FnHandler::on_pre_checkout_query(precheckout_callback), 0).await;
app.add_handler(
FnHandler::new(
|u| {
u.effective_message()
.and_then(|m| m.successful_payment.as_ref())
.is_some()
},
successful_payment_callback,
),
0,
).await;
println!("Payment bot is running. Press Ctrl+C to stop.");
app.run_polling().await.unwrap();
}
Next Steps
- Inline Mode – inline mode can initiate payment flows.
- Conversations – use a conversation handler for multi-step checkout.
Custom Filters
Filters decide whether a handler should run for a given update. The crate ships filters for common cases (commands, text, media types), but you can write your own for any matching logic.
The Filter Trait
Every filter implements the Filter trait:
#![allow(unused)]
fn main() {
pub trait Filter: Send + Sync + 'static {
fn check_update(&self, update: &Update) -> FilterResult;
fn name(&self) -> &str {
std::any::type_name::<Self>()
}
}
}
Implement it on any type:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{Filter, FilterResult, Update};
pub struct TextLengthFilter {
min: usize,
max: usize,
}
impl TextLengthFilter {
pub fn new(min: usize, max: usize) -> Self {
Self { min, max }
}
}
impl Filter for TextLengthFilter {
fn check_update(&self, update: &Update) -> FilterResult {
let text = update
.effective_message()
.and_then(|m| m.text.as_deref());
match text {
Some(t) if t.len() >= self.min && t.len() <= self.max => {
FilterResult::Match
}
_ => FilterResult::NoMatch,
}
}
fn name(&self) -> &str {
"TextLengthFilter"
}
}
}
FilterResult
FilterResult has three variants:
| Variant | Meaning |
|---|---|
FilterResult::NoMatch | The filter did not match – handler is skipped |
FilterResult::Match | The filter matched – handler runs |
FilterResult::MatchWithData(HashMap<String, Vec<String>>) | Matched and carries extracted data |
Filters must not perform I/O or fail. They receive a &Update reference and return a pure result.
MatchWithData
Data filters pass extracted information directly to the handler, avoiding redundant parsing. For example, a regex filter uses this to pass capture groups:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use rust_tg_bot::ext::prelude::{Filter, FilterResult, Update};
pub struct HashtagFilter;
impl Filter for HashtagFilter {
fn check_update(&self, update: &Update) -> FilterResult {
let text = update
.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("");
let tags: Vec<String> = text
.split_whitespace()
.filter(|w| w.starts_with('#'))
.map(|w| w.to_string())
.collect();
if tags.is_empty() {
FilterResult::NoMatch
} else {
let mut data = HashMap::new();
data.insert("hashtags".to_string(), tags);
FilterResult::MatchWithData(data)
}
}
fn name(&self) -> &str {
"HashtagFilter"
}
}
}
The handler can access this data through context.matches.
Composing Filters with Operators
The F wrapper provides bitwise operators for combining filters:
| Operator | Meaning | Example |
|---|---|---|
& | AND – both must match | TEXT() & !COMMAND() |
| | OR – either can match | PHOTO | VIDEO |
^ | XOR – exactly one must match | filter_a ^ filter_b |
! | NOT – inverts the match | !COMMAND() |
Using F to Wrap Custom Filters
Wrap your custom filter in F to use the operators:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{F, MessageHandler, TEXT, COMMAND};
let length_filter = F::new(TextLengthFilter::new(1, 100));
let combined = TEXT() & !COMMAND() & length_filter;
app.add_handler(
MessageHandler::new(combined, my_handler), 0,
).await;
}
How Composition Works Internally
AndFilter: checks left first. If it returnsNoMatch, short-circuits. Otherwise checks right and merges data from both results.OrFilter: checks left first. If it matches, returns immediately. Otherwise checks right.NotFilter: invertsMatchtoNoMatchand vice versa. Data is lost on inversion.XorFilter: matches only when exactly one side matches.
FnFilter for Closures
For one-off filters that do not warrant their own struct, use FnFilter:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{F, Filter, FilterResult};
use rust_tg_bot::ext::prelude::Update;
// Import FnFilter from the filters module
use rust_tg_bot::ext::filters::base::FnFilter;
let admin_only = FnFilter::new("admin_only", |update: &Update| {
update
.effective_user()
.map(|u| u.id == 123456789)
.unwrap_or(false)
});
let filter = F::new(admin_only);
}
FnFilter::new takes a label (used for debug output) and a closure Fn(&Update) -> bool. The closure is wrapped so true becomes FilterResult::Match and false becomes FilterResult::NoMatch.
Stateful Filters
Filters can hold state. Since they must be Send + Sync, use thread-safe wrappers:
#![allow(unused)]
fn main() {
use std::collections::HashSet;
use std::sync::Mutex;
use rust_tg_bot::ext::prelude::{Filter, FilterResult, Update};
pub struct AllowlistFilter {
allowed: Mutex<HashSet<i64>>,
}
impl AllowlistFilter {
pub fn new(initial: impl IntoIterator<Item = i64>) -> Self {
Self {
allowed: Mutex::new(initial.into_iter().collect()),
}
}
pub fn add(&self, user_id: i64) {
self.allowed.lock().unwrap().insert(user_id);
}
pub fn remove(&self, user_id: i64) {
self.allowed.lock().unwrap().remove(&user_id);
}
}
impl Filter for AllowlistFilter {
fn check_update(&self, update: &Update) -> FilterResult {
let user_id = update.effective_user().map(|u| u.id);
match user_id {
Some(id) if self.allowed.lock().unwrap().contains(&id) => {
FilterResult::Match
}
_ => FilterResult::NoMatch,
}
}
fn name(&self) -> &str {
"AllowlistFilter"
}
}
}
Use std::sync::Mutex (not tokio::sync::Mutex) because check_update is synchronous and filters must not be async.
Built-in Filters
The crate ships a comprehensive set of filters. Here are the most common ones:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{TEXT, COMMAND, F};
use rust_tg_bot::ext::filters::base::{ALL, PHOTO, VIDEO, AUDIO, VOICE, LOCATION, CONTACT};
use rust_tg_bot::ext::filters::chat::{ChatTypePrivate, ChatTypeGroup};
use rust_tg_bot::ext::filters::text::{CAPTION, TextFilter, CaptionFilter};
use rust_tg_bot::ext::filters::regex::RegexFilter;
use rust_tg_bot::ext::filters::user::UserFilter;
// Text and commands
TEXT() // Any text message
COMMAND() // Any bot command
F::new(ALL) // Any message
// Media types
F::new(PHOTO) // Photo messages
F::new(VIDEO) // Video messages
F::new(AUDIO) // Audio files
F::new(VOICE) // Voice messages
// Chat types
F::new(ChatTypePrivate) // Private chats only
// Regex matching
F::new(RegexFilter::new(r"^\d+$").unwrap()) // Matches digits only
// Specific users
F::new(UserFilter::new(vec![123456789])) // Specific user IDs
}
Next Steps
- Error Handling – what happens when a filtered handler’s async body fails.
- Testing – unit-test your custom filters without a live bot connection.
Error Handling
Every handler returns HandlerResult, which is Result<(), HandlerError>. When a handler returns Err, the application dispatches the error to any registered error handlers instead of crashing.
HandlerError
HandlerError wraps any error type. The Other variant holds a Box<dyn std::error::Error + Send + Sync>:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::HandlerError;
// From any std error type
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
let e = HandlerError::Other(Box::new(io_err));
}
Inside handlers, the ? operator on Telegram API calls automatically converts errors to HandlerError. For other error types, use .map_err:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{Arc, Context, HandlerError, HandlerResult, Update};
async fn handler(update: Arc<Update>, context: Context) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap();
// Telegram errors convert directly with ?
context.reply_text(&update, "Hello!").await?;
// Other errors need .map_err
let _data = std::fs::read_to_string("config.toml")
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Deliberately Triggering Errors
Sometimes you want to signal an error condition from a handler:
#![allow(unused)]
fn main() {
async fn bad_command(
_update: Arc<Update>,
_context: Context,
) -> HandlerResult {
Err(HandlerError::Other(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"This is a deliberately triggered error from /bad_command!",
))))
}
}
Registering an Error Handler
app.add_error_handler registers a callback that runs whenever any handler returns Err:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CallbackContext, Context, HandlerError,
HandlerResult, CommandHandler, Update,
};
async fn error_handler(
update: Option<Arc<Update>>,
context: CallbackContext,
) -> bool {
let error_text = context
.error
.as_ref()
.map(|e| format!("{e}"))
.unwrap_or_else(|| "Unknown error".to_string());
tracing::error!("Exception while handling an update: {error_text}");
// Build diagnostic info
let update_str = update
.as_ref()
.map(|u| format!("{u:?}"))
.unwrap_or_else(|| "No update".to_string());
let chat_data_str = context
.chat_data()
.await
.map(|d| format!("{d:?}"))
.unwrap_or_else(|| "None".to_string());
let user_data_str = context
.user_data()
.await
.map(|d| format!("{d:?}"))
.unwrap_or_else(|| "None".to_string());
// Truncate to respect the 4096-char Telegram limit
let message = format!(
"An exception was raised while handling an update\n\n\
update = {update_str}\n\n\
chat_data = {chat_data_str}\n\n\
user_data = {user_data_str}\n\n\
error = {error_text}"
);
let message = if message.len() > 4000 {
format!("{}...(truncated)", &message[..4000])
} else {
message
};
let dev_chat_id: i64 = std::env::var("DEVELOPER_CHAT_ID")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
if dev_chat_id != 0 {
let _ = context
.bot()
.send_message(dev_chat_id, &message)
.await;
}
// Return false so other error handlers (if any) can also run
false
}
}
Error Handler Signature
The error handler signature is:
#![allow(unused)]
fn main() {
async fn error_handler(
update: Option<Arc<Update>>,
context: CallbackContext,
) -> bool
}
update– the update that caused the error, if available. It isNonefor errors that occur outside of update processing.context– aCallbackContextthat provides access to the bot, user/chat data, and theerrorfield.- Returns
bool– returnfalseto allow other error handlers to run;trueto stop the chain.
Registering the Handler
Error handlers must be wrapped in an Arc and pinned:
#![allow(unused)]
fn main() {
app.add_error_handler(
Arc::new(|update, ctx| Box::pin(error_handler(update, ctx))),
true, // block: whether to wait for the handler to complete
).await;
}
Accessing Error Details
The error is available on context.error:
#![allow(unused)]
fn main() {
async fn error_handler(
update: Option<Arc<Update>>,
context: CallbackContext,
) -> bool {
if let Some(ref err) = context.error {
tracing::error!("Handler error: {err}");
}
// You can also access data stores for diagnostics
if let Some(user_data) = context.user_data().await {
tracing::debug!("User data at time of error: {user_data:?}");
}
false
}
}
Combining with Typed Errors
Define a domain error type with thiserror for structured error handling:
#![allow(unused)]
fn main() {
use thiserror::Error;
use rust_tg_bot::ext::prelude::HandlerError;
#[derive(Debug, Error)]
pub enum BotError {
#[error("user {user_id} is not authorised for {action}")]
Unauthorised { user_id: i64, action: String },
#[error("database error: {0}")]
Database(String),
#[error("invalid input: {0}")]
InvalidInput(String),
}
// Convert to HandlerError
impl From<BotError> for HandlerError {
fn from(e: BotError) -> Self {
HandlerError::Other(Box::new(e))
}
}
}
Use it in handlers:
#![allow(unused)]
fn main() {
async fn restricted(update: Arc<Update>, context: Context) -> HandlerResult {
let user = update
.effective_user()
.ok_or_else(|| BotError::Unauthorised {
user_id: 0,
action: "restricted".into(),
})?;
if user.id != 123456789 {
return Err(BotError::Unauthorised {
user_id: user.id,
action: "restricted".into(),
}.into());
}
context.reply_text(&update, "Access granted.").await?;
Ok(())
}
}
Best Practices
Always Answer Callback Queries
If your handler for a callback query fails before calling answer_callback_query, the user sees a perpetual loading spinner. Use the error handler to clean up:
#![allow(unused)]
fn main() {
async fn error_handler(
update: Option<Arc<Update>>,
context: CallbackContext,
) -> bool {
// If the errored update was a callback query, answer it
if let Some(ref u) = update {
if let Some(cq) = u.callback_query() {
let _ = context
.bot()
.answer_callback_query(&cq.id)
.await;
}
}
// Log the error
if let Some(ref err) = context.error {
tracing::error!("Handler error: {err}");
}
false
}
}
Avoid unwrap in Handlers
Panics inside handlers are caught by the async runtime, but they produce opaque error messages. Prefer ok_or and ?:
#![allow(unused)]
fn main() {
// Avoid:
let msg = update.effective_message().unwrap();
// Prefer:
let msg = update.effective_message().ok_or_else(|| {
HandlerError::Other("update has no message".into())
})?;
}
Or simply return early:
#![allow(unused)]
fn main() {
let Some(msg) = update.effective_message() else {
return Ok(());
};
}
Complete Example
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CallbackContext, CommandHandler, Context,
HandlerError, HandlerResult, Update,
};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
let chat_id = update.effective_chat().map(|c| c.id).unwrap_or(0);
context
.reply_text(
&update,
&format!(
"Use /bad_command to cause an error.\nYour chat id is {}.",
chat_id,
),
)
.await?;
Ok(())
}
async fn bad_command(
_update: Arc<Update>,
_context: Context,
) -> HandlerResult {
Err(HandlerError::Other(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"This is a deliberately triggered error!",
))))
}
async fn on_error(
update: Option<Arc<Update>>,
context: CallbackContext,
) -> bool {
let error_text = context
.error
.as_ref()
.map(|e| format!("{e}"))
.unwrap_or_else(|| "Unknown error".to_string());
tracing::error!("Error: {error_text}");
let dev_id: i64 = std::env::var("DEVELOPER_CHAT_ID")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
if dev_id != 0 {
let msg = format!("Error: {error_text}");
let _ = context.bot().send_message(dev_id, &msg).await;
}
false
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(CommandHandler::new("bad_command", bad_command), 0).await;
app.add_error_handler(
Arc::new(|update, ctx| Box::pin(on_error(update, ctx))),
true,
).await;
println!("Error handler bot is running. Press Ctrl+C to stop.");
app.run_polling().await.unwrap();
}
Next Steps
- Custom Filters – prevent errors by filtering updates before they reach handlers.
- Testing – assert that your error handler is invoked correctly.
Nested Conversations
Nested conversations let you build multi-level state machines where selecting an option in one conversation enters a child conversation, and finishing the child returns control to the parent. This is useful for complex data collection flows.
Architecture
A nested conversation is modelled as a state machine with multiple levels:
Level 1 (Top): [Add member] [Add self] [Show data] [Done]
|
Level 2 (Member): [Add parent] [Add child] [Show data] [Back]
|
Level 3 (Features): [Name] [Age] [Done]
Each level has its own set of states. Transitioning “down” enters a child level. Transitioning “up” returns to the parent via state restoration.
Defining States
Use enums to model each level:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TopState {
SelectingAction,
AddingMember,
DescribingSelf,
ShowingData,
Stopped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MemberState {
SelectingLevel,
SelectingGender,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FeatureState {
SelectingFeature,
Typing,
}
}
Combine them into a single discriminated state:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, Eq)]
enum ConvState {
Top(TopState),
Member(MemberState),
Feature(FeatureState),
End,
}
impl Default for ConvState {
fn default() -> Self {
ConvState::End
}
}
}
Shared State Store
Store per-user conversation state in a thread-safe map:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{Arc, HashMap, RwLock};
#[derive(Debug, Clone, Default)]
struct PersonInfo {
gender: Option<String>,
name: Option<String>,
age: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct UserState {
conv: ConvState,
current_level: String,
current_feature: String,
current_person: PersonInfo,
family: HashMap<String, Vec<PersonInfo>>,
}
type StateStore = Arc<RwLock<HashMap<i64, UserState>>>;
}
State-Based Predicates
Each handler fires only when the user is in the correct state. Write predicate functions that check the store:
#![allow(unused)]
fn main() {
fn is_in_state(store: &StateStore, user_id: i64, expected: &ConvState) -> bool {
store
.try_read()
.map(|guard| {
guard
.get(&user_id)
.map(|us| &us.conv == expected)
.unwrap_or(false)
})
.unwrap_or(false)
}
fn is_callback_in_top_state(update: &Update, store: &StateStore) -> bool {
if update.callback_query().is_none() {
return false;
}
let user_id = match update.effective_user() {
Some(u) => u.id,
None => return false,
};
is_in_state(store, user_id, &ConvState::Top(TopState::SelectingAction))
|| is_in_state(store, user_id, &ConvState::Top(TopState::ShowingData))
}
}
Use try_read() instead of .read().await because predicates are synchronous – they run inside the FnHandler’s filter closure, which cannot be async.
Entering a Child Level
When the user selects “Add member” at the top level, transition to the member level:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::{
Arc, Context, HandlerError, HandlerResult, InlineKeyboardButton,
InlineKeyboardMarkup, Update,
};
async fn handle_top_action(
update: Arc<Update>,
context: Context,
store: StateStore,
) -> HandlerResult {
let user_id = update.effective_user().unwrap().id;
let cq = update.callback_query().expect("must have callback_query");
let data = cq.data.as_deref().unwrap_or("");
context
.bot()
.answer_callback_query(&cq.id)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
let msg = cq.message.as_deref().expect("must have message");
let chat_id = msg.chat().id;
match data {
"add_member" => {
// Transition DOWN to Member level
let mut s = store.write().await;
let us = s.entry(user_id).or_default();
us.conv = ConvState::Member(MemberState::SelectingLevel);
let keyboard = serde_json::to_value(InlineKeyboardMarkup::new(vec![
vec![
InlineKeyboardButton::callback("Add parent", "parents"),
InlineKeyboardButton::callback("Add child", "children"),
],
vec![
InlineKeyboardButton::callback("Show data", "show"),
InlineKeyboardButton::callback("Back", "back"),
],
])).unwrap();
context
.bot()
.edit_message_text("Choose a member type or go back.")
.chat_id(chat_id)
.message_id(msg.message_id())
.reply_markup(keyboard)
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
}
// ... handle other top-level actions ...
_ => {}
}
Ok(())
}
}
Returning to a Parent Level
When the user presses “Back” or “Done” at a child level, restore the parent state:
#![allow(unused)]
fn main() {
// Inside the member-level handler:
"back" => {
// Transition UP to Top level
let mut s = store.write().await;
let us = s.entry(user_id).or_default();
us.conv = ConvState::Top(TopState::SelectingAction);
drop(s);
context
.bot()
.edit_message_text("Choose an action.")
.chat_id(chat_id)
.message_id(msg.message_id())
.reply_markup(top_menu_keyboard())
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
}
}
When “Done” is pressed in the feature level, save the collected data and return to either the member level (for family members) or the top level (for self):
#![allow(unused)]
fn main() {
"done" => {
let mut s = store.write().await;
let us = s.entry(user_id).or_default();
let level = us.current_level.clone();
let person = us.current_person.clone();
us.family.entry(level.clone()).or_default().push(person);
us.current_person = PersonInfo::default();
if level == "self" {
// Return to top level
us.conv = ConvState::Top(TopState::SelectingAction);
} else {
// Return to member level
us.conv = ConvState::Member(MemberState::SelectingLevel);
}
}
}
Handling Free-Text Input
When the user is in a “typing” state, text messages should be captured as feature values instead of being treated as commands:
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::prelude::MessageEntityType;
fn is_text_in_typing_state(update: &Update, store: &StateStore) -> bool {
let msg = match update.effective_message() {
Some(m) => m,
None => return false,
};
if msg.text.is_none() {
return false;
}
// Exclude commands
let is_cmd = msg
.entities
.as_ref()
.and_then(|ents| ents.first())
.map(|e| {
e.entity_type == MessageEntityType::BotCommand && e.offset == 0
})
.unwrap_or(false);
if is_cmd {
return false;
}
let user_id = match update.effective_user() {
Some(u) => u.id,
None => return false,
};
is_in_state(store, user_id, &ConvState::Feature(FeatureState::Typing))
}
async fn handle_text_input(
update: Arc<Update>,
context: Context,
store: StateStore,
) -> HandlerResult {
let user_id = update.effective_user().unwrap().id;
let chat_id = update.effective_chat().unwrap().id;
let text = update
.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("")
.to_string();
let mut s = store.write().await;
let us = s.entry(user_id).or_default();
match us.current_feature.as_str() {
"name" => us.current_person.name = Some(text),
"age" => us.current_person.age = Some(text),
_ => {}
}
us.conv = ConvState::Feature(FeatureState::SelectingFeature);
drop(s);
context
.bot()
.send_message(chat_id, "Got it! Please select a feature to update.")
.reply_markup(feature_keyboard())
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Registering Handlers with Shared State
Each handler needs its own clones of the store for both the predicate and the handler body:
use rust_tg_bot::ext::prelude::{
Application, ApplicationBuilder, Arc, FnHandler, HashMap, RwLock,
};
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app: Arc<Application> = ApplicationBuilder::new().token(token).build();
let store: StateStore = Arc::new(RwLock::new(HashMap::new()));
// Top-level callback handler
{
let s = Arc::clone(&store);
let s_check = Arc::clone(&store);
app.add_handler(
FnHandler::new(
move |u| is_callback_in_top_state(u, &s_check),
move |update, ctx| {
let s = Arc::clone(&s);
async move { handle_top_action(update, ctx, s).await }
},
),
1,
).await;
}
// Text input handler
{
let s = Arc::clone(&store);
let s_check = Arc::clone(&store);
app.add_handler(
FnHandler::new(
move |u| is_text_in_typing_state(u, &s_check),
move |update, ctx| {
let s = Arc::clone(&s);
async move { handle_text_input(update, ctx, s).await }
},
),
1,
).await;
}
// ... register other level handlers in group 1 ...
app.run_polling().await.unwrap();
}
The pattern for each handler is:
- Clone the store twice – once for the predicate closure, once for the handler closure.
- The predicate closure captures
s_checkand callsis_in_state. - The handler closure captures
s, clones it into the async block, and passes it to the handler function. - Register all conversation handlers in group 1 (or higher) so command handlers in group 0 get priority.
Timeout Handling
Add a /stop command that resets the conversation from any level:
#![allow(unused)]
fn main() {
async fn stop_command(
update: Arc<Update>,
context: Context,
store: StateStore,
) -> HandlerResult {
let chat_id = update.effective_chat().unwrap().id;
let user_id = update.effective_user().unwrap().id;
{
let mut s = store.write().await;
let us = s.entry(user_id).or_default();
us.conv = ConvState::End;
}
context
.bot()
.send_message(chat_id, "Okay, bye.")
.await
.map_err(|e| HandlerError::Other(Box::new(e)))?;
Ok(())
}
}
Register it in group 0 so it takes priority over conversation handlers:
#![allow(unused)]
fn main() {
{
let s = Arc::clone(&store);
app.add_handler(
FnHandler::new(
|u| check_command(u, "stop"),
move |update, ctx| {
let s = Arc::clone(&s);
async move { stop_command(update, ctx, s).await }
},
),
0,
).await;
}
}
Design Tips
- Group ordering matters. Register
/startand/stopin group 0 so they always fire. Register conversation handlers in group 1. - Use
try_readin predicates. Predicates are synchronous. Using.read().awaitwould require an async context that is not available during filter evaluation. - Drop write guards early. Call
drop(s)after modifying state and before making async API calls to avoid holding the lock acrossawaitpoints. - Store per-user, not per-chat. In group chats, multiple users might have independent conversations. Key the store by user ID.
- Combine with persistence. For conversations that should survive restarts, store the conversation state in
user_dataviacontext.set_user_data()instead of an in-memoryHashMap.
Next Steps
- Conversations – simpler single-level conversations.
- Persistence – persist conversation state across restarts.
Testing
Testing your bot ensures that handlers behave correctly, filters match the right updates, and persistence stores data as expected. This chapter covers strategies from unit testing individual components to integration testing with real persistence backends.
Unit Testing Filters
Filters are pure functions of &Update -> FilterResult. Test them without a running bot by constructing Update values from JSON:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use rust_tg_bot::ext::prelude::{Filter, FilterResult, Update};
fn make_update(json_val: serde_json::Value) -> Update {
serde_json::from_value(json_val).unwrap()
}
fn text_message_update(text: &str) -> Update {
make_update(json!({
"update_id": 1,
"message": {
"message_id": 1,
"date": 0,
"chat": { "id": 1, "type": "private" },
"from": { "id": 1, "is_bot": false, "first_name": "Test" },
"text": text
}
}))
}
#[test]
fn text_length_filter_accepts_valid() {
let filter = TextLengthFilter::new(1, 100);
let update = text_message_update("Hello!");
assert!(filter.check_update(&update).is_match());
}
#[test]
fn text_length_filter_rejects_empty() {
let filter = TextLengthFilter::new(1, 100);
let update = text_message_update("");
assert!(!filter.check_update(&update).is_match());
}
#[test]
fn text_length_filter_rejects_too_long() {
let filter = TextLengthFilter::new(1, 5);
let update = text_message_update("This is too long");
assert!(!filter.check_update(&update).is_match());
}
}
}
Testing Built-in Filters
The same approach works for built-in filters:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use rust_tg_bot::ext::prelude::{TEXT, COMMAND};
#[test]
fn text_filter_matches_text_messages() {
let update = text_message_update("hello");
assert!(TEXT().0.check_update(&update).is_match());
}
#[test]
fn command_filter_matches_commands() {
let update = make_update(json!({
"update_id": 1,
"message": {
"message_id": 1,
"date": 0,
"chat": { "id": 1, "type": "private" },
"from": { "id": 1, "is_bot": false, "first_name": "Test" },
"text": "/start",
"entities": [{
"type": "bot_command",
"offset": 0,
"length": 6
}]
}
}));
assert!(COMMAND().0.check_update(&update).is_match());
}
}
}
Testing Filter Composition
Verify that combined filters behave correctly:
#![allow(unused)]
fn main() {
#[test]
fn text_and_not_command() {
let filter = TEXT() & !COMMAND();
// Plain text should match
let text_update = text_message_update("hello");
assert!(filter.0.check_update(&text_update).is_match());
// Command should not match
let cmd_update = make_update(json!({
"update_id": 1,
"message": {
"message_id": 1,
"date": 0,
"chat": { "id": 1, "type": "private" },
"from": { "id": 1, "is_bot": false, "first_name": "Test" },
"text": "/start",
"entities": [{ "type": "bot_command", "offset": 0, "length": 6 }]
}
}));
assert!(!filter.0.check_update(&cmd_update).is_match());
}
}
Testing Handler Logic
Handlers contain your business logic. Test the logic by extracting it into pure functions:
#![allow(unused)]
fn main() {
// In your bot code:
fn format_greeting(first_name: &str, visit_count: i64) -> String {
if visit_count == 0 {
format!("Welcome, {first_name}!")
} else {
format!("Welcome back, {first_name}! Visit #{}", visit_count + 1)
}
}
// In your tests:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_first_visit() {
assert_eq!(format_greeting("Alice", 0), "Welcome, Alice!");
}
#[test]
fn greeting_return_visit() {
assert_eq!(
format_greeting("Bob", 4),
"Welcome back, Bob! Visit #5"
);
}
}
}
This pattern – extract logic into testable functions, call them from handlers – is the most reliable way to test bot behaviour without mocking the Telegram API.
Testing Persistence
In-Memory SQLite
Use SqlitePersistence::in_memory() to test persistence logic without touching the filesystem:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use rust_tg_bot::ext::persistence::sqlite::SqlitePersistence;
use rust_tg_bot::ext::persistence::base::BasePersistence;
use std::collections::HashMap;
#[tokio::test]
async fn sqlite_round_trip() {
let persistence = SqlitePersistence::in_memory()
.expect("failed to create in-memory SQLite");
// Write user data
let mut data = HashMap::new();
data.insert("name".to_string(), serde_json::json!("Alice"));
persistence.update_user_data(42, &data).await.unwrap();
// Read it back
let all_users = persistence.get_user_data().await.unwrap();
let user_42 = all_users.get(&42).unwrap();
assert_eq!(
user_42.get("name").and_then(|v| v.as_str()),
Some("Alice"),
);
}
}
}
Temporary JSON Files
For JsonFilePersistence, use a temporary directory:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use rust_tg_bot::ext::persistence::json_file::JsonFilePersistence;
use rust_tg_bot::ext::persistence::base::BasePersistence;
#[tokio::test]
async fn json_file_round_trip() {
let dir = tempfile::tempdir().unwrap();
let prefix = dir
.path()
.join("test_bot")
.to_string_lossy()
.to_string();
let persistence = JsonFilePersistence::new(&prefix, true, false);
let mut data = std::collections::HashMap::new();
data.insert("key".to_string(), serde_json::json!("value"));
persistence.update_bot_data(&data).await.unwrap();
persistence.flush().await.unwrap();
let loaded = persistence.get_bot_data().await.unwrap();
assert_eq!(
loaded.get("key").and_then(|v| v.as_str()),
Some("value"),
);
}
}
}
Testing Conversation State
Test conversation state transitions by directly manipulating the state store:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn state_transitions() {
let store: StateStore = Arc::new(RwLock::new(HashMap::new()));
// Simulate /start
{
let mut s = store.write().await;
s.insert(1, UserState {
conv: ConvState::Top(TopState::SelectingAction),
..Default::default()
});
}
// Verify state
{
let s = store.read().await;
assert_eq!(
s.get(&1).unwrap().conv,
ConvState::Top(TopState::SelectingAction),
);
}
// Simulate transition to member level
{
let mut s = store.write().await;
let us = s.get_mut(&1).unwrap();
us.conv = ConvState::Member(MemberState::SelectingLevel);
}
// Verify transition
{
let s = store.read().await;
assert_eq!(
s.get(&1).unwrap().conv,
ConvState::Member(MemberState::SelectingLevel),
);
}
}
}
}
Integration Testing Patterns
For full integration tests that exercise the entire handler pipeline, you have two options.
1. Build the Application but Do Not Run It
Create the Application, register handlers, but do not call run_polling(). You can verify handler registration and filter matching:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn integration_test_setup() {
let token = "fake-token-for-testing";
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(
MessageHandler::new(TEXT() & !COMMAND(), echo), 0,
).await;
// The app is configured but not connected to Telegram.
// Verify handler registration, filter composition, etc.
}
}
2. Test with Fake Updates
Feed hand-crafted updates through the update sender to test the full pipeline:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_update_processing() {
let token = "fake-token-for-testing";
let app = ApplicationBuilder::new().token(token).build();
// Register handlers...
app.initialize().await.unwrap();
app.start().await.unwrap();
// Send a fake update through the channel
let raw_update = serde_json::from_value(json!({
"update_id": 1,
"message": {
"message_id": 1,
"date": 0,
"chat": { "id": 1, "type": "private" },
"from": { "id": 1, "is_bot": false, "first_name": "Test" },
"text": "hello"
}
})).unwrap();
app.update_sender().send(raw_update).await.unwrap();
// Allow time for processing
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
app.stop().await.ok();
}
}
Note: API calls will fail without a real token, but this tests the dispatch pipeline.
Test Organisation
A recommended project layout for tests:
crates/
my-bot/
src/
main.rs
handlers.rs # Handler functions
filters.rs # Custom filters
logic.rs # Pure business logic
tests/
filters_test.rs # Unit tests for filters
logic_test.rs # Unit tests for business logic
persistence_test.rs # Integration tests for persistence
Keep handler functions thin – they should extract data from the update, call business logic functions, and send responses. Test the business logic functions directly.
Tips
- Use
serde_json::json!to construct updates. It is the fastest way to create test fixtures. - Test filters independently. Filters are the easiest part of the bot to test thoroughly.
- Test state machines with direct store manipulation. You do not need a running bot to verify state transitions.
- Use
#[tokio::test]for async tests. The persistence backends require an async runtime. - Use
tempfilefor filesystem tests. It automatically cleans up temporary directories. - Keep business logic pure. Functions that take plain values and return plain values are trivial to test.
Next Steps
- Deployment – deploy your tested bot to production.
- Custom Filters – write filters that are easy to test in isolation.
Deployment
This chapter covers everything you need to deploy a rust-tg-bot application to production: release builds, containerisation, systemd service management, and operational concerns.
Release Builds
Always deploy release builds. Debug builds are significantly larger and slower.
cargo build --release -p rust-tg-bot --example my_bot
The binary lands in target/release/examples/my_bot.
Binary Size Optimisation
Add these settings to your workspace Cargo.toml for smaller binaries:
[profile.release]
lto = true # Link-time optimisation
codegen-units = 1 # Single codegen unit for better optimisation
strip = true # Strip debug symbols
opt-level = "z" # Optimise for size (use "3" for speed)
panic = "abort" # Smaller binary, no unwinding overhead
With these settings a typical bot binary compiles to approximately 6.2 MB (stripped) — smaller than teloxide at 6.6 MB. See the benchmarks for measured numbers.
Feature Flags
Only enable the features you use. Each feature pulls in additional dependencies:
[dependencies]
rust-tg-bot = { version = "1.0.0-rc.1", features = ["persistence-sqlite", "webhooks"] }
Available features:
| Feature | Dependencies Added |
|---|---|
webhooks | axum, hyper |
job-queue | tokio-cron-scheduler |
persistence-json | (minimal – serde_json already present) |
persistence-sqlite | rusqlite (with bundled SQLite) |
rate-limiter | governor |
Docker
Minimal Dockerfile
Use a multi-stage build to keep the final image small:
# Build stage
FROM rust:1.83-slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release -p rust-tg-bot --example my_bot \
--features "persistence-sqlite,webhooks"
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/examples/my_bot /usr/local/bin/my_bot
ENV RUST_LOG=info
ENTRYPOINT ["my_bot"]
Docker Compose
version: "3.8"
services:
bot:
build: .
restart: unless-stopped
environment:
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN}"
WEBHOOK_URL: "https://bot.example.com"
WEBHOOK_SECRET: "${WEBHOOK_SECRET}"
RUST_LOG: "info"
ports:
- "8443:8443"
volumes:
- bot-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8443/healthcheck"]
interval: 30s
timeout: 5s
retries: 3
volumes:
bot-data:
Persistence in Docker
When using JsonFilePersistence or SqlitePersistence, mount a volume so data survives container restarts:
#![allow(unused)]
fn main() {
let persistence = SqlitePersistence::open("/data/bot.db")
.expect("failed to open database");
}
Map /data to a Docker volume as shown in the compose file above.
systemd
For bare-metal or VM deployments, run the bot as a systemd service.
Service File
Create /etc/systemd/system/rust-tg-bot.service:
[Unit]
Description=Telegram Bot
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=bot
Group=bot
WorkingDirectory=/opt/rust-tg-bot
ExecStart=/opt/rust-tg-bot/my_bot
Restart=always
RestartSec=5
# Environment
EnvironmentFile=/opt/rust-tg-bot/.env
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/rust-tg-bot/data
PrivateTmp=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=rust-tg-bot
[Install]
WantedBy=multi-user.target
Environment File
Create /opt/rust-tg-bot/.env:
TELEGRAM_BOT_TOKEN=your-token-here
WEBHOOK_URL=https://bot.example.com
WEBHOOK_SECRET=your-secret-here
RUST_LOG=info
Managing the Service
# Enable on boot
sudo systemctl enable rust-tg-bot
# Start
sudo systemctl start rust-tg-bot
# Check status
sudo systemctl status rust-tg-bot
# View logs
sudo journalctl -u rust-tg-bot -f
# Restart after deploy
sudo systemctl restart rust-tg-bot
Webhook vs Polling in Production
When to Use Polling
- Simple bots with low traffic.
- Development and staging environments.
- Deployments without a public IP or domain.
- When you cannot provision TLS certificates.
When to Use Webhooks
- Production bots handling more than a few messages per second.
- Serverless or container environments where idle resource usage matters.
- When you need the lowest possible latency.
- When you already run a web server alongside the bot.
Switching Modes by Environment
#[tokio::main]
async fn main() {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap();
let app = ApplicationBuilder::new().token(token).build();
// ... register handlers ...
if let Ok(webhook_url) = std::env::var("WEBHOOK_URL") {
let config = WebhookConfig {
listen: "0.0.0.0".into(),
port: std::env::var("PORT")
.unwrap_or_else(|_| "8443".into())
.parse()
.unwrap(),
webhook_url: Some(webhook_url),
secret_token: std::env::var("WEBHOOK_SECRET").ok(),
..Default::default()
};
app.run_webhook(config).await.unwrap();
} else {
println!("No WEBHOOK_URL set, falling back to polling.");
app.run_polling().await.unwrap();
}
}
Monitoring with tracing
The framework uses the tracing crate internally. Configure a subscriber in main to capture structured logs:
#[tokio::main]
async fn main() {
// Basic stderr logging
tracing_subscriber::fmt::init();
// Or with environment filter
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env(),
)
.json() // JSON output for log aggregation
.init();
// ...
}
Set the log level via the RUST_LOG environment variable:
RUST_LOG=info # Application info and above
RUST_LOG=rust_tg_bot=debug # Debug logs from the framework
RUST_LOG=my_bot=trace,info # Trace for your code, info for everything else
Key Events to Monitor
| Event | What to Watch |
|---|---|
| Handler errors | Spikes indicate bugs or API issues |
| Update processing time | Latency degradation |
| Webhook delivery failures | Network or TLS problems |
| Persistence flush failures | Disk space or database issues |
| Rate limit responses (HTTP 429) | Too many API calls |
Graceful Shutdown
The framework handles SIGTERM and SIGINT (Ctrl+C) automatically when using run_polling() or run_webhook(). For custom servers, handle shutdown explicitly:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use tokio::sync::Notify;
let stop = Arc::new(Notify::new());
let stop_signal = Arc::clone(&stop);
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
tracing::info!("Received shutdown signal");
stop_signal.notify_waiters();
});
// In your server loop:
let shutdown = Arc::clone(&stop);
axum::serve(listener, router)
.with_graceful_shutdown(async move {
shutdown.notified().await;
})
.await
.unwrap();
// Teardown
app.stop().await.ok();
app.shutdown().await.ok();
}
Production Checklist
- Build in release mode with LTO and stripping enabled.
- Set a webhook secret to prevent spoofed updates.
- Use persistence (
SqlitePersistenceorJsonFilePersistence) so data survives restarts. - Configure
tracingwith structured logging and an appropriate log level. - Run behind a reverse proxy (nginx, Caddy) for TLS termination and certificate renewal.
- Set up monitoring – alerting on handler errors and latency spikes.
- Use systemd or Docker for process management and automatic restarts.
- Mount a persistent volume if using file-based persistence in Docker.
- Enable only the features you need to minimise binary size and attack surface.
- Test your shutdown path – verify that persistence is flushed on
SIGTERM.
Next Steps
- Webhooks – detailed webhook configuration.
- Error Handling – production error handling strategies.
- Testing – test before you deploy.
From python-telegram-bot
This guide helps developers migrating from python-telegram-bot to rust-tg-bot. The architecture is deliberately similar, so most concepts map directly. The key differences are Rust’s type system, ownership model, and async runtime.
Concept Mapping
| python-telegram-bot | rust-tg-bot | Notes |
|---|---|---|
Application.builder().token(...).build() | ApplicationBuilder::new().token(...).build() | Typestate pattern in Rust |
update: Update | update: Arc<Update> | Arc for cheap cloning across tasks |
context: ContextTypes.DEFAULT_TYPE | context: Context | Context is an alias for CallbackContext |
context.bot | context.bot() | Method call, not field access |
context.user_data | context.user_data().await | Async, returns Option<HashMap> |
context.bot_data | context.bot_data().await | Returns DataReadGuard |
CommandHandler("start", start) | CommandHandler::new("start", start) | Typed constructor |
MessageHandler(filters.TEXT & ~filters.COMMAND, echo) | MessageHandler::new(TEXT() & !COMMAND(), echo) | ! not ~, functions not constants |
ConversationHandler | FnHandler with state store | Manual state machine via Arc<RwLock<HashMap>> |
CallbackQueryHandler(callback) | FnHandler::on_callback_query(callback) | Factory method on FnHandler |
InlineQueryHandler(handler) | FnHandler::on_inline_query(handler) | Factory method on FnHandler |
application.run_polling() | app.run_polling().await | Explicit .await |
application.run_webhook(...) | app.run_webhook(config).await | WebhookConfig struct |
PicklePersistence | JsonFilePersistence | JSON instead of pickle |
from telegram.ext import * | use rust_tg_bot::ext::prelude::{specific, items}; | No wildcards |
Side-by-Side Comparison
Python Echo Bot
from telegram import Update
from telegram.ext import (
ApplicationBuilder,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
f"Hi {update.effective_user.first_name}!"
)
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(update.message.text)
def main():
app = ApplicationBuilder().token("TOKEN").build()
app.add_handler(CommandHandler("start", start))
app.add_handler(MessageHandler(
filters.TEXT & ~filters.COMMAND, echo
))
app.run_polling()
if __name__ == "__main__":
main()
Rust Echo Bot
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult,
MessageHandler, Update, COMMAND, TEXT,
};
async fn start(update: Arc<Update>, context: Context) -> HandlerResult {
let name = update
.effective_user()
.map(|u| u.first_name.as_str())
.unwrap_or("there");
context
.reply_text(&update, &format!("Hi {name}!"))
.await?;
Ok(())
}
async fn echo(update: Arc<Update>, context: Context) -> HandlerResult {
let text = update
.effective_message()
.and_then(|m| m.text.as_deref())
.unwrap_or("");
if !text.is_empty() {
context.reply_text(&update, text).await?;
}
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.expect("TELEGRAM_BOT_TOKEN must be set");
let app = ApplicationBuilder::new().token(token).build();
app.add_handler(CommandHandler::new("start", start), 0).await;
app.add_handler(
MessageHandler::new(TEXT() & !COMMAND(), echo), 0,
).await;
app.run_polling().await.unwrap();
}
Key Differences
1. Arc<Update> Instead of Update
In Python, the Update is passed by reference and garbage collected. In Rust, the Update is wrapped in Arc<Update> (atomic reference counting) so it can be cheaply shared across async tasks without copying.
#![allow(unused)]
fn main() {
// Access fields through Arc transparently:
let user = update.effective_user(); // same as Python
let chat = update.effective_chat(); // same as Python
let msg = update.effective_message(); // same as Python
}
2. Explicit Imports
Python encourages wildcard imports. Rust benefits from explicit imports for clarity and compile speed:
# Python
from telegram.ext import *
#![allow(unused)]
fn main() {
// Rust -- import exactly what you need
use rust_tg_bot::ext::prelude::{
ApplicationBuilder, Arc, CommandHandler, Context, HandlerResult,
MessageHandler, Update, COMMAND, TEXT,
};
}
3. Typed Constructors Instead of json
Python often uses dicts or keyword arguments. Rust uses typed constructors:
# Python
InlineKeyboardButton("Click", callback_data="1")
LabeledPrice("Item", 100)
#![allow(unused)]
fn main() {
// Rust
InlineKeyboardButton::callback("Click", "1")
LabeledPrice::new("Item", 100)
}
4. #[tokio::main] and Explicit .await
Python’s application.run_polling() manages the event loop internally. In Rust, you declare the async runtime explicitly:
#[tokio::main]
async fn main() {
// ...
app.run_polling().await.unwrap();
}
Every async operation requires .await. There is no implicit awaiting.
5. Handler Return Types
Python handlers return None implicitly. Rust handlers return HandlerResult:
# Python
async def handler(update, context):
await update.message.reply_text("Hi")
# implicitly returns None
#![allow(unused)]
fn main() {
// Rust
async fn handler(update: Arc<Update>, context: Context) -> HandlerResult {
context.reply_text(&update, "Hi").await?;
Ok(()) // explicit return
}
}
The ? operator propagates errors. If a Telegram API call fails, the error flows to your error handler instead of crashing.
6. Handler Groups
Python uses add_handler(handler, group=0). Rust uses add_handler(handler, group):
# Python
app.add_handler(CommandHandler("start", start), group=0)
#![allow(unused)]
fn main() {
// Rust
app.add_handler(CommandHandler::new("start", start), 0).await;
}
7. Filter Syntax
Python uses ~ for NOT and &/| for composition. Rust uses ! for NOT:
# Python
filters.TEXT & ~filters.COMMAND
filters.PHOTO | filters.VIDEO
#![allow(unused)]
fn main() {
// Rust
TEXT() & !COMMAND()
F::new(PHOTO) | F::new(VIDEO)
}
Note that TEXT() and COMMAND() are functions that return F wrappers, not bare constants.
8. Persistence
Python uses PicklePersistence. Rust uses JsonFilePersistence or SqlitePersistence:
# Python
persistence = PicklePersistence(filepath="bot_data")
app = ApplicationBuilder().token("TOKEN").persistence(persistence).build()
#![allow(unused)]
fn main() {
// Rust
use rust_tg_bot::ext::persistence::json_file::JsonFilePersistence;
let persistence = JsonFilePersistence::new("bot_data", true, false);
let app = ApplicationBuilder::new()
.token(token)
.persistence(Box::new(persistence))
.build();
}
Data access is also different:
# Python
context.user_data["key"] = "value"
value = context.user_data.get("key")
#![allow(unused)]
fn main() {
// Rust
context.set_user_data("key".to_string(), JsonValue::String("value".into())).await;
let data = context.user_data().await.unwrap_or_default();
let value = data.get("key").and_then(|v| v.as_str());
}
9. Conversation Handlers
Python has a dedicated ConversationHandler class. Rust models conversations as manual state machines:
# Python
conv_handler = ConversationHandler(
entry_points=[CommandHandler("start", start)],
states={
CHOOSING: [MessageHandler(filters.TEXT, choice)],
TYPING: [MessageHandler(filters.TEXT, received)],
},
fallbacks=[CommandHandler("cancel", cancel)],
)
#![allow(unused)]
fn main() {
// Rust -- use FnHandler with state predicates
type ConvStore = Arc<RwLock<HashMap<i64, ConvState>>>;
let cs = Arc::clone(&conv_store);
let cs_check = Arc::clone(&conv_store);
app.add_handler(
FnHandler::new(
move |u| is_in_state(u, &cs_check, ConvState::Choosing),
move |update, ctx| {
let cs = Arc::clone(&cs);
async move { choice(update, ctx, cs).await }
},
),
1,
).await;
}
10. Error Handling
Python uses application.add_error_handler(callback). Rust uses a similar pattern but with a different signature:
# Python
async def error_handler(update, context):
logger.error("Exception: %s", context.error)
app.add_error_handler(error_handler)
#![allow(unused)]
fn main() {
// Rust
async fn error_handler(
update: Option<Arc<Update>>,
context: CallbackContext,
) -> bool {
if let Some(ref err) = context.error {
tracing::error!("Exception: {err}");
}
false // allow other error handlers to run
}
app.add_error_handler(
Arc::new(|update, ctx| Box::pin(error_handler(update, ctx))),
true,
).await;
}
11. Callback Queries
# Python
app.add_handler(CallbackQueryHandler(button))
#![allow(unused)]
fn main() {
// Rust
app.add_handler(FnHandler::on_callback_query(button), 0).await;
}
12. Inline Queries
# Python
app.add_handler(InlineQueryHandler(inline_handler))
#![allow(unused)]
fn main() {
// Rust
app.add_handler(FnHandler::on_inline_query(inline_handler), 0).await;
}
Common Patterns
Sending Messages with Parse Mode
# Python
await context.bot.send_message(
chat_id, text, parse_mode=ParseMode.HTML
)
#![allow(unused)]
fn main() {
// Rust
context.bot()
.send_message(chat_id, &text)
.parse_mode(ParseMode::Html)
.await?;
}
Editing Messages
# Python
await query.edit_message_text("Updated text")
#![allow(unused)]
fn main() {
// Rust
context.bot()
.edit_message_text("Updated text")
.chat_id(chat_id)
.message_id(message_id)
.await?;
}
Answering Callback Queries
# Python
await query.answer(text="Done!", show_alert=True)
#![allow(unused)]
fn main() {
// Rust
context.bot()
.answer_callback_query(&cq.id)
.text("Done!")
.show_alert(true)
.await?;
}
What You Gain
- Compile-time safety. Wrong types, missing fields, and invalid filter combinations are caught before you run the bot.
- Performance. Async Rust on tokio is significantly faster than Python’s asyncio.
- Memory safety. No null pointer exceptions, data races, or use-after-free.
- Single binary deployment. No virtual environment, no pip install, no Python version management.
- Predictable resource usage. No garbage collector pauses.
Next Steps
- Your First Bot – build a complete bot from scratch.
- Core Concepts – deep dive into handlers, filters, and context.
API Reference
This page provides an overview of the crate structure, key types, and how to explore the full API documentation.
Generating Documentation
The most detailed and always-up-to-date reference is the cargo doc output. Generate and open it with:
cargo doc --open -p rust-tg-bot --all-features
This builds HTML documentation for all public types, traits, and functions, including source links and cross-references.
To include the underlying crates:
cargo doc --open --workspace --all-features
Online Documentation
- Guide: rust-tg-bot-docs.vercel.app – mdBook with tutorials, guides, and architecture docs
- API reference: docs.rs/rust-tg-bot – auto-generated from source (available after crates.io publish)
Crate Structure
The framework is split into four crates:
| Crate | Purpose | You Use Directly? |
|---|---|---|
rust-tg-bot | Facade crate – re-exports all of the below | Yes |
rust-tg-bot-raw | Low-level Bot API types, HTTP methods, request builders | Rarely |
rust-tg-bot-ext | High-level Application, handlers, filters, context, persistence | Rarely |
rust-tg-bot-macros | Proc macros (#[derive(BotCommands)]) | Rarely |
You almost always depend only on rust-tg-bot in your Cargo.toml. It re-exports everything you need through two module paths:
rust_tg_bot::ext::prelude– handlers, filters, context, applicationrust_tg_bot::raw::types– low-level Telegram types when needed
The Prelude
The rust_tg_bot::ext::prelude module re-exports the most commonly needed types:
Core Application Types
| Type | Description |
|---|---|
Application | The main application that manages handlers, dispatching, and lifecycle |
ApplicationBuilder | Builder for constructing an Application with token, persistence, etc. |
HandlerResult | Alias for Result<(), HandlerError> |
HandlerError | Error type returned by handlers |
Context | Alias for CallbackContext – passed to every handler |
CallbackContext | The full context type with bot, data access, and convenience methods |
Handler Types
| Type | Description |
|---|---|
CommandHandler | Matches /command messages |
MessageHandler | Matches messages that pass a filter |
FnHandler | Custom predicate-based handler for any update type |
CallbackQueryHandler | Matches callback queries from inline keyboards |
Filter Types
| Type | Description |
|---|---|
F | Wrapper around Arc<dyn Filter> with &, |, ! operators |
Filter | The trait every filter implements |
FilterResult | NoMatch, Match, or MatchWithData |
TEXT() | Function returning a filter for text messages |
COMMAND() | Function returning a filter for bot commands |
Telegram Types
| Type | Description |
|---|---|
Update | A Telegram update (message, callback query, inline query, etc.) |
Arc | Re-exported std::sync::Arc for wrapping Update |
Message | A Telegram message |
User | A Telegram user |
Chat | A Telegram chat |
ChatId | Enum for chat identifiers |
CallbackQuery | Data from an inline keyboard button press |
Keyboard Types
| Type | Description |
|---|---|
InlineKeyboardMarkup | Layout for inline keyboard buttons |
InlineKeyboardButton | A single inline keyboard button |
ReplyKeyboardMarkup | Layout for reply keyboard buttons |
KeyboardButton | A single reply keyboard button |
ReplyKeyboardRemove | Removes the reply keyboard |
ForceReply | Forces the user to reply |
Constants
| Type | Description |
|---|---|
ParseMode | Html, Markdown, MarkdownV2 |
ChatAction | Typing, UploadPhoto, etc. |
ChatType | Private, Group, Supergroup, Channel |
MessageEntityType | BotCommand, Mention, Url, etc. |
ChatMemberStatus | Creator, Administrator, Member, etc. |
Data Types
| Type | Description |
|---|---|
DataReadGuard | Typed read guard for bot-wide data |
DataWriteGuard | Typed write guard for bot-wide data |
JsonValue | Re-exported serde_json::Value |
HashMap | Re-exported std::collections::HashMap |
RwLock | Re-exported tokio::sync::RwLock |
Feature-Gated Types
| Type | Feature | Description |
|---|---|---|
WebhookConfig | webhooks | Configuration for webhook mode |
WebhookHandler | webhooks | Handles incoming webhook requests |
WebhookServer | webhooks | Built-in webhook HTTP server |
Raw Types Module
For types not in the prelude, import from rust_tg_bot::raw::types:
#![allow(unused)]
fn main() {
// Inline query types
use rust_tg_bot::raw::types::inline::inline_query_result_article::InlineQueryResultArticle;
use rust_tg_bot::raw::types::inline::input_message_content::InputMessageContent;
use rust_tg_bot::raw::types::inline::input_text_message_content::InputTextMessageContent;
// Payment types
use rust_tg_bot::raw::types::payment::labeled_price::LabeledPrice;
use rust_tg_bot::raw::types::payment::shipping_option::ShippingOption;
// Chat member types
use rust_tg_bot::raw::types::chat_member::ChatMember;
}
Persistence Module
Persistence types live outside the prelude:
#![allow(unused)]
fn main() {
// The trait
use rust_tg_bot::ext::persistence::base::{
BasePersistence, PersistenceError, PersistenceInput, PersistenceResult,
};
// Backends
use rust_tg_bot::ext::persistence::json_file::JsonFilePersistence;
#[cfg(feature = "persistence-sqlite")]
use rust_tg_bot::ext::persistence::sqlite::SqlitePersistence;
}
Filters Module
Advanced filter types beyond TEXT() and COMMAND():
#![allow(unused)]
fn main() {
use rust_tg_bot::ext::filters::base::{
Filter, FilterResult, FnFilter, F, ALL,
PHOTO, VIDEO, AUDIO, VOICE, LOCATION, CONTACT,
};
use rust_tg_bot::ext::filters::text::{TextFilter, CaptionFilter, CAPTION};
use rust_tg_bot::ext::filters::regex::RegexFilter;
use rust_tg_bot::ext::filters::user::UserFilter;
use rust_tg_bot::ext::filters::chat::{ChatTypePrivate, ChatTypeGroup};
use rust_tg_bot::ext::filters::update_type::{MESSAGE, EDITED_MESSAGE};
}
Bot Methods
The Bot (accessed via context.bot()) provides methods mirroring the Telegram Bot API. Most methods return a builder that you finalise with .await (builders implement IntoFuture):
#![allow(unused)]
fn main() {
// Send a message
context.bot().send_message(chat_id, "text").await?;
// Send with options
context.bot().send_message(chat_id, "text")
.parse_mode(ParseMode::Html)
.reply_markup(keyboard)
.await?;
// Edit a message
context.bot().edit_message_text("new text")
.chat_id(chat_id)
.message_id(msg_id)
.await?;
// Answer a callback query
context.bot().answer_callback_query(&cq_id).await?;
// Answer an inline query
context.bot().answer_inline_query(&iq_id, results).await?;
// Send an invoice
context.bot().send_invoice(chat_id, title, desc, payload, currency, prices)
.provider_token(&token)
.await?;
// Set webhook
context.bot().set_webhook(&url).await?;
// Delete webhook
context.bot().delete_webhook(false).await?;
}
Convenience Methods on Context
Context provides shortcuts that skip the builder pattern:
#![allow(unused)]
fn main() {
// Reply with text (extracts chat_id from update automatically)
context.reply_text(&update, "Hello!").await?;
// Reply with HTML
context.reply_html(&update, "<b>Bold</b>").await?;
// Reply with MarkdownV2
context.reply_markdown_v2(&update, "*Bold*").await?;
}
Next Steps
For the complete API surface, run cargo doc --open -p rust-tg-bot --all-features in your project. The generated documentation includes every public type, method, and trait implementation with source links.