Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ferogram

Crates.io docs.rs TL Layer License

ferogram is an async Rust library for Telegram’s MTProto protocol. It works for user accounts and bots, and talks to Telegram directly over MTProto with no Bot API HTTP proxy in the middle.

Everything in the workspace is written from scratch: the .tl schema parser, the TL code generator, AES-IGE crypto, the DH key exchange, MTProto framing, the session layer, and the high-level client on top. Each piece is its own crate so you can pull in just what you need.

Why it exists

I was already using other MTProto libraries and kept running into cases where I needed things to work a bit differently than they allowed. So I wrote my own.

The goal was to cover the major use cases first, and it does. If something’s missing for you, drop by t.me/FerogramChat. I genuinely like hearing what people are building with it.

Crates

Most people only need ferogram. But each crate is independently published if you need a specific layer on its own.

CrateWhat it does
ferogramHigh-level client. Auth, messaging, media, dispatcher, FSM, middleware.
ferogram-sessionSession types and pluggable storage backends (file, memory, SQLite, LibSQL, base64).
ferogram-fsmFSM state storage and context. StateStorage trait, MemoryStorage, StateContext.
ferogram-parsersTelegram Markdown and HTML entity parsers.
ferogram-derive#[derive(FsmState)] proc macro.
ferogram-mtsenderDC connection pool and retry policy. AutoSleep, NoRetries, CircuitBreaker.
ferogram-connectRaw TCP, MTProto framing, obfuscation, SOCKS5, MTProxy, gzip.
ferogram-mtprotoMTProto 2.0 session, DH key exchange, message framing, PFS key binding.
ferogram-cryptoAES-IGE, RSA, SHA, Diffie-Hellman, PQ factorization, auth key derivation.
ferogram-tl-typesAuto-generated TL types, functions, and enums for Layer 225.
ferogram-tl-genBuild-time code generator from TL AST to Rust source.
ferogram-tl-parserParses .tl schema text into a Definition AST.

The rough dependency chain:

ferogram
└ ferogram-mtsender
  └ ferogram-connect
    ├ ferogram-mtproto
    │ ├ ferogram-tl-types
    │ │ └ (build) ferogram-tl-gen
    │ │   └ (build) ferogram-tl-parser
    │ └ ferogram-crypto
    └ ferogram-crypto

Quick install

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

Get api_id and api_hash from my.telegram.org. That’s all you need to get started.

Where to go next

Python

Python support is live via ferogram-py. Pre-built wheels, no Rust toolchain needed.

pip install ferogram

Community

License

MIT OR Apache-2.0.

Usage must comply with Telegram’s API Terms of Service.

Release History

ferogram started as a renamed continuation of layer v0.5.0. Every release since then has been a proper source release with a tagged version and a changelog entry.


v0.5.0

Released 2026-05-16. API consolidation release. Paired functions that differed only by a single boolean condition have been merged into one. Download and upload paths were redesigned around AsyncRead/AsyncWrite. No new protocol or behavioural changes.

Breaking changes

Merged paired functions into one

RemovedReplacement
set_online() / set_offline()set_presence(online: bool)
block_user(peer) / unblock_user(peer)block(peer, true/false)
pin_dialog(peer) / unpin_dialog(peer)pin_dialog(peer, true/false)
archive_chat(peer) / unarchive_chat(peer)archive(peer, true/false)
pin_message(peer, id, silent) / unpin_message(peer, id)pin_message(peer, id, true/false)
delete_channel(peer) / delete_chat(id)delete_chat(peer) (dispatches by peer type)
install_sticker_set(set, archived) / uninstall_sticker_set(set)toggle_stickers(set, true/false)
get_broadcast_stats(peer) / get_megagroup_stats(peer)stats(peer) -> ChannelStats
get_poll_stats(peer, id) / get_poll_results(peer, id)poll_results(peer, id)
promote_participant / demote_participantset_admin(peer, user, rights)
ban_participant(peer, user) / ban_participant_until(peer, user, ts)ban(peer, user, until: Option<i32>)
kick_participant(peer, user)kick(peer, user)
set_banned_rights(peer, user, rights)restrict(peer, user, rights)
set_admin_rights(peer, user, rights)set_admin(peer, user, rights)
set_profile(first, last, about) / set_username(u) / set_emoji_status(id, until) / edit_chat_title / edit_chat_about / edit_chat_photoset_profile(peer) -> SetProfileBuilder
get_message_by_id(peer, id) / get_messages_by_id(peer, ids)get_messages(peer, ids)
mark_as_read(peer) / mark_dialog_read(peer)mark_read(peer)
resolve_peer(str)resolve(str) (or pass peer string directly via PeerRef)
accept_invite_link(link)join_link(link)

Download and upload API redesigned

Old API passed raw InputFileLocation handles. New API works directly with &MessageMedia:

#![allow(unused)]
fn main() {
// download to any AsyncWrite sink
client.download(msg.media().unwrap(), &mut file).await?;

// download to disk
client.download_file(msg.media().unwrap(), "photo.jpg").await?;

// lazy chunk iterator
let mut iter = client.iter_download(msg.media().unwrap()).unwrap();
while let Some(chunk) = iter.next().await? { ... }

// upload from any AsyncRead
let uploaded = client.upload(reader, "file.jpg").await?;

// upload from path (stats file, streams to upload)
let uploaded = client.upload_file_from_path("photo.jpg").await?;
}

IncomingMessage also gains two convenience methods:

#![allow(unused)]
fn main() {
msg.download(&mut buf).await?;   // stream to AsyncWrite
let bytes = msg.bytes().await?;  // into Vec<u8>
}

set_profile is now a builder

#![allow(unused)]
fn main() {
// user
client.set_profile("me").name("Alice", "").bio("Hello!").send().await?;

// channel / group
client.set_profile("@mychannel").title("New Name").bio("About text").send().await?;
}

stats returns ChannelStats

#![allow(unused)]
fn main() {
match client.stats("@mychannel").await? {
    ChannelStats::Broadcast(s) => { /* channel */ }
    ChannelStats::Megagroup(s) => { /* supergroup */ }
}
}

Upload part-size table revised

Five tiers keyed on file size (< 1 MB, 1-32 MB, 32-512 MB, 512 MB-1 GB, > 1 GB) replace the old two-tier heuristic. This reduces per-chunk overhead for small files and stays safely below the 4000-part hard limit for large ones.

Upgrading from 0.4.1

ferogram = "0.5.0"

Rename call sites per the table above. The session format, feature flags, and all other APIs are unchanged.


v0.4.1

Released 2026-05-14. Patch on top of 0.4.0: one new API for faster onboarding, a configurable update buffer, session schema improvements, and 15 new examples. No breaking changes.

Client::quick_connect

One call handles the builder, connection, and the full auth flow from stdin. Returns immediately if the session is already authorized.

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

const API_ID: i32 = 12345;
const API_HASH: &str = "your_api_hash";

let (client, _shutdown) = Client::quick_connect("bot.session", API_ID, API_HASH).await?;
}

Bot tokens are detected automatically by their <digits>:<string> shape, so the same call works for both bots and users.

If you need an option quick_connect does not expose (proxy, PFS, custom transport, catch-up), switch to Client::builder(). The session file is compatible.

See quick_connect reference for the full signature and error table.

Configurable update buffer

Two new builder methods let you tune the user-facing dispatch queue. Internal MTProto state (pts, qts, getDifference) is unaffected; only the Update queue from stream_updates() is governed here.

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

let (client, _) = Client::builder()
    .api_id(API_ID)
    .api_hash(API_HASH)
    .session("bot.session")
    .update_queue_capacity(512)
    .update_overflow_strategy(OverflowStrategy::DropOldest)
    .connect().await?;
}

DropOldest (default) evicts ephemeral updates (typing, online status) first, then the oldest normal update, keeping the incoming one. DropNewest discards the incoming update instead. Default capacity is 2048.

For memory-constrained hosts (Termux, small VPS) there is a shortcut:

#![allow(unused)]
fn main() {
Client::builder()
    // ...
    .low_memory_mode(true)  // 256-slot queue, DropOldest
    .connect().await?;
}

Session schema migration

Two additions to the SQLite/LibSQL schema, applied automatically when the database is opened. Both are no-ops on a fresh database.

  • peers table gains an is_chat column for tracking basic groups.
  • New min_peers table stores min-user message contexts needed for InputPeerUserFromMessage.

Existing session files migrate automatically.

Periodic session save

The client now writes a full session snapshot (peers, channel_pts, min_peers, DC auth/salt data) to the session backend every 60 seconds whenever the peer cache has been mutated since the last save. A final save runs unconditionally on shutdown. Previously peers were only written on explicit save_session() calls, so a crash between calls could lose recently seen peers.

Salt expiry fix past 2038

valid_until in future salts was stored as i32. Telegram sends validity windows that extend into the 2050s. Those values overflow i32 and wrap negative, making every salt look expired on a signed comparison and causing constant re-fetches. Changed to u32 throughout.

GuestChatAnswer return type corrected

GuestChatAnswer::send now returns InputBotInlineMessageID matching the actual TL schema. The setBotGuestChatResult constructor ID was also wrong in the previous schema; both are fixed. If you were pattern-matching on the old bool return, update your call sites.

New type and methods

  • ParticipantStatus is now exported from the crate root.
  • User::bot_guestchat() returns true if the bot supports guest-chat mode.
  • GuestChatQuery::via_from() returns the original requester peer when Telegram includes guestchat_via_from in the message.

15 new examples

All examples live under ferogram/examples/.

Userbot tools: admin_log, chat_history, dialogs_list, download_media, get_participants, schedule_message, search_messages, serverless_userbot, string_session_gen.

Bots: echo_bot, filters_showcase, hello_self, inline_keyboard, inline_query_bot, poll_bot, translate_bot.

Upgrading from 0.4.0

ferogram = "0.4.1"

No API changes required. The session migration is automatic.


[0.4.0]: 2026-05-08

0.4.0 is the first production-ready release of ferogram. It ships Layer 225 support and a reworked poll API. All users are advised to upgrade to 0.4.0 (or 0.4.x+) as the most recommended and supported version.

If you run into any bugs, please open an issue on GitHub or reach us at @FerogramChat. Thank you for using ferogram!

For the latest git revision: https://github.com/ankit-chaubey/ferogram

Note: 0.3.9 was a broken publish. The workspace internal deps were not bumped so crates.io resolved ferogram-tl-types to the old Layer 224 build. 0.4.0 fixes that and is the correct release to use.


v0.3.9

Released 2026-05-07. Updated to TL Layer 225. Poll builder overhaul, guest-chat support for bots, two new client methods, and full MarkdownV2/HTML spec compliance in the parsers.

send_poll now takes PollBuilder (breaking)

The old flat signature (question, answers, quiz, correct_index, multiple_choice) is gone. Pass a PollBuilder instead:

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

client.send_poll(peer,
    PollBuilder::new("Favourite runtime?")
        .answers(["Tokio", "async-std", "smol"])
        .public_voters(true)
        .close_period(300)
).await?;

// Quiz with answer explanation
client.send_poll(peer,
    PollBuilder::new("Capital of France?")
        .answers(["Berlin", "Paris", "Rome"])
        .quiz(true)
        .correct_index(1)
        .solution("It's Paris.")
        .hide_results_until_close(true)
).await?;
}

New fields the old API did not expose: public_voters, shuffle_answers, hide_results_until_close, close_period, close_date, solution, subscribers_only, countries_iso2.

Guest-chat queries (bots only)

A new Update::GuestChatQuery variant handles updateBotGuestChatQuery. Fires when a user invites the bot into a guest-chat context. GuestChatQuery derefs to IncomingMessage and carries query_id, message, reference_messages, and qts.

Answer with the GuestChatAnswer builder:

#![allow(unused)]
fn main() {
if let Update::GuestChatQuery(q) = update {
    q.answer()
        .article("My result")
        .text("The answer")
        .send(&client)
        .await?;
}
}

Supported result kinds: article, photo, document, game, location, venue, contact, webpage, invoice, raw. Sends via messages.setBotGuestChatResult.

New client methods

delete_reaction(peer, msg_id, participant) reports and removes a specific user’s reaction on a message. Returns true on success.

get_poll_stats(peer, msg_id) returns detailed vote stats for a poll. Returns tl::types::stats::PollStats.

BannedRights::send_reactions

New field on the ban-rights builder:

#![allow(unused)]
fn main() {
BannedRights::default().send_reactions(false)
}

MarkdownV2 and HTML parser update (ferogram-parsers)

parse_markdown and generate_markdown now implement the full Telegram Bot API MarkdownV2 spec. The main breaking change: __text__ is Underline now, not Italic. If you relied on the old behaviour, call parse_markdown_v1 explicitly (deprecated, removed in 0.4.0).

Explicit aliases added: parse_markdown_v2, generate_markdown_v2.

HTML: added <ins> as underline, <span class="tg-spoiler"> as spoiler, <blockquote>/<blockquote expandable> for block quotes, <tg-time unix="N"> for formatted dates. The <pre><code class="language-X"> bug that produced two entities instead of one is fixed. generate_html emits all of the above.

Upgrading from 0.3.8

ferogram = "0.3.9"

Two things to fix: send_poll call sites (see above), and any markdown that relied on __text__ being Italic (change to _text_).


v0.3.8

Released 2026-05-06. A small patch release fixing two broken APIs from 0.3.7.

send_to_self is fixed

In 0.3.7 the function body got swapped during a refactor. Calling send_to_self(msg) was silently hitting the wrong code path. It now correctly sends to your Saved Messages using messages.sendMessage with InputPeer::PeerSelf, and returns the sent message as before.

open_mini_app is now public

open_mini_app(peer, MiniApp) was accidentally left private in 0.3.7. It’s pub now. Supports all four mini-app types: Main, Url, App, and Simple.

get_chat_full is now public

Was pub(crate) before. Now pub, so you can call it directly if you need the raw full chat info without going through a helper.


v0.3.7

Released 2026-05-05. Workspace restructure, three new crates, and a handful of API cleanups.

New crates

Three crates were extracted this release. The main ferogram crate re-exports everything from them, so if you’re not doing anything low-level, your code doesn’t need to change.

ferogram-connect is now a real crate instead of a throwaway demo binary. It owns the raw TCP connection layer, MTProto framing, transport negotiation (Intermediate, Obfuscated, FakeTLS), SOCKS5, and proxy support. Useful if you want to build something that speaks MTProto without pulling in the full client.

ferogram-fsm packages the FSM layer (FsmState, StateContext, StateStorage, MemoryStorage) as a standalone crate that can be versioned and published on its own.

ferogram-mtsender does the same for the sender pool and retry policy. RetryPolicy, AutoSleep, CircuitBreaker, NoRetries all live here now.

The old ferogram-app and ferogram-bot example binaries were removed. They’ve been replaced by examples inside ferogram/examples/.

Getting a peer’s ID with .bare_id()

You used to need a full match to pull a numeric ID out of a tl::enums::Peer. Now there’s PeerExt:

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

// any Peer variant → i64
let id = peer.bare_id();

// works on Option<&Peer> too, no .map() needed
let sender = msg.sender_id().bare_id(); // Option<i64>
let chat   = msg.peer_id().bare_id();   // Option<i64>
}

The name bare_id is intentional: it gives you the native Telegram ID, not the Bot-API-encoded one. A channel with native ID 1234567890 is -1001234567890 in the Bot API.

PeerCache and ExperimentalFeatures are public

PeerCache is now in its own file and fully public. It’s what handles every peer lookup: user hashes, channel hashes, basic groups, min-users, the username index, the phone index. You can read from it directly if you need low-level access.

ExperimentalFeatures lets you opt into behaviours that deviate from strict Telegram spec. The main flag is allow_zero_hash, which lets bots skip needing a cached access hash (don’t use this on user accounts):

#![allow(unused)]
fn main() {
Client::builder()
    .experimental_features(ExperimentalFeatures {
        allow_zero_hash: true,
        ..Default::default()
    })
    .connect().await?;
}

Breaking changes

download_media_to_file is now download_file:

#![allow(unused)]
fn main() {
// before
client.download_media_to_file(location, &path).await?;

// now
client.download_file(location, &path).await?;
}

forward_messages now takes a fourth argument:

#![allow(unused)]
fn main() {
// before
client.forward_messages(dest, &[id], src).await?;

// now
client.forward_messages(dest, &[id], src, ForwardOptions::default()).await?;
}

respond_ex is gone. respond already accepts InputMessage, so it was redundant:

#![allow(unused)]
fn main() {
// before
msg.respond_ex(InputMessage::html("<b>hi</b>")).await?;

// now
msg.respond(InputMessage::html("<b>hi</b>")).await?;
}

Upgrading from 0.3.6

ferogram = "0.3.7"

The three breaking changes above need fixing. The rest is additive.


v0.3.6

Released 2026-04-30. API stabilization update towards v0.4.0.

Some APIs have been simplified, merged, or removed where redundant. This may require a one-time migration. The goal is a consistent, predictable API that does not need disruptive changes again.

Future updates will focus on new features and improvements.

See FEATURES.md for the full current API surface.

Upgrading from 0.3.5

ferogram = "0.3.6"

v0.3.5

Released 2026-04-30. Critical deserialization fix and update-state hardening.

PollResults deserialization fix

PollResults was incorrectly treated as a bare type throughout the codebase, meaning the 4-byte constructor ID was never read from the wire. The deserializer consumed that ID as the flags field instead, producing garbage flag values and misaligning every subsequent field read. Any getChannelDifference or getDifference response that contained a poll message would fail with an unexpected constructor id error and drop the entire update batch.

The fix routes PollResults through crate::enums::PollResults like every other boxed type, so the constructor ID is read and validated before fields are deserialized. Both MessageMediaPoll.results and updateMessagePoll.results are affected.

getDifference self-deadlock fix

The reader_loop select arm that fires the MessageBoxes gap deadline was directly awaiting run_pending_differences(). Because reader_loop is the only task reading TCP frames, the getDifference RPC it sent could never receive a response, producing a 30-second hang after any gap detection. The fix spawns a separate task for the diff runner, matching the pattern already used by the keepalive arm. A diff_in_flight: AtomicBool guard prevents duplicate spawns while a diff is already in progress.

Lazy access-hash resolution

Channel access hashes are now resolved purely from incoming update entities and the persisted peer cache. The automatic GetDialogs call at startup and catch-up has been removed. This makes ferogram resilient to Telegram schema changes in dialog-related types without requiring a layer bump.

Client::warm_peer_cache_from_dialogs() is a new public opt-in method for cases where you need access hashes before any update has arrived for a channel. See Raw API Access for usage details.

Upgrading from 0.3.4

ferogram = "0.3.5"

No API changes required. The fix is automatic.


v0.3.4

Released 2026-04-28. MTProto hardening release: PFS temp-key sessions, access-hash prefetch on startup, and safer deserialization across the board.

PFS (Perfect Forward Secrecy)

A new .pfs(true) method on ClientBuilder enables Perfect Forward Secrecy at the transport layer. When set, the DC pool performs a temporary DH key bind immediately after the permanent auth key is established. The connection then runs under a short-lived session key derived from that bind; the permanent key is never used to encrypt traffic directly. If the bind RPC fails for any reason the pool falls back to the standard session without disrupting the connection.

Access-hash prefetch

prefetch_channel_access_hashes is now called automatically at startup and after every catch-up cycle. It issues a single GetDialogs request and caches all returned channel and user access hashes before the first live update is dispatched. In practice this eliminates the CHANNEL_INVALID errors that previously appeared on reconnects when an incoming update referenced a channel the in-memory cache had not yet seen.

from_bytes_exact

Deserializable::from_bytes_exact is a new method available on all TL types. It wraps the common Cursor::from_slice + deserialize pattern and additionally returns an error if any bytes remain unconsumed after deserialization. All call sites across lib.rs, dc_pool.rs, and pts.rs have been migrated to it. Parse failures on incoming Updates frames are now logged as warnings instead of being silently discarded.

Concurrent get_difference fix

Previously, if two tasks raced to call get_difference at the same time, the second would return immediately with an empty result and potentially miss a fill cycle. It now polls every 50 ms waiting for the in-flight call to finish, and gives up after 35 s with a warning so the next gap tick can retry rather than hanging indefinitely.

Upgrading from 0.3.3

ferogram = "0.3.4"

To enable PFS:

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

v0.3.3

Released 2026-04-22. Bot framework release: composable filters, finite state machine, middleware pipeline, conversation API, and a new proc-macro crate.

ferogram-derive

A new ferogram-derive crate adds the #[derive(FsmState)] proc-macro. Applying it to a unit-variant enum generates as_key and from_key implementations automatically. The crate is gated behind a derive feature flag and FsmState is re-exported from the crate root, so the only import you need is use ferogram::FsmState.

Filters

ferogram::filters provides composable, synchronous predicates over IncomingMessage. Built-in constructors cover the common cases: command, private, text, media, and others. Predicates compose with &, |, and ! operators, so you can express things like command("start") & private() directly in the handler registration. Filters also integrate with the FSM via StateContext, letting you gate handlers on the current conversation state.

FSM

ferogram::fsm provides the full finite state machine layer: the FsmState trait, StateContext, StateKey, StateKeyStrategy, and StateStorage. The default storage is an in-memory DashMap-backed store keyed by peer. Custom backends can be plugged in via an async-trait extension point, so SQLite or Redis-backed stores are easy to add. A new examples/order_bot.rs walks through a multi-step order flow driven by the FSM.

Middleware

ferogram::middleware adds a Middleware trait and a Next chain that wraps every handler dispatch. The crate ships a ready-to-use rate-limit middleware backed by DashMap. DispatchError and DispatchResult are exported for use in custom middleware.

Conversation

ferogram::conversation provides a Conversation type for sequential, stateful exchanges with a single peer. It wraps an UpdateStream scoped to the conversation lifetime and transparently buffers updates arriving from other peers during the exchange.

IncomingMessage helpers

IncomingMessage gained a full set of inspection methods: chat_id, is_private, is_group, is_channel, is_any_group, from_id, is_bot_command, command, is_command_named, command_args, has_media, has_photo, has_document, is_forwarded, is_reply, and album_id.

New update types

Eight new update types are now exported from the crate root: ParticipantUpdate, JoinRequestUpdate, MessageReactionUpdate, PollVoteUpdate, BotStoppedUpdate, ShippingQueryUpdate, PreCheckoutQueryUpdate, and ChatBoostUpdate.

New API method

Client::get_chat_administrators() returns all admins and the creator for a channel or supergroup. For basic groups it returns all participants; use the is_admin field on the result to distinguish.

New documentation pages

Bot Framework: Middleware & Dispatcher, Finite State Machine (FSM), Conversation API. API reference: Bot Configuration, Stats & Analytics.

Upgrading from 0.3.2

ferogram = "0.3.3"

To use #[derive(FsmState)]:

ferogram = { version = "0.3.3", features = ["derive"] }

v0.3.2

Released 2026-04-21. Correctness and session-save hardening.

SeenMsgIds

The SeenMsgIds deque is now paired with a HashSet so duplicate checks under concurrent workers are O(1) instead of O(n). On busy connections receiving many server messages simultaneously this removes a hot path that was linear in the deque length.

Session save race

Session temp files now get a unique name per write, and a write_lock serializes concurrent saves. Previously two concurrent saves could race on the rename step, which caused data loss on Windows. Both are now safe.

Bug fixes

Five correctness bugs were patched:

The PaddedIntermediate handshake was not being sent on DC pool worker connections. Without it the server would silently drop or misparse every frame from those connections.

new_session_created was resetting the session on fresh connections even when it should not, which caused a session ID mismatch on every subsequent decrypt.

scan_body was passing None as sent_msg_id during container iterations, letting stale cached results overwrite live responses from the server.

The importAuthorization branch condition was inverted, so the import was skipped precisely in the cases where it was required and ran in cases where it was not.

Server 4-byte transport error codes received during the DH handshake are now surfaced properly instead of being misclassified as “plain frame too short”.


v0.3.1

Released 2026-04-20. Patch release fixing the docs.rs build. No functional changes from 0.3.0.

Upgrading from 0.3.0

ferogram = "0.3.1"

v0.3.0

Released 2026-04-19. The biggest release so far: two new crates, a redesigned connection stack, CDN file download support, and a much larger API reference.

Two new crates

ferogram-session takes over all session persistence. It owns PersistedSession, DcEntry, DcFlags, UpdatesStateSnap, CachedPeer, CachedMinPeer, default_dc_addresses, and all storage backends: BinaryFileBackend, InMemoryBackend, StringSessionBackend, SqliteBackend, and LibSqlBackend. The main ferogram crate re-exports everything from it, so existing code needs no changes.

ferogram-parsers takes over Telegram entity parsing. It provides parse_markdown, generate_markdown, parse_html, and generate_html. An optional html5ever feature swaps in a spec-compliant HTML5 tokenizer. The main ferogram::parsers module re-exports these, so again, nothing changes for most users.

Both crates can also be used as standalone dependencies if you only need the session or parser layer without the full client.

Session format

The binary session format moved to version 5. It now persists the home DC, the full DC table with per-DC auth keys and flags, update state (pts, qts, date, seq), per-channel pts values, the peer access-hash cache, and min-user message contexts for InputPeerUserFromMessage. Older session files still load without error. Saves are atomic: written to a .tmp file first, then renamed into place, so a crash during save cannot corrupt the session. DC flags are now persisted, which means media and CDN DC entries survive restarts.

Connection options

ClientBuilder gained three new methods.

.probe_transport(true) races Obfuscated, Abridged, and HTTP transports at connect time and uses whichever one succeeds first. Useful on networks where one transport is throttled or blocked. Has no effect when MTProxy is configured.

.resilient_connect(true) adds two fallback layers when direct TCP fails. First it tries DNS-over-HTTPS, querying both Google DoH and Mozilla/Cloudflare DoH. If that also fails, it tries Telegram’s Firebase/Google special-config endpoint to get working DC addresses. Intended for restricted networks where normal TCP and DNS are both unreliable.

.experimental_features(...) accepts an ExperimentalFeatures struct with three fields: allow_zero_hash, allow_missing_channel_hash, and auto_resolve_peers (reserved, not yet active).

CDN downloads

A new ferogram::cdn_download module handles the full Telegram CDN file path. It requests chunks via upload.getCdnFile, re-uploads stale chunks via upload.reuploadCdnFile, and decrypts each chunk with AES-256-CTR using the key and IV provided by Telegram. Exports CdnDownloader, CdnChunkResult, and CDN_CHUNK_SIZE. Used internally when large files are served from a CDN DC rather than the main DC.

