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

⚡ layer

layer — Async Rust MTProto

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

Crates.io docs.rs TL Layer License Rust

layer 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 layer?

Most Telegram libraries are thin wrappers around generated code or ports from Python/JavaScript. layer is different — it was built from scratch to understand MTProto at the lowest level, then wrapped in an ergonomic 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

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

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


Quick install

[dependencies]
layer-client = "0.4.5"
tokio        = { version = "1", features = ["full"] }

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


What’s new in v0.4.5

  • StringSessionBackend — portable base64 sessions, no file required
  • LibSqlBackend — libsql/Turso remote database sessions
  • Update::ChatAction and Update::UserStatus — new typed update variants
  • sync_update_state() — force immediate pts/seq reconciliation
  • 7 bug fixes (pagination, memory leaks, chunk alignment, and more)

See the full CHANGELOG.


Acknowledgements

  • Lonami for grammers — the architecture, SRP math, and session design are deeply inspired by this fantastic library.
  • Telegram for the detailed MTProto specification.
  • The Rust async ecosystem: tokio, flate2, getrandom, sha2, and friends.
layer

Installation

Add to Cargo.toml

[dependencies]
layer-client = "0.4.5"
tokio        = { version = "1", features = ["full"] }

layer-client 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

layer-client = { version = "0.4.5", 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.4.5

layer-client = { version = "0.4.5", 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 layer_client::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.4.5

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)
layer-client = { version = "0.4.5", features = ["html"] }

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

Raw type system features (layer-tl-types)

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

layer-tl-types = { version = "0.4.5", 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 layer_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 layer_client::{Client, Config, SignInError};
use layer_client::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 layer! 👋").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 layer_client::{Client, InputMessage, parsers::parse_markdown, update::Update};
use layer_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 **layer** — 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 layer — 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 layer_client::{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.

MTProto DH key exchange flow diagram
  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 layer

#![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 layer_client::{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

  • layer-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 layer_client::{Client, Config};

let (client, _shutdown) = Client::connect(Config {
    session_path: "my.session".into(),
    api_id:       12345,
    api_hash:     "abc123".into(),
    ..Default::default()
}).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 layer_client::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)

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

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


String session — New in v0.4.5

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 layer_client::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 {
    session_path: "account_a.session".into(),
    api_id, api_hash: api_hash.clone(), ..Default::default()
}).await?;

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

Session Backends

layer-client ships four session backends out of the box. All implement the SessionBackend trait, so you can also plug in your own.

Overview

BackendFeature flagStorageBest for
BinaryFileBackend(default)Local binary fileDevelopment, simple scripts
InMemoryBackend(default)RAM onlyTests, ephemeral bots
SqliteBackendsqlite-sessionLocal SQLite fileProduction bots, long-running servers
LibSqlBackendlibsql-sessionlibsql / Turso (remote or embedded)Serverless, distributed deployments
StringSessionBackend(default)Caller-provided stringEnv vars, DB columns, CI environments

BinaryFileBackend (default)

# No extra feature needed
layer-client = "0.4.5"
#![allow(unused)]
fn main() {
use layer_client::{Client, Config};

let (client, _shutdown) = Client::connect(Config {
    session_path: "my.session".into(),
    api_id:       12345,
    api_hash:     "abc123".into(),
    ..Default::default()
}).await?;
}

The session is stored in a compact binary format at session_path. Created on first login; reloaded automatically on subsequent connect() calls.

Security: treat this file like a password — add *.session to .gitignore and chmod 600.


InMemoryBackend

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

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

Nothing is written to disk. Login is required on every run. Ideal for integration tests and short-lived scripts.


SqliteBackend

layer-client = { version = "0.4.5", features = ["sqlite-session"] }
#![allow(unused)]
fn main() {
use layer_client::{Client, Config};

let (client, _shutdown) = Client::connect(Config {
    session_path: "session.db".into(),  // use a .db extension
    api_id:       12345,
    api_hash:     "abc123".into(),
    ..Default::default()
}).await?;
}

SQLite is more resilient against crash-corruption than the binary format. A good choice for any bot that runs continuously or handles many accounts.


LibSqlBackend — New in 0.4.5

For libsql (the open-source Turso database engine):

layer-client = { version = "0.4.5", features = ["libsql-session"] }
#![allow(unused)]
fn main() {
use layer_client::session_backend::LibSqlBackend;
use layer_client::{Client, Config};

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

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

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

LibSqlBackend is a drop-in replacement for SqliteBackend but works with remote databases, making it ideal for serverless or horizontally-scaled deployments.


StringSessionBackend — New in 0.4.5

Encodes the entire session as a portable base64 string. Store it in environment variables, a secrets manager, a database column, or anywhere else you can store a string.

Export an existing session

#![allow(unused)]
fn main() {
// After a successful login, export the session
let session_string = client.export_session_string().await?;
println!("{session_string}");
// → "AQAAAAEDAADtE1lMHBT7...LrKO3y8=" (example)
}

Save this string securely (e.g. in a SESSION environment variable).

Restore from string

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

let session_str = std::env::var("TG_SESSION")?;

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

// If the session is valid, is_authorized() returns true — no re-login needed
assert!(client.is_authorized().await?);
}

Convenience constructor

#![allow(unused)]
fn main() {
// Equivalent to the above — shorthand for StringSessionBackend
let session_str = std::env::var("TG_SESSION")?;
let (client, _shutdown) = Client::with_string_session(
    &session_str,
    12345,         // api_id
    "abc123",      // api_hash
).await?;
}

Typical workflow for CI / serverless

# One-time: generate session on your dev machine
cargo run --bin login_helper
# → prints: TG_SESSION=AQAAAAEDAADtE1lMHBT7...
# Add TG_SESSION to your CI secrets
#![allow(unused)]
fn main() {
// In production / CI
let (client, _shutdown) = Client::with_string_session(
    &std::env::var("TG_SESSION")?,
    std::env::var("TG_API_ID")?.parse()?,
    std::env::var("TG_API_HASH")?,
).await?;
}

Implementing a custom backend

#![allow(unused)]
fn main() {
use layer_client::session_backend::SessionBackend;

pub struct MyRedisBackend {
    key: String,
    // ... your redis client
}

#[async_trait::async_trait]
impl SessionBackend for MyRedisBackend {
    async fn load(&self) -> Option<Vec<u8>> {
        // read bytes from Redis
    }

    async fn save(&self, data: &[u8]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        // write bytes to Redis
    }
}
}

Then pass it via ClientBuilder::session().


Comparing session backends

FileSQLiteLibSQLStringMemory
Survives restart✅*
Crash-safe⚠️N/AN/A
Remote storage✅*
Zero disk I/O
Extra depsNonerusqlitelibsqlNoneNone

* — provided the caller re-saves the exported string after each connect.

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 layer_client::{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 layer_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 layer_client::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

Inline keyboards appear as button rows attached below messages. They trigger Update::CallbackQuery when pressed.

#![allow(unused)]
fn main() {
use layer_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/layer")],
]);

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 and send a photo

#![allow(unused)]
fn main() {
// Upload from disk path
let uploaded = client.upload_file("photo.jpg").await?;

// Send as compressed photo
client.send_file(
    peer,
    uploaded.as_photo_media(),
    Some("My caption here"),
).await?;
}

Upload and send a document (any file)

#![allow(unused)]
fn main() {
let uploaded = client.upload_file("report.pdf").await?;

// Send as document (preserves original quality/format)
client.send_file(
    peer,
    uploaded.as_document_media(),
    Some("Monthly report"),
).await?;
}

TIP: For photos, as_photo_media() lets Telegram compress and display them inline. Use as_document_media() to preserve original file quality and format.

Upload from a stream

#![allow(unused)]
fn main() {
use tokio::fs::File;

let file    = File::open("video.mp4").await?;
let name    = "video.mp4".to_string();
let size    = file.metadata().await?.len() as i32;
let mime    = "video/mp4".to_string();

let uploaded = client.upload_stream(file, size, name, mime).await?;
client.send_file(peer, uploaded.as_document_media(), None).await?;
}

Send an album (multiple photos/videos)

#![allow(unused)]
fn main() {
let img1 = client.upload_file("photo1.jpg").await?;
let img2 = client.upload_file("photo2.jpg").await?;
let img3 = client.upload_file("photo3.jpg").await?;

client.send_album(
    peer,
    vec![
        img1.as_photo_media(),
        img2.as_photo_media(),
        img3.as_photo_media(),
    ],
    Some("Our trip 📸"),
).await?;
}

Albums are grouped as a single visual unit in the chat.

UploadedFile — methods

MethodReturnsDescription
uploaded.name()&strOriginal filename
uploaded.mime_type()&strDetected MIME type
uploaded.as_photo_media()InputMediaSend as compressed photo
uploaded.as_document_media()InputMediaSend as document

Download media from a message

#![allow(unused)]
fn main() {
if let tl::enums::Message::Message(m) = &raw_msg {
    if let Some(media) = &m.media {
        // download_media returns an async iterator of chunks
        let location = client.download_location(media);
        if let Some(loc) = location {
            let mut iter = client.iter_download(loc);
            let mut file = tokio::fs::File::create("download.bin").await?;

            while let Some(chunk) = iter.next().await? {
                tokio::io::AsyncWriteExt::write_all(&mut file, &chunk).await?;
            }
        }
    }
}
}

DownloadIter — options

#![allow(unused)]
fn main() {
let mut iter = client.iter_download(location)
    .chunk_size(512 * 1024);  // 512 KB per request (default: 128 KB)
}

MIME type reference

File typeMIMEDisplays as
JPEG, PNG, WebPimage/jpeg, image/pngPhoto (compressed)
GIFimage/gifAnimated image
MP4, MOVvideo/mp4Video player
OGG (Opus codec)audio/oggVoice message
MP3, FLACaudio/mpegAudio player
PDFapplication/pdfDocument with preview
ZIP, RARapplication/zipGeneric document
TGSapplication/x-tgstickerAnimated sticker

Get profile photos

#![allow(unused)]
fn main() {
let photos = client.get_profile_photos(peer, 10).await?;

for photo in &photos {
    if let tl::enums::Photo::Photo(p) = photo {
        println!("Photo ID {} — {} sizes", p.id, p.sizes.len());

        // Find the largest size
        let best = p.sizes.iter()
            .filter_map(|s| match s {
                tl::enums::PhotoSize::PhotoSize(ps) => Some(ps),
                _ => None,
            })
            .max_by_key(|ps| ps.size);

        if let Some(size) = best {
            println!("  Largest: {}x{} ({}B)", size.w, size.h, size.size);
        }
    }
}
}

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 layer_client::parsers::parse_markdown;
use layer_client::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 layer_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 and include a count of how many users chose each reaction.

Send a reaction

#![allow(unused)]
fn main() {
use layer_client::reactions::Reaction;

// Emoji reaction
client.send_reaction(peer, message_id, Reaction::emoticon("👍")).await?;

// Custom emoji reaction (premium)
client.send_reaction(peer, message_id, Reaction::custom_emoji(document_id)).await?;

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

Big reaction

Send a “big” reaction (plays a full-screen animation):

#![allow(unused)]
fn main() {
client.send_reaction(
    peer,
    message_id,
    Reaction::emoticon("🔥").big(),
).await?;
}

Add to recent

Keep the reaction in the user’s recently-used list:

#![allow(unused)]
fn main() {
client.send_reaction(
    peer,
    message_id,
    Reaction::emoticon("❤️").add_to_recent(),
).await?;
}

Reading reactions from a message

When you receive a message, reactions are available via msg.reactions():

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

Reaction builder reference

MethodDescription
Reaction::emoticon("👍")Standard emoji reaction
Reaction::custom_emoji(doc_id)Custom emoji (Premium)
Reaction::remove()Remove all reactions
.big()Full-screen animation
.add_to_recent()Add to user’s recent list

Raw API: get who reacted

To see which users chose a specific reaction on a message:

#![allow(unused)]
fn main() {
use layer_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 reaction_with_peer in &list.reactions {
        if let enums::MessagePeerReaction::MessagePeerReaction(r) = reaction_with_peer {
            println!("peer: {:?}", r.peer_id);
        }
    }
}
}

Update Types

All Telegram events flow through stream_updates() as variants of the Update enum. Every variant is strongly typed — no raw JSON or untagged maps.

#![allow(unused)]
fn main() {
use layer_client::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(s) were deleted */ }
        Update::CallbackQuery(cb)     => { /* inline button pressed */ }
        Update::InlineQuery(iq)       => { /* @bot inline query */ }
        Update::InlineSend(is)        => { /* inline result chosen */ }
        Update::ChatAction(action)    => { /* user typing / uploading */ }
        Update::UserStatus(status)    => { /* contact online status */ }
        Update::Raw(raw)              => { /* unrecognised update */ }
        _ => {}   // required: Update is #[non_exhaustive]
    }
}
}

