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

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 — all as strongly typed Rust enums.
🔧
Raw API Escape Hatch
Call any of 500+ Telegram API methods directly via client.invoke() with full type safety.
🏗️
Auto-Generated Types
All 2,300+ Layer 223 constructors generated at build time from the official TL schema.

Crate overview

CrateDescriptionTypical user
layer-clientHigh-level async client — auth, send, receive, bots✅ You
layer-tl-typesAll Layer 223 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.2.2"
tokio        = { version = "1", features = ["full"] }

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


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.

Installation

Add to Cargo.toml

[dependencies]
layer-client = "0.2.2"
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.2.2", features = ["sqlite-session"] }

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

Raw type system features (layer-tl-types)

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

layer-tl-types = { version = "0.2.2", 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
] }
FeatureDefaultWhat it enables
tl-apiAll Telegram API constructors and functions
tl-mtprotoLow-level MTProto transport types
impl-debug#[derive(Debug)] on every generated type
impl-from-typeFrom<types::Message> for enums::Message
impl-from-enumTryFrom<enums::Message> for types::Message
name-for-idLook up constructor name by ID — useful for debugging
impl-serdeJSON serialization via serde

Verifying installation

use layer_tl_types::LAYER;

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

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

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 = Client::connect(Config {
        session_path: "my.session".into(),
        api_id:       std::env::var("TG_API_ID")?.parse()?,
        api_hash:     std::env::var("TG_API_HASH")?,
        ..Default::default()
    }).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::connectOpens 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

Quick Start — Bot

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

use layer_client::{Client, Config, InputMessage, parsers::parse_markdown, update::Update};
use layer_tl_types as tl;
use std::sync::Arc;

const API_ID:    i32  = 0;        // set your values
const API_HASH:  &str = "";
const BOT_TOKEN: &str = "";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Arc::new(Client::connect(Config {
        session_path: "bot.session".into(),
        api_id:       API_ID,
        api_hash:     API_HASH.to_string(),
        ..Default::default()
    }).await?);

    if !client.is_authorized().await? {
        client.bot_sign_in(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."
                    );
                    let kb = inline_kb(vec![
                        vec![cb_btn("📖 Help", "help"), cb_btn("ℹ️ About", "about")],
                        vec![url_btn("⭐ GitHub", "https://github.com/ankit-chaubey/layer")],
                    ]);
                    client.send_message_to_peer_ex(peer, &InputMessage::text(t)
                        .entities(e).reply_markup(kb).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\
                         /calc `<expr>` — Simple calculator\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" => {
                    let reply = if arg.is_empty() {
                        "Usage: /echo <text>".to_string()
                    } else {
                        arg.to_string()
                    };
                    client.send_message_to_peer(peer, &reply).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"  => { cb.answer(client, "Send /help for all commands").await?; }
                "about" => { cb.answer_alert(client, "Built with layer — Rust MTProto 🦀").await?; }
                _       => { cb.answer(client, "").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(())
}

// ── Keyboard helpers ──────────────────────────────────────────────────────────

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

fn cb_btn(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 url_btn(text: &str, url: &str) -> tl::enums::KeyboardButton {
    tl::enums::KeyboardButton::Url(tl::types::KeyboardButtonUrl {
        style: None, text: text.into(), url: url.into(),
    })
}

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

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.

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() {
Config {
    session_path: "my.session".into(),
    ..Default::default()
}
}

After login:

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

The file is created at the given path and loaded automatically on the next Client::connect. Keep it in .gitignore — it’s equivalent to your account password.

In-memory (ephemeral)

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

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

// Using InMemoryBackend directly via Config
Config {
    // session_path is ignored when using a custom backend
    ..Default::default()
}
}

With an in-memory session, login is required on every run.

SQLite (robust, long-running servers)

Enable the feature flag:

layer-client = { version = "0.2.2", features = ["sqlite-session"] }
#![allow(unused)]
fn main() {
// SQLite session is automatically used when the feature is enabled
// and the session file has a .db extension
Config {
    session_path: "session.db".into(),
    ..Default::default()
}
}

SQLite is more robust against crash-corruption than the binary file format, making it ideal for production bots.

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 call 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?;
}

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

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 223

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

Update Types

All Telegram events flow through stream_updates() as variants of the Update enum. Here is every variant, what it carries, and how to handle it.

#![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)     => { /* ... */ }
        Update::MessageEdited(msg)  => { /* ... */ }
        Update::MessageDeleted(del) => { /* ... */ }
        Update::CallbackQuery(cb)   => { /* ... */ }
        Update::InlineQuery(iq)     => { /* ... */ }
        Update::InlineSend(is)      => { /* ... */ }
        Update::Raw(raw)            => { /* ... */ }
        _ => {}
    }
}
}

