ferogram-py · Python bindings

The Pythonic way to build on Ferogram.

A native Rust core handles networking, encryption, TL parsing, and session management underneath - you write clean, idiomatic Python on top. This page covers the full surface, from your first message to raw MTProto calls.

Start here

Why ferogram-py

Most of the heavy lifting in an MTProto client happens behind the scenes: networking, encryption, TL parsing, keeping up with updates. ferogram-py lets the same Rust core that powers ferogram handle all of that, while exposing a clean, Pythonic API on top.

For most applications, the high-level API is all you'll need. When you want a brand-new Telegram feature before it's wrapped, or need something more advanced, you can always drop down to the raw MTProto API and invoke requests directly - the same one the high-level methods are built on.

Architecture
The compiled extension (_ferogram.so) stays deliberately small: networking, encryption, session storage, and MTProto internals live in Rust. Everything you touch day to day - the client, handlers, filters, keyboards - is plain Python and can change without a recompile.
Start here

Installation

shell
pip install ferogram

Wheels ship prebuilt for Linux (x86_64, aarch64), macOS (x86_64, arm64), Windows (x86_64), and Android/Termux (aarch64, x86_64) - pip grabs the right one on its own, no Rust toolchain required. Requires Python 3.9+.

Building from source

shell
make dev      # editable install into .venv (builds the Rust extension)
make build    # release wheel for this machine
make codegen  # regenerate TL code without a Rust rebuild
make test     # run tests
make clean    # wipe .venv, target, dist, generated/

On Termux: pkg install rust clang python first. Changed only Rust code and don't want to wait on codegen: FEROGRAM_SKIP_CODEGEN=1 maturin develop.

Start here

Quick start

Bot

bot.py
from ferogram import Client, filters

app = Client("mybot", api_id=0, api_hash="", bot_token="123:TOKEN")

@app.on_message(filters.command("start"))
async def start(client, message):
    await message.reply("Hello!")

app.run()

User account

main.py
import asyncio
from ferogram import Client

app = Client("myaccount", api_id=0, api_hash="", phone="+1234567890")

async def main():
    async with app as client:
        await client.send_message("me", "logged in")

asyncio.run(main())

Credentials also work from environment variables: API_ID, API_HASH, BOT_TOKEN. Filename matters: don't name your script ferogram.py - it shadows the package and breaks the import (the library raises a clear ImportError telling you to rename it).

Logging

Python
import ferogram.logging as fero_log

fero_log.setup()           # INFO to stderr
fero_log.setup(level=10) # DEBUG
Start here

Client setup

Three ways to run it:

Python
app.run()                           # blocking, handles start/stop/signals
# or
await app.start()
await app.run_until_disconnected()
# or
async with app as client:
    ...                              # connects on enter, disconnects on exit

Full init signature

Python
app = Client(
    session="mybot",             # name, or a Session object - see below
    api_id=123456,
    api_hash="abc...",
    bot_token="123:TOKEN",       # omit for a user account
    phone="+1234567890",
    password="2fa_password",

    proxy=None,                    # SOCKS5 relay: "socks5://host:port"
    proxy_secret=None,              # AES-obfuscation secret (with transport=)
    proxy_link=None,                # tg://proxy?... - derives everything above
    domain=None,                    # SNI hostname, only for transport="faketls"
    transport=None,                 # full/abridged/intermediate/http/
                                     # obfuscated/padded_intermediate/fastest
    test_mode=False,
    dc_id=0,                       # 0 = auto (session's home DC, else DC2)
    allow_ipv6=False,
    dc_addr=None,
    resilient_connect=False,          # DNS-over-HTTPS + config fallback

    catch_up=False,                  # replay missed updates on reconnect
    pfs=False,                       # perfect forward secrecy
    future_auth_token=None,          # seed a fast re-login token

    device=None, system_version=None, app_version=None,
    lang_code=None, system_lang_code=None, lang_pack=None,

    session_string=None,
    update_queue_capacity=None,
    update_overflow="drop_oldest",
    parse_mode=None,                  # None, "html", or "markdown"
    workers=4,
    flood_sleep_threshold=60,
    transfer_limits=None,              # TransferLimits(...) - see Media below
)