Note: As of v0.4.5, Update is #[non_exhaustive]. Your match arms must include a _ => {} fallback or the code will fail to compile when new variants are added.


NewMessage

Fires for every new message the account receives in any chat.

#![allow(unused)]
fn main() {
Update::NewMessage(msg) => {
    if msg.outgoing() { return; }  // skip messages you sent

    let text    = msg.text().unwrap_or("");
    let msg_id  = msg.id();
    let peer    = msg.peer_id();   // the chat it arrived in
    let sender  = msg.sender_id(); // who sent it

    println!("[{msg_id}] {text}");
}
}

See IncomingMessage for the full list of accessors.


MessageEdited

Same structure as NewMessage — carries the new version of the edited message.

#![allow(unused)]
fn main() {
Update::MessageEdited(msg) => {
    println!("Edited [{id}]: {text}",
        id   = msg.id(),
        text = msg.text().unwrap_or(""),
    );
    if let Some(when) = msg.edit_date_utc() {
        println!("  Edited at: {when}");
    }
}
}

MessageDeleted

Contains only the message IDs, not the content (which is gone).

#![allow(unused)]
fn main() {
Update::MessageDeleted(del) => {
    println!("Deleted IDs: {:?}", del.messages());

    // For channel deletions, channel_id is set
    if let Some(ch_id) = del.channel_id() {
        println!("  In channel: {ch_id}");
    }
}
}

CallbackQuery

Fires when a user presses an inline keyboard button.

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

    match data {
        "yes" => {
            // edit the message, then acknowledge
            client.edit_message(peer, cb.msg_id, "You said yes!").await?;
            client.answer_callback_query(qid, None, false).await?;
        }
        "no" => {
            cb.answer(&client, "Cancelled.").await?;
        }
        _ => {
            client.answer_callback_query(qid, Some("Unknown"), false).await?;
        }
    }
}
}

See Callback Queries for full reference.


InlineQuery

Fires when a user types @yourbot <query> in any chat.

