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

⚡ ferogram

A modular, production-grade async Rust implementation of the Telegram MTProto protocol

Crates.io docs.rs TL Layer License Rust

ferogram is a hand-written, bottom-up implementation of Telegram MTProto in pure Rust. Every component: from the .tl schema parser, to AES-IGE encryption, to the Diffie-Hellman key exchange, to the typed async update stream: is owned and understood by this project.

No black boxes. No magic. Just Rust, all the way down.


Why ferogram?

Most Telegram libraries are thin wrappers around generated code or ports from Python/JavaScript. ferogram is different: it was built from scratch to understand MTProto at the lowest level, then exposed through a straightforward high-level API.

🦀
Pure Rust
No FFI, no unsafe blocks. Fully async with Tokio. Works on Android (Termux), Linux, macOS, Windows.
Full MTProto 2.0
Complete DH handshake, AES-IGE encryption, salt tracking, DC migration: all handled automatically.
🔐
User + Bot Auth
Phone login with 2FA SRP, bot token login, session persistence across restarts.
📡
Typed Update Stream
NewMessage, MessageEdited, CallbackQuery, InlineQuery, ChatAction, UserStatus: all strongly typed.
🔧
Raw API Escape Hatch
Call any of 500+ Telegram API methods directly via client.invoke() with full type safety.
🏗️
Auto-Generated Types
All 2,329 Layer 224 constructors generated at build time from the official TL schema.

Crate overview

CrateDescriptionTypical user
ferogramHigh-level async client: auth, send, receive, bots✅ You
ferogram-tl-typesAll Layer 224 constructors, functions, enumsRaw API calls
ferogram-mtprotoMTProto session, DH, framing, transportLibrary authors
ferogram-cryptoAES-IGE, RSA, SHA, auth key derivationInternal
ferogram-tl-genBuild-time Rust code generatorBuild tool
ferogram-tl-parser.tl schema → AST parserBuild tool

TIP: Most users only ever import ferogram. The other crates are either used internally or for advanced raw API calls.


Quick install

[dependencies]
ferogram = "0.1.1"
tokio        = { version = "1", features = ["full"] }

Then head to Installation for credentials setup, or jump straight to:


Initial Release v0.1.0

Proxy & Transport

  • MTProxy support: connect via t.me/proxy / tg://proxy links or manual host/port/secret config
  • PaddedIntermediate transport (0xDD secrets): randomized padding to mimic official Telegram traffic
  • FakeTLS transport (0xEE secrets): TLS-like framing to make MTProto traffic resemble HTTPS
  • SOCKS5 proxy support in Config with optional username/password authentication
  • IPv6 connectivity for Telegram DCs and proxy connections

Session & Client

  • Multiple session backend support with new Config helpers and builder methods for MTProxy

Protocol Fixes

  • Auth key generation fixed: now uses correct PQInnerDataDc constructor including the DC id: resolves auth failures on many DCs
  • Incoming message validation: rolling buffer of last 500 server msg_ids + ±300 s timestamp window to prevent replay attacks
  • dh_gen_retry handling: step 3 now retries with cached params, up to 5 attempts (matching Telegram Desktop)
  • MTProxy routing bug fixed: connections now correctly route through the proxy host instead of going directly to Telegram DCs
  • Channel difference sync: initial getChannelDifference starts at limit 100, subsequent calls increase to 1000

See the full CHANGELOG.


Author

Developed by Ankit Chaubey out of curiosity to explore.

Crates.io docs.rs License TL Layer Telegram

ferogram is developed as part of exploration, learning, and experimentation with the Telegram MTProto protocol. Use it at your own risk. Its future and stability are not yet guaranteed.


Terms of Service

Ensure your usage complies with Telegram’s Terms of Service and API Terms of Service. Misuse of the Telegram API, including spam, mass scraping, or automation of normal user accounts, may result in account limitations or permanent bans.

Installation

Add to Cargo.toml

[dependencies]
ferogram = "0.1.1"
tokio        = { version = "1", features = ["full"] }

ferogram re-exports everything you need for both user clients and bots.


Getting API credentials

Every Telegram API call requires an api_id (integer) and api_hash (hex string) from your registered app.

Step-by-step:

  1. Go to https://my.telegram.org and log in with your phone number
  2. Click API development tools
  3. Fill in any app name, short name, platform (Desktop), and URL (can be blank)
  4. Click Create application
  5. Copy App api_id and App api_hash

SECURITY: Never hardcode credentials in source code. Use environment variables or a secrets file that is in .gitignore.

#![allow(unused)]
fn main() {
// Good: from environment
let api_id:   i32    = std::env::var("TG_API_ID")?.parse()?;
let api_hash: String = std::env::var("TG_API_HASH")?;

// Bad: hardcoded in source
let api_id   = 12345;
let api_hash = "deadbeef..."; // ← never do this in a public repo
}

Bot token (bots only)

For bots, additionally get a bot token from @BotFather:

  1. Open Telegram → search @BotFather/start
  2. Send /newbot
  3. Choose a display name (e.g. “My Awesome Bot”)
  4. Choose a username ending in bot (e.g. my_awesome_bot)
  5. Copy the token: 1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ

Optional features

SQLite session storage

ferogram = { version = "0.1.1", features = ["sqlite-session"] }

Stores session data in a SQLite database instead of a binary file. More robust for long-running servers.

LibSQL / Turso session storage: New in v0.1.1

ferogram = { version = "0.1.1", features = ["libsql-session"] }

Backed by libsql: supports local embedded databases and remote Turso cloud databases. Ideal for serverless or distributed deployments.

#![allow(unused)]
fn main() {
use ferogram::session_backend::LibSqlBackend;

// Local
let backend = LibSqlBackend::open_local("session.libsql").await?;

// Remote (Turso cloud)
let backend = LibSqlBackend::open_remote(
    "libsql://your-db.turso.io",
    "your-turso-auth-token",
).await?;
}

String session (portable, no extra deps): New in v0.1.1

No feature flag needed. Encode a session as a base64 string and restore it anywhere:

#![allow(unused)]
fn main() {
// Export
let s = client.export_session_string().await?;

// Restore
let (client, _shutdown) = Client::with_string_session(
    &s, api_id, api_hash,
).await?;
}

See Session Backends for the full guide.

HTML entity parsing

# Built-in hand-rolled HTML parser (no extra deps)
ferogram = { version = "0.1.1", features = ["html"] }

# OR: spec-compliant html5ever tokenizer (overrides built-in)
ferogram = { version = "0.1.1", features = ["html5ever"] }
FeatureDeps addedNotes
htmlnoneFast, minimal, covers common Telegram HTML tags
html5everhtml5everFull spec-compliant tokenizer; use when parsing arbitrary HTML

Raw type system features (ferogram-tl-types)

If you use ferogram-tl-types directly for raw API access:

ferogram-tl-types = { version = "0.1.1", features = [
    "tl-api",          # Telegram API types (required)
    "tl-mtproto",      # Low-level MTProto types
    "impl-debug",      # Debug trait on all types (default ON)
    "impl-from-type",  # From<types::T> for enums::E (default ON)
    "impl-from-enum",  # TryFrom<enums::E> for types::T (default ON)
    "name-for-id",     # name_for_id(u32) -> Option<&'static str>
    "impl-serde",      # serde::Serialize / Deserialize
] }

Verifying installation

use ferogram_tl_types::LAYER;

fn main() {
    println!("Using Telegram API Layer {}", LAYER);
    // → Using Telegram API Layer 224
}

Platform notes

PlatformStatusNotes
Linux x86_64✅ Fully supported
macOS (Apple Silicon + Intel)✅ Fully supported
Windows✅ SupportedUse WSL2 for best experience
Android (Termux)✅ WorksNative ARM64
iOS⚠️ UntestedNo async runtime constraints known

Quick Start: User Account

A complete working example: connect, log in, send a message to Saved Messages, and listen for incoming messages.

use ferogram::{Client, Config, SignInError};
use ferogram::update::Update;
use std::io::{self, Write};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (client, _shutdown) = Client::builder()
        .api_id(std::env::var("TG_API_ID")?.parse()?)
        .api_hash(std::env::var("TG_API_HASH")?)
        .session("my.session")
        .connect()
        .await?;

    // Login (skipped if session file already has valid auth)
    if !client.is_authorized().await? {
        print!("Phone number (+1234567890): ");
        io::stdout().flush()?;
        let phone = read_line();

        let token = client.request_login_code(&phone).await?;

        print!("Verification code: ");
        io::stdout().flush()?;
        let code = read_line();

        match client.sign_in(&token, &code).await {
            Ok(name) => println!("✅ Signed in as {name}"),
            Err(SignInError::PasswordRequired(pw_token)) => {
                print!("2FA password: ");
                io::stdout().flush()?;
                let pw = read_line();
                client.check_password(pw_token, &pw).await?;
                println!("✅ 2FA verified");
            }
            Err(e) => return Err(e.into()),
        }
        client.save_session().await?;
    }

    // Send a message to yourself
    client.send_to_self("Hello from ferogram! 👋").await?;
    println!("Message sent to Saved Messages");

    // Stream incoming updates
    println!("Listening for messages… (Ctrl+C to quit)");
    let mut updates = client.stream_updates();

    while let Some(update) = updates.next().await {
        match update {
            Update::NewMessage(msg) if !msg.outgoing() => {
                let text   = msg.text().unwrap_or("(no text)");
                let sender = msg.sender_id()
                    .map(|p| format!("{p:?}"))
                    .unwrap_or_else(|| "unknown".into());

                println!("📨 [{sender}] {text}");
            }
            Update::MessageEdited(msg) => {
                println!("✏️  Edited: {}", msg.text().unwrap_or(""));
            }
            _ => {}
        }
    }

    Ok(())
}

fn read_line() -> String {
    let mut s = String::new();
    io::stdin().read_line(&mut s).unwrap();
    s.trim().to_string()
}

Run it

TG_API_ID=12345 TG_API_HASH=yourHash cargo run

On first run you’ll be prompted for your phone number and the code Telegram sends. On subsequent runs, the session is reloaded from my.session and login is skipped automatically.


What each step does

StepMethodDescription
ConnectClient::builder().connect()Opens TCP, performs DH handshake, loads session
Check authis_authorizedReturns true if session has a valid logged-in user
Request coderequest_login_codeSends SMS/app code to the phone
Sign insign_inSubmits the code. Returns PasswordRequired if 2FA is on
2FAcheck_passwordPerforms SRP exchange: password never sent in plain text
Savesave_sessionWrites auth key + DC info to disk
Streamstream_updatesReturns an UpdateStream async iterator

Next steps

Quick Start: Bot

A production-ready bot skeleton with commands, callback queries, and inline mode: all handled concurrently.

use ferogram::{Client, InputMessage, parsers::parse_markdown, update::Update};
use ferogram_tl_types as tl;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (client, _shutdown) = Client::builder()
        .api_id(std::env::var("API_ID")?.parse()?)
        .api_hash(std::env::var("API_HASH")?)
        .session("bot.session")
        .connect()
        .await?;
    let client = Arc::new(client);

    if !client.is_authorized().await? {
        client.bot_sign_in(&std::env::var("BOT_TOKEN")?).await?;
        client.save_session().await?;
    }

    let me = client.get_me().await?;
    println!("✅ @{} is online", me.username.as_deref().unwrap_or("bot"));

    let mut updates = client.stream_updates();

    while let Some(update) = updates.next().await {
        let client = client.clone();
        // Each update in its own task: the loop never blocks
        tokio::spawn(async move {
            if let Err(e) = dispatch(update, &client).await {
                eprintln!("Handler error: {e}");
            }
        });
    }

    Ok(())
}