NewMessage

Fires for every new message received in any chat the account participates in.

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

    let text     = msg.text().unwrap_or("");
    let msg_id   = msg.id();
    let date     = msg.date_utc();  // chrono::DateTime<Utc>
    let is_post  = msg.post();      // from a channel
    let has_media = msg.media().is_some();

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

Key accessors on IncomingMessage:

MethodReturnsNotes
id()i32Unique message ID within the chat
text()Option<&str>Plain text content
peer_id()Option<&Peer>The chat it was sent in
sender_id()Option<&Peer>Who sent it
outgoing()boolSent by the logged-in account
date()i32Unix timestamp
date_utc()Option<DateTime<Utc>>Parsed chrono datetime
edit_date()Option<i32>When last edited
media()Option<&MessageMedia>Attached media
entities()Option<&Vec<MessageEntity>>Formatting regions
mentioned()boolAccount was @mentioned
silent()boolSent without notification
pinned()boolA pin notification
post()boolFrom a channel
noforwards()boolCannot be forwarded
reply_to_message_id()Option<i32>ID of replied-to message
reply_markup()Option<&ReplyMarkup>Keyboard attached to this message
forward_count()Option<i32>How many times forwarded
view_count()Option<i32>View count (channels)
reply_count()Option<i32>Comment count
grouped_id()Option<i64>Album group ID
forward_header()Option<&MessageFwdHeader>Forward origin info

MessageEdited

Same structure as NewMessage — carries the updated version of the message.

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

MessageDeleted

Contains only message IDs — content is gone by the time this fires.

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

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

CallbackQuery

Fired when a user presses an inline keyboard button on a bot message.

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

    match data {
        "action:confirm" => {
            // answer() shows a brief toast to the user
            cb.answer(&client, "✅ Confirmed!").await?;
        }
        "action:cancel" => {
            // answer_alert() shows a modal popup
            cb.answer_alert(&client, "❌ Cancelled").await?;
        }
        _ => {
            // Must always answer — otherwise spinner shows forever
            client.answer_callback_query(qid, None, false).await?;
        }
    }
}
}

WARNING: You must call answer_callback_query for every CallbackQuery. If you don’t, the button shows a loading spinner to the user indefinitely.


InlineQuery

Fired when a user types @yourbot something in any chat.

#![allow(unused)]
fn main() {
Update::InlineQuery(iq) => {
    let query  = iq.query();   // the typed text
    let qid    = iq.query_id;
    let offset = iq.offset();  // for pagination

    let results = vec![
        make_article("1", "Result title", "Result text"),
    ];

    // cache_time = seconds to cache the results (0 = no cache)
    // is_personal = true if results differ per user
    // next_offset = Some("page2") for pagination
    client.answer_inline_query(qid, results, 300, false, None).await?;
}
}

InlineSend

Fired when the user selects one of your inline results and it gets sent.

#![allow(unused)]
fn main() {
Update::InlineSend(is) => {
    println!("User chose result id: {}", is.id());
    // Useful for analytics or follow-up actions
}
}

Raw

Any TL update variant not mapped to one of the above. Carries the constructor ID for identification.

