A comprehensive, async-first Rust framework for the Telegram Bot API.
[dependencies]
rustigram = "0.9.7"
tokio = { version = "1", features = ["full"] }rustigram is a typed, ergonomic Rust library for building Telegram bots. Every Bot API method is exposed as an awaitable builder — set only the parameters you need, then .await. Incoming updates are routed through a composable filter-handler pipeline running concurrently on tokio.
The framework is split into focused crates so you can depend on only what you need:
| Crate | Purpose |
|---|---|
rustigram-types |
All Bot API types, fully serde-serialisable |
rustigram-api |
HTTP client and typed method builders |
rustigram-bot |
Dispatcher, filters, handlers, FSM, update listeners |
rustigram-macros |
Procedural macros (#[handler], #[derive(DialogueState)]) |
rustigram |
Public facade re-exporting all sub-crates |
Supported Bot API version: 9.7 (May 2026) Minimum Rust version: 1.75
- rustigram
- Overview
- Table of Contents
- Quick start
- Architecture
- Receiving updates
- Sending messages
- Filters
- Handlers
- Callback queries and inline keyboards
- Conversation state (FSM)
- Shared state
- File uploads
- Payments and Stars
- Webhook setup
- Local Bot API server
- Error handling
- Examples
- Running the tests
- Contributing
- License
use rustigram::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let bot = Bot::new(std::env::var("BOT_TOKEN")?)?;
bot.dispatcher()
.on(filters::command("start"), handler_fn(start))
.on(filters::command("help"), handler_fn(help))
.on(filters::message(), handler_fn(echo))
.build()
.polling()
.await?;
Ok(())
}
async fn start(ctx: Context) -> BotResult<()> {
if let Some(r) = ctx.reply("Hello! Send me any message.") {
r.await?;
}
Ok(())
}
async fn help(ctx: Context) -> BotResult<()> {
if let Some(r) = ctx.reply("/start — greeting\n/help — this message") {
r.await?;
}
Ok(())
}
async fn echo(ctx: Context) -> BotResult<()> {
if let (Some(text), Some(chat_id)) = (ctx.text(), ctx.chat_id()) {
ctx.bot.send_message(chat_id, text).await?;
}
Ok(())
}Get your bot token from @BotFather, then:
BOT_TOKEN=your_token cargo runTelegram
│ getUpdates (long poll) or POST (webhook)
▼
Dispatcher
├── Route 1: filter::command("start") ──► handler A ─┐
├── Route 2: filter::callback_query() ──► handler B │ tokio::spawn
├── Route 3: filter::message() ──► handler C │ (concurrent)
└── Fallback ──► handler D ──┘
│
Context { update, bot }
│
ctx.bot.send_message(...).await
Each incoming update is dispatched in its own tokio::spawn task. Handlers run concurrently — a slow handler does not block others. The dispatcher evaluates routes in registration order and stops at the first match.
Long polling is the simplest mode and requires no public server.
bot.dispatcher()
.on(filters::message(), handler_fn(my_handler))
.build()
.polling()
.await?;The poller automatically advances the offset after each batch, so updates are never processed twice. Transient network errors (timeout, connection reset) are retried automatically. Rate-limit responses (HTTP 429) honour the retry_after value from Telegram.
// Register the webhook URL with Telegram once
bot.client
.set_webhook("https://example.com/")
.secret_token("change_me")
.await?;
// Start the axum-based server
bot.dispatcher()
.on(filters::message(), handler_fn(my_handler))
.build()
.webhook("0.0.0.0:8443".parse()?)
.await?;Supported Telegram webhook ports: 443, 80, 88, 8443.
The secret token is validated on every incoming request. Requests with a missing or incorrect X-Telegram-Bot-Api-Secret-Token header are rejected with HTTP 401.
Every API method returns a builder. Call only the setters you need, then .await.
// Plain text
ctx.bot.send_message(chat_id, "Hello!").await?;
// With formatting and a reply
ctx.bot
.send_message(chat_id, "<b>Bold</b> and <i>italic</i>")
.parse_mode(ParseMode::HTML)
.reply_to(message_id)
.await?;
// Disable notification
ctx.bot
.send_message(chat_id, "Silent message")
.disable_notification(true)
.await?;
// Photo from URL
ctx.bot
.send_photo(chat_id, InputFile::Url("https://example.com/photo.jpg".into()))
.caption("A photo")
.await?;
// Forward a message
ctx.bot.forward_message(to_chat_id, from_chat_id, message_id).await?;
// Delete a message
ctx.bot.delete_message(chat_id, message_id).await?;
// Edit an existing message
ctx.bot
.edit_message_text(chat_id, message_id, "Updated text")
.parse_mode(ParseMode::HTML)
.await?;| Method | Description |
|---|---|
send_message |
Text (up to 4096 characters) |
send_photo |
Photo (.jpg, .png, etc.) |
send_audio |
Audio file treated as music |
send_document |
General file |
send_video |
Video (MPEG4) |
send_animation |
GIF or silent H.264 video |
send_voice |
Voice note (OGG/OPUS) |
send_video_note |
Rounded-square video |
send_sticker |
.WEBP / .TGS / .WEBM sticker |
send_location |
Map point, optionally live |
send_contact |
Phone contact |
send_poll |
Native poll or quiz |
send_dice |
Animated dice/emoji |
send_invoice |
Payment invoice |
send_media_group |
Album of 2–10 media items |
send_chat_action |
Typing indicator etc. |
forward_message |
Forward from another chat |
copy_message |
Copy without forward header |
Filters are composable predicates evaluated against an incoming Context. The dispatcher calls the first handler whose filter returns true.
use rustigram::prelude::filters;
filters::message() // Any Message update
filters::edited_message() // Any EditedMessage update
filters::callback_query() // Any CallbackQuery update
filters::inline_query() // Any InlineQuery update
filters::command("start") // /start (case-insensitive, strips @BotName)
filters::text("exact string") // Exact text match
filters::text_contains("substring") // Substring match
filters::callback_data("btn_ok") // Exact callback data
filters::callback_data_prefix("nav:") // Callback data prefix
filters::private() // Private chats only
filters::group() // Groups and supergroups
filters::any() // Always passesuse rustigram::prelude::filters;
use rustigram_bot::filter::FilterExt;
// Both must match
filters::message().and(filters::private())
// Either can match
filters::private().or(filters::group())
// Invert
filters::private().not()
// Chain freely
filters::command("ban")
.and(filters::group())
.and(filter_fn(|ctx| is_admin(ctx)))use rustigram_bot::filter::{filter_fn, Filter};
let has_photo = filter_fn(|ctx| {
ctx.message().and_then(|m| m.photo.as_ref()).is_some()
});
bot.dispatcher()
.on(has_photo, handler_fn(photo_handler))
.build()
.polling()
.await?;A handler is any async function with the signature async fn(Context) -> BotResult<()>.
use rustigram::prelude::*;
async fn my_handler(ctx: Context) -> BotResult<()> {
// ctx.bot — the API client, call any method
// ctx.update — the raw Update
// ctx.text() — message text or caption
// ctx.command() — command name without / and @BotName
// ctx.chat_id() — chat ID from message or callback query
// ctx.from_id() — sender user ID
// ctx.reply(t) — send a reply to the current message
if let Some(r) = ctx.reply("Got it!") {
r.await?;
}
Ok(())
}Handlers can also be closures:
bot.dispatcher()
.on(filters::command("ping"), handler_fn(|ctx: Context| async move {
if let Some(r) = ctx.reply("pong") { r.await?; }
Ok(())
}))
.build()
.polling()
.await?;use rustigram::prelude::*;
use rustigram_types::keyboard::{InlineKeyboardMarkup, InlineKeyboardButton};
async fn menu(ctx: Context) -> BotResult<()> {
let markup = InlineKeyboardMarkup::new()
.row(vec![
InlineKeyboardButton::callback("Yes", "answer:yes"),
InlineKeyboardButton::callback("No", "answer:no"),
])
.row(vec![
InlineKeyboardButton::url("Documentation", "https://core.telegram.org/bots/api"),
]);
if let Some(chat_id) = ctx.chat_id() {
ctx.bot
.send_message(chat_id, "Make a choice:")
.reply_markup(ReplyMarkup::InlineKeyboard(markup))
.await?;
}
Ok(())
}
async fn handle_answer(ctx: Context) -> BotResult<()> {
if let Some(q) = ctx.callback_query() {
let answer = q.data.as_deref().unwrap_or("").trim_start_matches("answer:");
ctx.bot
.answer_callback_query(&q.id)
.text(format!("You chose: {answer}"))
.await?;
}
Ok(())
}
// Registration
bot.dispatcher()
.on(filters::command("menu"), handler_fn(menu))
.on(filters::callback_data_prefix("answer:"), handler_fn(handle_answer))
.build()
.polling()
.await?;DialogueStorage tracks per-user state keyed by (chat_id, user_id). State values are type-erased with Any so any type works without a shared trait.
use rustigram::prelude::*;
use rustigram_bot::state::DialogueStorage;
use std::sync::Arc;
#[derive(Clone)]
enum RegistrationState {
AwaitingName,
AwaitingEmail { name: String },
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let bot = Bot::new(std::env::var("BOT_TOKEN")?)?;
let storage = Arc::new(DialogueStorage::new());
bot.dispatcher()
.on(filters::command("register"), {
let s = storage.clone();
handler_fn(move |ctx| { let s = s.clone(); async move { start(ctx, s).await } })
})
.on(filters::message(), {
let s = storage.clone();
handler_fn(move |ctx| { let s = s.clone(); async move { step(ctx, s).await } })
})
.build()
.polling()
.await?;
Ok(())
}
async fn start(ctx: Context, storage: Arc<DialogueStorage>) -> BotResult<()> {
let (chat_id, user_id) = match (ctx.chat_id(), ctx.from_id()) {
(Some(ChatId::Id(c)), Some(u)) => (c, u),
_ => return Ok(()),
};
storage.set(chat_id, user_id, RegistrationState::AwaitingName);
if let Some(r) = ctx.reply("What is your name?") { r.await?; }
Ok(())
}
async fn step(ctx: Context, storage: Arc<DialogueStorage>) -> BotResult<()> {
let (chat_id, user_id) = match (ctx.chat_id(), ctx.from_id()) {
(Some(ChatId::Id(c)), Some(u)) => (c, u),
_ => return Ok(()),
};
let text = match ctx.text() { Some(t) => t.to_owned(), None => return Ok(()) };
match storage.get::<RegistrationState>(chat_id, user_id) {
Some(RegistrationState::AwaitingName) => {
storage.set(chat_id, user_id, RegistrationState::AwaitingEmail { name: text.clone() });
if let Some(r) = ctx.reply(format!("Nice to meet you, {text}! What is your email?")) { r.await?; }
}
Some(RegistrationState::AwaitingEmail { name }) => {
storage.remove(chat_id, user_id);
if let Some(r) = ctx.reply(format!("Registered: {name} <{text}>")) { r.await?; }
}
None => {}
}
Ok(())
}StateStorage is a thread-safe, type-keyed store for data shared across all handlers — database connection pools, configuration, counters, etc.
use rustigram_bot::state::StateStorage;
use std::sync::Arc;
#[derive(Clone)]
struct AppConfig {
admin_id: i64,
prefix: String,
}
let store = StateStorage::new();
store.insert(AppConfig { admin_id: 123456, prefix: "!".into() });
store.insert(my_db_pool);
// Inside a handler (store captured via Arc):
let config = store.get::<AppConfig>().unwrap();InputFile accepts three variants:
use rustigram_api::BotClient;
use rustigram_types::file::InputFile;
// Re-send an existing Telegram file by file_id (most efficient)
InputFile::FileId("AgACAgIAAxkBAAI...".into())
// Download from a URL (Telegram fetches it; max 20 MB for most types)
InputFile::Url("https://example.com/document.pdf".into())
// Upload raw bytes
InputFile::Bytes {
filename: "report.pdf".into(),
data: std::fs::read("report.pdf")?,
mime_type: "application/pdf".into(),
}All three variants share the same API:
ctx.bot
.send_document(chat_id, InputFile::Bytes {
filename: "data.csv".into(),
data: csv_bytes,
mime_type: "text/csv".into(),
})
.caption("Your export")
.await?;Downloading a file sent to your bot:
let file = ctx.bot.get_file(&document.file_id).await?;
let bytes = ctx.bot.download_file(&file.file_path.unwrap()).await?;use rustigram_types::payments::LabeledPrice;
ctx.bot
.send_invoice(
chat_id,
"Premium Access",
"30 days of premium features",
"payload_premium_30d",
"XTR",
vec![LabeledPrice { label: "30-day plan".into(), amount: 100 }],
)
.await?;No provider_token is needed for Stars. The amount is in whole Stars.
async fn handle_pre_checkout(ctx: Context) -> BotResult<()> {
if let Some(q) = ctx.update.kind.as_pre_checkout_query() {
ctx.bot
.answer_pre_checkout_query(&q.id, true)
.await?;
}
Ok(())
}let balance = ctx.bot.get_my_star_balance().await?;
println!("Balance: {} Stars", balance.amount);A complete production webhook setup:
use rustigram::prelude::*;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let token = std::env::var("BOT_TOKEN")?;
let webhook_url = std::env::var("WEBHOOK_URL")?; // e.g. https://bot.example.com
let secret = std::env::var("WEBHOOK_SECRET")?;
let bind_addr = std::env::var("BIND_ADDR").unwrap_or("0.0.0.0:8443".into());
let bot = Bot::new(&token)?;
// Register with Telegram
bot.client
.set_webhook(&webhook_url)
.secret_token(&secret)
.max_connections(40)
.drop_pending_updates(true)
.await?;
tracing::info!("Webhook registered at {}", webhook_url);
bot.dispatcher()
.on(filters::command("start"), handler_fn(start_handler))
.on(filters::message(), handler_fn(echo_handler))
.build()
.webhook(bind_addr.parse()?)
.await?;
Ok(())
}To remove the webhook and switch back to polling:
bot.client.delete_webhook().drop_pending_updates(true).await?;Run a local Bot API server for higher limits:
- File upload up to 2 GB (vs 50 MB)
- File download up to 2 GB (vs 20 MB)
- Up to 100,000 webhook connections
- HTTP webhooks on any port and local IP
getFilereturns absolute local path
Point rustigram at your local server:
use rustigram_api::ClientConfig;
use std::time::Duration;
let config = ClientConfig::new(token)?
.api_base_url("http://localhost:8081")
.timeout(Duration::from_secs(120));
let client = rustigram_api::BotClient::new(config)?;BotError covers all failure modes:
use rustigram_bot::error::BotError;
use rustigram_api::Error as ApiError;
match result {
Err(BotError::Api(ApiError::Api { error_code: 403, .. })) => {
// Bot was blocked by the user
}
Err(BotError::Api(ApiError::RateLimit { retry_after })) => {
// Flood control — wait retry_after seconds
}
Err(BotError::Api(ApiError::Api { error_code: 400, description, .. })) => {
// Bad request — check description
eprintln!("Bad request: {}", description);
}
Err(e) => eprintln!("Unexpected error: {}", e),
Ok(_) => {}
}Polling and webhook modes handle RateLimit and transient network errors automatically. Only non-recoverable errors propagate to your application.
let err: rustigram_api::Error = ...;
err.is_rate_limit() // true for HTTP 429
err.is_blocked() // true when bot was blocked by user
err.is_chat_not_found() // true for "chat not found" 400s
err.retry_after() // Some(secs) when rate-limitedThe examples/ directory contains complete runnable bots:
| Example | Description |
|---|---|
echo_bot |
Minimal bot — echoes any message back |
inline_keyboard |
Inline buttons and callback query routing |
webhook_bot |
Production webhook server setup |
state_machine |
Multi-step conversation with FSM |
Run any example:
BOT_TOKEN=your_token cargo run --example echo_botThe test suite requires no bot token — all tests use hand-crafted in-memory data.
cargo test --workspace| Suite | Count | What is tested |
|---|---|---|
client_tests |
11 | Token validation, error types, client config |
types_tests |
23 | Serde round-trips, helper methods, Update deserialization |
dispatcher_tests |
20 | All filters, combinators, Context helpers, state storage |
Contributions are welcome. Please open an issue before submitting a pull request for non-trivial changes.
Guidelines:
- Code style follows
rustfmtdefaults — runcargo fmtbefore committing - All public items must have doc comments
- New features should include tests
- Avoid
unwrap()in library code; propagate errors with?
Running checks locally:
cargo fmt --check
cargo clippy --workspace -- -D warnings
cargo test --workspaceMIT — see LICENSE.