#![allow(unused)]
fn main() {
Update::InlineQuery(iq) => {
    let q   = iq.query();
    let qid = iq.query_id;

    let results = build_results(q);

    client.answer_inline_query(
        qid,
        results,
        300,   // cache seconds
        false, // is_personal
        None,  // next_offset
    ).await?;
}
}

See Inline Mode for result builders.


InlineSend

Fires when the user actually sends a chosen inline result.

#![allow(unused)]
fn main() {
Update::InlineSend(is) => {
    let result_id   = is.result_id();
    let query       = is.query();
    let inline_msg  = is.message_id(); // Option — present only if inline_feedback is on

    println!("User sent inline result '{result_id}' for query '{query}'");
}
}

To edit the sent inline message:

#![allow(unused)]
fn main() {
if let Some(inline_msg_id) = is.message_id() {
    client.edit_inline_message(
        inline_msg_id,
        "Updated content!",
    ).await?;
}
}

ChatAction — New in v0.4.5

Fires when a user starts or stops typing, uploading, recording, etc. in a chat the account is in.

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

    match action {
        tl::enums::SendMessageAction::SendMessageTypingAction => {
            println!("user {:?} is typing in {:?}", user, peer);
        }
        tl::enums::SendMessageAction::SendMessageUploadPhotoAction(_) => {
            println!("user {:?} is uploading a photo", user);
        }
        tl::enums::SendMessageAction::SendMessageRecordAudioAction => {
            println!("user {:?} is recording audio", user);
        }
        tl::enums::SendMessageAction::SendMessageCancelAction => {
            println!("user {:?} stopped", user);
        }
        _ => {}
    }
}
}

UserStatus — New in v0.4.5

Fires when a contact’s online/offline status changes. Only received for contacts or people in mutual chats (depending on their privacy settings).

#![allow(unused)]
fn main() {
Update::UserStatus(status) => {
    let user_id = status.user_id();    // i64
    let online  = status.status();     // tl::enums::UserStatus

    match online {
        tl::enums::UserStatus::UserStatusOnline(s) => {
            println!("user {user_id} went online (expires {})", s.expires);
        }
        tl::enums::UserStatus::UserStatusOffline(s) => {
            println!("user {user_id} went offline (was online {})", s.was_online);
        }
        tl::enums::UserStatus::UserStatusRecently => {
            println!("user {user_id}: seen recently");
        }
        tl::enums::UserStatus::UserStatusLastWeek(_) => {
            println!("user {user_id}: seen last week");
        }
        tl::enums::UserStatus::UserStatusLastMonth(_) => {
            println!("user {user_id}: seen last month");
        }
        _ => {}
    }
}
}

Raw

Any update that doesn’t map to a named variant is passed through as Update::Raw:

#![allow(unused)]
fn main() {
Update::Raw(raw) => {
    // raw.constructor_id() — the TL constructor ID (u32)
    // raw.bytes()          — the raw serialised bytes
    println!("Unhandled update: 0x{:08x}", raw.constructor_id());
}
}

Use this as an escape hatch to handle updates that layer-client doesn’t yet have a typed variant for.


Concurrent handling

Spawn each update in its own Tokio task to prevent one slow handler from blocking others:

#![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 {
        if let Err(e) = handle(update, &client).await {
            eprintln!("Handler error: {e}");
        }
    });
}

async fn handle(
    update: Update,
    client: &Client,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    match update {
        Update::NewMessage(msg) if !msg.outgoing() => {
            // …
        }
        _ => {}
    }
    Ok(())
}
}

IncomingMessage

IncomingMessage wraps tl::enums::Message and provides convenient typed accessors. It’s the type carried by Update::NewMessage and Update::MessageEdited.

All accessors

MethodReturnsDescription
id()i32Unique message ID within the chat
text()Option<&str>Plain text content
peer_id()Option<&Peer>The chat this message belongs to
sender_id()Option<&Peer>Who sent it (None for anonymous channels)
outgoing()boolSent by the logged-in account
date()i32Unix timestamp of creation
date_utc()Option<DateTime<Utc>>Parsed chrono datetime
edit_date()Option<i32>Unix timestamp of last edit
edit_date_utc()Option<DateTime<Utc>>Parsed edit datetime
mentioned()boolThe account was @mentioned
silent()boolSent without notification
post()boolPosted by a channel (not a user)
pinned()boolThis is a pin service message
noforwards()boolCannot be forwarded or screenshotted
reply_to_message_id()Option<i32>ID of the replied-to message
forward_count()Option<i32>Times this message was forwarded
view_count()Option<i32>View count (channels only)
reply_count()Option<i32>Comment count
grouped_id()Option<i64>Album group ID
media()Option<&MessageMedia>Attached media
entities()Option<&Vec<MessageEntity>>Text formatting regions
reply_markup()Option<&ReplyMarkup>Inline keyboard
forward_header()Option<&MessageFwdHeader>Forward origin info
rawtl::enums::MessageThe underlying raw TL type

Getting sender user ID

#![allow(unused)]
fn main() {
fn user_id(msg: &IncomingMessage) -> Option<i64> {
    match msg.sender_id()? {
        tl::enums::Peer::User(u) => Some(u.user_id),
        _ => None,
    }
}
}

Determining chat type

#![allow(unused)]
fn main() {
match msg.peer_id() {
    Some(tl::enums::Peer::User(u))    => {
        println!("Private DM with user {}", u.user_id);
    }
    Some(tl::enums::Peer::Chat(c))    => {
        println!("Basic group {}", c.chat_id);
    }
    Some(tl::enums::Peer::Channel(c)) => {
        println!("Channel or supergroup {}", c.channel_id);
    }
    None => {
        println!("Unknown peer");
    }
}
}

Accessing media

#![allow(unused)]
fn main() {
if let Some(media) = msg.media() {
    match media {
        tl::enums::MessageMedia::Photo(p)     => println!("📷 Photo"),
        tl::enums::MessageMedia::Document(d)  => println!("📎 Document"),
        tl::enums::MessageMedia::Geo(g)       => println!("📍 Location"),
        tl::enums::MessageMedia::Contact(c)   => println!("👤 Contact"),
        tl::enums::MessageMedia::Poll(p)      => println!("📊 Poll"),
        tl::enums::MessageMedia::WebPage(w)   => println!("🔗 Web preview"),
        tl::enums::MessageMedia::Sticker(s)   => println!("🩷 Sticker"),
        tl::enums::MessageMedia::Dice(d)      => println!("🎲 Dice"),
        tl::enums::MessageMedia::Game(g)      => println!("🎮 Game"),
        _ => println!("Other media"),
    }
}
}

Accessing entities

#![allow(unused)]
fn main() {
if let Some(entities) = msg.entities() {
    for entity in entities {
        match entity {
            tl::enums::MessageEntity::Bold(e)   => {
                let bold_text = &msg.text().unwrap_or("")[e.offset as usize..][..e.length as usize];
                println!("Bold: {bold_text}");
            }
            tl::enums::MessageEntity::BotCommand(e) => {
                let cmd = &msg.text().unwrap_or("")[e.offset as usize..][..e.length as usize];
                println!("Command: {cmd}");
            }
            tl::enums::MessageEntity::Url(e) => {
                let url = &msg.text().unwrap_or("")[e.offset as usize..][..e.length as usize];
                println!("URL: {url}");
            }
            _ => {}
        }
    }
}
}

Forward info