#![allow(unused)]
fn main() {
Update::Raw(raw) => {
    println!("Unhandled update: 0x{:08x}", raw.constructor_id);

    // You can decode it manually using the TL types
    // if you know the constructor:
    // let upd: tl::enums::Update = ...;
}
}

Use this as a catch-all for new update types as the Telegram API evolves, or to handle specialized updates like updateBotChatInviteRequester, updateBotStopped, etc.


Concurrent handling pattern

#![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 c = client.clone();
    tokio::spawn(async move {
        if let Err(e) = handle(update, &c).await {
            eprintln!("Error: {e}");
        }
    });
}
}

This ensures slow handlers don’t block the receive loop.

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, InvocationError>
Opens a TCP connection to Telegram, performs the full 3-step DH key exchange, and loads any existing session from disk. This is the entry point for all layer usage.
async client.is_authorized() → Result<bool, InvocationError>
Returns true if the current 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 disk. Call this after a successful login.
async client.sign_out() → Result<bool, InvocationError>
Revokes the auth key on Telegram's servers and deletes the local session file. The returned bool indicates whether session teardown was confirmed.

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 that must be passed to sign_in.
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) if 2FA is enabled.
async client.check_password(token: PasswordToken, password: &str) → Result<(), InvocationError>
Completes the SRP 2FA verification. The password is never transmitted — only a zero-knowledge proof.
async client.bot_sign_in(token: &str) → Result<String, InvocationError>
Logs in using a bot token from @BotFather. Returns the bot's username.
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.

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.
async client.delete_messages(ids: Vec<i32>, revoke: bool) → Result<(), InvocationError>
Delete messages by ID. revoke: true deletes for everyone; false deletes only locally.
async client.forward_messages(from: Peer, to: Peer, ids: Vec<i32>) → Result<(), InvocationError>
Forward messages from one chat to another.
async client.get_messages(peer: Peer, limit: i32, offset_id: i32) → Result<Vec<tl::enums::Message>, InvocationError>
Fetch message history. offset_id = 0 starts from the newest message. Returns up to limit messages in reverse chronological order.
async client.get_messages_by_id(peer: Peer, ids: Vec<i32>) → Result<Vec<tl::enums::Message>, InvocationError>
Fetch specific messages by their IDs.
async client.pin_message(peer: Peer, message_id: i32, silent: bool) → Result<(), InvocationError>
Pin a message. silent: true pins without sending a notification.
async client.unpin_message(peer: Peer, message_id: i32) → Result<(), InvocationError>
Unpin a specific message.
async client.unpin_all_messages(peer: Peer) → Result<(), InvocationError>
Remove all pinned messages in a chat.

async client.search_messages(peer: Peer, query: &str, limit: i32) → Result<Vec<tl::enums::Message>, InvocationError>
Search for messages in a specific chat matching query.
async client.search_global(query: &str, limit: i32) → Result<Vec<tl::enums::Message>, InvocationError>
Search across all chats and public channels.
async client.search_peer(query: &str) → Result<Vec<tl::enums::Peer>, InvocationError>
Search contacts, dialogs, and global results for a username or name prefix.

Dialogs & Chats

async client.get_dialogs(limit: i32) → Result<Vec<Dialog>, InvocationError>
Returns the most recent limit dialogs (conversations). Each Dialog has title(), peer(), unread_count(), top_message().
fn client.iter_dialogs() → DialogIter
Returns a paginating iterator over all dialogs. Call iter.next(&client).await to get one dialog at a time, automatically fetching more pages.
fn client.iter_messages(peer: Peer) → MessageIter
Returns a paginating iterator over all messages in a chat from newest to oldest.
async client.mark_as_read(peer: Peer) → Result<(), InvocationError>
Marks all messages in the chat as read.
async client.delete_dialog(peer: Peer) → Result<(), InvocationError>
Removes the dialog from the chat list (does not delete messages from the server).
async client.join_chat(peer: Peer) → Result<(), InvocationError>
Join a public group or channel.
async client.accept_invite_link(link: &str) → Result<(), InvocationError>
Join via a t.me/+hash invite link.