Proxy & MTProxy

proxy is a plain SOCKS5 relay, unrelated to MTProxy. For Telegram's own MTProxy, pass a full tg://proxy?... link via proxy_link - it derives dc_addr, transport, and proxy_secret for you and wins over any of those passed alongside it. This mirrors ferogram's Rust ClientBuilder.proxy_link() and the same Classic/DD/FakeTLS transports documented on docs.ferogram.dev.

transport="fastest" races a couple of built-in transports on a fresh handshake only; a resumed connection with a saved key reuses whatever transport it was originally established on, ignoring this setting.

Start here

Session backends

Python
from ferogram import FileSession, MemorySession, StringSession, SqliteSession, CustomSession

Client(session=FileSession("mybot"), ...)
Client(session=SqliteSession("mybot"), ...)   # better crash resilience
Client(session=MemorySession(), ...)          # nothing persisted, gone on exit
Client(session=StringSession("base64_session"), ...)

CustomSession accepts any Python object with save, load, and delete methods, for your own storage backend (Redis, a database, a secrets manager). Session classes are implemented in the Rust extension, same as the core - they're not pure-Python shims.

Export a portable session string any time with await client.export_session_string().

Guide

Authentication

app.run() and async with app handle login interactively for you - prompting for phone, code, and 2FA password right in the terminal on first run. This section is for explicit control.

Python
if not await client.is_authorized():
    sent = await client.request_login_code(phone)
    try:
        await client.sign_in(phone, input("Code: "), sent.phone_code_hash)
    except SignInCodeInvalid as e:
        # e.expired: True = code timed out, call resend / request again
        #            False = wrong code, re-prompt with the same sent_code
        raise
    except PasswordRequired as pw_info:
        await client.check_password(pw_info, input("2FA password: "))

Bots: await client.bot_sign_in(token) (alias: login_bot).

QR code login

Python
token = await client.export_login_token()
# render token as a tg://login?token=... QR code, then poll:
name = await client.check_qr_login(token)
Fast re-login
sign_out() captures a future_auth_token when Telegram provides one; request_login_code() replays it automatically next login. When that succeeds, Telegram authorizes the session immediately with no code entry needed - check the returned dict for "_": "auth.sentCodeSuccess".

Session and device management: get_authorizations() lists active sessions, terminate_session(hash) revokes one.

Guide

Event handlers

Every handler is async def handler(client, update). All 17 event types:

@app.on_message@app.on_edited_message@app.on_message_deleted
@app.on_callback_query@app.on_inline_query@app.on_inline_send
@app.on_user_status@app.on_chat_action@app.on_participant_update
@app.on_join_request@app.on_message_reaction@app.on_poll_vote
@app.on_bot_stopped@app.on_shipping_query@app.on_pre_checkout_query
@app.on_chat_boost@app.on_raw_update

Every decorator takes (*filters, group=0). Within dispatch: lower group numbers run first; within a group, the first matching handler wins.

Python
from ferogram import StopPropagation, ContinuePropagation

@app.on_message(filters.text)
async def log_all(client, msg):
    print(msg.text)
    raise ContinuePropagation  # keep searching this group for another match

@app.on_message(filters.command("secret"))
async def secret(client, msg):
    await msg.reply("shh")
    raise StopPropagation      # stop all further processing for this update

Handlers can also be registered at runtime instead of with decorators:

app.add_handler("message", my_func, filters.text, group=0)
app.remove_handler("message", my_func, group=0)
Guide

Filters

from ferogram import filters - compose them freely, they're just predicates.

Message filters