DNS-over-HTTPS and special-config

ferogram::dns_resolver queries Google DoH and Mozilla/Cloudflare DoH, merges IPv4 and IPv6 answers, and caches results by TTL.

ferogram::special_config implements Telegram’s last-resort fallback: decodes the encrypted response from Telegram’s Firebase/Google endpoint and extracts DC addresses from help.configSimple.

MTProto internals

ferogram-mtproto gained a bind_temp_key module and now re-exports encrypt_bind_inner, gen_msg_id, serialize_bind_temp_auth_key, EncryptedSession, SeenMsgIds, and new_seen_msg_ids. Primarily useful for library authors working at the MTProto layer directly.

New documentation pages

Advanced: CDN Downloads, Transport Probing and Resilient Connect, Connection Restart Policy, Experimental Features.

API reference: ClientBuilder, Types Reference, Chat Management, Contacts and Blocking, Forum Topics, Games and Payments, Invite Links, Polls and Votes, Privacy and Notifications, Profile and Account, Stickers.

Upgrading from 0.2.0

ferogram = "0.3.0"

No API changes required. If you want to use the new connection options:

#![allow(unused)]
fn main() {
let client = Client::builder()
    .probe_transport(true)
    .resilient_connect(true)
    .connect()
    .await?;
}

v0.2.0

Released 2026-04-13. Focused on concurrency, protocol correctness, and transport hardening.

Concurrency

The peer cache moved from RwLock<HashMap> to a moka concurrent cache, removing lock contention during peer lookups. The pending RPC map was replaced with DashMap for lock-free response routing. The DC pool switched from parking_lot::Mutex to tokio::sync::Mutex so it no longer blocks the async runtime during DC operations.

Protocol correctness

Fresh DH sessions now wait 2 seconds after key derivation to allow Telegram to propagate the new auth key across DCs before the first request is sent. Stale key detection was simplified: only error -404 triggers key rotation now. getDifference deserialization tolerates unknown server responses instead of failing and dropping buffered updates. Container message parsing validates inner message alignment and safely discards malformed frames.

Transport

FakeTLS transport now prepends the Change Cipher Spec record to the first application data chunk, matching the TLS handshake pattern Telegram expects. Transport errors -429 and -444 are now logged clearly before reconnecting rather than failing silently.


v0.1.0

Released 2026-04-11. The initial release of ferogram, renamed and rebranded from layer v0.5.0.

Proxy and transport

Full MTProxy support via t.me/proxy or tg://proxy links, or manually with host, port, and secret. PaddedIntermediate transport (0xDD secrets) adds randomized padding to blend in with official Telegram client traffic. FakeTLS transport (0xEE secrets) wraps MTProto in TLS-like framing. SOCKS5 proxy with optional username and password. IPv6 connectivity for both Telegram DCs and proxy connections.

Session backends

Binary file, in-memory, string/base64, SQLite, and libSQL.

Protocol fixes

Auth key generation now uses the correct PQInnerDataDc constructor with the DC id included, resolving auth failures on many DCs. Incoming message validation uses a rolling buffer of the last 500 server msg_id values plus a 300 second timestamp window to prevent replay attacks. DH step 3 retry (dh_gen_retry) retries with cached params for up to 5 attempts, matching Telegram Desktop behavior. MTProxy connections now correctly route through the proxy host instead of going directly to Telegram DCs. getChannelDifference starts at limit 100 and increases to 1000 on subsequent calls.


See the full CHANGELOG for the raw entry format.

Installation

Add to Cargo.toml

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

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


Getting API credentials

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

Step-by-step:

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

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

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

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

Bot token (bots only)

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

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

Optional features

SQLite session storage

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

Stores session data in a SQLite database instead of a binary file. Better choice for long-running servers where crash-corruption of the binary file is a concern.

LibSQL / Turso session storage: New in v0.2.0

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

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

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

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

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

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

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

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

// Restore
let (client, _shutdown) = Client::builder()
    .api_id(api_id)
    .api_hash(api_hash)
    .session_string(s)
    .connect()
    .await?;
}

See Session Backends for the full guide.

HTML entity parsing

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

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

Raw type system features (ferogram-tl-types)

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

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

Verifying installation

use ferogram_tl_types::LAYER;

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

Platform notes

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

Quick Start: User Account

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

use ferogram::Client;
use ferogram::update::Update;

const API_ID: i32 = 0; // from https://my.telegram.org
const API_HASH: &str = ""; // from https://my.telegram.org

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (client, _shutdown) = Client::quick_connect("my.session", API_ID, API_HASH).await?;

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

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

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

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

    Ok(())
}

Run it

cargo run

Fill in API_ID and API_HASH at the top of the file before running. 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
Connect + authClient::quick_connectOpens TCP, DH handshake, loads session, prompts for phone/code/2FA or bot token if not yet authorized, saves session
Streamstream_updatesReturns an UpdateStream async iterator

For the full auth flow broken down step by step, see User Login.


Next steps

Quick Start: Bot

A working bot with commands, callback queries, and inline mode. Each update runs in its own task so the loop never blocks.

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

const API_ID: i32 = 0; // from https://my.telegram.org
const API_HASH: &str = ""; // from https://my.telegram.org

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

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

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

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

            match cmd {
                "/start" => {
                    let (t, e) = parse_markdown(
                        "👋 **Hello!** I'm built with **ferogram**: async Telegram MTProto in Rust 🦀\n\n\
                         Use /help to see all commands."
                    );
                    msg.reply(InputMessage::text(t).entities(e)).await?;
                }
                "/help" => {
                    let (t, e) = parse_markdown(
                        "📖 **Commands**\n\n\
                         /start: Welcome message\n\
                         /ping: Latency check\n\
                         /echo `<text>`: Repeat your text\n\
                         /upper `<text>`: UPPERCASE\n\
                         /lower `<text>`: lowercase\n\
                         /reverse `<text>`: esreveR\n\
                         /id: Your user and chat ID"
                    );
                    msg.reply(InputMessage::text(t).entities(e)).await?;
                }
                "/ping" => {
                    let start = std::time::Instant::now();
                    let sent = msg.respond("🏓 …").await?;
                    let ms = start.elapsed().as_millis();
                    let (t, e) = parse_markdown(&format!("🏓 **Pong!** `{ms} ms`"));
                    sent.edit(InputMessage::text(t).entities(e)).await?;
                }
                "/echo"    => { msg.reply(if arg.is_empty() { "Usage: /echo <text>" } else { arg }).await?; }
                "/upper"   => { msg.reply(arg.to_uppercase().as_str()).await?; }
                "/lower"   => { msg.reply(arg.to_lowercase().as_str()).await?; }
                "/reverse" => {
                    let rev: String = arg.chars().rev().collect();
                    msg.reply(rev.as_str()).await?;
                }
                "/id" => {
                    if let Some(peer) = msg.peer_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}"));
                        msg.reply(InputMessage::text(t).entities(e)).await?;
                    }
                }
                _ => { msg.reply("❓ Unknown command. Try /help").await?; }
            }
        }

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

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

        _ => {}
    }

    Ok(())
}

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

Key differences: User vs Bot

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

Next steps

User Login

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

Just want something working? Client::quick_connect handles all three steps interactively in one call, including 2FA. This page covers the explicit step-by-step flow for non-interactive setups (reading from env vars, config files, custom prompts, etc.).

Step 1: Request login code

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

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

Step 2: Submit the code

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

sign_in returns:

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

Step 3: 2FA password (if required)

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

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

Save the session

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

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

Full example with stdin

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

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

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

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

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

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

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

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

Sign out

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

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


How the DH auth key exchange works

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

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

See Crate Architecture for more on the MTProto internals.

Bot Login

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

Just want something working? Client::quick_connect prompts for a bot token interactively and handles the full flow in one call. This page covers the explicit bot_sign_in path for when you need non-interactive auth or more control.

Getting a bot token

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

Login

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

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

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

Get bot info

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

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

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

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

Then run:

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

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

Two-Factor Authentication (2FA)

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

How it works in ferogram

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

check_password performs the full SRP computation internally:

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

Getting the password hint

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

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

Changing the 2FA password

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

Wrong password errors

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

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

Security notes

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

Session Persistence

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

Binary file (default)

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

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

After login, save to disk:

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

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


In-memory (ephemeral)

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

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

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

Login is required on every run since nothing persists.


SQLite (local database)

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

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


String session: New in v0.2.0

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

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

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

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

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


What’s stored in a session

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

Security

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

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

Multi-session / multi-account

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

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

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

Session Backends

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


Built-in backends

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

BinaryFileBackend (default)

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

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

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

Or construct directly:

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

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

InMemoryBackend

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

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

Or construct directly:

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

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

StringSessionBackend: portable auth

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

Export

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

Restore

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

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

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

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


SqliteBackend: local database

Requires feature flag:

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

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

The file is created if it doesn’t exist.


LibSqlBackend: libsql / Turso

Requires feature flag:

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

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

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

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

Custom backend

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

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

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

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

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

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

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

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

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

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

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

Granular SessionBackend methods

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

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

Using ClientBuilder to attach a backend

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

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

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

Additional backend methods

StringSessionBackend::current()

Reads the current serialised session string at any time:

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

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

BinaryFileBackend::path()

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

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

InMemoryBackend::snapshot()

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

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

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

Sending Messages

Basic send

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

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

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

send_message accepts anything that implements Into<PeerRef> as the peer (username string, numeric ID, or resolved tl::enums::Peer). The second argument accepts anything that implements Into<InputMessage>, including a bare &str or String for plain text.


Rich messages with InputMessage

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

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

// Plain text
client.send_message("@peer", InputMessage::text("Hello!")).await?;

// Markdown-formatted
client.send_message("@peer", InputMessage::markdown("**Bold** and _italic_ and `code`")).await?;

// HTML-formatted (requires `html` feature)
client.send_message("@peer", InputMessage::html("Hello <b>world</b>")).await?;

// Reply to a message
let msg = InputMessage::text("This is a reply").reply_to(Some(original_id));
client.send_message("@peer", msg).await?;

// Silent message (no notification)
let msg = InputMessage::text("Quiet update").silent(true);
client.send_message("@peer", msg).await?;
}

Message shorthands on IncomingMessage

Every received message exposes shorthand methods that embed the client:

#![allow(unused)]
fn main() {
// Quote-reply in the same chat
msg.reply(InputMessage::text("Got it!")).await?;

// Send to the same chat without quoting
msg.respond("Noted.").await?;

// Edit the message
msg.edit(InputMessage::text("Updated!")).await?;

// Forward to another chat
msg.forward_to("@other_chat").await?;

// Delete
msg.delete().await?;

// Pin / unpin
msg.pin().await?;
msg.unpin().await?;

// Mark as read
msg.mark_read().await?;

// Reload from server
msg.refetch().await?;

// Fetch the message being replied to
if let Some(parent) = msg.get_reply().await? {
    println!("Replied to: {}", parent.text().unwrap_or(""));
}
}

All of these have explicit _with(client, ...) variants for use outside handler closures.


Inline keyboards with InputMessage

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

let kb = InlineKeyboard::new()
    .row([
        Button::callback("✅ Yes", b"yes"),
        Button::callback("❌ No",  b"no"),
    ]);

let msg = InputMessage::text("Confirm?").reply_markup(kb);
client.send_message("@peer", msg).await?;
}

Clicking inline buttons

Three targeting modes via ButtonFilter:

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

// By position (row, col - 0-based)
msg.click_button(ButtonFilter::Pos(0, 0)).await?;

// By exact button label
msg.click_button(ButtonFilter::Text("✅ Yes")).await?;

// By callback data bytes
msg.click_button(ButtonFilter::Data(b"action:buy")).await?;

// By arbitrary predicate
msg.click_button_where(|text, data| text.starts_with("✅")).await?;
}

find_button and find_button_where return Option<(row, col)> without sending anything.


Edit, forward, delete, pin (standalone client methods)

#![allow(unused)]
fn main() {
// Edit by message ID
client.edit_message("@peer", msg_id, InputMessage::text("New text")).await?;

// Forward messages
client.forward_messages("@source", &[id1, id2], "@dest").await?;

// Delete messages (revoke = remove for everyone)
client.delete_messages(&[msg_id_1, msg_id_2], true).await?;

// Pin / unpin
client.pin_message("@peer", msg_id).await?;
client.pin_message("@peer", msg_id, false).await?;
client.unpin_all_messages("@peer").await?;

// Get pinned message
let pinned = client.get_pinned_message("@peer").await?;

// Mark as read
client.mark_read("@peer").await?;

// Export a permanent message link
let link = client.export_message_link("@peer", msg_id).await?;

// Who has read a message (groups/channels with read receipts)
let readers = client.get_message_read_participants("@peer", msg_id).await?;
}

Fetch message history

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

for msg in messages {
    println!("{}: {}", msg.id(), msg.text().unwrap_or(""));
}

// Lazy iterator (auto-paginating)
let mut iter = client.iter_messages("@peer");
while let Some(msg) = iter.next(&client).await {
    println!("{}", msg.text().unwrap_or(""));
}
}

Scheduled messages

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

// Schedule at a Unix timestamp
let msg = InputMessage::text("Happy New Year!").schedule_date(Some(1735689600));
client.send_message("@peer", msg).await?;

// Schedule to send when the recipient comes online
let msg = InputMessage::text("Hey!").schedule_once_online();
client.send_message("@peer", msg).await?;

// List scheduled messages
let scheduled = client.get_scheduled_messages("@peer").await?;

// Send a scheduled message immediately
client.send_scheduled_now("@peer", &[scheduled_id]).await?;

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

Drafts

#![allow(unused)]
fn main() {
// Save or update a draft
client.save_draft("@peer", "Draft text").await?;

// Trigger server push of all drafts as update events
client.sync_drafts().await?;

// Delete all drafts
client.clear_all_drafts().await?;
}

Receiving Updates

The update stream

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

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

let mut updates = client.stream_updates();

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

Concurrent update handling

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

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

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

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

Filtering outgoing messages

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

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

MessageDeleted

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

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

Inline Keyboards & Reply Markup

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

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

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

InlineKeyboard: buttons attached to a message

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

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

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

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

InlineKeyboard methods

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

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


Button: all button types

Callback, URL, and common types

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

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

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

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

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

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

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

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

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

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

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

Reply-keyboard-only buttons

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

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

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

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

Escape hatch

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

ReplyKeyboard: replacement keyboard

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

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

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

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

ReplyKeyboard methods

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

Remove keyboard

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

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

Answer callback queries

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

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

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

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

Legacy raw TL pattern (still works)

If you prefer constructing TL types directly:

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

```rust
use ferogram_tl_types as tl;

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

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

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

Send with keyboard

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

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

client.send_message(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

ferogram provides three upload methods. Choose based on file size and where the data comes from.

MethodInputBest for
upload_file(path)path on diskAny file - stat → chunked upload
upload(source, name)impl AsyncReadIn-memory bytes or any async reader
async client.upload_file(path: impl AsRef<Path>) → Result<UploadedFile, InvocationError>

Upload a file from disk. Stats the file for optimal part sizing, then uploads in chunks. Automatically uses parallel workers and saveBigFilePart for files over 10 MB.

#![allow(unused)]
fn main() {
let uploaded = client.upload_file("/tmp/photo.jpg").await?;
client.send_media(peer, InputMessage::media(uploaded.as_photo_media())).await?;
}

client.upload(source: impl AsyncRead + Unpin + Send, name: &str) → Result<UploadedFile, InvocationError>

Upload from any AsyncRead source (in-memory Cursor, network stream, etc.). Buffers the stream to determine size, then uploads with optimal part sizing.

#![allow(unused)]
fn main() {
// from a Vec<u8>
use std::io::Cursor;
let uploaded = client.upload(Cursor::new(bytes), "photo.jpg").await?;

// from a tokio File
let f = tokio::fs::File::open("video.mp4").await?;
let uploaded = client.upload(f, "video.mp4").await?;
}
Upload a byte slice sequentially. Uses a single worker and sends chunks one at a time. Suitable for files under ~10 MB or when parallelism is not needed.
#![allow(unused)]
fn main() {
let bytes: Vec<u8> = std::fs::read("photo.jpg")?;
let uploaded = client.upload_file("/tmp/photo.jpg").await?;
}

UploadedFile methods

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

Upload media (reusable)

Upload a file to Telegram’s servers and get back an InputMedia handle that can be reused in multiple sends without re-uploading:

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

let uploaded = client.upload_file(&bytes, "photo.jpg", "image/jpeg").await?;
let media = uploaded.as_photo_media();

// Upload to Telegram's servers (no message sent)
let stored = client.upload_media("@peer", media.clone()).await?;

// Reuse the stored media handle
let msg = InputMessage::text("Here it is!").copy_media(stored.into_input_media());
client.send_message("@peer", msg).await?;
}

Send file

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

// Send a file as document or photo
let uploaded = client.upload_file(&bytes, "photo.jpg", "image/jpeg").await?;
client.send_file("@peer", uploaded.as_photo_media(), &InputMessage::text("Caption")).await?;

// Or attach via InputMessage
let msg = InputMessage::text("Here is the file")
    .copy_media(uploaded.as_document_media());
client.send_message("@peer", msg).await?;

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

AlbumItem: per-item control in albums

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

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

Download

#![allow(unused)]
fn main() {
// To bytes: sequential
```rust
let mut buf = Vec::new();
client.download(msg.media().unwrap(), &mut buf).await?;

client.download_file(msg.media().unwrap(), "/tmp/output.jpg").await?;

let bytes = msg.bytes().await?;                        // → Vec<u8>

let mut file = tokio::fs::File::create("out.mp4").await?;
msg.download(&mut file).await?;                        // stream to file

if let Some(mut iter) = client.iter_download(msg.media().unwrap()) {
    while let Some(chunk) = iter.next().await? {
        // chunk: bytes::Bytes - zero-copy slice
        process(&chunk);
    }
}
}
MethodDestReturns
client.download(media, dest)any AsyncWriteu64 bytes written
client.download_file(media, path)file on disku64 bytes written
client.iter_download(media)caller-drivenOption<DownloadIter>
msg.download(dest)any AsyncWriteu64 bytes written
msg.bytes()in-memory Vec<u8>Vec<u8>

Downloadable trait

Downloadable trait

Photo, Document, and Sticker all implement Downloadable, so you can use client.download_item(&item) (internal) on any of them uniformly. For the public API, pass msg.media() to client.download.

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

async fn save_any<D: Downloadable>(client: &Client, item: &D) -> Vec<u8> {
    // internal method - public API uses client.download(media, dest)
    client.download(item).await.unwrap()
}
}

Download location from message

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

// use msg.bytes() or client.download(msg.media().unwrap(), &mut buf) instead

// Or via IncomingMessage convenience:
client.download_file(msg.media().unwrap(), "output.jpg").await?;
}

Media groups (albums)

When Telegram delivers a grouped media send (album), each message in the group carries the same grouped_id. To fetch all messages belonging to the same album as a known message ID:

#![allow(unused)]
fn main() {
let msgs = client.get_media_group("@mychannel", 42).await?;
println!("{} messages in this album", msgs.len());

for m in &msgs {
    if let Some(photo) = m.photo() {
        println!("  photo id={}", photo.id());
    } else if let Some(doc) = m.document() {
        println!("  document mime={}", doc.mime_type().unwrap_or("?"));
    }
}
}

get_media_group accepts any peer and a message ID that is part of the album. It returns all messages in the group including the seed message. For non-channel chats the server returns only the single message.

Detecting albums in the update stream

#![allow(unused)]
fn main() {
if let Update::NewMessage(msg) = update {
    if msg.grouped_id().is_some() {
        // This message is part of an album; you can call
        // client.get_media_group(msg.peer_id(), msg.id()).await
        // to retrieve the full set.
    }
}
}

Message Formatting

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

Quickest way: InputMessage constructors

The simplest approach is InputMessage::markdown or InputMessage::html, which parse the text and attach entities in one call:

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

// Markdown
let msg = InputMessage::markdown("**Bold**, _italic_, `code`");

// HTML
let msg = InputMessage::html("<b>Bold</b>, <i>italic</i>, <code>code</code>");

client.send_message(peer, msg).await?;
}

If you just want to send without building an InputMessage first, the convenience methods do the same thing in one step:

#![allow(unused)]
fn main() {
client.send_message(peer, InputMessage::html("<b>Bold</b> and <i>italic</i>")).await?;
client.send_message(peer, InputMessage::markdown("**Bold** and _italic_")).await?;
}

Using parse_markdown directly

If you need the (String, Vec<MessageEntity>) tuple for further processing:

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

let (plain, entities) = parse_markdown("**Bold text**, _italic_, `inline code`");

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

Markdown syntax

SyntaxEntity
**text** or *text*Bold
_text_Italic
__text__Underline
~text~Strikethrough
||text||Spoiler
`text`Code (inline)
```lang\ncode\n```Pre (code block)
[label](url)TextUrl
[label](tg://user?id=123)MentionName
![text](tg://emoji?id=123)CustomEmoji
> line at line startBlockquote
**> line at line startExpandable blockquote
\*, \_, \~Escaped literal character

parse_markdown follows the Telegram Bot API MarkdownV2 spec. __text__ is Underline, not Italic. For the legacy V1 behaviour call parse_markdown_v1 (deprecated, removed in 0.4.0).

HTML syntax

Supported tags:

TagEntity
<b>, <strong>Bold
<i>, <em>Italic
<u>, <ins>Underline
<s>, <del>, <strike>Strikethrough
<tg-spoiler>, <span class="tg-spoiler">Spoiler
<code>Code (inline)
<pre>Pre (code block)
<pre><code class="language-X">Pre with language
<blockquote>Blockquote
<blockquote expandable>Expandable (collapsible) blockquote
<tg-time unix="N" format="F">FormattedDate
<a href="url">TextUrl
<a href="tg://user?id=123">MentionName
<tg-emoji emoji-id="123">CustomEmoji
<br>Newline

Building entities manually

For full control, construct MessageEntity values directly:

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

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

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

Pre block with language

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

Mention by user ID

#![allow(unused)]
fn main() {
tl::enums::MessageEntity::MentionName(tl::types::MessageEntityMentionName {
    offset:  0,
    length:  label.encode_utf16().count() as i32,
    user_id: 123456789,
})
}

Blockquote

messageEntityBlockquote has an optional collapsed flag:

#![allow(unused)]
fn main() {
tl::enums::MessageEntity::Blockquote(tl::types::MessageEntityBlockquote {
    collapsed: false, // true = collapsible quote
    offset: 0,
    length: text.encode_utf16().count() as i32,
})
}

FormattedDate

Displays a Unix timestamp in the user’s local timezone and locale. Set the boolean flags you want; the rest default to false:

#![allow(unused)]
fn main() {
tl::enums::MessageEntity::FormattedDate(tl::types::MessageEntityFormattedDate {
    relative:    false,
    short_time:  false,
    long_time:   false,
    short_date:  true,  // e.g. "Jan 5"
    long_date:   false,
    day_of_week: false,
    offset: 0,
    length: placeholder_text.encode_utf16().count() as i32,
    date:   1736000000, // Unix timestamp
})
}

Generating markup from entities

Both generate_markdown and generate_html are available if you need to serialise entities back to text:

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

let md  = generate_markdown(plain_text, &entities);
let htm = generate_html(plain_text, &entities);
}

generate_markdown emits full MarkdownV2 syntax including __text__ for Underline, ~text~ for Strike, and > prefix for Blockquote.

All entity types

VariantDescription
BoldBold text
ItalicItalic text
UnderlineUnderlined (HTML only)
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
BlockquoteBlock quote, optionally collapsible
CustomEmojiCustom emoji by document ID
FormattedDateUnix timestamp rendered in local time

Reactions

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

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


Send a reaction

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

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

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

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

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

Modifiers

Chain modifiers after a constructor:

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

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

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

Multi-reaction (premium users)

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

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

InputReactions API reference

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

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


Reading reactions from a message

Reactions are embedded in msg.raw:

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

Raw API: who reacted

To see which users chose a specific reaction:

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

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

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

Update Types

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

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

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

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

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

        // Group/channel events
        Update::ParticipantUpdate(p)  => { /* ParticipantUpdate */ }
        Update::JoinRequest(jr)       => { /* JoinRequest */ }
        Update::MessageReaction(mr)   => { /* MessageReaction */ }
        Update::PollVote(pv)          => { /* PollVote */ }
        Update::BotStopped(bs)        => { /* BotStopped */ }

        // Payments
        Update::ShippingQuery(sq)     => { /* ShippingQuery */ }
        Update::PreCheckoutQuery(pcq) => { /* PreCheckoutQuery */ }

        // Boosts
        Update::ChatBoost(cb)         => { /* ChatBoost */ }

        // Guest chat (bots only)
        Update::GuestChatQuery(q)     => { /* GuestChatQuery */ }

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

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

MessageDeleted

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

CallbackQuery

See the full Callback Queries page.

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

InlineQuery

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

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


InlineSend

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

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

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

UserTyping

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

UserStatus

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

ParticipantUpdate

Fires when a user joins, leaves, or has their rights changed in a group or channel.

#![allow(unused)]
fn main() {
Update::ParticipantUpdate(p) => {
    p.peer      // tl::enums::Peer: the chat
    p.user_id   // i64
    // p.prev_participant / p.new_participant: Option<tl::enums::ChannelParticipant>
}
}

JoinRequest

Fires when a user submits a join request to a group or channel.

#![allow(unused)]
fn main() {
Update::JoinRequest(jr) => {
    jr.peer     // tl::enums::Peer
    jr.user_id  // i64
    // Approve/decline via client.join_request(peer, user_id, approve)
}
}

MessageReaction

Fires when a reaction is added or removed on a message.

#![allow(unused)]
fn main() {
Update::MessageReaction(mr) => {
    mr.peer    // tl::enums::Peer
    mr.msg_id  // i32
    // mr.reactions: list of current reactions
}
}

PollVote

Fires when a user votes in a poll.

#![allow(unused)]
fn main() {
Update::PollVote(pv) => {
    pv.poll_id  // i64
    pv.user_id  // i64
    // pv.options: Vec<Vec<u8>> - option IDs chosen
}
}

BotStopped

Fires when a user blocks or unblocks the bot.

#![allow(unused)]
fn main() {
Update::BotStopped(bs) => {
    bs.user_id  // i64
    bs.stopped  // bool: true = blocked, false = unblocked
}
}

ShippingQuery

Fires during the payment flow when the user provides a shipping address (for physical goods).

#![allow(unused)]
fn main() {
Update::ShippingQuery(sq) => {
    // sq.query_id, sq.user_id, sq.payload, sq.shipping_address
    client.answer_shipping_query(sq.query_id, true, shipping_options, None).await?;
}
}

PreCheckoutQuery

Fires just before a payment is confirmed.

#![allow(unused)]
fn main() {
Update::PreCheckoutQuery(pcq) => {
    // pcq.query_id, pcq.user_id, pcq.currency, pcq.total_amount, pcq.payload
    client.answer_precheckout_query(pcq.query_id, true, None).await?;
}
}

ChatBoost

Fires when a channel receives a boost event.

#![allow(unused)]
fn main() {
Update::ChatBoost(cb) => {
    cb.peer   // tl::enums::Peer: the channel
    // cb.boost: boost details
}
}

GuestChatQuery

Fires when a user invites the bot into a guest-chat context (updateBotGuestChatQuery). Bots only. GuestChatQuery derefs to IncomingMessage so you can read the message text directly.

#![allow(unused)]
fn main() {
Update::GuestChatQuery(q) => {
    println!("query_id: {}", q.query_id);
    println!("message: {}", q.text());
    println!("qts: {}", q.qts);

    // reference_messages: previous messages for context
    for ref_msg in &q.reference_messages {
        println!("  ref: {}", ref_msg.text());
    }

    // Answer with GuestChatAnswer
    q.answer()
        .article("My result")
        .text("Answer body")
        .send(&client)
        .await?;
}
}
FieldTypeDescription
query_idi64ID to pass to setBotGuestChatResult
messageIncomingMessageThe message that triggered the query
reference_messagesVec<IncomingMessage>Prior context messages, if any
qtsi32QTS sequence number

GuestChatAnswer supports all standard inline result kinds: article, photo, document, game, location, venue, contact, webpage, invoice, and raw. Call .send(&client) to submit via messages.setBotGuestChatResult.


RawUpdate

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

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

Raw update stream

If you need all updates unfiltered:

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

IncomingMessage

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


Basic accessors

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

Extended accessors

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

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

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

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

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

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

Convenience action methods

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

Reply

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

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

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

Respond (no reply thread)

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

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

Edit

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

Delete

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

Mark as read

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

Pin / Unpin

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

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

React

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

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

Forward

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

Download media

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

Fetch replied-to message

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

Refetch

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

Reply-to-message (via Client)

#![allow(unused)]
fn main() {
// Fetch the replied-to message by ID
if let Some(reply_id) = msg.reply_to_message_id() {
    let parent = client.get_messages(msg.peer(), &[reply_id]).await.ok().and_then(|mut v| v.into_iter().next()).await?;
}
}

Full handler example

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

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

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

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

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

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

Callback Queries

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


Basic handling

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

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

CallbackQuery fields

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

Answer builder (fluent API)

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

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

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

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

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

Answer methods

MethodDescription
cb.answer()Start building an answer
.text(str)Toast message shown to the user
.alert(str)Modal popup shown to the user
.url(str)URL to open (with user confirmation)
.cache_time(Duration)How long Telegram caches this answer client-side (default: 0)
.send(&client)Execute: always call this

Convenience shortcuts

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

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

Via Client directly

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

Edit the message on callback

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

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

Full example: vote bot

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

Inline Mode

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


Receiving inline queries (bot side)

Via update stream

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

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

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

Via InlineQueryIter

For a more structured approach, use the dedicated iterator:

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

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

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


InlineQuery fields

#![allow(unused)]
fn main() {
iq.query()       // &str: the search text
iq.query_id      // i64
iq.user_id       // i64: who sent the query
iq.offset        // String: pagination offset
iq.peer          // Option<tl::enums::Peer>: chat context the user is in (None if not shared)
}

peer is populated when the bot has access to the chat - use it to tailor results per chat type.


Receiving inline sends (bot side)

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

#![allow(unused)]
fn main() {
Update::InlineSend(is) => {
    println!("User {} chose result '{}'", is.user_id, is.id);
    println!("Original query: {}", is.query);
    // is.msg_id is Some(...) when the message is still editable
}
}

InlineSend fields

FieldTypeDescription
is.user_idi64Who picked the inline result
is.queryStringThe original query text
is.idStringThe result ID that was chosen
is.msg_idOption<tl::enums::InputBotInlineMessageId>Present when the sent message can still be edited

Editing the sent inline message

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

Sending inline queries (user account side)

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

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

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

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

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

InlineResult methods

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

InlineResultIter methods

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

answer_inline_query parameters

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

Guest Chat Queries

Update::GuestChatQuery fires when a user invites the bot into a guest-chat context (updateBotGuestChatQuery). Bots only.


Receiving the update

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

while let Some(update) = stream.next().await {
    if let Update::GuestChatQuery(q) = update {
        println!("query_id: {}", q.query_id);
        println!("from: {:?}", q.message.sender_id());
        println!("text: {}", q.message.text());
    }
}
}

