Declarative Telegram bot framework for Rust.
One screen at a time. Zero garbage in chat. Direct MTProto over persistent TCP.
| HTTP Bot API | blazegram | |
|---|---|---|
| Latency | ~50 ms per call (2 hops) | ~5 ms (direct MTProto) |
| File uploads | 50 MB limit | 2 GB |
| Connection | New HTTP per call | Persistent TCP socket |
| Message management | Manual IDs everywhere | Automatic diffing |
| Chat cleanup | You delete manually | Auto-managed |
blazegram holds a single persistent TCP socket to Telegram's datacenter via grammers MTProto β no webhook server, no middleman, no HTTP overhead.
On top of that, it introduces the Screen abstraction: declare what the user should see, and a Virtual Chat Differ computes the minimal set of API calls to get there.
[dependencies]
blazegram = "0.4"
tokio = { version = "1", features = ["full"] }use blazegram::{handler, prelude::*};
#[tokio::main]
async fn main() {
App::builder("BOT_TOKEN")
.command("start", handler!(ctx => {
ctx.navigate(
Screen::text("home", "<b>Pick a side.</b>")
.keyboard(|kb| kb
.button("Light", "pick:light")
.button("Dark", "pick:dark"))
.build()
).await
}))
.callback("pick", handler!(ctx => {
let side = ctx.callback_param().unwrap_or_default();
ctx.navigate(
Screen::text("chosen", format!("You chose <b>{side}</b>."))
.keyboard(|kb| kb.button_row("Back", "menu"))
.build()
).await
}))
.run().await;
}First launch authenticates via MTProto and creates a .session file.
Subsequent starts reconnect in under 100 ms.
A Screen is a declarative snapshot of what the user should see. Call navigate() and the
differ handles everything:
callback (button press) β edit in place (1 API call)
user sent text / command β delete old + send (2β3 calls)
content identical β nothing (0 calls)
No message IDs. No "should I edit or re-send?" logic. No stale buttons lingering in chat.
# use blazegram::prelude::*;
// text + keyboard
Screen::text("menu", "Pick one:")
.keyboard(|kb| kb
.button_row("A", "pick:a")
.button_row("B", "pick:b"))
.build();
// photo with caption
Screen::builder("gallery")
.photo("https://example.com/pic.jpg")
.caption("Nice shot")
.keyboard(|kb| kb.button_row("Next", "next"))
.done()
.build();
// multi-message screen
Screen::builder("receipt")
.text("Order confirmed.").done()
.photo("https://example.com/qr.png").caption("QR code").done()
.build();push() / pop() give you a navigation stack (capped at 20 levels) β
back buttons work out of the box:
ctx.push(detail_screen).await?; // push new screen
ctx.pop(|prev_id| make_screen(prev_id)).await?; // pop backFluent keyboard builder with automatic row management:
.keyboard(|kb| kb
.button("A", "a").button("B", "b").button("C", "c").row() // 3 buttons in one row
.button_row("Full width", "full") // single-button row
.grid(items, 3, |item| (item.name, item.id)) // auto-grid from iterator
.pagination(page, total, "page") // β [2/5] β
.nav_back("menu") // localized back button
.confirm_cancel("OK", "ok", "Cancel", "cancel") // confirm/cancel pair
)One-liner paginated lists with navigation buttons:
let paginator = Paginator::new(items, 5); // 5 per page
let screen = paginated_screen(
"items", // screen ID
"Your items", // title
&paginator, // paginator
|i, item| (item.name.clone(), format!("view:{i}")), // formatter
"page", // callback prefix for β/β
"menu", // back callback
);
ctx.navigate(screen).await?;β [2/5] β buttons auto-generated. Handles empty lists. Labels localized via i18n.
Declarative form wizard with validation, type coercion, and auto-generated keyboards:
Form::builder("signup")
.text_step("name", "name", "Your name?")
.validator(|s| if s.len() < 2 { Err("Too short".into()) } else { Ok(()) })
.done()
.integer_step("age", "age", "Age?").min(13).max(120).done()
.choice_step("plan", "plan", "Pick a plan:", vec![("Free", "free"), ("Pro", "pro")])
.confirm_step(|d| format!("Name: {}\nAge: {}", d["name"], d["age"]))
.on_complete(form_handler!(ctx, data => {
ctx.navigate(Screen::text("done", "Welcome aboard.").build()).await
}))
.build()
.unwrap()Bad input auto-deleted, error shown as 3 s toast, cancel button built-in.
Stream edits to a single message, auto-throttled to respect Telegram rate limits. Perfect for LLM streaming, progress bars, live dashboards:
let h = ctx.progressive(Screen::text("t", "Loading...").build()).await?;
h.update(Screen::text("t", "Loading... 40%").build()).await;
h.update(Screen::text("t", "Loading... 80%").build()).await;
h.finalize(Screen::text("t", "Done β
").build()).await?;If navigate() is called before finalize(), the stream is cancelled automatically β no races.
For conversational bots (LLM wrappers, support bots) that don't need chat cleanup:
ctx.reply(Screen::text("r", "thinking...").build()).await?; // sends new message
ctx.reply(Screen::text("r", "thinking... ok").build()).await?; // edits same message
ctx.reply(Screen::text("r", "Here you go.").build()).await?; // edits same message
// next handler call β fresh messageUser messages are not deleted. Combine with freeze_message() to keep important messages
across navigate() transitions.
Typed per-chat state with zero boilerplate:
// key-value
ctx.set("counter", &42);
let n: i32 = ctx.get("counter").unwrap_or(0);
// or full typed state
#[derive(Serialize, Deserialize, Default)]
struct Profile { xp: u64, level: u32 }
let p: Profile = ctx.state();
ctx.set_state(&Profile { xp: 100, level: 2 });4 backends, same API:
| Backend | Setup | Persistence |
|---|---|---|
| In-memory | default | none |
| Memory + snapshot | .snapshot("state.bin") |
periodic flush to disk |
| redb | .redb_store("bot.redb") |
pure Rust, ACID, zero C deps |
| Redis | .redis_store("redis://...") |
multi-instance, feature redis |
FTL-based with automatic user language detection:
// locales/en.ftl: greeting = Hello, { $name }!
// locales/ru.ftl: greeting = ΠΡΠΈΠ²Π΅Ρ, { $name }!
let text = ctx.t_with("greeting", &[("name", "World")]);
// β "Hello, World!" or "ΠΡΠΈΠ²Π΅Ρ, World!" depending on user.language_codeFramework labels (back, next, cancel) are auto-localized.
Mass-message all users with built-in rate limiting and optional dismiss button:
let screen = Screen::text("update", "π New feature!").build();
let result = broadcast(&bot, &store, screen, BroadcastOptions::default().hideable()).await;
// result.sent = 1523, result.blocked = 12, result.failed = 0Declarative result builders with auto-pagination:
.on_inline(handler!(ctx, query, offset => {
let results = search(&query).iter().map(|r|
InlineResult::article(&r.id)
.title(&r.title)
.description(&r.summary)
.screen(Screen::text("r", &r.body).build())
.build()
).collect();
let answer = InlineAnswer::new(results).per_page(20).cache_time(60);
let (page, next) = answer.paginate(&offset);
ctx.answer_inline(page.into_iter().map(|r| r.clone().into()).collect(), Some(next), Some(60), false).await
}))Composable middleware chain β auth, throttle, logging, analytics:
App::builder("TOKEN")
.middleware(LoggingMiddleware)
.middleware(ThrottleMiddleware::new(5, Duration::from_secs(1)))
.middleware(AuthMiddleware::new(vec![UserId(123456)]))
.run().await;Full test harness with MockBotApi β no network, no tokens:
use blazegram::{handler, prelude::*};
use blazegram::testing::TestApp;
fn make_router() -> Router {
let mut r = Router::new();
r.command("start", handler!(ctx => {
ctx.navigate(Screen::text("home", "Pick a side.").build()).await
}));
r
}
#[tokio::test]
async fn test_start() {
let app = TestApp::new(make_router());
app.send_message(100, "/start").await.unwrap();
let msgs = app.sent_messages();
assert!(msgs.last().unwrap().text.contains("Pick a side"));
}
#[tokio::test]
async fn test_callback() {
let app = TestApp::new(make_router());
app.send_message(100, "/start").await.unwrap();
app.send_callback(100, "pick:dark").await.unwrap();
}Simulate any update type: text, callbacks, photos, voice, stickers, locations, payments, member joins/leaves.
// Send invoice (Telegram Stars)
ctx.send_invoice(Invoice {
title: "Premium".into(),
description: "Unlock premium features".into(),
payload: "premium_1".into(),
currency: "XTR".into(),
prices: vec![("Premium".into(), 100)],
provider_token: None, // None = Stars
..Default::default()
}).await?;
// Handle checkout
.on_pre_checkout(handler!(ctx => { ctx.approve_checkout().await }))
.on_successful_payment(handler!(ctx => {
ctx.navigate(Screen::text("ty", "Thanks for your purchase! π").build()).await
}))Unrecognized messages are deleted by default to keep the chat clean.
Disable with .delete_unrecognized(false).
Rate limiting is adaptive: global (30 rps), per-chat (1 rps private, 20/min groups),
with automatic FLOOD_WAIT retry. answer_callback_query bypasses the limiter.
Entity fallback: if HTML formatting fails, the executor automatically retries as plain text.
handler! macro eliminates Box::pin(async move { ... }) boilerplate:
handler!(ctx => { ... }) // commands, callbacks
handler!(ctx, text => { ... }) // on_input
form_handler!(ctx, data => { ... }) // form completion Handlers .command() / .callback() / .on_input()
β
βΌ
Ctx navigate() / push() / pop() / reply()
β
βΌ
Differ old msgs + new Screen β minimal ops
β
βΌ
Executor FLOOD_WAIT retry, entity fallback
β
βΌ
BotApi 70+ async methods (trait, mockable)
β
βΌ
grammers MTProto β Telegram DC (persistent TCP)
Per-chat mutex guarantees sequential update processing. No race conditions.
MIT