FilterMatches
filters.all_updatesevery update
filters.privateprivate chat messages
filters.groupgroup messages
filters.channelchannel posts
filters.textmessages with text
filters.photo / document / mediamessages carrying that kind of attachment
filters.outgoing / incomingsent by you / not sent by you
filters.mentionedmessages mentioning you
filters.forwardedforwarded messages
filters.via_botsent via an inline bot
filters.reply / pinned / album / scheduledreplies, pinned messages, grouped albums, scheduled sends
filters.bot / no_botsender is / isn't a bot

Text, peer, callback & status helpers

Python
filters.command("start")              # prefix="/" by default
filters.regex(r"^/ping")
filters.text_contains("hello")
filters.startswith("/cmd") / filters.endswith("!")
filters.min_length(3) / filters.max_length(100)

filters.user(12345) / filters.chat(-1001234567890)

filters.data("btn1") / filters.data_regex(r"^page:") / filters.data_startswith("page:")
filters.inline() / filters.inline(r"^cat")

filters.online / filters.offline / filters.status("online")
filters.action("typing") / filters.typing
filters.reaction("👍", "❤️")
filters.participant_status("member", "admin")
filters.constructor(0x12345678) / filters.update_type("UpdateNewMessage")

Composition

Python
filters.AND(filters.private, filters.text)
filters.OR(filters.photo, filters.document)
filters.NOT(filters.outgoing)
Guide

The Message object

Fields: id, text, sender_id, peer_id, chat_id (derived from peer_id), date, edit_date, reply_to_msg_id, forward_from_id, media, entities, views, via_bot_id, grouped_id, out, mentioned, silent, pinned.

Messages that arrive through a dispatcher handler are bound to the client, so these convenience methods work directly on them:

MethodDoes
await msg.reply(text)Send a message quoting this one
await msg.respond(text)Send a new message to the same chat, without quoting
await msg.edit(new_text)Edit this message
await msg.delete()Delete this message
await msg.react(emoji)React to this message
await msg.pin()Pin this message
await msg.forward_to(peer)Forward this message elsewhere
await msg.reply_photo(path) / reply_document(path)Reply with media
await msg.get_sender() / get_chat() / get_reply_message()Resolve related objects lazily
Only bound messages
These methods only work on a Message received from a dispatcher handler. A Message you construct or fetch manually (e.g. via Message.from_tl) has no client attached, and calling one of them raises a RuntimeError telling you so.
Guide

Keyboards

Inline keyboards

Python
from ferogram import InlineButton, InlineKeyboard

kb = InlineKeyboard()
kb.add_row([
    InlineButton.callback("Click me", b"btn1"),
    InlineButton.url("Open site", "https://example.com"),
])
kb.add_row([InlineButton.switch_inline("Search here", "cats")])

await client.send_message(peer, "Pick one:", reply_markup=kb)

Constructors: callback, url, switch_inline, switch_elsewhere, copy_text, mini_app, mini_app_simple, game, buy.

Reply keyboards

Python
from ferogram import ReplyButton, ReplyKeyboard

kb = ReplyKeyboard(resize=True, single_use=True, placeholder="Choose one")
kb.add_row([ReplyButton.text("Option A"), ReplyButton.text("Option B")])
kb.add_row([ReplyButton.request_phone("Share phone")])

Constructors: text, request_phone, request_geo, request_poll, request_quiz.

Remove keyboard / force reply

from ferogram import RemoveKeyboard, ForceReply

await client.send_message(peer, "Done.", reply_markup=RemoveKeyboard())
await client.send_message(peer, "Reply to this:", reply_markup=ForceReply())

reply_markup is accepted by send_message and edit_inline_message.

Guide

Rich messages

A standout feature of the Python layer: a Telegraph-style content model - headings, tables, task lists, footnotes, block math, blockquotes, collapsible details, photo/video/audio blocks, collages, slideshows, even embedded maps - sent as a single native Telegram message, from either Markdown or HTML source.