#![allow(unused)]
fn main() {
if let Some(fwd) = msg.forward_header() {
    if let tl::enums::MessageFwdHeader::MessageFwdHeader(h) = fwd {
        println!("Forwarded at: {}", h.date);
        if let Some(tl::enums::Peer::Channel(c)) = &h.from_id {
            println!("From channel: {}", c.channel_id);
        }
    }
}
}

Reply to previous message

#![allow(unused)]
fn main() {
// Quick reply with text
msg.reply(&mut client, "Got it!").await?;

// Reply with full InputMessage (formatted, keyboard, etc.)
if let Some(peer) = msg.peer_id() {
    let (t, e) = parse_markdown("**Acknowledged** ✅");
    client.send_message_to_peer_ex(peer.clone(), &InputMessage::text(t)
        .entities(e)
        .reply_to(Some(msg.id()))
    ).await?;
}
}

Accessing raw TL fields

For fields not exposed by accessors, use .raw directly:

#![allow(unused)]
fn main() {
if let tl::enums::Message::Message(raw) = &msg.raw {
    // Layer 223 additions
    println!("from_rank: {:?}",             raw.from_rank);
    println!("suggested_post: {:?}",        raw.suggested_post);
    println!("paid_message_stars: {:?}",    raw.paid_message_stars);
    println!("schedule_repeat_period: {:?}",raw.schedule_repeat_period);
    println!("summary_from_language: {:?}", raw.summary_from_language);

    // Standard fields
    println!("grouped_id: {:?}",            raw.grouped_id);
    println!("restriction_reason: {:?}",    raw.restriction_reason);
    println!("ttl_period: {:?}",            raw.ttl_period);
    println!("effect: {:?}",                raw.effect);
    println!("factcheck: {:?}",             raw.factcheck);
}
}

Callback Queries

Callback queries are fired when users press inline keyboard buttons on bot messages.

Full handling example

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

    // Parse structured data
    let parts: Vec<&str> = data.splitn(2, ':').collect();
    match parts.as_slice() {
        ["vote", choice] => {
            record_vote(choice);
            cb.answer(&client, &format!("Voted: {choice}")).await?;
        }
        ["page", n] => {
            let page: usize = n.parse().unwrap_or(0);
            // Edit the original message to show new page
            client.edit_message(
                peer_from_cb(&cb),
                cb.msg_id,
                &format_page(page),
            ).await?;
            client.answer_callback_query(qid, None, false).await?;
        }
        ["confirm"] => {
            cb.answer_alert(&client, "Are you sure? This is permanent.").await?;
        }
        _ => {
            client.answer_callback_query(qid, Some("Unknown action"), false).await?;
        }
    }
}
}

CallbackQuery fields

Field / MethodTypeDescription
cb.query_idi64Unique query ID — must be answered
cb.msg_idi32ID of the message that has the button
cb.data()Option<&str>The data string set in the button
cb.sender_id()Option<&Peer>Who pressed the button
cb.answer(client, text)asyncToast notification to user
cb.answer_alert(client, text)asyncModal alert popup to user

answer vs answer_alert

#![allow(unused)]
fn main() {
// Toast (brief notification at bottom of screen)
cb.answer(&client, "✅ Done!").await?;

// Alert popup (requires user to dismiss)
cb.answer_alert(&client, "⚠️ This will delete everything!").await?;

// Silent acknowledge (no visible notification)
client.answer_callback_query(cb.query_id, None, false).await?;
}

Editing message after a button press

A common pattern is updating the message content when a button is pressed:

#![allow(unused)]
fn main() {
Update::CallbackQuery(cb) => {
    if cb.data() == Some("next_page") {
        // Edit the message text
        client.edit_message(
            tl::enums::Peer::User(/* resolved peer */),
            cb.msg_id,
            "Updated content after button press",
        ).await?;

        // Always acknowledge
        client.answer_callback_query(cb.query_id, None, false).await?;
    }
}
}

Button data format tips

Keep button data under 64 bytes (Telegram’s limit). For structured data use short prefixes:

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

// Bad — too verbose
"user_wants_to_vote_for_option_yes"
}

Inline Mode

Inline mode lets users type @yourbot query in any chat — the bot responds with a list of results the user can tap to send.

Enable inline mode

In @BotFather:

  1. Send /mybots → select your bot
  2. Bot SettingsInline ModeTurn on

Handling inline queries

#![allow(unused)]
fn main() {
Update::InlineQuery(iq) => {
    let query  = iq.query().to_string();
    let qid    = iq.query_id;

    let results = build_results(&query);

    client.answer_inline_query(
        qid,
        results,
        300,   // cache_time in seconds
        false, // is_personal (true = don't share cache across users)
        None,  // next_offset (for pagination)
    ).await?;
}
}

Building results

Article result (text message)

#![allow(unused)]
fn main() {
fn article(id: &str, title: &str, description: &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(description.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,
                }
            ),
        }
    )
}
}

Multiple results for a query

#![allow(unused)]
fn main() {
fn build_results(q: &str) -> Vec<tl::enums::InputBotInlineResult> {
    if q.is_empty() {
        // Default suggestions when query is blank
        return vec![
            article("time", "🕐 Current Time",
                &chrono::Utc::now().format("%H:%M UTC").to_string(),
                &chrono::Utc::now().to_rfc2822()),
            article("help", "📖 Help", "See all commands", "/help"),
        ];
    }

    vec![
        article("u", &format!("UPPER: {}", q.to_uppercase()),
            "Uppercase version", &q.to_uppercase()),
        article("l", &format!("lower: {}", q.to_lowercase()),
            "Lowercase version", &q.to_lowercase()),
        article("r", &format!("Reversed"),
            "Reversed text", &q.chars().rev().collect::<String>()),
        article("c", "📊 Character count",
            &format!("{} chars, {} words", q.len(), q.split_whitespace().count()),
            &format!("{} characters • {} words • {} lines",
                q.chars().count(), q.split_whitespace().count(), q.lines().count())),
    ]
}
}

InlineQuery fields

Field / MethodTypeDescription
iq.query()&strThe text the user typed
iq.query_idi64Unique ID for this query
iq.offset()&strPagination offset
iq.peer_typevariesType of chat where query was issued

InlineSend — when a result is chosen

#![allow(unused)]
fn main() {
Update::InlineSend(is) => {
    // Fired when the user picks one of your results
    println!("Result chosen: {}", is.id());
    // Use this for logging, stats, or post-send actions
}
}

Pagination

For large result sets, implement pagination using next_offset:

#![allow(unused)]
fn main() {
let page: usize = iq.offset().parse().unwrap_or(0);
let items = get_items_page(page, 10);
let next  = if items.len() == 10 { Some(format!("{}", page + 1)) } else { None };

client.answer_inline_query(
    iq.query_id,
    items,
    60,
    false,
    next.as_deref(),
).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.4.5
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.4.5
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.4.5
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 layer_client::reactions::Reaction;

client.send_reaction(peer, msg_id, Reaction::emoticon(“👍”)).await?; client.send_reaction(peer, msg_id, Reaction::remove()).await?; // remove all client.send_reaction(peer, msg_id, Reaction::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.4.5 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.4.5).

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 layer_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 layer_client::InputMessage;
use layer_client::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 layer_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?;
}

Participants & Members

Methods for fetching, banning, promoting, and managing chat members.

Get participants

#![allow(unused)]
fn main() {
// Get up to 200 participants from a channel/supergroup
let members = client.get_participants(
    tl::enums::Peer::Channel(tl::types::PeerChannel { channel_id: 123 }),
    200,  // limit (0 = use default)
).await?;

for member in members {
    println!("{} ({:?})", member.user.first_name.as_deref().unwrap_or("?"), member.status);
}
}

