Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rust-tg-bot

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:

CratePurpose
rust-tg-bot-rawLow-level Bot API types, HTTP methods, request builders
rust-tg-bot-extHigh-level Application, handlers, filters, context, persistence
rust-tg-bot-macrosProc macros (#[derive(BotCommands)])
rust-tg-botFacade 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:

  1. Getting Started – Install the library, write your first bot, and run it.
  2. Core Concepts – Understand the building blocks: Update, Bot, handlers, filters, context, and the Application lifecycle.
  3. Guides – Build real features: command handling, inline keyboards, multi-step conversations, scheduled jobs, data persistence, webhooks, inline mode, and payments.
  4. Advanced – Write custom filters, handle errors gracefully, nest conversations, test your bot, and deploy to production.
  5. 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:

FeatureDescriptionDefault
webhooksWebhook-based update delivery (requires axum)No
job-queueScheduled and recurring tasks via JobQueueNo
persistenceBase persistence traitNo
persistence-jsonJSON file persistence backendNo
persistence-sqliteSQLite persistence backendNo
rate-limiterBuilt-in rate limiting for API callsNo
fullEnables all featuresNo

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:

  1. Command handlers – respond to /start and /help.
  2. Message handlers with filters – catch text messages that are not commands.
  3. 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 in Arc for cheap cloning across async tasks.
  • context: Context – provides access to the bot instance, user/chat data, job queue, and convenience methods like reply_text.
  • HandlerResult – an alias for Result<(), 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:

  1. Extracts the chat ID from the update.
  2. Calls bot.send_message(chat_id, text).
  3. Returns a Result you 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 /start and calls the start function.
  • MessageHandler::new(TEXT() & !COMMAND(), echo) – matches text messages that are NOT commands, then calls echo.
  • 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:

  1. Define async handler functions.
  2. Build an Application with ApplicationBuilder.
  3. Register handlers.
  4. Call run_polling() or run_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:

  1. Calls initialize() to set up the application.
  2. Calls start() to begin processing.
  3. Enters a loop calling getUpdates on the Telegram API.
  4. Dispatches each update to your registered handlers.
  5. On Ctrl+C, calls stop() and shutdown().

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
StageWhat Happens
initialize()Calls getMe to validate the token, loads persistence data, runs post_init hook
start()Begins the update processing loop
idleThe 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 after initialize().
  • post_stop – runs after stop().
  • post_shutdown – runs after shutdown().

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 TypeAccessorWhen It Fires
Messageeffective_message()User sends a text, photo, sticker, etc.
Edited Messageeffective_message()User edits an existing message
Channel Posteffective_message()New post in a channel the bot is in
Callback Querycallback_query()User presses an inline keyboard button
Inline Queryinline_query()User types @yourbot query in any chat
Chosen Inline Resultchosen_inline_result()User selects an inline query result
Shipping Queryshipping_query()Payment: user selected a shipping address
Pre-Checkout Querypre_checkout_query()Payment: final confirmation before charging
Pollpoll()Poll state changes
Poll Answerpoll_answer()User votes in a poll
Chat Membermy_chat_member() / chat_member()Bot or user’s membership status changes
Chat Join Requestchat_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::Html
  • ParseMode::Markdown
  • ParseMode::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:

HandlerUse Case
CommandHandlerResponds to /command messages
MessageHandlerResponds to messages matching a filter
FnHandlerCustom predicate-based handler for any update type
CallbackQueryHandlerResponds 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:

  1. Groups are processed in ascending numeric order (0, then 1, then 2, …).
  2. Within a group, the first handler whose predicate/filter matches wins. Only that one handler fires.
  3. After one handler fires in a group, the dispatcher moves to the next group.
  4. 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:

ModuleWhat It Matches
textText content presence
commandBot commands (/start, etc.)
chatChat type (private, group, supergroup, channel)
userUser properties
documentDocument/file messages
photoPhoto messages
entityMessage entity types (mentions, URLs, etc.)
forwardedForwarded messages
regexText matching a regular expression
status_updateChat status changes (member joined, etc.)
via_botMessages 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

MethodReturnsPurpose
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_queueOption<Arc<JobQueue>>Scheduled task system
errorOption<HandlerError>Error details (in error handlers)
argsOption<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

MethodDescription
.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 – an i32 group 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

#![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

FeatureWhat It Enables
job-queue.job_queue() on the builder
persistence.persistence() on the builder
persistence-jsonJSON file persistence backend
persistence-sqliteSQLite persistence backend
webhooksrun_webhook() method
rate-limiterRate limiting on API calls

Next Steps

You now understand all the core concepts. Move on to the practical guides:

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.

#![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;
}
#![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:

  1. Open @BotFather.
  2. Send /setcommands.
  3. Select your bot.
  4. Send a list like:
start - Start the bot
help - Show help message
settings - Configure settings
set - Set a timer

Next Steps

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

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:

MethodDescription
.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

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:

BackendFeature FlagBest For
DictPersistencepersistenceTesting, prototyping (in-memory only)
JsonFilePersistencepersistence-jsonSimple bots, human-readable storage
SqlitePersistencepersistence-sqliteProduction 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:

  1. File prefix – the base name for the JSON file(s). Single-file mode creates my_bot_data.json.
  2. Single-file modetrue stores everything in one file; false creates separate files per data category (my_bot_data_user_data.json, my_bot_data_chat_data.json, etc.).
  3. Pretty-printtrue formats the JSON with indentation for debugging; false is 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:

MethodPurpose
get_user_data / get_chat_data / get_bot_dataLoad data at startup
update_user_data / update_chat_data / update_bot_dataPersist changes
get_conversations / update_conversationStore conversation state
get_callback_data / update_callback_dataStore callback data cache
drop_chat_data / drop_user_dataDelete data for a specific entity
flushCalled on shutdown to save pending writes
update_intervalHow often (in seconds) the Application flushes (default: 60)
store_dataReturns PersistenceInput controlling which categories are persisted
refresh_user_data / refresh_chat_data / refresh_bot_dataOptional 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

PollingWebhooks
SetupZero configRequires HTTPS endpoint
LatencyDepends on poll intervalNear-instant
Resource usageConstant network callsIdle until update arrives
Best forDevelopment, low-traffic botsProduction, 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:

FieldTypeDefaultPurpose
listenString"127.0.0.1"Address to bind the HTTP server
portu1680Port to listen on
url_pathString""URL path for the webhook endpoint
webhook_urlOption<String>NoneFull public URL Telegram will POST to
secret_tokenOption<String>NoneToken for validating requests from Telegram
bootstrap_retriesi320Retries when setting the webhook on Telegram
drop_pending_updatesboolfalseDiscard updates that arrived while offline
allowed_updatesOption<Vec<String>>NoneFilter which update types you receive
max_connectionsu3240Max 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:

  1. Call app.initialize() and app.start() instead of run_polling() or run_webhook().
  2. Set the webhook URL on Telegram with app.bot().set_webhook(url).await.
  3. Use app.update_sender() to get the mpsc::Sender channel and forward parsed updates into it.
  4. Call app.stop() and app.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:

  1. Send /mybots and select your bot.
  2. Choose “Bot Settings” then “Inline Mode”.
  3. 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('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;");

    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 @botname without a query.
  • Result limits. Telegram allows up to 50 results per answer_inline_query call.
  • Caching. Telegram caches results for 300 seconds by default. Use the cache_time builder method on answer_inline_query to adjust.
  • Unique IDs. Every result in a single response must have a unique id. Using the inline query’s own id as 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::Value via serde_json::to_value() before passing to answer_inline_query.
  • 10-second deadline. You must call answer_inline_query within 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

  1. Send /mybots to @BotFather, select your bot, then choose Payments.
  2. Pick a provider (Stripe for testing).
  3. 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:

VariantMeaning
FilterResult::NoMatchThe filter did not match – handler is skipped
FilterResult::MatchThe 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:

OperatorMeaningExample
&AND – both must matchTEXT() & !COMMAND()
|OR – either can matchPHOTO | VIDEO
^XOR – exactly one must matchfilter_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 returns NoMatch, short-circuits. Otherwise checks right and merges data from both results.
  • OrFilter: checks left first. If it matches, returns immediately. Otherwise checks right.
  • NotFilter: inverts Match to NoMatch and 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 is None for errors that occur outside of update processing.
  • context – a CallbackContext that provides access to the bot, user/chat data, and the error field.
  • Returns bool – return false to allow other error handlers to run; true to 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:

  1. Clone the store twice – once for the predicate closure, once for the handler closure.
  2. The predicate closure captures s_check and calls is_in_state.
  3. The handler closure captures s, clones it into the async block, and passes it to the handler function.
  4. 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 /start and /stop in group 0 so they always fire. Register conversation handlers in group 1.
  • Use try_read in predicates. Predicates are synchronous. Using .read().await would 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 across await points.
  • 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_data via context.set_user_data() instead of an in-memory HashMap.

Next Steps

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 tempfile for 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:

FeatureDependencies Added
webhooksaxum, hyper
job-queuetokio-cron-scheduler
persistence-json(minimal – serde_json already present)
persistence-sqliterusqlite (with bundled SQLite)
rate-limitergovernor

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

EventWhat to Watch
Handler errorsSpikes indicate bugs or API issues
Update processing timeLatency degradation
Webhook delivery failuresNetwork or TLS problems
Persistence flush failuresDisk 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

  1. Build in release mode with LTO and stripping enabled.
  2. Set a webhook secret to prevent spoofed updates.
  3. Use persistence (SqlitePersistence or JsonFilePersistence) so data survives restarts.
  4. Configure tracing with structured logging and an appropriate log level.
  5. Run behind a reverse proxy (nginx, Caddy) for TLS termination and certificate renewal.
  6. Set up monitoring – alerting on handler errors and latency spikes.
  7. Use systemd or Docker for process management and automatic restarts.
  8. Mount a persistent volume if using file-based persistence in Docker.
  9. Enable only the features you need to minimise binary size and attack surface.
  10. Test your shutdown path – verify that persistence is flushed on SIGTERM.

Next Steps

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-botrust-tg-botNotes
Application.builder().token(...).build()ApplicationBuilder::new().token(...).build()Typestate pattern in Rust
update: Updateupdate: Arc<Update>Arc for cheap cloning across tasks
context: ContextTypes.DEFAULT_TYPEcontext: ContextContext is an alias for CallbackContext
context.botcontext.bot()Method call, not field access
context.user_datacontext.user_data().awaitAsync, returns Option<HashMap>
context.bot_datacontext.bot_data().awaitReturns 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
ConversationHandlerFnHandler with state storeManual 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().awaitExplicit .await
application.run_webhook(...)app.run_webhook(config).awaitWebhookConfig struct
PicklePersistenceJsonFilePersistenceJSON 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

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

Crate Structure

The framework is split into four crates:

CratePurposeYou Use Directly?
rust-tg-botFacade crate – re-exports all of the belowYes
rust-tg-bot-rawLow-level Bot API types, HTTP methods, request buildersRarely
rust-tg-bot-extHigh-level Application, handlers, filters, context, persistenceRarely
rust-tg-bot-macrosProc 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, application
  • rust_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

TypeDescription
ApplicationThe main application that manages handlers, dispatching, and lifecycle
ApplicationBuilderBuilder for constructing an Application with token, persistence, etc.
HandlerResultAlias for Result<(), HandlerError>
HandlerErrorError type returned by handlers
ContextAlias for CallbackContext – passed to every handler
CallbackContextThe full context type with bot, data access, and convenience methods

Handler Types

TypeDescription
CommandHandlerMatches /command messages
MessageHandlerMatches messages that pass a filter
FnHandlerCustom predicate-based handler for any update type
CallbackQueryHandlerMatches callback queries from inline keyboards

Filter Types

TypeDescription
FWrapper around Arc<dyn Filter> with &, |, ! operators
FilterThe trait every filter implements
FilterResultNoMatch, Match, or MatchWithData
TEXT()Function returning a filter for text messages
COMMAND()Function returning a filter for bot commands

Telegram Types

TypeDescription
UpdateA Telegram update (message, callback query, inline query, etc.)
ArcRe-exported std::sync::Arc for wrapping Update
MessageA Telegram message
UserA Telegram user
ChatA Telegram chat
ChatIdEnum for chat identifiers
CallbackQueryData from an inline keyboard button press

Keyboard Types

TypeDescription
InlineKeyboardMarkupLayout for inline keyboard buttons
InlineKeyboardButtonA single inline keyboard button
ReplyKeyboardMarkupLayout for reply keyboard buttons
KeyboardButtonA single reply keyboard button
ReplyKeyboardRemoveRemoves the reply keyboard
ForceReplyForces the user to reply

Constants

TypeDescription
ParseModeHtml, Markdown, MarkdownV2
ChatActionTyping, UploadPhoto, etc.
ChatTypePrivate, Group, Supergroup, Channel
MessageEntityTypeBotCommand, Mention, Url, etc.
ChatMemberStatusCreator, Administrator, Member, etc.

Data Types

TypeDescription
DataReadGuardTyped read guard for bot-wide data
DataWriteGuardTyped write guard for bot-wide data
JsonValueRe-exported serde_json::Value
HashMapRe-exported std::collections::HashMap
RwLockRe-exported tokio::sync::RwLock

Feature-Gated Types

TypeFeatureDescription
WebhookConfigwebhooksConfiguration for webhook mode
WebhookHandlerwebhooksHandles incoming webhook requests
WebhookServerwebhooksBuilt-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.