blazegram 0.4.1

Telegram bot framework: clean chats, zero garbage, declarative screens, pure Rust MTProto.
Documentation

blazegram

Declarative Telegram bot framework for Rust.
One screen at a time. Zero garbage in chat. Direct MTProto over persistent TCP.

Crates.io docs.rs License: MIT Rust 1.85+


Why blazegram?

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.

Quick start

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


Core concepts

Screens & the Differ

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();

Navigation stack

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 back

Features

🔲 Keyboards & Grids

Fluent 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
)

📄 Pagination

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.

📝 Multi-step forms

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()

Bad input auto-deleted, error shown as 3 s toast, cancel button built-in.

⚡ Progressive updates (streaming)

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.

💬 Reply mode

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 message

User messages are not deleted. Combine with freeze_message() to keep important messages across navigate() transitions.

💾 State management

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

🌍 i18n

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_code

Framework labels (back, next, cancel) are auto-localized.

📡 Broadcast

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 = 0

🔌 Inline mode

Declarative 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
}))

🛡️ Middleware

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;

🧪 Testing

Full test harness with MockBotApi — no network, no tokens:

#[tokio::test]
async fn test_start() {
    let app = TestApp::new();
    let reply = app.send_command("/start").await;
    assert!(reply.text.contains("Pick a side"));
}

#[tokio::test]
async fn test_callback() {
    let app = TestApp::new();
    app.send_command("/start").await;
    let reply = app.press_button("pick:dark").await;
    assert!(reply.text.contains("dark"));
}

Simulate any update type: text, callbacks, photos, voice, stickers, locations, payments, member joins/leaves.

💳 Payments (Stars & Fiat)

// 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
}))

Good to know

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

Architecture

   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.

License

MIT