ParticipantStatus variants

VariantMeaning
MemberRegular member
CreatorThe group/channel creator
AdminHas admin rights
RestrictedPartially banned (some rights removed)
BannedFully banned
LeftHas left the group

Kick from basic group

#![allow(unused)]
fn main() {
// Removes the user from a basic group (not a supergroup/channel)
client.kick_participant(chat_id, user_id).await?;
}

For channels/supergroups, use ban_participant instead.


Ban from channel

#![allow(unused)]
fn main() {
// Permanent ban (until_date = 0)
client.ban_participant(
    tl::enums::Peer::Channel(tl::types::PeerChannel { channel_id: 123 }),
    user_id,
    0,  // until_date: 0 = permanent
).await?;

// Temporary ban — expires at unix timestamp
let expires = chrono::Utc::now().timestamp() as i32 + 86400; // 24h
client.ban_participant(peer, user_id, expires).await?;
}

What ban_participant does

Sets ChatBannedRights with view_messages: true, which is the Telegram way of banning — it prevents the user from reading or sending any messages.

For selective restrictions (e.g. no stickers, no media), use the raw API with channels.editBanned.


Promote / demote admin

#![allow(unused)]
fn main() {
// Grant admin rights
client.promote_participant(channel_peer, user_id, true).await?;

// Remove admin rights
client.promote_participant(channel_peer, user_id, false).await?;
}

The default promotion grants: change_info, post_messages, edit_messages, delete_messages, ban_users, invite_users, pin_messages, manage_call.

For custom rights, use channels.editAdmin via client.invoke():

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

client.invoke(&functions::channels::EditAdmin {
    flags: 0,
    channel: enums::InputChannel::InputChannel(types::InputChannel {
        channel_id, access_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,
        edit_messages:          false,
        delete_messages:        true,
        ban_users:              true,
        invite_users:           true,
        pin_messages:           true,
        add_admins:             false,  // can they add other admins?
        anonymous:              false,
        manage_call:            true,
        other:                  false,
        manage_topics:          false,
        post_stories:           false,
        edit_stories:           false,
        delete_stories:         false,
        manage_direct_messages: false,
        manage_ranks:           false,   // Layer 223: custom rank management
    }),
    rank: Some("Moderator".into()),  // Layer 223: rank is now Option<String>
}).await?;
}

Get profile photos

#![allow(unused)]
fn main() {
let photos = client.get_profile_photos(peer, 10).await?;

for photo in &photos {
    if let tl::enums::Photo::Photo(p) = photo {
        println!("Photo ID: {}", p.id);
    }
}
}

Send a reaction

#![allow(unused)]
fn main() {
// React with 👍
client.send_reaction(peer, message_id, "👍").await?;

// Remove reaction
client.send_reaction(peer, message_id, "").await?;

// Custom emoji reaction (premium)
// Use the raw API: messages.sendReaction with ReactionCustomEmoji
}

ChatAdminRights — Layer 224 fields

#![allow(unused)]
fn main() {
types::ChatAdminRights {
    change_info:            bool, // can change group info
    post_messages:          bool, // can post in channels
    edit_messages:          bool, // can edit any message
    delete_messages:        bool, // can delete messages
    ban_users:              bool, // can ban members
    invite_users:           bool, // can invite members
    pin_messages:           bool, // can pin messages
    add_admins:             bool, // can promote admins
    anonymous:              bool, // post as channel anonymously
    manage_call:            bool, // can start/manage calls
    other:                  bool, // other rights
    manage_topics:          bool, // can manage forum topics
    post_stories:           bool, // can post stories
    edit_stories:           bool, // can edit stories
    delete_stories:         bool, // can delete stories
    manage_direct_messages: bool, // can manage DM links
    manage_ranks:           bool, // ✨ NEW in Layer 223
}
}

ChatBannedRights — Layer 224 fields

#![allow(unused)]
fn main() {
types::ChatBannedRights {
    view_messages:    bool, // ban completely (can't read)
    send_messages:    bool, // can't send text
    send_media:       bool, // can't send media
    send_stickers:    bool,
    send_gifs:        bool,
    send_games:       bool,
    send_inline:      bool, // can't use inline bots
    embed_links:      bool, // can't embed link previews
    send_polls:       bool,
    change_info:      bool, // can't change group info
    invite_users:     bool, // can't invite others
    pin_messages:     bool, // can't pin messages
    manage_topics:    bool,
    send_photos:      bool,
    send_videos:      bool,
    send_roundvideos: bool,
    send_audios:      bool,
    send_voices:      bool,
    send_docs:        bool,
    send_plain:       bool, // can't send plain text
    edit_rank:        bool, // ✨ NEW in Layer 223
    until_date:       i32,  // 0 = permanent
}
}

Admin & Ban Rights

layer-client provides typed builders for promoting administrators and restricting users.


Promoting an admin — AdminRightsBuilder

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

client.set_admin_rights(
    peer,
    user_id,
    AdminRightsBuilder::new()
        .post_messages(true)
        .edit_messages(true)
        .delete_messages(true)
        .invite_users(true)
        .pin_messages(true)
        .rank("Editor"),  // optional custom title
).await?;
}

All AdminRightsBuilder methods

MethodDefaultDescription
.change_info(v)falseEdit channel title, description, photo
.post_messages(v)falsePost in the channel
.edit_messages(v)falseEdit any message
.delete_messages(v)falseDelete any message
.ban_users(v)falseRestrict other members
.invite_users(v)falseAdd members
.pin_messages(v)falsePin and unpin messages
.add_admins(v)falsePromote other admins (requires self to have this right)
.anonymous(v)falsePost as the channel (anonymous)
.manage_call(v)falseStart/manage video chats
.manage_topics(v)falseCreate/edit/delete forum topics
.rank(r)Custom admin title shown in the member list
AdminRightsBuilder::full_admin()All rights enabled

Full admin shorthand

#![allow(unused)]
fn main() {
client.set_admin_rights(
    peer,
    user_id,
    AdminRightsBuilder::full_admin(),
).await?;
}

Remove admin rights

Pass an empty builder to revoke all rights:

#![allow(unused)]
fn main() {
client.set_admin_rights(
    peer,
    user_id,
    AdminRightsBuilder::new(),  // all false = remove admin
).await?;
}

Restricting a member — BanRightsBuilder

#![allow(unused)]
fn main() {
use layer_client::participants::BanRightsBuilder;

// Mute a user (disable text, stickers, GIFs, inline)
client.set_banned_rights(
    peer,
    user_id,
    BanRightsBuilder::new()
        .send_messages(false)
        .send_stickers(false)
        .send_gifs(false)
        .send_inline(false),
).await?;
}

All BanRightsBuilder methods

MethodDefaultDescription
.view_messages(v)trueCan see the chat at all
.send_messages(v)trueCan send text messages
.send_media(v)trueCan send photos, videos, etc.
.send_stickers(v)trueCan send stickers
.send_gifs(v)trueCan send GIFs
.send_games(v)trueCan send games
.send_inline(v)trueCan use inline bots
.embed_links(v)trueCan include link previews
.send_polls(v)trueCan create polls
.change_info(v)trueCan edit group info
.invite_users(v)trueCan add members
.pin_messages(v)trueCan pin messages
.until_date(ts)0 (permanent)Restriction expires at this unix timestamp
BanRightsBuilder::full_ban()All rights false (full kick/ban)

Temporary restriction (mute for 24 hours)