async fn dispatch(
    update: Update,
    client: &Client,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {

    match update {
        // Commands
        Update::NewMessage(msg) if !msg.outgoing() => {
            let text = msg.text().unwrap_or("").trim().to_string();
            let peer = match msg.peer_id() {
                Some(p) => p.clone(),
                None    => return Ok(()),
            };
            let reply_to = msg.id();

            if !text.starts_with('/') { return Ok(()); }

            let cmd = text.split_whitespace().next().unwrap_or("");
            let arg = text[cmd.len()..].trim();

            match cmd {
                "/start" => {
                    let (t, e) = parse_markdown(
                        "👋 **Hello!** I'm built with **ferogram**: async Telegram MTProto in Rust 🦀\n\n\
                         Use /help to see all commands."
                    );
                    client.send_message_to_peer_ex(peer, &InputMessage::text(t)
                        .entities(e).reply_to(Some(reply_to))).await?;
                }
                "/help" => {
                    let (t, e) = parse_markdown(
                        "📖 **Commands**\n\n\
                         /start: Welcome message\n\
                         /ping: Latency check\n\
                         /echo `<text>`: Repeat your text\n\
                         /upper `<text>`: UPPERCASE\n\
                         /lower `<text>`: lowercase\n\
                         /reverse `<text>`: esreveR\n\
                         /id: Your user and chat ID"
                    );
                    client.send_message_to_peer_ex(peer, &InputMessage::text(t)
                        .entities(e).reply_to(Some(reply_to))).await?;
                }
                "/ping" => {
                    let start = std::time::Instant::now();
                    client.send_message_to_peer(peer.clone(), "🏓 …").await?;
                    let ms = start.elapsed().as_millis();
                    let (t, e) = parse_markdown(&format!("🏓 **Pong!** `{ms} ms`"));
                    client.send_message_to_peer_ex(peer, &InputMessage::text(t)
                        .entities(e).reply_to(Some(reply_to))).await?;
                }
                "/echo"    => { client.send_message_to_peer(peer, if arg.is_empty() { "Usage: /echo <text>" } else { arg }).await?; }
                "/upper"   => { client.send_message_to_peer(peer, &arg.to_uppercase()).await?; }
                "/lower"   => { client.send_message_to_peer(peer, &arg.to_lowercase()).await?; }
                "/reverse" => {
                    let rev: String = arg.chars().rev().collect();
                    client.send_message_to_peer(peer, &rev).await?;
                }
                "/id" => {
                    let chat = match &peer {
                        tl::enums::Peer::User(u)    => format!("User `{}`",    u.user_id),
                        tl::enums::Peer::Chat(c)    => format!("Group `{}`",   c.chat_id),
                        tl::enums::Peer::Channel(c) => format!("Channel `{}`", c.channel_id),
                    };
                    let (t, e) = parse_markdown(&format!("🪪 **Chat:** {chat}"));
                    client.send_message_to_peer_ex(peer, &InputMessage::text(t)
                        .entities(e).reply_to(Some(reply_to))).await?;
                }
                _ => { client.send_message_to_peer(peer, "❓ Unknown command. Try /help").await?; }
            }
        }

        // Callback queries
        Update::CallbackQuery(cb) => {
            match cb.data().unwrap_or("") {
                "help"  => { client.answer_callback_query(cb.query_id, Some("Send /help for commands"), false).await?; }
                "about" => { client.answer_callback_query(cb.query_id, Some("Built with ferogram: Rust MTProto 🦀"), true).await?; }
                _       => { client.answer_callback_query(cb.query_id, None, false).await?; }
            }
        }

        // Inline mode
        Update::InlineQuery(iq) => {
            let q   = iq.query().to_string();
            let qid = iq.query_id;
            let results = vec![
                make_article("1", "🔠 UPPER", &q.to_uppercase()),
                make_article("2", "🔡 lower", &q.to_lowercase()),
                make_article("3", "🔄 Reversed", &q.chars().rev().collect::<String>()),
            ];
            client.answer_inline_query(qid, results, 30, false, None).await?;
        }

        _ => {}
    }

    Ok(())
}

fn make_article(id: &str, title: &str, text: &str) -> tl::enums::InputBotInlineResult {
    tl::enums::InputBotInlineResult::InputBotInlineResult(tl::types::InputBotInlineResult {
        id: id.into(), r#type: "article".into(),
        title: Some(title.into()), description: Some(text.into()),
        url: None, thumb: None, content: None,
        send_message: tl::enums::InputBotInlineMessage::Text(
            tl::types::InputBotInlineMessageText {
                no_webpage: false, invert_media: false,
                message: text.into(), entities: None, reply_markup: None,
            }
        ),
    })
}

Key differences: User vs Bot

CapabilityUser accountBot
Login methodPhone + code + optional 2FABot token from @BotFather
Read all messages✅ In any joined chat❌ Only messages directed at it
Send to any peer❌ User must start the bot first
Inline mode@botname query in any chat
Callback queries
Anonymous in groups✅ If admin
Rate limitsStricterMore generous

Next steps

User Login

User login happens in three steps: request code → submit code → (optional) submit 2FA password.

Step 1: Request login code

#![allow(unused)]
fn main() {
let token = client.request_login_code("+1234567890").await?;
}

This sends a verification code to the phone number via SMS or Telegram app notification. The returned LoginToken must be passed to the next step.

Step 2: Submit the code

#![allow(unused)]
fn main() {
match client.sign_in(&token, "12345").await {
    Ok(name) => {
        println!("Signed in as {name}");
    }
    Err(SignInError::PasswordRequired(password_token)) => {
        // 2FA is enabled: go to step 3
    }
    Err(e) => return Err(e.into()),
}
}

sign_in returns:

  • Ok(String): the user’s full name, login complete
  • Err(SignInError::PasswordRequired(PasswordToken)): 2FA is enabled, need password
  • Err(e): wrong code, expired code, or network error

Step 3: 2FA password (if required)

#![allow(unused)]
fn main() {
client.check_password(password_token, "my_2fa_password").await?;
}

This performs the full SRP (Secure Remote Password) exchange. The password is never sent to Telegram in plain text: only a cryptographic proof is transmitted.

Save the session

After a successful login, always save the session so you don’t need to log in again:

#![allow(unused)]
fn main() {
client.save_session().await?;
}

Full example with stdin

#![allow(unused)]
fn main() {
use ferogram::{Client, Config, SignInError};
use std::io::{self, BufRead, Write};

async fn login(client: &Client) -> Result<(), Box<dyn std::error::Error>> {
    if client.is_authorized().await? {
        return Ok(());
    }

    print!("Phone number: ");
    io::stdout().flush()?;
    let phone = read_line();

    let token = client.request_login_code(&phone).await?;

    print!("Code: ");
    io::stdout().flush()?;
    let code = read_line();

    match client.sign_in(&token, &code).await {
        Ok(name) => println!("✅ Welcome, {name}!"),
        Err(SignInError::PasswordRequired(t)) => {
            print!("2FA password: ");
            io::stdout().flush()?;
            let pw = read_line();
            client.check_password(t, &pw).await?;
            println!("✅ 2FA verified");
        }
        Err(e) => return Err(e.into()),
    }

    client.save_session().await?;
    Ok(())
}

fn read_line() -> String {
    let stdin = io::stdin();
    stdin.lock().lines().next().unwrap().unwrap().trim().to_string()
}
}

Sign out

#![allow(unused)]
fn main() {
client.sign_out().await?;
}

This revokes the auth key on Telegram’s servers and deletes the local session file.


How the DH auth key exchange works

Under the hood, every new session establishes a shared auth key via a 3-step Diffie-Hellman exchange before any login code is ever sent. This key is what secures the entire session.

  1. Client sends req_pq_multi: server responds with a pq product
  2. Client factorises pq into primes (Pollard’s rho), encrypts its DH parameters with the server’s RSA key
  3. Server responds with server_DH_params_ok: client completes g^ab mod p
  4. Both sides now share a 2048-bit auth key: login code is sent encrypted using this key

See Crate Architecture for more on the MTProto internals.

Bot Login

Bot login is simpler than user login: just a single call with a bot token.

Getting a bot token

  1. Open Telegram and start a chat with @BotFather
  2. Send /newbot
  3. Follow the prompts to choose a name and username
  4. BotFather gives you a token like: 1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ

Login

#![allow(unused)]
fn main() {
client.bot_sign_in("1234567890:ABCdef...").await?;
client.save_session().await?;
}

That’s it. On the next run, is_authorized() returns true and you skip the login entirely:

#![allow(unused)]
fn main() {
if !client.is_authorized().await? {
    client.bot_sign_in(BOT_TOKEN).await?;
    client.save_session().await?;
}
}

Get bot info

After login you can fetch the bot’s own User object:

#![allow(unused)]
fn main() {
let me = client.get_me().await?;
println!("Bot: @{}", me.username.as_deref().unwrap_or("?"));
println!("ID: {}", me.id);
println!("Is bot: {}", me.bot);
}

Don’t hardcode credentials in source code. Use environment variables instead:

#![allow(unused)]
fn main() {
let api_id: i32   = std::env::var("API_ID")?.parse()?;
let api_hash      = std::env::var("API_HASH")?;
let bot_token     = std::env::var("BOT_TOKEN")?;
}

Then run:

API_ID=12345 API_HASH=abc123 BOT_TOKEN=xxx:yyy cargo run

Or put them in a .env file and use the dotenvy crate.

Two-Factor Authentication (2FA)

Telegram’s 2FA uses Secure Remote Password (SRP): a zero-knowledge proof. Your password is never sent to Telegram’s servers; only a cryptographic proof is transmitted.

How it works in ferogram

#![allow(unused)]
fn main() {
match client.sign_in(&login_token, &code).await {
    Ok(name) => {
        // ✅ No 2FA: login complete
        println!("Welcome, {name}!");
    }
    Err(SignInError::PasswordRequired(password_token)) => {
        // 2FA is enabled: the password_token carries SRP parameters
        client.check_password(password_token, "my_2fa_password").await?;
        println!("✅ 2FA verified");
    }
    Err(e) => return Err(e.into()),
}
}

check_password performs the full SRP computation internally:

  1. Downloads SRP parameters from Telegram (account.getPassword)
  2. Derives a verifier from your password using PBKDF2-SHA512
  3. Computes the SRP proof and sends it (auth.checkPassword)

Getting the password hint

The PasswordToken gives you access to the hint the user set when enabling 2FA:

#![allow(unused)]
fn main() {
Err(SignInError::PasswordRequired(token)) => {
    let hint = token.hint().unwrap_or("no hint set");
    println!("Enter your 2FA password (hint: {hint}):");
    let pw = read_line();
    client.check_password(token, &pw).await?;
}
}

Changing the 2FA password

NOTE: Changing 2FA password requires calling account.updatePasswordSettings via raw API. This is an advanced operation: see Raw API Access.

Wrong password errors

#![allow(unused)]
fn main() {
use ferogram::{InvocationError, RpcError};

match client.check_password(token, &pw).await {
    Ok(_) => println!("✅ OK"),
    Err(InvocationError::Rpc(RpcError { message, .. }))
        if message.contains("PASSWORD_HASH_INVALID") =>
    {
        println!("❌ Wrong password. Try again.");
    }
    Err(e) => return Err(e.into()),
}
}

Security notes

  • ferogram-crypto implements the SRP math from scratch: no external SRP library
  • The password derivation uses PBKDF2-SHA512 with 100,000+ iterations
  • The SRP exchange is authenticated: a MITM cannot substitute their own verifier

Session Persistence

A session stores your auth key, DC address, and peer access-hash cache. Without it, you’d need to log in on every run.

Binary file (default)

#![allow(unused)]
fn main() {
use ferogram::{Client, Config};

let (client, _shutdown) = Client::builder()
        .api_id(12345)
        .api_hash("abc123")
        .session("my.session")
        .connect().await?.await?;
}

After login, save to disk:

#![allow(unused)]
fn main() {
client.save_session().await?;
}

The file is created at session_path and reloaded automatically on the next Client::connect. Keep it in .gitignore: it grants full API access to your account.


In-memory (ephemeral)

Nothing written to disk. Useful for tests or short-lived scripts:

#![allow(unused)]
fn main() {
use ferogram::session_backend::InMemoryBackend;

let (client, _shutdown) = Client::builder()
    .session(InMemoryBackend::new())
    .api_id(12345)
    .api_hash("abc123")
    .connect()
    .await?;
}

Login is required on every run since nothing persists.


SQLite (robust, long-running servers)

ferogram = { version = "0.1.1", features = ["sqlite-session"] }
#![allow(unused)]
fn main() {
let (client, _shutdown) = Client::connect(Config {
    ..Default::default()
}).await?;
}

SQLite is more resilient against crash-corruption than the binary format. Ideal for production bots.


String session: New in v0.1.1

Encode the entire session as a portable base64 string. Store it in an env var, a DB column, or CI secrets:

#![allow(unused)]
fn main() {
// Export (after login)
let s = client.export_session_string().await?;
// → "AQAAAAEDAADtE1lMHBT7...=="

// Restore
let (client, _shutdown) = Client::with_string_session(
    &s, api_id, api_hash,
).await?;

// Or via builder
use ferogram::session_backend::StringSessionBackend;
let (client, _shutdown) = Client::builder()
    .session(StringSessionBackend::new(&s))
    .api_id(api_id)
    .api_hash(api_hash)
    .connect()
    .await?;
}

See Session Backends for the full guide including LibSQL (Turso) backend.


What’s stored in a session

FieldDescription
Auth key2048-bit DH-derived key for encryption
Auth key IDHash of the key, used as identifier
DC IDWhich Telegram data center to connect to
DC addressThe IP:port of the DC
Server saltUpdated regularly by Telegram
Sequence numbersFor message ordering
Peer cacheUser/channel access hashes (speeds up API calls)

Security

SECURITY: A stolen session file gives full API access to your account. Protect it like a password.

  • Add to .gitignore: *.session, *.session.db
  • Set restrictive permissions: chmod 600 my.session
  • Never log or print session file contents
  • If compromised: revoke from Telegram → Settings → Devices → Terminate session

Multi-session / multi-account

Each Client::connect loads one session. For multiple accounts, use multiple files:

#![allow(unused)]
fn main() {
let (client_a, _) = Client::connect(Config {
    api_id, api_hash: api_hash.clone(), ..Default::default()
}).await?;

let (client_b, _) = Client::connect(Config {
    api_id, api_hash: api_hash.clone(), ..Default::default()
}).await?;
}

Session Backends

ferogram ships five session backends out of the box. They all implement the SessionBackend trait and are hot-swappable: switch by changing one line.


Built-in backends

BackendFeature flagBest for
BinaryFileBackend(default, no flag)Single-process bots, local scripts
InMemoryBackend(default, no flag)Tests, ephemeral tasks
StringSessionBackend(default, no flag)Serverless, env-var storage, CI bots
SqliteBackendsqlite-sessionMulti-session local apps
LibSqlBackendlibsql-sessionDistributed / Turso-backed storage

BinaryFileBackend (default)

Saves the session as a binary file on disk. No feature flag needed.

#![allow(unused)]
fn main() {
use ferogram::Client;

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_api_hash")
    .session("my.session")  // BinaryFileBackend at this path
    .connect()
    .await?;
}

Or construct directly:

#![allow(unused)]
fn main() {
use ferogram::session_backend::BinaryFileBackend;
use std::sync::Arc;

let backend = Arc::new(BinaryFileBackend::new("bot.session"));
}

InMemoryBackend

Non-persistent: lost on process exit. Ideal for tests.

#![allow(unused)]
fn main() {
let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_api_hash")
    .in_memory()
    .connect()
    .await?;
}

Or construct directly:

#![allow(unused)]
fn main() {
use ferogram::session_backend::InMemoryBackend;
use std::sync::Arc;

let backend = Arc::new(InMemoryBackend::new());
}

StringSessionBackend: portable auth

Encodes the entire session (auth key + DC + peer cache) as a single base64 string. Store it in an environment variable, a database column, or a secret manager.

Export

#![allow(unused)]
fn main() {
let session_string = client.export_session_string().await?;
println!("{session_string}"); // store this somewhere safe
}

Restore

#![allow(unused)]
fn main() {
// Via builder
let session = std::env::var("TG_SESSION").unwrap_or_default();

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_api_hash")
    .session_string(session)
    .connect()
    .await?;
}
#![allow(unused)]
fn main() {
// Via Config shorthand
let (client, _shutdown) = Client::connect(Config::with_string_session(session_string))
    .await?;
}
#![allow(unused)]
fn main() {
// Construct backend directly
use ferogram::session_backend::StringSessionBackend;
use std::sync::Arc;

let backend = Arc::new(StringSessionBackend::new(session_string));
}

Pass an empty string to start a fresh session with no stored data.


SqliteBackend: local database

Requires feature flag:

ferogram = { version = "0.1.1", features = ["sqlite-session"] }
#![allow(unused)]
fn main() {
use ferogram::SqliteBackend;
use std::sync::Arc;

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_api_hash")
    .session_backend(Arc::new(SqliteBackend::new("sessions.db")))
    .connect()
    .await?;
}

The file is created if it doesn’t exist.


LibSqlBackend: libsql / Turso

Requires feature flag:

ferogram = { version = "0.1.1", features = ["libsql-session"] }
#![allow(unused)]
fn main() {
use ferogram::LibSqlBackend;
use std::sync::Arc;

// Local embedded database
let backend = LibSqlBackend::new("local.db");

// Remote Turso database
let backend = LibSqlBackend::remote(
    "libsql://your-db.turso.io",
    "your-turso-auth-token",
);

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_api_hash")
    .session_backend(Arc::new(backend))
    .connect()
    .await?;
}

Custom backend

Implement SessionBackend to use any storage: Redis, Postgres, S3, or anything else:

#![allow(unused)]
fn main() {
use ferogram::session_backend::{SessionBackend, PersistedSession, DcEntry, UpdateStateChange};
use std::io;

struct MyBackend {
    // your fields (e.g. a DB pool)
}

impl SessionBackend for MyBackend {
    fn save(&self, session: &PersistedSession) -> io::Result<()> {
        // Serialize and store session
        let bytes = serde_json::to_vec(session)
            .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
        // e.g. write to Redis / Postgres
        Ok(())
    }

    fn load(&self) -> io::Result<Option<PersistedSession>> {
        // Load and deserialize
        Ok(None)
    }

    fn delete(&self) -> io::Result<()> {
        Ok(())
    }

    fn name(&self) -> &str {
        "my-custom-backend"
    }

    // Optional: override granular methods for better performance
    // Default impls call load() → mutate → save()

    fn update_dc(&self, entry: &DcEntry) -> io::Result<()> {
        // UPDATE single DC row (e.g. SQL UPDATE)
        todo!()
    }

    fn set_home_dc(&self, dc_id: i32) -> io::Result<()> {
        // UPDATE home_dc column only
        todo!()
    }
}

// Use it
let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_api_hash")
    .session_backend(Arc::new(MyBackend { /* ... */ }))
    .connect()
    .await?;
}

Granular SessionBackend methods

High-performance backends can override these to avoid full load/save round-trips:

MethodWhen calledDefault behaviour
save(session)Any state changeRequired
load()On connect, and by default implsRequired
delete()Session wipeRequired
name()Logging/debugRequired
update_dc(entry)After DH handshake on a new DCload → mutate → save
set_home_dc(dc_id)After a MIGRATE redirectload → mutate → save
apply_update_state(change)After update-sequence changeload → mutate → save

Using ClientBuilder to attach a backend

#![allow(unused)]
fn main() {
use std::sync::Arc;
use ferogram::{Client, session_backend::SessionBackend};

async fn connect_with_backend(
    backend: impl SessionBackend + 'static,
    api_id: i32,
    api_hash: &str,
) -> anyhow::Result<()> {
    let (client, _shutdown) = Client::builder()
        .api_id(api_id)
        .api_hash(api_hash)
        .session_backend(Arc::new(backend))
        .connect()
        .await?;

    // ...
    Ok(())
}
}

Additional backend methods

StringSessionBackend::current()

Reads the current serialised session string at any time:

#![allow(unused)]
fn main() {
use ferogram::session_backend::StringSessionBackend;

let backend = StringSessionBackend::new("");
// ... after connecting and authenticating ...
let s = backend.current(); // base64 session string
}

BinaryFileBackend::path()

#![allow(unused)]
fn main() {
use ferogram::session_backend::BinaryFileBackend;

let backend = BinaryFileBackend::new("bot.session");
println!("Saving to: {}", backend.path().display());
}

InMemoryBackend::snapshot()

Take a point-in-time snapshot of the in-memory session (useful in tests):

#![allow(unused)]
fn main() {
use ferogram::session_backend::InMemoryBackend;

let backend = InMemoryBackend::new();
// ... after auth ...
if let Some(session) = backend.snapshot() {
    // inspect or serialize session data
}
}

Sending Messages

Basic send

#![allow(unused)]
fn main() {
// By username
client.send_message("@username", "Hello!").await?;

// To yourself (Saved Messages)
client.send_message("me", "Note to self").await?;
client.send_to_self("Quick note").await?;

// By numeric ID (string form)
client.send_message("123456789", "Hi").await?;
}

Send to a resolved peer

#![allow(unused)]
fn main() {
let peer = client.resolve_peer("@username").await?;
client.send_message_to_peer(peer, "Hello!").await?;
}

Rich messages with InputMessage

InputMessage gives you full control over formatting, entities, reply markup, and more:

#![allow(unused)]
fn main() {
use ferogram::{InputMessage, parsers::parse_markdown};

// Markdown formatting
let (text, entities) = parse_markdown("**Bold** and _italic_ and `code`");
let msg = InputMessage::text(text)
    .entities(entities);

client.send_message_to_peer_ex(peer, &msg).await?;
}

Reply to a message

#![allow(unused)]
fn main() {
let msg = InputMessage::text("This is a reply")
    .reply_to(Some(original_message_id));

client.send_message_to_peer_ex(peer, &msg).await?;
}

With inline keyboard

#![allow(unused)]
fn main() {
use ferogram_tl_types as tl;

let keyboard = tl::enums::ReplyMarkup::ReplyInlineMarkup(
    tl::types::ReplyInlineMarkup {
        rows: vec![
            tl::enums::KeyboardButtonRow::KeyboardButtonRow(
                tl::types::KeyboardButtonRow {
                    buttons: vec![
                        tl::enums::KeyboardButton::Callback(
                            tl::types::KeyboardButtonCallback {
                                requires_password: false,
                                style: None,
                                text: "Click me!".into(),
                                data: b"my_data".to_vec(),
                            }
                        ),
                    ]
                }
            )
        ]
    }
);

let msg = InputMessage::text("Choose an option:")
    .reply_markup(keyboard);

client.send_message_to_peer_ex(peer, &msg).await?;
}

Delete messages

#![allow(unused)]
fn main() {
// revoke = true removes for everyone, false removes only for you
client.delete_messages(vec![msg_id_1, msg_id_2], true).await?;
}

Fetch message history

#![allow(unused)]
fn main() {
// (peer, limit, offset_id)
// offset_id = 0 means start from the newest
let messages = client.get_messages(peer, 50, 0).await?;

for msg in messages {
    if let tl::enums::Message::Message(m) = msg {
        println!("{}: {}", m.id, m.message);
    }
}
}

Receiving Updates

The update stream

stream_updates() returns an async stream of typed Update events:

#![allow(unused)]
fn main() {
use ferogram::update::Update;

let mut updates = client.stream_updates();

while let Some(update) = updates.next().await {
    match update {
        Update::NewMessage(msg)     => { /* new message arrived */ }
        Update::MessageEdited(msg)  => { /* message was edited */ }
        Update::MessageDeleted(del) => { /* message was deleted */ }
        Update::CallbackQuery(cb)   => { /* inline button pressed */ }
        Update::InlineQuery(iq)     => { /* @bot query in another chat */ }
        Update::InlineSend(is)      => { /* inline result was chosen */ }
        Update::Raw(raw)            => { /* any other update by constructor ID */ }
        _ => {}
    }
}
}

Concurrent update handling

For bots under load, spawn each update into its own task so the receive loop never blocks:

#![allow(unused)]
fn main() {
use std::sync::Arc;

let client = Arc::new(client);
let mut updates = client.stream_updates();

while let Some(update) = updates.next().await {
    let client = client.clone();
    tokio::spawn(async move {
        handle(update, client).await;
    });
}
}

Filtering outgoing messages

In user accounts, your own sent messages come back as updates with out = true. Filter them:

#![allow(unused)]
fn main() {
Update::NewMessage(msg) if !msg.outgoing() => {
    // only incoming messages
}
}

MessageDeleted

Deleted message updates only contain the message IDs, not the content:

#![allow(unused)]
fn main() {
Update::MessageDeleted(del) => {
    println!("Deleted IDs: {:?}", del.messages());
    // del.channel_id(): Some if deleted from a channel
}
}

Inline Keyboards & Reply Markup

ferogram ships with two high-level keyboard builders: InlineKeyboard and ReplyKeyboard: so you never have to construct raw TL types by hand.

Both builders are in ferogram::keyboard and re-exported at the crate root:

#![allow(unused)]
fn main() {
use ferogram::keyboard::{Button, InlineKeyboard, ReplyKeyboard};
}

InlineKeyboard: buttons attached to a message

Inline keyboards appear below a message and trigger Update::CallbackQuery when tapped.

#![allow(unused)]
fn main() {
use ferogram::keyboard::{Button, InlineKeyboard};
use ferogram::InputMessage;

let kb = InlineKeyboard::new()
    .row([
        Button::callback("✅ Yes", b"confirm:yes"),
        Button::callback("❌ No",  b"confirm:no"),
    ])
    .row([
        Button::url("📖 Docs", "https://docs.rs/ferogram"),
    ]);

client
    .send_message_to_peer_ex(
        peer.clone(),
        &InputMessage::text("Do you want to proceed?").keyboard(kb),
    )
    .await?;
}

InlineKeyboard methods

MethodDescription
InlineKeyboard::new()Create an empty keyboard
.row(buttons)Append a row; accepts any IntoIterator<Item = Button>
.into_markup()Convert to tl::enums::ReplyMarkup

InlineKeyboard implements Into<tl::enums::ReplyMarkup>, so you can also pass it directly to InputMessage::reply_markup().


Button: all button types

Callback, URL, and common types

#![allow(unused)]
fn main() {
// Sends data to your bot as Update::CallbackQuery (max 64 bytes)
Button::callback("✅ Confirm", b"action:confirm")

// Opens URL in a browser
Button::url("🌐 Website", "https://example.com")

// Copy text to clipboard (Telegram 10.3+)
Button::copy_text("📋 Copy code", "PROMO2024")

// Login-widget: authenticates user before opening URL
Button::url_auth("🔐 Login", "https://example.com/auth", None, bot_input_user)

// Opens bot inline mode in the current chat with query pre-filled
Button::switch_inline("🔍 Search here", "default query")

// Chat picker so user can choose which chat to use inline mode in
Button::switch_elsewhere("📤 Share", "")

// Telegram Mini App with full JS bridge
Button::webview("🚀 Open App", "https://myapp.example.com")

// Simple webview without JS bridge
Button::simple_webview("ℹ️ Info", "https://info.example.com")

// Plain text for reply keyboards
Button::text("📸 Send photo")

// Launch a Telegram game (bots only)
Button::game("🎮 Play")

// Payment buy button (bots only, used with invoice)
Button::buy("💳 Pay $4.99")
}

Reply-keyboard-only buttons

#![allow(unused)]
fn main() {
// Shares user's phone number on tap
Button::request_phone("📞 Share my number")

// Shares user's location on tap
Button::request_geo("📍 Share location")

// Opens poll creation interface
Button::request_poll("📊 Create poll")

// Forces quiz mode in poll creator
Button::request_quiz("🧠 Create quiz")
}

Escape hatch

#![allow(unused)]
fn main() {
// Get the underlying tl::enums::KeyboardButton
let raw = Button::callback("x", b"x").into_raw();
}

ReplyKeyboard: replacement keyboard

A reply keyboard replaces the user’s text input keyboard until dismissed. The user’s tap arrives as a plain-text Update::NewMessage.

#![allow(unused)]
fn main() {
use ferogram::keyboard::{Button, ReplyKeyboard};

let kb = ReplyKeyboard::new()
    .row([
        Button::text("📸 Photo"),
        Button::text("📄 Document"),
    ])
    .row([Button::text("❌ Cancel")])
    .resize()      // shrink to fit content (recommended)
    .single_use(); // hide after one press

client
    .send_message_to_peer_ex(
        peer.clone(),
        &InputMessage::text("Choose file type:").keyboard(kb),
    )
    .await?;
}

ReplyKeyboard methods

MethodDescription
ReplyKeyboard::new()Create an empty keyboard
.row(buttons)Append a row of buttons
.resize()Shrink keyboard to fit button count
.single_use()Dismiss after one tap
.selective()Show only to mentioned/replied users
.into_markup()Convert to tl::enums::ReplyMarkup

Remove keyboard

#![allow(unused)]
fn main() {
use ferogram_tl_types as tl;

let remove = tl::enums::ReplyMarkup::ReplyKeyboardHide(
    tl::types::ReplyKeyboardHide { selective: false }
);
client
    .send_message_to_peer_ex(peer.clone(), &InputMessage::text("Done.").reply_markup(remove))
    .await?;
}

Answer callback queries

Always answer every CallbackQuery: Telegram shows a loading spinner until you do.

#![allow(unused)]
fn main() {
Update::CallbackQuery(cb) => {
    let data = cb.data().unwrap_or(b"");
    match data {
        b"confirm:yes" => client.answer_callback_query(cb.query_id, Some("✅ Done!"), false).await?,
        b"confirm:no"  => client.answer_callback_query(cb.query_id, Some("❌ Cancelled"), false).await?,
        _              => client.answer_callback_query(cb.query_id, None, false).await?,
    }
}
}

Pass alert: true to show a popup alert instead of a toast:

#![allow(unused)]
fn main() {
client.answer_callback_query(cb.query_id, Some("⛔ Access denied"), true).await?;
}

Legacy raw TL pattern (still works)

If you prefer constructing TL types directly:

#![allow(unused)]
fn main() {
fn inline_kb(rows: Vec<Vec<tl::enums::KeyboardButton>>) -> tl::enums::ReplyMarkup {

```rust
use ferogram_tl_types as tl;

fn inline_kb(rows: Vec<Vec<tl::enums::KeyboardButton>>) -> tl::enums::ReplyMarkup {
    tl::enums::ReplyMarkup::ReplyInlineMarkup(tl::types::ReplyInlineMarkup {
        rows: rows.into_iter().map(|buttons|
            tl::enums::KeyboardButtonRow::KeyboardButtonRow(
                tl::types::KeyboardButtonRow { buttons }
            )
        ).collect(),
    })
}

fn btn_cb(text: &str, data: &str) -> tl::enums::KeyboardButton {
    tl::enums::KeyboardButton::Callback(tl::types::KeyboardButtonCallback {
        requires_password: false,
        style:             None,
        text:              text.into(),
        data:              data.as_bytes().to_vec(),
    })
}

fn btn_url(text: &str, url: &str) -> tl::enums::KeyboardButton {
    tl::enums::KeyboardButton::Url(tl::types::KeyboardButtonUrl {
        style: None,
        text:  text.into(),
        url:   url.into(),
    })
}
}

Send with keyboard

#![allow(unused)]
fn main() {
let kb = inline_kb(vec![
    vec![btn_cb("✅ Yes", "confirm:yes"), btn_cb("❌ No", "confirm:no")],
    vec![btn_url("🌐 Docs", "https://github.com/ankit-chaubey/ferogram")],
]);

let (text, entities) = parse_markdown("**Do you want to proceed?**");
let msg = InputMessage::text(text)
    .entities(entities)
    .reply_markup(kb);

client.send_message_to_peer_ex(peer, &msg).await?;
}

All button types

TypeConstructorDescription
CallbackKeyboardButtonCallbackTriggers CallbackQuery with custom data
URLKeyboardButtonUrlOpens a URL in the browser
Web AppKeyboardButtonSimpleWebViewOpens a Telegram Web App
Switch InlineKeyboardButtonSwitchInlineOpens inline mode with a query
Request PhoneKeyboardButtonRequestPhoneRequests the user’s phone number
Request LocationKeyboardButtonRequestGeoLocationRequests location
Request PollKeyboardButtonRequestPollOpens poll creator
Request PeerKeyboardButtonRequestPeerRequests peer selection
GameKeyboardButtonGameOpens a Telegram game
BuyKeyboardButtonBuyPurchase button for payments
CopyKeyboardButtonCopyCopies text to clipboard

Switch Inline button

Opens the bot’s inline mode in the current or another chat:

#![allow(unused)]
fn main() {
tl::enums::KeyboardButton::SwitchInline(tl::types::KeyboardButtonSwitchInline {
    same_peer:  false, // false = let user pick any chat
    text:       "🔍 Search with me".into(),
    query:      "default query".into(),
    peer_types: None,
})
}

Web App button

#![allow(unused)]
fn main() {
tl::enums::KeyboardButton::SimpleWebView(tl::types::KeyboardButtonSimpleWebView {
    text: "Open App".into(),
    url:  "https://myapp.example.com".into(),
})
}

Reply keyboard (replaces user’s keyboard)

#![allow(unused)]
fn main() {
let reply_kb = tl::enums::ReplyMarkup::ReplyKeyboardMarkup(
    tl::types::ReplyKeyboardMarkup {
        resize:      true,       // shrink to fit buttons
        single_use:  true,       // hide after one tap
        selective:   false,      // show to everyone
        persistent:  false,      // don't keep after message
        placeholder: Some("Choose an option…".into()),
        rows: vec![
            tl::enums::KeyboardButtonRow::KeyboardButtonRow(
                tl::types::KeyboardButtonRow {
                    buttons: vec![
                        tl::enums::KeyboardButton::KeyboardButton(
                            tl::types::KeyboardButton { text: "🍕 Pizza".into() }
                        ),
                        tl::enums::KeyboardButton::KeyboardButton(
                            tl::types::KeyboardButton { text: "🍔 Burger".into() }
                        ),
                    ]
                }
            ),
            tl::enums::KeyboardButtonRow::KeyboardButtonRow(
                tl::types::KeyboardButtonRow {
                    buttons: vec![
                        tl::enums::KeyboardButton::KeyboardButton(
                            tl::types::KeyboardButton { text: "❌ Cancel".into() }
                        ),
                    ]
                }
            ),
        ],
    }
);
}

The user’s choices arrive as plain text NewMessage updates.

Remove keyboard

#![allow(unused)]
fn main() {
let remove = tl::enums::ReplyMarkup::ReplyKeyboardHide(
    tl::types::ReplyKeyboardHide { selective: false }
);
let msg = InputMessage::text("Keyboard removed.").reply_markup(remove);
}

Button data format

Telegram limits callback button data to 64 bytes. Use compact, parseable formats:

#![allow(unused)]
fn main() {
// Good: structured, compact
"vote:yes"
"page:3"
"item:42:delete"
"menu:settings:notifications"

// Bad: verbose
"user_clicked_the_settings_button"
}

Media & Files


Upload

#![allow(unused)]
fn main() {
// Upload from bytes: sequential
let uploaded: UploadedFile = client
    .upload_file("photo.jpg", &bytes)
    .await?;

// Upload from bytes: parallel chunks (faster for large files)
let uploaded = client
    .upload_file_concurrent("video.mp4", &bytes)
    .await?;

// Upload from an async reader (e.g. tokio::fs::File)
use tokio::fs::File;
let f = File::open("document.pdf").await?;
let uploaded = client.upload_stream("document.pdf", f).await?;
}

UploadedFile methods

MethodReturnDescription
uploaded.name()&strOriginal filename
uploaded.mime_type()&strDetected MIME type
uploaded.as_document_media()tl::enums::InputMediaReady to send as document
uploaded.as_photo_media()tl::enums::InputMediaReady to send as photo

Send file

#![allow(unused)]
fn main() {
// Send as document (false) or as photo/media (true)
client.send_file(peer.clone(), uploaded, false).await?;

// Send as album (multiple files in one message group)
client.send_album(peer.clone(), vec![uploaded_a, uploaded_b]).await?;
}

AlbumItem: per-item control in albums

#![allow(unused)]
fn main() {
use ferogram::media::AlbumItem;

let items = vec![
    AlbumItem::new(uploaded_a.as_photo_media())
        .caption("First photo 📸"),
    AlbumItem::new(uploaded_b.as_document_media())
        .caption("The report 📄")
        .reply_to(Some(msg_id)),
];
client.send_album(peer.clone(), items).await?;
}
MethodDescription
AlbumItem::new(media)Wrap an InputMedia
.caption(str)Caption text for this item
.reply_to(Option<i32>)Reply to message ID

Download

#![allow(unused)]
fn main() {
// To bytes: sequential
let bytes: Vec<u8> = client.download_media(&msg_media).await?;

// To bytes: parallel chunks
let bytes = client.download_media_concurrent(&msg_media).await?;

// Stream to file
client.download_media_to_file(&msg_media, "output.jpg").await?;

// Via Downloadable trait (Photo, Document, Sticker)
let bytes = client.download(&photo).await?;
}

DownloadIter: streaming chunks

#![allow(unused)]
fn main() {
let location = msg.raw.download_location().unwrap();
let mut iter = client.iter_download(location);
iter = iter.chunk_size(128 * 1024); // 128 KB chunks

while let Some(chunk) = iter.next().await? {
    file.write_all(&chunk).await?;
}
}
MethodDescription
client.iter_download(location)Create a lazy chunk iterator
iter.chunk_size(bytes)Set download chunk size
iter.next()async → Option<Vec<u8>>

Photo type

#![allow(unused)]
fn main() {
use ferogram::media::Photo;

let photo = Photo::from_media(&msg.raw).unwrap();
// or
let photo = msg.photo().unwrap();

photo.id()                // i64
photo.access_hash()       // i64
photo.date()              // i32: Unix timestamp
photo.has_stickers()      // bool
photo.largest_thumb_type() // &str: e.g. "y", "x", "s"

let bytes = client.download(&photo).await?;
}
ConstructorDescription
Photo::from_raw(tl::types::Photo)Wrap raw TL photo
Photo::from_media(&MessageMedia)Extract from message media

Document type

#![allow(unused)]
fn main() {
use ferogram::media::Document;

let doc = Document::from_media(&msg.raw).unwrap();
// or
let doc = msg.document().unwrap();

doc.id()              // i64
doc.access_hash()     // i64
doc.date()            // i32
doc.mime_type()       // &str
doc.size()            // i64: bytes
doc.file_name()       // Option<&str>
doc.is_animated()     // bool: animated GIF or sticker

let bytes = client.download(&doc).await?;
}
ConstructorDescription
Document::from_raw(tl::types::Document)Wrap raw TL document
Document::from_media(&MessageMedia)Extract from message media

Sticker type

#![allow(unused)]
fn main() {
use ferogram::media::Sticker;

let sticker = Sticker::from_media(&msg.raw).unwrap();

sticker.id()          // i64
sticker.mime_type()   // &str: "image/webp" or "video/webm"
sticker.emoji()       // Option<&str>: associated emoji
sticker.is_video()    // bool: animated video sticker

let bytes = client.download(&sticker).await?;
}
ConstructorDescription
Sticker::from_document(Document)Wrap a document as a sticker
Sticker::from_media(&MessageMedia)Extract sticker from message

Downloadable trait

Photo, Document, and Sticker all implement Downloadable, so you can use client.download(&item) on any of them uniformly.

#![allow(unused)]
fn main() {
use ferogram::media::Downloadable;

async fn save_any<D: Downloadable>(client: &Client, item: &D) -> Vec<u8> {
    client.download(item).await.unwrap()
}
}

Download location from message

#![allow(unused)]
fn main() {
// Get an InputFileLocation from the raw message
use ferogram::media::download_location_from_media;

if let Some(loc) = download_location_from_media(&msg.raw) {
    let bytes = client.download_media(&loc).await?;
}

// Or via IncomingMessage convenience:
msg.download_media("output.jpg").await?;
}

Message Formatting

Telegram supports rich text formatting through message entities: positional markers that indicate bold, italic, code, links, and more.

Using parse_markdown

The easiest way is parse_markdown, which converts a Markdown-like syntax into a (String, Vec<MessageEntity>) tuple:

#![allow(unused)]
fn main() {
use ferogram::parsers::parse_markdown;
use ferogram::InputMessage;

let (plain, entities) = parse_markdown(
    "**Bold text**, _italic text_, `inline code`\n\
     and a [clickable link](https://example.com)"
);

let msg = InputMessage::text(plain).entities(entities);
client.send_message_to_peer_ex(peer, &msg).await?;
}

Supported syntax

MarkdownEntity typeExample
**text**BoldHello
_text_ItalicHello
*text*ItalicHello
__text__UnderlineHello
~~text~~StrikethroughHello
`text`Code (inline)Hello
```text```Pre (code block)block
||text||Spoiler▓▓▓▓▓
[label](url)Text linkclickable

Building entities manually

For full control, construct MessageEntity values directly:

#![allow(unused)]
fn main() {
use ferogram_tl_types as tl;

let text = "Hello world";
let entities = vec![
    // Bold "Hello"
    tl::enums::MessageEntity::Bold(tl::types::MessageEntityBold {
        offset: 0,
        length: 5,
    }),
    // Code "world"
    tl::enums::MessageEntity::Code(tl::types::MessageEntityCode {
        offset: 6,
        length: 5,
    }),
];

let msg = InputMessage::text(text).entities(entities);
}

All entity types (Layer 224)

Enum variantDescription
BoldBold text
ItalicItalic text
UnderlineUnderlined
StrikeStrikethrough
SpoilerHidden until tapped
CodeMonospace inline
PreCode block (optional language)
TextUrlHyperlink with custom label
UrlAuto-detected URL
EmailAuto-detected email
PhoneAuto-detected phone number
Mention@username mention
MentionNameInline mention by user ID
Hashtag#hashtag
Cashtag$TICKER
BotCommand/command
BankCardBank card number
BlockquoteCollapsibleCollapsible quote block
CustomEmojiCustom emoji by document ID
FormattedDate✨ New in Layer 223: displays a date in local time

Pre block with language

#![allow(unused)]
fn main() {
tl::enums::MessageEntity::Pre(tl::types::MessageEntityPre {
    offset:   0,
    length:   code_text.len() as i32,
    language: "rust".into(),
})
}

Mention by user ID (no @username needed)

#![allow(unused)]
fn main() {
tl::enums::MessageEntity::MentionName(tl::types::MessageEntityMentionName {
    offset:  0,
    length:  5,   // length of the label text
    user_id: 123456789,
})
}

FormattedDate: Layer 224

A new entity that automatically formats a unix timestamp into the user’s local timezone and locale:

#![allow(unused)]
fn main() {
tl::enums::MessageEntity::FormattedDate(tl::types::MessageEntityFormattedDate {
    flags:    0,
    relative:    false, // "yesterday", "2 days ago"
    short_time:  false, // "14:30"
    long_time:   false, // "2:30 PM"
    short_date:  true,  // "Jan 5"
    long_date:   false, // "January 5, 2026"
    day_of_week: false, // "Monday"
    offset:      0,
    length:      text.len() as i32,
    date:        1736000000, // unix timestamp
})
}

Reactions

Reactions are emoji responses attached to messages. They appear below messages with per-emoji counts.

ferogram provides the InputReactions builder. It converts from &str and String automatically, so simple cases need no import.


Send a reaction

#![allow(unused)]
fn main() {
use ferogram::reactions::InputReactions;

// Standard emoji: shorthand (no import needed, &str converts automatically)
client.send_reaction(peer.clone(), message_id, "👍").await?;

// Standard emoji: explicit builder
client.send_reaction(peer.clone(), message_id, InputReactions::emoticon("🔥")).await?;

// Custom (premium) emoji by document_id
client.send_reaction(peer.clone(), message_id, InputReactions::custom_emoji(1234567890)).await?;

// Remove all reactions from the message
client.send_reaction(peer.clone(), message_id, InputReactions::remove()).await?;
}

Modifiers

Chain modifiers after a constructor:

#![allow(unused)]
fn main() {
// Big animated reaction (full-screen effect)
client.send_reaction(peer.clone(), message_id,
    InputReactions::emoticon("🔥").big()
).await?;

// Add to the user's recently-used reaction list
client.send_reaction(peer.clone(), message_id,
    InputReactions::emoticon("❤️").add_to_recent()
).await?;

// Both at once
client.send_reaction(peer.clone(), message_id,
    InputReactions::emoticon("🎉").big().add_to_recent()
).await?;
}

Multi-reaction (premium users)

#![allow(unused)]
fn main() {
use ferogram_tl_types::{enums::Reaction, types};

client.send_reaction(
    peer.clone(),
    message_id,
    InputReactions::from(vec![
        Reaction::Emoji(types::ReactionEmoji { emoticon: "👍".into() }),
        Reaction::Emoji(types::ReactionEmoji { emoticon: "❤️".into() }),
    ]),
).await?;
}

InputReactions API reference

ConstructorDescription
InputReactions::emoticon("👍")React with a standard Unicode emoji
InputReactions::custom_emoji(doc_id)React with a custom (premium) emoji
InputReactions::remove()Remove all reactions from the message
InputReactions::from(vec![…])Multi-reaction from a Vec<Reaction>
ModifierDescription
.big()Play the full-screen large animation
.add_to_recent()Add to the user’s recent reactions list

InputReactions also implements From<&str> and From<String>, so you can pass a plain emoji string directly to send_reaction.


Reading reactions from a message

Reactions are embedded in msg.raw:

#![allow(unused)]
fn main() {
Update::NewMessage(msg) => {
    if let tl::enums::Message::Message(m) = &msg.raw {
        if let Some(tl::enums::MessageReactions::MessageReactions(r)) = &m.reactions {
            for result in &r.results {
                if let tl::enums::ReactionCount::ReactionCount(rc) = result {
                    match &rc.reaction {
                        tl::enums::Reaction::Emoji(e) => {
                            println!("{}: {} users", e.emoticon, rc.count);
                        }
                        tl::enums::Reaction::CustomEmoji(e) => {
                            println!("custom {}: {} users", e.document_id, rc.count);
                        }
                        _ => {}
                    }
                }
            }
        }
    }
}
}

Raw API: who reacted

To see which users chose a specific reaction:

#![allow(unused)]
fn main() {
use ferogram_tl_types::{functions, enums, types};

let result = client.invoke(&functions::messages::GetMessageReactionsList {
    peer:     peer_input,
    id:       message_id,
    reaction: Some(enums::Reaction::Emoji(types::ReactionEmoji {
        emoticon: "👍".into(),
    })),
    offset:   None,
    limit:    50,
}).await?;

if let enums::messages::MessageReactionsList::MessageReactionsList(list) = result {
    for r in &list.reactions {
        if let enums::MessagePeerReaction::MessagePeerReaction(rr) = r {
            println!("peer: {:?}", rr.peer_id);
        }
    }
}
}

Update Types

stream.next().await yields Option<Update>. Update is #[non_exhaustive]: always include _ => {}.

#![allow(unused)]
fn main() {
use ferogram::update::Update;

while let Some(update) = stream.next().await {
    match update {
        // Messages
        Update::NewMessage(msg)     => { /* IncomingMessage */ }
        Update::MessageEdited(msg)  => { /* IncomingMessage */ }
        Update::MessageDeleted(del) => { /* MessageDeletion */ }

        // Bot interactions
        Update::CallbackQuery(cb)   => { /* CallbackQuery */ }
        Update::InlineQuery(iq)     => { /* InlineQuery */ }
        Update::InlineSend(is)      => { /* InlineSend */ }

        // Presence
        Update::UserTyping(action)  => { /* ChatActionUpdate */ }
        Update::UserStatus(status)  => { /* UserStatusUpdate */ }

        // Raw passthrough
        Update::Raw(raw)            => { /* RawUpdate */ }

        _ => {}  // required: Update is #[non_exhaustive]
    }
}
}

MessageDeletion

#![allow(unused)]
fn main() {
Update::MessageDeleted(del) => {
    let ids: Vec<i32> = del.into_messages();
}
}
MethodReturnDescription
del.into_messages()Vec<i32>IDs of deleted messages

CallbackQuery

See the full Callback Queries page.

#![allow(unused)]
fn main() {
cb.query_id      // i64
cb.user_id       // i64
cb.msg_id        // Option<i32>
cb.data()        // Option<&str>
cb.answer()      // → Answer builder
cb.answer_flat(&client, text)
cb.answer_alert(&client, text)
}

InlineQuery

#![allow(unused)]
fn main() {
iq.query_id      // i64
iq.user_id       // i64
iq.query()       // &str: the typed query
iq.offset        // String: pagination offset
}

Answer with client.answer_inline_query(...). See Inline Mode.


InlineSend

Fires when a user picks a result from your bot’s inline mode.

#![allow(unused)]
fn main() {
is.result_id     // String: which result was chosen
is.user_id       // i64
is.query         // String: original query

// Edit the message the inline result was sent as
is.edit_message(&client, updated_input_msg).await?;
}

ChatActionUpdate (UserTyping)

#![allow(unused)]
fn main() {
Update::UserTyping(action) => {
    action.peer      // tl::enums::Peer: the chat
    action.user_id   // Option<i64>
    action.action    // tl::enums::SendMessageAction
}
}

UserStatusUpdate

#![allow(unused)]
fn main() {
Update::UserStatus(status) => {
    status.user_id  // i64
    status.status   // tl::enums::UserStatus
    // variants: UserStatusOnline, UserStatusOffline, UserStatusRecently, etc.
}
}

RawUpdate

Any TL update that doesn’t map to a typed variant:

#![allow(unused)]
fn main() {
Update::Raw(raw) => {
    raw.update   // tl::enums::Update: the raw TL object
}
}

Raw update stream

If you need all updates unfiltered:

#![allow(unused)]
fn main() {
let mut stream = client.stream_updates();
while let Some(raw) = stream.next_raw().await {
    println!("{:?}", raw.update);
}
}

IncomingMessage

IncomingMessage is the type of Update::NewMessage and Update::MessageEdited. It wraps a raw tl::enums::Message and provides typed accessors plus a full suite of convenience action methods that let you act on a message without passing the Client around explicitly (when the message was received from the update stream it already carries a client reference).


Basic accessors

#![allow(unused)]
fn main() {
Update::NewMessage(msg) => {
    msg.id()                  // i32: unique message ID in this chat
    msg.text()                // Option<&str>: text or media caption
    msg.peer_id()             // Option<&tl::enums::Peer>: chat this is in
    msg.sender_id()           // Option<&tl::enums::Peer>: who sent it
    msg.outgoing()            // bool: sent by us?
    msg.date()                // i32: Unix timestamp
    msg.edit_date()           // Option<i32>: last edit timestamp
    msg.mentioned()           // bool: are we @mentioned?
    msg.silent()              // bool: no notification
    msg.pinned()              // bool: currently pinned
    msg.post()                // bool: channel post (no sender)
    msg.noforwards()          // bool: forwarding disabled
    msg.from_scheduled()      // bool: was a scheduled message
    msg.edit_hide()           // bool: edit not shown in UI
    msg.media_unread()        // bool: media not yet viewed
    msg.raw                   // tl::enums::Message: full TL object
}
}

Extended accessors

#![allow(unused)]
fn main() {
// Counters
msg.forward_count()          // Option<i32>: number of times forwarded
msg.view_count()             // Option<i32>: view count (channels)
msg.reply_count()            // Option<i32>: number of replies in thread
msg.reaction_count()         // i32: total reactions
msg.reply_to_message_id()    // Option<i32>: ID of the message replied to

// Typed timestamps
msg.date_utc()               // Option<DateTime<Utc>>
msg.edit_date_utc()          // Option<DateTime<Utc>>

// Rich content
msg.media()                  // Option<&tl::enums::MessageMedia>
msg.entities()               // Option<&Vec<tl::enums::MessageEntity>>
msg.action()                 // Option<&tl::enums::MessageAction>: service messages
msg.reply_markup()           // Option<&tl::enums::ReplyMarkup>
msg.forward_header()         // Option<&tl::enums::MessageFwdHeader>
msg.grouped_id()             // Option<i64>: album group ID
msg.via_bot_id()             // Option<i64>: inline bot that sent this
msg.post_author()            // Option<&str>: channel post author signature
msg.restriction_reason()     // Option<&Vec<tl::enums::RestrictionReason>>

// Formatted text helpers (requires html feature for html_text)
msg.markdown_text()          // Option<String>: entities rendered as Markdown
msg.html_text()              // Option<String>: entities rendered as HTML

// Typed media extraction
msg.photo()                  // Option<Photo>
msg.document()               // Option<Document>

// Sender details
msg.sender_user_id()         // Option<i64>: sender's user ID
msg.sender_chat_id()         // Option<i64>: sender's chat ID
msg.sender_user()            // async → Option<User> (fetches from cache/API)
}

Convenience action methods

These methods work without passing a &Client when the message came from stream_updates() (the client reference is embedded). Use the _with(client) variants when you hold the message outside an update handler.

Reply

#![allow(unused)]
fn main() {
// Reply to this message (reply_to set automatically)
msg.reply("Got it! ✅").await?;

// Reply with full InputMessage control
msg.reply_ex(
    InputMessage::text("Here you go")
        .silent(true)
        .no_webpage(true)
).await?;

// Explicit client variants
msg.reply_with(&client, "Hi").await?;
msg.reply_ex_with(&client, input_msg).await?;
}

Respond (no reply thread)

#![allow(unused)]
fn main() {
// Send to the same chat without quoting
msg.respond("Hello everyone!").await?;
msg.respond_ex(InputMessage::text("...").keyboard(kb)).await?;

// Explicit client variants
msg.respond_with(&client, "Hi").await?;
msg.respond_ex_with(&client, input_msg).await?;
}

Edit

#![allow(unused)]
fn main() {
// Edit this message's text
msg.edit("Updated content").await?;
msg.edit_with(&client, "Updated content").await?;
}

Delete

#![allow(unused)]
fn main() {
msg.delete().await?;
msg.delete_with(&client).await?;
}

Mark as read

#![allow(unused)]
fn main() {
msg.mark_as_read().await?;
msg.mark_as_read_with(&client).await?;
}

Pin / Unpin

#![allow(unused)]
fn main() {
msg.pin().await?;          // pins with notification
msg.pin_with(&client).await?;

msg.unpin().await?;
msg.unpin_with(&client).await?;
}

React

#![allow(unused)]
fn main() {
use ferogram::reactions::InputReactions;

msg.react("👍").await?;
msg.react(InputReactions::emoticon("🔥").big()).await?;
msg.react_with(&client, "❤️").await?;
}

Forward

#![allow(unused)]
fn main() {
// Forward to another peer
msg.forward_to("@someuser").await?;
msg.forward_to_with(&client, peer).await?;
}

Download media

#![allow(unused)]
fn main() {
// Download attached media to a file path, returns true if media existed
let downloaded = msg.download_media("output.jpg").await?;
msg.download_media_with(&client, "output.jpg").await?;
}

Fetch replied-to message

#![allow(unused)]
fn main() {
// Get the message this one replies to
if let Some(parent) = msg.get_reply().await? {
    println!("Replying to: {}", parent.text().unwrap_or(""));
}
msg.get_reply_with(&client).await?;
}

Refetch

#![allow(unused)]
fn main() {
// Re-fetch this message from the server (update its state)
msg.refetch().await?;
msg.refetch_with(&client).await?;
}

Reply-to-message (via Client)

#![allow(unused)]
fn main() {
// Fetch the replied-to message via client
let parent = client.get_reply_to_message(peer.clone(), msg.id()).await?;
}

Full handler example

#![allow(unused)]
fn main() {
use ferogram::{Client, update::Update};

let mut stream = client.stream_updates();
while let Some(update) = stream.next().await {
    match update {
        Update::NewMessage(msg) if !msg.outgoing() => {
            let text = msg.text().unwrap_or("");

            if text == "/start" {
                msg.reply("Welcome! 👋").await.ok();

            } else if text == "/me" {
                if let Ok(Some(user)) = msg.sender_user().await {
                    msg.reply(&format!("You are: {}", user.full_name())).await.ok();
                }

            } else if text.starts_with("/echo ") {
                let echo = &text[6..];
                msg.respond(echo).await.ok();

            } else if let Some(media) = msg.media() {
                msg.reply("Downloading…").await.ok();
                msg.download_media("received_file").await.ok();
                msg.react("✅").await.ok();
            }
        }
        _ => {}
    }
}
}

Callback Queries

When a user taps an inline keyboard button, the bot receives Update::CallbackQuery. Always answer it: Telegram shows a loading spinner until you do.


Basic handling

#![allow(unused)]
fn main() {
Update::CallbackQuery(cb) => {
    let data = cb.data().unwrap_or("");

    match data {
        "vote:yes" => cb.answer().text("✅ Voted yes!").send(&client).await?,
        "vote:no"  => cb.answer().text("❌ Voted no").send(&client).await?,
        _          => cb.answer().send(&client).await?,  // empty answer
    }
}
}

CallbackQuery fields

#![allow(unused)]
fn main() {
cb.query_id       // i64: must be passed to answer_callback_query
cb.user_id        // i64: who pressed the button
cb.msg_id         // Option<i32>: message the button was on
cb.data()         // Option<&str>: the callback data string
cb.inline_msg_id  // Option<tl::enums::InputBotInlineMessageID>: for inline messages
}

Answer builder (fluent API)

cb.answer() returns an Answer builder. Chain modifiers then call .send(&client).

#![allow(unused)]
fn main() {
// Toast notification (default)
cb.answer()
    .text("✅ Done!")
    .send(&client)
    .await?;

// Alert popup (modal dialog)
cb.answer()
    .alert("⛔ You don't have permission for this action.")
    .send(&client)
    .await?;

// Open a URL (Telegram shows a confirmation first)
cb.answer()
    .url("https://example.com/auth")
    .send(&client)
    .await?;

// Silent answer (no notification, just clears the spinner)
cb.answer().send(&client).await?;
}

Answer methods

MethodDescription
cb.answer()Start building an answer
.text(str)Toast message shown to the user
.alert(str)Modal popup shown to the user
.url(str)URL to open (with user confirmation)
.send(&client)Execute: always call this

Convenience shortcuts

#![allow(unused)]
fn main() {
// Flat answer with optional text
cb.answer_flat(&client, Some("✅ Done")).await?;
cb.answer_flat(&client, None).await?;  // silent

// Alert shortcut
cb.answer_alert(&client, "⛔ Access denied").await?;
}

Via Client directly

#![allow(unused)]
fn main() {
// answer_callback_query(query_id, text, alert)
client.answer_callback_query(cb.query_id, Some("✅ Done!"), false).await?;
client.answer_callback_query(cb.query_id, Some("⛔ No!"), true).await?;  // alert
client.answer_callback_query(cb.query_id, None, false).await?;  // silent
}

Edit the message on callback

#![allow(unused)]
fn main() {
Update::CallbackQuery(cb) => {
    // Answer first (clears spinner)
    cb.answer().text("Loading…").send(&client).await?;

    // Then edit the original message
    if let Some(msg_id) = cb.msg_id {
        client.edit_message(
            // peer from the callback: resolve from context
            peer.clone(),
            msg_id,
            "Updated content",
        ).await?;
    }
}
}

Full example: vote bot

#![allow(unused)]
fn main() {
Update::CallbackQuery(cb) => {
    match cb.data().unwrap_or("") {
        "vote:yes" => {
            cb.answer().text("Thanks for voting Yes! 👍").send(&client).await?;
        }
        "vote:no" => {
            cb.answer().text("Thanks for voting No! 👎").send(&client).await?;
        }
        "vote:info" => {
            cb.answer()
                .url("https://example.com/vote-info")
                .send(&client)
                .await?;
        }
        _ => {
            cb.answer().send(&client).await?; // always answer
        }
    }
}
}

Inline Mode

Inline mode lets users type @yourbot query in any chat and receive results. ferogram supports both sides: receiving queries (bot) and sending queries (user account).


Receiving inline queries (bot side)

Via update stream

#![allow(unused)]
fn main() {
Update::InlineQuery(iq) => {
    let query    = iq.query();    // &str: what the user typed
    let query_id = iq.query_id;  // i64: must be passed to answer_inline_query

    let results = vec![
        tl::enums::InputBotInlineResult::InputBotInlineResult(
            tl::types::InputBotInlineResult {
                id:    "1".into(),
                r#type: "article".into(),
                title: Some("Result title".into()),
                description: Some(query.to_string()),
                url: None, thumb: None, content: None,
                send_message: tl::enums::InputBotInlineMessage::Text(
                    tl::types::InputBotInlineMessageText {
                        no_webpage: false, invert_media: false,
                        message: query.to_string(),
                        entities: None, reply_markup: None,
                    },
                ),
            },
        ),
    ];

    // cache_time: seconds, is_personal: false, next_offset: None
    client.answer_inline_query(query_id, results, 30, false, None).await?;
}
}

Via InlineQueryIter

For a more structured approach, use the dedicated iterator:

#![allow(unused)]
fn main() {
use ferogram::inline_iter::InlineQueryIter;

let mut iter = client.iter_inline_queries();
while let Some(iq) = iter.next().await {
    println!("Query: {}", iq.query());
    // answer it...
}
}

InlineQueryIter is backed by the update stream: it filters and yields only InlineQuery updates.


InlineQuery fields

#![allow(unused)]
fn main() {
iq.query()       // &str: the search text
iq.query_id      // i64
iq.user_id       // i64: who sent the query
iq.offset        // String: pagination offset
}

Receiving inline sends (bot side)

When a user selects a result from your bot’s inline mode, you get Update::InlineSend:

#![allow(unused)]
fn main() {
Update::InlineSend(is) => {
    // is.result_id : which result was chosen
    // is.user_id   : who chose it
    // is.query     : the original query
}
}

InlineSend also has edit_message() for editing the sent inline message:

#![allow(unused)]
fn main() {
is.edit_message(&client, updated_msg).await?;
}

Sending inline queries (user account side)

A user account can invoke another bot’s inline mode with client.inline_query() and iterate the results:

#![allow(unused)]
fn main() {
use ferogram::inline_iter::InlineResultIter;

let mut iter = client
    .inline_query("@gif", "cute cats")
    .peer(input_peer_for_target_chat)
    .await?;

while let Some(result) = iter.next().await? {
    println!("Result: {:?}: {:?}", result.id(), result.title());

    // Send the first result to a chat
    result.send(target_peer.clone()).await?;
    break;
}
}

InlineResult methods

MethodReturnDescription
result.id()&strResult ID string
result.title()Option<&str>Display title
result.description()Option<&str>Display description
result.rawtl::enums::BotInlineResultRaw TL object
result.send(peer)async → ()Send this result to a chat

InlineResultIter methods

MethodDescription
client.inline_query(bot, query)Create builder, returns InlineResultIter
iter.peer(input_peer)Set the chat context (required by some bots)
iter.next()async → Option<InlineResult>: fetch next result

answer_inline_query parameters

#![allow(unused)]
fn main() {
client.answer_inline_query(
    query_id,   // i64: from InlineQuery
    results,    // Vec<InputBotInlineResult>
    30,         // cache_time: i32: seconds to cache results
    false,      // is_personal: bool: different results per user?
    None,       // next_offset: Option<String>: for pagination
).await?;
}

Client Methods: Full Reference

All methods on Client. Every method is async and returns Result<T, InvocationError> unless noted.


Connection & Session

async Client::connect(config: Config) → Result<(Client, ShutdownToken), InvocationError>
Opens a TCP connection to Telegram, performs the full 3-step DH key exchange, and loads any existing session. Returns both the client handle and a ShutdownToken for graceful shutdown.
sync Client::with_string_session(session: &str, api_id: i32, api_hash: &str) → Result<(Client, ShutdownToken), InvocationError> New 0.1.1
Convenience constructor that connects using a StringSessionBackend. Pass the string exported by export_session_string().
async client.is_authorized() → Result<bool, InvocationError>
Returns true if the session has a logged-in user or bot. Use this to skip the login flow on subsequent runs.
async client.save_session() → Result<(), InvocationError>
Writes the current session (auth key + DC info + peer cache) to the backend. Call after a successful login.
async client.export_session_string() → Result<String, InvocationError> New 0.1.1
Serialises the current session to a portable base64 string. Store it in an env var, DB column, or CI secret. Restore with Client::with_string_session() or StringSessionBackend.
let s = client.export_session_string().await?;
std::env::set_var("TG_SESSION", &s);
async client.sign_out() → Result<bool, InvocationError>
Revokes the auth key on Telegram's servers and deletes the local session. The bool indicates whether teardown was confirmed server-side.
sync client.disconnect()
Immediately closes the TCP connection and stops the reader task without waiting for pending RPCs to drain. For graceful shutdown that waits for pending calls, use ShutdownToken::cancel() instead.
async client.sync_update_state() New 0.1.1
Forces an immediate updates.getState round-trip and reconciles local pts/seq/qts counters. Useful after a long disconnect or when you suspect a gap but don't want to wait for the gap-detection timer.
sync client.signal_network_restored()
Signals to the reconnect logic that the network is available. Skips the exponential backoff and triggers an immediate reconnect attempt. Call from Android ConnectivityManager or iOS NWPathMonitor callbacks.

Authentication

async client.request_login_code(phone: &str) → Result<LoginToken, InvocationError>
Sends a verification code to phone via SMS or Telegram app. Returns a LoginToken to pass to sign_in. Phone must be in E.164 format: "+12345678900".
async client.sign_in(token: &LoginToken, code: &str) → Result<String, SignInError>
Submits the verification code. Returns the user's full name on success, or SignInError::PasswordRequired(PasswordToken) when 2FA is enabled. The PasswordToken carries the hint set by the user.
async client.check_password(token: PasswordToken, password: &str) → Result<(), InvocationError>
Completes the SRP 2FA verification. The password is never transmitted in plain text: only a zero-knowledge cryptographic proof is sent.
async client.bot_sign_in(token: &str) → Result<String, InvocationError>
Logs in using a bot token from @BotFather. Returns the bot's username on success.
async client.get_me() → Result<tl::types::User, InvocationError>
Fetches the full User object for the logged-in account. Contains id, username, first_name, last_name, phone, bot flag, verified flag, and more.

Updates

sync client.stream_updates() → UpdateStream
Returns an UpdateStream: an async iterator that yields typed Update values. Call .next().await in a loop to process events. The stream runs until the connection is closed.
let mut updates = client.stream_updates();
while let Some(update) = updates.next().await {
    match update {
        Update::NewMessage(msg) => { /* … */ }
        _ => {}
    }
}

Messaging

async client.send_message(peer: &str, text: &str) → Result<(), InvocationError>
Send a plain-text message. peer can be "me", "@username", or a numeric ID string. For rich formatting, use send_message_to_peer_ex.
async client.send_to_self(text: &str) → Result<(), InvocationError>
Sends a message to your own Saved Messages. Shorthand for send_message("me", text).
async client.send_message_to_peer(peer: Peer, text: &str) → Result<(), InvocationError>
Send a plain text message to a resolved tl::enums::Peer.
async client.send_message_to_peer_ex(peer: Peer, msg: &InputMessage) → Result<(), InvocationError>
Full-featured send with the InputMessage builder: supports markdown entities, reply-to, inline keyboard, scheduled date, silent flag, and more.
async client.edit_message(peer: Peer, message_id: i32, new_text: &str) → Result<(), InvocationError>
Edit the text of a previously sent message. Only works on messages sent by the logged-in account (or bot).
async client.edit_inline_message(inline_msg_id: tl::enums::InputBotInlineMessageId, text: &str) → Result<(), InvocationError>
Edit the text of a message that was sent via inline mode. The inline_msg_id is provided in Update::InlineSend.
async client.forward_messages(from_peer: Peer, to_peer: Peer, ids: Vec<i32>) → Result<(), InvocationError>
Forward one or more messages from from_peer into to_peer.
async client.delete_messages(ids: Vec<i32>, revoke: bool) → Result<(), InvocationError>
revoke: true deletes for everyone; false deletes only for the current account.
async client.get_messages_by_id(peer: Peer, ids: &[i32]) → Result<Vec<tl::enums::Message>, InvocationError>
Fetch specific messages by their IDs from a peer. Returns messages in the same order as the input IDs.
async client.pin_message(peer: Peer, message_id: i32, silent: bool) → Result<(), InvocationError>
Pin a message. silent: true pins without notifying members.
async client.unpin_message(peer: Peer, message_id: i32) → Result<(), InvocationError>
Unpin a specific message.
async client.unpin_all_messages(peer: Peer) → Result<(), InvocationError>
Unpin all pinned messages in a chat.
async client.get_pinned_message(peer: Peer) → Result<Option<tl::enums::Message>, InvocationError>
Fetch the currently pinned message, or None if nothing is pinned.

Reactions

async client.send_reaction(peer: Peer, msg_id: i32, reaction: Reaction) → Result<(), InvocationError>
Send a reaction to a message. Build reactions using the Reaction helper:
use ferogram::reactions::InputReactions;

client.send_reaction(peer, msg_id, InputReactions::emoticon(“👍”)).await?; client.send_reaction(peer, msg_id, InputReactions::remove()).await?; // remove all client.send_reaction(peer, msg_id, InputReactions::emoticon(“🔥”).big()).await?; See Reactions for the full guide.


Sending chat actions

async client.send_chat_action(peer: Peer, action: SendMessageAction, top_msg_id: Option<i32>) → Result<(), InvocationError>
Send a one-shot typing / uploading / recording indicator. Expires after ~5 seconds. Use TypingGuard to keep it alive for longer operations. top_msg_id restricts the indicator to a forum topic.

sync client.search(peer: impl Into<PeerRef>, query: &str) → SearchBuilder
Returns a SearchBuilder for per-peer message search with date filters, sender filter, media type filter, and pagination.
sync client.search_global_builder(query: &str) → GlobalSearchBuilder
Returns a GlobalSearchBuilder for searching across all dialogs simultaneously.
async client.search_messages(peer: Peer, query: &str, limit: i32) → Result<Vec<tl::enums::Message>, InvocationError>
Simple one-shot search within a peer. For advanced options use client.search().
async client.search_global(query: &str, limit: i32) → Result<Vec<tl::enums::Message>, InvocationError>
Simple one-shot global search. For advanced options use client.search_global_builder().

Dialogs & History

async client.get_dialogs(limit: i32) → Result<Vec<Dialog>, InvocationError>
Fetch the most recent limit dialogs. Each Dialog has title(), peer(), unread_count(), and top_message().
sync client.iter_dialogs() → DialogIter
Lazy iterator that pages through all dialogs automatically. Call iter.next(&client).await?. iter.total() returns the server-reported count after the first page.
sync client.iter_messages(peer: impl Into<PeerRef>) → MessageIter
Lazy iterator over the full message history of a peer, newest first. Call iter.next(&client).await?.
async client.get_messages(peer: Peer, limit: i32, offset_id: i32) → Result<Vec<tl::enums::Message>, InvocationError>
Fetch a page of messages. Pass the lowest message ID from the previous page as offset_id to paginate.
async client.mark_as_read(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Mark all messages in a dialog as read.
async client.clear_mentions(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Clear unread @mention badges in a chat.
async client.delete_dialog(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Delete a dialog from the account's dialog list (does not delete messages for others).

Scheduled messages

async client.get_scheduled_messages(peer: Peer) → Result<Vec<tl::enums::Message>, InvocationError>
Fetch all messages scheduled to be sent in a chat.
async client.delete_scheduled_messages(peer: Peer, ids: Vec<i32>) → Result<(), InvocationError>
Cancel and delete scheduled messages by ID.

Participants & Admin

async client.get_participants(peer: Peer, limit: i32) → Result<Vec<Participant>, InvocationError>
Fetch members of a chat or channel. Pass limit = 0 for the default server maximum per page. Use iter_participants to lazily page all members.
async client.iter_participants(peer: Peer) → ParticipantIter
Lazy async iterator that pages through all members, including beyond the 200-member limit. Fixed in v0.1.1 to paginate correctly for large channels.
async client.set_admin_rights(peer: Peer, user_id: i64, rights: AdminRightsBuilder) → Result<(), InvocationError>
Promote a user to admin with specified rights. See Admin & Ban Rights.
async client.set_banned_rights(peer: Peer, user_id: i64, rights: BanRightsBuilder) → Result<(), InvocationError>
Restrict or ban a user. Pass BanRightsBuilder::full_ban() to fully ban. See Admin & Ban Rights.
async client.get_profile_photos(peer: Peer, limit: i32) → Result<Vec<tl::enums::Photo>, InvocationError>
Fetch a user's profile photo list.
async client.get_permissions(peer: Peer, user_id: i64) → Result<Participant, InvocationError>
Fetch the effective permissions of a user in a chat. Check .is_admin(), .is_banned(), etc. on the returned Participant.

Media

async client.upload_file(path: &str) → Result<UploadedFile, InvocationError>
Upload a file from a local path. Returns an UploadedFile with .as_photo_media() and .as_document_media() methods.
async client.send_file(peer: Peer, media: InputMedia, caption: Option<&str>) → Result<(), InvocationError>
Send an uploaded file as a photo or document with an optional caption.
async client.send_album(peer: Peer, media: Vec<InputMedia>, caption: Option<&str>) → Result<(), InvocationError>
Send multiple media items as a grouped album (2–10 items).
async client.download_media_to_file(location: impl Downloadable, path: &str) → Result<(), InvocationError>
Download a media attachment and write it directly to a file path.

Callbacks & Inline

async client.answer_callback_query(query_id: i64, text: Option<&str>, alert: bool) → Result<(), InvocationError>
Acknowledge an inline button press. text shows a toast (or alert if alert=true). Must be called within 60 seconds of the button press.
async client.answer_inline_query(query_id: i64, results: Vec<InputBotInlineResult>, cache_time: i32, is_personal: bool, next_offset: Option<&str>) → Result<(), InvocationError>
Respond to an inline query with a list of results. cache_time in seconds. Empty result list now handled correctly (fixed in v0.1.1).

Peer resolution

async client.resolve_peer(peer: &str) → Result<tl::enums::Peer, InvocationError>
Resolve a string ("@username", "+phone", "me", numeric ID) to a Peer with cached access hash.
async client.resolve_username(username: &str) → Result<tl::enums::Peer, InvocationError>
Resolve a bare username (without @) to a Peer.

Raw API

async client.invoke<R: RemoteCall>(req: &R) → Result<R::Return, InvocationError>
Call any Layer 224 API method directly. See Raw API Access for the full guide.
use ferogram_tl_types::functions;
let state = client.invoke(&functions::updates::GetState {}).await?;
async client.invoke_on_dc<R: RemoteCall>(req: &R, dc_id: i32) → Result<R::Return, InvocationError>
Send a request to a specific Telegram data centre. Used for file downloads from CDN DCs.

Chat management

async client.join_chat(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Join a group or channel by peer reference.
async client.accept_invite_link(link: &str) → Result<(), InvocationError>
Accept a t.me/+hash or t.me/joinchat/hash invite link.

InputMessage Builder

InputMessage is a fluent builder for composing rich messages with full control over every parameter.

Import

#![allow(unused)]
fn main() {
use ferogram::InputMessage;
use ferogram::parsers::parse_markdown;
}

Builder methods

MethodTypeDescription
InputMessage::text(text)impl Into<String>Create with plain text (constructor)
.set_text(text)impl Into<String>Replace the text
.entities(entities)Vec<MessageEntity>Formatting entities from parse_markdown
.reply_to(id)Option<i32>Reply to a message ID
.reply_markup(markup)ReplyMarkupInline or reply keyboard
.silent(v)boolSend without notification
.background(v)boolSend as background message
.clear_draft(v)boolClear the chat draft on send
.no_webpage(v)boolDisable link preview
.schedule_date(ts)Option<i32>Unix timestamp to schedule the send

Plain text

#![allow(unused)]
fn main() {
let msg = InputMessage::text("Hello, world!");
client.send_message_to_peer_ex(peer, &msg).await?;
}

Markdown formatting

parse_markdown converts Markdown to plain text + entity list:

#![allow(unused)]
fn main() {
let (plain, entities) = parse_markdown(
    "**Bold**, _italic_, `inline code`, and [a link](https://example.com)"
);
let msg = InputMessage::text(plain).entities(entities);
}

Supported Markdown syntax:

SyntaxResult
**text**Bold
_text_ or *text*Italic
\text``Inline code
\``text````Pre-formatted block
[label](url)Hyperlink
__text__Underline
~~text~~Strikethrough
||text||Spoiler

Reply to a message

#![allow(unused)]
fn main() {
let msg = InputMessage::text("This is my reply")
    .reply_to(Some(original_msg_id));
}

With inline keyboard

#![allow(unused)]
fn main() {
use ferogram_tl_types as tl;

let keyboard = tl::enums::ReplyMarkup::ReplyInlineMarkup(
    tl::types::ReplyInlineMarkup {
        rows: vec![
            tl::enums::KeyboardButtonRow::KeyboardButtonRow(
                tl::types::KeyboardButtonRow {
                    buttons: vec![
                        tl::enums::KeyboardButton::Callback(
                            tl::types::KeyboardButtonCallback {
                                requires_password: false,
                                style: None,
                                text: "Click me".into(),
                                data: b"my_action".to_vec(),
                            }
                        )
                    ]
                }
            )
        ]
    }
);

let msg = InputMessage::text("Pick an action:").reply_markup(keyboard);
}

Silent message (no notification)

#![allow(unused)]
fn main() {
let msg = InputMessage::text("Heads-up (no ping)").silent(true);
}

Scheduled message

#![allow(unused)]
fn main() {
use std::time::{SystemTime, UNIX_EPOCH};

// Schedule for 1 hour from now
let in_one_hour = SystemTime::now()
    .duration_since(UNIX_EPOCH).unwrap()
    .as_secs() as i32 + 3600;

let msg = InputMessage::text("This will appear in 1 hour")
    .schedule_date(Some(in_one_hour));
}
#![allow(unused)]
fn main() {
let msg = InputMessage::text("https://example.com: visit it!")
    .no_webpage(true);
}

Combining everything

#![allow(unused)]
fn main() {
let (text, entities) = parse_markdown("📢 **Announcement:** check out _this week's update_!");

let msg = InputMessage::text(text)
    .entities(entities)
    .reply_to(Some(pinned_msg_id))
    .silent(false)
    .no_webpage(true)
    .reply_markup(keyboard);

client.send_message_to_peer_ex(channel_peer, &msg).await?;
}

Peer Types & PeerRef

ferogram provides typed wrappers over the raw tl::enums::User and tl::enums::Chat types, plus PeerRef: a flexible peer argument accepted by every Client method.


PeerRef: flexible peer argument

Every Client method that previously required a bare tl::enums::Peer now accepts impl Into<PeerRef>. That means you can pass:

#![allow(unused)]
fn main() {
// @username string (with or without @)
client.send_message_to_peer("@durov", "hi").await?;
client.send_message_to_peer("durov",  "hi").await?;

// "me" / "self": always resolves to the logged-in account
client.send_message_to_peer("me", "Note to self").await?;

// Positive i64: Telegram user ID
client.send_message_to_peer(12345678_i64, "hi").await?;

// Negative i64: Bot-API channel ID (-100… prefix)
client.iter_messages(-1001234567890_i64);

// Negative i64: Bot-API basic-group ID (small negative)
client.mark_as_read(-123456_i64).await?;

// Already-resolved TL peer: zero overhead, no network call
use ferogram::tl;
let peer = tl::enums::Peer::User(tl::types::PeerUser { user_id: 123 });
client.send_message_to_peer(peer, "hi").await?;
}

PeerRef variants

VariantHow resolved
PeerRef::Username(s)contacts.resolveUsername RPC (cached after first call)
PeerRef::Id(i64)Decoded from Bot-API encoding: no network call
PeerRef::Peer(tl::enums::Peer)Forwarded as-is: zero cost

Bot-API ID encoding

RangeMaps to
id > 0User (PeerUser { user_id: id })
-1_000_000_000_000 < id < 0Basic group (PeerChat { chat_id: -id })
id ≤ -1_000_000_000_000Channel (channel_id = -id - 1_000_000_000_000)

Resolving manually

#![allow(unused)]
fn main() {
use ferogram::PeerRef;

let peer_ref = PeerRef::from("@someuser");
let peer: tl::enums::Peer = peer_ref.resolve(&client).await?;
}

User: user account wrapper

#![allow(unused)]
fn main() {
use ferogram::types::User;

// Wrap from raw TL
if let Some(user) = User::from_raw(raw_tl_user) {
    println!("ID: {}", user.id());
    println!("Name: {}", user.full_name());
    println!("Username: {:?}", user.username());
    println!("Is bot: {}", user.bot());
    println!("Is premium: {}", user.premium());
}
}

User accessor methods

MethodReturn typeDescription
id()i64Telegram user ID
access_hash()Option<i64>Access hash for API calls
first_name()Option<&str>First name
last_name()Option<&str>Last name
full_name()String"First [Last]" combined
username()Option<&str>Primary username (without @)
usernames()Vec<&str>All active usernames
phone()Option<&str>Phone number (if visible)
bot()boolIs a bot account
verified()boolIs a verified account
premium()boolIs a premium account
deleted()boolAccount has been deleted
scam()boolFlagged as scam
restricted()boolAccount is restricted
is_self()boolIs the currently logged-in user
contact()boolIn the logged-in user’s contacts
mutual_contact()boolMutual contact
support()boolTelegram support staff
lang_code()Option<&str>User’s client language code
status()Option<&tl::enums::UserStatus>Online/offline status
photo()Option<&tl::types::UserProfilePhoto>Profile photo
bot_inline_placeholder()Option<&str>Inline mode compose bar hint
bot_inline_geo()boolBot supports inline without location
bot_supports_chats()boolBot can be added to groups
restriction_reason()Vec<&tl::enums::RestrictionReason>Restriction reasons
as_peer()tl::enums::PeerConvert to Peer
as_input_peer()tl::enums::InputPeerConvert to InputPeer

User implements Display as "Full Name (@username)" or "Full Name [user_id]".


Group: basic group wrapper

#![allow(unused)]
fn main() {
use ferogram::types::Group;

if let Some(group) = Group::from_raw(raw_tl_chat) {
    println!("ID: {}", group.id());
    println!("Title: {}", group.title());
    println!("Members: {}", group.participants_count());
    println!("I am creator: {}", group.creator());
}
}

Group accessor methods

MethodReturn typeDescription
id()i64Group ID
title()&strGroup name
participants_count()i32Member count
creator()boolLogged-in user is the creator
migrated_to()Option<&tl::enums::InputChannel>Points to supergroup after migration
as_peer()tl::enums::PeerConvert to Peer
as_input_peer()tl::enums::InputPeerConvert to InputPeer

Channel: channel / supergroup wrapper

#![allow(unused)]
fn main() {
use ferogram::types::{Channel, ChannelKind};

if let Some(channel) = Channel::from_raw(raw_tl_chat) {
    println!("ID: {}", channel.id());
    println!("Title: {}", channel.title());
    println!("Username: {:?}", channel.username());
    println!("Kind: {:?}", channel.kind());
    println!("Members: {:?}", channel.participants_count());
}
}

Channel accessor methods

MethodReturn typeDescription
id()i64Channel ID
access_hash()Option<i64>Access hash
title()&strChannel / supergroup name
username()Option<&str>Public username (without @)
usernames()Vec<&str>All active usernames
megagroup()boolIs a supergroup (not a broadcast channel)
broadcast()boolIs a broadcast channel
gigagroup()boolIs a broadcast group (gigagroup)
kind()ChannelKindBroadcast / Megagroup / Gigagroup
verified()boolVerified account
restricted()boolIs restricted
signatures()boolPosts have author signatures
participants_count()Option<i32>Approximate member count
photo()Option<&tl::types::ChatPhoto>Channel photo
admin_rights()Option<&tl::types::ChatAdminRights>Your admin rights
restriction_reason()Vec<&tl::enums::RestrictionReason>Restriction reasons
as_peer()tl::enums::PeerConvert to Peer
as_input_peer()tl::enums::InputPeerConvert to InputPeer (requires hash)
as_input_channel()tl::enums::InputChannelConvert to InputChannel

ChannelKind enum

#![allow(unused)]
fn main() {
use ferogram::types::ChannelKind;

match channel.kind() {
    ChannelKind::Broadcast  => { /* Posts only, no member replies */ }
    ChannelKind::Megagroup  => { /* All members can post */ }
    ChannelKind::Gigagroup  => { /* Large public broadcast group */ }
}
}

Chat: unified chat enum

Chat unifies Group and Channel into one enum with shared accessors:

#![allow(unused)]
fn main() {
use ferogram::types::Chat;

if let Some(chat) = Chat::from_raw(raw_tl_chat) {
    println!("ID: {}", chat.id());
    println!("Title: {}", chat.title());

    match &chat {
        Chat::Group(g)   => println!("Basic group, {} members", g.participants_count()),
        Chat::Channel(c) => println!("{:?} channel", c.kind()),
    }
}
}

Chat methods

MethodReturn typeDescription
id()i64ID regardless of variant
title()&strName regardless of variant
as_peer()tl::enums::PeerPeer variant
as_input_peer()tl::enums::InputPeerInputPeer variant

Participants & Members

Methods for fetching, banning, kicking, promoting, and managing chat members. All methods accept impl Into<PeerRef> for the peer argument.


Fetch participants

#![allow(unused)]
fn main() {
use ferogram::participants::Participant;

// Fetch up to N participants at once
let members: Vec<Participant> = client
    .get_participants(peer.clone(), 200)
    .await?;

for p in &members {
    println!(
        "{}: admin: {}, banned: {}",
        p.user.first_name.as_deref().unwrap_or("?"),
        p.is_admin(),
        p.is_banned(),
    );
}
}

Paginated iterator (large groups)

#![allow(unused)]
fn main() {
let mut iter = client.iter_participants(peer.clone());
while let Some(p) = iter.next(&client).await? {
    println!("{}", p.user.first_name.as_deref().unwrap_or(""));
}
}

Search contacts and dialogs by name

#![allow(unused)]
fn main() {
// Returns combined results from contacts, dialogs, and global
let results: Vec<tl::enums::Peer> = client.search_peer("John").await?;
}

Participant fields

#![allow(unused)]
fn main() {
p.user          // tl::types::User: raw user data
p.is_creator()  // bool: is the channel/group creator
p.is_admin()    // bool: has any admin rights
p.is_banned()   // bool: is banned/restricted
p.is_member()   // bool: active member (not banned, not left)
}

Kick participant

#![allow(unused)]
fn main() {
// Removes the user from a basic group
// For channels/supergroups, use ban_participant instead
client.kick_participant(peer.clone(), user_id).await?;
}

Ban participant: BannedRightsBuilder

Use the fluent BannedRightsBuilder for granular bans:

#![allow(unused)]
fn main() {
use ferogram::participants::BannedRightsBuilder;

// Permanent full ban
client
    .ban_participant(peer.clone(), user_id, BannedRightsBuilder::full_ban())
    .await?;

// Partial restriction: no media, no stickers, expires in 24 h
let expires = (std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 86400) as i32;

client
    .ban_participant(
        peer.clone(),
        user_id,
        BannedRightsBuilder::new()
            .send_media(true)
            .send_stickers(true)
            .send_gifs(true)
            .until_date(expires),
    )
    .await?;

// Unban: pass an empty builder to restore full permissions
client
    .ban_participant(peer.clone(), user_id, BannedRightsBuilder::new())
    .await?;
}

BannedRightsBuilder methods

MethodDescription
BannedRightsBuilder::new()All permissions granted (empty ban = unban)
BannedRightsBuilder::full_ban()All rights revoked, permanent
.view_messages(bool)Prevent reading messages
.send_messages(bool)Prevent sending text
.send_media(bool)Prevent sending media
.send_stickers(bool)Prevent sending stickers
.send_gifs(bool)Prevent sending GIFs
.send_games(bool)Prevent sending games
.send_inline(bool)Prevent using inline bots
.embed_links(bool)Prevent embedding links
.send_polls(bool)Prevent sending polls
.change_info(bool)Prevent changing chat info
.invite_users(bool)Prevent inviting users
.pin_messages(bool)Prevent pinning messages
.until_date(ts: i32)Expiry Unix timestamp (0 = permanent)

Promote admin: AdminRightsBuilder

#![allow(unused)]
fn main() {
use ferogram::participants::AdminRightsBuilder;

// Promote with specific rights and a custom title
client
    .promote_participant(
        peer.clone(),
        user_id,
        AdminRightsBuilder::new()
            .post_messages(true)
            .delete_messages(true)
            .ban_users(true)
            .invite_users(true)
            .pin_messages(true)
            .rank("Moderator"), // custom admin title (max 16 chars)
    )
    .await?;

// Full admin (all standard rights except add_admins)
client
    .promote_participant(peer.clone(), user_id, AdminRightsBuilder::full_admin())
    .await?;

// Demote: pass an empty builder to remove all admin rights
client
    .promote_participant(peer.clone(), user_id, AdminRightsBuilder::new())
    .await?;
}

AdminRightsBuilder methods

MethodDescription
AdminRightsBuilder::new()No rights (use to demote)
AdminRightsBuilder::full_admin()All standard rights
.change_info(bool)Can change channel/group info
.post_messages(bool)Can post in channels
.edit_messages(bool)Can edit others’ messages
.delete_messages(bool)Can delete messages
.ban_users(bool)Can ban / restrict members
.invite_users(bool)Can add members
.pin_messages(bool)Can pin messages
.add_admins(bool)Can promote other admins (use carefully)
.anonymous(bool)Posts appear as channel name, not user
.manage_call(bool)Can manage voice/video chats
.manage_topics(bool)Can manage forum topics
.rank(str)Custom admin title shown beside name

Get participant permissions

Check the effective permissions of a user in a channel or supergroup:

#![allow(unused)]
fn main() {
use ferogram::participants::ParticipantPermissions;

let perms: ParticipantPermissions = client
    .get_permissions(peer.clone(), user_id)
    .await?;

println!("Creator: {}", perms.is_creator());
println!("Admin: {}",   perms.is_admin());
println!("Banned: {}",  perms.is_banned());
println!("Member: {}",  perms.is_member());
println!("Can send: {}", perms.can_send_messages);
println!("Can pin: {}",  perms.can_pin_messages);
println!("Admin title: {:?}", perms.admin_rank);
}

ParticipantPermissions fields & methods

SymbolTypeDescription
is_creator()boolIs the channel/group creator
is_admin()boolHas any admin rights
is_banned()boolIs banned or restricted
is_member()boolActive member (not banned, not left)
can_send_messagesboolCan send text messages
can_send_mediaboolCan send media
can_pin_messagesboolCan pin messages
can_add_adminsboolCan promote admins
admin_rankOption<String>Custom admin title

Profile photos

#![allow(unused)]
fn main() {
// Fetch a page of profile photos (user_id, offset, limit)
let photos = client.get_profile_photos(user_id, 0, 10).await?;

// Lazy iterator across all pages
let mut iter = client.iter_profile_photos(user_id);
while let Some(photo) = iter.next(&client).await? {
    let bytes = client.download(&photo).await?;
}
}

Join and leave chats

#![allow(unused)]
fn main() {
// Join a public group or channel
client.join_chat("@somegroup").await?;

// Accept a private invite link
client.accept_invite_link("https://t.me/joinchat/AbCdEfG").await?;

// Parse invite hash from any link format
let hash = Client::parse_invite_hash("https://t.me/+AbCdEfG12345");

// Leave / remove dialog
client.delete_dialog(peer.clone()).await?;
}

ParticipantStatus enum

#![allow(unused)]
fn main() {
use ferogram::participants::ParticipantStatus;

for p in &members {
    match p.status {
        ParticipantStatus::Member     => println!("Regular member"),
        ParticipantStatus::Creator    => println!("Creator"),
        ParticipantStatus::Admin      => println!("Admin"),
        ParticipantStatus::Restricted => println!("Restricted"),
        ParticipantStatus::Banned     => println!("Banned"),
        ParticipantStatus::Left       => println!("Left"),
    }
}
}

ProfilePhotoIter: extended methods

#![allow(unused)]
fn main() {
let mut iter = client.iter_profile_photos(user_id);

// Total photo count (available after first fetch)
if let Some(total) = iter.total_count() {
    println!("{total} profile photos");
}

// Collect all photos at once
let all_photos = iter.collect().await?;
}
MethodDescription
iter.next()async → Option<tl::enums::Photo>
iter.collect()async → Vec<tl::enums::Photo>: all photos
iter.total_count()Option<i32>: total count after first fetch

Low-level rights setters

For advanced use cases, set_banned_rights and set_admin_rights give direct access to the TL layer:

#![allow(unused)]
fn main() {
// Set banned rights directly (channel/supergroup only)
client.set_banned_rights(
    peer.clone(),
    user_input_peer,
    BannedRightsBuilder::new().send_media(true),
).await?;

// Set admin rights directly
client.set_admin_rights(
    peer.clone(),
    user_input_peer,
    AdminRightsBuilder::new().delete_messages(true),
    Some("Moderator".into()),  // optional custom rank
).await?;
}

Admin & Ban Rights

ferogram provides two fluent builders for granular admin and ban rights: AdminRightsBuilder and BannedRightsBuilder: both in the ferogram::participants module.


BannedRightsBuilder: restrict a member

#![allow(unused)]
fn main() {
use ferogram::participants::BannedRightsBuilder;

// Permanent full ban
client
    .ban_participant(peer.clone(), user_id, BannedRightsBuilder::full_ban())
    .await?;

// Partial restriction (no media, expires in 24 h)
let tomorrow = (std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 86400) as i32;

client
    .ban_participant(
        peer.clone(), user_id,
        BannedRightsBuilder::new()
            .send_media(true)
            .send_stickers(true)
            .send_gifs(true)
            .until_date(tomorrow),
    )
    .await?;

// Unban
// Passing an empty builder restores full permissions
client
    .ban_participant(peer.clone(), user_id, BannedRightsBuilder::new())
    .await?;
}

Method reference

MethodDefaultDescription
BannedRightsBuilder::new()all falseNo restrictions (use to unban)
BannedRightsBuilder::full_ban()all true, until_date = 0Total permanent ban
.view_messages(bool)falseBlock reading messages
.send_messages(bool)falseBlock sending text
.send_media(bool)falseBlock sending media
.send_stickers(bool)falseBlock stickers
.send_gifs(bool)falseBlock GIFs
.send_games(bool)falseBlock games
.send_inline(bool)falseBlock inline bots
.embed_links(bool)falseBlock link embeds
.send_polls(bool)falseBlock polls
.change_info(bool)falseBlock changing chat info
.invite_users(bool)falseBlock inviting users
.pin_messages(bool)falseBlock pinning messages
.until_date(ts: i32)0Expiry Unix timestamp (0 = permanent)

Note: Setting view_messages: true is a full ban: the member cannot read messages or remain in the group.


AdminRightsBuilder: grant admin rights

#![allow(unused)]
fn main() {
use ferogram::participants::AdminRightsBuilder;

// Custom moderator
client
    .promote_participant(
        peer.clone(), user_id,
        AdminRightsBuilder::new()
            .delete_messages(true)
            .ban_users(true)
            .invite_users(true)
            .pin_messages(true)
            .rank("Moderator"),  // shown next to name
    )
    .await?;

// Full admin (all standard rights)
client
    .promote_participant(peer.clone(), user_id, AdminRightsBuilder::full_admin())
    .await?;

// Demote (remove all admin rights)
client
    .promote_participant(peer.clone(), user_id, AdminRightsBuilder::new())
    .await?;
}

Method reference

MethodDefaultDescription
AdminRightsBuilder::new()all falseNo rights (use to demote)
AdminRightsBuilder::full_admin()standard setAll rights except add_admins
.change_info(bool)falseCan edit channel/group info & photo
.post_messages(bool)falseCan post in broadcast channels
.edit_messages(bool)falseCan edit other users’ messages
.delete_messages(bool)falseCan delete any message
.ban_users(bool)falseCan restrict / ban members
.invite_users(bool)falseCan add new members
.pin_messages(bool)falseCan pin messages
.add_admins(bool)falseCan promote others to admin ⚠️
.anonymous(bool)falsePosts appear as channel name
.manage_call(bool)falseCan start/manage voice chats
.manage_topics(bool)falseCan create/edit/delete forum topics
.rank(str)NoneCustom admin title (max 16 chars)

.add_admins(true) grants significant trust: admins with this right can promote others to full admin level.


ParticipantPermissions: read effective rights

To inspect the actual current permissions of a user in a channel:

#![allow(unused)]
fn main() {
use ferogram::participants::ParticipantPermissions;

let perms: ParticipantPermissions = client
    .get_permissions(peer.clone(), user_id)
    .await?;
}

Fields & methods

SymbolTypeDescription
is_creator()boolIs the creator
is_admin()boolHas any admin rights
is_banned()boolIs banned or restricted
is_member()boolActive member (!is_banned && !is_left)
can_send_messagesboolCan send text
can_send_mediaboolCan send media
can_pin_messagesboolCan pin messages
can_add_adminsboolCan promote others
admin_rankOption<String>Custom admin title

Quick patterns

#![allow(unused)]
fn main() {
// Temporarily mute: no messages for 1 hour
let in_1h = (chrono::Utc::now().timestamp() + 3600) as i32;
client.ban_participant(peer.clone(), uid,
    BannedRightsBuilder::new().send_messages(true).until_date(in_1h)
).await?;

// Promote to channel editor
client.promote_participant(peer.clone(), uid,
    AdminRightsBuilder::new()
        .post_messages(true)
        .edit_messages(true)
        .delete_messages(true)
).await?;

// Check before acting
let perms = client.get_permissions(peer.clone(), uid).await?;
if !perms.is_admin() && !perms.is_banned() {
    client.ban_participant(peer.clone(), uid, BannedRightsBuilder::full_ban()).await?;
}
}

Dialogs & History


Fetch dialogs

#![allow(unused)]
fn main() {
// Fetch up to N dialogs (returns the most recent first)
let dialogs = client.get_dialogs(50).await?;

for d in &dialogs {
    println!("{}: {} unread: top msg: {}",
        d.title(), d.unread_count(), d.top_message());
}
}

Dialog accessors

MethodReturnDescription
d.title()StringChat name
d.peer()Option<&tl::enums::Peer>The peer for this dialog
d.unread_count()i32Unread message count
d.top_message()i32ID of the latest message

DialogIter: lazy paginated iterator

#![allow(unused)]
fn main() {
let mut iter = client.iter_dialogs();

// Total count (available after first page is fetched)
if let Some(total) = iter.total() {
    println!("Total dialogs: {total}");
}

while let Some(dialog) = iter.next(&client).await? {
    println!("{}", dialog.title());
}
}
MethodDescription
client.iter_dialogs()Create iterator
iter.total()Option<i32>: total count after first fetch
iter.next(&client)async → Option<Dialog>

MessageIter: lazy message history

#![allow(unused)]
fn main() {
let mut iter = client.iter_messages(peer.clone());

// Total count of messages in this chat
if let Some(total) = iter.total() {
    println!("Total messages: {total}");
}

while let Some(msg) = iter.next(&client).await? {
    println!("[{}] {}", msg.id, msg.message);
}
}
MethodDescription
client.iter_messages(peer)Create iterator (newest first)
iter.total()Option<i32>: total message count after first fetch
iter.next(&client)async → Option<tl::types::Message>

Fetch messages directly

#![allow(unused)]
fn main() {
// Latest N messages from a peer
let messages = client.get_messages(peer.clone(), 20).await?;

// Specific message IDs
let messages = client.get_messages_by_id(peer.clone(), &[100, 101, 102]).await?;
// Returns Vec<Option<tl::enums::Message>>: None if not found

// Pinned message
let pinned = client.get_pinned_message(peer.clone()).await?;

// The message a given message replies to
let parent = client.get_reply_to_message(peer.clone(), msg_id).await?;
}

Scheduled messages

#![allow(unused)]
fn main() {
// List all scheduled messages
let scheduled = client.get_scheduled_messages(peer.clone()).await?;

// Cancel a scheduled message
client.delete_scheduled_messages(peer.clone(), &[scheduled_id]).await?;
}

Dialog management

#![allow(unused)]
fn main() {
// Mark all messages as read
client.mark_as_read(peer.clone()).await?;

// Clear @mention badges
client.clear_mentions(peer.clone()).await?;

// Leave and remove from dialog list
client.delete_dialog(peer.clone()).await?;

// Join a public group/channel
client.join_chat("@somegroup").await?;

// Accept a private invite link
client.accept_invite_link("https://t.me/joinchat/AbCdEfG").await?;

// Parse invite hash from any link format
let hash = Client::parse_invite_hash("https://t.me/+AbCdEfG12345");
}

Search

ferogram provides two fluent search builders:

  • SearchBuilder: search within a single peer (client.search())
  • GlobalSearchBuilder: search across all dialogs (client.search_global_builder())

Both builders return Vec<IncomingMessage> from .fetch(&client).await?.


#![allow(unused)]
fn main() {
use ferogram_tl_types::enums::MessagesFilter;

let results = client
    .search(peer.clone(), "rust async")  // peer: impl Into<PeerRef>
    .limit(50)
    .fetch(&client)
    .await?;

for msg in &results {
    println!("[{}] {}", msg.id, msg.message);
}
}

client.search(peer, query) accepts any impl Into<PeerRef>: a &str username, a tl::enums::Peer, or a numeric i64 ID.

All SearchBuilder methods

MethodDefaultDescription
.limit(n: i32)100Maximum results to return
.min_date(ts: i32)0Only messages at or after this Unix timestamp
.max_date(ts: i32)0Only messages at or before this Unix timestamp
.offset_id(id: i32)0Start from this message ID (pagination)
.add_offset(n: i32)0Additional offset for fine pagination
.max_id(id: i32)0Only messages with ID ≤ max_id
.min_id(id: i32)0Only messages with ID ≥ min_id
.filter(f: MessagesFilter)EmptyFilter by media type
.sent_by_self():Only messages sent by the logged-in user
.from_peer(peer: InputPeer)NoneOnly messages from this specific sender
.top_msg_id(id: i32)NoneRestrict search to a forum topic thread
.fetch(&client):Execute: returns Vec<IncomingMessage>

Filter by media type

#![allow(unused)]
fn main() {
use ferogram_tl_types::enums::MessagesFilter;

// Photos only
let photos = client
    .search(peer.clone(), "")
    .filter(MessagesFilter::InputMessagesFilterPhotos)
    .limit(30)
    .fetch(&client)
    .await?;

// Documents only
let docs = client
    .search(peer.clone(), "report")
    .filter(MessagesFilter::InputMessagesFilterDocument)
    .fetch(&client)
    .await?;

// Voice messages
let voices = client
    .search(peer.clone(), "")
    .filter(MessagesFilter::InputMessagesFilterVoice)
    .fetch(&client)
    .await?;
}

Common MessagesFilter values

FilterMatches
InputMessagesFilterEmptyAll messages (default)
InputMessagesFilterPhotosPhotos
InputMessagesFilterVideoVideos
InputMessagesFilterDocumentDocuments / files
InputMessagesFilterAudioAudio files
InputMessagesFilterVoiceVoice messages
InputMessagesFilterRoundVideoVideo notes (round videos)
InputMessagesFilterUrlMessages with URLs
InputMessagesFilterMyMentionsMessages where you were @mentioned
InputMessagesFilterPinnedPinned messages
InputMessagesFilterGeoMessages with location
#![allow(unused)]
fn main() {
// Messages from the last 7 days
let week_ago = (std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH)
    .unwrap()
    .as_secs() - 7 * 86400) as i32;

let results = client
    .search(peer.clone(), "error")
    .min_date(week_ago)
    .limit(100)
    .fetch(&client)
    .await?;
}

Search from a specific sender

#![allow(unused)]
fn main() {
// Messages sent by yourself
let mine = client
    .search(peer.clone(), "")
    .sent_by_self()
    .fetch(&client)
    .await?;

// Messages from a specific InputPeer
let alice_peer = tl::enums::InputPeer::User(tl::types::InputPeerUser {
    user_id: alice_id,
    access_hash: alice_hash,
});

let from_alice = client
    .search(peer.clone(), "hello")
    .from_peer(alice_peer)
    .fetch(&client)
    .await?;
}
#![allow(unused)]
fn main() {
let results = client
    .search(supergroup_peer.clone(), "query")
    .top_msg_id(topic_msg_id)
    .fetch(&client)
    .await?;
}

Pagination

#![allow(unused)]
fn main() {
let mut offset_id = 0;

loop {
    let page = client
        .search(peer.clone(), "keyword")
        .offset_id(offset_id)
        .limit(50)
        .fetch(&client)
        .await?;

    if page.is_empty() { break; }

    for msg in &page {
        println!("[{}] {}", msg.id, msg.message);
    }

    // Move the cursor to the oldest message in this page
    offset_id = page.iter().map(|m| m.id).min().unwrap_or(0);
}
}

GlobalSearchBuilder: search all chats

#![allow(unused)]
fn main() {
let results = client
    .search_global_builder("rust async")
    .limit(30)
    .fetch(&client)
    .await?;

for msg in &results {
    println!("[{:?}] [{}] {}", msg.peer_id, msg.id, msg.message);
}
}

All GlobalSearchBuilder methods

MethodDefaultDescription
.limit(n: i32)100Maximum results
.min_date(ts: i32)0Only messages at or after this timestamp
.max_date(ts: i32)0Only messages at or before this timestamp
.offset_rate(r: i32)0Pagination: rate from last response
.offset_id(id: i32)0Pagination: message ID from last response
.folder_id(id: i32)NoneRestrict to a specific dialog folder
.broadcasts_only(v: bool)falseOnly search channels
.groups_only(v: bool)falseOnly search groups / supergroups
.users_only(v: bool)falseOnly search private chats / bots
.filter(f: MessagesFilter)EmptyFilter by media type
.fetch(&client):Execute: returns Vec<IncomingMessage>

Filter by chat type

#![allow(unused)]
fn main() {
// Channels only
let channel_results = client
    .search_global_builder("announcement")
    .broadcasts_only(true)
    .limit(20)
    .fetch(&client)
    .await?;

// Groups / supergroups only
let group_results = client
    .search_global_builder("discussion")
    .groups_only(true)
    .fetch(&client)
    .await?;

// Private chats and bots only
let dm_results = client
    .search_global_builder("invoice")
    .users_only(true)
    .fetch(&client)
    .await?;
}

Combined filters

#![allow(unused)]
fn main() {
// Photo messages from channels, last 30 days
let cutoff = (chrono::Utc::now().timestamp() - 30 * 86400) as i32;

let photos = client
    .search_global_builder("")
    .broadcasts_only(true)
    .filter(MessagesFilter::InputMessagesFilterPhotos)
    .min_date(cutoff)
    .limit(50)
    .fetch(&client)
    .await?;
}

Simple one-liner methods (no builder)

For quick lookups that don’t need date/filter options:

#![allow(unused)]
fn main() {
// Per-chat search: returns Vec<IncomingMessage>
let results = client.search_messages(peer.clone(), "query", 20).await?;

// Global search: returns Vec<IncomingMessage>
let results = client.search_global("ferogram rust", 10).await?;
}

Typing Guard

TypingGuard is a RAII wrapper that keeps a “typing…” or “uploading…” indicator alive for the duration of an operation and automatically cancels it when dropped. You never need to call SetTyping with CancelAction by hand.

The guard re-sends the action every 4 seconds (Telegram drops indicators after ~5 s) until the guard is dropped or .cancel() is called.


Setup

TypingGuard is re-exported from ferogram: no extra import needed beyond use ferogram::TypingGuard;.


Convenience methods on Client

These are the recommended entry points:

#![allow(unused)]
fn main() {
use ferogram::{Client, TypingGuard};

// "typing…"
let _typing = client.typing(peer.clone()).await?;

// "uploading document…"
let _typing = client.uploading_document(peer.clone()).await?;

// "recording video…"
let _typing = client.recording_video(peer.clone()).await?;

// typing inside a forum topic (top_msg_id)
let _typing = client.typing_in_topic(peer.clone(), topic_id).await?;
}

The guard auto-cancels when it goes out of scope.


Using TypingGuard::start directly

For any SendMessageAction variant: including ones that don’t have a convenience method:

#![allow(unused)]
fn main() {
use ferogram::TypingGuard;
use ferogram_tl_types as tl;

// Record audio / voice message
let _guard = TypingGuard::start(
    client,
    peer.clone(),
    tl::enums::SendMessageAction::SendMessageRecordAudioAction,
).await?;

// Upload photo
let _guard = TypingGuard::start(
    client,
    peer.clone(),
    tl::enums::SendMessageAction::SendMessageUploadPhotoAction(
        tl::types::SendMessageUploadPhotoAction { progress: 0 },
    ),
).await?;

// Choose sticker
let _guard = TypingGuard::start(
    client,
    peer.clone(),
    tl::enums::SendMessageAction::SendMessageChooseStickerAction,
).await?;
}

TypingGuard::start_ex: forum topics + custom delay

#![allow(unused)]
fn main() {
use std::time::Duration;

let _guard = TypingGuard::start_ex(
    client,
    peer,                   // tl::enums::Peer (already resolved)
    tl::enums::SendMessageAction::SendMessageTypingAction,
    Some(topic_msg_id),     // top_msg_id: None for normal chats
    Duration::from_secs(4), // repeat delay (≤ 4 s recommended)
).await?;
}

Manual .cancel()

Call .cancel() to stop the indicator immediately without waiting for the guard to drop:

#![allow(unused)]
fn main() {
let mut guard = client.typing(peer.clone()).await?;

do_some_work().await;

guard.cancel(); // indicator stops here

// guard still lives, but the task is already stopped
send_reply(client, peer).await?;
}

One-shot send_chat_action (no guard)

If you don’t need the automatic renewal, fire a single action:

#![allow(unused)]
fn main() {
client.send_chat_action(
    peer.clone(),
    tl::enums::SendMessageAction::SendMessageTypingAction,
    None, // top_msg_id: Some(id) for forum topics
).await?;
}

Telegram shows the indicator for ~5 seconds and then removes it automatically.


Complete example: long task with typing

#![allow(unused)]
fn main() {
use ferogram::{Client, InvocationError};
use ferogram_tl_types as tl;

async fn handle_command(
    client: &Client,
    peer: tl::enums::Peer,
) -> Result<(), InvocationError> {
    // Indicator starts immediately, renewed every 4 s
    let _typing = client.typing(peer.clone()).await?;

    // Simulate a slow operation
    let result = compute_answer().await;

    // _typing drops here → indicator cancelled
    client
        .send_message_to_peer(peer, &result)
        .await?;

    Ok(())
}

async fn compute_answer() -> String {
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
    "Here is your answer!".into()
}
}

Example: upload with “uploading document…” indicator

#![allow(unused)]
fn main() {
async fn send_document(
    client: &Client,
    peer: tl::enums::Peer,
    bytes: Vec<u8>,
    filename: &str,
) -> Result<(), InvocationError> {
    // Show "uploading document…" while the upload runs
    let _guard = client.uploading_document(peer.clone()).await?;

    let uploaded = client.upload_file(filename, &bytes).await?;

    drop(_guard); // cancel the indicator before sending

    client.send_file(peer, uploaded, false).await?;
    Ok(())
}
}

How it works internally

  1. start() calls send_chat_action_ex(peer, action, topic_id) immediately.
  2. A tokio::spawn loop wakes every repeat_delay (default 4 s) and re-sends the action.
  3. A tokio::sync::Notify signals the loop to stop when the guard is dropped or .cancel() is called.
  4. On loop exit, SendMessageCancelAction is sent to immediately clear the indicator.

API reference

SymbolKindDescription
TypingGuardstructRAII guard; drop to cancel
TypingGuard::start(client, peer, action)async fnStart any SendMessageAction
TypingGuard::start_ex(client, peer, action, topic_id, delay)async fnFull control: topic support + custom repeat delay
guard.cancel()fnStop the indicator immediately (guard stays alive)
client.typing(peer)async fnShorthand for TypingAction
client.uploading_document(peer)async fnShorthand for UploadDocumentAction
client.recording_video(peer)async fnShorthand for RecordVideoAction
client.typing_in_topic(peer, topic_id)async fnTyping inside a forum topic thread

Raw API Access

Every Telegram API method is available as a typed struct in ferogram_tl_types::functions. Use client.invoke() to call any of them directly with full compile-time type safety.

Basic usage

#![allow(unused)]
fn main() {
use ferogram_tl_types::functions;

// Get current update state
let state = client.invoke(
    &functions::updates::GetState {}
).await?;

println!("pts={} qts={} seq={}", state.pts, state.qts, state.seq);
}

All 500+ functions are organized by namespace matching the TL schema:

TL namespaceRust pathExamples
auth.*functions::auth::SendCode, SignIn, LogOut
account.*functions::account::GetPrivacy, UpdateProfile
users.*functions::users::GetFullUser, GetUsers
contacts.*functions::contacts::Search, GetContacts, AddContact
messages.*functions::messages::SendMessage, GetHistory, Search
updates.*functions::updates::GetState, GetDifference
photos.*functions::photos::UploadProfilePhoto, GetUserPhotos
upload.*functions::upload::SaveFilePart, GetFile
channels.*functions::channels::GetParticipants, EditAdmin
bots.*functions::bots::SetBotCommands, GetBotCommands
payments.*functions::payments::GetStarGiftAuctionState (L223)
stories.*functions::stories::GetStories, CreateAlbum (L223)

Examples

Get full user info

#![allow(unused)]
fn main() {
use ferogram_tl_types::{functions, enums, types};

let user_full = client.invoke(&functions::users::GetFullUser {
    id: enums::InputUser::InputUser(types::InputUser {
        user_id:     target_user_id,
        access_hash: user_access_hash,
    }),
}).await?;

let tl::enums::users::UserFull::UserFull(uf) = user_full;
if let enums::UserFull::UserFull(info) = uf.full_user {
    println!("About: {:?}", info.about);
    println!("Common chats: {}", info.common_chats_count);
    println!("Stars rating: {:?}", info.stars_rating);
}
}

Send a message with all parameters

#![allow(unused)]
fn main() {
client.invoke(&functions::messages::SendMessage {
    no_webpage:        false,
    silent:            false,
    background:        false,
    clear_draft:       true,
    noforwards:        false,
    update_stickersets_order: false,
    invert_media:      false,
    peer:              peer_input,
    reply_to:          None,
    message:           "Hello from raw API!".into(),
    random_id:         ferogram::random_i64_pub(),
    reply_markup:      None,
    entities:          None,
    schedule_date:     None,
    send_as:           None,
    quick_reply_shortcut: None,
    effect:            None,
    allow_paid_floodskip: false,
}).await?;
}

Edit admin rights (Layer 223)

In Layer 223, rank is now Option<String>:

#![allow(unused)]
fn main() {
client.invoke(&functions::channels::EditAdmin {
    flags: 0,
    channel: enums::InputChannel::InputChannel(types::InputChannel {
        channel_id, access_hash: ch_hash,
    }),
    user_id: enums::InputUser::InputUser(types::InputUser {
        user_id, access_hash: user_hash,
    }),
    admin_rights: enums::ChatAdminRights::ChatAdminRights(types::ChatAdminRights {
        change_info: true,
        post_messages: true,
        delete_messages: true,
        ban_users: true,
        invite_users: true,
        pin_messages: true,
        manage_call: true,
        manage_ranks: true,  // new in Layer 223
        // ... all others false
        edit_messages: false, add_admins: false, anonymous: false,
        other: false, manage_topics: false, post_stories: false,
        edit_stories: false, delete_stories: false,
        manage_direct_messages: false,
    }),
    rank: Some("Moderator".into()),  // Layer 223: optional
}).await?;
}

Set bot commands

#![allow(unused)]
fn main() {
client.invoke(&functions::bots::SetBotCommands {
    scope:    enums::BotCommandScope::Default,
    lang_code: "en".into(),
    commands: vec![
        types::BotCommand { command: "start".into(), description: "Start the bot".into() },
        types::BotCommand { command: "help".into(),  description: "Show help".into()  },
        types::BotCommand { command: "ping".into(),  description: "Latency check".into() },
    ],
}).await?;
}

New in Layer 223: edit chat creator

#![allow(unused)]
fn main() {
client.invoke(&functions::messages::EditChatCreator {
    peer: chat_input_peer,
    user_id: new_creator_input_user,
    password: enums::InputCheckPasswordSRP::InputCheckPasswordEmpty,
}).await?;
}

New in Layer 223: URL auth match code

#![allow(unused)]
fn main() {
let valid = client.invoke(&functions::messages::CheckUrlAuthMatchCode {
    url:        "https://example.com/login".into(),
    match_code: "abc123".into(),
}).await?;
}

Access hashes

Many raw API calls need an access_hash alongside user/channel IDs. The internal peer cache is populated by resolve_peer, get_participants, get_dialogs, etc.:

#![allow(unused)]
fn main() {
// This populates the peer cache
let peer = client.resolve_peer("@username").await?;

// For users
let user_hash = client.inner_peer_cache_users().get(&user_id).copied().unwrap_or(0);

// Simpler: use resolve_to_input_peer for a ready-to-use InputPeer
let input_peer = client.resolve_to_input_peer("@username").await?;
}

Error patterns

#![allow(unused)]
fn main() {
use ferogram::{InvocationError, RpcError};

match client.invoke(&req).await {
    Ok(result) => use_result(result),
    Err(InvocationError::Rpc(RpcError { code: 400, message, .. })) => {
        eprintln!("Bad request: {message}");
    }
    Err(InvocationError::Rpc(RpcError { code: 403, message, .. })) => {
        eprintln!("Forbidden: {message}");
    }
    Err(InvocationError::Rpc(RpcError { code: 420, message, .. })) => {
        // FLOOD_WAIT (only if using NoRetries policy)
        let secs: u64 = message
            .strip_prefix("FLOOD_WAIT_").and_then(|s| s.parse().ok()).unwrap_or(60);
        tokio::time::sleep(Duration::from_secs(secs)).await;
    }
    Err(e) => return Err(e.into()),
}
}

Retry & Flood Wait

Telegram’s rate limiting system sends FLOOD_WAIT_X errors when you call the API too frequently. X is the number of seconds you must wait before retrying.

Default behaviour: AutoSleep

By default, ferogram uses AutoSleep: it transparently sleeps for the required duration, then retries. Your code never sees the error.

#![allow(unused)]
fn main() {
use ferogram::{Config, AutoSleep};
use std::sync::Arc;

let (client, _shutdown) = Client::connect(Config {
    retry_policy: Arc::new(AutoSleep::default()),
    ..Default::default()
}).await?;
}

This is the default. You don’t need to set it explicitly.

NoRetries: propagate immediately

If you want to handle FLOOD_WAIT yourself:

#![allow(unused)]
fn main() {
use ferogram::NoRetries;

let (client, _shutdown) = Client::connect(Config {
    retry_policy: Arc::new(NoRetries),
    ..Default::default()
}).await?;
}

Then in your code:

#![allow(unused)]
fn main() {
use ferogram::{InvocationError, RpcError};
use tokio::time::{sleep, Duration};

loop {
    match client.send_message("@user", "hi").await {
        Ok(_) => break,
        Err(InvocationError::Rpc(RpcError { code: 420, ref message, .. })) => {
            let secs: u64 = message
                .strip_prefix("FLOOD_WAIT_")
                .and_then(|s| s.parse().ok())
                .unwrap_or(60);
            println!("Rate limited. Waiting {secs}s");
            sleep(Duration::from_secs(secs)).await;
        }
        Err(e) => return Err(e.into()),
    }
}
}

Custom retry policy

Implement RetryPolicy for full control: cap the wait, log, or give up after N attempts:

#![allow(unused)]
fn main() {
use ferogram::{RetryPolicy, RetryContext};
use std::ops::ControlFlow;
use std::time::Duration;

struct CappedSleep {
    max_wait_secs: u64,
    max_attempts:  u32,
}

impl RetryPolicy for CappedSleep {
    fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration> {
        if ctx.attempt() >= self.max_attempts {
            log::warn!("Giving up after {} attempts", ctx.attempt());
            return ControlFlow::Break(());
        }

        let wait = ctx.flood_wait_secs();
        if wait > self.max_wait_secs {
            log::warn!("FLOOD_WAIT too long ({wait}s), giving up");
            return ControlFlow::Break(());
        }

        log::info!("FLOOD_WAIT {wait}s (attempt {})", ctx.attempt());
        ControlFlow::Continue(Duration::from_secs(wait))
    }
}

let (client, _shutdown) = Client::connect(Config {
    retry_policy: Arc::new(CappedSleep {
        max_wait_secs: 30,
        max_attempts:  3,
    }),
    ..Default::default()
}).await?;
}

RetryContext fields

MethodReturnsDescription
ctx.flood_wait_secs()u64How long Telegram wants you to wait
ctx.attempt()u32How many times this call has been retried
ctx.error_message()&strThe raw error message string

Avoiding flood waits

  • Add small delays between bulk operations: tokio::time::sleep(Duration::from_millis(100)).await
  • Cache peer resolutions: don’t resolve the same username repeatedly
  • Don’t send messages in tight loops
  • Bots have more generous limits than user accounts
  • Some methods (e.g. GetHistory) have separate, more generous limits
  • Use send_message for a single message; avoid rapid-fire parallel calls

DC Migration

Telegram’s infrastructure is split across multiple Data Centers (DCs). When you connect to the wrong DC for your account, Telegram responds with a PHONE_MIGRATE_X or USER_MIGRATE_X error telling you which DC to use instead.

ferogram handles DC migration automatically and transparently. You don’t need to do anything.

How it works

  1. You connect to DC2 (the default)
  2. You log in with a phone number registered on DC1
  3. Telegram returns PHONE_MIGRATE_1
  4. ferogram reconnects to DC1, re-performs the DH handshake, and retries your request
  5. Your code sees a successful response: the migration is invisible

The correct DC is then saved in the session file for future connections.

Each new DC connection performs a full DH key exchange to establish a fresh auth key for that DC.

Overriding the initial DC

By default, ferogram starts on DC2. If you know your account is on a different DC, you can set the initial address:

#![allow(unused)]
fn main() {
let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("my.session")
    .dc_addr("149.154.167.91:443")   // DC4
    .connect()
    .await?;
}

DC addresses:

DCPrimary IPNotes
DC1149.154.175.53US East
DC2149.154.167.51US East (default)
DC3149.154.175.100US West
DC4149.154.167.91EU Amsterdam
DC591.108.56.130Singapore

In practice, just leave the default and let auto-migration handle it.

DC pool (for media)

When downloading media, Telegram may route large files through CDN DCs different from your account’s home DC. ferogram maintains a connection pool across DCs and handles this automatically via invoke_on_dc.

Proxies

ferogram supports two proxy types: SOCKS5 (generic TCP tunnel) and MTProxy (Telegram’s native obfuscated proxy protocol).

SOCKS5

Without authentication

#![allow(unused)]
fn main() {
use ferogram::{Client, socks5::Socks5Config};

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .socks5(Socks5Config::new("127.0.0.1:1080"))
    .connect()
    .await?;
}

With username/password authentication

#![allow(unused)]
fn main() {
.socks5(Socks5Config::with_auth(
    "proxy.example.com:1080",
    "username",
    "password",
))
}

Tor

Point SOCKS5 at 127.0.0.1:9050 (default Tor SOCKS port):

#![allow(unused)]
fn main() {
.socks5(Socks5Config::new("127.0.0.1:9050"))
}

Tor exit nodes are sometimes blocked by Telegram DCs. If connections fail consistently, try a different circuit or use TransportKind::Obfuscated alongside it.


MTProxy

MTProxy is Telegram’s own proxy protocol. It uses obfuscated transports and connects you directly to a Telegram DC via a third-party relay server.

Use parse_proxy_link to decode a tg://proxy?... or https://t.me/proxy?... link. The transport is selected automatically from the secret prefix.

#![allow(unused)]
fn main() {
use ferogram::{Client, proxy::parse_proxy_link};

let proxy = parse_proxy_link(
    "tg://proxy?server=proxy.example.com&port=443&secret=eedeadbeef..."
).expect("invalid proxy link");

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .mtproxy(proxy)
    .connect()
    .await?;
}

.mtproxy() sets the transport automatically. Do not also call .transport() when using MTProxy.

Secret format and transport mapping

Secret prefixTransport selected
32 hex chars (plain)Obfuscated (Obfuscated2, Abridged framing)
dd + 32 hex charsPaddedIntermediate (Obfuscated2, padded framing)
ee + 32 hex + domainFakeTls (TLS 1.3 ClientHello disguise)

Secrets can be hex strings or base64url. parse_proxy_link handles both.

Building MtProxyConfig manually

#![allow(unused)]
fn main() {
use ferogram::proxy::{MtProxyConfig, secret_to_transport};

let secret_hex = "dddeadbeefdeadbeefdeadbeefdeadbeef";
let secret_bytes: Vec<u8> = (0..secret_hex.len())
    .step_by(2)
    .map(|i| u8::from_str_radix(&secret_hex[i..i+2], 16).unwrap())
    .collect();

let proxy = MtProxyConfig {
    host:      "proxy.example.com".into(),
    port:      443,
    transport: secret_to_transport(&secret_bytes),
    secret:    secret_bytes,
};

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .mtproxy(proxy)
    .connect()
    .await?;
}

Upgrading the TL Layer

The Telegram API evolves continuously. Each new ferogram adds constructors, modifies existing types, and deprecates old ones. Upgrading ferogram is designed to be a one-file operation.

How the system works

ferogram-tl-types is fully auto-generated at build time:

tl/api.tl          (source of truth: the only file you replace)
    │
    ▼
build.rs           (reads api.tl, invokes ferogram-tl-gen)
    │
    ▼
$OUT_DIR/
  generated_common.rs     ← pub const LAYER: i32 = 224;
  generated_types.rs      ← pub mod types { ... }
  generated_enums.rs      ← pub mod enums { ... }
  generated_functions.rs  ← pub mod functions { ... }

The LAYER constant is extracted from the // LAYER N comment on the first line of api.tl. Everything else flows from there.

Step 1: Replace api.tl

# Get the new schema from Telegram's official sources
# (TDLib repository, core.telegram.org, or unofficial mirrors)

cp new-layer-224.tl ferogram-tl-types/tl/api.tl

Make sure the first line of the file is:

// LAYER 224

Step 2: Build

cargo build 2>&1 | head -40

The build script automatically:

  • Parses the new schema
  • Generates updated Rust source
  • Patches pub const LAYER: i32 = 224; into generated_common.rs

If there are no breaking type changes in ferogram, it compiles cleanly.

Step 3: Fix compile errors

New layers commonly add fields to existing structs. These show up as errors like:

error[E0063]: missing field `my_new_field` in initializer of `types::SomeStruct`

Fix them by adding the field with a sensible default:

#![allow(unused)]
fn main() {
// Boolean flags → false
my_new_flag: false,

// Option<T> fields → None
my_new_option: None,

// i32/i64 counts → 0
my_new_count: 0,

// String fields → String::new()
my_new_string: String::new(),
}

New enum variants in match statements:

#![allow(unused)]
fn main() {
// error[E0004]: non-exhaustive patterns: `Update::NewVariant(_)` not covered
Update::NewVariant(_) => { /* handle or ignore */ }
// OR add to the catch-all:
_ => {}
}

Step 4: Bump version and publish

# In Cargo.toml workspace section
version = "0.1.1"

Then publish in dependency order (see Publishing).

What propagates automatically

Once api.tl is updated with the new layer number, these update with zero additional changes:

WhatWhereHow
tl::LAYER constantferogram-tl-types/src/lib.rsbuild.rs patches it
invokeWithLayer callferogram/src/lib.rs:1847reads tl::LAYER
/about bot commandlayer-bot/src/main.rs:333reads tl::LAYER at runtime
Badge in READMEManual: update onceString replace

Diff the changes

diff old-api.tl ferogram-tl-types/tl/api.tl | grep "^[<>]" | head -40

This shows you exactly which constructors changed, helping you anticipate which ferogram files need updating.

Configuration

Config is the struct passed to Client::connect. The recommended way to build it is via Client::builder(). All fields except api_id and api_hash have defaults.

#![allow(unused)]
fn main() {
use ferogram::{Client, TransportKind};

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_api_hash")
    .session("bot.session")       // default: "ferogram.session"
    .transport(TransportKind::Obfuscated { secret: None })
    .catch_up(false)
    .connect()
    .await?;
}

Builder methods

api_id / api_hash

Required. Get these from my.telegram.org.

#![allow(unused)]
fn main() {
.api_id(12345)
.api_hash("your_hash")
}

session

Path to a binary session file. Default: "ferogram.session".

#![allow(unused)]
fn main() {
.session("mybot.session")
}

session_string

Portable base64 session (for serverless / env-var storage). Pass an empty string to start fresh.

#![allow(unused)]
fn main() {
.session_string(std::env::var("SESSION").unwrap_or_default())
}

in_memory

Non-persistent session. Useful for tests.

#![allow(unused)]
fn main() {
.in_memory()
}

session_backend

Inject a custom SessionBackend directly, e.g. LibSqlBackend:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use ferogram::LibSqlBackend;

.session_backend(Arc::new(LibSqlBackend::new("remote.db")))
}

transport

Which MTProto framing to use. Default: TransportKind::Abridged.

VariantNotes
AbridgedMinimal overhead. Default.
Intermediate4-byte LE length prefix. Better compat with some proxies.
FullIntermediate + seqno + CRC32 integrity check.
Obfuscated { secret }AES-256-CTR (Obfuscated2). Pass secret: None for direct connections, or a 16-byte key for MTProxy with a plain secret.
PaddedIntermediate { secret }Obfuscated2 with padded Intermediate framing. Required for 0xDD MTProxy secrets.
FakeTls { secret, domain }Disguises traffic as a TLS 1.3 ClientHello. Required for 0xEE MTProxy secrets.
#![allow(unused)]
fn main() {
use ferogram::TransportKind;

// plain obfuscation, no proxy
.transport(TransportKind::Obfuscated { secret: None })

// Intermediate framing
.transport(TransportKind::Intermediate)

// FakeTLS (manual, normally set by .mtproxy())
.transport(TransportKind::FakeTls {
    secret: [0xab; 16],
    domain: "example.com".into(),
})
}

When using .mtproxy(), the transport is set automatically. Do not also call .transport().

socks5

Route connections through a SOCKS5 proxy.

#![allow(unused)]
fn main() {
use ferogram::socks5::Socks5Config;

// no auth
.socks5(Socks5Config::new("127.0.0.1:1080"))

// with auth
.socks5(Socks5Config::with_auth("proxy.example.com:1080", "user", "pass"))
}

mtproxy

Route connections through an MTProxy relay. The transport is auto-selected from the secret.

#![allow(unused)]
fn main() {
use ferogram::proxy::parse_proxy_link;

let proxy = parse_proxy_link("tg://proxy?server=...&port=443&secret=...").unwrap();
.mtproxy(proxy)
}

See Proxies & Transports for full details.

dc_addr

Override the initial DC address. After login the correct DC is cached in the session, so this is only needed if you know exactly which DC to target.

#![allow(unused)]
fn main() {
.dc_addr("149.154.167.51:443")  // DC2
}

catch_up

When true, replays missed updates via updates.getDifference on reconnect. Default: false.

#![allow(unused)]
fn main() {
.catch_up(true)
}

allow_ipv6

Allow IPv6 DC addresses. Default: false.

#![allow(unused)]
fn main() {
.allow_ipv6(true)
}

retry_policy

How to handle FLOOD_WAIT errors. Default: AutoSleep (sleep the required duration and retry).

#![allow(unused)]
fn main() {
use std::sync::Arc;
use ferogram::retry::{AutoSleep, NoRetries};

.retry_policy(Arc::new(AutoSleep::default()))   // sleep and retry
.retry_policy(Arc::new(NoRetries))              // propagate immediately
}

Building Config without connecting

#![allow(unused)]
fn main() {
let config = Client::builder()
    .api_id(12345)
    .api_hash("hash")
    .build()?;

// later
let (client, _shutdown) = Client::connect(config).await?;
}

build() returns Err(BuilderError::MissingApiId) or Err(BuilderError::MissingApiHash) if those fields are missing, before touching the network.

Error Types


InvocationError

Every Client method returns Result<T, InvocationError>.

#![allow(unused)]
fn main() {
use ferogram::{InvocationError, RpcError};

match client.send_message("@peer", "Hello").await {
    Ok(()) => {}

    // Telegram returned an RPC error
    Err(InvocationError::Rpc(e)) => {
        eprintln!("Telegram error {}: {}", e.code, e.message);

        // Pattern-match the error name
        if e.is("FLOOD_WAIT") {
            let secs = e.flood_wait_seconds().unwrap_or(0);
            eprintln!("Rate limited, wait {secs}s");
        }
        if e.is("USER_PRIVACY_RESTRICTED") {
            eprintln!("Can't message this user");
        }
    }

    // Network / I/O failure
    Err(InvocationError::Io(e)) => eprintln!("I/O: {e}"),

    Err(e) => eprintln!("Other: {e}"),
}
}

InvocationError variants

VariantDescription
InvocationError::Rpc(RpcError)Telegram returned a TL error
InvocationError::Io(io::Error)Network or socket error
InvocationError::Deserialize(e)Failed to decode server response

InvocationError methods

MethodReturnDescription
.is("PATTERN")booltrue if this is an Rpc error whose message contains PATTERN
.flood_wait_seconds()Option<u64>Seconds to wait for FLOOD_WAIT_X errors

RpcError

#![allow(unused)]
fn main() {
let e: RpcError = /* ... */;

e.code               // i32: HTTP-like error code (400, 401, 403, 420, etc.)
e.message            // String: e.g. "FLOOD_WAIT_30"
e.is("FLOOD_WAIT")   // bool: prefix/substring match
e.flood_wait_seconds() // Option<u64>: parses the number from FLOOD_WAIT_N
}

Common error codes

CodeMeaning
400Bad request: wrong parameters
401Unauthorized: not logged in
403Forbidden: no permission
404Not found
420FLOOD_WAIT: too many requests
500Internal server error

Common error messages

MessageMeaning
FLOOD_WAIT_NWait N seconds before retrying
USER_PRIVACY_RESTRICTEDUser’s privacy settings block this
CHAT_WRITE_FORBIDDENNo permission to write in this chat
MESSAGE_NOT_MODIFIEDEdit content is the same as current
PEER_ID_INVALIDThe peer ID is wrong or missing access hash
USER_ID_INVALIDInvalid user ID or no access hash
CHANNEL_INVALIDInvalid channel or missing access hash
SESSION_REVOKEDSession was logged out remotely
AUTH_KEY_UNREGISTEREDAuth key no longer valid

SignInError

Returned by client.sign_in():

#![allow(unused)]
fn main() {
use ferogram::SignInError;

match client.sign_in(&token, &code).await {
    Ok(name) => println!("Welcome, {name}!"),

    Err(SignInError::PasswordRequired(password_token)) => {
        // 2FA is enabled: provide the password
        println!("2FA hint: {:?}", password_token.hint());
        client.check_password(*password_token, "my_password").await?;
    }

    Err(SignInError::InvalidCode) => eprintln!("Wrong code"),

    Err(SignInError::Other(e)) => eprintln!("Error: {e}"),
}
}

PasswordToken methods

#![allow(unused)]
fn main() {
password_token.hint()   // Option<&str>: 2FA password hint
}

BuilderError

Returned by ClientBuilder::connect() or ClientBuilder::build():

#![allow(unused)]
fn main() {
use ferogram::builder::BuilderError;

match Client::builder().api_id(0).api_hash("").connect().await {
    Err(BuilderError::MissingApiId)   => eprintln!("Set .api_id()"),
    Err(BuilderError::MissingApiHash) => eprintln!("Set .api_hash()"),
    Err(BuilderError::Connect(e))     => eprintln!("Connection failed: {e}"),
    Ok(_) => {}
}
}

FLOOD_WAIT auto-retry

FLOOD_WAIT errors are automatically retried by the default AutoSleep policy: you don’t need to handle them unless you want custom behaviour.

#![allow(unused)]
fn main() {
use ferogram::retry::{AutoSleep, NoRetries};
use std::sync::Arc;

// Default: retries FLOOD_WAIT automatically
Client::builder().retry_policy(Arc::new(AutoSleep::default()))

// Disable all retries
Client::builder().retry_policy(Arc::new(NoRetries))
}

Crate Architecture

ferogram is a workspace of focused, single-responsibility crates. Understanding the stack helps when you need to go below the high-level API.

Dependency graph

Your App
 └ ferogram ← high-level Client, UpdateStream, InputMessage
 ├ ferogram-mtproto ← MTProto session, DH, message framing
 │ └ ferogram-crypto ← AES-IGE, RSA, SHA, factorize
 └ ferogram-tl-types ← all generated types + LAYER constant
 ├ ferogram-tl-gen (build-time code generator)
 └ ferogram-tl-parser (build-time TL schema parser)

ferogram

The high-level async Telegram client. Import this in your application.

What it provides

  • Client: the main handle with all high-level methods
  • ClientBuilder: fluent builder for connecting (Client::builder()...connect())
  • Config: connection configuration
  • Update enum: typed update events
  • InputMessage: fluent message builder
  • parsers::parse_markdown / parsers::parse_html: text → entities
  • UpdateStream: async iterator
  • Dialog, DialogIter, MessageIter: dialog/history access
  • Participant, ParticipantStatus: member info
  • Photo, Document, Sticker, Downloadable: typed media wrappers
  • UploadedFile, DownloadIter: upload/download
  • TypingGuard: auto-cancels chat action on drop
  • SearchBuilder, GlobalSearchBuilder: fluent search
  • InlineKeyboard, ReplyKeyboard, Button: keyboard builders
  • SessionBackend trait + BinaryFileBackend, InMemoryBackend, StringSessionBackend, SqliteBackend, LibSqlBackend
  • Socks5Config: proxy configuration
  • TransportKind: Abridged, Intermediate, Obfuscated
  • Error types: InvocationError, RpcError, SignInError, LoginToken, PasswordToken
  • Retry traits: RetryPolicy, AutoSleep, NoRetries, RetryContext

ferogram-tl-types

All generated Telegram API types. Auto-regenerated at cargo build from tl/api.tl.

What it provides

  • LAYER: i32: the current layer number (224)
  • types::*: 1,200+ concrete structs (types::Message, types::User, etc.)
  • enums::*: 400+ boxed type enums (enums::Message, enums::Peer, etc.)
  • functions::*: 500+ RPC function structs implementing RemoteCall
  • Serializable / Deserializable traits
  • Cursor: zero-copy deserializer
  • RemoteCall: marker trait for RPC functions
  • Optional: name_for_id(u32) -> Option<&'static str>

Key type conventions

PatternMeaning
tl::types::FooConcrete constructor: a struct
tl::enums::BarBoxed type: an enum wrapping one or more types::*
tl::functions::ns::MethodRPC function: implements RemoteCall

Most Telegram API fields use enums::* types because the wire format is polymorphic.


ferogram-mtproto

The MTProto session layer. Handles the low-level mechanics of talking to Telegram.

What it provides

  • EncryptedSession: manages auth key, salt, session ID, message IDs
  • authentication::*: complete 3-step DH key exchange
  • Message framing: serialization, padding, encryption, HMAC
  • msg_container unpacking (batched responses)
  • gzip decompression of gzip_packed responses
  • Transport abstraction (abridged, intermediate, obfuscated)

DH handshake steps

  1. PQ factorization: req_pq_multi → server sends resPQ
  2. Server DH params: req_DH_params with encrypted key → server_DH_params_ok
  3. Client DH finish: set_client_DH_paramsdh_gen_ok

After step 3, both sides hold the same auth key derived from the shared DH secret.


ferogram-crypto

Cryptographic primitives. Pure Rust, #![deny(unsafe_code)].

ComponentAlgorithmUsage
aesAES-256-IGEMTProto 2.0 message encryption/decryption
auth_keySHA-256, XORAuth key derivation from DH material
factorizePollard’s rhoPQ factorization in DH step 1
RSAPKCS#1 v1.5Encrypting PQ proof with Telegram’s public keys
SHA-1SHA-1Used in auth key derivation
SHA-256SHA-256MTProto 2.0 MAC computation
obfuscatedAES-CTRTransport-layer obfuscation init
PBKDF2PBKDF2-SHA5122FA password derivation (via ferogram)

ferogram-tl-parser

TL schema parser. Converts .tl text into structured Definition values.

Parsed AST types

  • Definition: a single TL line (constructor or function)
  • Category: Type or Function
  • Parameter: a named field with type
  • ParameterType: flags, conditionals, generic, basic
  • Flag: flags.N?type conditional fields

Used exclusively by build.rs in ferogram-tl-types. You never import it directly.


ferogram-tl-gen

Rust code generator. Takes the parsed AST and emits valid Rust source files.

Output files (written to $OUT_DIR)

FileContents
generated_common.rspub const LAYER: i32 = N; + optional name_for_id
generated_types.rspub mod types { … }: all constructor structs
generated_enums.rspub mod enums { … }: all boxed type enums
generated_functions.rspub mod functions { … }: all RPC function structs

Each type automatically gets:

  • impl Serializable: binary TL encoding
  • impl Deserializable: binary TL decoding
  • impl Identifiable: const CONSTRUCTOR_ID: u32
  • Optional: impl Debug, impl From, impl TryFrom, impl Serialize/Deserialize

Feature Flags

ferogram

FeatureDefaultDescription
sqlite-sessionSQLite-backed session storage via rusqlite
libsql-sessionlibsql / Turso session storage: local or remote (Added in v0.4.7)
htmlBuilt-in hand-rolled HTML parser (parse_html, generate_html)
html5everSpec-compliant html5ever tokenizer: overrides the built-in html parser
serdeserde::Serialize / Deserialize on Config and public structs

The following are always available without any feature flag:

  • InlineKeyboard, ReplyKeyboard, Button: keyboard builders
  • InputReactions: reaction builder
  • TypingGuard: RAII typing indicator
  • SearchBuilder, GlobalSearchBuilder: fluent search
  • PeerRef: flexible peer argument
  • User, Group, Channel, Chat: typed wrappers
  • Socks5Config: SOCKS5 proxy config
  • BannedRightsBuilder, AdminRightsBuilder, ParticipantPermissions
  • StringSessionBackend, InMemoryBackend, BinaryFileBackend
  • ClientBuilder: fluent connection builder
# SQLite session only
ferogram = { version = "0.1.1", features = ["sqlite-session"] }

# LibSQL / Turso session (new in 0.1.1)
ferogram = { version = "0.1.1", features = ["libsql-session"] }

# HTML parsing (minimal, no extra deps)
ferogram = { version = "0.1.1", features = ["html"] }

# HTML parsing (spec-compliant, adds html5ever dep)
ferogram = { version = "0.1.1", features = ["html5ever"] }

# Multiple features at once
ferogram = { version = "0.1.1", features = ["sqlite-session", "html"] }

ferogram-tl-types

FeatureDefaultDescription
tl-apiTelegram API schema (constructors, functions, enums)
tl-mtprotoLow-level MTProto transport types
impl-debug#[derive(Debug)] on all generated types
impl-from-typeFrom<types::T> for enums::E conversions
impl-from-enumTryFrom<enums::E> for types::T conversions
deserializable-functionsDeserializable for function types (server-side use)
name-for-idname_for_id(id: u32) -> Option<&'static str>
impl-serdeserde::Serialize + serde::Deserialize on all types

Example: enable serde

ferogram-tl-types = { version = "0.1.1", features = ["tl-api", "impl-serde"] }
#![allow(unused)]
fn main() {
let json = serde_json::to_string(&some_tl_type)?;
}

Example: name_for_id (debugging)

ferogram-tl-types = { version = "0.1.1", features = ["tl-api", "name-for-id"] }
#![allow(unused)]
fn main() {
use ferogram_tl_types::name_for_id;

if let Some(name) = name_for_id(0x74ae4240) {
    println!("Constructor: {name}"); // → "updates"
}
}

Example: minimal (no Debug, no conversions)

ferogram-tl-types = { version = "0.1.1", default-features = false, features = ["tl-api"] }

Reduces compile time when you don’t need convenience traits.


String session: no feature flag needed

StringSessionBackend and export_session_string() are available in the default build: no feature flag required:

ferogram = "0.1.1"   # already includes StringSessionBackend
#![allow(unused)]
fn main() {
let s = client.export_session_string().await?;
let (client, _) = Client::with_string_session(&s, api_id, api_hash).await?;
}

docs.rs build matrix

When building docs on docs.rs, all feature flags are enabled:

[package.metadata.docs.rs]
features = ["sqlite-session", "libsql-session", "html", "html5ever"]
rustdoc-args = ["--cfg", "docsrs"]