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.
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.
Installation
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
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.
Quick start
Bot
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
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
import ferogram.logging as fero_log fero_log.setup() # INFO to stderr fero_log.setup(level=10) # DEBUG
Client setup
Three ways to run it:
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
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.
Session backends
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().
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.
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
token = await client.export_login_token()
# render token as a tg://login?token=... QR code, then poll:
name = await client.check_qr_login(token)
Session and device management: get_authorizations() lists active sessions, terminate_session(hash) revokes one.
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.
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)
Filters
from ferogram import filters - compose them freely, they're just predicates.
Message filters
| Filter | Matches |
|---|---|
| filters.all_updates | every update |
| filters.private | private chat messages |
| filters.group | group messages |
| filters.channel | channel posts |
| filters.text | messages with text |
| filters.photo / document / media | messages carrying that kind of attachment |
| filters.outgoing / incoming | sent by you / not sent by you |
| filters.mentioned | messages mentioning you |
| filters.forwarded | forwarded messages |
| filters.via_bot | sent via an inline bot |
| filters.reply / pinned / album / scheduled | replies, pinned messages, grouped albums, scheduled sends |
| filters.bot / no_bot | sender is / isn't a bot |
Text, peer, callback & status helpers
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
filters.AND(filters.private, filters.text) filters.OR(filters.photo, filters.document) filters.NOT(filters.outgoing)
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:
| Method | Does |
|---|---|
| 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 |
Keyboards
Inline keyboards
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
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.
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.
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:
await client.send_rich_message(peer, """   """, mode="markdown")
| Function | Does |
|---|---|
| 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.
Media & transfers
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:
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:
| Field | Default | Controls |
|---|---|---|
| download_tcp_connections / upload_tcp_connections | 4 | Parallel worker connections one transfer may open for itself. Small files always use 1; larger files scale up towards this. |
| max_tcp_connections | 12 | Total 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_depth | 4 | Chunk requests a single connection keeps in flight at once instead of waiting for each response. |
| bypass_tcp_allotments | False | Skip 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)
Polls
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
Inline bots
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.
Chats & participants
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)
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)
Invite links & join requests
await client.invite_links(peer) async for link in client.iter_invite_links(peer, revoked=False): ... async for member in client.iter_invite_link_members(peer, link): ... await client.edit_invite_link(peer, link, expire_date=None, usage_limit=None) await client.revoke_invite_link(peer, link) / delete_invite_link(peer, link) await client.resolve_invite_link(link) / join_invite_link(link) await client.join_request(peer, user_id, approve=True) await client.all_join_requests(peer, approve=True)
Account & contacts
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.
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()
Notifications & privacy
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.
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="")
Stats, payments & custom emoji
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=[...])
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.
Raw API
Four styles of the same thing - pick whichever reads best in context:
await client.raw.messages.SendMessage(peer="@user", message="Hello", no_webpage=True) await client.raw.channels.GetFullChannel(channel="@telegram")
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, ))
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.
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.
- 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
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.
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.
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.
Community & links
- Examples: github.com/ankit-chaubey/ferogram-py/examples - thirteen working files, from a one-file echo bot to admin tooling and rich messages
- PyPI: pypi.org/project/ferogram
- Source: github.com/ankit-chaubey/ferogram-py
- Rust core: docs.ferogram.dev
- Chat (questions & discussion): @FerogramChat
- Channel (releases & announcements): @Ferogram