#![allow(unused)]
fn main() {
let expires = chrono::Utc::now().timestamp() as i32 + 86_400; // 24h

client.set_banned_rights(
    peer,
    user_id,
    BanRightsBuilder::new()
        .send_messages(false)
        .send_media(false)
        .until_date(expires),
).await?;
}

Full ban (kick + block from re-joining)

#![allow(unused)]
fn main() {
client.set_banned_rights(
    peer,
    user_id,
    BanRightsBuilder::full_ban(),
).await?;
}

Lift all restrictions (unban)

Pass a builder with all defaults (everything true, no until_date):

#![allow(unused)]
fn main() {
client.set_banned_rights(
    peer,
    user_id,
    BanRightsBuilder::new(),  // all true = no restrictions
).await?;
}

Check effective permissions

#![allow(unused)]
fn main() {
let perms = client.get_permissions(peer, user_id).await?;
println!("can send messages: {}", perms.can_send_messages());
println!("is admin: {}", perms.is_admin());
println!("is creator: {}", perms.is_creator());
}

Kick from basic group

For legacy basic groups (not supergroups/channels), use the kick method:

#![allow(unused)]
fn main() {
client.kick_participant(chat_id, user_id).await?;
}

This removes the user from the group. They can be added back by any member.


Participant status

get_participants returns Participant structs with a status field:

#![allow(unused)]
fn main() {
let members = client.get_participants(peer, 0).await?;

for member in members {
    match member.status {
        ParticipantStatus::Creator  => println!("{} is the creator", member.user.first_name.as_deref().unwrap_or("?")),
        ParticipantStatus::Admin    => println!("{} is an admin", member.user.first_name.as_deref().unwrap_or("?")),
        ParticipantStatus::Member   => {}
        ParticipantStatus::Banned   => println!("{} is banned", member.user.first_name.as_deref().unwrap_or("?")),
        ParticipantStatus::Restricted => println!("{} is restricted", member.user.first_name.as_deref().unwrap_or("?")),
        ParticipantStatus::Left     => println!("{} has left", member.user.first_name.as_deref().unwrap_or("?")),
    }
}
}

Dialogs & Message History

List dialogs (conversations)

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

for dialog in &dialogs {
    println!(
        "[{}] {} unread — top msg {}",
        dialog.title(),
        dialog.unread_count(),
        dialog.top_message(),
    );
}
}

Dialog fields

MethodReturnsDescription
dialog.title()StringName of the chat/channel/user
dialog.peer()Option<&Peer>The peer identifier
dialog.unread_count()i32Number of unread messages
dialog.top_message()i32ID of the last message

Paginating dialogs (all)

For iterating all dialogs beyond the first page:

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

while let Some(dialog) = iter.next(&client).await? {
    println!("{} — {} unread", dialog.title(), dialog.unread_count());
}
}

The iterator automatically requests more pages from Telegram as needed.


Paginating messages

#![allow(unused)]
fn main() {
let peer = client.resolve_peer("@somechannel").await?;
let mut iter = client.iter_messages(peer);

let mut count = 0;
while let Some(msg) = iter.next(&client).await? {
    println!("[{}] {}", msg.id(), msg.text().unwrap_or("(media)"));
    count += 1;
    if count >= 500 { break; }
}
}

Get message history (basic)

#![allow(unused)]
fn main() {
// Newest 50 messages
let messages = client.get_messages(peer, 50, 0).await?;

// Next page: pass the last message's ID as offset
let last_id = messages.last()
    .and_then(|m| if let tl::enums::Message::Message(m) = m { Some(m.id) } else { None })
    .unwrap_or(0);

let older = client.get_messages(peer, 50, last_id).await?;
}

Scheduled messages

#![allow(unused)]
fn main() {
// Fetch messages scheduled to be sent
let scheduled = client.get_scheduled_messages(peer).await?;

for msg in &scheduled {
    if let tl::enums::Message::Message(m) = msg {
        println!("Scheduled: {} at {}", m.message, m.date);
    }
}

// Delete a scheduled message
client.delete_scheduled_messages(peer, vec![msg_id]).await?;
}

Search within a chat

#![allow(unused)]
fn main() {
let results = client.search_messages(
    peer,
    "error log",  // search query
    20,           // limit
).await?;

for msg in &results {
    if let tl::enums::Message::Message(m) = msg {
        println!("[{}] {}", m.id, m.message);
    }
}
}
#![allow(unused)]
fn main() {
let results = client.search_global("layer rust telegram", 10).await?;
}

Mark as read / unread management

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

// Clear all @mentions in a group
client.clear_mentions(peer).await?;
}

Get pinned message

#![allow(unused)]
fn main() {
if let Some(msg) = client.get_pinned_message(peer).await? {
    if let tl::enums::Message::Message(m) = msg {
        println!("Pinned: {}", m.message);
    }
}
}

Search

layer-client provides two search builders: SearchBuilder for searching within a single peer, and GlobalSearchBuilder for searching across all dialogs at once.

#![allow(unused)]
fn main() {
let results = client
    .search("@somechannel", "rust async")
    .limit(20)
    .fetch()
    .await?;

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

All builder methods

MethodDefaultDescription
.limit(n)20Maximum number of results
.min_date(ts)Only messages after this unix timestamp
.max_date(ts)Only messages before this unix timestamp
.offset_id(id)0Start from this message ID (for pagination)
.add_offset(n)0Skip this many results from the start
.max_id(id)0Upper bound message ID
.min_id(id)0Lower bound message ID
.from_peer(peer)Only messages from this sender
.top_msg_id(id)Restrict to a specific forum topic
.filter(f)EmptyFilter by media type (see below)
.fetch()Execute and return Vec<tl::enums::Message>

Date range example

#![allow(unused)]
fn main() {
use chrono::{Utc, Duration};

let one_week_ago = (Utc::now() - Duration::days(7)).timestamp() as i32;

let results = client
    .search("@mychannel", "error")
    .min_date(one_week_ago)
    .limit(50)
    .fetch()
    .await?;
}

Filter by media type

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

// Only photo messages
let photos = client
    .search("@channel", "")
    .filter(enums::MessagesFilter::InputMessagesFilterPhotos)
    .limit(30)
    .fetch()
    .await?;

// Only documents
let docs = client
    .search("@channel", "report")
    .filter(enums::MessagesFilter::InputMessagesFilterDocument)
    .limit(30)
    .fetch()
    .await?;
}

Common MessagesFilter values

FilterMatches
InputMessagesFilterEmptyAll messages (default)
InputMessagesFilterPhotosPhotos
InputMessagesFilterVideoVideos
InputMessagesFilterDocumentDocuments
InputMessagesFilterAudioAudio files
InputMessagesFilterVoiceVoice messages
InputMessagesFilterUrlMessages containing URLs
InputMessagesFilterMyMentionsMessages where you were mentioned
InputMessagesFilterPinnedPinned messages

Search from a specific sender

#![allow(unused)]
fn main() {
let sender = client.resolve_to_input_peer("@alice").await?;

let results = client
    .search("@groupchat", "hello")
    .from_peer(sender)
    .fetch()
    .await?;
}

Pagination

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

loop {
    let page = client
        .search("@channel", "query")
        .offset_id(offset_id)
        .limit(page_size)
        .fetch()
        .await?;

    if page.is_empty() {
        break;
    }

    for msg in &page {
        // process…
    }

    // Get the lowest message ID for the next page
    offset_id = page.iter()
        .filter_map(|m| if let tl::enums::Message::Message(m) = m { Some(m.id) } else { None })
        .min()
        .unwrap_or(0);
}
}