Python
await client.send_rich_message(peer, """
# Ferogram Rich Message Demo

## Features
- **Bold**, _italic_, ~~strikethrough~~, `inline code`
- ||Spoiler text||

## Table
| Feature | HTML | Markdown |
|:--|:-:|:-:|
| Headings | yes | yes |
| Tables | yes | yes |

## Block math
$$E = mc^2$$

> A blockquote, spanning
> multiple lines
""", mode="markdown")

HTML mode supports the same content model with tags like <tg-spoiler>, <tg-math>, <details>, <table bordered striped>, and <tg-map lat="41.9" long="12.5" zoom="14"/>. Media blocks come from plain image/video/audio syntax:

Python
await client.send_rich_message(peer, """
![](https://telegram.org/example/photo.jpg "Photo caption")


![](https://telegram.org/example/photo.jpg)
![](https://telegram.org/example/video.mp4)

""", mode="markdown")
FunctionDoes
send_rich_message(peer, content, mode=)Send a full rich message. Also takes rtl, noautolink, upload_media, silent, noforwards, reply_to, reply_markup, schedule_date, send_as, effect
edit_rich_message(peer, message_id, content)Edit an existing rich message in place
send_rich_message_draft(peer, content, draft_id=)Stream a partial render while content is still generating, then finalize with a normal send
get_rich_message(peer, msg_id)Fetch a message's rendered block tree back

upload_media=True pre-uploads referenced HTTP/HTTPS media; leave it False for public URLs, since Telegram's servers fetch those directly.

Guide

Media & transfers

Python
await client.send_photo(peer, path, caption="")
await client.send_document(peer, path, caption="", mime_type=None)
await client.send_audio(peer, path, caption="")
await client.send_video(peer, path, caption="")
await client.send_voice(peer, path, caption="")
await client.send_sticker(peer, path)
await client.download_media(peer, msg_id, path)

send_file is an alias of send_document. All the send_* media methods take filesystem paths.

Progress, pause, resume, cancel

Mirrors ferogram's Rust TransferHandle exactly - create one, pass it to a transfer, and control it from any coroutine:

Python
from ferogram import TransferHandle, TransferCancelled

handle = TransferHandle()
asyncio.get_event_loop().call_later(10, handle.cancel)

try:
    await client.upload_with_progress(
        "big.mp4",
        on_progress=lambda d, t: print(f"{d}/{t}"),
        handle=handle,
    )
except TransferCancelled:
    print("cancelled")

# from elsewhere:
handle.pause()   # pauses after the current chunk
handle.resume()
print(handle.progress())
# {'done':.., 'total':.., 'percent':.., 'speed_bps':.., 'eta_secs':..,
#  'speed_human': '1.4 MB/s', 'bytes_human': '12.3 MB / 50.0 MB'}

download_with_progress works the same way, same on_progress / handle parameters.

Tuning concurrency

TransferLimits mirrors the Rust core's highway/trucks model exactly - same field names, same defaults:

FieldDefaultControls
download_tcp_connections / upload_tcp_connections4Parallel worker connections one transfer may open for itself. Small files always use 1; larger files scale up towards this.
max_tcp_connections12Total worker connections shared across every concurrent transfer on the client. Lower on memory- or socket-constrained devices (e.g. Termux on older Android hardware).
download_pipeline_depth / upload_pipeline_depth4Chunk requests a single connection keeps in flight at once instead of waiting for each response.
bypass_tcp_allotmentsFalseSkip size-based lookup tables entirely and always use the connection counts above, even for small files.
Client(..., transfer_limits=TransferLimits(
    download_tcp_connections=1, upload_tcp_connections=1,
    max_tcp_connections=12,
    download_pipeline_depth=16, upload_pipeline_depth=16,
))

Profile & chat photos

await client.edit_chat_photo(peer, path)
await client.delete_profile_photos()
await client.get_profile_photos(peer, limit=100)
await client.get_chat_photos(peer, limit=100)
Guide

Polls