GuestChatQuery derefs to IncomingMessage, so all the usual accessors (text(), sender_id(), chat_id(), date(), etc.) work directly on q.


Fields

FieldTypeDescription
query_idi64ID used to submit the answer
messageIncomingMessageThe message that triggered the query
reference_messagesVec<IncomingMessage>Prior context messages provided by Telegram, if any
qtsi32QTS sequence number for this update

Answering

Call q.answer() to get a GuestChatAnswer builder, then call .send(&client).

#![allow(unused)]
fn main() {
if let Update::GuestChatQuery(q) = update {
    q.answer()
        .article("My result")
        .text("Answer body")
        .send(&client)
        .await?;
}
}

Result kinds

MethodDescription
.article(title)Text article result
.photo(input_photo)Existing photo
.document(input_doc, title)Existing document
.game(short_name)Game result
.location(lat, long)Geographic location
.venue(lat, long, title, address, ...)Venue
.contact(phone, first_name, last_name)Contact card
.webpage(url)Web page preview
.invoice(title, description, ...)Invoice
.raw(InputBotInlineResult)Fully constructed TL result

Message content

MethodDescription
.text(str)Text content for the result message
.caption(str)Caption for media results
.no_webpage(bool)Disable link preview
.invert_media(bool)Invert media position
.entities(vec)Manual MessageEntity list
.reply_markup(markup)Attach inline keyboard

Other builder methods

MethodDescription
.id(str)Custom result ID (auto-generated if omitted)
.description(str)Short description shown under title
.url(str)URL for the result
.thumb(InputWebDocument)Thumbnail

Checking if a bot supports guest chat

In the User type, bot_guestchat (flags2.19) is set when the bot has declared guest-chat support. This is a Layer 225 addition.

Middleware & Dispatcher

ferogram ships a Dispatcher that routes incoming updates to typed handlers, and a middleware chain that intercepts every update before it reaches any handler.


Dispatcher basics

#![allow(unused)]
fn main() {
use ferogram::filters::{self, Dispatcher};

let mut dp = Dispatcher::new();

// Handle /start command (bots)
dp.on_message(filters::command("start"), |msg| async move {
    msg.reply("Hello!").await.ok();
});

// Handle any private text
dp.on_message(filters::private() & filters::text(), |msg| async move {
    let echo = msg.text().unwrap_or_default().to_string();
    msg.reply(echo).await.ok();
});

// Drive the dispatcher from the update stream
let mut stream = client.stream_updates();
while let Some(upd) = stream.next().await {
    dp.dispatch(upd).await;
}
}

dispatch is async and runs handlers serially per update. For concurrent handling, spawn each dispatch call:

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

let dp = Arc::new(dp);
let mut stream = client.stream_updates();
while let Some(upd) = stream.next().await {
    let dp = dp.clone();
    tokio::spawn(async move { dp.dispatch(upd).await; });
}
}

Built-in filters

All filters return a BoxFilter which supports & (AND), | (OR), and ! (NOT).