GlobalSearchBuilder — search everywhere

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

for msg in results {
    if let tl::enums::Message::Message(m) = msg {
        println!("[peer {:?}] [{}] {}", m.peer_id, m.id, m.message);
    }
}
}

All global builder methods

MethodDefaultDescription
.limit(n)20Maximum number of results
.min_date(ts)Only messages after this timestamp
.max_date(ts)Only messages before this timestamp
.offset_rate(r)0Pagination: rate value from last result
.offset_id(id)0Pagination: message ID from last result
.folder_id(id)Restrict to a specific folder
.broadcasts_only(v)falseOnly search channels
.groups_only(v)falseOnly search groups
.users_only(v)falseOnly search private chats
.filter(f)EmptyFilter by media type
.fetch()Execute and return results

Filter by dialog type

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

// Search only in groups
let results = client
    .search_global_builder("discussion")
    .groups_only(true)
    .fetch()
    .await?;
}

Convenience one-liner

For a quick global search without the builder:

#![allow(unused)]
fn main() {
let results = client.search_global("rust async", 10).await?;
}

Simple per-peer search (no builder)

For basic cases without date filters:

#![allow(unused)]
fn main() {
let results = client.search_messages(peer, "query", 20).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 on drop.

Basic usage

#![allow(unused)]
fn main() {
use layer_client::TypingGuard;

async fn process_message(client: &Client, peer: tl::enums::Peer) {
    // Start "typing…" — cancelled automatically when guard drops
    let _guard = TypingGuard::typing(client, peer).await;

    // Do your slow work here
    let response = compute_heavy_reply().await;

    // _guard drops here → typing indicator cancelled automatically
    client.send_message_to_peer(peer, &response).await.ok();
}
}

Action types

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

// Typing a text message
let _guard = TypingGuard::typing(client, peer).await;

// Uploading a photo
let _guard = TypingGuard::upload_photo(client, peer).await;

// Uploading a document / file
let _guard = TypingGuard::upload_document(client, peer).await;

// Recording a voice message
let _guard = TypingGuard::record_audio(client, peer).await;

// Recording a video note
let _guard = TypingGuard::record_round(client, peer).await;

// Choose a sticker
let _guard = TypingGuard::choose_sticker(client, peer).await;

// Custom action (raw TL type)
let _guard = TypingGuard::custom(
    client,
    peer,
    enums::SendMessageAction::SendMessageTypingAction,
).await;
}

Forum topic typing (top_msg_id)

For forum supergroups, pass the topic’s top_msg_id to restrict the indicator to that topic:

#![allow(unused)]
fn main() {
client.send_chat_action(
    peer,
    enums::SendMessageAction::SendMessageTypingAction,
    Some(topic_msg_id),  // top_msg_id
).await?;
}

TypingGuard sends this on an interval (default 5 seconds) so the indicator doesn’t expire while you’re working.

Manual send_chat_action

Without TypingGuard, you can fire a one-shot indicator:

#![allow(unused)]
fn main() {
client.send_chat_action(
    peer,
    tl::enums::SendMessageAction::SendMessageTypingAction,
    None,  // no topic
).await?;
}

The indicator automatically expires after ~5 seconds on Telegram’s end. TypingGuard keeps refreshing it on an interval.

How TypingGuard works

On creation, TypingGuard:

  1. Sends the action immediately
  2. Spawns a background task that re-sends the action every ~4.5 seconds

On drop, it cancels the background task and optionally sends a SendMessageCancelAction to clear the indicator immediately.

Example: file upload with progress indicator

#![allow(unused)]
fn main() {
async fn send_file_with_progress(
    client: &Client,
    peer: tl::enums::Peer,
    path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    // Show "uploading document…" while uploading
    let _guard = TypingGuard::upload_document(client, peer.clone()).await;

    let uploaded = client.upload_file(path).await?;

    // Guard drops here, cancelling the indicator
    drop(_guard);

    // Now send the file — no typing indicator
    client.send_file(peer, uploaded.as_document_media(), None).await?;

    Ok(())
}
}

Raw API Access