Python
await client.send_poll(
    peer, question, answers=["A", "B", "C"],
    quiz=False, correct_index=None, multiple_choice=False,
    public_voters=False, shuffle_answers=False,
    close_period=None, close_date=None, solution=None,
)
await client.send_vote(peer, msg_id, options=[b"\x00"])
await client.get_poll_votes(peer, msg_id, limit=100)   # -> list[tuple[int, bytes]]
await client.get_poll_stats(peer, msg_id)                # alias: poll_results
Guide

Inline bots

Python
await client.answer_callback_query(query_id, text=None, alert=False)

await client.answer_inline_query_articles(
    query_id,
    articles=[("id1", "Title One", "Message text one")],
    cache_time=300,
)

from ferogram import InlineMessageId
await client.edit_inline_message(
    InlineMessageId(dc_id=2, id_bytes=b"..."), "updated text",
)

Result types: InlineArticle(id, title, message_text, ...), InlinePhoto(id, title, message_text, photo_url, ...), InlineDocument(id, title, message_text, document_url, mime_type, ...). edit_inline_message also accepts a plain (dc_id, id_bytes) tuple instead of InlineMessageId.

Reference

Chats & participants

Python
await client.create_group(title, users=[])
await client.create_channel(title, about="", broadcast=True, megagroup=False)
await client.delete_channel(peer) / delete_chat(chat_id) / delete_chat_history(peer)
await client.invite_users(peer, [user_id, ...])
await client.get_chat_administrators(peer) / get_online_count(peer) / get_chat_full(peer)
await client.join_chat(peer) / leave_chat(peer)
await client.archive_chat(peer) / unarchive_chat(peer)
await client.pin_dialog(peer) / unpin_dialog(peer) / delete_dialog(peer)

await client.get_participants(peer, limit=200)
await client.kick_participant(peer, user) / ban_participant(peer, user)
await client.ban_participant_until(peer, user, until_date)
await client.promote_participant(peer, user, rights=None) / demote_participant(peer, user)
Reference

Forum topics

await client.toggle_forum(peer, enabled=True)
await client.get_forum_topics(peer, limit=100)
await client.create_forum_topic(peer, title, icon_color=None)
await client.edit_forum_topic(peer, topic_id, title=None, closed=None)
await client.delete_forum_topic_history(peer, top_msg_id)
Reference

Account & contacts

Python
await client.get_me()
await client.get_users_by_id([user_id, ...]) / get_user_full(user_id)
async for dialog in client.iter_dialogs(): ...
async for msg in client.iter_messages(peer, limit=None): ...
await client.set_profile(first_name=None, last_name=None, about=None)
await client.set_username(username)
await client.set_online() / set_offline()

await client.get_contacts() / add_contact(user_id, first_name)
await client.delete_contacts([user_id, ...]) / get_common_chats(user_id)
await client.block_user(peer) / unblock_user(peer) / get_blocked_users()

User fields: id, first_name, last_name, username, phone, is_bot, is_verified, is_restricted, is_scam, is_fake, is_premium, access_hash, lang_code, full_name.

Reference

Search & drafts

await client.search_messages(peer, query, limit=100)
await client.search_global(query, limit=100) / search_peer(query, limit=100)

await client.save_draft(peer, text)
await client.clear_all_drafts() / sync_drafts()
Reference

Notifications & privacy

Python
await client.mute_chat(peer, mute_until) / unmute_chat(peer)
await client.get_notify_settings(peer)
await client.update_notify_settings(peer, mute_until=None, silent=None)

from ferogram import PrivacyKey, PrivacyRule
await client.get_privacy(PrivacyKey.STATUS_TIMESTAMP)
await client.set_privacy(PrivacyKey.PHONE_NUMBER, PrivacyRule.ALLOW_CONTACTS)

Privacy keys: STATUS_TIMESTAMP, CHAT_INVITE, CALL, FORWARDS, PROFILE_PHOTO, PHONE_NUMBER, VOICE_MESSAGES, BIO, BIRTHDAY. Rules: ALLOW_ALL, ALLOW_CONTACTS, DISALLOW_ALL, DISALLOW_CONTACTS.