Bot-specific

async client.answer_callback_query(query_id: i64, text: Option<&str>, alert: bool) → Result<(), InvocationError>
Must be called in response to every CallbackQuery. text is the notification shown to the user. alert: true shows it as a modal alert; false shows it as a brief toast.
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 InlineQuery with a list of results. cache_time is seconds to cache results (300 = 5 min). is_personal: true disables shared caching.

Reactions & Actions

async client.send_reaction(peer: Peer, message_id: i32, reaction: &str) → Result<(), InvocationError>
Add a reaction to a message. reaction is an emoji string like "👍". Pass an empty string to remove your reaction.
async client.send_chat_action(peer: Peer, action: ChatAction) → Result<(), InvocationError>
Show a typing indicator or other status. Actions: Typing, UploadPhoto, RecordVideo, UploadVideo, RecordAudio, UploadAudio, UploadDocument, GeoLocation, ChooseContact.

Peer Resolution

async client.resolve_peer(peer: &str) → Result<tl::enums::Peer, InvocationError>
Resolve a string to a Peer. Supported formats:
  • "me" — your own account
  • "@username" — any public username
  • "123456789" — numeric user/chat/channel ID
Also caches the access hash for future API calls.

Raw API

async client.invoke<R: RemoteCall>(req: &R) → Result<R::Return, InvocationError>
Call any Telegram API function directly. R is a struct from layer_tl_types::functions. See Raw API Access.

Updates

fn client.stream_updates() → UpdateStream
Returns an UpdateStream. Call .next().await to receive the next Update. The stream never ends unless the connection is dropped.

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 223 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 223 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
}
}

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

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

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() {
use std::net::SocketAddr;

let client = Client::connect(Config {
    dc_addr: Some("149.154.167.91:443".parse::<SocketAddr>().unwrap()),
    ..Default::default()
}).await?;
}

DC addresses:

DCIP
DC1149.154.175.53
DC2149.154.167.51
DC3149.154.175.100
DC4149.154.167.91
DC591.108.56.130

In practice, just leave dc_addr: None and let the auto-migration handle it.

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 = 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 = 223;
  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.2.3"

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

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

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

What it provides

  • Client — the main handle with all high-level methods
  • Config — connection configuration
  • Update enum — typed update events
  • InputMessage — fluent message builder
  • parsers::parse_markdown — Markdown → entities
  • UpdateStream — async iterator
  • Dialog, DialogIter, MessageIter — dialog/history access
  • Participant, ParticipantStatus — member info
  • UploadedFile, DownloadIter — media
  • TypingGuard — auto-cancels chat action on drop
  • SessionBackend trait + BinaryFileBackend, InMemoryBackend, SqliteBackend
  • Socks5Config — proxy configuration
  • Error types: InvocationError, RpcError, SignInError, LoginToken, PasswordToken
  • Retry traits: RetryPolicy, AutoSleep, NoRetries, RetryContext

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

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

What it provides

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

DH handshake steps

  1. PQ 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

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
PBKDF2PBKDF2-SHA5122FA password derivation (via layer-client)

layer-tl-parser

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

Parsed AST types

  • Definition — a single TL line (constructor or function)
  • CategoryTypes or Functions
  • 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

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

FeatureDefaultDescription
sqlite-sessionSQLite-backed session storage via rusqlite
layer-client = { version = "0.2.2", features = ["sqlite-session"] }

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
name-for-idname_for_id(id: u32) -> Option<&'static str>
impl-serdeserde::Serialize + serde::Deserialize on all types
deserializable-functionsDeserializable for function types (server use)

Example: enable serde

layer-tl-types = { version = "0.2.2", features = ["tl-api", "impl-serde"] }

Then:

#![allow(unused)]
fn main() {
let json = serde_json::to_string(&some_tl_type)?;
}

Example: name_for_id (debugging)

layer-tl-types = { version = "0.2.2", 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.2.2", default-features = false, features = ["tl-api"] }

This reduces compile time if you don’t need the convenience traits.