Every Telegram API method is available as a typed struct in layer_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 layer_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 layer_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:         layer_client::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 layer_client::{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, layer-client uses AutoSleep: it transparently sleeps for the required duration, then retries. Your code never sees the error.

#![allow(unused)]
fn main() {
use layer_client::{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 layer_client::NoRetries;

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

Then in your code:

#![allow(unused)]
fn main() {
use layer_client::{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 layer_client::{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.

layer-client 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. layer-client 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.

MTProto DH key exchange used on each DC connection

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, layer-client 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. layer-client maintains a connection pool across DCs and handles this automatically via invoke_on_dc.

Socks5 Proxy

layer-client supports SOCKS5 proxies, including those with username/password authentication.

Configuration

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

let (client, _shutdown) = Client::connect(Config {
    session_path: "session.session".into(),
    api_id:       12345,
    api_hash:     "your_hash".into(),
    socks5:       Some(Socks5Config {
        addr:     "127.0.0.1:1080".parse().unwrap(),
        username: None,
        password: None,
    }),
    ..Default::default()
}).await?;
}

With authentication

#![allow(unused)]
fn main() {
socks5: Some(Socks5Config {
    addr:     "proxy.example.com:1080".parse().unwrap(),
    username: Some("user".into()),
    password: Some("pass".into()),
}),
}

Common use cases

MTProxy is a Telegram-specific proxy format. layer-client uses standard SOCKS5. To use an MTProxy, you’ll need a SOCKS5 bridge or use the transport_obfuscated module for protocol obfuscation.

Tor: Point SOCKS5 at 127.0.0.1:9050 (the default Tor port) to route all Telegram traffic through the Tor network.

#![allow(unused)]
fn main() {
socks5: Some(Socks5Config {
    addr:     "127.0.0.1:9050".parse().unwrap(),
    username: None,
    password: None,
}),
}

NOTE: When using Tor, Telegram connections may be slower and some DCs may block Tor exit nodes. Consider using Telegram’s .onion address if available.

Obfuscated transport

For networks that block Telegram, layer also supports the obfuscated transport:

#![allow(unused)]
fn main() {
use layer_client::TransportKind;

Config {
    transport: TransportKind::ObfuscatedAbridged,
    ..Default::default()
}
}

This disguises MTProto traffic to look like random bytes, making it harder for firewalls to detect and block.

Upgrading the TL Layer

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

How the system works

layer-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 layer-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 layer-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 layer-client, 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.4.5"

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 constantlayer-tl-types/src/lib.rsbuild.rs patches it
invokeWithLayer calllayer-client/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 layer-tl-types/tl/api.tl | grep "^[<>]" | head -40

This shows you exactly which constructors changed, helping you anticipate which layer-client files need updating.

Configuration

Config is the single struct passed to Client::connect. All fields except api_id and api_hash have defaults.

#![allow(unused)]
fn main() {
use layer_client::{Config, AutoSleep, TransportKind, Socks5Config};
use layer_client::session_backend::{BinaryFileBackend, InMemoryBackend};
use std::sync::Arc;

let (client, _shutdown) = Client::connect(Config {
    // Required
    api_id:   12345,
    api_hash: "your_api_hash".into(),

    // Session (default: BinaryFileBackend("session.session"))
    session_path: "my.session".into(),

    // DC override (default: DC2)
    dc_addr: None,

    // Transport (default: Abridged)
    transport: TransportKind::Abridged,

    // Flood wait retry (default: AutoSleep)
    retry_policy: Arc::new(AutoSleep::default()),

    // Proxy (default: None)
    socks5: None,

    ..Default::default()
}).await?;
}

All fields

api_id — required

Your Telegram app’s numeric ID from my.telegram.org.

#![allow(unused)]
fn main() {
api_id: 12345_i32,
}

api_hash — required

Your Telegram app’s hex hash string from my.telegram.org.

#![allow(unused)]
fn main() {
api_hash: "deadbeef01234567...".into(),
}

session_path

Path to the binary session file. Default: "session.session".

#![allow(unused)]
fn main() {
session_path: "/data/myapp/auth.session".into(),
}

dc_addr

Override the initial DC address. Default: None (uses DC2 = 149.154.167.51:443). After login, the correct DC is cached in the session.

#![allow(unused)]
fn main() {
dc_addr: Some("149.154.175.53:443".parse().unwrap()), // DC1
}

transport

The MTProto transport protocol. Default: TransportKind::Abridged.

VariantDescription
AbridgedMinimal overhead, default
IntermediateFixed-length framing
ObfuscatedAbridgedDisguised for firewall evasion
#![allow(unused)]
fn main() {
transport: TransportKind::ObfuscatedAbridged,
}

retry_policy

How to handle FLOOD_WAIT errors. Default: AutoSleep.

#![allow(unused)]
fn main() {
use layer_client::{AutoSleep, NoRetries};

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

socks5

Optional SOCKS5 proxy configuration.

#![allow(unused)]
fn main() {
socks5: Some(Socks5Config {
    addr:     "127.0.0.1:1080".parse().unwrap(),
    username: None,
    password: None,
}),
}

Full default values

#![allow(unused)]
fn main() {
Config {
    api_id:        0,
    api_hash:      String::new(),
    session_path:  "session.session".into(),
    dc_addr:       None,
    transport:     TransportKind::Abridged,
    retry_policy:  Arc::new(AutoSleep::default()),
    socks5:        None,
}
}

Error Types

InvocationError

All Client async methods return Result<T, InvocationError>:

#![allow(unused)]
fn main() {
pub enum InvocationError {
    Rpc(RpcError),        // Telegram returned an error response
    Deserialize(String),  // failed to decode the server's binary response
    Io(std::io::Error),   // network or IO failure
}
}

RpcError

#![allow(unused)]
fn main() {
pub struct RpcError {
    pub code:    i32,
    pub message: String,
}
}

Error code groups

CodeCategoryMeaning
303See OtherDC migration — handled automatically by layer
400Bad RequestWrong parameters, invalid data
401UnauthorizedNot logged in, session invalid/expired
403ForbiddenInsufficient permissions
404Not FoundResource doesn’t exist
406Not AcceptableContent not acceptable
420FloodFLOOD_WAIT_X — rate limited
500Server ErrorTelegram internal error, retry later

Common error messages

MessageCauseFix
PHONE_NUMBER_INVALIDBad phone formatUse E.164 format: +12345678900
PHONE_CODE_INVALIDWrong codeAsk user to try again
PHONE_CODE_EXPIREDCode timed outCall request_login_code again
SESSION_PASSWORD_NEEDED2FA requiredUse check_password
PASSWORD_HASH_INVALIDWrong 2FA passwordRe-prompt the user
PEER_ID_INVALIDUnknown peerResolve peer first or check the ID
ACCESS_TOKEN_INVALIDBad bot tokenCheck token from @BotFather
CHAT_WRITE_FORBIDDENCan’t post hereBot not in group or read-only channel
USER_PRIVACY_RESTRICTEDPrivacy blocks actionCan’t message/add this user
FLOOD_WAIT_NRate limitedWait N seconds (AutoSleep handles this)
FILE_PARTS_INVALIDUpload errorRetry the upload
MEDIA_EMPTYNo media providedCheck your InputMedia
MESSAGE_NOT_MODIFIEDEdit with no changesEnsure new text differs
BOT_INLINE_DISABLEDInline mode offEnable in @BotFather
QUERY_ID_INVALIDCallback too oldAnswer within 60 seconds

SignInError

Returned specifically by client.sign_in():

#![allow(unused)]
fn main() {
pub enum SignInError {
    PasswordRequired(PasswordToken), // 2FA is on — pass to check_password()
    InvalidCode,                     // wrong code submitted
    Other(InvocationError),          // anything else
}
}

Full error handling example

#![allow(unused)]
fn main() {
use layer_client::{InvocationError, RpcError, SignInError};
use std::time::Duration;

// Login errors
match client.sign_in(&token, &code).await {
    Ok(name)                                       => println!("✅ {name}"),
    Err(SignInError::PasswordRequired(pw))         => handle_2fa(pw).await?,
    Err(SignInError::InvalidCode)                  => println!("❌ Wrong code"),
    Err(SignInError::Other(InvocationError::Rpc(e))) => println!("RPC {}: {}", e.code, e.message),
    Err(SignInError::Other(e))                     => println!("IO/decode error: {e}"),
}

// General method errors
match client.send_message("@user", "hi").await {
    Ok(_) => {}

    // Rate limit (only visible if using NoRetries policy)
    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. Sleeping {secs}s");
        tokio::time::sleep(Duration::from_secs(secs)).await;
    }

    // Permission error
    Err(InvocationError::Rpc(RpcError { code: 403, ref message, .. })) => {
        println!("Permission denied: {message}");
    }

    // Network error
    Err(InvocationError::Io(e)) => {
        println!("Network error: {e}");
        // Consider reconnecting
    }

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

Implementing From for your error type

#![allow(unused)]
fn main() {
#[derive(Debug)]
enum MyError {
    Telegram(layer_client::InvocationError),
    Io(std::io::Error),
    Custom(String),
}

impl From<layer_client::InvocationError> for MyError {
    fn from(e: layer_client::InvocationError) -> Self {
        MyError::Telegram(e)
    }
}

// Now you can use ? throughout your handlers
async fn my_handler(client: &Client) -> Result<(), MyError> {
    client.send_message("me", "hello").await?;  // auto-converts
    Ok(())
}
}

Crate Architecture

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

Dependency graph

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

layer-client

layer-client

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

layer-tl-types

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


layer-mtproto

layer-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

MTProto DH key exchange flow
  1. PQ factorizationreq_pq_multi → server sends resPQ
  2. Server DH paramsreq_DH_params with encrypted key → server_DH_params_ok
  3. Client DH finishset_client_DH_paramsdh_gen_ok

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


layer-crypto

layer-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 layer-client)

layer-tl-parser

layer-tl-parser

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

Parsed AST types

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

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


layer-tl-gen

layer-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 Identifiableconst CONSTRUCTOR_ID: u32
  • Optional: impl Debug, impl From, impl TryFrom, impl Serialize/Deserialize

Feature Flags

layer feature flags overview

layer-client

FeatureDefaultDescription
sqlite-sessionSQLite-backed session storage via rusqlite
libsql-sessionlibsql / Turso session storage — local or remote (New in v0.4.5)
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
# SQLite session only
layer-client = { version = "0.4.5", features = ["sqlite-session"] }

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

# HTML parsing (minimal, no extra deps)
layer-client = { version = "0.4.5", features = ["html"] }

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

# Multiple features at once
layer-client = { version = "0.4.5", features = ["sqlite-session", "html"] }

layer-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

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

Example: name_for_id (debugging)

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

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

Example: minimal (no Debug, no conversions)

layer-tl-types = { version = "0.4.5", 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:

layer-client = "0.4.5"   # 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"]