Reference

Bot management

await client.set_bot_commands([("start", "Start the bot"), ("help", "Show help")])
await client.delete_bot_commands(lang_code="")
await client.set_bot_info(name=None, about=None, description=None)
await client.get_bot_info(lang_code="")
await client.open_mini_app(peer, app_type="main", app_value="")
Reference

Stats, payments & custom emoji

Python
await client.get_broadcast_stats(peer) / get_megagroup_stats(peer)
await client.get_game_high_scores(peer, msg_id, user_id)

await client.send_invoice(
    peer, title, description, payload, currency,
    prices=[("Label", 100)],
    need_shipping_address=False, is_flexible=False,
)

await client.get_custom_emoji_documents(document_ids=[...])
Reference

Peer resolution

await client.resolve_peer(peer)         # -> numeric peer identifier
await client.resolve_username(username)
await client.resolve(peer)
await client.warm_peer_cache_from_dialogs()

peer throughout the whole API accepts "@username", "me", or an integer-like peer identifier - the client resolves it internally wherever it's needed.

Reference

Raw API

Four styles of the same thing - pick whichever reads best in context:

1. Namespace proxy
await client.raw.messages.SendMessage(peer="@user", message="Hello", no_webpage=True)
await client.raw.channels.GetFullChannel(channel="@telegram")
2. functions import
from ferogram.raw import functions

await client.invoke(functions.messages.SendMessage(
    peer=await client.resolve_peer("@user"),
    message="Hello", random_id=0, no_webpage=True,
))
3 & 4. api import / direct class import
from ferogram.raw.api import functions
from ferogram.raw.generated.functions.messages import GetHistory, SendMessage

Every high-level method on Client is built on exactly this - when a brand-new Telegram feature lands before it's wrapped, this is how you reach it on day one.

Responsible use

Session security

A session - whether it's a FileSession on disk, a SqliteSession database, or a string from export_session_string() - grants full API access to the account it belongs to. Treat it like a password.

Never share your session
Not the .session file, not a StringSession value, not in a bug report or a public repo. Anyone who has it can act as the account, no password needed.
  • Add session files to .gitignore: *.session, *.session.db
  • Prefer SqliteSession over FileSession for long-running bots - better crash resilience if the process dies mid-write
  • Use MemorySession for throwaway scripts and tests that shouldn't leave a session on disk at all
  • Never log or print a session string, anywhere, even for debugging
  • Read api_id / api_hash / bot_token from environment variables (they already work that way by default) instead of hardcoding them
  • If a session is ever compromised, revoke it immediately: await client.terminate_session(hash), or from Telegram itself under Settings → Devices
Responsible use

Terms of service & your responsibility

ferogram-py gives you the same direct access to Telegram's API that the Rust core does - the raw API escape hatch reaches everything, not just what's wrapped. Usage must comply with Telegram's API Terms of Service.

The library sends the requests you tell it to send; it doesn't decide what's appropriate on your behalf. Whatever you build - a bot, a userbot, an automation script - you are responsible for its behavior: rate limits, spam prevention, user consent, data handling, and staying within Telegram's rules for automated and user-account activity alike. Neither the library nor its maintainer is responsible for how an application built with it is used.

User accounts (userbots) are held to Telegram's normal user-facing terms, not bot-specific allowances. Be deliberate about what a user-account automation does; abuse there tends to get accounts limited or banned.

Project

About the maintainer

ferogram-py is developed by Ankit Chaubey, alongside the Rust core (ferogram) and the calling stack (tgcalls). The Python layer is intentionally thin: the compiled extension handles networking, encryption, and session storage, while everything you interact with day to day stays plain, readable Python that can evolve without waiting on a Rust release.

Ankit Chaubey
Ankit Chaubey
Creator & maintainer - ferogram-py, Ferogram, TgCalls

Licensed under MIT OR Apache-2.0. You're free to use, modify, and distribute this software, including commercially, provided the license and copyright notice are included.

Project

Community & links