⚡ layer
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.
client.invoke() with full type safety.Crate overview
| Crate | Description | Typical user |
|---|---|---|
layer-client | High-level async client — auth, send, receive, bots | ✅ You |
layer-tl-types | All Layer 223 constructors, functions, enums | Raw API calls |
layer-mtproto | MTProto session, DH, framing, transport | Library authors |
layer-crypto | AES-IGE, RSA, SHA, auth key derivation | Internal |
layer-tl-gen | Build-time Rust code generator | Build tool |
layer-tl-parser | .tl schema → AST parser | Build 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:
- Quick Start — User Account — login, send a message, receive updates
- Quick Start — Bot — bot token login, commands, callbacks
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:
- Go to https://my.telegram.org and log in with your phone number
- Click API development tools
- Fill in any app name, short name, platform (Desktop), and URL (can be blank)
- Click Create application
- Copy
App api_idandApp 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:
- Open Telegram → search
@BotFather→/start - Send
/newbot - Choose a display name (e.g. “My Awesome Bot”)
- Choose a username ending in
bot(e.g.my_awesome_bot) - 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
] }
| Feature | Default | What it enables |
|---|---|---|
tl-api | ✅ | All Telegram API constructors and functions |
tl-mtproto | ❌ | Low-level MTProto transport types |
impl-debug | ✅ | #[derive(Debug)] on every generated type |
impl-from-type | ✅ | From<types::Message> for enums::Message |
impl-from-enum | ✅ | TryFrom<enums::Message> for types::Message |
name-for-id | ❌ | Look up constructor name by ID — useful for debugging |
impl-serde | ❌ | JSON 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
| Platform | Status | Notes |
|---|---|---|
| Linux x86_64 | ✅ Fully supported | |
| macOS (Apple Silicon + Intel) | ✅ Fully supported | |
| Windows | ✅ Supported | Use WSL2 for best experience |
| Android (Termux) | ✅ Works | Native ARM64 |
| iOS | ⚠️ Untested | No 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
| Step | Method | Description |
|---|---|---|
| Connect | Client::connect | Opens TCP, performs DH handshake, loads session |
| Check auth | is_authorized | Returns true if session has a valid logged-in user |
| Request code | request_login_code | Sends SMS/app code to the phone |
| Sign in | sign_in | Submits the code. Returns PasswordRequired if 2FA is on |
| 2FA | check_password | Performs SRP exchange — password never sent in plain text |
| Save | save_session | Writes auth key + DC info to disk |
| Stream | stream_updates | Returns 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
| Capability | User account | Bot |
|---|---|---|
| Login method | Phone + code + optional 2FA | Bot 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 limits | Stricter | More 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 completeErr(SignInError::PasswordRequired(PasswordToken))— 2FA is enabled, need passwordErr(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
- Open Telegram and start a chat with @BotFather
- Send
/newbot - Follow the prompts to choose a name and username
- 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);
}
Environment variables (recommended)
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:
- Downloads SRP parameters from Telegram (
account.getPassword) - Derives a verifier from your password using PBKDF2-SHA512
- 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.updatePasswordSettingsvia 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-cryptoimplements 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
| Field | Description |
|---|---|
| Auth key | 2048-bit DH-derived key for encryption |
| Auth key ID | Hash of the key, used as identifier |
| DC ID | Which Telegram data center to connect to |
| DC address | The IP:port of the DC |
| Server salt | Updated regularly by Telegram |
| Sequence numbers | For message ordering |
| Peer cache | User/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.
Helper functions (recommended pattern)
#![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
| Type | Constructor | Description |
|---|---|---|
| Callback | KeyboardButtonCallback | Triggers CallbackQuery with custom data |
| URL | KeyboardButtonUrl | Opens a URL in the browser |
| Web App | KeyboardButtonSimpleWebView | Opens a Telegram Web App |
| Switch Inline | KeyboardButtonSwitchInline | Opens inline mode with a query |
| Request Phone | KeyboardButtonRequestPhone | Requests the user’s phone number |
| Request Location | KeyboardButtonRequestGeoLocation | Requests location |
| Request Poll | KeyboardButtonRequestPoll | Opens poll creator |
| Request Peer | KeyboardButtonRequestPeer | Requests peer selection |
| Game | KeyboardButtonGame | Opens a Telegram game |
| Buy | KeyboardButtonBuy | Purchase button for payments |
| Copy | KeyboardButtonCopy | Copies 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. Useas_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
| Method | Returns | Description |
|---|---|---|
uploaded.name() | &str | Original filename |
uploaded.mime_type() | &str | Detected MIME type |
uploaded.as_photo_media() | InputMedia | Send as compressed photo |
uploaded.as_document_media() | InputMedia | Send 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 type | MIME | Displays as |
|---|---|---|
| JPEG, PNG, WebP | image/jpeg, image/png | Photo (compressed) |
| GIF | image/gif | Animated image |
| MP4, MOV | video/mp4 | Video player |
| OGG (Opus codec) | audio/ogg | Voice message |
| MP3, FLAC | audio/mpeg | Audio player |
application/pdf | Document with preview | |
| ZIP, RAR | application/zip | Generic document |
| TGS | application/x-tgsticker | Animated 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
| Markdown | Entity type | Example |
|---|---|---|
**text** | Bold | Hello |
_text_ | Italic | Hello |
*text* | Italic | Hello |
__text__ | Underline | Hello |
~~text~~ | Strikethrough | |
`text` | Code (inline) | Hello |
```text``` | Pre (code block) | block |
||text|| | Spoiler | ▓▓▓▓▓ |
[label](url) | Text link | clickable |
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 variant | Description |
|---|---|
Bold | Bold text |
Italic | Italic text |
Underline | Underlined |
Strike | |
Spoiler | Hidden until tapped |
Code | Monospace inline |
Pre | Code block (optional language) |
TextUrl | Hyperlink with custom label |
Url | Auto-detected URL |
Email | Auto-detected email |
Phone | Auto-detected phone number |
Mention | @username mention |
MentionName | Inline mention by user ID |
Hashtag | #hashtag |
Cashtag | $TICKER |
BotCommand | /command |
BankCard | Bank card number |
BlockquoteCollapsible | Collapsible quote block |
CustomEmoji | Custom 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:
| Method | Returns | Notes |
|---|---|---|
id() | i32 | Unique 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() | bool | Sent by the logged-in account |
date() | i32 | Unix 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() | bool | Account was @mentioned |
silent() | bool | Sent without notification |
pinned() | bool | A pin notification |
post() | bool | From a channel |
noforwards() | bool | Cannot 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_queryfor everyCallbackQuery. 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
| Method | Returns | Description |
|---|---|---|
id() | i32 | Unique 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() | bool | Sent by the logged-in account |
date() | i32 | Unix 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() | bool | The account was @mentioned |
silent() | bool | Sent without notification |
post() | bool | Posted by a channel (not a user) |
pinned() | bool | This is a pin service message |
noforwards() | bool | Cannot 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 |
raw | tl::enums::Message | The 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 / Method | Type | Description |
|---|---|---|
cb.query_id | i64 | Unique query ID — must be answered |
cb.msg_id | i32 | ID 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) | async | Toast notification to user |
cb.answer_alert(client, text) | async | Modal 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:
- Send
/mybots→ select your bot - Bot Settings → Inline Mode → Turn 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 / Method | Type | Description |
|---|---|---|
iq.query() | &str | The text the user typed |
iq.query_id | i64 | Unique ID for this query |
iq.offset() | &str | Pagination offset |
iq.peer_type | varies | Type 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
true if the current session has a logged-in user or bot. Use this to skip the login flow on subsequent runs.
bool indicates whether session teardown was confirmed.
Authentication
phone via SMS or Telegram app. Returns a LoginToken that must be passed to sign_in.SignInError::PasswordRequired(PasswordToken) if 2FA is enabled.
User object for the logged-in account. Contains id, username, first_name, last_name, phone, bot flag, verified flag, and more.Messaging
peer can be "me", "@username", or a numeric ID string. For rich formatting, use send_message_to_peer_ex.
send_message("me", text).tl::enums::Peer.InputMessage builder — supports markdown entities, reply-to, inline keyboard, scheduled date, silent flag, and more.
revoke: true deletes for everyone; false deletes only locally.offset_id = 0 starts from the newest message. Returns up to limit messages in reverse chronological order.silent: true pins without sending a notification.Search
query.Dialogs & Chats
limit dialogs (conversations). Each Dialog has title(), peer(), unread_count(), top_message().iter.next(&client).await to get one dialog at a time, automatically fetching more pages.t.me/+hash invite link.Bot-specific
CallbackQuery. text is the notification shown to the user. alert: true shows it as a modal alert; false shows it as a brief toast.
InlineQuery with a list of results. cache_time is seconds to cache results (300 = 5 min). is_personal: true disables shared caching.Reactions & Actions
reaction is an emoji string like "👍". Pass an empty string to remove your reaction.Typing, UploadPhoto, RecordVideo, UploadVideo, RecordAudio, UploadAudio, UploadDocument, GeoLocation, ChooseContact.Peer Resolution
Peer. Supported formats:
"me"— your own account"@username"— any public username"123456789"— numeric user/chat/channel ID
Raw API
R is a struct from layer_tl_types::functions. See Raw API Access.Updates
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
| Method | Type | Description |
|---|---|---|
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) | ReplyMarkup | Inline or reply keyboard |
.silent(v) | bool | Send without notification |
.background(v) | bool | Send as background message |
.clear_draft(v) | bool | Clear the chat draft on send |
.no_webpage(v) | bool | Disable 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:
| Syntax | Result |
|---|---|
**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));
}
No link preview
#![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
| Variant | Meaning |
|---|---|
Member | Regular member |
Creator | The group/channel creator |
Admin | Has admin rights |
Restricted | Partially banned (some rights removed) |
Banned | Fully banned |
Left | Has 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
| Method | Returns | Description |
|---|---|---|
dialog.title() | String | Name of the chat/channel/user |
dialog.peer() | Option<&Peer> | The peer identifier |
dialog.unread_count() | i32 | Number of unread messages |
dialog.top_message() | i32 | ID 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);
}
}
}
Global search
#![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);
}
Navigation
All 500+ functions are organized by namespace matching the TL schema:
| TL namespace | Rust path | Examples |
|---|---|---|
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
| Method | Returns | Description |
|---|---|---|
ctx.flood_wait_secs() | u64 | How long Telegram wants you to wait |
ctx.attempt() | u32 | How many times this call has been retried |
ctx.error_message() | &str | The 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_messagefor 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
- You connect to DC2 (the default)
- You log in with a phone number registered on DC1
- Telegram returns
PHONE_MIGRATE_1 layer-clientreconnects to DC1, re-performs the DH handshake, and retries your request- 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:
| DC | IP |
|---|---|
| DC1 | 149.154.175.53 |
| DC2 | 149.154.167.51 |
| DC3 | 149.154.175.100 |
| DC4 | 149.154.167.91 |
| DC5 | 91.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
.onionaddress 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;intogenerated_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:
| What | Where | How |
|---|---|---|
tl::LAYER constant | layer-tl-types/src/lib.rs | build.rs patches it |
invokeWithLayer call | layer-client/src/lib.rs:1847 | reads tl::LAYER |
/about bot command | layer-bot/src/main.rs:333 | reads tl::LAYER at runtime |
| Badge in README | Manual — update once | String 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.
| Variant | Description |
|---|---|
Abridged | Minimal overhead, default |
Intermediate | Fixed-length framing |
ObfuscatedAbridged | Disguised 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
| Code | Category | Meaning |
|---|---|---|
303 | See Other | DC migration — handled automatically by layer |
400 | Bad Request | Wrong parameters, invalid data |
401 | Unauthorized | Not logged in, session invalid/expired |
403 | Forbidden | Insufficient permissions |
404 | Not Found | Resource doesn’t exist |
406 | Not Acceptable | Content not acceptable |
420 | Flood | FLOOD_WAIT_X — rate limited |
500 | Server Error | Telegram internal error, retry later |
Common error messages
| Message | Cause | Fix |
|---|---|---|
PHONE_NUMBER_INVALID | Bad phone format | Use E.164 format: +12345678900 |
PHONE_CODE_INVALID | Wrong code | Ask user to try again |
PHONE_CODE_EXPIRED | Code timed out | Call request_login_code again |
SESSION_PASSWORD_NEEDED | 2FA required | Use check_password |
PASSWORD_HASH_INVALID | Wrong 2FA password | Re-prompt the user |
PEER_ID_INVALID | Unknown peer | Resolve peer first or check the ID |
ACCESS_TOKEN_INVALID | Bad bot token | Check token from @BotFather |
CHAT_WRITE_FORBIDDEN | Can’t post here | Bot not in group or read-only channel |
USER_PRIVACY_RESTRICTED | Privacy blocks action | Can’t message/add this user |
FLOOD_WAIT_N | Rate limited | Wait N seconds (AutoSleep handles this) |
FILE_PARTS_INVALID | Upload error | Retry the upload |
MEDIA_EMPTY | No media provided | Check your InputMedia |
MESSAGE_NOT_MODIFIED | Edit with no changes | Ensure new text differs |
BOT_INLINE_DISABLED | Inline mode off | Enable in @BotFather |
QUERY_ID_INVALID | Callback too old | Answer 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 methodsConfig— connection configurationUpdateenum — typed update eventsInputMessage— fluent message builderparsers::parse_markdown— Markdown → entitiesUpdateStream— async iteratorDialog,DialogIter,MessageIter— dialog/history accessParticipant,ParticipantStatus— member infoUploadedFile,DownloadIter— mediaTypingGuard— auto-cancels chat action on dropSessionBackendtrait +BinaryFileBackend,InMemoryBackend,SqliteBackendSocks5Config— 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 implementingRemoteCallSerializable/DeserializabletraitsCursor— zero-copy deserializerRemoteCall— marker trait for RPC functions- Optional:
name_for_id(u32) -> Option<&'static str>
Key type conventions
| Pattern | Meaning |
|---|---|
tl::types::Foo | Concrete constructor — a struct |
tl::enums::Bar | Boxed type — an enum wrapping one or more types::* |
tl::functions::ns::Method | RPC 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 IDsauthentication::*— complete 3-step DH key exchange- Message framing: serialization, padding, encryption, HMAC
msg_containerunpacking (batched responses)- gzip decompression of
gzip_packedresponses - Transport abstraction (abridged, intermediate, obfuscated)
DH handshake steps
- PQ factorization —
req_pq_multi→ server sendsresPQ - Server DH params —
req_DH_paramswith encrypted key →server_DH_params_ok - Client DH finish —
set_client_DH_params→dh_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)].
| Component | Algorithm | Usage |
|---|---|---|
aes | AES-256-IGE | MTProto 2.0 message encryption/decryption |
auth_key | SHA-256, XOR | Auth key derivation from DH material |
factorize | Pollard’s rho | PQ factorization in DH step 1 |
| RSA | PKCS#1 v1.5 | Encrypting PQ proof with Telegram’s public keys |
| SHA-1 | SHA-1 | Used in auth key derivation |
| SHA-256 | SHA-256 | MTProto 2.0 MAC computation |
| PBKDF2 | PBKDF2-SHA512 | 2FA 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)Category—TypesorFunctionsParameter— a named field with typeParameterType— flags, conditionals, generic, basicFlag—flags.N?typeconditional 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)
| File | Contents |
|---|---|
generated_common.rs | pub const LAYER: i32 = N; + optional name_for_id |
generated_types.rs | pub mod types { … } — all constructor structs |
generated_enums.rs | pub mod enums { … } — all boxed type enums |
generated_functions.rs | pub mod functions { … } — all RPC function structs |
Each type automatically gets:
impl Serializable— binary TL encodingimpl Deserializable— binary TL decodingimpl Identifiable—const CONSTRUCTOR_ID: u32- Optional:
impl Debug,impl From,impl TryFrom,impl Serialize/Deserialize
Feature Flags
layer-client
| Feature | Default | Description |
|---|---|---|
sqlite-session | ❌ | SQLite-backed session storage via rusqlite |
layer-client = { version = "0.2.2", features = ["sqlite-session"] }
layer-tl-types
| Feature | Default | Description |
|---|---|---|
tl-api | ✅ | Telegram API schema (constructors, functions, enums) |
tl-mtproto | ❌ | Low-level MTProto transport types |
impl-debug | ✅ | #[derive(Debug)] on all generated types |
impl-from-type | ✅ | From<types::T> for enums::E conversions |
impl-from-enum | ✅ | TryFrom<enums::E> for types::T conversions |
name-for-id | ❌ | name_for_id(id: u32) -> Option<&'static str> |
impl-serde | ❌ | serde::Serialize + serde::Deserialize on all types |
deserializable-functions | ❌ | Deserializable 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.