FilterDescription
all()Always matches
none()Never matches
private()Message is in a private chat (DM)
group()Message is in a basic group
channel()Message is in a channel
text()Message has non-empty text
media()Message has any media attachment
photo()Message contains a photo
document()Message contains a document / file
forwarded()Message was forwarded
reply()Message is a reply to another message
album()Message is part of a media album
any_command()Message starts with any /command
command("start")Message is exactly the /start command
text_contains("word")Message text contains the substring
text_starts_with("!")Message text starts with the prefix
from_user(user_id)Message was sent by a specific user ID
in_chat(chat_id)Message is from a specific chat ID
`custom(msg

Combining filters

#![allow(unused)]
fn main() {
// Both conditions must be true
let f = filters::private() & filters::text();

// Either condition must be true
let f = filters::command("help") | filters::command("start");

// Negate a filter
let f = !filters::forwarded();

// Complex expressions
let f = (filters::group() | filters::channel()) & !filters::forwarded();
}

Dispatcher methods

MethodDescription
dp.on_message(filter, handler)Register a handler for new messages
dp.on_edit(filter, handler)Register a handler for edited messages
dp.on_message_fsm(filter, state, handler)FSM-gated message handler (see FSM)
dp.on_edit_fsm(filter, state, handler)FSM-gated edit handler
dp.middleware(mw)Prepend middleware to the chain
dp.with_state_storage(storage)Set the FSM storage backend
dp.with_key_strategy(strategy)Set the FSM key strategy
dp.include(router)Mount a Router sub-tree
dp.dispatch(update).awaitRoute an update through the chain

Routers

Router lets you split a large bot into feature modules with their own handlers and optional scoped filters.

#![allow(unused)]
fn main() {
use ferogram::filters::{Router, command, private};

pub fn admin_router() -> Router {
    let mut r = Router::new();
    // Only handle /ban and /kick from private chats
    r.scope(private());
    r.on_message(command("ban"),  handle_ban);
    r.on_message(command("kick"), handle_kick);
    r
}

// In main:
dp.include(admin_router());
}

scope(filter) adds a guard that runs before any handler in the router. If the filter does not match, the router is skipped entirely.


Middleware

Middleware intercepts every Update before it reaches a handler. Common use cases include logging, rate-limiting, authentication, and metrics.

Implementing Middleware

#![allow(unused)]
fn main() {
use ferogram::middleware::{Middleware, Next, BoxFuture, DispatchResult};
use ferogram::update::Update;

struct LoggingMiddleware;

impl Middleware for LoggingMiddleware {
    fn call(&self, update: Update, next: Next) -> BoxFuture {
        Box::pin(async move {
            println!("Update: {:?}", update);
            let result = next.run(update).await;
            if let Err(ref e) = result {
                println!("Handler error: {e}");
            }
            result
        })
    }
}
}

Registering middleware

#![allow(unused)]
fn main() {
dp.middleware(LoggingMiddleware);
}

Middleware runs in registration order: the first middleware() call wraps the outermost layer.

Next

Next represents the remainder of the chain. Call next.run(update).await to pass control forward. If you do not call it, all remaining middleware and the handler are skipped.

#![allow(unused)]
fn main() {
impl Middleware for AuthMiddleware {
    fn call(&self, update: Update, next: Next) -> BoxFuture {
        Box::pin(async move {
            if let Update::NewMessage(ref msg) = update {
                if !is_allowed(msg.sender_user_id()) {
                    return Ok(()); // block: do not call next
                }
            }
            next.run(update).await
        })
    }
}
}

Closure middleware

For simple cases, pass a closure directly via the middleware_fn helper (if available) or wrap in a newtype:

#![allow(unused)]
fn main() {
struct TimerMiddleware;

impl Middleware for TimerMiddleware {
    fn call(&self, update: Update, next: Next) -> BoxFuture {
        Box::pin(async move {
            let start = std::time::Instant::now();
            let result = next.run(update).await;
            println!("Handled in {:?}", start.elapsed());
            result
        })
    }
}

dp.middleware(TimerMiddleware);
}

Full example: rate-limiter middleware

#![allow(unused)]
fn main() {
use dashmap::DashMap;
use ferogram::middleware::{BoxFuture, DispatchResult, Middleware, Next};
use ferogram::update::Update;
use std::sync::Arc;
use std::time::{Duration, Instant};

struct RateLimiter {
    last_seen: Arc<DashMap<i64, Instant>>,
    period: Duration,
}

impl RateLimiter {
    fn new(period: Duration) -> Self {
        Self { last_seen: Arc::new(DashMap::new()), period }
    }
}

impl Middleware for RateLimiter {
    fn call(&self, update: Update, next: Next) -> BoxFuture {
        let last_seen = self.last_seen.clone();
        let period = self.period;
        Box::pin(async move {
            if let Update::NewMessage(ref msg) = update {
                if let Some(uid) = msg.sender_user_id() {
                    let now = Instant::now();
                    if let Some(t) = last_seen.get(&uid) {
                        if now.duration_since(*t) < period {
                            return Ok(()); // drop: too fast
                        }
                    }
                    last_seen.insert(uid, now);
                }
            }
            next.run(update).await
        })
    }
}

// Register: limit each user to one message per second
dp.middleware(RateLimiter::new(Duration::from_secs(1)));
}

Handler signatures

All handlers must be async fn or closures returning a Future:

#![allow(unused)]
fn main() {
// No arguments (ignores the message)
dp.on_message(filters::command("ping"), || async {
    // nothing
});

// Message only
dp.on_message(filters::text(), |msg| async move {
    println!("{:?}", msg.text());
});
}

The dispatcher calls handlers with msg.clone() so handlers can be registered multiple times. Handlers do not need to return a value; errors should be handled internally (e.g. .ok() on send results).

Finite State Machine (FSM)

The FSM module lets bots track per-user conversation state across multiple messages without managing a global HashMap manually. State is stored in a pluggable StateStorage backend and keyed by user ID + chat ID.


Quick start

Add the derive feature to your Cargo.toml:

ferogram = { version = "0.3", features = ["derive"] }

Define a state enum and derive FsmState:

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

#[derive(FsmState, Clone, Debug, PartialEq)]
enum OrderState {
    AwaitingProduct,
    AwaitingQuantity,
    AwaitingConfirmation,
}
}

Wire it into the dispatcher:

use std::sync::Arc;
use ferogram::{Client, filters, fsm::MemoryStorage};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let (client, _sd) = Client::builder()
        .api_id(12345)
        .api_hash("your_hash")
        .session("bot.session")
        .connect()
        .await?;

    client.bot_sign_in("TOKEN").await?;
    client.save_session().await?;

    let mut dp = filters::Dispatcher::new();
    dp.with_state_storage(Arc::new(MemoryStorage::new()));

    // Entry point - no active state yet
    dp.on_message(filters::command("order"), |msg| async move {
        msg.reply("What product would you like?").await.ok();
    });

    // Triggered only when the user is in AwaitingProduct
    dp.on_message_fsm(filters::text(), OrderState::AwaitingProduct, |msg, state| async move {
        let product = msg.text().unwrap_or_default().to_string();
        state.set_data("product", &product).await.ok();
        state.transition(OrderState::AwaitingQuantity).await.ok();
        msg.reply("How many?").await.ok();
    });

    dp.on_message_fsm(filters::text(), OrderState::AwaitingQuantity, |msg, state| async move {
        let qty = msg.text().unwrap_or_default().to_string();
        let product: String = state.get_data("product").await.unwrap_or_default();
        state.transition(OrderState::AwaitingConfirmation).await.ok();
        msg.reply(format!("Order {} × {}. Confirm? (yes/no)", qty, product)).await.ok();
    });

    dp.on_message_fsm(filters::text(), OrderState::AwaitingConfirmation, |msg, state| async move {
        match msg.text().unwrap_or("").trim() {
            "yes" => {
                state.clear_state().await.ok();
                msg.reply("Order placed!").await.ok();
            }
            _ => {
                state.clear_state().await.ok();
                msg.reply("Order cancelled.").await.ok();
            }
        }
    });

    let mut stream = client.stream_updates();
    while let Some(upd) = stream.next().await {
        dp.dispatch(upd).await;
    }
    Ok(())
}

FsmState trait

Any enum that derives FsmState can be used as a state discriminant:

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

#[derive(FsmState, Clone, Debug, PartialEq)]
enum Form {
    Name,
    Email,
    Done,
}
}

You can also implement FsmState manually if you need custom serialization:

#![allow(unused)]
fn main() {
impl ferogram::fsm::FsmState for Form {
    fn as_key(&self) -> String {
        format!("{:?}", self)
    }
    fn from_key(key: &str) -> Option<Self> {
        match key {
            "Name" => Some(Self::Name),
            "Email" => Some(Self::Email),
            "Done" => Some(Self::Done),
            _ => None,
        }
    }
}
}

StateContext

Handler functions registered via on_message_fsm receive a StateContext as the second argument. It provides the following methods:

MethodDescription
state.transition(new_state).awaitMove to a new FSM state
state.clear_state().awaitReset to no state (end the flow)
state.set_data(field, value).awaitStore a serializable value
state.get_data::<T>(field).awaitRetrieve a stored value
state.get_all_data().awaitAll stored data as HashMap<String, Value>
state.clear_data().awaitDelete all data, keep current state
state.clear_all().awaitDelete state and all associated data
state.key()Inspect the active StateKey

set_data / get_data use serde_json internally, so any Serialize + DeserializeOwned type works.


State key strategies

By default, state is tracked per user per chat. Change the strategy on the dispatcher:

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

dp.with_key_strategy(StateKeyStrategy::PerUser);    // one session per user across all chats
dp.with_key_strategy(StateKeyStrategy::PerChat);    // shared state per chat (e.g. group games)
dp.with_key_strategy(StateKeyStrategy::PerUserPerChat); // default
}

Storage backends

MemoryStorage (default for testing)

In-process DashMap-backed storage. State is lost on restart.

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

dp.with_state_storage(Arc::new(MemoryStorage::new()));
}

Custom backend

Implement the StateStorage trait to persist state in Redis, a database, or any store:

#![allow(unused)]
fn main() {
use ferogram::fsm::{StateStorage, StateKey, StorageError};
use async_trait::async_trait;

struct RedisStorage { /* ... */ }

#[async_trait]
impl StateStorage for RedisStorage {
    async fn get_state(&self, key: &StateKey) -> Result<Option<String>, StorageError> { todo!() }
    async fn set_state(&self, key: &StateKey, state: &str) -> Result<(), StorageError> { todo!() }
    async fn clear_state(&self, key: &StateKey) -> Result<(), StorageError> { todo!() }
    async fn get_data(&self, key: &StateKey, field: &str) -> Result<Option<String>, StorageError> { todo!() }
    async fn set_data(&self, key: &StateKey, field: &str, value: &str) -> Result<(), StorageError> { todo!() }
    async fn clear_data(&self, key: &StateKey, field: &str) -> Result<(), StorageError> { todo!() }
    async fn clear_all_data(&self, key: &StateKey) -> Result<(), StorageError> { todo!() }
}
}

Handler signature

FSM handlers take two arguments: the message and the state context:

#![allow(unused)]
fn main() {
dp.on_message_fsm(filter, MyState::SomeVariant, |msg, state| async move {
    // msg  : ferogram::update::IncomingMessage
    // state: ferogram::fsm::StateContext
});
}

For edited messages use on_edit_fsm with the same signature.


Routers with FSM

Routers support FSM handlers too, which is useful for splitting a bot into feature modules:

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

fn order_router() -> Router {
    let mut r = Router::new();
    r.on_message_fsm(filters::text(), OrderState::AwaitingProduct, handle_product);
    r.on_message_fsm(filters::text(), OrderState::AwaitingQuantity, handle_qty);
    r
}

// Then include in the dispatcher:
dp.include(order_router());
}

Conversation API

The Conversation type provides a high-level, blocking-style interface for multi-step bot flows: send a question, wait for the answer, send the next question, and so on - all within a single async fn, without manually tracking state.

Conversation wraps a mutable reference to an UpdateStream and filters updates to a single peer. Updates from other peers are buffered internally and can be retrieved with drain_buffered().

Quick start

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

// In a handler or task that already owns the update stream:
let mut conv = Conversation::new(&client, &mut stream, "@username").await?;

conv.ask("What is your name?").await?;
let name_msg = conv.get_response(Duration::from_secs(60)).await?;
let name = name_msg.text().unwrap_or("unknown").to_string();

conv.ask(format!("Nice to meet you, {}! How old are you?", name)).await?;
let age_msg = conv.get_response(Duration::from_secs(60)).await?;
let age = age_msg.text().unwrap_or("?").to_string();

conv.respond(format!("Got it: {} is {} years old.", name, age)).await?;
}

Creating a Conversation

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

let mut conv = Conversation::new(&client, &mut stream, peer).await?;
}

peer accepts anything that implements Into<PeerRef>: a &str username, a numeric ID, or a resolved tl::enums::Peer.


Sending messages

MethodDescription
conv.ask(text).awaitSend a message and return the sent IncomingMessage
conv.respond(text).awaitAlias for ask

Both accept any Into<String>.


Waiting for responses

MethodDescription
conv.get_response(deadline).awaitWait for the next message from the peer
conv.wait_click(deadline).awaitWait for the peer to press an inline button
conv.wait_read(deadline).awaitWait until messages are read (any non-message update from peer)
conv.ask_and_wait(text, deadline).awaitSend a message and immediately wait for the reply

deadline is a std::time::Duration. If no response arrives within the deadline, the method returns ConversationError::Timeout.

Non-matching updates (from other peers, or update types other than NewMessage / CallbackQuery) are buffered. Retrieve them with:

#![allow(unused)]
fn main() {
let leftover: Vec<Update> = conv.drain_buffered();
}

Error handling

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

match conv.get_response(Duration::from_secs(30)).await {
    Ok(msg) => { /* process msg */ }
    Err(ConversationError::Timeout(d)) => {
        conv.respond("You took too long! Please try again.").await.ok();
    }
    Err(ConversationError::StreamClosed) => { /* bot is shutting down */ }
    Err(ConversationError::Invocation(e)) => { return Err(e.into()); }
}
}

Button interaction example

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

let mut conv = Conversation::new(&client, &mut stream, peer).await?;

let kb = InlineKeyboard::new()
    .row([
        Button::callback("✅ Yes", b"yes"),
        Button::callback("❌ No",  b"no"),
    ]);

conv.ask(InputMessage::text("Confirm your order?").keyboard(kb)).await?;

let click = conv.wait_click(Duration::from_secs(120)).await?;
match click.data().unwrap_or("") {
    "yes" => conv.respond("Order confirmed!").await?,
    _     => conv.respond("Order cancelled.").await?,
};
}

Integrating with the dispatcher

Because Conversation borrows &mut UpdateStream exclusively, you typically use it in a handler that was given the stream, or spin up a dedicated task:

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

// Give one user their own conversation task
let stream = Arc::new(Mutex::new(client.stream_updates()));

// … in a handler triggered by /start:
let client2 = client.clone();
let stream2 = stream.clone();
let user_peer = msg.peer_id().cloned().unwrap();

tokio::spawn(async move {
    let mut locked = stream2.lock().await;
    if let Ok(mut conv) = Conversation::new(&client2, &mut locked, user_peer).await {
        run_onboarding(&mut conv).await.ok();
    }
});
}

For multi-user bots, the FSM approach is usually a better fit - see FSM. Use Conversation when the flow is short and you need the simplicity of sequential await calls.


Full example: simple registration flow

#![allow(unused)]
fn main() {
use std::time::Duration;
use ferogram::conversation::{Conversation, ConversationError};

async fn registration_flow(
    client: &ferogram::Client,
    stream: &mut ferogram::UpdateStream,
    peer: ferogram::tl::enums::Peer,
) -> Result<(), ConversationError> {
    let mut conv = Conversation::new(client, stream, peer).await?;
    let timeout = Duration::from_secs(120);

    conv.ask("Welcome! Please enter your first name:").await?;
    let first = conv.get_response(timeout).await?;
    let first_name = first.text().unwrap_or("").trim().to_string();
    if first_name.is_empty() {
        conv.respond("Name cannot be empty. Please /start again.").await?;
        return Ok(());
    }

    conv.ask("Great! Now your email address:").await?;
    let email_msg = conv.get_response(timeout).await?;
    let email = email_msg.text().unwrap_or("").trim().to_string();

    conv.respond(format!(
        "Registered: {} <{}>. Welcome aboard!", first_name, email
    )).await?;

    Ok(())
}
}

Client Methods: Full Reference

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


Connection & Session

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

Authentication

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

Updates

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

Messaging

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

Reactions

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

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

async client.get_reactions(peer: impl Into<PeerRef>, msg_ids: Vec<i32>) → Result<(), InvocationError>
Trigger a server push of the current reaction counters for the given message IDs. The server responds with updateMessageReactions updates in the stream.
async client.iter_reaction_users(peer: impl Into<PeerRef>, msg_id: i32, reaction: Option<tl::enums::Reaction>, limit: i32, offset: Option<String>) → Result<tl::types::messages::MessageReactionsList, InvocationError>
Get the list of users who reacted to a message. Pass reaction = None for all reactions. limit max 100; use offset from the previous response to paginate.
async client.send_paid_reaction(peer: impl Into<PeerRef>, msg_id: i32, count: i32) → Result<(), InvocationError>
Send a paid (Stars) reaction to a message. count is the number of Stars to spend.
async client.read_reactions(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Mark all unread reactions in a chat as seen (clears reaction badges).
async client.clear_recent_reactions() → Result<(), InvocationError>
Clear the recent reactions list shown in the reaction picker.

Sending chat actions

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

sync client.search(peer: impl Into<PeerRef>, query: &str) → SearchBuilder
Returns a SearchBuilder for per-peer message search with date filters, sender filter, media type filter, and pagination.
sync client.search_global_builder(query: &str) → GlobalSearchBuilder
Returns a GlobalSearchBuilder for searching across all dialogs simultaneously.
async client.search(peer: impl Into<PeerRef>) → SearchBuilder
Build a message search for a peer. Chain .query(), .limit(), .filter(), etc., then call .collect(&client).await? to run it. For a one-shot query, use client.search().query("text").collect(&client).await?.
async client.search_global(query: &str, limit: i32) → Result<Vec<IncomingMessage>, InvocationError>
Simple one-shot global search. For advanced options use client.search_global_builder().

Dialogs & History

async client.get_dialogs(limit: i32) → Result<Vec<Dialog>, InvocationError>
Fetch the most recent limit dialogs. Each Dialog has title(), peer(), unread_count(), and top_message().
sync client.iter_dialogs() → DialogIter
Lazy iterator that pages through all dialogs automatically. Call iter.next(&client).await?. iter.total() returns the server-reported count after the first page.
sync client.iter_messages(peer: impl Into<PeerRef>) → MessageIter
Lazy iterator over the full message history of a peer, newest first. Call iter.next(&client).await?.
async client.get_message_history(peer: impl Into<PeerRef>, limit: i32, offset_id: i32) → Result<Vec<IncomingMessage>, InvocationError>
Fetch a page of messages. Pass the lowest message ID from the previous page as offset_id to paginate.
async client.mark_read(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Mark all messages in a dialog as read.
async client.clear_mentions(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Clear unread @mention badges in a chat.
async client.delete_dialog(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Delete a dialog from the account's dialog list (does not delete messages for others).
async client.delete_chat_history(peer: impl Into<PeerRef>, max_id: i32, revoke: bool) → Result<(), InvocationError>
Delete message history up to max_id. Pass max_id = 0 to delete everything. revoke = true also removes messages for all other participants (requires admin rights in channels).
async client.pin_dialog(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Pin a dialog to the top of the dialog list.
async client.pin_dialog(peer: impl Into<PeerRef>, pin: bool) → Result<(), InvocationError>
Unpin a previously pinned dialog.
async client.get_pinned_dialogs(folder_id: i32) → Result<Vec<tl::enums::Dialog>, InvocationError>
Fetch all pinned dialogs in a folder. Use folder_id = 0 for the main list, 1 for the archive.
async client.mark_dialog_unread(peer: impl Into<PeerRef>, unread: bool) → Result<(), InvocationError>
Manually mark a dialog as unread (true) or read (false). This sets the unread dot without actually having new messages.

Scheduled messages

async client.get_scheduled_messages(peer: impl Into<PeerRef>) → Result<Vec<IncomingMessage>, InvocationError>
Fetch all messages currently scheduled in a chat.
async client.delete_scheduled_messages(peer: impl Into<PeerRef>, ids: Vec<i32>) → Result<(), InvocationError>
Cancel and delete scheduled messages by their scheduled message IDs.
async client.send_scheduled_now(peer: impl Into<PeerRef>, ids: Vec<i32>) → Result<(), InvocationError>
Immediately deliver one or more scheduled messages. ids are the scheduled message IDs returned by get_scheduled_messages.

Participants & Admin

async client.get_participants(peer: impl Into<PeerRef>, limit: i32) → Result<Vec<Participant>, InvocationError>
Fetch members of a chat or channel. Pass limit = 0 for the default server maximum per page. Use iter_participants to lazily page all members.
async client.iter_participants(peer: impl Into<PeerRef>, filter: Option<tl::enums::ChannelParticipantsFilter>, limit: i32) → Result<Vec<Participant>, InvocationError>
Fetch all members of a channel or supergroup, optionally filtered and limited. Pass filter = None and limit = 0 to retrieve all members up to the server default. For basic groups use get_participants.
async client.kick(peer: impl Into<PeerRef>, user_id: i64) → Result<(), InvocationError>
Remove a user from a group, channel, or supergroup.
async client.ban(peer: impl Into<PeerRef>, user_id: i64, until: Option<i32>) → Result<(), InvocationError>
Ban a user from a channel or supergroup. until: None is permanent; Some(ts) bans until that Unix timestamp. To unban, use restrict with an empty BannedRightsBuilder.
async client.set_admin(peer: impl Into<PeerRef>, user_id: i64, rights: AdminRightsBuilder) → Result<(), InvocationError>
Set admin rights. Use AdminRightsBuilder::full_admin() to promote or AdminRightsBuilder::new() to demote.
async client.set_admin(peer: impl Into<PeerRef>, user_id: i64, rights: AdminRightsBuilder) → Result<(), InvocationError>
Promote a user to admin with specified rights. See Admin & Ban Rights.
async client.restrict(peer: impl Into<PeerRef>, user_id: i64, rights: BannedRightsBuilder) → Result<(), InvocationError>
Restrict or ban a user. Pass BannedRightsBuilder::full_ban() to fully ban. See Admin & Ban Rights.
async client.get_profile_photos(peer: impl Into<PeerRef>, limit: i32) → Result<Vec<tl::enums::Photo>, InvocationError>
Fetch a page of a user's profile photos.
async client.iter_profile_photos(peer: impl Into<PeerRef>, chunk_size: i32) → Result<ProfilePhotoIter, InvocationError>
Lazy iterator over all profile photos of a user, fetched in pages of chunk_size. Pass chunk_size = 0 for the default (100). Call iter.next().await? to advance. Only works for users.
async client.search_peer(query: &str) → Result<Vec<tl::enums::Peer>, InvocationError>
Search for a peer (user, group, or channel) by name prefix. Searches contacts, dialogs, and globally. Returns combined results.
async client.get_permissions(peer: impl Into<PeerRef>, user_id: i64) → Result<ParticipantPermissions, InvocationError>
Fetch the effective permissions of a user in a channel or supergroup. See Participants & Members.

Media

async client.upload_file(path: impl AsRef<Path>) → Result<UploadedFile, InvocationError>
Upload a file from disk. Stats the file for optimal part sizing; uses parallel workers and saveBigFilePart for files over 10 MB automatically. Returns an UploadedFile.
let data = std::fs::read("photo.jpg")?;
let uploaded = client.upload_file("/tmp/photo.jpg").await?;
let media = uploaded.as_photo_media();
async client.send_file(peer: tl::enums::Peer, media: tl::enums::InputMedia, caption: &str) → Result<(), InvocationError>
Send an uploaded file as a photo or document. caption is the message text shown below the media; pass "" for no caption (it is not Option).
async client.send_album(peer: tl::enums::Peer, items: Vec<AlbumItem>) → Result<(), InvocationError>
Send 2-10 media items as a grouped album. Each AlbumItem carries its own caption and optional reply_to. See AlbumItem for builder details.
use ferogram::media::AlbumItem;
client.send_album(peer, vec![
    AlbumItem::new(photo1).caption("First"),
    AlbumItem::new(photo2).caption("Second"),
]).await?;
async client.download(media: &MessageMedia, dest: impl AsyncWrite + Unpin) → Result<u64, InvocationError>
Download a media file and write it to path. The path argument accepts anything that implements AsRef<Path> (e.g. &str, String, PathBuf). DC routing is handled automatically.

Callbacks & Inline

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

Peer resolution

async client.resolve<P: Into<PeerRef>>(peer: P) → Result<tl::enums::Peer, InvocationError>
Resolve any peer reference to a Peer. Accepts all PeerRef input types:
  • &str / String: "@username", "me", "self", numeric string, t.me/ URL, invite link, E.164 phone
  • i64 / i32: Bot-API encoded numeric ID
  • tl::enums::Peer: returned as-is, zero cost
  • tl::enums::InputPeer: access hash cached, then stripped to Peer
Resolution is cache-first; an RPC is only made on a cache miss.
async client.resolve(peer: &str) → Result<tl::enums::Peer, InvocationError>
String-only variant of resolve(). Accepts "@username", "+phone", "me", numeric string, t.me/ URL, and invite links. Prefer resolve() when the input may not be a string.
async client.resolve_to_input_peer(peer: &tl::enums::Peer) → Result<tl::enums::InputPeer, InvocationError>
Convert a bare Peer to an InputPeer with access hash. Returns an error if the peer has not been seen in a prior API call and the hash is unknown.

Raw API

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

Chat management

async client.join_chat(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Join a group or channel by peer reference.
async client.join_link(link: &str) → Result<(), InvocationError>
Accept a t.me/+hash or t.me/joinchat/hash invite link.
async client.create_group(title: impl Into<String>, user_ids: Vec<i64>) → Result<tl::enums::Chat, InvocationError>
Create a new basic group with the given title and initial member list. Returns the created Chat. Basic groups support up to 200 members; migrate to supergroup with migrate_chat if you need more.
async client.create_channel(title: impl Into<String>, about: impl Into<String>, broadcast: bool) → Result<tl::enums::Chat, InvocationError>
Create a new channel (broadcast = true) or supergroup (broadcast = false). Returns the created Chat.
async client.delete_chat(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Permanently delete a channel or supergroup. Only the creator can do this. Irreversible.
async client.delete_chat(chat_id: i64) → Result<(), InvocationError>
Delete a legacy basic group by its chat ID. Only the creator can do this. Use delete_chat for all peer types.
async client.leave_chat(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Leave a channel or supergroup. For basic groups, use kick on yourself or delete_dialog to just hide it.
async client.set_profile(peer).title(: impl Into<PeerRef>, title: impl Into<String>) → Result<(), InvocationError>
Rename a chat, group, channel, or supergroup. Works for both basic groups and channels/supergroups.
async client.set_profile(peer).bio(: impl Into<PeerRef>, about: impl Into<String>) → Result<(), InvocationError>
Set or update the description/about text for any chat type.
async client.set_profile(peer).chat_photo(peer: impl Into<PeerRef>, photo: tl::enums::InputChatPhoto) → Result<(), InvocationError>
Change the profile photo of a chat. Pass tl::enums::InputChatPhoto::Empty to remove the current photo.
async client.edit_chat_default_banned_rights(peer: impl Into<PeerRef>, build: impl FnOnce(BannedRightsBuilder) → BannedRightsBuilder) → Result<(), InvocationError>
Set default permissions for all members of a group or channel via a closure:
// Disable media and polls for everyone
client.edit_chat_default_banned_rights(peer, |b| {
    b.send_media(true).send_polls(true)
}).await?;
async client.get_chat_full(peer: impl Into<PeerRef>) → Result<tl::enums::messages::ChatFull, InvocationError>
Get the full info object for any chat type. Contains full description, pinned message ID, linked channel, member count, slow mode, and more.
async client.migrate_chat(chat_id: i64) → Result<tl::enums::Chat, InvocationError>
Upgrade a legacy basic group to a supergroup. Returns the new channel peer. The original chat ID becomes invalid after migration.
async client.invite_users(peer: impl Into<PeerRef>, user_ids: Vec<i64>) → Result<(), InvocationError>
Add one or more users to a chat. For channels all users are added in one request; for basic groups each user is added individually.
async client.set_history_ttl(peer: impl Into<PeerRef>, period: i32) → Result<(), InvocationError>
Set the auto-delete timer for a chat. period is in seconds. Common values: 86400 (1 day), 604800 (1 week), 2678400 (1 month). Pass 0 to disable.
async client.get_common_chats(user_id: i64, max_id: i64, limit: i32) → Result<Vec<tl::enums::Chat>, InvocationError>
Get chats shared between the logged-in account and user_id. Pass max_id = 0 for the first page. Max limit is 100.
async client.get_online_count(peer: impl Into<PeerRef>) → Result<i32, InvocationError>
Get the approximate number of members currently online in a group or channel.
async client.toggle_no_forwards(peer: impl Into<PeerRef>, enabled: bool) → Result<(), InvocationError>
Enable or disable the no-forwards restriction. When enabled, members cannot forward messages out of this chat.
sync Client::parse_invite_hash(link: &str) → Option<&str>
Parse the invite hash out of any t.me/+… or t.me/joinchat/… link format. Returns None if the link is not a valid invite.
async client.set_chat_theme(peer: impl Into<PeerRef>, emoticon: impl Into<String>) → Result<(), InvocationError>
Set the emoji-based colour theme for a chat. Pass a single emoji string such as "🌈" or "❄️" to apply a theme, or an empty string to reset to default.
async client.set_chat_reactions(peer: impl Into<PeerRef>, reactions: tl::enums::ChatReactions) → Result<(), InvocationError>
Control which reactions are allowed in a chat. Use ChatReactionsAll, ChatReactionsSome, or ChatReactionsNone.
// Allow all (including custom emoji)
client.set_chat_reactions(peer.clone(),
    tl::enums::ChatReactions::ChatReactionsAll(
        tl::types::ChatReactionsAll { allow_custom: true }
    )
).await?;

// Disable all reactions client.set_chat_reactions(peer.clone(), tl::enums::ChatReactions::ChatReactionsNone ).await?;

async client.toggle_forum(peer: impl Into<PeerRef>, enabled: bool) → Result<(), InvocationError>
Enable or disable forum (topics) mode on a supergroup. Requires channel admin rights. Once enabled the group gains a General topic (ID 1) automatically. See Forum Topics for full topic management.

Advanced Messaging

async client.forward_messages_returning(destination: impl Into<PeerRef>, message_ids: &[i32], source: impl Into<PeerRef>) → Result<Vec<IncomingMessage>, InvocationError>
Like forward_messages but returns the newly created message objects in the destination chat. Useful when you need the forwarded message IDs immediately.
async msg.get_reply() → Option<&IncomingMessage>
Access the message this message replies to. Available directly on IncomingMessage. Returns None if not a reply. To fetch the replied-to message by ID, use client.get_messages(peer, &[id]).
async client.get_users_by_id(ids: &[i64]) → Result<Vec<Option<User>>, InvocationError>
Bulk-fetch typed User objects by their IDs. The result is in the same order as ids; entries are None for deleted/unknown users.
async client.get_user_full(user_id: i64) → Result<tl::types::UserFull, InvocationError>
Get the full info object for a user. Contains bio, common chats count, bot info, profile/fallback photos, privacy settings, pinned message ID, and more.
async client.get_pinned_message(peer: impl Into<PeerRef>) → Result<Option<IncomingMessage>, InvocationError>
Fetch the currently pinned message in a chat. Returns None if nothing is pinned.
async client.pin_message(peer: impl Into<PeerRef>, message_id: i32, silent: bool, unpin: bool, pm_oneside: bool) → Result<(), InvocationError>
Pin (or unpin) a message. Set silent = true to avoid a pin notification. pm_oneside = true pins only for the logged-in user in DMs.
async client.pin_message(peer: impl Into<PeerRef>, id: i32, pin: bool) → Result<(), InvocationError>
Shorthand for unpinning a specific message (calls pin_message with unpin = true).
async client.unpin_all_messages(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Unpin every pinned message in a chat at once.
async client.get_message_read_participants(peer: impl Into<PeerRef>, msg_id: i32) → Result<Vec<tl::types::ReadParticipantDate>, InvocationError>
Get the list of users who have read a specific message and the time each read it. Only works in groups; returns an empty list for channels and private chats.
async client.get_replies(peer: impl Into<PeerRef>, msg_id: i32, limit: i32, offset_id: i32) → Result<Vec<IncomingMessage>, InvocationError>
Fetch thread replies under a message. msg_id is the root message ID. Pass offset_id = 0 for the first page; use the lowest ID from the previous page to paginate. Max limit is 100.
async client.get_discussion_message(peer: impl Into<PeerRef>, msg_id: i32) → Result<tl::types::messages::DiscussionMessage, InvocationError>
Get the linked discussion message in the comments group for a channel post. Returns the discussion metadata including unread counts and max-read IDs.
async client.read_discussion(peer: impl Into<PeerRef>, msg_id: i32, read_max_id: i32) → Result<(), InvocationError>
Mark a discussion thread as read up to read_max_id. peer is the channel, msg_id is the root post.
async client.get_web_page_preview(text: impl Into<String>) → Result<tl::enums::MessageMedia, InvocationError>
Generate a link preview for a URL embedded in text. Returns the MessageMedia that Telegram would attach (webpage card, article embed, video thumbnail, etc.).
async client.upload_media(peer: impl Into<PeerRef>, media: tl::enums::InputMedia) → Result<tl::enums::MessageMedia, InvocationError>
Upload a media object to Telegram servers without sending it as a message. The returned MessageMedia can be reused in subsequent send_message calls via InputMessage::copy_media(). Distinct from upload_file - this works with an existing InputMedia.
async client.export_message_link(peer: impl Into<PeerRef>, msg_id: i32, grouped: bool, thread: bool) → Result<String, InvocationError>
Get a t.me/c/… permalink for a message in a channel. grouped = true returns a link to the album group; thread = true links to the discussion thread. Only works for channels (not basic groups).
async client.get_send_as_peers(peer: impl Into<PeerRef>) → Result<Vec<tl::enums::Peer>, InvocationError>
Get the list of identities the logged-in user can send messages as in a chat (e.g. own account, linked anonymous channel). Used to implement "send as channel" / anonymous posting.
async client.set_default_send_as(peer: impl Into<PeerRef>, send_as_peer: impl Into<PeerRef>) → Result<(), InvocationError>
send_as_peer must be one of the peers returned by get_send_as_peers. Sets the default identity for new messages in peer.

Translation & Transcription

async client.translate_messages(peer: impl Into<PeerRef>, msg_ids: Vec<i32>, to_lang: impl Into<String>) → Result<Vec<tl::types::TextWithEntities>, InvocationError>
Translate one or more messages to to_lang (e.g. "en", "ru"). Returns translated text in the same order as msg_ids. Requires Telegram Premium for many language pairs.
async client.transcribe_audio(peer: impl Into<PeerRef>, msg_id: i32) → Result<tl::types::messages::TranscribedAudio, InvocationError>
Request speech-to-text transcription of a voice message or video note. Transcription may be pending on first call; poll again if result.pending == true. Requires Telegram Premium.
async client.toggle_peer_translations(peer: impl Into<PeerRef>, disabled: bool) → Result<(), InvocationError>
Show or hide the "Translate" toolbar button for a chat. disabled = true hides it.

Admin Log

async client.get_admin_log(peer: impl Into<PeerRef>, query: impl Into<String>, limit: i32, max_id: i64, min_id: i64) → Result<Vec<tl::types::ChannelAdminLogEvent>, InvocationError>
Fetch the admin action log for a channel or supergroup. query filters by keyword (pass "" for all events). Max limit is 100. Use max_id / min_id for pagination; pass 0 for both on the first call. Only works for channels/supergroups; returns an error for basic groups.

Drafts

async client.save_draft(peer: impl Into<PeerRef>, text: impl Into<String>) → Result<(), InvocationError>
Save a draft message for a chat. Pass an empty string to clear the current draft.
async client.sync_drafts() → Result<(), InvocationError>
Sync all saved drafts from the server. The server responds with updateDraftMessage updates in the update stream.
async client.clear_all_drafts() → Result<(), InvocationError>
Delete all saved drafts across all chats at once.

ClientBuilder

ClientBuilder is the fluent, type-safe constructor for a Client connection. Obtain one via Client::builder().

Just getting started? Client::quick_connect connects and authenticates in one call with no options to think about. Come back here when you need proxies, custom transports, low memory mode, or anything else the defaults don’t cover.

use ferogram::Client;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let (client, _shutdown) = Client::builder()
        .api_id(12345)
        .api_hash("abc123")
        .session("my.session")
        .catch_up(true)
        .connect()
        .await?;
    Ok(())
}

connect() returns Result<(Client, ShutdownToken), BuilderError>. The BuilderError can be MissingApiId, MissingApiHash, or a network-level Connect(InvocationError).


Common patterns

Most people only need a handful of options. Here are the three most common setups:

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

// 99% of users - just fill in credentials and go
let (client, _) = Client::builder()
    .api_id(API_ID)
    .api_hash(API_HASH)
    .session("bot.session")
    .connect().await?;

// Termux / tiny VPS - running on constrained hardware
let (client, _) = Client::builder()
    .api_id(API_ID)
    .api_hash(API_HASH)
    .session("bot.session")
    .low_memory_mode(true)
    .connect().await?;

// Power user - custom update queue to handle high-traffic bots
use ferogram::update_config::OverflowStrategy;

let (client, _) = Client::builder()
    .api_id(API_ID)
    .api_hash(API_HASH)
    .session("bot.session")
    .update_queue_capacity(512)
    .update_overflow_strategy(OverflowStrategy::DropNewest)
    .connect().await?;
}

The full option reference follows below.


Credentials

sync .api_id(id: i32) → ClientBuilder
Set the Telegram API ID obtained from my.telegram.org. Required - connect() returns BuilderError::MissingApiId if not set.
sync .api_hash(hash: impl Into<String>) → ClientBuilder
Set the Telegram API hash from my.telegram.org. Required - connect() returns BuilderError::MissingApiHash if not set.

Session

Three session backends are available. They are mutually exclusive - the last call wins.

sync .session(path: impl AsRef<Path>) → ClientBuilder
Use a binary file session at path. This is the default backend ("ferogram.session" in the working directory if no session method is called).
.session("mybot.session")
sync .session_string(s: impl Into<String>) → ClientBuilder
Use a portable base64 string session. Pass an empty string to start fresh; the string exported by client.export_session_string() can be injected here directly (e.g. via an environment variable). No file is written to disk.
.session_string(std::env::var("SESSION").unwrap_or_default())
sync .in_memory() → ClientBuilder
Use a non-persistent in-memory session. The session is lost when the process exits. Useful for tests and throwaway scripts.
sync .session_backend(backend: Arc<dyn SessionBackend>) → ClientBuilder
Inject a fully custom SessionBackend implementation. Use this for LibSqlBackend (bundled SQLite, no system dependency) or any custom persistence layer.
#[cfg(feature = "libsql-session")]
use ferogram::LibSqlBackend;
use std::sync::Arc;

.session_backend(Arc::new(LibSqlBackend::new(“my.db”))) Requires the libsql-session feature for LibSqlBackend. See Feature Flags.


Updates

sync .catch_up(enabled: bool) → ClientBuilder
When true, replay missed updates via updates.getDifference immediately after connecting. Useful for bots or userbots that must not miss messages during downtime. Default: false.

Network

sync .dc_addr(addr: impl Into<String>) → ClientBuilder
Override the first DC address. Useful when connecting to a test server.
.dc_addr("149.154.167.40:443")   // production DC 1
.dc_addr("149.154.167.40:80")    // test DC
sync .allow_ipv6(allow: bool) → ClientBuilder
Allow IPv6 DC addresses when resolving the DC table. Default: false.
sync .transport(kind: TransportKind) → ClientBuilder
Choose the MTProto transport framing layer.
VariantDescription
TransportKind::AbridgedSmallest overhead (default)
TransportKind::IntermediateCompatible with more proxies
TransportKind::ObfuscatedDeep-packet-inspection resistant
TransportKind::HttpPlain HTTP wrapping

Note: when using .mtproxy() or .proxy_link(), the transport is set automatically from the secret prefix - do not also call .transport().

sync .probe_transport(enabled: bool) → ClientBuilder
Race Obfuscated, Abridged, and HTTP transports in parallel and connect using whichever completes the DH handshake first. The losing attempts are cancelled immediately. Ideal when you don't know which transport your network or firewall permits. Incompatible with MTProxy. Default: false.
#![allow(unused)]
fn main() {
.probe_transport(true)
}

See Transport Probing & Resilient Connect for the race schedule, timing details, and interaction with MTProxy.

sync .resilient_connect(enabled: bool) → ClientBuilder
If direct TCP fails, retry via DNS-over-HTTPS (Mozilla + Google DoH), then fall back to Firebase / Google special-config endpoints. Useful in restricted networks where Telegram DCs are ISP-blocked. Default: false.
#![allow(unused)]
fn main() {
.resilient_connect(true)
}

See Transport Probing & Resilient Connect for the full fallback chain and when to combine with probe_transport.

sync .pfs(enabled: bool) → ClientBuilder
Enable Perfect Forward Secrecy. When set, a temporary DH key bind is performed immediately after the permanent auth key is established. Traffic runs under a short-lived session key derived from that bind; the permanent key is never used to encrypt traffic directly. If the bind RPC fails for any reason, the pool falls back to the standard session without interrupting the connection.

Adds one extra DH round-trip per connection. Off by default. Enable only if your threat model requires it.

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

Proxy

sync .socks5(addr: impl Into<String>) → ClientBuilder
Route all connections through a SOCKS5 proxy. Pass a "host:port" string:
.socks5("127.0.0.1:1080")
sync .mtproxy(proxy: MtProxyConfig) → ClientBuilder
Route all connections through an MTProxy. The transport is automatically selected from the proxy secret prefix; do not also call .transport(). Build with ferogram::parse_proxy_link(url) or construct manually.
sync .proxy_link(url: &str) → ClientBuilder
Set an MTProxy from a https://t.me/proxy?... or tg://proxy?... link. An empty string is a no-op. Transport is selected from the secret prefix automatically.
.proxy_link("https://t.me/proxy?server=1.2.3.4&port=443&secret=abc123")
See Proxies & Transports for full details.

Identity (InitConnection)

These strings are sent to Telegram in the InitConnection call and appear in the active sessions list on my.telegram.org.

sync .device_model(model: impl Into<String>) → ClientBuilder
Device model shown in sessions. Default: "Linux".
Example: .device_model("Pixel 9 Pro")
sync .system_version(version: impl Into<String>) → ClientBuilder
OS / system version shown in sessions. Default: "1.0".
Example: .system_version("Android 15")
sync .app_version(version: impl Into<String>) → ClientBuilder
App version shown in sessions. Default: the crate version from CARGO_PKG_VERSION.
sync .lang_code(code: impl Into<String>) → ClientBuilder
BCP-47 language code sent in InitConnection. Default: "en".
sync .system_lang_code(code: impl Into<String>) → ClientBuilder
System language code. Default: "en".
sync .lang_pack(pack: impl Into<String>) → ClientBuilder
Language pack name. Default: "" (empty). Leave unset unless building a client that mirrors an official Telegram app.

Retry & Restart

sync .retry_policy(policy: Arc<dyn RetryPolicy>) → ClientBuilder
Override the flood-wait / rate-limit retry strategy. The default is AutoSleep: automatically sleep for FLOOD_WAIT durations. See Retry & Flood Wait.
sync .restart_policy(policy: Arc<dyn ConnectionRestartPolicy>) → ClientBuilder
Override the reconnect behaviour after a connection drop. The default is NeverRestart: the event loop exits on disconnect and the shutdown signal fires. Use FixedInterval for automatic reconnection, or implement the trait for custom backoff logic.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::time::Duration;
use ferogram::FixedInterval;

// Reconnect 5 seconds after any drop
.restart_policy(Arc::new(FixedInterval {
    interval: Duration::from_secs(5),
}))
}

See Connection Restart Policy for all built-in types, custom implementation, and scheduled periodic restarts.


Experimental Features

sync .experimental_features(features: ExperimentalFeatures) → ClientBuilder
Opt in to experimental behaviours that deviate from strict Telegram spec. All flags default to false. Always use ..Default::default() to stay forward-compatible with new flags.
#![allow(unused)]
fn main() {
use ferogram::{Client, ExperimentalFeatures};

Client::builder()
    .api_id(12345)
    .api_hash("abc")
    .experimental_features(ExperimentalFeatures {
        allow_zero_hash: true,  // bots only: allow hash=0 on cache miss
        ..Default::default()
    })
    .connect()
    .await?;
}

See Experimental Features for all flags, safety constraints, and when to use each one.


Terminal Methods

sync .build() → Result<Config, BuilderError>
Build the Config struct without establishing a network connection. Useful if you want to pass the Config to Client::connect(config) manually, or to inspect the built configuration.
async .connect() → Result<(Client, ShutdownToken), BuilderError>
Build the Config and connect in one step. Returns Err(BuilderError::MissingApiId) or Err(BuilderError::MissingApiHash) before attempting any network I/O if required fields are absent.

BuilderError

VariantMeaning
BuilderError::MissingApiId.api_id() was not called (or set to 0)
BuilderError::MissingApiHash.api_hash() was not called (or left empty)
BuilderError::Connect(InvocationError)Network / MTProto connection failed
#![allow(unused)]
fn main() {
match client_result {
    Err(BuilderError::MissingApiId) => eprintln!("Set API_ID"),
    Err(BuilderError::MissingApiHash) => eprintln!("Set API_HASH"),
    Err(BuilderError::Connect(e)) => eprintln!("Network error: {e}"),
    Ok((client, _)) => { /* use client */ }
}
}

quick_connect

Client::quick_connect connects and authenticates in a single call, handling the full auth flow interactively from stdin. If the session is already authorized the prompt is skipped entirely.

For advanced options (proxy, PFS, custom transport, catch-up, etc.) use ClientBuilder directly.


Signature

#![allow(unused)]
fn main() {
pub async fn quick_connect(
    session: impl AsRef<Path>,
    api_id: i32,
    api_hash: &str,
) -> Result<(Client, ShutdownToken), QuickConnectError>
}

Usage

use ferogram::Client;

const API_ID: i32 = 12345;
const API_HASH: &str = "your_api_hash";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (client, _shutdown) = Client::quick_connect("my.session", API_ID, API_HASH).await?;
    // client is ready to use
    Ok(())
}

When run, the prompt sequence looks like this:

  • Already signed in: no prompt, returns immediately.
  • User account: asks for phone number, then login code, then 2FA password if required.
  • Bot: paste a bot token (123456789:AABBcc...) instead of a phone number.

The bot token is detected automatically by its <digits>:<string> format, so the same prompt works for both users and bots.


Error Handling

QuickConnectError covers every failure mode:

VariantWhen it fires
Builder(BuilderError)ClientBuilder::connect failed (bad credentials or network error)
Auth(InvocationError)An MTProto RPC call during auth failed
InvalidCodeWrong login code entered
SignUpRequiredPhone number not registered on Telegram
Io(std::io::Error)Failed to read from stdin
#![allow(unused)]
fn main() {
use ferogram::client::QuickConnectError;

match Client::quick_connect("my.session", API_ID, API_HASH).await {
    Ok((client, _)) => { /* use client */ }
    Err(QuickConnectError::InvalidCode) => eprintln!("Wrong code, try again"),
    Err(QuickConnectError::SignUpRequired) => eprintln!("Phone not registered"),
    Err(QuickConnectError::Auth(e)) => eprintln!("Auth error: {e}"),
    Err(e) => eprintln!("Connect failed: {e}"),
}
}

quick_connect vs ClientBuilder

quick_connect and ClientBuilder connect to the same underlying transport. The difference is how much control you want.

#![allow(unused)]
fn main() {
// 99% of users - just works
let (client, _) = Client::quick_connect("bot.session", API_ID, API_HASH).await?;

// Termux / tiny VPS - memory-constrained environment
let (client, _) = Client::builder()
    .api_id(API_ID)
    .api_hash(API_HASH)
    .session("bot.session")
    .low_memory_mode(true)
    .connect().await?;

// Power user with specific needs
let (client, _) = Client::builder()
    .api_id(API_ID)
    .api_hash(API_HASH)
    .session("bot.session")
    .update_queue_capacity(512)
    .update_overflow_strategy(OverflowStrategy::DropNewest)
    .connect().await?;
}

quick_connect is ClientBuilder with sensible defaults baked in and the auth flow handled for you. If you start with quick_connect and later need an option it doesn’t expose, switching to ClientBuilder is a straight drop-in: same session file, same API.


When to use ClientBuilder instead

quick_connect is intentionally minimal. Reach for ClientBuilder when you need any of the following:

  • SOCKS5 or MTProxy
  • Perfect Forward Secrecy (.pfs(true))
  • Transport probing or resilient connect
  • Custom session backend (e.g. LibSqlBackend)
  • Catch-up on missed updates (.catch_up(true))
  • Custom retry or reconnect policy
  • Low memory mode (.low_memory_mode(true))
  • Non-interactive auth (reading credentials from env vars or a config file)

Types Reference

ferogram wraps the raw TL layer’s tl::enums::User and tl::enums::Chat variants in typed structs so you never need to pattern-match bare enums.

WrapperUnderlying TL type
Usertl::enums::User (variant User)
Grouptl::types::Chat
Channeltl::types::Channel
ChatUnified enum: Chat::Group or Chat::Channel

All four types are available without any feature flags.


User

User wraps a non-empty tl::enums::User::User variant.

Construction

sync User::from_raw(raw: tl::enums::User) → Option<User>
Returns None for tl::enums::User::Empty, Some(User) otherwise. The raw field is public if you need direct TL access.

Identity

sync user.id() → i64
Telegram user ID. Stable and unique forever.
sync user.access_hash() → Option<i64>
Access hash needed for most API calls targeting this user. May be None for users not in your contact list or not recently seen.
sync user.first_name() → Option<&str>
First name, if set.
sync user.last_name() → Option<&str>
Last name, if set.
sync user.full_name() → String
Concatenates first + last name with a space. Returns an empty string if both are absent.
sync user.username() → Option<&str>
Primary username without the @ prefix.
sync user.usernames() → Vec<&str>
All active usernames (primary + extras for Fragment usernames), without @.
sync user.phone() → Option<&str>
Phone number, if visible to the logged-in account.

Flags

sync user.bot() → bool
true if this is a bot account.
sync user.verified() → bool
true if the account has a blue verification badge.
sync user.premium() → bool
true if the user has Telegram Premium.
sync user.is_self() → bool
true if this is the currently logged-in account.
sync user.deleted() → bool
true if the account has been deleted.
sync user.scam() → bool
true if Telegram has flagged this account as a scam.
sync user.restricted() → bool
true if the account is spam-restricted.
sync user.contact() → bool
true if this user is in the logged-in user's contact list.
sync user.mutual_contact() → bool
true if the logged-in user is also in this user's contact list.
sync user.support() → bool
true if this account belongs to Telegram support staff.

Online & Media

sync user.status() → Option<&tl::enums::UserStatus>
Current online status (Online, Offline, Recently, LastWeek, LastMonth, Empty).
sync user.photo() → Option<&tl::types::UserProfilePhoto>
Profile photo metadata, if set. Use client.iter_profile_photos() to download the actual image.
sync user.lang_code() → Option<&str>
Language code reported by the user's Telegram client.

Bot-specific

sync user.bot_inline_placeholder() → Option<&str>
Placeholder text shown in the compose bar when the user activates this bot's inline mode.
sync user.bot_inline_geo() → bool
true if the bot can be used inline without requiring a location share.
sync user.bot_supports_chats() → bool
true if the bot can be added to groups/channels.

Peer Conversion

sync user.as_peer() → tl::enums::Peer
Convert to a PeerUser for use in API calls.
sync user.as_input_peer() → tl::enums::InputPeer
Convert to an InputPeer. Returns InputPeerUser { user_id, access_hash } when an access hash is available, or InputPeerPeerSelf for the logged-in account.

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


Group

Group wraps a basic group (tl::types::Chat). Basic groups have ≤ 200 members; larger groups are supergroups (use Channel with megagroup() == true).

Construction

sync Group::from_raw(raw: tl::enums::Chat) → Option<Group>
Returns None if the raw value is Empty, Forbidden, Channel, or ChannelForbidden. The raw field (tl::types::Chat) is public for direct TL access.

Accessors

sync group.id() → i64
Group ID.
sync group.title() → &str
Group title.
sync group.participants_count() → i32
Current member count.
sync group.creator() → bool
true if the logged-in user is the creator of this group.
sync group.migrated_to() → Option<&tl::enums::InputChannel>
If the group was upgraded to a supergroup, contains the InputChannel of the new supergroup.
sync group.as_peer() → tl::enums::Peer
Convert to PeerChat.
sync group.as_input_peer() → tl::enums::InputPeer
Convert to InputPeerChat.

Group implements Display as "Title [group id]".


Channel

Channel wraps both broadcast channels and supergroups (tl::types::Channel). Use kind() or megagroup() / broadcast() to distinguish them.

Construction

sync Channel::from_raw(raw: tl::enums::Chat) → Option<Channel>
Returns None for non-channel variants. The raw field (tl::types::Channel) is public.

Identity

sync channel.id() → i64
Channel / supergroup ID.
sync channel.access_hash() → Option<i64>
Access hash required for channel-targeted API calls.
sync channel.title() → &str
Channel / supergroup title.
sync channel.username() → Option<&str>
Primary public username without @, if set.
sync channel.usernames() → Vec<&str>
All active usernames (primary + Fragment extras), without @.
sync channel.participants_count() → Option<i32>
Approximate member count. May be None for private channels.

Kind

sync channel.kind() → ChannelKind
VariantDescription
ChannelKind::BroadcastBroadcast channel (posts only)
ChannelKind::MegagroupSupergroup (all members can post)
ChannelKind::GigagroupLarge broadcast group (gigagroup)
sync channel.megagroup() → bool
true for supergroups.
sync channel.broadcast() → bool
true for broadcast channels.

Flags

sync channel.verified() → bool
true if the channel has a verification badge.
sync channel.restricted() → bool
true if the channel is unavailable in certain regions.
sync channel.signatures() → bool
true if post author signatures are shown in the channel.

Rights & Media

sync channel.admin_rights() → Option<&tl::types::ChatAdminRights>
Admin rights granted to the logged-in user in this channel, if any.
sync channel.photo() → Option<&tl::types::ChatPhoto>
Channel profile photo metadata, if set.
sync channel.restriction_reason() → Vec<&tl::enums::RestrictionReason>
Regional restriction reasons (e.g. country codes where the channel is blocked).

Peer Conversion

sync channel.as_peer() → tl::enums::Peer
Convert to PeerChannel.
sync channel.as_input_peer() → tl::enums::InputPeer
Convert to InputPeerChannel { channel_id, access_hash }. Returns InputPeerEmpty if the access hash is absent.
sync channel.as_input_channel() → tl::enums::InputChannel
Convert to InputChannel for channel-specific RPCs (e.g. channels.GetParticipants). Returns InputChannelEmpty if the access hash is absent.

Channel implements Display as "Title (@username)" or "Title [channel id]".


Chat (unified enum)

Chat is a convenience enum that holds either a Group or a Channel. Most client methods return peers as Chat when the type is not known in advance.

#![allow(unused)]
fn main() {
match chat {
    Chat::Group(g) => println!("Basic group: {}", g.title()),
    Chat::Channel(c) => println!("Channel/supergroup: {}", c.title()),
}
}

Construction

sync Chat::from_raw(raw: tl::enums::Chat) → Option<Chat>
Returns None for Empty, Forbidden, and ChannelForbidden variants.

Common Accessors

sync chat.id() → i64
ID regardless of variant.
sync chat.title() → &str
Title regardless of variant.
sync chat.as_peer() → tl::enums::Peer
Convert to the appropriate Peer variant.
sync chat.as_input_peer() → tl::enums::InputPeer
Convert to the appropriate InputPeer variant.

Quick Reference

#![allow(unused)]
fn main() {
use ferogram::{User, Group, Channel, Chat};

// User
if let Some(user) = User::from_raw(raw_user) {
    println!("{} id={}", user.full_name(), user.id());
    if user.bot() { println!("It's a bot"); }
    let peer = user.as_input_peer(); // for API calls
}

// Group
if let Some(group) = Group::from_raw(raw_chat) {
    println!("{} ({} members)", group.title(), group.participants_count());
}

// Channel / Supergroup
if let Some(ch) = Channel::from_raw(raw_chat) {
    match ch.kind() {
        ferogram::ChannelKind::Broadcast => println!("Channel"),
        ferogram::ChannelKind::Megagroup => println!("Supergroup"),
        ferogram::ChannelKind::Gigagroup => println!("Gigagroup"),
    }
}

// Unified
if let Some(chat) = Chat::from_raw(raw_chat) {
    println!("id={} title={}", chat.id(), chat.title());
}
}

InputMessage Builder

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

Import

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

Builder methods

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

Plain text

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

Markdown formatting

parse_markdown converts Markdown to plain text + entity list:

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

Supported Markdown syntax:

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

Reply to a message

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

With inline keyboard

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

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

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

Silent message (no notification)

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

Scheduled message

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

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

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

Combining everything

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

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

client.send_message(channel_peer, msg).await?;
}

Peer Types

ferogram provides typed wrappers over the raw tl::enums::User and tl::enums::Chat types, and a flexible peer input system that every Client method uses automatically.


Auto-resolution

Every Client method that targets a chat, user, or channel accepts any of the following directly. You never need to pre-resolve anything.

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

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

// E.164 phone number
client.send_message("+12025551234", "hi").await?;

// t.me URL
client.send_message("https://t.me/telegram", "hi").await?;

// Invite link (must already be a member, otherwise call join_link first)
client.send_message("https://t.me/+AbCdEfGhIjKl", "hi").await?;

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

// Negative i64: Bot-API channel ID (-100... prefix)
client.get_message_history(-1001234567890_i64, 50, 0).await?;

// Small negative i64: basic group
client.mark_read(-123456_i64).await?;

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

// Already-resolved InputPeer: hash is used directly
let ip: tl::enums::InputPeer = get_it_from_somewhere();
client.send_message(ip, "hi").await?;
}

Accepted invite link formats: https://t.me/+HASH, https://t.me/joinchat/HASH, tg://join?invite=HASH.

Resolution is cache-first. Usernames, phone numbers, and IDs that have been seen before are resolved from memory with no RPC. An RPC is only made on a genuine cache miss.

Bot-API ID encoding

RangePeer type
id > 0User
-1_000_000_000_000 < id < 0Basic group
id <= -1_000_000_000_000Channel or supergroup

Manual resolution

Use client.resolve() when you need a Peer value explicitly. It accepts all the same input types as every other Client method:

#![allow(unused)]
fn main() {
// &str: username, phone, URL, invite link
let peer = client.resolve("@username").await?;
let peer = client.resolve("+12025551234").await?;
let peer = client.resolve("https://t.me/+HASH").await?;
let peer = client.resolve("me").await?;

// i64 / i32: Bot-API numeric ID
let peer = client.resolve(12345678_i64).await?;
let peer = client.resolve(-1001234567890_i64).await?;

// tl::enums::Peer: zero cost, returned as-is
use ferogram::tl;
let raw = tl::enums::Peer::User(tl::types::PeerUser { user_id: 123 });
let peer = client.resolve(raw).await?;

// tl::enums::InputPeer: hash cached, then stripped to Peer
let ip: tl::enums::InputPeer = get_it_from_somewhere();
let peer = client.resolve(ip).await?;
}

client.resolve(peer: &str) is the string-only variant; use it when the input is always a &str. Use resolve() for everything else.

To go from a Peer back to an InputPeer (with access hash):

#![allow(unused)]
fn main() {
let input = client.resolve_to_input_peer(&peer).await?;
}

This returns an error if the peer has not appeared in any prior API response and the access hash is unknown.

Via PeerRef directly (same result):

#![allow(unused)]
fn main() {
use ferogram::PeerRef;
let peer = PeerRef::from("@username").resolve(&client).await?;
let peer = PeerRef::from(12345678_i64).resolve(&client).await?;
}

User: user account wrapper

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

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

User accessor methods

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

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


Group: basic group wrapper

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

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

Group accessor methods

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

Channel: channel / supergroup wrapper

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

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

Channel accessor methods

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

ChannelKind enum

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

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

Chat: unified chat enum

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

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

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

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

Chat methods

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

PeerExt / OptionPeerExt: extract numeric ID without match

When you have a raw tl::enums::Peer and just need the i64 ID, use the PeerExt trait instead of writing a full match block every time.

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

// Instead of:
let id = match peer {
    tl::enums::Peer::User(u)    => u.user_id,
    tl::enums::Peer::Chat(c)    => c.chat_id,
    tl::enums::Peer::Channel(c) => c.channel_id,
};

// Just write:
let id = peer.bare_id();
}

OptionPeerExt adds the same .bare_id() to Option<&tl::enums::Peer>, which is what IncomingMessage::sender_id() and IncomingMessage::peer_id() return:

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

// sender numeric ID: Option<i64>
let sender: Option<i64> = msg.sender_id().bare_id();

// chat numeric ID: Option<i64>
let chat: Option<i64> = msg.peer_id().bare_id();
}

Note: these are native Telegram IDs, not Bot-API-encoded. A channel with native ID 1234567890 is -1001234567890 in the Bot API. Use PeerRef if you need the Bot-API form.

Import summary

TraitImplemented forMethodReturns
PeerExttl::enums::Peer.bare_id()i64
OptionPeerExtOption<&tl::enums::Peer>.bare_id()Option<i64>

Participants & Members

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


Fetch participants

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

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

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

Paginated iterator (large groups)

#![allow(unused)]
fn main() {
let members = client.iter_participants(peer.clone(), None, 0).await?;
for p in &members {
    println!("{}", p.user.first_name.as_deref().unwrap_or(""));
}
}

Search contacts and dialogs by name

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

Participant fields

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

Kick participant

#![allow(unused)]
fn main() {
// Removes the user from a basic group by chat_id (i64)
// For channels/supergroups, use ban instead
client.kick(chat_id, user_id).await?;
}

Note: kick takes a chat_id: i64, not a PeerRef. Use the raw numeric ID of the basic group.


Ban participant

ban takes a Unix timestamp for until_date. For granular per-permission restrictions use restrict with BannedRightsBuilder.

#![allow(unused)]
fn main() {
// Permanent full ban (until_date = 0)
client.ban(peer.clone(), user_id, None).await?;

// Timed ban: expires in 24 h
let expires = (std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 86400) as i32;
client.ban(peer.clone(), user_id, Some(expires)).await?;

// Unban: set a past timestamp (e.g. 1) to lift the ban
client.ban(peer.clone(), user_id, Some(1)).await?;
}

For per-permission restrictions (no media, no stickers, etc.) use restrict with BannedRightsBuilder:

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

client
    .restrict(
        peer.clone(),
        user_input_peer,
        BannedRightsBuilder::new()
            .send_media(true)
            .send_stickers(true)
            .until_date(expires),
    )
    .await?;
}

BannedRightsBuilder methods

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

Promote admin: set_admin and AdminRightsBuilder

set_admin is a boolean shorthand - true grants all standard rights, false demotes:

#![allow(unused)]
fn main() {
// Quick promote (all standard rights except add_admins)
client.set_admin(peer.clone(), user_id, AdminRightsBuilder::full_admin()).await?;

// Demote back to regular member
client.set_admin(peer.clone(), user_id, AdminRightsBuilder::new()).await?;
}

For fine-grained control, use set_admin with AdminRightsBuilder:

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

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

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

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

AdminRightsBuilder methods

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

Get participant permissions

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

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

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

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

ParticipantPermissions fields & methods

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

Profile photos

#![allow(unused)]
fn main() {
// Fetch a page of profile photos (limit = number to fetch)
let photos = client.get_profile_photos(peer.clone(), 10).await?;

// Lazy iterator across all pages (chunk_size = 0 uses default of 100)
let mut iter = client.iter_profile_photos(peer.clone(), 0).await?;
while let Some(photo) = iter.next().await? {
    // download photo bytes...
}
}

Join and leave chats

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

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

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

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

ParticipantStatus enum

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

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

ProfilePhotoIter: extended methods

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

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

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

Low-level rights setters

For advanced use cases, restrict and set_admin give direct access to the TL layer:

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

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

Admin & Ban Rights

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


BannedRightsBuilder: restrict a member

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

// Permanent full ban
client
    .ban(peer.clone(), user_id, None)
    .await?;

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

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

// Unban
// Passing an empty builder restores full permissions
client
    .ban(peer.clone(), user_id, None)
    .await?;
}

Method reference

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

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


AdminRightsBuilder: grant admin rights

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

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

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

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

Method reference

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

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


ParticipantPermissions: read effective rights

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

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

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

Fields & methods

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

Quick patterns

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

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

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

Dialogs & History


Fetch dialogs

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

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

Dialog accessors

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

DialogIter: lazy paginated iterator

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

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

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

MessageIter: lazy message history

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

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

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

Fetch messages directly

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

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

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

// Fetch the message a reply refers to by ID
let parent = client.get_messages(peer.clone(), &[reply_id]).await?.into_iter().next();
}

Scheduled messages

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

// Send a scheduled message immediately
client.send_scheduled_now(peer.clone(), &[scheduled_id]).await?;

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

Dialog management

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

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

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

// Pin / unpin a dialog
client.pin_dialog(peer.clone()).await?;
client.pin_dialog(peer.clone(), false).await?;

// Get pinned dialogs (folder_id: 0 = main, 1 = archived)
let pinned = client.get_pinned_dialogs(0).await?;

// Set manual unread flag on a dialog
client.mark_dialog_unread(peer.clone(), true).await?;

// Clear the manual unread flag
client.mark_read(peer.clone()).await?;

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

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

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

Archive & unarchive

Move a dialog into the Telegram archive (folder 1) or back to the main list (folder 0):

#![allow(unused)]
fn main() {
// Move to archive - chat disappears from the main dialog list
client.archive("@somebot").await?;

// Move back to main list
client.archive("@somebot").await?;
}

Archived chats still receive messages; they are simply hidden from the main list and muted by default until the user opens them.

Low-level folder move

For finer control (e.g. custom folders), use move_to_folder:

#![allow(unused)]
fn main() {
// folder_id 0 = main list, 1 = archive
client.move_to_folder("@somebot", 0).await?;
}

Search

ferogram provides two fluent search builders:

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

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


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

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

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

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

All SearchBuilder methods

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

Filter by media type

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

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

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

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

Common MessagesFilter values

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

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

Search from a specific sender

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

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

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

Pagination

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

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

    if page.is_empty() { break; }

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

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

GlobalSearchBuilder: search all chats

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

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

All GlobalSearchBuilder methods

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

Filter by chat type

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

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

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

Combined filters

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

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

Simple one-liner methods (no builder)

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

#![allow(unused)]
fn main() {
// Per-chat search via the builder with defaults
let results = client.search(peer.clone()).query("query").limit(20).collect(&client).await?;

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

Typing Guard

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

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


Setup

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


Convenience methods on Client

These are the recommended entry points:

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

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

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

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

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

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


Using TypingGuard::start directly

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

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

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

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

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

TypingGuard::start_ex: forum topics + custom delay

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

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

Manual .cancel()

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

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

do_some_work().await;

guard.cancel(); // indicator stops here

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

One-shot send_chat_action (no guard)

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

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

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


Complete example: long task with typing

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

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

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

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

    Ok(())
}

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

Example: upload with “uploading document…” indicator

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

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

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

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

How it works internally

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

API reference

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

Chat Management

Full reference for creating, editing, and deleting groups and channels. All methods are async and return Result<_, InvocationError>.

For invite link management see Invite Links. For forum topics see Forum Topics.


Join & leave

async client.join_chat(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Join a public group or channel by peer reference (username, ID, or t.me link).
async client.join_link(link: &str) → Result<(), InvocationError>
Accept a t.me/+hash or t.me/joinchat/hash invite link and join the chat.
async client.leave_chat(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Leave a channel or supergroup. For basic groups, use kick on yourself or delete_dialog to hide it.

Create

async client.create_group(title: impl Into<String>, user_ids: Vec<i64>) → Result<tl::enums::Chat, InvocationError>
Create a new legacy basic group with an initial member list. Basic groups support up to 200 members. To go larger, call migrate_chat to upgrade to a supergroup.
#![allow(unused)]
fn main() {
let chat = client.create_group("Dev Team", vec![user_a, user_b]).await?;
}
async client.create_channel(title: impl Into<String>, about: impl Into<String>, broadcast: bool) → Result<tl::enums::Chat, InvocationError>
Create a new channel (broadcast = true) or supergroup (broadcast = false).
#![allow(unused)]
fn main() {
// Supergroup
let sg = client.create_channel("My Community", "A place to chat", false).await?;

// Broadcast channel
let ch = client.create_channel("My News", "Daily updates", true).await?;
}

Delete & migrate

async client.delete_chat(peer: impl Into<PeerRef>) → Result<(), InvocationError>
Permanently delete a channel or supergroup. Only the creator can do this. Irreversible - all messages are lost.
async client.delete_chat(chat_id: i64) → Result<(), InvocationError>
Delete a legacy basic group by its raw numeric chat ID. Only the creator can do this. For supergroups and channels use delete_chat.
async client.migrate_chat(chat_id: i64) → Result<tl::enums::Chat, InvocationError>
Upgrade a legacy basic group to a supergroup. Returns the new channel peer. The original chat_id becomes invalid after migration; update any stored references.

Edit

async client.set_profile(peer).title(: impl Into<PeerRef>, title: impl Into<String>) → Result<(), InvocationError>
Rename a chat, group, channel, or supergroup.
async client.set_profile(peer).bio(: impl Into<PeerRef>, about: impl Into<String>) → Result<(), InvocationError>
Set or update the description/about text. Works for all chat types.
async client.set_profile(peer).chat_photo(peer: impl Into<PeerRef>, photo: tl::enums::InputChatPhoto) → Result<(), InvocationError>
Change the group/channel photo. Pass InputChatPhoto::Empty to remove it.
#![allow(unused)]
fn main() {
// Set a new photo (upload first)
let uploaded = client.upload_file("photo.jpg").await?;
client.set_profile(peer).chat_photo(
    peer.clone(),
    tl::enums::InputChatPhoto::InputChatUploadedPhoto(
        tl::types::InputChatUploadedPhoto {
            video: false, video_emoji_markup: None,
            file: Some(uploaded.into_input_file()),
        }
    ),
).await?;

// Remove photo
client.set_profile(peer).chat_photo(peer.clone(), tl::enums::InputChatPhoto::Empty).await?;
}
async client.edit_chat_default_banned_rights(peer: impl Into<PeerRef>, build: impl FnOnce(BannedRightsBuilder) → BannedRightsBuilder) → Result<(), InvocationError>
Set default permissions for all members via a closure on BannedRightsBuilder. Passing true to a method restricts that action.
#![allow(unused)]
fn main() {
// Read-only group: members can only read
client.edit_chat_default_banned_rights(peer.clone(), |b| {
    b.send_messages(true)
     .send_media(true)
     .send_polls(true)
}).await?;

// Restore all defaults
client.edit_chat_default_banned_rights(peer.clone(), |b| b).await?;
}
async client.set_chat_theme(peer: impl Into<PeerRef>, emoticon: impl Into<String>) → Result<(), InvocationError>
Set the emoji colour theme for a chat. Pass a single emoji (e.g. "🌈") to apply it, or an empty string to reset to the default.
async client.set_chat_reactions(peer: impl Into<PeerRef>, reactions: tl::enums::ChatReactions) → Result<(), InvocationError>
Control which reactions members can use. See Client Methods § Chat management for the three variant forms.

Members & info

async client.invite_users(peer: impl Into<PeerRef>, user_ids: Vec<i64>) → Result<(), InvocationError>
Add one or more users to a chat. For channels all users are added in a single request; for basic groups each user is added individually.
async client.get_chat_full(peer: impl Into<PeerRef>) → Result<tl::enums::messages::ChatFull, InvocationError>
Fetch the full info object for any chat. Contains description, pinned message ID, linked channel, member count, slow mode delay, call info, and more.
async client.get_online_count(peer: impl Into<PeerRef>) → Result<i32, InvocationError>
Get the approximate number of members currently online in a group or channel.
async client.get_common_chats(user_id: i64, max_id: i64, limit: i32) → Result<Vec<tl::enums::Chat>, InvocationError>
Get chats shared between the current account and user_id. Start with max_id = 0; use the last returned chat ID for subsequent pages. Max limit is 100.

Moderation settings

async client.toggle_no_forwards(peer: impl Into<PeerRef>, enabled: bool) → Result<(), InvocationError>
Enable or disable the no-forwards restriction. When on, members cannot forward messages out of this chat.
async client.set_history_ttl(peer: impl Into<PeerRef>, period: i32) → Result<(), InvocationError>
Set the auto-delete timer for messages. period is in seconds - common values: 86400 (1 day), 604800 (1 week), 2678400 (1 month). Pass 0 to disable.
async client.toggle_forum(peer: impl Into<PeerRef>, enabled: bool) → Result<(), InvocationError>
Enable or disable forum (topics) mode on a supergroup. See Forum Topics for full topic management after enabling.

Quick-start recipe

#![allow(unused)]
fn main() {
// 1. Create a supergroup
let chat = client.create_channel("My Group", "Welcome!", false).await?;

// 2. Add members
client.invite_users(chat.clone(), vec![user_a, user_b]).await?;

// 3. Set description and lock down media for all
client.set_profile(chat.clone()).bio("Read the rules before posting.").send().await?;
client.edit_chat_default_banned_rights(chat.clone(), |b| {
    b.send_media(true).send_polls(true)
}).await?;

// 4. Enable auto-delete (1 week)
client.set_history_ttl(chat.clone(), 604_800).await?;

// 5. Generate an approval-gated invite link
let inv = client.export_invite_link(chat.clone(), None, None, true).await?;
println!("Invite: {}", match &inv {
    tl::enums::ExportedChatInvite::Invite(i) => &i.link,
    _ => "",
});
}

Transfer ownership

Transfer ownership of a basic group to another member. The calling user must be the current owner and must supply their 2FA SRP credential.

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

// For a no-password account, use InputCheckPasswordEmpty
let password_check = tl::enums::InputCheckPasswordSrp::Empty(
    tl::types::InputCheckPasswordEmpty {}
);

client.transfer_chat_ownership(
    "@mygroup",
    new_owner_user_id,
    password_check,
).await?;
}

Use Client::compute_password_check to build the SRP object when 2FA is enabled.

Note: For channels and supergroups, ownership transfer uses channels.editCreator on the Telegram layer, which is not yet wrapped by a dedicated helper. Use the raw API for that case.


Linked channel

A broadcast channel can have a linked discussion supergroup and vice-versa. Retrieve the linked chat’s ID:

#![allow(unused)]
fn main() {
if let Some(linked_id) = client.get_linked_channel("@mychannel").await? {
    println!("Linked chat ID: {linked_id}");
}
}

Returns None when no linked chat is configured. Works for both directions - pass a channel to get its discussion group, or pass a supergroup to get its linked broadcast channel.


History, albums, and identity

async client.delete_chat_history(peer: impl Into<PeerRef>, max_id: i32, revoke: bool) → Result<(), InvocationError>
Delete the current user's message history in a chat up to max_id. When revoke is true, deletes for all members (only possible if you are an admin).
async client.get_media_group(peer: impl Into<PeerRef>, msg_id: i32) → Result<Vec<IncomingMessage>, InvocationError>
Fetch all messages that belong to the same grouped media album as msg_id.
async client.get_send_as_peers(peer: impl Into<PeerRef>) → Result<Vec<tl::enums::Peer>, InvocationError>
List the identities the current user can send messages as in peer. Returns the user's own account plus any channels they manage that are allowed as send-as identities in this chat.
async client.set_default_send_as(peer: impl Into<PeerRef>, send_as: impl Into<PeerRef>) → Result<(), InvocationError>
Set the default send-as identity for peer. send_as must be one of the peers returned by get_send_as_peers.
#![allow(unused)]
fn main() {
// Delete own history up to message 500
client.delete_chat_history("@peer", 500, false).await?;

// Fetch all messages in an album
let album = client.get_media_group("@channel", msg_id).await?;

// List send-as identities
let identities = client.get_send_as_peers("@group").await?;

// Set default send-as
client.set_default_send_as("@group", "@mychannel").await?;
}

Invite Links

Full API for creating, editing, revoking, and managing chat invite links, as well as handling join requests.


Create & export

async client.export_invite_link(peer: impl Into<PeerRef>, expire_date: Option<i32>, usage_limit: Option<i32>, request_needed: bool) → Result<tl::enums::ExportedChatInvite, InvocationError>
Create a new invite link for a chat.
  • expire_date - Unix timestamp after which the link stops working. Pass None for no expiry.
  • usage_limit - Maximum number of times the link can be used. Pass None for unlimited.
  • request_needed - If true, users who join via this link must be approved by an admin before entering.
#![allow(unused)]
fn main() {
// Permanent link, up to 50 uses
let inv = client.export_invite_link(peer.clone(), None, Some(50), false).await?;

// Link that expires in 24 hours, requires approval
let tomorrow = (std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH).unwrap()
    .as_secs() + 86400) as i32;
let inv = client.export_invite_link(peer.clone(), Some(tomorrow), None, true).await?;
}
sync Client::parse_invite_hash(link: &str) → Option<&str>
Extract the raw invite hash from any t.me/+… or t.me/joinchat/… link format. Returns None if the string is not a valid invite link.
#![allow(unused)]
fn main() {
let hash = Client::parse_invite_hash("https://t.me/+AbCdEfGhIj");
// => Some("AbCdEfGhIj")
}

Revoke & edit

async client.revoke_invite_link(peer: impl Into<PeerRef>, link: impl Into<String>) → Result<tl::enums::ExportedChatInvite, InvocationError>
Revoke an existing invite link immediately. After revocation the link stops accepting new joins. Returns the updated invite object showing the revoked state. The link is not deleted - it still appears in history and can be deleted with delete_invite_link.
async client.edit_invite_link(peer: impl Into<PeerRef>, link: impl Into<String>, expire_date: Option<i32>, usage_limit: Option<i32>, request_needed: bool) → Result<tl::enums::ExportedChatInvite, InvocationError>
Update an existing invite link's properties. Only non-permanent links can be edited. The same fields as export_invite_link apply.

List & delete

async client.get_invite_links(peer: impl Into<PeerRef>, admin_id: i64, revoked: bool, limit: i32) → Result<Vec<tl::enums::ExportedChatInvite>, InvocationError>
List invite links created by a specific admin. Pass revoked: false for active links, true for revoked ones. Maximum limit is 100.
#![allow(unused)]
fn main() {
// All active links created by the logged-in user (user_id from get_me)
let me = client.get_me().await?;
let links = client.get_invite_links(peer.clone(), me.id, false, 100).await?;
}
async client.delete_invite_link(peer: impl Into<PeerRef>, link: impl Into<String>) → Result<(), InvocationError>
Permanently delete a revoked invite link. The link must already be revoked before it can be deleted.
async client.delete_revoked_invite_links(peer: impl Into<PeerRef>, admin_id: i64) → Result<(), InvocationError>
Bulk-delete all revoked links created by admin_id in one call. Useful for cleaning up the invite history.

Join requests

When a link is created with request_needed: true, users who click it appear as pending join requests that an admin must approve or reject.

async client.approve_join_request(peer: impl Into<PeerRef>, user_id: i64) → Result<(), InvocationError>
Approve a single pending join request. The user is added to the chat immediately.
async client.reject_join_request(peer: impl Into<PeerRef>, user_id: i64) → Result<(), InvocationError>
Reject and dismiss a single pending join request.
async client.approve_all_join_requests(peer: impl Into<PeerRef>, link: Option<String>) → Result<(), InvocationError>
Approve all pending join requests at once. If link is Some(url), only requests submitted via that specific link are approved. Pass None to approve all pending requests regardless of which link they came from.
async client.reject_all_join_requests(peer: impl Into<PeerRef>, link: Option<String>) → Result<(), InvocationError>
Reject all pending join requests at once, with the same optional link filter as approve_all_join_requests.

async client.get_invite_link_members(peer: impl Into<PeerRef>, link: Option<String>, requested: bool, limit: i32) → Result<tl::types::messages::ChatInviteImporters, InvocationError>
List members who joined via a specific invite link, or all pending join requesters.
  • link - the invite URL. None to query across all links.
  • requested: false - users who already joined.
  • requested: true - users with a pending join request still awaiting approval.
#![allow(unused)]
fn main() {
// Who is waiting for approval?
let pending = client
    .get_invite_link_members(peer.clone(), None, true, 50)
    .await?;
}
async client.get_admins_with_invites(peer: impl Into<PeerRef>) → Result<tl::types::messages::ChatAdminsWithInvites, InvocationError>
Get a breakdown of each admin's invite link counts (active, revoked). Useful for auditing which admins created how many links.

Full example: approval-gated invite

#![allow(unused)]
fn main() {
// Create link that requires admin approval
let inv = client
    .export_invite_link(peer.clone(), None, None, true)
    .await?;

println!("Share this link: {}", match &inv {
    tl::enums::ExportedChatInvite::Invite(i) => &i.link,
    _ => "",
});

// Later: check who is waiting
let pending = client
    .get_invite_link_members(peer.clone(), None, true, 100)
    .await?;

for importer in &pending.importers {
    println!("Approving user {}", importer.user_id);
    client.approve_join_request(peer.clone(), importer.user_id).await?;
}
}

Contacts & Blocking

Methods for managing your contact list and blocking/unblocking users.


Contacts

async client.get_contacts() → Result<Option<Vec<tl::enums::User>>, InvocationError>
Fetch the full contact list. Returns None when the server indicates the contact list is unchanged since the last fetch (the server uses a hash-based caching scheme). In practice always returns Some on the first call.
#![allow(unused)]
fn main() {
if let Some(contacts) = client.get_contacts().await? {
    for c in contacts {
        if let tl::enums::User::User(u) = c {
            println!("{} {}", u.first_name.unwrap_or_default(), u.last_name.unwrap_or_default());
        }
    }
}
}
async client.add_contact(user_id: i64, first_name: impl Into<String>, last_name: impl Into<String>, phone: impl Into<String>) → Result<(), InvocationError>
Add a user to your contact list. phone can be an empty string if you are adding by user ID rather than phone number. The name you supply is stored locally as your label for this contact, independent of the user's own profile name.
#![allow(unused)]
fn main() {
client.add_contact(user_id, "Alice", "Smith", "").await?;
}
async client.delete_contacts(user_ids: Vec<i64>) → Result<(), InvocationError>
Remove one or more users from your contact list. Passing an empty vec is a no-op.
#![allow(unused)]
fn main() {
client.delete_contacts(vec![user_a, user_b]).await?;
}
async client.search_contacts(query: impl Into<String>, limit: i32) → Result<Vec<tl::enums::Peer>, InvocationError>
Search for users, groups, and channels by name. Searches across contacts, dialogs, and global results. Returns a merged, deduplicated list ordered by relevance.
#![allow(unused)]
fn main() {
let results = client.search_contacts("John", 20).await?;
}

Import contacts

Import phone-number contacts in bulk. Each entry is (phone, first_name, last_name). Returns the raw ImportedContacts result containing imported IDs and resolved user objects.

#![allow(unused)]
fn main() {
let result = client.import_contacts(&[
    ("+15550001234", "Alice", "Smith"),
    ("+15550005678", "Bob",   "Jones"),
]).await?;

println!("Imported {} contacts", result.imported.len());
for user in &result.users {
    println!("  resolved: {user:?}");
}
}

result.retry_contacts contains entries that could not be resolved (e.g. the number is not registered on Telegram).


Blocking

async client.block(peer: impl Into<PeerRef>, block: bool) → Result<(), InvocationError>
Block a user. Blocked users cannot send you messages, see your phone number, or add you to groups. The block also suppresses their stories from your feed.
async → Result<(), InvocationError>
Remove a user from your block list.
async client.get_blocked_users(offset: i32, limit: i32) → Result<Vec<tl::enums::Peer>, InvocationError>
Paginate through your block list. Start with offset = 0. The server caps limit at 100.
#![allow(unused)]
fn main() {
let mut offset = 0;
loop {
    let page = client.get_blocked_users(offset, 100).await?;
    if page.is_empty() { break; }
    offset += page.len() as i32;
    for peer in &page {
        println!("{peer:?}");
    }
}
}

Full example

#![allow(unused)]
fn main() {
// Find and block all users named "Spammer"
let results = client.search_contacts("Spammer", 50).await?;
for peer in results {
    client.block(peer, true).await?;
    println!("Blocked {peer:?}");
}

// List current block list
let mut offset = 0;
loop {
    let page = client.get_blocked_users(offset, 100).await?;
    if page.is_empty() { break; }
    println!("Blocked users page: {page:?}");
    offset += page.len() as i32;
}
}

Profile & Account

Methods for updating your own profile, managing active sessions, and controlling account-level settings.


Profile

async client.set_profile(first_name: Option<String>, last_name: Option<String>, about: Option<String>) → Result<tl::enums::User, InvocationError>
Change your display name and/or bio. Pass None for any field you want to leave unchanged. Returns the updated User object.
#![allow(unused)]
fn main() {
// Change just the bio
client.set_profile(None, None, Some("🦀 Rust developer".to_string())).await?;

// Change full name
client.set_profile(
    Some("Alice".to_string()),
    Some("Smith".to_string()),
    None,
).await?;
}
async client.set_profile("me").username(username: impl Into<String>) → Result<tl::enums::User, InvocationError>
Set or change your @username. Pass an empty string to remove the username. Returns the updated User object. Telegram will return an error if the username is already taken or violates naming rules.
#![allow(unused)]
fn main() {
client.set_profile("me").username("my_new_handle").send().await?;
client.set_profile("me").username("").send().await?;  // remove username
}
async client.set_presence(true) / set_presence(false) → Result<(), InvocationError>
Manually set your online/offline status. Pass offline: false to appear online, true to mark yourself as offline. Telegram resets online status automatically after ~5 minutes of inactivity, so call this periodically if you need a persistent "online" appearance.

Profile photo

async client.set_profile("me").photo(file: UploadedFile) → Result<tl::enums::Photo, InvocationError>
Set or add a new profile photo. Upload the image with upload_file(path) first.
#![allow(unused)]
fn main() {
let bytes = tokio::fs::read("avatar.jpg").await?;
let uploaded = client.upload_file("avatar.jpg").await?;
let photo = client.set_profile("me").photo(uploaded).send().await?;
}
async client.delete_profile_photos(photo_ids: Vec<(i64, i64, Vec<u8>)>) → Result<Vec<i64>, InvocationError>
# Profile & Account

Methods for updating your own profile, managing active sessions, and controlling account-level settings.


Profile

async client.set_profile(first_name: Option<String>, last_name: Option<String>, about: Option<String>) → Result<tl::enums::User, InvocationError>
Change your display name and/or bio. Pass None for any field you want to leave unchanged. Returns the updated User object.
#![allow(unused)]
fn main() {
// Change just the bio
client.set_profile(None, None, Some("🦀 Rust developer".to_string())).await?;

// Change full name
client.set_profile(
    Some("Alice".to_string()),
    Some("Smith".to_string()),
    None,
).await?;
}
async client.set_profile("me").username(username: impl Into<String>) → Result<tl::enums::User, InvocationError>
Set or change your @username. Pass an empty string to remove the username. Returns the updated User object. Telegram will return an error if the username is already taken or violates naming rules.
#![allow(unused)]
fn main() {
client.set_profile("me").username("my_new_handle").send().await?;
client.set_profile("me").username("").send().await?;  // remove username
}
async client.set_presence(true) / set_presence(false) → Result<(), InvocationError>
Manually set your online/offline status. Pass offline: false to appear online, true to mark yourself as offline. Telegram resets online status automatically after ~5 minutes of inactivity.

Profile photo

async client.get_profile_photos(peer: impl Into<PeerRef>, limit: i32) → Result<Vec<tl::enums::Photo>, InvocationError>
Fetch up to limit profile photos for a user. Only works on user peers; passing a chat or channel returns an error.
#![allow(unused)]
fn main() {
let photos = client.get_profile_photos(peer, 10).await?;
for p in photos {
    if let tl::enums::Photo::Photo(photo) = p {
        println!("photo id={}", photo.id);
    }
}
}
async client.iter_profile_photos(peer: impl Into<PeerRef>, chunk_size: i32) → ProfilePhotoIter
Returns a lazy iterator over all profile photos for a user. chunk_size controls how many are fetched per page (pass 0 for the default). Call .total_count() on the iterator to get the total before iterating.
#![allow(unused)]
fn main() {
let mut iter = client.iter_profile_photos(peer, 0).await?;
if let Some(total) = iter.total_count() {
    println!("{total} photos total");
}
while let Some(photo) = iter.next(&client).await? {
    // handle photo
}
}
async client.set_profile("me").photo(file: UploadedFile) → Result<tl::enums::Photo, InvocationError>
Set or add a new profile photo. Upload the image with upload_file(path) first. Returns the new Photo object.
#![allow(unused)]
fn main() {
let bytes = tokio::fs::read("avatar.jpg").await?;
let uploaded = client.upload_file("avatar.jpg").await?;
let photo = client.set_profile("me").photo(uploaded).send().await?;
}
async client.delete_profile_photos(photo_ids: Vec<(i64, i64, Vec<u8>)>) → Result<Vec<i64>, InvocationError>
Delete one or more profile photos. Each tuple is (photo_id, access_hash, file_reference) - all three come from a tl::types::Photo. Returns the IDs of successfully deleted photos.
#![allow(unused)]
fn main() {
// Get photos via iter_profile_photos, then delete
let tl::enums::Photo::Photo(p) = photo else { return; };
client.delete_profile_photos(vec![(p.id, p.access_hash, p.file_reference.clone())]).await?;
}

Sessions

async client.get_authorizations() → Result<Vec<tl::types::Authorization>, InvocationError>
List all active login sessions for this account. Each Authorization contains:
FieldTypeDescription
hashi64Session identifier - pass to terminate_session
device_modelStringe.g. "iPhone 15", "Chrome"
platformStringe.g. "iOS", "Linux"
app_nameStringClient app name
date_createdi32Unix timestamp
date_activei32Last-seen Unix timestamp
ipStringIP address of the session
countryStringCountry code
currentboolWhether this is the current session
#![allow(unused)]
fn main() {
let sessions = client.get_authorizations().await?;
for s in &sessions {
    println!("{}  -  {}  -  active: {}", s.device_model, s.ip, s.date_active);
}
}
async client.terminate_session(hash: i64) → Result<(), InvocationError>
Revoke a specific session by its hash from get_authorizations. The device is immediately logged out.
#![allow(unused)]
fn main() {
let sessions = client.get_authorizations().await?;
for s in sessions {
    if !s.current && s.app_name.contains("WebK") {
        // terminate old web sessions
        client.terminate_session(s.hash).await?;
    }
}
}

Full example: profile refresh

#![allow(unused)]
fn main() {
// Update name and bio at once
client.set_profile(
    Some("Bot".to_string()),
    Some("Account".to_string()),
    Some("Powered by ferogram 🦀".to_string()),
).await?;

// Set a new avatar
let bytes = tokio::fs::read("new_avatar.png").await?;
let f = client.upload_file("avatar.png").await?;
client.set_profile("me").photo(f).send().await?;

// Go offline
client.set_presence(false).await?;
}

Emoji status

Set or clear the animated emoji shown next to the logged-in user’s name (Telegram Premium feature):

#![allow(unused)]
fn main() {
// Set an emoji status using a custom emoji document ID
// (obtain IDs from sticker sets via client.get_sticker_set)
client.set_profile("me").emoji_status(Some(5260885697911948121), None).send().await?;

// Set with an expiry (Unix timestamp)
client.set_profile("me").emoji_status(Some(5260885697911948121), Some(1_800_000_000)).send().await?;

// Clear the current emoji status
client.set_profile("me").emoji_status(None, None).send().await?;
}

document_id is the id field from a tl::types::Document belonging to a custom-emoji sticker. Pass None to remove the status. until is an optional Unix timestamp after which the status expires automatically; pass None for no expiry.

Forum Topics

Telegram supergroups can be converted to forums - they gain named topics that act as separate message threads inside one chat. Each topic has its own message history, read state, and optional icon.

Enable forum mode on a supergroup with toggle_forum.


Enable / disable forum mode

async client.toggle_forum(peer: impl Into<PeerRef>, enabled: bool) → Result<(), InvocationError>
Turn forum mode on or off for a supergroup. Requires channel admin rights. Once enabled the chat gains a General topic (topic ID 1) automatically.
#![allow(unused)]
fn main() {
client.toggle_forum(peer.clone(), true).await?;   // enable
client.toggle_forum(peer.clone(), false).await?;  // disable
}

List topics

async client.get_forum_topics(peer: impl Into<PeerRef>, query: Option<String>, limit: i32, offset_date: i32, offset_id: i32, offset_topic: i32) → Result<Vec<tl::enums::ForumTopic>, InvocationError>
Paginate through forum topics. Use query to search by topic title. Start with all offsets at 0.
ParamDescription
querySearch string. None returns all topics.
limitTopics per page (max 100).
offset_dateDate of last topic in previous page (for pagination).
offset_idtop_msg_id of last topic in previous page.
offset_topicTopic ID of last topic in previous page.
#![allow(unused)]
fn main() {
// First page
let topics = client
    .get_forum_topics(peer.clone(), None, 100, 0, 0, 0)
    .await?;

for topic in &topics {
    if let tl::enums::ForumTopic::Topic(t) = topic {
        println!("[{}] {}  -  {} unread", t.id, t.title, t.unread_count);
    }
}
}
async client.get_forum_topics_by_id(peer: impl Into<PeerRef>, topic_ids: Vec<i32>) → Result<Vec<tl::enums::ForumTopic>, InvocationError>
Fetch specific topics by their IDs in one request.
#![allow(unused)]
fn main() {
let topics = client.get_forum_topics_by_id(peer.clone(), vec![1, 42, 99]).await?;
}

Create & edit topics

async client.create_forum_topic(peer: impl Into<PeerRef>, title: impl Into<String>, icon_color: Option<i32>, icon_emoji_id: Option<i64>) → Result<(), InvocationError>
Create a new topic in a forum supergroup.
  • icon_color - RGB color int for the topic icon. Telegram supports six presets: 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F. Pass None to use the default blue.
  • icon_emoji_id - Custom emoji document ID (from a premium sticker set) to use as the topic icon. Pass None to use a plain color circle.
#![allow(unused)]
fn main() {
// Simple topic
client.create_forum_topic(peer.clone(), "📢 Announcements", None, None).await?;

// Colored topic
client.create_forum_topic(
    peer.clone(),
    "🛠 Dev",
    Some(0x6FB9F0),
    None,
).await?;
}
async client.edit_forum_topic(peer: impl Into<PeerRef>, topic_id: i32, title: Option<String>, icon_emoji_id: Option<i64>, closed: Option<bool>, hidden: Option<bool>) → Result<(), InvocationError>
Update topic properties. Pass None for any field to leave it unchanged.
  • closed - Some(true) to prevent new messages; Some(false) to reopen.
  • hidden - Only valid for topic ID 1 (General). Hides it from the topic list.
#![allow(unused)]
fn main() {
// Rename a topic
client.edit_forum_topic(peer.clone(), 42, Some("New Name".into()), None, None, None).await?;

// Close a topic
client.edit_forum_topic(peer.clone(), 42, None, None, Some(true), None).await?;

// Hide the General topic
client.edit_forum_topic(peer.clone(), 1, None, None, None, Some(true)).await?;
}

Delete topic history

async client.delete_forum_topic_history(peer: impl Into<PeerRef>, top_msg_id: i32) → Result<(), InvocationError>
Delete all messages inside a forum topic. top_msg_id is the topic's root message ID (returned as ForumTopic::Topic.id). This is destructive and irreversible. The method automatically pages through the deletion until all messages are removed.
#![allow(unused)]
fn main() {
// Wipe the "Dev" topic (top_msg_id = 42)
client.delete_forum_topic_history(peer.clone(), 42).await?;
}

Sending messages to a topic

To send a message inside a specific topic, set reply_to on the InputMessage to the topic’s top_msg_id:

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

// Send into topic #42
client.send_message(
    peer.clone(),
    InputMessage::text("Hello topic!").reply_to(Some(42)),
).await?;
}

ForumTopic fields reference

#![allow(unused)]
fn main() {
if let tl::enums::ForumTopic::Topic(t) = topic {
    t.id            // i32: topic ID / top_msg_id
    t.title         // String: display name
    t.icon_color    // i32: RGB color
    t.icon_emoji_id // Option<i64>: custom emoji ID
    t.top_message   // i32: most recent message ID
    t.unread_count  // i32: unread messages
    t.unread_mentions_count // i32
    t.closed        // bool: no new messages allowed
    t.pinned        // bool: pinned in topic list
    t.short         // bool: General topic placeholder
}
}

Polls & Votes

ferogram lets you send polls, vote on them, and inspect results and voter lists.


Sending a poll

Use PollBuilder and pass it to send_poll:

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

// Regular anonymous poll
client.send_poll(peer.clone(),
    PollBuilder::new("What is your favourite language?")
        .answers(["Rust", "Go", "C++"])
).await?;

// Public voters, auto-close after 5 minutes
client.send_poll(peer.clone(),
    PollBuilder::new("Vote now")
        .answers(["Yes", "No"])
        .public_voters(true)
        .close_period(300)
).await?;

// Quiz mode with explanation
client.send_poll(peer.clone(),
    PollBuilder::new("Capital of France?")
        .answers(["Berlin", "Paris", "Rome"])
        .quiz(true)
        .correct_index(1)
        .solution("It's Paris.")
        .hide_results_until_close(true)
).await?;

// Multiple choice
client.send_poll(peer.clone(),
    PollBuilder::new("Pick your tools")
        .answers(["vim", "emacs", "VSCode", "Helix"])
        .multiple_choice(true)
).await?;
}

PollBuilder methods

MethodDescription
PollBuilder::new(question)Start a builder with a question string
.answers(iter)Answer strings, in order
.quiz(bool)Quiz mode (one correct answer)
.correct_index(usize)Which answer index is correct (quiz only)
.solution(text)Explanation shown after quiz answer
.multiple_choice(bool)Allow selecting more than one answer
.public_voters(bool)Show who voted
.shuffle_answers(bool)Randomise answer order per viewer
.hide_results_until_close(bool)Keep results hidden until closed
.close_period(secs: i32)Auto-close after N seconds (1-600)
.close_date(ts: i32)Auto-close at Unix timestamp
.subscribers_only(bool)Only channel subscribers can vote
.countries_iso2(codes)Restrict voting to ISO 3166-1 alpha-2 country codes

Voting

async client.send_vote(peer: impl Into<PeerRef>, msg_id: i32, options: Vec<Vec<u8>>) -> Result<(), InvocationError>
Cast a vote. `options` is a list of answer byte vectors matching the `option` field on each `PollAnswer`. For single-choice polls pass one item; for multiple-choice pass several.
#![allow(unused)]
fn main() {
// Vote for option 0 on message 1234
client.send_vote(peer.clone(), 1234, vec![vec![0]]).await?;
}

Results

async client.poll_results(peer: impl Into<PeerRef>, msg_id: i32, poll_hash: i64) -> Result<(), InvocationError>
Request a fresh result snapshot from the server. The server responds with an `updateMessagePoll` in the update stream. `poll_hash` comes from `PollResults.results_hash` on the message.
async client.get_poll_votes(peer: impl Into<PeerRef>, msg_id: i32, option: Option<Vec<u8>>, limit: i32, offset: Option<String>) -> Result<tl::types::messages::VotesList, InvocationError>
Paginated list of who voted. Only works for public-voters polls.
  • option: filter to a specific answer byte vector, or None for all votes.
  • offset: continuation token from the previous page’s next_offset.
#![allow(unused)]
fn main() {
let page = client
    .get_poll_votes(peer.clone(), msg_id, Some(vec![1]), 50, None)
    .await?;

for vote in &page.votes {
    println!("User {} voted at {}", vote.user_id, vote.date);
}

if let Some(next) = page.next_offset {
    let page2 = client
        .get_poll_votes(peer.clone(), msg_id, Some(vec![1]), 50, Some(next))
        .await?;
}
}

VotesList fields

FieldTypeDescription
counti32Total vote count across all options
votesVec<MessageUserVote>Votes for this page
usersVec<User>Resolved user objects for votes
next_offsetOption<String>Pagination cursor for the next page

Poll stats

async client.poll_results(peer: impl Into<PeerRef>, msg_id: i32) -> Result<tl::types::stats::PollStats, InvocationError>
Fetch detailed vote graph stats for a poll (`stats.getPollStats`). Returns a `PollStats` with a `votes_graph` field containing a `StatsGraph` you can render as a chart.
#![allow(unused)]
fn main() {
let stats = client.poll_results(peer.clone(), msg_id).await?;
// stats.votes_graph: tl::enums::StatsGraph
}

Closing a poll

Edit the message and set closed: true on the poll. You need the original poll ID from the message:

#![allow(unused)]
fn main() {
let close_media = tl::enums::InputMedia::Poll(Box::new(tl::types::InputMediaPoll {
    poll: tl::enums::Poll::Poll(tl::types::Poll {
        id: existing_poll_id,
        closed: true,
        // other fields same as original
        ..
    }),
    correct_answers: None,
    solution: None,
    solution_entities: None,
    solution_media: None,
}));

client.invoke(&tl::functions::messages::EditMessage {
    peer: input_peer,
    id: msg_id,
    media: Some(close_media),
    // ..
}).await?;
}

Stickers

Methods for fetching, installing, and managing sticker sets, as well as resolving custom emoji.

See also: Sticker type in the Media reference for how to receive and download sticker files from messages.


Sticker sets

async client.get_sticker_set(stickerset: tl::enums::InputStickerSet) → Result<tl::types::messages::StickerSet, InvocationError>
Fetch a sticker set and all its stickers. Pass an InputStickerSet - the most common variants are:
  • InputStickerSet::InputStickerSetShortName - by @short_name
  • InputStickerSet::InputStickerSetID - by numeric ID + access hash
#![allow(unused)]
fn main() {
let set = client.get_sticker_set(
    tl::enums::InputStickerSet::InputStickerSetShortName(
        tl::types::InputStickerSetShortName { short_name: "Animals".into() }
    )
).await?;

println!("Set: {}  -  {} stickers", set.set.title, set.set.count);
for doc in &set.documents {
    if let tl::enums::Document::Document(d) = doc {
        println!("  doc_id={}", d.id);
    }
}
}
async client.toggle_stickers(stickerset: tl::enums::InputStickerSet, archived: bool) → Result<tl::enums::messages::StickerSetInstallResult, InvocationError>
Install a sticker set for the current account. Pass archived: true to add it to the archive instead of the active set list.

The return value is StickerSetInstallResult::Success on a clean install, or StickerSetInstallResult::Archive when older sets were moved to the archive to make room.

#![allow(unused)]
fn main() {
let result = client.toggle_stickers(
    tl::enums::InputStickerSet::InputStickerSetShortName(
        tl::types::InputStickerSetShortName { short_name: "Animals".into() }
    ),
    false,
).await?;
}
async client.untoggle_stickers(stickerset: tl::enums::InputStickerSet) → Result<(), InvocationError>
Remove a sticker set from the account's installed sets.
async client.get_all_stickers(hash: i64) → Result<Option<Vec<tl::types::StickerSet>>, InvocationError>
List all sticker sets installed for the current account. Pass hash = 0 to always get the full list. Returns None when the server confirms the list is unchanged (hash match - used for caching).
#![allow(unused)]
fn main() {
if let Some(sets) = client.get_all_stickers(0).await? {
    for s in &sets {
        println!("{} ({})", s.title, s.short_name);
    }
}
}

Custom emoji

async client.get_custom_emoji_documents(document_ids: Vec<i64>) → Result<Vec<tl::enums::Document>, InvocationError>
Fetch the Document objects for a list of custom emoji IDs. Custom emoji IDs come from MessageEntity::CustomEmoji { document_id } when parsing formatted message text.
#![allow(unused)]
fn main() {
use ferogram::media::Document;

// Grab custom emoji IDs from a message's entities
let ids: Vec<i64> = msg.raw.entities.iter().flatten()
    .filter_map(|e| match e {
        tl::enums::MessageEntity::CustomEmoji(ce) => Some(ce.document_id),
        _ => None,
    })
    .collect();

let docs = client.get_custom_emoji_documents(ids).await?;
for doc in &docs {
    let d = Document::from_raw(match doc.clone() {
        tl::enums::Document::Document(d) => d,
        _ => continue,
    });
    println!("emoji doc {} mime={}", d.id(), d.mime_type());
}
}

Sending a sticker

Stickers are sent as document media. Get the sticker’s Document, build an InputDocument, and use send_file:

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

// From a received sticker message
if let Some(sticker) = Sticker::from_media(&incoming_msg.raw) {
    let input_media = tl::enums::InputMedia::InputMediaDocument(
        tl::types::InputMediaDocument {
            spoiler: false,
            optional_attributes: false,
            id: tl::enums::InputDocument::InputDocument(tl::types::InputDocument {
                id: sticker.id(),
                access_hash: sticker.access_hash(),   // from raw document
                file_reference: vec![],               // get from raw Document
            }),
            ttl_seconds: None,
            query: None,
        }
    );
    client.send_file(peer, input_media, "").await?;
}
}

Games & Payments

Bot API for HTML5 games (scores, high scores) and Telegram Payments (shipping and pre-checkout confirmations).


Bots

async client.start_bot(bot_user_id: i64, peer: impl Into<PeerRef>, start_param: impl Into<String>) → Result<(), InvocationError>
Send a /start command to a bot with a deep-link parameter, as if the user clicked a t.me/BotUsername?start=param link. The bot receives the parameter in Message.text as /start param.

Parameters

ParamTypeDescription
bot_user_idi64The user ID of the bot to start. Must match a bot account.
peerimpl Into<PeerRef>The chat or user where the /start message is sent. Usually the same as the bot’s own private chat (pass the bot’s user ID).
start_paramimpl Into<String>The deep-link payload. Max 64 characters, only A-Z a-z 0-9 _ - are permitted by Telegram.

Examples

#![allow(unused)]
fn main() {
// Basic: open a bot in a private chat with a deep-link parameter
// bot_user_id is the numeric ID of the bot (not the username)
client.start_bot(bot_user_id, bot_user_id, "welcome").await?;
}
#![allow(unused)]
fn main() {
// Affiliate / referral tracking: encode a referrer ID in the param
let ref_param = format!("ref_{}", referrer_user_id);
client.start_bot(bot_user_id, bot_user_id, ref_param).await?;
}
#![allow(unused)]
fn main() {
// Launch a specific game flow
client.start_bot(bot_user_id, bot_user_id, "play").await?;
}
#![allow(unused)]
fn main() {
// Open the bot inside a group chat (bot must be a member)
client.start_bot(bot_user_id, group_peer, "group_hello").await?;
}

Getting bot_user_id

bot_user_id is the numeric user ID of the bot, not its username string. You can resolve it from the username once and cache it:

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

let bot = client.resolve("MyBotUsername").await?;
let bot_user_id: i64 = match bot {
    PeerRef::UserId(id) => id,
    other => panic!("expected a user, got {:?}", other),
};
}

Or if you already have the bot’s user object from an earlier API call:

#![allow(unused)]
fn main() {
let bot_user_id = user.id();
}

What the bot receives

The bot’s update handler sees a Message whose text is exactly /start <start_param>. For example, if you pass "ref_42":

/start ref_42

The bot can extract the param from message.text.strip_prefix("/start ").

Notes

  • start_param is limited to 64 characters. Attempting a longer value will be rejected by the Telegram server with START_PARAM_INVALID.
  • Allowed characters: A-Z, a-z, 0-9, _, -. Spaces and special characters are not permitted.
  • The call is idempotent from the server’s perspective. Sending the same /start param twice triggers two separate updates on the bot side.
  • For bots with allow_zero_hash disabled (the default), the bot’s access_hash must already be in the peer cache. Call client.resolve() at least once before start_bot to populate the cache, or enable ExperimentalFeatures::allow_zero_hash.

Games

async client.set_game_score(peer: impl Into<PeerRef>, msg_id: i32, user_id: i64, score: i32, force: bool, edit_message: bool) → Result<(), InvocationError>
Set a user's score in a game that was sent in a chat message.
ParamDescription
peerChat where the game message lives
msg_idID of the message containing the game
user_idUser whose score to update
scoreNew score value
forceIf true, allow setting a score lower than the current one
edit_messageIf true, the game message is edited to show the new score
#![allow(unused)]
fn main() {
client.set_game_score(peer.clone(), msg_id, user_id, 42_000, false, true).await?;
}
async client.get_game_high_scores(peer: impl Into<PeerRef>, msg_id: i32, user_id: i64) → Result<Vec<tl::types::HighScore>, InvocationError>
Retrieve the high-score table for a game message, anchored around user_id. Returns up to 5 entries centred on the specified user's position.
#![allow(unused)]
fn main() {
let scores = client.get_game_high_scores(peer.clone(), msg_id, user_id).await?;
for s in &scores {
    println!("#{}  -  user {}  -  {} pts", s.pos, s.user_id, s.score);
}
}

HighScore fields

FieldTypeDescription
posi32Rank position (1-based)
user_idi64User ID
scorei32Score value

Payments

async client.answer_shipping_query(query_id: i64, error: Option<String>, shipping_options: Option<Vec<tl::enums::ShippingOption>>) → Result<(), InvocationError>
Respond to a ShippingQuery update that a bot receives when a user provides their shipping address for an invoice with is_flexible = true.
  • Pass error = None and shipping_options = Some(...) to confirm available shipping options.
  • Pass error = Some("message") and shipping_options = None to reject the query with an error shown to the user.
#![allow(unused)]
fn main() {
// Accept with two shipping options
client.answer_shipping_query(
    query.query_id,
    None,
    Some(vec![
        tl::enums::ShippingOption::ShippingOption(tl::types::ShippingOption {
            id: "standard".into(),
            title: "Standard (5-7 days)".into(),
            prices: vec![
                tl::enums::LabeledPrice::LabeledPrice(tl::types::LabeledPrice {
                    label: "Shipping".into(),
                    amount: 500,  // in smallest currency unit
                }),
            ],
        }),
    ]),
).await?;

// Reject (address not serviceable)
client.answer_shipping_query(
    query.query_id,
    Some("We don't ship to this address.".into()),
    None,
).await?;
}
async client.answer_precheckout_query(query_id: i64, ok: bool, error_message: Option<String>) → Result<(), InvocationError>
Confirm or reject a pre-checkout query. You receive this just before Telegram finalises a payment - use it to verify stock availability, validate the order, etc.
  • ok: true - approve the payment; Telegram completes it.
  • ok: false - reject; pass error_message to explain why.

You must answer within 10 seconds or the payment times out.

#![allow(unused)]
fn main() {
// Approve
client.answer_precheckout_query(query.query_id, true, None).await?;

// Reject
client.answer_precheckout_query(
    query.query_id,
    false,
    Some("Item is out of stock.".into()),
).await?;
}

Update variants

Payments generate update variants that you handle in your update loop:

#![allow(unused)]
fn main() {
Update::ShippingQuery(q) => {
    // q.query_id, q.user_id, q.payload, q.shipping_address
    client.answer_shipping_query(q.query_id, None, Some(options)).await?;
}

Update::PreCheckoutQuery(q) => {
    // q.query_id, q.user_id, q.currency, q.total_amount, q.payload
    client.answer_precheckout_query(q.query_id, true, None).await?;
}
}

Bot Configuration & Special Features

This page covers bot-specific configuration: setting the command menu, editing bot profile info, QR-code login, sending dice, invoices, and starting bots programmatically.


Command menu

The command menu appears in the Telegram UI when users tap the / button or the bot’s profile.

Set commands

#![allow(unused)]
fn main() {
client.set_bot_commands(
    &[
        ("start", "Start the bot"),
        ("help",  "Show help"),
        ("order", "Place an order"),
    ],
    None,   // scope: None = default (all users, all chats)
    "",     // lang_code: "" = default language
).await?;
}

Scopes limit where the commands appear. Pass any tl::enums::BotCommandScope variant:

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

// Only show /admin_ban in group chats
client.set_bot_commands(
    &[("admin_ban", "Ban a user")],
    Some(tl::enums::BotCommandScope::Chats),
    "",
).await?;
}

Delete commands

#![allow(unused)]
fn main() {
// Remove the default command list
client.delete_bot_commands(None, "").await?;
}

Bot profile info

Set the bot’s display name, about text, and description for a given language:

#![allow(unused)]
fn main() {
client.set_bot_info(
    Some("My Awesome Bot"),         // name shown in chat header
    Some("I help with orders"),     // about text (bio)
    Some("Send /start to begin."),  // description shown before first message
    "",                             // lang_code: "" = default locale
).await?;
}

Pass None for any field you do not want to change.

Retrieve the current values:

#![allow(unused)]
fn main() {
let info = client.get_bot_info("").await?;
println!("Name: {}", info.name);
println!("About: {}", info.about);
println!("Description: {}", info.description);
}

Start a bot programmatically

Send /start start_param as if a user pressed a deep-link button:

#![allow(unused)]
fn main() {
// bot_user_id: the bot's user ID
// peer: the chat where /start is sent
// start_param: the payload after the link (empty string for plain /start)
client.start_bot(
    bot_user_id,
    "@somebot",
    "ref_12345",
).await?;
}

This is equivalent to the user clicking https://t.me/somebot?start=ref_12345.


Send a dice / animated emoji

#![allow(unused)]
fn main() {
// Classic 🎲 dice (value 1-6)
client.send_dice("@mychat", "🎲").await?;

// Dart 🎯 (value 1-6)
client.send_dice("@mychat", "🎯").await?;

// Basketball 🏀 (value 1-5)
client.send_dice("@mychat", "🏀").await?;

// Slot machine 🎰 (value 1-64)
client.send_dice("@mychat", "🎰").await?;
}

Supported emoticons: 🎲, 🎯, 🏀, , 🎳, 🎰.


QR-code login (user accounts)

Generate a login token encoded as a QR code, then poll until the user scans it.

#![allow(unused)]
fn main() {
// 1. Generate a token
let (token_bytes, expires_ts) = client.export_login_token().await?;

// 2. Encode as tg://login?token=<base64url> and display as QR code
let b64 = base64::encode_config(&token_bytes, base64::URL_SAFE_NO_PAD);
let url = format!("tg://login?token={}", b64);
println!("Scan: {url}");

// 3. Poll until the user scans it
loop {
    match client.check_qr_login(token_bytes.clone()).await? {
        Some(username) => {
            println!("Logged in as: {username}");
            client.save_session().await?;
            break;
        }
        None => {
            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        }
    }
}
}

export_login_token handles DC migration automatically. Returns (vec![], 0) if the user already scanned before you called it.


Send an invoice (payments)

Send a payment invoice to a chat (bots only):

#![allow(unused)]
fn main() {
client.send_invoice(
    "@user",
    "Premium subscription",          // title
    "One month of premium access",   // description
    "sub_monthly",                   // payload (your internal ID)
    "USD",                           // currency
    &[
        ("1 Month Premium", 999),    // (label, amount in cents)
    ],
    None,                            // photo_url
    false,                           // need_name
    false,                           // need_phone
    false,                           // need_email
    false,                           // need_shipping_address
    false,                           // is_flexible (shipping address changes price)
).await?;
}

Handle the resulting shipping and pre-checkout queries via Update::ShippingQuery and Update::PreCheckoutQuery:

#![allow(unused)]
fn main() {
Update::ShippingQuery(sq) => {
    // Approve with shipping options, or decline with an error
    client.answer_shipping_query(
        sq.query_id,
        None,  // no error
        Some(vec![/* ShippingOption... */]),
    ).await?;
}

Update::PreCheckoutQuery(pcq) => {
    // Confirm the payment
    client.answer_precheckout_query(pcq.query_id, true, None).await?;
    // Or decline:
    // client.answer_precheckout_query(pcq.query_id, false, Some("Out of stock".into())).await?;
}
}

See the Telegram Payments documentation for the full payment flow.

Stats & Analytics

ferogram exposes Telegram’s channel and supergroup statistics endpoints.

Note: Statistics are only available for channels with at least 500 subscribers and for supergroups that meet Telegram’s threshold. The statistics DC must be reachable.


Broadcast channel statistics

#![allow(unused)]
fn main() {
let stats = client.stats("@mychannel").await?;
// dark=true requests dark-themed graph images
}

Returns tl::enums::stats::BroadcastStats. Key fields:

#![allow(unused)]
fn main() {
let tl::enums::stats::BroadcastStats::BroadcastStats(s) = stats;

// Follower count range
println!("Followers: {} (prev {})", s.followers.current, s.followers.previous);

// Views per post
println!("Views/post: {} (prev {})", s.views_per_post.current, s.views_per_post.previous);

// Shares per post
println!("Shares/post: {} (prev {})", s.shares_per_post.current, s.shares_per_post.previous);
}

BroadcastStats also contains graph references (s.growth_graph, s.followers_graph, s.top_hours_graph, s.interactions_graph, s.iv_interactions_graph, s.views_by_source_graph, s.new_followers_by_source_graph, s.languages_graph) that can be fetched via stats.loadAsyncGraph if they carry an async token.


Supergroup (megagroup) statistics

#![allow(unused)]
fn main() {
let stats = client.stats("@mysupergroup").await?;
}

Returns tl::enums::stats::MegagroupStats. Key fields:

#![allow(unused)]
fn main() {
let tl::enums::stats::MegagroupStats::MegagroupStats(s) = stats;

println!("Members: {} (prev {})", s.members.current, s.members.previous);
println!("Messages: {} (prev {})", s.messages.current, s.messages.previous);
println!("Viewers: {} (prev {})", s.viewers.current, s.viewers.previous);
println!("Posters: {} (prev {})", s.posters.current, s.posters.previous);
}

MegagroupStats also exposes s.top_posters, s.top_admins, s.top_inviters (lists of users with message/invite counts) and graph references for member growth, languages, messages by hour/weekday.


Online member count

Get the current approximate number of online members in a group or channel:

#![allow(unused)]
fn main() {
let online = client.get_online_count("@mychannel").await?;
println!("{online} members are online right now");
}

Poll statistics

#![allow(unused)]
fn main() {
let stats = client.poll_results(peer.clone(), msg_id).await?;
}

Returns tl::types::stats::PollStats. Contains a votes_graph field with a StatsGraph showing vote distribution over time. Use stats.loadAsyncGraph if the graph carries an async token.

#![allow(unused)]
fn main() {
let tl::enums::stats::PollStats::PollStats(s) = stats;
// s.votes_graph: tl::enums::StatsGraph
}

Only available for polls in channels that meet Telegram’s stats threshold.

Privacy & Notifications

Methods for reading and writing Telegram account privacy rules and per-chat notification settings.


Privacy rules

Privacy rules control who can see your phone number, last seen, profile photo, etc.

async client.get_privacy(key: tl::enums::InputPrivacyKey) → Result<Vec<tl::enums::PrivacyRule>, InvocationError>
Fetch the current privacy rules for a specific setting identified by key.

Privacy keys

VariantControls
InputPrivacyKey::StatusTimestampLast seen / online status
InputPrivacyKey::ChatInviteWho can add you to groups
InputPrivacyKey::CallWho can call you
InputPrivacyKey::ProfilePhotoWho sees your profile photo
InputPrivacyKey::PhoneNumberWho sees your phone number
InputPrivacyKey::ForwardedMessagesWho can link forwards to your account
InputPrivacyKey::PhoneCallWho can voice-call you
InputPrivacyKey::PhoneP2PPeer-to-peer call mode
InputPrivacyKey::VoicesVoice messages
InputPrivacyKey::AboutWho sees your bio
#![allow(unused)]
fn main() {
let rules = client.get_privacy(
    tl::enums::InputPrivacyKey::StatusTimestamp
).await?;

for rule in &rules {
    println!("{rule:?}");
}
}
async client.set_privacy(key: tl::enums::InputPrivacyKey, rules: Vec<tl::enums::InputPrivacyRule>) → Result<Vec<tl::enums::PrivacyRule>, InvocationError>
Update the privacy rules for a key. Rules are evaluated in order - the first matching rule wins.

Rule variants

VariantMeaning
InputPrivacyValueAllowAllAllow everyone
InputPrivacyValueAllowContactsAllow contacts only
InputPrivacyValueAllowUsers { users }Allow specific users
InputPrivacyValueDisallowAllBlock everyone
InputPrivacyValueDisallowContactsBlock contacts
InputPrivacyValueDisallowUsers { users }Block specific users
#![allow(unused)]
fn main() {
use tl::enums::{InputPrivacyKey, InputPrivacyRule};

// Last seen: contacts only
client.set_privacy(
    InputPrivacyKey::StatusTimestamp,
    vec![
        InputPrivacyRule::InputPrivacyValueAllowContacts,
        InputPrivacyRule::InputPrivacyValueDisallowAll,
    ],
).await?;

// Phone: nobody
client.set_privacy(
    InputPrivacyKey::PhoneNumber,
    vec![InputPrivacyRule::InputPrivacyValueDisallowAll],
).await?;

// Profile photo: everyone
client.set_privacy(
    InputPrivacyKey::ProfilePhoto,
    vec![InputPrivacyRule::InputPrivacyValueAllowAll],
).await?;
}

Notification settings

async client.get_notify_settings(peer: impl Into<PeerRef>) → Result<tl::enums::PeerNotifySettings, InvocationError>
Get the notification settings for a specific chat.
#![allow(unused)]
fn main() {
let settings = client.get_notify_settings(peer.clone()).await?;
if let tl::enums::PeerNotifySettings::PeerNotifySettings(s) = settings {
    println!("muted until: {:?}", s.mute_until);
    println!("sound: {:?}", s.sound);
    println!("show_previews: {:?}", s.show_previews);
}
}
async client.update_notify_settings(peer: impl Into<PeerRef>, settings: tl::enums::InputPeerNotifySettings) → Result<(), InvocationError>
Update notification settings for a chat. Only the fields you set are changed on the server; unset Option fields are left as-is.

InputPeerNotifySettings fields

FieldTypeDescription
mute_untilOption<i32>Unix timestamp to mute until. Some(i32::MAX) = forever. Some(0) = unmute.
soundOption<NotificationSound>Sound to play
show_previewsOption<bool>Show message preview in notification
silentOption<bool>Deliver silently without sound
#![allow(unused)]
fn main() {
use tl::enums::InputPeerNotifySettings;
use tl::types::InputPeerNotifySettings as S;

// Mute a chat for 1 hour
let until = (std::time::SystemTime::now()
    .duration_since(std::time::UNIX_EPOCH).unwrap()
    .as_secs() + 3600) as i32;

client.update_notify_settings(
    peer.clone(),
    tl::enums::InputPeerNotifySettings::InputPeerNotifySettings(S {
        show_previews: None,
        silent: None,
        mute_until: Some(until),
        sound: None,
        stories_muted: None,
        stories_hide_sender: None,
        stories_sound: None,
    }),
).await?;

// Unmute
client.update_notify_settings(
    peer.clone(),
    tl::enums::InputPeerNotifySettings::InputPeerNotifySettings(S {
        mute_until: Some(0),
        show_previews: None, silent: None, sound: None,
        stories_muted: None, stories_hide_sender: None, stories_sound: None,
    }),
).await?;
}

Common privacy recipes

#![allow(unused)]
fn main() {
// "Ghost mode"  -  hide everything from non-contacts
use tl::enums::{InputPrivacyKey as Key, InputPrivacyRule as Rule};

let ghost = vec![Rule::InputPrivacyValueAllowContacts, Rule::InputPrivacyValueDisallowAll];

client.set_privacy(Key::StatusTimestamp, ghost.clone()).await?;
client.set_privacy(Key::ProfilePhoto,    ghost.clone()).await?;
client.set_privacy(Key::PhoneNumber,     vec![Rule::InputPrivacyValueDisallowAll]).await?;
client.set_privacy(Key::ChatInvite,      ghost.clone()).await?;
}

Raw API Access

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

Basic usage

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

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

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

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

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

Examples

Get full user info

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

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

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

Send a message with all parameters

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

Edit admin rights (Layer 223)

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

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

Set bot commands

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

New in Layer 223: edit chat creator

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

New in Layer 223: URL auth match code

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

Access hashes

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

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

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

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

Error patterns

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

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

Peer cache helpers

Two methods let you seed or refresh the internal peer cache without waiting for an update to arrive:

warm_peer_cache_from_dialogs

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

Issues a single GetDialogs call and caches all returned channel and user access hashes. Call this at startup if your code needs to address channels by ID before any update has come in for them. Without this, a CHANNEL_INVALID error can appear on the first request to a channel the bot hasn’t interacted with yet in the current session.

This is opt-in; ferogram no longer calls GetDialogs automatically at startup. See the v0.3.5 release notes for why.

cache_users_slice_pub / cache_chats_slice_pub

#![allow(unused)]
fn main() {
client.cache_users_slice_pub(&users_vec).await;
client.cache_chats_slice_pub(&chats_vec).await;
}

Manually insert tl::enums::User or tl::enums::Chat slices into the access-hash cache. Useful when you’ve called a raw RPC that returned user or chat objects and want them to be addressable by subsequent high-level calls.


Update state

sync_update_state

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

Calls updates.getState and resets the internal pts/qts/seq/date counters to match the server. Called automatically on connect(). Call it manually after resuming from a long hibernation to avoid replaying a large backlog of missed updates.

sync_pts_state

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

The fallible version of sync_update_state. Returns an error if the RPC itself fails. sync_update_state silently discards that error; use sync_pts_state directly when you need to handle or log a failure.

Retry & Flood Wait

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

Default behaviour: AutoSleep

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

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

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

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

NoRetries: propagate immediately

If you want to handle FLOOD_WAIT yourself:

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

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

Then in your code:

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

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

Custom retry policy

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

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

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

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

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

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

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

RetryContext fields

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

Avoiding flood waits

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

DC Migration

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

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

How it works

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

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

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

Overriding the initial DC

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

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

DC addresses:

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

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

DC pool (for media)

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

Proxies

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

SOCKS5

Without authentication

#![allow(unused)]
fn main() {
let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .socks5("127.0.0.1:1080")
    .connect()
    .await?;
}

Tor

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

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

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


MTProxy

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

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

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

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

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

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

Secret format and transport mapping

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

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

Building MtProxyConfig manually

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

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

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

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

Transport Probing & Resilient Connect

Two independent options that improve connectivity in restricted networks. They can be used separately or together.


probe_transport

Races three MTProto transports in parallel at connect time and keeps whichever completes the DH handshake first. The remaining attempts are cancelled immediately so no extra bandwidth is used after a winner is picked.

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

Race schedule

TransportStart delayNotes
Obfuscated0 msRuns first. Best for DPI-heavy networks.
Abridged200 msStaggered so Obfuscated has a head start.
Http800 msLast resort. SOCKS5 is not used for HTTP probes.

The winner’s connection is reused directly. No second DH exchange is performed, so there is no extra round trip cost.

If all three transports fail, the connection attempt returns the last InvocationError from the race.

When to use

Use probe_transport when:

  • You are deploying to a network you do not control (shared hosting, cloud functions, university networks).
  • Your region has inconsistent DPI filtering where some transports are blocked but others work.
  • You want automatic transport selection without hardcoding TransportKind.

Incompatibility with MTProxy

probe_transport is incompatible with MTProxy. An MTProxy enforces its own transport (set by the secret prefix), so probing makes no sense. If you set both, probe_transport is silently skipped and the MTProxy transport is used as normal.

#![allow(unused)]
fn main() {
// Wrong: probe_transport has no effect when mtproxy is set
.mtproxy(proxy)
.probe_transport(true)   // ignored

// Correct: use one or the other
.probe_transport(true)   // for direct connections
}

resilient_connect

If the initial direct TCP connect fails, resilient_connect tries two additional fallback paths before giving up. Normal operation is unaffected when the direct connect succeeds; the fallbacks only activate on failure.

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

Fallback chain

Direct TCP
    |
    v (fails)
DNS-over-HTTPS  (Mozilla DoH + Google DoH)
    resolves venus.web.telegram.org -> IP list
    tries each IP on the DC port
    |
    v (all fail)
Firebase / Google special-config
    fetches Telegram's Firebase-hosted DC address list
    tries each matching DC option
    |
    v (all fail)
Returns the original direct-connect error

Step 1: DNS-over-HTTPS. Queries venus.web.telegram.org via Mozilla and Google DoH resolvers. Each resolved IP is tried on the same port as the default DC address. This bypasses ISP-level DNS poisoning.

Step 2: Firebase / Google special-config. Fetches the alternate DC address list that Telegram publishes to Firebase. This is the same endpoint the official Telegram apps fall back to when all normal connections fail. Only addresses matching the target DC ID are tried.

If every path fails, the original direct-connect error is returned.

When to use

Use resilient_connect when:

  • You are deploying in a region where Telegram DCs are ISP-blocked (Iran, Russia, some corporate networks).
  • You want your bot to self-recover from transient DNS outages without manual restarts.
  • You are running behind a network that intercepts DNS but allows HTTPS.

Combining both options

probe_transport and resilient_connect are orthogonal and can be enabled together. probe_transport changes which transport framing is used for a successful direct connect. resilient_connect adds fallback paths when direct connect fails entirely.

#![allow(unused)]
fn main() {
let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .probe_transport(true)      // pick best transport when direct works
    .resilient_connect(true)    // fall back via DoH + Firebase when it doesn't
    .connect()
    .await?;
}

probe_transport wins if direct TCP succeeds on any transport. Only if every direct attempt fails does resilient_connect activate its fallback chain, which also benefits from whatever transport was last used by the probe.

Connection Restart Policy

ConnectionRestartPolicy controls whether ferogram automatically re-establishes a dropped connection and how long it waits between attempts.

The policy is set via .restart_policy() on the builder and defaults to NeverRestart.


Built-in policies

NeverRestart (default)

Does not restart. When the underlying TCP connection drops, the event loop exits and the shutdown signal fires. Your code is responsible for reconnecting or exiting.

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

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .restart_policy(Arc::new(NeverRestart))   // this is already the default
    .connect()
    .await?;
}

Use NeverRestart when you manage the process lifecycle externally (systemd, supervisord, Docker restart policies) or when you want the process to crash loudly on a disconnect rather than silently loop.


FixedInterval

Restarts the connection after a fixed duration following a drop. The interval is measured from the moment the drop is detected to the next connect attempt.

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

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .restart_policy(Arc::new(FixedInterval {
        interval: Duration::from_secs(5),
    }))
    .connect()
    .await?;
}

Common values:

IntervalUse case
Duration::from_secs(1)Low-latency bots where a 1-second gap is acceptable
Duration::from_secs(5)General-purpose bots, reasonable default
Duration::from_secs(30)Rate-limited or metered connections

Custom policy

Implement ConnectionRestartPolicy to add exponential backoff, jitter, or circuit-breaker logic.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::time::Duration;
use std::sync::atomic::{AtomicU32, Ordering};
use ferogram::ConnectionRestartPolicy;

struct ExponentialBackoff {
    attempt: AtomicU32,
    base_ms: u64,
    max_ms: u64,
}

impl ExponentialBackoff {
    fn new(base_ms: u64, max_ms: u64) -> Self {
        Self {
            attempt: AtomicU32::new(0),
            base_ms,
            max_ms,
        }
    }
}

impl ConnectionRestartPolicy for ExponentialBackoff {
    fn restart_interval(&self) -> Option<Duration> {
        let n = self.attempt.fetch_add(1, Ordering::Relaxed);
        let ms = (self.base_ms * (1u64 << n.min(10))).min(self.max_ms);
        Some(Duration::from_millis(ms))
    }
}

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .restart_policy(Arc::new(ExponentialBackoff::new(500, 60_000)))
    .connect()
    .await?;
}

Trait definition

#![allow(unused)]
fn main() {
pub trait ConnectionRestartPolicy: Send + Sync + 'static {
    /// Return `Some(duration)` to restart after that delay, or `None` to not restart.
    fn restart_interval(&self) -> Option<Duration>;
}
}

restart_interval is called each time a connection drop is detected. Return None at any point to stop restarting (e.g. after N attempts).


Scheduled periodic restarts

FixedInterval can be used to schedule periodic connection refreshes even when the connection has not dropped, by setting the interval to a wall-clock cycle. This is useful for very long-lived processes where you want to rotate the TCP session once a day to avoid silent stale connections.

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

// Restart the connection every 12 hours
.restart_policy(Arc::new(FixedInterval {
    interval: Duration::from_secs(12 * 60 * 60),
}))
}

Note: a scheduled restart drops the TCP connection cleanly and reconnects. Any in-flight RPC calls at that moment will return an error and need to be retried by your application. Pair this with .catch_up(true) to replay any updates missed during the restart window.

#![allow(unused)]
fn main() {
.catch_up(true)
.restart_policy(Arc::new(FixedInterval {
    interval: Duration::from_secs(12 * 60 * 60),
}))
}

Experimental Features

ExperimentalFeatures is a struct that holds opt-in flags for behaviours that deviate from the strict Telegram MTProto spec. Every flag defaults to false. Enable only what you need after reading the warnings below.

Pass the struct to .experimental_features() on the builder:

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

let (client, _shutdown) = Client::builder()
    .api_id(12345)
    .api_hash("your_hash")
    .session("bot.session")
    .experimental_features(ExperimentalFeatures {
        allow_zero_hash: true,
        ..Default::default()
    })
    .connect()
    .await?;
}

Flags

allow_zero_hash

Default: false
Safe for: bot accounts only

When no access_hash is cached for a user or channel, fall back to access_hash = 0 instead of returning InvocationError::PeerNotCached.

The Telegram spec explicitly permits hash = 0 for bot accounts when only a min-hash is available. Bot tokens receive this entitlement from the server automatically. On user accounts, sending hash = 0 produces USER_ID_INVALID or CHANNEL_INVALID.

#![allow(unused)]
fn main() {
// Bot receiving a message from a user it has never seen before.
// Without this flag, calling client.send_message(user_id, ...) would
// fail with PeerNotCached because no access_hash is in the cache yet.
// With allow_zero_hash the request goes out with hash=0 and succeeds.
ExperimentalFeatures {
    allow_zero_hash: true,
    ..Default::default()
}
}

When to use: Enable this if your bot handles updateShortMessage, updateShortChatMessage, or other compact update types that carry only a user_id / chat_id without an access_hash. These updates arrive before the bot has a chance to cache the peer’s full info.

Do not enable on user (non-bot) accounts. The server will reject the request.


allow_missing_channel_hash

Default: false
Safe for: debugging and testing only

When resolving a min-user via InputPeerUserFromMessage, if the containing channel’s access_hash is not in the cache, proceed with channel access_hash = 0 rather than returning InvocationError::PeerNotCached.

In practice this is almost always wrong. The inner InputPeerChannel { access_hash: 0 } makes the entire InputPeerUserFromMessage invalid and Telegram will reject it with CHANNEL_INVALID. The flag exists solely for debugging peer resolution without triggering the cache-miss guard.

#![allow(unused)]
fn main() {
ExperimentalFeatures {
    allow_missing_channel_hash: true,  // debugging only
    ..Default::default()
}
}

Do not enable in production.


auto_resolve_peers

Default: false
Safe for: bot accounts only

When getChannelDifference runs and no access_hash is cached for the target channel, this flag controls what happens next.

false (default): the diff is deferred. The entry stays alive in the update state machine with its pts preserved and its deadline reset. The diff retries automatically once the hash arrives via a future update’s entity list. No RPC is fired. At most one diff window is missed.

true: ferogram immediately calls channels.getChannels with access_hash = 0 to fetch the hash, caches the result, and retries the diff in the same loop iteration. If the RPC fails or the channel is private, the diff falls back to the deferred path rather than dropping the entry.

This flag only affects getChannelDifference. It does not change how InputPeer resolution works for outgoing API calls.

Bot accounts only. On user accounts, channels.getChannels { access_hash: 0 } succeeds only for public channels and channels the account is currently a member of. For private channels it returns CHANNEL_PRIVATE and the diff is deferred regardless.

#![allow(unused)]
fn main() {
// Bot that needs zero missed updates for channels it joins dynamically.
ExperimentalFeatures {
    auto_resolve_peers: true,
    ..Default::default()
}
}

Burst behaviour: ferogram tracks peer cache misses in a rolling 30-second window. If 10 or more misses occur within that window, a background task calls warm_peer_cache_from_dialogs to bulk-populate the cache from messages.getDialogs. A 15-minute cooldown prevents repeated bulk calls. This escalation runs regardless of whether auto_resolve_peers is set.


Combining flags

All flags are independent. Use ..Default::default() to leave the rest at false:

#![allow(unused)]
fn main() {
ExperimentalFeatures {
    allow_zero_hash: true,
    allow_missing_channel_hash: false,  // explicit, or just use ..Default::default()
    auto_resolve_peers: false,
    ..Default::default()  // forward-compatible: new flags stay false
}
}

Always use ..Default::default() so that new flags added in future versions default to false without requiring changes to your code.


Relationship to PeerNotCached

Without any experimental flags, a cache miss on an access_hash returns:

InvocationError::PeerNotCached { peer_id: 123456789 }

The correct fix in most cases is to ensure the peer appears in an update or API response before you try to address it. For bots, enabling allow_zero_hash is the idiomatic workaround for compact update types.

#![allow(unused)]
fn main() {
// Typical bot pattern: handle updateShortMessage
// user_id is known, but no access_hash yet
match client.send_message(user_id, "hello").await {
    Err(InvocationError::PeerNotCached { .. }) => {
        // cache not warm yet, would not happen with allow_zero_hash
    }
    Ok(_) => {}
    Err(e) => return Err(e),
}
}

Upgrading the TL Layer

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

How the system works

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

tl/api.tl          (source of truth: the only file you replace)
    │
    ▼
build.rs           (reads api.tl, invokes ferogram-tl-gen)
    │
    ▼
$OUT_DIR/
  generated_common.rs     ← pub const LAYER: i32 = 225;
  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-225.tl ferogram-tl-types/tl/api.tl

Make sure the first line of the file is:

// LAYER 225

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 = 225; into generated_common.rs

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

Step 3: Fix compile errors

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

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

Fix them by adding the field with a sensible default:

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

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

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

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

New enum variants in match statements:

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

Step 4: Bump version and publish

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

Then publish in dependency order (see Publishing).

What propagates automatically

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

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

Diff the changes

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

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

CDN Downloads

Large files on Telegram are often served from CDN DCs - lightweight edge data-centres that don’t participate in the normal MTProto auth flow. ferogram exposes the full CDN download path via CdnDownloader in ferogram::cdn_download.

In normal usage you never need to interact with CdnDownloader directly - client.download, client.download_file, and iter_download handle CDN redirects transparently. This page is for advanced use-cases where you need explicit control.


How CDN redirects work

  1. You call upload.getFile on the home DC.
  2. If the file lives on a CDN DC, the server returns upload.fileCdnRedirect containing:
    • dc_id - which CDN DC to talk to
    • file_token - opaque credential for upload.getCdnFile
    • encryption_key (32 bytes AES-256-CTR key)
    • encryption_iv (16 bytes)
  3. Connect to the CDN DC with CdnDownloader::connect.
  4. Call download_chunk_raw, download_all, or download_all_with_reupload.
  5. CDN DCs use AES-256-CTR (not AES-IGE). CdnDownloader decrypts transparently.

Constants

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

// 131072 bytes  -  CDN DCs require exactly 128 KB fixed-size parts
// so the offset → hash mapping in upload.getCdnFileHashes stays consistent.
assert_eq!(CDN_CHUNK_SIZE, 128 * 1024);
}

CdnDownloader

Construction

async CdnDownloader::connect(cdn_dc_addr: &str, cdn_dc_id: i16, file_token: Vec<u8>, encryption_key: [u8; 32], encryption_iv: [u8; 16], socks5: Option<&Socks5Config>) → Result<Self, InvocationError>
Open a fresh connection to the CDN DC at cdn_dc_addr (format: "ip:port") and return a ready downloader. Uses obfuscated transport. Pass the proxy config from your Client setup if one is active.
sync CdnDownloader::new(conn: DcConnection, file_token: Vec<u8>, encryption_key: [u8; 32], encryption_iv: [u8; 16]) → Self
Wrap an already-open DcConnection. Useful if you manage connection pooling yourself.

Downloading

async cdn.download_chunk_raw(byte_offset: i64, limit: i32) → Result<CdnChunkResult, InvocationError>
Download and AES-CTR-decrypt a single chunk at byte_offset with limit bytes. Use CDN_CHUNK_SIZE as the limit.

Returns one of:

  • CdnChunkResult::Data(Vec<u8>) - decrypted bytes.
  • CdnChunkResult::ReuploadNeeded(Vec<u8>) - server wants the file reuploaded to it first. Contains the request_token; call upload.reuploadCdnFile on the main DC then retry.
async cdn.download_all(total_size: Option<i64>) → Result<Vec<u8>, InvocationError>
Download and reassemble the full file. If ReuploadNeeded is encountered, returns an error - use download_all_with_reupload instead if the file might need reuploading.

Pass total_size = Some(n) for pre-allocation; None is fine too.

async cdn.download_all_with_reupload<F, Fut>(total_size: Option<i64>, reupload_fn: F) → Result<Vec<u8>, InvocationError>
Like download_all but handles ReuploadNeeded automatically. The reupload_fn closure receives the request_token bytes and must call upload.reuploadCdnFile on the main DC, then return Ok(()). The downloader retries the chunk after the reupload completes.
#![allow(unused)]
fn main() {
let bytes = cdn.download_all_with_reupload(
    Some(file_size),
    |request_token| async move {
        let body = serialize_reupload_cdn_file(&file_token, &request_token);
        main_dc_conn.rpc_call_raw(&body).await?;
        Ok(())
    },
).await?;
}

Low-level TL helpers

These are public for callers who need to build raw requests manually:

#![allow(unused)]
fn main() {
use ferogram::cdn_download::{serialize_get_cdn_file, serialize_reupload_cdn_file};

// Build an upload.getCdnFile#395f69da payload
let req_bytes = serialize_get_cdn_file(&file_token, byte_offset, CDN_CHUNK_SIZE);

// Build an upload.reuploadCdnFile payload
let reup_bytes = serialize_reupload_cdn_file(&file_token, &request_token);
}

Full example

#![allow(unused)]
fn main() {
use ferogram::cdn_download::{CdnDownloader, CDN_CHUNK_SIZE};

// 1. Detect the CDN redirect from an upload.getFile call (raw API)
// ...

// 2. Connect to the CDN DC
let mut cdn = CdnDownloader::connect(
    "149.154.167.222:443",  // CDN DC address from the redirect
    5,                       // CDN DC ID
    file_token,
    encryption_key,
    encryption_iv,
    None,  // no SOCKS5
).await?;

// 3. Download everything
let bytes = cdn.download_all(Some(total_size)).await?;

println!("Downloaded {} bytes", bytes.len());
}

Configuration

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

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

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

Builder methods

api_id / api_hash

Required. Get these from my.telegram.org.

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

session

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

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

session_string

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

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

in_memory

Non-persistent session. Useful for tests.

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

session_backend

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

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

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

transport

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

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

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

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

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

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

socks5

Route connections through a SOCKS5 proxy.

#![allow(unused)]
fn main() {
// no auth
.socks5("127.0.0.1:1080")
}

mtproxy

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

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

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

See Proxies & Transports for full details.

dc_addr

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

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

catch_up

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

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

allow_ipv6

Allow IPv6 DC addresses. Default: false.

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

retry_policy

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

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

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

restart_policy

What to do when the TCP connection drops. Default: NeverRestart (exit the event loop; the shutdown signal fires).

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

.restart_policy(Arc::new(FixedInterval {
    interval: Duration::from_secs(5),
}))
}

See Connection Restart Policy for all built-in types and custom implementations.

probe_transport

Race Obfuscated, Abridged, and HTTP transports in parallel on connect and keep the fastest. Incompatible with MTProxy. Default: false.

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

resilient_connect

If direct TCP fails, retry via DNS-over-HTTPS then Firebase special-config. Useful in ISP-blocked regions. Default: false.

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

See Transport Probing & Resilient Connect for the full fallback chain.

experimental_features

Opt-in flags that deviate from strict Telegram spec. All default to false.

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

.experimental_features(ExperimentalFeatures {
    allow_zero_hash: true,   // bots only
    ..Default::default()
})
}

See Experimental Features for all flags and safety constraints.


Building Config without connecting

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

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

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

Error Types


InvocationError

Every Client method returns Result<T, InvocationError>.

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

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

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

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

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

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

InvocationError variants

VariantDescription
InvocationError::Rpc(RpcError)Telegram returned a TL error
InvocationError::Io(io::Error)Network or socket error
InvocationError::Deserialize(e)Failed to decode server response
InvocationError::DroppedRequest dropped (sender task shut down)
InvocationError::PeerNotCached(String)Peer seen before but its access_hash was never stored; resolve via client.resolve() first

InvocationError methods

MethodReturnDescription
.is("PATTERN")booltrue if this is an Rpc error whose name matches PATTERN; supports * prefix/suffix wildcards
.flood_wait_seconds()Option<u64>Seconds to wait for FLOOD_WAIT_X errors

RpcError

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

e.code               // i32: HTTP-like error code (400, 401, 403, 420, etc.)
e.name               // String: SCREAMING_SNAKE_CASE with digits removed, e.g. "FLOOD_WAIT"
e.value              // Option<u32>: numeric suffix, e.g. Some(30) for FLOOD_WAIT_30
e.is("FLOOD_WAIT")   // bool: exact match; "FLOOD_*" / "*_INVALID" for prefix/suffix wildcards
e.flood_wait_seconds() // Option<u64>: seconds for FLOOD_WAIT_N errors
}

Common error codes

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

Common error messages

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

SignInError

Returned by client.sign_in():

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

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

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

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

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

PasswordToken methods

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

BuilderError

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

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

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

FLOOD_WAIT auto-retry

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

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

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

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

Crate Architecture

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

Dependency graph

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

ferogram

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

What it provides

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

ferogram-tl-types

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

What it provides

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

Key type conventions

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

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


ferogram-mtproto

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

What it provides

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

DH handshake steps

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

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


ferogram-crypto

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

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

ferogram-tl-parser

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

Parsed AST types

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

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


ferogram-tl-gen

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

Output files (written to $OUT_DIR)

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

Each type automatically gets:

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

ferogram-session

Session persistence types and pluggable storage backends.

What it provides

  • PersistedSession: versioned binary format holding the full DC table, update state, and peer cache
  • SessionBackend trait: implement to add custom storage (Redis, Postgres, etc.)
  • BinaryFileBackend: stores session as a binary file on disk (default)
  • InMemoryBackend: in-memory only, useful for testing or short-lived bots
  • StringSessionBackend: base64 string, useful for environment-variable sessions
  • SqliteBackend (feature: sqlite-session): SQLite-backed persistent sessions
  • LibSqlBackend (feature: libsql-session): libSQL-backed persistent sessions
  • DcEntry / DcFlags: per-DC auth key, salt, and capability flag storage
  • UpdatesStateSnap: pts, qts, seq, date, and per-channel pts counters
  • CachedPeer / CachedMinPeer: peer access-hash cache for users, channels, groups

Feature flags

FlagWhat it enables
sqlite-sessionSqliteBackend via rusqlite
libsql-sessionLibSqlBackend via libsql
serdeSerialize/Deserialize on session types

Stack position

ferogram
└ ferogram-session

ferogram-parsers

Telegram HTML and Markdown entity parsers.

What it provides

  • parse_markdown(src)(String, Vec<MessageEntity>): Telegram-flavoured Markdown to plain text + entity list
  • generate_markdown(text, entities)String: entity list back to Markdown
  • parse_html(src)(String, Vec<MessageEntity>): Telegram HTML to plain text + entity list
  • generate_html(text, entities)String: entity list back to HTML

Used by ferogram for InputMessage::markdown() and InputMessage::html(), and available standalone for any crate that works with Telegram formatted text.

Supported Markdown syntax

SyntaxEntity
**bold** or *bold*Bold
__italic__ or _italic_Italic
~~strike~~Strikethrough
||spoiler||Spoiler
`code`Code
```lang\npre\n```Pre (code block)
[text](url)TextUrl
[text](tg://user?id=123)MentionName
![text](tg://emoji?id=123)CustomEmoji

Supported HTML tags

<b>, <strong>, <i>, <em>, <u>, <s>, <del>, <code>, <pre>, <tg-spoiler>, <a href="url">, <tg-emoji emoji-id="id">

Feature flags

FlagWhat it enables
html5everReplaces parse_html with a spec-compliant html5ever tokenizer

Stack position

ferogram
└ ferogram-parsers
  └ ferogram-tl-types (tl-api feature)

Feature Flags

ferogram

FeatureDefaultDescription
sqlite-sessionSQLite-backed session storage via rusqlite
libsql-sessionlibsql / Turso session storage: local or remote (Added in v0.3.0)
htmlBuilt-in hand-rolled HTML parser (parse_html, generate_html)
html5everSpec-compliant html5ever tokenizer: overrides the built-in html parser
serdeserde::Serialize / Deserialize on Config and public structs
derive#[derive(FsmState)] proc-macro for FSM state enums
parserRe-exports ferogram-tl-parser for custom TL schema tooling
codegenRe-exports ferogram-tl-gen for custom code generation tooling

The following are always available without any feature flag:

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

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

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

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

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

ferogram-tl-types

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

Example: enable serde

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

Example: name_for_id (debugging)

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

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

Example: minimal (no Debug, no conversions)

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

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


String session: no feature flag needed

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

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

docs.rs build matrix

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

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